Migrate railing terminal type to PickType menu

Switches the IfcRailingType terminal-type selector from cycle-on-click
to a popup menu of all terminal-type literals — 5+ values trip the
§2.8 menu-pick threshold. Updates classes registration; removes
EditRailingTerminalType in favour of PickRailingTerminalType which
inherits PickTypeMixin.

Adapts the cherry-pick from db016d881 to post-PR5 framework state:
- Imports CycleTypeMixin / PickTypeMixin / PathPreservingEditMixin
  from bim.parametric_lifecycle (PR5 moved them off gizmos.py).
- Routes is_railing through tool.Parametric (predicates moved off
  tool.Blender.Modifier between PR3-PR5).

Skips the parametric_lifecycle.py framework refactor the source
commit shipped — HEAD has the more-evolved post-PR5 framework that
already covers it.

Adds the _FakePropsBase + make_lifecycle_obj test helpers to
test/bim/conftest.py so the new test_railing_lifecycle.py can
exercise the edit triad without a real bpy.types.Object. Brings the
test_railing_schematic.py marker in line with the rest of the model
lane.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-11 20:49:27 +02:00
parent d1d1e1d4a2
commit 05c9df74f9
5 changed files with 172 additions and 176 deletions
@@ -247,8 +247,8 @@ classes = (
railing.AddRailing, railing.AddRailing,
railing.CancelEditingRailing, railing.CancelEditingRailing,
railing.CycleRailingType, railing.CycleRailingType,
railing.EditRailingTerminalType,
railing.FinishEditingRailing, railing.FinishEditingRailing,
railing.PickRailingTerminalType,
railing.FlipRailingPathOrder, railing.FlipRailingPathOrder,
railing.EnableEditingRailing, railing.EnableEditingRailing,
railing.GizmoRailingSchematic, railing.GizmoRailingSchematic,
+45 -108
View File
@@ -19,7 +19,7 @@
import json import json
import math import math
from typing import Any, get_args from typing import Any
import bmesh import bmesh
import bpy import bpy
@@ -38,7 +38,11 @@ from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model import prop from bonsai.bim.module.model import prop
from bonsai.bim.module.model.data import RailingData, refresh from bonsai.bim.module.model.data import RailingData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin from bonsai.bim.parametric_lifecycle import (
CycleTypeMixin,
PathPreservingEditMixin,
PickTypeMixin,
)
from bonsai.tool.cad import WELD_TOLERANCE from bonsai.tool.cad import WELD_TOLERANCE
V_ = tool.Blender.V_ V_ = tool.Blender.V_
@@ -497,53 +501,9 @@ class _RailingEditMixin(PathPreservingEditMixin):
update_railing_modifier_ifc_data(context) update_railing_modifier_ifc_data(context)
@classmethod @classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_bmesh(context) """WALL_MOUNTED_HANDRAIL reloads the committed Body; others rebuild the preview bmesh."""
props = tool.Model.get_railing_props(obj)
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Skip the IFC commit when the draft matches the stored pset (no-op edit)."""
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
stored = pset_data["data_dict"]
path_data = stored["path_data"]
draft = props.get_general_kwargs(convert_to_project_units=True)
draft["path_data"] = path_data
if draft == stored:
props.is_editing = False
return
cls._update_pset(element, draft)
cls._update_modifier_ifc_data(obj, context)
props.is_editing = False
@classmethod
def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""WALL_MOUNTED_HANDRAIL switches the representation back to Body on cancel
(the cylinder preview is lower-poly than the committed swept-disk solid).
Skip the switch when the draft matches the stored pset."""
resolved = cls._resolve(obj)
if resolved is None:
return
_element, props = resolved
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
stored = pset_data["data_dict"]
draft = props.get_general_kwargs(convert_to_project_units=True)
draft["path_data"] = stored["path_data"]
nothing_changed = draft == stored
data = cls._post_load_data(stored)
props.set_props_kwargs_from_ifc_data(data)
if nothing_changed:
props.is_editing = False
return
if props.railing_type == "WALL_MOUNTED_HANDRAIL": if props.railing_type == "WALL_MOUNTED_HANDRAIL":
element = tool.Ifc.get_entity(obj) element = tool.Ifc.get_entity(obj)
assert element assert element
@@ -555,10 +515,8 @@ class _RailingEditMixin(PathPreservingEditMixin):
obj=obj, obj=obj,
representation=body, representation=body,
) )
else: return
cls._update_modifier_bmesh(obj, context) update_railing_modifier_bmesh(context)
props.is_editing = False
class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator): class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
@@ -588,14 +546,14 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera
return self._finish_targets(context) return self._finish_targets(context)
class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin): class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
"""Cycle railing_type (FRAMELESS_PANEL ↔ WALL_MOUNTED_HANDRAIL). Shift+click reverses.""" """Cycle railing_type (FRAMELESS_PANEL ↔ WALL_MOUNTED_HANDRAIL). Shift+click reverses."""
bl_idname = "bim.cycle_railing_type" bl_idname = "bim.cycle_railing_type"
bl_label = "Cycle Railing Type" bl_label = "Cycle Railing Type"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Blender.Modifier.is_railing element_checker = tool.Parametric.is_railing
props_getter = tool.Model.get_railing_props props_getter = tool.Model.get_railing_props
type_literal = tool.Model.RailingType type_literal = tool.Model.RailingType
type_attr = "railing_type" type_attr = "railing_type"
@@ -616,58 +574,42 @@ class ToggleRailingUseManualSupports(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
def execute(self, context): def execute(self, context):
obj = context.active_object resolved = tool.Model.resolve_active_props_for_edit(
if not obj: context,
return {"CANCELLED"} tool.Model.get_railing_props,
props = tool.Model.get_railing_props(obj) subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
if not props.is_editing or props.railing_type != "WALL_MOUNTED_HANDRAIL": )
if resolved is None:
return {"CANCELLED"} return {"CANCELLED"}
_obj, props = resolved
props.use_manual_supports = not props.use_manual_supports props.use_manual_supports = not props.use_manual_supports
return {"FINISHED"} return {"FINISHED"}
class EditRailingTerminalType(bpy.types.Operator): class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
"""Popup menu for terminal_type; writes the picked value via a HIDDEN string property.""" """Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing."""
bl_idname = "bim.edit_railing_terminal_type" bl_idname = "bim.pick_railing_terminal_type"
bl_label = "Choose Railing Terminal Type" bl_label = "Pick Railing Terminal Type"
bl_description = "Pick the cap geometry applied at the rail ends" bl_description = "Pick the cap geometry applied at the rail ends"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
terminal_type: bpy.props.StringProperty(name="Terminal Type", default="", options={"HIDDEN", "SKIP_SAVE"}) skip_element_check = True
props_getter = tool.Model.get_railing_props
type_literal = prop.CapType
type_attr = "terminal_type"
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object if (
if not obj: tool.Model.resolve_active_props_for_edit(
context,
tool.Model.get_railing_props,
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
)
is None
):
return {"CANCELLED"} return {"CANCELLED"}
props = tool.Model.get_railing_props(obj) return self._pick_type(context)
if not props.is_editing or props.railing_type != "WALL_MOUNTED_HANDRAIL":
return {"CANCELLED"}
choices = [v for v in get_args(prop.CapType)]
def draw(menu_self, _menu_context):
layout = menu_self.layout
for v in choices:
op = layout.operator(self.bl_idname, text=v)
op.terminal_type = v
context.window_manager.popup_menu(draw, title="Terminal Type", icon="MOD_LATTICE")
return {"FINISHED"}
def execute(self, context: bpy.types.Context) -> set[str]:
# Re-open the popup if called without a value (e.g. from the command palette).
if not self.terminal_type:
return self.invoke(context, None) # type: ignore[arg-type]
obj = context.active_object
if not obj:
return {"CANCELLED"}
props = tool.Model.get_railing_props(obj)
if self.terminal_type not in get_args(prop.CapType):
self.report({"ERROR"}, f"Unknown terminal_type: {self.terminal_type!r}")
return {"CANCELLED"}
props.terminal_type = self.terminal_type # type: ignore[assignment]
return {"FINISHED"}
def _format_attr_distance(attr_name: str): def _format_attr_distance(attr_name: str):
@@ -822,7 +764,7 @@ class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup)
@classmethod @classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Blender.Modifier.is_railing(element) return tool.Parametric.is_railing(element)
@classmethod @classmethod
def schematic_cache_key(cls, props) -> tuple: def schematic_cache_key(cls, props) -> tuple:
@@ -846,22 +788,17 @@ class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup)
""" """
default_color, highlight_color = self.get_decoration_colors() default_color, highlight_color = self.get_decoration_colors()
for slot in ("lock_open_gizmo", "lock_closed_gizmo"): self.lock_open_gizmo, self.lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
bl_idname = "VIEW3D_GT_lock_open" if slot == "lock_open_gizmo" else "VIEW3D_GT_lock_closed" "bim.toggle_railing_use_manual_supports",
gz = self.gizmos.new(bl_idname) open_color=default_color,
gz.color = default_color )
gz.color_highlight = highlight_color
gz.use_draw_scale = False
gz.alpha = 0.8
gz.target_set_operator("bim.toggle_railing_use_manual_supports")
setattr(self, slot, gz)
self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_cycle") self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_menu")
self.terminal_gizmo.color = default_color self.terminal_gizmo.color = default_color
self.terminal_gizmo.color_highlight = highlight_color self.terminal_gizmo.color_highlight = highlight_color
self.terminal_gizmo.use_draw_scale = False self.terminal_gizmo.use_draw_scale = False
self.terminal_gizmo.alpha = 0.8 self.terminal_gizmo.alpha = 0.8
self.terminal_gizmo.target_set_operator("bim.edit_railing_terminal_type") self.terminal_gizmo.target_set_operator("bim.pick_railing_terminal_type")
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None: def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
"""Position and gate the WALL_MOUNTED_HANDRAIL-only gizmos. """Position and gate the WALL_MOUNTED_HANDRAIL-only gizmos.
+41
View File
@@ -1,5 +1,46 @@
import pytest import pytest
class _FakePropsBase:
"""Base for parametric-edit PropertyGroup stand-ins used in lifecycle tests.
The parametric-edit lifecycle mixins read/write a common contract:
``is_editing`` (bool), ``last_kwargs`` (dict | None — capture of the last
data written via ``set_props_kwargs_from_ifc_data``),
``set_props_kwargs_from_ifc_data(data)``, and
``get_general_kwargs(convert_to_project_units=True)``. Per-type stand-ins
(door, railing, roof) subclass this and add their own kwargs accessors
and per-type fields."""
def __init__(self, general: dict | None = None):
self.is_editing = False
self.last_kwargs: dict | None = None
self.general = dict(general) if general is not None else {}
def set_props_kwargs_from_ifc_data(self, data):
self.last_kwargs = dict(data)
def get_general_kwargs(self, convert_to_project_units=True):
return dict(self.general)
def make_lifecycle_obj(props, *, name="obj"):
"""Build a ``bpy.types.Object`` stand-in for parametric-lifecycle tests.
The mixin code under test reads ``obj.props`` (the PropertyGroup
stand-in) and ``obj.name`` (used in error reports). ``spec=bpy.types.Object``
catches typo'd attribute access at test time. ``bpy`` is imported inside
the function so this conftest stays importable when bpy is absent."""
from unittest import mock
import bpy
obj = mock.Mock(spec=bpy.types.Object, name=name)
obj.props = props
obj.name = name
return obj
# pytest by default doesn't print steps and where it failed. Let's fix that. # pytest by default doesn't print steps and where it failed. Let's fix that.
@@ -18,23 +18,24 @@
# #
# This file was generated with the assistance of an AI coding tool. # This file was generated with the assistance of an AI coding tool.
"""Unit coverage for the ``_RailingEditMixin`` lifecycle overrides. """Unit coverage for the ``_RailingEditMixin`` overrides and the lifecycle
behaviour railing inherits from ``PathPreservingEditMixin``.
The generic ``PathPreservingEditMixin`` lifecycle is tested in The parent short-circuit (skip the IFC commit / viewport rebuild when the
``test_parametric_lifecycle.py``. This file pins the **railing-specific draft is identical to the stored pset) lives in
overrides** that subclass it: ``PathPreservingEditMixin``; the tests below verify railing's subclass
honours that contract by inheritance, then pin the railing-specific
viewport-restore dispatch:
- ``_RailingEditMixin._finish_one`` short-circuit: when the draft equals - Finish / Cancel no-op short-circuit: inherited from the parent — verified
the stored pset, ``_update_pset`` and ``_update_modifier_ifc_data`` are here because railing was the original consumer that motivated the
skipped so an Enable → Finish-without-changes cycle creates no new optimisation.
``IfcShapeRepresentation``. - ``_RailingEditMixin._restore_viewport_after_cancel`` dispatch: WALL_MOUNTED_HANDRAIL
- ``_RailingEditMixin._cancel_one`` short-circuit: same logic guards the reloads the high-poly Body representation via ``switch_representation``;
expensive ``bonsai.core.geometry.switch_representation`` call (which FRAMELESS_PANEL rebuilds the bmesh preview via
re-tessellates the swept-disk solid) when nothing actually changed. ``update_railing_modifier_bmesh``. This is the per-type branch that used
- ``_RailingEditMixin._cancel_one`` WALL_MOUNTED_HANDRAIL branch: when to live in ``_cancel_one`` and now lives in the viewport-restore hook the
changes WERE made, the cancel reloads the IFC body via parent's ``_cancel_one`` calls.
``switch_representation`` instead of running ``update_modifier_bmesh``
(which would leave the low-poly cylinder-segment preview on screen).
""" """
from unittest import mock from unittest import mock
@@ -68,10 +69,16 @@ class _FakeRailingProps(_FakePropsBase):
def patched_railing(): def patched_railing():
"""Patch the railing module's external references for unit testing. """Patch the railing module's external references for unit testing.
``_RailingEditMixin`` calls ``tool.Model.get_modeling_bbim_pset_data``, ``_RailingEditMixin`` and the parent lifecycle reach for
``tool.Ifc.get_entity``, ``ifcopenshell.util.representation.get_representation``, ``tool.Model.get_modeling_bbim_pset_data``, ``tool.Ifc.get_entity``,
and ``bonsai.core.geometry.switch_representation`` — each looked up ``ifcopenshell.util.representation.get_representation``,
through the railing module's own bindings, so we patch them there. ``bonsai.core.geometry.switch_representation``, and the module-level
``update_railing_modifier_bmesh`` — each looked up through the railing
module's own bindings, so we patch them there.
``parametric_lifecycle.tool`` is patched separately so the parent's
``_resolve`` and ``_cancel_one`` can read ``tool.Model.get_modeling_bbim_pset_data``
without falling through to the real Blender bindings.
Uses ``mock.patch.object`` with a direct module reference rather than Uses ``mock.patch.object`` with a direct module reference rather than
the dotted-string form: ``mock.patch("bonsai.bim.module.model.railing.bonsai")`` the dotted-string form: ``mock.patch("bonsai.bim.module.model.railing.bonsai")``
@@ -81,17 +88,28 @@ def patched_railing():
Returns a dict for tests to seed return values and assert call sites. Returns a dict for tests to seed return values and assert call sites.
""" """
from bonsai.bim import parametric_lifecycle
from bonsai.bim.module.model import railing from bonsai.bim.module.model import railing
with ( with (
mock.patch.object(railing, "tool") as mock_tool, mock.patch.object(railing, "tool") as mock_tool,
mock.patch.object(railing, "ifcopenshell") as mock_ifc, mock.patch.object(railing, "ifcopenshell") as mock_ifc,
mock.patch.object(railing, "bonsai") as mock_bonsai, mock.patch.object(railing, "bonsai") as mock_bonsai,
mock.patch.object(railing, "update_railing_modifier_bmesh") as mock_update_bmesh,
mock.patch.object(parametric_lifecycle, "tool") as mock_pl_tool,
): ):
# _resolve will be overridden on the test subclass below so the # _resolve will be overridden on the test subclass below so the
# parametric_lifecycle.tool patch isn't needed. # parametric_lifecycle.tool patch isn't needed for that path, but the
# parent's _cancel_one / _finish_one still call
# tool.Model.get_modeling_bbim_pset_data and would otherwise miss.
mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity") mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity")
yield {"tool": mock_tool, "ifcopenshell": mock_ifc, "bonsai": mock_bonsai} yield {
"tool": mock_tool,
"ifcopenshell": mock_ifc,
"bonsai": mock_bonsai,
"update_bmesh": mock_update_bmesh,
"pl_tool": mock_pl_tool,
}
def _railing_test_subclass(props): def _railing_test_subclass(props):
@@ -108,7 +126,7 @@ def _railing_test_subclass(props):
class _TestRailingMixin(_RailingEditMixin): class _TestRailingMixin(_RailingEditMixin):
pset_updates: mock.MagicMock = mock.MagicMock(name="_update_pset") pset_updates: mock.MagicMock = mock.MagicMock(name="_update_pset")
ifc_data_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_ifc_data") ifc_data_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_ifc_data")
bmesh_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_bmesh") bmesh_updates: mock.MagicMock = mock.MagicMock(name="_restore_viewport_after_cancel")
@classmethod @classmethod
def _resolve(cls, obj): def _resolve(cls, obj):
@@ -123,7 +141,7 @@ def _railing_test_subclass(props):
cls.ifc_data_updates(obj, context) cls.ifc_data_updates(obj, context)
@classmethod @classmethod
def _update_modifier_bmesh(cls, obj, context): def _restore_viewport_after_cancel(cls, obj, context):
cls.bmesh_updates(obj, context) cls.bmesh_updates(obj, context)
# The base _post_load_data JSON-serialises path_data; bypass that # The base _post_load_data JSON-serialises path_data; bypass that
@@ -148,11 +166,14 @@ def test_finish_one_short_circuits_when_draft_matches_stored(patched_railing):
would create a fresh ``IfcShapeRepresentation``, pollute the file's would create a fresh ``IfcShapeRepresentation``, pollute the file's
representation list, and burn an undo entry — the user-visible representation list, and burn an undo entry — the user-visible
regression that motivated the short-circuit. regression that motivated the short-circuit.
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
the coverage as the original consumer of the contract.
""" """
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
props = _FakeRailingProps(general=dict(stored)) props = _FakeRailingProps(general=dict(stored))
obj = _make_obj(props) obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = { patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, "data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
} }
@@ -171,7 +192,7 @@ def test_finish_one_writes_when_draft_differs(patched_railing):
# Draft height differs: simulating a user edit. # Draft height differs: simulating a user edit.
props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5}) props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5})
obj = _make_obj(props) obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = { patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, "data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
} }
@@ -198,11 +219,14 @@ def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing):
visibly slow on a long handrail. When nothing changed, the mesh on visibly slow on a long handrail. When nothing changed, the mesh on
screen is still the committed IFC representation (the preview only screen is still the committed IFC representation (the preview only
builds on a property change) — skip the reload entirely. builds on a property change) — skip the reload entirely.
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
the coverage as the original consumer of the contract.
""" """
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
props = _FakeRailingProps(general=dict(stored)) props = _FakeRailingProps(general=dict(stored))
obj = _make_obj(props) obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = { patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, "data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
} }
@@ -211,60 +235,56 @@ def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing):
assert props.is_editing is False assert props.is_editing is False
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called() patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
patched_railing["update_bmesh"].assert_not_called()
cls.bmesh_updates.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 # _RailingEditMixin._restore_viewport_after_cancel — per-type viewport-restore dispatch
committed Body representation (high-poly, IFC-derived) rather than #
re-running the low-poly bmesh preview — that preview is a viewport-only # The parent's _cancel_one calls cls._restore_viewport_after_cancel whenever
approximation and would persist visibly after Cancel without this. # the draft differs from the stored pset. Railing's override branches on
""" # railing_type so WALL_MOUNTED_HANDRAIL reloads the high-poly Body
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0} # representation rather than rebuilding the low-poly cylinder-segment preview.
# 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}, def test_restore_viewport_wall_mounted_handrail_switches_representation(patched_railing):
) """WALL_MOUNTED_HANDRAIL restore must call ``switch_representation`` with
the Body representation — the preview is viewport-only (low-poly cylinder)
and would persist visibly without the reload."""
from bonsai.bim.module.model.railing import _RailingEditMixin
props = _FakeRailingProps(railing_type="WALL_MOUNTED_HANDRAIL")
obj = _make_obj(props) obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = { patched_railing["tool"].Model.get_railing_props.return_value = props
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
}
body_repr = mock.Mock(name="body_representation") body_repr = mock.Mock(name="body_representation")
patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr
cls, _element = _railing_test_subclass(props) _RailingEditMixin._restore_viewport_after_cancel(obj, mock.Mock(name="context"))
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() patched_railing["bonsai"].core.geometry.switch_representation.assert_called_once()
kwargs = patched_railing["bonsai"].core.geometry.switch_representation.call_args.kwargs kwargs = patched_railing["bonsai"].core.geometry.switch_representation.call_args.kwargs
assert kwargs["obj"] is obj assert kwargs["obj"] is obj
assert kwargs["representation"] is body_repr assert kwargs["representation"] is body_repr
cls.bmesh_updates.assert_not_called() # Must NOT fall through to the FRAMELESS bmesh-rebuild path.
patched_railing["update_bmesh"].assert_not_called()
def test_cancel_one_frameless_panel_runs_bmesh_preview(patched_railing): def test_restore_viewport_frameless_panel_calls_module_bmesh_rebuild(patched_railing):
"""FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC """FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC
swept-disk solid to reload. Cancel must run the bmesh rebuild instead swept-disk solid to reload. The restore must delegate to the module-level
of switch_representation, which would no-op or worse.""" ``update_railing_modifier_bmesh`` rebuilder rather than swap representations."""
stored = {"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.05} from bonsai.bim.module.model.railing import _RailingEditMixin
props = _FakeRailingProps(
railing_type="FRAMELESS_PANEL", props = _FakeRailingProps(railing_type="FRAMELESS_PANEL")
general={"railing_type": "FRAMELESS_PANEL", "height": 1.0, "thickness": 0.08},
)
obj = _make_obj(props) obj = _make_obj(props)
patched_railing["tool"].Model.get_modeling_bbim_pset_data.return_value = { patched_railing["tool"].Model.get_railing_props.return_value = props
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}}, ctx = mock.Mock(name="context")
}
cls, _element = _railing_test_subclass(props) _RailingEditMixin._restore_viewport_after_cancel(obj, ctx)
cls._cancel_one(obj, mock.Mock(name="context"))
assert props.is_editing is False patched_railing["update_bmesh"].assert_called_once_with(ctx)
cls.bmesh_updates.assert_called_once_with(obj, mock.ANY)
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called() patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
@@ -32,7 +32,7 @@ from bonsai.bim.module.drawing.gizmos import (
) )
from bonsai.bim.module.model.railing import GizmoRailingSchematic from bonsai.bim.module.model.railing import GizmoRailingSchematic
pytestmark = pytest.mark.railing pytestmark = pytest.mark.model
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -167,10 +167,8 @@ def test_schematic_dim_visible_length_is_constant():
def test_schematic_no_compute_schematic_scale_override(): def test_schematic_no_compute_schematic_scale_override():
"""The constant-length design has no need for a scale factor. If a """The constant-length schematic must not reintroduce scale-based
subclass redefines ``_compute_schematic_scale``, it indicates the proportional sizing via a ``_compute_schematic_scale`` override."""
scale-based proportional sizing was reintroduced — which is the design
we deliberately stepped away from."""
assert "_compute_schematic_scale" not in GizmoRailingSchematic.__dict__ assert "_compute_schematic_scale" not in GizmoRailingSchematic.__dict__