From 2c3935bf9d9369bbcdc6598cbae36d464fe7fbd7 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 9 Jun 2026 16:13:59 +0200 Subject: [PATCH] Add readonly door swing arc preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bonsai/bonsai/bim/handler.py | 6 + .../bonsai/bim/module/drawing/gizmos.py | 7 +- .../bonsai/bim/module/model/decorator.py | 121 ++++++++++- .../bim/module/model/test_door_decorator.py | 190 ++++++++++++++++++ 4 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_door_decorator.py diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index 9ed9c201c5..dc4af08813 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -49,6 +49,7 @@ from bonsai.bim.module.model.data import AuthoringData from bonsai.bim.module.model.decorator import ( BendPreviewDecorator, BoundingBoxDecorator, + DoorSwingReadonlyDecorator, MEPSegmentExtendPreviewDecorator, SlabDirectionDecorator, WallAxisDecorator, @@ -516,6 +517,7 @@ def _install_viewport_overlays() -> None: BendPreviewDecorator.uninstall() MEPSegmentExtendPreviewDecorator.uninstall() WallGizmoPreviewDecorator.uninstall() + DoorSwingReadonlyDecorator.uninstall() ArrayPreviewDecorator.uninstall() ArraySelectionHighlightDecorator.uninstall() uninstall_decorator_cache_handlers() @@ -545,6 +547,10 @@ def _install_viewport_overlays() -> None: # for join / extend-to-wall / cursor-extend / cursor-split previews. # Free when no preview-eligible state is active. 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 # family membership, so installation has no cost when no array # element is selected. diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index ba1cd10bec..3f794d5804 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -3327,11 +3327,16 @@ class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo): """Static quarter-arc glyph for swing visualisation. 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" __slots__ = ("custom_shape",) tris = ARC_TRIS_DEFAULT + outline_alpha = 0.0 def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]: diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 3f912ce7bf..bdaf1f48bc 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -20,7 +20,7 @@ from __future__ import annotations import math from math import cos, pi, radians, sin, tan -from typing import Any, Literal +from typing import Any, Literal, NamedTuple import blf import bmesh @@ -41,6 +41,11 @@ from mathutils import Matrix, Quaternion, Vector import bonsai.core.geometry 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 @@ -2393,6 +2398,120 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator): 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 = ( (0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), diff --git a/src/bonsai/test/bim/module/model/test_door_decorator.py b/src/bonsai/test/bim/module/model/test_door_decorator.py new file mode 100644 index 0000000000..939f7b292c --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_door_decorator.py @@ -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 . +# +# 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)