Fix door swing arcs + declarative SwingArcConfig

The recent per-gizmo-prefs cleanup left ``update_swing_gizmos`` with a
stale ``prefs`` reference that raised NameError mid-refresh, so the flip
arc's ``matrix_basis`` was never reassigned and the gizmo drifted to the
world origin. SINGLE_SWING_RIGHT also lacked an X-mirror on the primary
arc, so the swing extended past the door's right edge instead of
sweeping back over the panel.

Five related fixes / additions:

* Drop the leftover ``prefs.decorations_colour[:3]`` per-frame colour
  override (the setup-time ``decorator_color_special`` is the durable
  contract — there's no reason to overwrite it every refresh).
* Add X-mirror to RIGHT-hinged single-panel transforms so the arc
  sweeps back over the door rather than past the right edge.
* Treat DOUBLE_DOOR_SINGLE_SWING as a two-panel layout: 4 arcs total
  (left + right panels, each with its own Y-mirrored flip) scaled to
  ``overall_width / 2``.
* Hide all swing arcs for SLIDING_TO_LEFT / SLIDING_TO_RIGHT /
  DOUBLE_DOOR_SLIDING — sliding doors don't swing. A slide-direction
  indicator is deferred to a separate change.
* Pin ``select_bias = -1000.0`` on every arc gizmo so the big
  quarter-arc hit shapes don't steal clicks from the smaller dimension
  and edit gizmos drawn on top.

Architectural cleanup driven by the same diff: the imperative
4-create + 50-line update block is replaced by a declarative
``swing_arc_props`` list of ``SwingArcConfig`` entries (mirrors the
existing ``dimension_gizmo_props`` pattern). Setup iterates the list
and creates one (main, flip) pair per entry under
``gizmo_swing_arc_<name>`` / ``gizmo_swing_arc_<name>_flip``; update
iterates the same list and positions each pair via the lambdas. Adding
a hypothetical multi-panel variant becomes a config entry rather than
two more attribute names plus a transform branch.

``ToggleDoorSwing`` gets a ``description`` classmethod that returns
user-facing wording per ``flip_geometry`` branch so the tooltip on
hover stops reading like operator internals.

``test/bim/module/model/test_door_gizmos.py`` (new) pins the
per-door-type contract: 11 cases covering LEFT / RIGHT hinge positions,
DOUBLE_SWING parity with SINGLE_SWING, DOUBLE_DOOR 4-arc layout, the
sliding-types hide invariant, ``is_editing=False`` hide invariant,
flip-arc matrix re-assignment, and world-matrix pre-multiplication.

Verified: ``pytest test/bim/module/model/test_door_gizmos.py`` 11/11
green; combined wall + stair + door gizmo lanes 37/37 green; ruff +
black clean on the three touched files.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-02 10:15:09 +02:00
committed by Thomas Krijnen
parent a2d600b9af
commit 95ad96c25e
3 changed files with 324 additions and 39 deletions
@@ -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_<name>``: main arc on the active swing side
- ``self.gizmo_swing_arc_<name>_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."""
+72 -39
View File
@@ -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_<name>`` and ``self.gizmo_swing_arc_<name>_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
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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)