mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-05 20:06:25 +00:00
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:
committed by
Thomas Krijnen
parent
a2d600b9af
commit
95ad96c25e
@@ -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)
|
||||
Reference in New Issue
Block a user