Add railing parametric edit + schematic preview

Port gizmos-8088's railing gizmo block to v0.8.0:

- _RailingEditMixin (PathPreservingEditMixin specialisation) +
  EnableEditingRailing / CancelEditingRailing / FinishEditingRailing
  edit triad
- CycleRailingType (2-value type cycler) + ToggleRailingUseManualSupports
  one-shot + EditRailingTerminalType
- FlipRailingPathOrder + EnableEditingRailingPath /
  CancelEditingRailingPath / FinishEditingRailingPath path-edit
  operators (mutually exclusive with the schematic frame)
- GizmoRailingSchematic (BaseSchematicGizmoGroup specialisation) —
  axonometric schematic frame with per-attribute dimension gizmos
  for FRAMELESS_PANEL + WALL_MOUNTED_HANDRAIL railing types;
  hover-on-attr highlights the schematic edges tagged with the
  matching feature

Tests: test_railing_lifecycle.py (280 LOC) +
test_railing_schematic.py (272 LOC).

Drops the per-feature GizmoPreferences{Door,Window,Stair,Wall,Roof,
Railing} PropertyGroups that the source commit added to bim/ui.py
— that finer-grained per-attribute toggle model was deliberately
collapsed to flat per-feature bools in the PR5b prefs sweep, and
GizmoRailingSchematic gates on the flat ``prefs.gizmos.railing``
bool via ``gizmo_pref_name`` so no functionality is lost.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-10 20:40:33 +02:00
parent 251157f8d4
commit d1d1e1d4a2
4 changed files with 1266 additions and 10 deletions
@@ -0,0 +1,280 @@
# 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.
"""Unit coverage for the ``_RailingEditMixin`` lifecycle overrides.
The generic ``PathPreservingEditMixin`` lifecycle is tested in
``test_parametric_lifecycle.py``. This file pins the **railing-specific
overrides** that subclass it:
- ``_RailingEditMixin._finish_one`` short-circuit: when the draft equals
the stored pset, ``_update_pset`` and ``_update_modifier_ifc_data`` are
skipped so an Enable → Finish-without-changes cycle creates no new
``IfcShapeRepresentation``.
- ``_RailingEditMixin._cancel_one`` short-circuit: same logic guards the
expensive ``bonsai.core.geometry.switch_representation`` call (which
re-tessellates the swept-disk solid) when nothing actually changed.
- ``_RailingEditMixin._cancel_one`` WALL_MOUNTED_HANDRAIL branch: when
changes WERE made, the cancel reloads the IFC body via
``switch_representation`` instead of running ``update_modifier_bmesh``
(which would leave the low-poly cylinder-segment preview on screen).
"""
from unittest import mock
import pytest
from test.bim.conftest import _FakePropsBase
from test.bim.conftest import make_lifecycle_obj as _make_obj
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeRailingProps(_FakePropsBase):
"""Stand-in for ``BIMRailingProperties`` — adds ``railing_type`` on top of
the shared parametric-edit contract. Starts in ``is_editing=True`` because
the railing-specific overrides under test only fire on Finish / Cancel,
not on Enable."""
def __init__(self, railing_type: str = "WALL_MOUNTED_HANDRAIL", general: dict | None = None):
super().__init__(general=general if general is not None else {"railing_type": railing_type, "height": 1.0})
self.railing_type = railing_type
self.is_editing = True
@pytest.fixture
def patched_railing():
"""Patch the railing module's external references for unit testing.
``_RailingEditMixin`` calls ``tool.Model.get_modeling_bbim_pset_data``,
``tool.Ifc.get_entity``, ``ifcopenshell.util.representation.get_representation``,
and ``bonsai.core.geometry.switch_representation`` — each looked up
through the railing module's own bindings, so we patch them there.
Uses ``mock.patch.object`` with a direct module reference rather than
the dotted-string form: ``mock.patch("bonsai.bim.module.model.railing.bonsai")``
needs ``pkgutil.resolve_name`` to traverse ``bonsai → bim → module → …``,
which fails at the ``bonsai.bim`` step until that subpackage has been
imported elsewhere. The direct-object form sidesteps the resolution.
Returns a dict for tests to seed return values and assert call sites.
"""
from bonsai.bim.module.model import railing
with (
mock.patch.object(railing, "tool") as mock_tool,
mock.patch.object(railing, "ifcopenshell") as mock_ifc,
mock.patch.object(railing, "bonsai") as mock_bonsai,
):
# _resolve will be overridden on the test subclass below so the
# parametric_lifecycle.tool patch isn't needed.
mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity")
yield {"tool": mock_tool, "ifcopenshell": mock_ifc, "bonsai": mock_bonsai}
def _railing_test_subclass(props):
"""Build a ``_RailingEditMixin`` subclass that bypasses ``_resolve``.
The base ``_resolve`` reads ``tool.Ifc.get_entity`` from
``parametric_lifecycle.tool`` (a separate import from the railing
module's ``tool``). Overriding it here keeps the test patches local
to the railing module and the hook closures local to the test."""
from bonsai.bim.module.model.railing import _RailingEditMixin
test_element = mock.Mock(name="ifc_element")
class _TestRailingMixin(_RailingEditMixin):
pset_updates: mock.MagicMock = mock.MagicMock(name="_update_pset")
ifc_data_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_ifc_data")
bmesh_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_bmesh")
@classmethod
def _resolve(cls, obj):
return test_element, props
@classmethod
def _update_pset(cls, element, data):
cls.pset_updates(element, data)
@classmethod
def _update_modifier_ifc_data(cls, obj, context):
cls.ifc_data_updates(obj, context)
@classmethod
def _update_modifier_bmesh(cls, obj, context):
cls.bmesh_updates(obj, context)
# The base _post_load_data JSON-serialises path_data; bypass that
# here so the round-trip stays a plain dict and tests can compare
# by reference / equality without re-parsing.
@classmethod
def _post_load_data(cls, data):
return dict(data)
return _TestRailingMixin, test_element
# ---------------------------------------------------------------------------
# _RailingEditMixin._finish_one
# ---------------------------------------------------------------------------
def test_finish_one_short_circuits_when_draft_matches_stored(patched_railing):
"""Enable → Finish without any property edit must NOT write to IFC.
Without this, every "open Edit, click Validate immediately" cycle
would create a fresh ``IfcShapeRepresentation``, pollute the file's
representation list, and burn an undo entry — the user-visible
regression that motivated the short-circuit.
"""
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
props = _FakeRailingProps(general=dict(stored))
obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
}
cls, _element = _railing_test_subclass(props)
cls._finish_one(obj, mock.Mock(name="context"))
assert props.is_editing is False, "is_editing must still flip even on no-op"
cls.pset_updates.assert_not_called()
cls.ifc_data_updates.assert_not_called()
def test_finish_one_writes_when_draft_differs(patched_railing):
"""The complement of the short-circuit: a real property change must
flow through to ``_update_pset`` + ``_update_modifier_ifc_data``."""
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
# Draft height differs: simulating a user edit.
props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5})
obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
}
cls, element = _railing_test_subclass(props)
cls._finish_one(obj, mock.Mock(name="context"))
assert props.is_editing is False
cls.pset_updates.assert_called_once()
# The pset must receive the DRAFT data, not the stored data — that's the
# whole point of Finish committing the user's edits.
written = cls.pset_updates.call_args[0][1]
assert written["height"] == 1.5
cls.ifc_data_updates.assert_called_once_with(obj, mock.ANY)
# ---------------------------------------------------------------------------
# _RailingEditMixin._cancel_one
# ---------------------------------------------------------------------------
def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing):
"""Cancel-without-changes is asymmetrically expensive without this guard:
``switch_representation`` re-tessellates the IfcSweptDiskSolid and is
visibly slow on a long handrail. When nothing changed, the mesh on
screen is still the committed IFC representation (the preview only
builds on a property change) — skip the reload entirely.
"""
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
props = _FakeRailingProps(general=dict(stored))
obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
}
cls, _element = _railing_test_subclass(props)
cls._cancel_one(obj, mock.Mock(name="context"))
assert props.is_editing is False
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
cls.bmesh_updates.assert_not_called()
def test_cancel_one_wall_mounted_handrail_switches_representation(patched_railing):
"""Cancel after a real edit on a WALL_MOUNTED_HANDRAIL must reload the
committed Body representation (high-poly, IFC-derived) rather than
re-running the low-poly bmesh preview — that preview is a viewport-only
approximation and would persist visibly after Cancel without this.
"""
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
# Differs → not a no-op → cancel must take the real branch.
props = _FakeRailingProps(
railing_type="WALL_MOUNTED_HANDRAIL",
general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5},
)
obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
}
body_repr = mock.Mock(name="body_representation")
patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr
cls, _element = _railing_test_subclass(props)
cls._cancel_one(obj, mock.Mock(name="context"))
assert props.is_editing is False
# Must call switch_representation with the Body representation; must NOT
# call _update_modifier_bmesh (that's the FRAMELESS branch).
patched_railing["bonsai"].core.geometry.switch_representation.assert_called_once()
kwargs = patched_railing["bonsai"].core.geometry.switch_representation.call_args.kwargs
assert kwargs["obj"] is obj
assert kwargs["representation"] is body_repr
cls.bmesh_updates.assert_not_called()
def test_cancel_one_frameless_panel_runs_bmesh_preview(patched_railing):
"""FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC
swept-disk solid to reload. Cancel must run the bmesh rebuild instead
of switch_representation, which would no-op or worse."""
stored = {"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.05}
props = _FakeRailingProps(
railing_type="FRAMELESS_PANEL",
general={"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.08},
)
obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
}
cls, _element = _railing_test_subclass(props)
cls._cancel_one(obj, mock.Mock(name="context"))
assert props.is_editing is False
cls.bmesh_updates.assert_called_once_with(obj, mock.ANY)
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
# ---------------------------------------------------------------------------
# _get_railing_path_anchor: tests removed.
#
# The schematic-redesign branch replaced ``GizmoRailingEdition`` with
# ``GizmoRailingSchematic``, which anchors via the schematic frame rather
# than the polyline's first vertex. ``_get_railing_path_anchor`` was the
# helper for the old anchor strategy and has been deleted along with the
# old gizmo group. If schematic-mode gains a similar path-derived helper,
# new tests should land here.
# ---------------------------------------------------------------------------
@@ -0,0 +1,272 @@
# 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.
import types
from types import SimpleNamespace
import bmesh
import bpy
import pytest
from bonsai import tool
from bonsai.bim.module.drawing.gizmos import (
BaseSchematicGizmoGroup,
DimensionGizmoConfig,
)
from bonsai.bim.module.model.railing import GizmoRailingSchematic
pytestmark = pytest.mark.railing
@pytest.fixture(autouse=True)
def _require_real_bpy():
"""Skip the file when ``bpy`` is mocked or absent.
Without this guard, mis-routed test runs (e.g. ``pytest test/bim/...``
invoked outside Blender) crash at module-collection time on the chain of
``bonsai.tool`` imports below, instead of producing a clean ``skipped``.
"""
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
# ── Class shape ──────────────────────────────────────────────────────────────
def test_railing_schematic_inherits_base():
"""GizmoRailingSchematic plugs into the schematic framework, not the
in-place dimension framework. If a future refactor breaks this lineage
the schematic-specific machinery (sliders, draw handler) silently goes
dormant."""
assert issubclass(GizmoRailingSchematic, BaseSchematicGizmoGroup)
def test_railing_schematic_bl_idname_preserved():
"""``OBJECT_GGT_bim_railing_edition`` is the user-facing identifier and
is referenced by keymaps and persistence. Preserve it across the class
rename — see the migration note in the class docstring."""
assert GizmoRailingSchematic.bl_idname == "OBJECT_GGT_bim_railing_edition"
def test_railing_schematic_props_getter_pairing():
"""``gizmo_pref_name = "railing"`` and ``props_getter = tool.Model.get_railing_props``
are the pairing test_parametric_registry depends on. If either drifts,
the addon-preferences gizmo toggle silently stops controlling this group."""
assert GizmoRailingSchematic.gizmo_pref_name == "railing"
assert GizmoRailingSchematic.props_getter == tool.Model.get_railing_props
def test_railing_schematic_disables_in_place_dimension_props():
"""The schematic owns the value-input surface — no in-place dimensions on the actual geometry."""
assert GizmoRailingSchematic.dimension_gizmo_props == []
# ── Dimension configuration ─────────────────────────────────────────────────
def test_railing_schematic_has_six_dimensions():
"""One dimension per parametric property — three for each railing_type."""
assert len(GizmoRailingSchematic.schematic_dimension_props) == 6
def test_railing_schematic_dimension_attr_names_complete():
"""The six bound attributes match the parametric properties that
``update_railing_modifier_bmesh`` reads when regenerating the live preview."""
attr_names = {c.attr_name for c in GizmoRailingSchematic.schematic_dimension_props}
assert attr_names == {
"height",
"thickness",
"spacing",
"railing_diameter",
"clear_width",
"support_spacing",
}
def test_railing_schematic_dimensions_are_dimension_configs():
"""The dimension-line aesthetic depends on ``DimensionGizmoConfig`` (with
arrows + label), not the abstract slider widget."""
for config in GizmoRailingSchematic.schematic_dimension_props:
assert isinstance(config, DimensionGizmoConfig)
def test_railing_schematic_dimensions_have_text_formatters():
"""Each dimension must format the label from the actual property value,
not from the visually-scaled value the gizmo's getter returns. Without a
formatter the label would show the schematic-scaled length, which is
meaningless to the user."""
for config in GizmoRailingSchematic.schematic_dimension_props:
assert config.text_formatter is not None, f"{config.attr_name} missing text_formatter"
@pytest.mark.parametrize(
"attr_name,railing_type,expected",
[
("height", "FRAMELESS_PANEL", True),
("height", "WALL_MOUNTED_HANDRAIL", False),
("thickness", "FRAMELESS_PANEL", True),
("spacing", "FRAMELESS_PANEL", True),
("railing_diameter", "WALL_MOUNTED_HANDRAIL", True),
("railing_diameter", "FRAMELESS_PANEL", False),
("clear_width", "WALL_MOUNTED_HANDRAIL", True),
],
)
def test_railing_schematic_dimension_visibility_gated_by_railing_type(attr_name, railing_type, expected):
"""The two railing types are mutually exclusive — height/thickness/spacing
belong to FRAMELESS_PANEL; railing_diameter/clear_width/support_spacing
belong to WALL_MOUNTED_HANDRAIL. The visibility lambdas enforce that."""
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == attr_name)
props = SimpleNamespace(railing_type=railing_type, use_manual_supports=False)
assert config.visibility_condition(props) is expected
def test_railing_schematic_support_spacing_hidden_for_manual_supports():
"""``support_spacing`` only drives auto-positioned supports — when the
user has switched to manual supports the dimension should disappear."""
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == "support_spacing")
auto = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=False)
manual = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=True)
assert config.visibility_condition(auto) is True
assert config.visibility_condition(manual) is False
# ── Fixed-length tag rendering ─────────────────────────────────────────────
def test_schematic_dim_visible_length_is_constant():
"""Every schematic dimension tag renders at the same width — the bar is a
UI affordance, not a proportional measurement. The constant ratio keeps
tiny (5 mm thickness) and huge (5 m height) values equally clickable; the
real value lives in the dimension label.
Regression guard: if value-proportional scaling is reintroduced, this
contract breaks silently — small dimensions start collapsing into stacked
arrows again.
"""
cls = GizmoRailingSchematic
ratio = cls.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO
assert ratio > 0
assert ratio <= 1.0 # bar must fit within the schematic box
def test_schematic_no_compute_schematic_scale_override():
"""The constant-length design has no need for a scale factor. If a
subclass redefines ``_compute_schematic_scale``, it indicates the
scale-based proportional sizing was reintroduced — which is the design
we deliberately stepped away from."""
assert "_compute_schematic_scale" not in GizmoRailingSchematic.__dict__
# ── Path-edit guard ─────────────────────────────────────────────────────────
def test_update_editing_gizmos_override_defined_on_subclass():
"""``GizmoRailingSchematic`` must own the override that hides the pen
icon during path-edit. The parent's version shows the pen whenever
``is_editing`` is False, which includes path-edit; that would let the
user open two editing modes at once."""
assert "update_editing_gizmos" in GizmoRailingSchematic.__dict__
# ── Schematic mesh building ─────────────────────────────────────────────────
def test_build_schematic_mesh_frameless_panel_returns_bmesh_with_edges():
"""FRAMELESS_PANEL renders as two separated wireframe boxes — 8 corners
per box × 2 = 16 verts; 12 edges per box × 2 = 24 edges. The visible
gap between the two boxes is the "spacing" semantic made literal.
The mesh proportions are fixed (independent of property values) so the
dimension gizmos can anchor to known feature positions; the property
values are shown through dimension labels, not the mesh size."""
props = SimpleNamespace(
railing_type="FRAMELESS_PANEL",
height=1.0,
thickness=0.05,
spacing=0.5,
)
bm = GizmoRailingSchematic.build_schematic_mesh(props)
try:
assert isinstance(bm, bmesh.types.BMesh)
assert len(bm.verts) == 16
assert len(bm.edges) == 24
finally:
bm.free()
def test_build_schematic_mesh_wall_mounted_handrail_returns_bmesh_with_edges():
"""WALL_MOUNTED_HANDRAIL renders as three visual elements:
- **Wall outline** — 4 corner verts, 4 edges (rectangle at z=0).
- **Hex tube** — 12 verts (6 per ring × 2 ends), 18 edges
(6 left ring + 6 right ring + 6 axial).
- **L-brackets** at each rail end — 3 verts per bracket (rail centre,
corner, wall attach) × 2 brackets = 6 verts; 2 edges per bracket
(rail→corner, corner→wall) × 2 = 4 edges.
Total: 22 verts, 26 edges.
"""
props = SimpleNamespace(
railing_type="WALL_MOUNTED_HANDRAIL",
railing_diameter=0.05,
clear_width=0.04,
support_spacing=1.0,
)
bm = GizmoRailingSchematic.build_schematic_mesh(props)
try:
assert isinstance(bm, bmesh.types.BMesh)
assert len(bm.verts) == 22
assert len(bm.edges) == 26
finally:
bm.free()
def test_build_schematic_mesh_proportions_independent_of_props():
"""The mesh uses fixed proportions so dimension gizmo anchor points stay
aligned with the geometry — extreme prop ratios don't change the mesh."""
small = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=0.01, thickness=0.005, spacing=0.05)
large = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=10.0, thickness=0.5, spacing=2.0)
bm_small = GizmoRailingSchematic.build_schematic_mesh(small)
bm_large = GizmoRailingSchematic.build_schematic_mesh(large)
try:
# Same vert count regardless of prop magnitude.
assert len(bm_small.verts) == len(bm_large.verts)
# Same bounding box in each axis (within floating-point noise).
for axis in range(3):
small_coords = [v.co[axis] for v in bm_small.verts]
large_coords = [v.co[axis] for v in bm_large.verts]
assert min(small_coords) == pytest.approx(min(large_coords))
assert max(small_coords) == pytest.approx(max(large_coords))
finally:
bm_small.free()
bm_large.free()
def test_build_schematic_mesh_panel_top_matches_height_frac():
"""The panel's top edge sits at exactly ``SCHEMATIC_MESH_HEIGHT_FRAC``,
which is also where the ``thickness`` dimension anchors above the box.
If this drifts, the dimension labels float disconnected from the mesh."""
props = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=1.0, thickness=0.05, spacing=0.3)
bm = GizmoRailingSchematic.build_schematic_mesh(props)
try:
max_y = max(v.co.y for v in bm.verts)
assert max_y == pytest.approx(GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC)
finally:
bm.free()