diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 6cfd321114..c1659f0bb0 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -35,6 +35,7 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`) "CoordinateSpace", "ModalState", "DimensionGizmoConfig", + "SwingArcConfig", "ViewDirection", "GizmoModalContext", "get_modal_context", @@ -1301,6 +1302,38 @@ class IconActionConfig: visibility_condition: Callable[[Any], bool] | None = None +@dataclass(slots=True) +class SwingArcConfig: + """Declarative config for one swing-arc panel — a pair of ``GizmoArc`` + instances representing a single hinged panel's two possible open sides. + + Each entry produces two gizmos at setup time: + - ``self.gizmo_swing_arc_``: main arc on the active swing side + - ``self.gizmo_swing_arc__flip``: Y-mirror of the main, on the + opposite side of the hinge line + + Both gizmos hide together when ``visibility_condition(props)`` is False. + When visible, each arc's ``matrix_basis`` is: + + Translation(hinge_x(props), hinge_y(props), 0) + @ Scale(panel_width(props), 4) + @ (Scale(-1, X) if x_mirror(props) else Identity) + @ (Scale(-1, Y) if this is the flip arc else Identity) + + The arc geometry (``GizmoArc.tris``) is a unit quarter-arc sweeping + counterclockwise from +X to +Y with its hinge at the origin, so the + transforms above translate the hinge into world position, scale to + panel size, and mirror across the hinge line as needed. + """ + + name: str + visibility_condition: Callable[[Any], bool] + hinge_x: Callable[[Any], float] + hinge_y: Callable[[Any], float] + panel_width: Callable[[Any], float] + x_mirror: Callable[[Any], bool] + + class SnapManager: """Manages snap point visualization and mesh snapping with caching.""" diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index c9323fa14e..e75bce15e1 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -644,12 +644,8 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator): class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator): - """Toggle door swing direction and optionally flip door geometry. - - Shift+Click (when flip_geometry=True): Flip geometry only without changing door direction""" - bl_idname = "bim.toggle_door_swing" - bl_label = "Toggle Door Swing" + bl_label = "Change Door Swing" bl_options = {"REGISTER", "UNDO"} flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False) @@ -660,6 +656,15 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator): name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"} ) + @classmethod + def description(cls, context: bpy.types.Context, properties: bpy.types.OperatorProperties) -> str: + if properties.flip_geometry: + return ( + "Swing the door from the opposite side of the wall. " + "Shift+click: mirror the door without changing which side it opens to" + ) + return "Move the door hinge to the opposite side" + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: self.skip_direction_change = event.shift return self.execute(context) @@ -835,6 +840,35 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): ), ] + # Big quarter-arc hit shapes cover much of the door face — without a + # negative select_bias they would steal clicks from the small dimension + # and edit gizmos drawn on top of them. + SWING_ARC_SELECT_BIAS = -1000.0 + swing_arc_operator = "bim.toggle_door_swing" + + swing_arc_props = [ + gizmo.SwingArcConfig( + name="primary", + visibility_condition=lambda p: p.is_editing and "SLIDING" not in p.door_type, + hinge_x=lambda p: ( + p.overall_width if p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type else 0.0 + ), + hinge_y=lambda p: p.lining_offset, + panel_width=lambda p: p.overall_width / 2 if "DOUBLE_DOOR" in p.door_type else p.overall_width, + x_mirror=lambda p: p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type, + ), + gizmo.SwingArcConfig( + name="secondary", + visibility_condition=lambda p: p.is_editing + and "DOUBLE_DOOR" in p.door_type + and "SLIDING" not in p.door_type, + hinge_x=lambda p: p.overall_width, + hinge_y=lambda p: p.lining_offset, + panel_width=lambda p: p.overall_width / 2, + x_mirror=lambda _p: True, + ), + ] + props_getter = tool.Model.get_door_props gizmo_pref_name = "door" @@ -858,22 +892,20 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): return (furthest_y, furthest_y) def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: - """Create door-specific swing arc gizmos.""" - prefs = tool.Blender.get_addon_preferences() - inactive_color = prefs.decorator_color_background[:3] - special_color = prefs.decorator_color_special[:3] + """Create one (main, flip) swing-arc pair per ``swing_arc_props`` entry. - self.gizmo_door_type = self.create_arc_gizmo( - special_color, - "bim.toggle_door_swing", - flip_geometry=False, - ) - self.gizmo_flip_arc = self.create_arc_gizmo( - inactive_color, - "bim.toggle_door_swing", - flip_geometry=True, - flip_local_axes="XY", - ) + Stored as ``self.gizmo_swing_arc_`` and ``self.gizmo_swing_arc__flip`` + and pinned to ``SWING_ARC_SELECT_BIAS`` so other door gizmos win selection.""" + prefs = tool.Blender.get_addon_preferences() + main_color = prefs.decorator_color_special[:3] + flip_color = prefs.decorator_color_background[:3] + for cfg in self.swing_arc_props: + main = self.create_arc_gizmo(main_color, self.swing_arc_operator, flip_geometry=False) + flip = self.create_arc_gizmo(flip_color, self.swing_arc_operator, flip_geometry=True) + for gz in (main, flip): + gz.select_bias = self.SWING_ARC_SELECT_BIAS + setattr(self, f"gizmo_swing_arc_{cfg.name}", main) + setattr(self, f"gizmo_swing_arc_{cfg.name}_flip", flip) def _refresh_element_specific( self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002 @@ -892,22 +924,23 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): self._update_view_dependent_dimensions(context, mw, props) def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None: - """Update swing gizmo position and color based on editing state.""" - door_type_visible = self.update_gizmo_visibility(self.gizmo_door_type, props.is_editing) - flip_arc_visible = self.update_gizmo_visibility(self.gizmo_flip_arc, props.is_editing) - - if not door_type_visible and not flip_arc_visible: - return - - swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0 - base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale( - props.overall_width, 4 - ) - - if door_type_visible: - self.gizmo_door_type.matrix_basis = mw @ base_swing_transform - self.gizmo_door_type.color = prefs.decorations_colour[:3] - - if flip_arc_visible: - mirror_y = Matrix.Scale(-1, 4, (0, 1, 0)) - self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y + """Position each declared swing-arc pair per its config + props state.""" + mirror_y = Matrix.Scale(-1, 4, (0, 1, 0)) + for cfg in self.swing_arc_props: + main = getattr(self, f"gizmo_swing_arc_{cfg.name}") + flip = getattr(self, f"gizmo_swing_arc_{cfg.name}_flip") + show = cfg.visibility_condition(props) + main_visible = self.update_gizmo_visibility(main, show) + flip_visible = self.update_gizmo_visibility(flip, show) + if not (main_visible or flip_visible): + continue + x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if cfg.x_mirror(props) else Matrix.Identity(4) + transform = ( + Matrix.Translation(V_(cfg.hinge_x(props), cfg.hinge_y(props), 0)) + @ Matrix.Scale(cfg.panel_width(props), 4) + @ x_flip + ) + if main_visible: + main.matrix_basis = mw @ transform + if flip_visible: + flip.matrix_basis = mw @ transform @ mirror_y diff --git a/src/bonsai/test/bim/module/model/test_door_gizmos.py b/src/bonsai/test/bim/module/model/test_door_gizmos.py new file mode 100644 index 0000000000..1f69b74721 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_door_gizmos.py @@ -0,0 +1,219 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Contract tests for the door swing-arc gizmo positioning. + +Each test calls ``GizmoDoorEdition.update_swing_gizmos`` as an unbound method +against a SimpleNamespace stand-in that records ``matrix_basis`` assignments and +``hide`` flags. The expected matrices are recomputed from first principles so +the tests describe the geometric contract directly rather than echoing the +implementation.""" + +import types +from types import SimpleNamespace +from unittest.mock import MagicMock + +import bpy +import pytest +from mathutils import Matrix, Vector + +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)") + + +def _make_props(door_type, overall_width=0.9, lining_offset=0.0, is_editing=True): + return SimpleNamespace( + door_type=door_type, + overall_width=overall_width, + lining_offset=lining_offset, + is_editing=is_editing, + ) + + +def _make_fake_group(): + """Stand-in for ``GizmoDoorEdition``: one MagicMock per declared arc gizmo + plus a stub ``update_gizmo_visibility`` that records the visibility flag on + each mock's ``hide`` attribute.""" + from bonsai.bim.module.model.door import GizmoDoorEdition + + fake = SimpleNamespace() + fake.swing_arc_props = GizmoDoorEdition.swing_arc_props + + def update_gizmo_visibility(gizmo, is_visible): + gizmo.hide = not is_visible + return is_visible + + fake.update_gizmo_visibility = update_gizmo_visibility + + for cfg in fake.swing_arc_props: + setattr(fake, f"gizmo_swing_arc_{cfg.name}", MagicMock(spec=["matrix_basis", "hide"])) + setattr(fake, f"gizmo_swing_arc_{cfg.name}_flip", MagicMock(spec=["matrix_basis", "hide"])) + + return fake + + +def _call_update(fake, props, mw=None): + from bonsai.bim.module.model.door import GizmoDoorEdition + + GizmoDoorEdition.update_swing_gizmos(fake, mw or Matrix.Identity(4), props) + + +def _matrix_approx(actual, expected, abs_tol=1e-6): + assert isinstance(actual, Matrix), f"matrix_basis was never assigned (got {type(actual).__name__})" + for i in range(4): + for j in range(4): + assert actual[i][j] == pytest.approx(expected[i][j], abs=abs_tol), ( + f"Mismatch at [{i}][{j}]: got {actual[i][j]}, expected {expected[i][j]}\n" + f"actual=\n{actual}\nexpected=\n{expected}" + ) + + +_MIRROR_X = Matrix.Scale(-1, 4, (1, 0, 0)) +_MIRROR_Y = Matrix.Scale(-1, 4, (0, 1, 0)) + + +def test_single_swing_left_primary_arc_hinges_at_left_edge(): + """Left-hinged single-swing: primary arc at (0, lining_offset), scaled to + overall_width, no X-mirror. Flip arc same transform composed with Y-mirror. + Secondary panel hidden.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.05) + _call_update(fake, props) + + expected = Matrix.Translation(Vector((0.0, 0.05, 0.0))) @ Matrix.Scale(0.9, 4) + _matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected) + _matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y) + assert fake.gizmo_swing_arc_secondary.hide is True + assert fake.gizmo_swing_arc_secondary_flip.hide is True + + +def test_single_swing_right_primary_arc_hinges_at_right_edge_with_x_mirror(): + """Right-hinged single-swing: primary arc anchored at (overall_width, lining_offset) + with an X-mirror applied so the arc sweeps back over the door panel rather + than extending past the right edge.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_RIGHT", overall_width=0.9, lining_offset=0.05) + _call_update(fake, props) + + expected = Matrix.Translation(Vector((0.9, 0.05, 0.0))) @ Matrix.Scale(0.9, 4) @ _MIRROR_X + _matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected) + _matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y) + assert fake.gizmo_swing_arc_secondary.hide is True + assert fake.gizmo_swing_arc_secondary_flip.hide is True + + +@pytest.mark.parametrize( + ("double_type", "single_type"), + [ + ("DOUBLE_SWING_LEFT", "SINGLE_SWING_LEFT"), + ("DOUBLE_SWING_RIGHT", "SINGLE_SWING_RIGHT"), + ], +) +def test_double_swing_uses_same_recipe_as_single_swing(double_type, single_type): + """DOUBLE_SWING_* (one panel that can open both ways) shares the + single-panel positioning recipe with its SINGLE_SWING_* counterpart.""" + fake_a = _make_fake_group() + fake_b = _make_fake_group() + props_a = _make_props(door_type=double_type, overall_width=0.9, lining_offset=0.05) + props_b = _make_props(door_type=single_type, overall_width=0.9, lining_offset=0.05) + _call_update(fake_a, props_a) + _call_update(fake_b, props_b) + + _matrix_approx( + fake_a.gizmo_swing_arc_primary.matrix_basis, + fake_b.gizmo_swing_arc_primary.matrix_basis, + ) + _matrix_approx( + fake_a.gizmo_swing_arc_primary_flip.matrix_basis, + fake_b.gizmo_swing_arc_primary_flip.matrix_basis, + ) + + +def test_double_door_shows_four_arcs_each_scaled_to_half_door_width(): + """DOUBLE_DOOR_SINGLE_SWING: left panel hinged at x=0, right panel hinged + at x=overall_width with X-mirror, both scaled to overall_width/2. Each + panel also gets a Y-mirrored flip arc — 4 arcs total.""" + fake = _make_fake_group() + props = _make_props(door_type="DOUBLE_DOOR_SINGLE_SWING", overall_width=1.6, lining_offset=0.0) + _call_update(fake, props) + + half = 1.6 / 2 + expected_primary = Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(half, 4) + expected_secondary = Matrix.Translation(Vector((1.6, 0.0, 0.0))) @ Matrix.Scale(half, 4) @ _MIRROR_X + + _matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected_primary) + _matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected_primary @ _MIRROR_Y) + _matrix_approx(fake.gizmo_swing_arc_secondary.matrix_basis, expected_secondary) + _matrix_approx(fake.gizmo_swing_arc_secondary_flip.matrix_basis, expected_secondary @ _MIRROR_Y) + + for cfg in fake.swing_arc_props: + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is False + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is False + + +@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"]) +def test_sliding_door_types_hide_all_arcs(door_type): + """Sliding doors don't swing — every arc in ``swing_arc_props`` is hidden.""" + fake = _make_fake_group() + props = _make_props(door_type=door_type, overall_width=0.9, lining_offset=0.0) + _call_update(fake, props) + + for cfg in fake.swing_arc_props: + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True + + +def test_not_editing_hides_all_arcs(): + """``is_editing=False`` collapses every arc's visibility, regardless of door type.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, is_editing=False) + _call_update(fake, props) + + for cfg in fake.swing_arc_props: + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True + + +def test_flip_arc_matrix_is_reassigned_each_refresh(): + """The flip arc's ``matrix_basis`` must be (re-)assigned on every refresh + so a stale identity matrix can never appear at the world origin.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.1) + _call_update(fake, props) + + assert isinstance(fake.gizmo_swing_arc_primary_flip.matrix_basis, Matrix) + assert fake.gizmo_swing_arc_primary_flip.matrix_basis != Matrix.Identity(4) + + +def test_world_matrix_pre_multiplies_into_arc_transform(): + """The caller's world matrix ``mw`` left-multiplies the per-panel transform: + a translated ``mw`` shifts every arc by the same offset.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.0) + mw = Matrix.Translation(Vector((10.0, 20.0, 30.0))) + _call_update(fake, props, mw=mw) + + expected = mw @ Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(0.9, 4) + _matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)