mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-22 22:12:32 +00:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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__
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user