mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
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:
committed by
Thomas Krijnen
parent
4a6087699b
commit
2c3935bf9d
@@ -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.
|
||||
|
||||
@@ -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], ...]:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user