Add readonly door swing arc preview

Selecting a Bonsai-parametric IfcDoor now shows the swing arc(s)
without entering edit mode. A new viewport decorator polls on the
active object, reads the door's BBIM_Door pset, and draws the same
arcs the parametric door swing gizmo would draw — matching the
hinge / panel-width / x-mirror contract minus the is_editing gate.

A forward-compat test walks every door operation type and cross-
checks the readonly decorator's arc selection against the gizmo's
swing-arc config table, so future enum additions fail in both
surfaces simultaneously.

Also disables the inherited 8-pass dark halo on GizmoArc: an open
curve has no enclosed silhouette, so the offset passes read as
ghost arcs rather than a uniform outline. The arc's own cross-
section thickness keeps it legible without the halo.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-09 16:13:59 +02:00
committed by Thomas Krijnen
parent 4a6087699b
commit 2c3935bf9d
4 changed files with 322 additions and 2 deletions
+6
View File
@@ -49,6 +49,7 @@ from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import ( from bonsai.bim.module.model.decorator import (
BendPreviewDecorator, BendPreviewDecorator,
BoundingBoxDecorator, BoundingBoxDecorator,
DoorSwingReadonlyDecorator,
MEPSegmentExtendPreviewDecorator, MEPSegmentExtendPreviewDecorator,
SlabDirectionDecorator, SlabDirectionDecorator,
WallAxisDecorator, WallAxisDecorator,
@@ -516,6 +517,7 @@ def _install_viewport_overlays() -> None:
BendPreviewDecorator.uninstall() BendPreviewDecorator.uninstall()
MEPSegmentExtendPreviewDecorator.uninstall() MEPSegmentExtendPreviewDecorator.uninstall()
WallGizmoPreviewDecorator.uninstall() WallGizmoPreviewDecorator.uninstall()
DoorSwingReadonlyDecorator.uninstall()
ArrayPreviewDecorator.uninstall() ArrayPreviewDecorator.uninstall()
ArraySelectionHighlightDecorator.uninstall() ArraySelectionHighlightDecorator.uninstall()
uninstall_decorator_cache_handlers() uninstall_decorator_cache_handlers()
@@ -545,6 +547,10 @@ def _install_viewport_overlays() -> None:
# for join / extend-to-wall / cursor-extend / cursor-split previews. # for join / extend-to-wall / cursor-extend / cursor-split previews.
# Free when no preview-eligible state is active. # Free when no preview-eligible state is active.
WallGizmoPreviewDecorator.install(bpy.context) WallGizmoPreviewDecorator.install(bpy.context)
# Always-installed: draw() self-polls on active object + IfcDoor +
# parametric pset, so the cost is one bpy/IFC lookup per redraw when
# nothing eligible is selected.
DoorSwingReadonlyDecorator.install(bpy.context)
# Always-installed: draw() self-polls on the active object's array # Always-installed: draw() self-polls on the active object's array
# family membership, so installation has no cost when no array # family membership, so installation has no cost when no array
# element is selected. # element is selected.
@@ -3327,11 +3327,16 @@ class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Static quarter-arc glyph for swing visualisation. """Static quarter-arc glyph for swing visualisation.
Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to
``matrix_basis``.""" ``matrix_basis``. ``outline_alpha = 0.0`` suppresses the inherited 8-pass
dark halo: an open curve has no enclosed silhouette for the dilation to
ring, so the offset passes read as ghost arcs rather than a uniform
outline. The arc's own cross-section thickness keeps it legible without
the halo."""
bl_idname = "VIEW3D_GT_arc" bl_idname = "VIEW3D_GT_arc"
__slots__ = ("custom_shape",) __slots__ = ("custom_shape",)
tris = ARC_TRIS_DEFAULT tris = ARC_TRIS_DEFAULT
outline_alpha = 0.0
def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]: def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]:
+120 -1
View File
@@ -20,7 +20,7 @@ from __future__ import annotations
import math import math
from math import cos, pi, radians, sin, tan from math import cos, pi, radians, sin, tan
from typing import Any, Literal from typing import Any, Literal, NamedTuple
import blf import blf
import bmesh import bmesh
@@ -41,6 +41,11 @@ from mathutils import Matrix, Quaternion, Vector
import bonsai.core.geometry import bonsai.core.geometry
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.module.drawing.gizmos import (
ARC_SEGMENTS,
DOOR_SWING_ANGLE_MAX,
DOOR_SWING_ANGLE_MIN,
)
from bonsai.bim.module.drawing.helper import format_distance from bonsai.bim.module.drawing.helper import format_distance
@@ -2393,6 +2398,120 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
return p2 if d2 >= d1 else p1 return p2 if d2 >= d1 else p1
class _DoorSwingArc(NamedTuple):
"""Parameters for one swing-arc draw call in door-local space."""
hinge_x: float
hinge_y: float
panel_width: float
x_mirror: bool
def _visible_arcs(door_type: str, overall_width: float, lining_offset: float) -> list[_DoorSwingArc]:
"""Arc specs for the parametric door swing visualisation, agnostic of
edit-mode state so the readonly preview and the editor view stay aligned.
Empty only for sliding-door types; unknown ``door_type`` values fall
through to a single left-hinged arc."""
if "SLIDING" in door_type:
return []
is_double = "DOUBLE_DOOR" in door_type
is_right_single = door_type.endswith("RIGHT") and not is_double
arcs = [
_DoorSwingArc(
hinge_x=overall_width if is_right_single else 0.0,
hinge_y=lining_offset,
panel_width=overall_width / 2 if is_double else overall_width,
x_mirror=is_right_single,
)
]
if is_double:
arcs.append(
_DoorSwingArc(
hinge_x=overall_width,
hinge_y=lining_offset,
panel_width=overall_width / 2,
x_mirror=True,
)
)
return arcs
# Unit quarter-arc samples shared with the edit-mode swing gizmo so the
# readonly arc traces the same curve. Re-scaled per draw via the per-arc
# transform.
_DOOR_SWING_ARC_ANGLE_MIN_RAD = math.radians(DOOR_SWING_ANGLE_MIN)
_DOOR_SWING_ARC_ANGLE_RANGE_RAD = math.radians(DOOR_SWING_ANGLE_MAX) - _DOOR_SWING_ARC_ANGLE_MIN_RAD
_DOOR_SWING_ARC_UNIT_POINTS: tuple[Vector, ...] = tuple(
Vector(
(
math.cos(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
math.sin(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
0.0,
)
)
for _i in range(ARC_SEGMENTS + 1)
)
class DoorSwingReadonlyDecorator(tool.Blender.ViewportDecorator):
"""Always-on swing-arc preview for the active Bonsai-parametric IfcDoor
when it is not currently in parametric edit mode. Matches the visual
contract of the parametric door's swing-arc gizmos so the hinge side
and opening direction can be read without entering edit mode.
Silent-skip cases (no draw, no error):
- active object missing / not selected / not an IfcDoor;
- door is mid-edit (the swing gizmo is already painting the arc);
- door has no ``BBIM_Door`` pset (legacy import, never edited in Bonsai)."""
LINE_WIDTH = 1.5
LINE_ALPHA = 0.8
def draw(self, context: bpy.types.Context) -> None:
obj = context.active_object
if obj is None or not obj.select_get():
return
element = tool.Ifc.get_entity(obj)
if element is None or not element.is_a("IfcDoor"):
return
props = getattr(obj, "BIMDoorProperties", None)
if props is not None and props.is_editing:
return
pset = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Door")
if not pset:
return
data = pset.get("data_dict")
if not data:
return
door_type = data.get("door_type", "")
overall_width_project = data.get("overall_width", 0.0)
lining_offset_project = (data.get("lining_properties") or {}).get("lining_offset", 0.0)
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
overall_width = overall_width_project * si_conversion
lining_offset = lining_offset_project * si_conversion
specs = _visible_arcs(door_type, overall_width, lining_offset)
if not specs:
return
prefs = tool.Blender.get_addon_preferences()
main_color = tuple(prefs.decorator_color_special[:3])
mw = obj.matrix_world
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
for spec in specs:
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if spec.x_mirror else Matrix.Identity(4)
transform = (
Matrix.Translation(Vector((spec.hinge_x, spec.hinge_y, 0.0)))
@ Matrix.Scale(spec.panel_width, 4)
@ x_flip
)
world_main = mw @ transform
pts = [world_main @ p for p in _DOOR_SWING_ARC_UNIT_POINTS]
for i in range(len(pts) - 1):
segments.append((tuple(pts[i]), tuple(pts[i + 1])))
_stroke_lines_alpha(context, segments, main_color, self.LINE_WIDTH, self.LINE_ALPHA)
_BBOX_EDGES = ( _BBOX_EDGES = (
(0, 1), (1, 2), (2, 3), (3, 0), (0, 1), (1, 2), (2, 3), (3, 0),
(4, 5), (5, 6), (6, 7), (7, 4), (4, 5), (5, 6), (6, 7), (7, 4),
@@ -0,0 +1,190 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Contract tests for the door swing-arc readonly decorator.
Two layers:
- Pure tests on ``_visible_arcs`` pin the readonly decorator's arc selection
per ``door_type`` enum value.
- A forward-compat guard walks ``GizmoDoorEdition.swing_arc_props`` and
asserts the readonly decorator picks the same arcs (hinge / width / mirror)
the edit-mode gizmo would, so the two surfaces stay visually identical
even when a new ``door_type`` is added."""
import types
from types import SimpleNamespace
from typing import get_args
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
# ----------------------------------------------------------------------------
# _visible_arcs — per-door-type arc selection
# ----------------------------------------------------------------------------
def _arcs(door_type, overall_width=0.9, lining_offset=0.05):
from bonsai.bim.module.model.decorator import _visible_arcs
return _visible_arcs(door_type, overall_width, lining_offset)
def test_single_swing_left_one_arc_hinged_at_origin():
arcs = _arcs("SINGLE_SWING_LEFT")
assert len(arcs) == 1
arc = arcs[0]
assert arc.hinge_x == pytest.approx(0.0)
assert arc.hinge_y == pytest.approx(0.05)
assert arc.panel_width == pytest.approx(0.9)
assert arc.x_mirror is False
def test_single_swing_right_one_arc_hinged_at_right_edge_x_mirrored():
arcs = _arcs("SINGLE_SWING_RIGHT")
assert len(arcs) == 1
arc = arcs[0]
assert arc.hinge_x == pytest.approx(0.9)
assert arc.panel_width == pytest.approx(0.9)
assert arc.x_mirror is True
@pytest.mark.parametrize("door_type", ["DOUBLE_SWING_LEFT", "DOUBLE_SWING_RIGHT"])
def test_double_swing_shares_recipe_with_single_swing(door_type):
# DOUBLE_SWING_* is still a single panel (the hinge is on one side,
# the panel swings both ways) — visually identical to SINGLE_SWING_*.
single_type = door_type.replace("DOUBLE_SWING", "SINGLE_SWING")
assert _arcs(door_type) == _arcs(single_type)
def test_double_door_single_swing_emits_two_half_width_arcs():
arcs = _arcs("DOUBLE_DOOR_SINGLE_SWING")
assert len(arcs) == 2
left, right = arcs
assert left.hinge_x == pytest.approx(0.0)
assert left.panel_width == pytest.approx(0.45)
assert left.x_mirror is False
assert right.hinge_x == pytest.approx(0.9)
assert right.panel_width == pytest.approx(0.45)
assert right.x_mirror is True
@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"])
def test_sliding_doors_emit_no_arcs(door_type):
assert _arcs(door_type) == []
def test_unknown_door_type_falls_back_to_single_left_swing_arc():
# Only ``"SLIDING"`` substrings short-circuit the swing predicate; any
# other novel ``door_type`` falls through to the default left-hinged arc.
arcs = _arcs("FUTURE_OPERATION_TYPE_42")
assert len(arcs) == 1
arc = arcs[0]
assert arc.hinge_x == pytest.approx(0.0)
assert arc.panel_width == pytest.approx(0.9)
assert arc.x_mirror is False
def test_lining_offset_drives_hinge_y_for_every_visible_arc():
for door_type in ("SINGLE_SWING_LEFT", "SINGLE_SWING_RIGHT", "DOUBLE_DOOR_SINGLE_SWING"):
for arc in _arcs(door_type, overall_width=0.9, lining_offset=0.12):
assert arc.hinge_y == pytest.approx(0.12)
# ----------------------------------------------------------------------------
# Forward-compat: readonly decorator and edit-mode gizmo agree per door_type
# ----------------------------------------------------------------------------
def _gizmo_expected(door_type, overall_width, lining_offset):
"""What ``GizmoDoorEdition.swing_arc_props`` would render for the props
snapshot, with ``is_editing=True`` so its visibility predicates pass."""
from bonsai.bim.module.model.door import GizmoDoorEdition
props = SimpleNamespace(
door_type=door_type,
overall_width=overall_width,
lining_offset=lining_offset,
is_editing=True,
)
expected = []
for cfg in GizmoDoorEdition.swing_arc_props:
if cfg.visibility_condition(props):
expected.append(
(
cfg.hinge_x(props),
cfg.hinge_y(props),
cfg.panel_width(props),
cfg.x_mirror(props),
)
)
return expected
def test_visible_arcs_matches_gizmo_swing_arc_props_for_every_door_type():
import bonsai.tool as tool
overall_width, lining_offset = 0.9, 0.05
for door_type in get_args(tool.Model.DoorType):
expected = _gizmo_expected(door_type, overall_width, lining_offset)
actual = _arcs(door_type, overall_width, lining_offset)
actual_tuples = [(a.hinge_x, a.hinge_y, a.panel_width, a.x_mirror) for a in actual]
assert actual_tuples == expected, (
f"Readonly decorator drifted from edit-mode gizmo for {door_type!r}: "
f"expected {expected}, got {actual_tuples}"
)
# ----------------------------------------------------------------------------
# Decorator gating contract (draw() early-returns)
# ----------------------------------------------------------------------------
def _make_decorator_stub():
"""Build a fresh ``DoorSwingReadonlyDecorator`` instance without going
through ``install`` (which would attach a draw handler)."""
from bonsai.bim.module.model.decorator import DoorSwingReadonlyDecorator
return DoorSwingReadonlyDecorator()
def _draw_with_active(decorator, active_obj):
"""Call ``draw`` with a minimal ``context`` stub."""
ctx = SimpleNamespace(active_object=active_obj)
decorator.draw(ctx)
def test_draw_early_returns_when_no_active_object():
# Should not raise; nothing to draw.
_draw_with_active(_make_decorator_stub(), None)
def test_draw_early_returns_when_active_not_selected():
obj = SimpleNamespace(select_get=lambda: False)
_draw_with_active(_make_decorator_stub(), obj)