Add MEP pipe / duct segment edit gizmos

Pipe and duct segments had no parametric-edit affordance — the only
length edit path was a property panel value with no live preview.
This commit ports the per-segment parametric edit triad
(enable / finish / cancel) plus a cursor-anchored extend operator
and a cursor-projected split operator into one gizmo group per
segment type. The two PropertyGroups (BIMPipeSegmentProperties,
BIMDuctSegmentProperties) host the draft length plus snap fields
so cancel / no-op-finish restore the segment to its exact pre-edit
visual state including a non-identity pre-edit scale. Length
commits are written through DumbProfileJoiner.set_depth and
auto-dispatch bim.regenerate_distribution_element so adjacent
fittings track the port move. The split operator preserves
downstream port connectivity and runs through tool.Ifc.run for
single-step undo. The two segment types are now first-class
entries in tool.Parametric.EDIT_TYPES, which resolves the FIXME
on auto-commit-on-save dispatch.

35 unit tests cover predicate truth tables, segment_world_length
geometry, preview-via-scale / restore-scale helpers, gizmo class
wiring, lifecycle operator registration, dimension matrix_position
rotation respect, and lifecycle drift-handling. The 6 extend-
preview-line decorator tests stay deferred until the bend preview
decorator commit lands MEPSegmentExtendPreviewDecorator.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-08 15:01:03 +02:00
committed by Thomas Krijnen
parent db27d0ec0a
commit 8374dd6d46
7 changed files with 1085 additions and 4 deletions
@@ -184,6 +184,8 @@ classes = (
prop.BIMRailingProperties, prop.BIMRailingProperties,
prop.BIMRoofProperties, prop.BIMRoofProperties,
prop.BIMWallProperties, prop.BIMWallProperties,
prop.BIMPipeSegmentProperties,
prop.BIMDuctSegmentProperties,
prop.BIMPolylineProperties, prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties, prop.BIMExternalParametricGeometryProperties,
prop.BIMBendPreviewProperties, prop.BIMBendPreviewProperties,
@@ -267,6 +269,18 @@ classes = (
mep.EnableBendPreview, mep.EnableBendPreview,
mep.FinishBendPreview, mep.FinishBendPreview,
mep.CancelBendPreview, mep.CancelBendPreview,
mep.EnableEditingPipeSegment,
mep.FinishEditingPipeSegment,
mep.CancelEditingPipeSegment,
mep.EnableEditingDuctSegment,
mep.FinishEditingDuctSegment,
mep.CancelEditingDuctSegment,
mep.ExtendPipeSegmentToCursor,
mep.ExtendDuctSegmentToCursor,
mep.SplitPipeSegmentAtCursor,
mep.SplitDuctSegmentAtCursor,
mep.GizmoPipeSegmentEdition,
mep.GizmoDuctSegmentEdition,
external.ApplyExternalParametricGeometry, external.ApplyExternalParametricGeometry,
) )
+527
View File
@@ -19,8 +19,10 @@
import collections.abc import collections.abc
import json import json
import re import re
import weakref
from copy import copy from copy import copy
from math import cos, degrees, pi, radians, sin, tan from math import cos, degrees, pi, radians, sin, tan
from typing import ClassVar
import bpy import bpy
import ifcopenshell.api.geometry import ifcopenshell.api.geometry
@@ -38,8 +40,11 @@ from mathutils import Matrix, Vector
import bonsai.core.root import bonsai.core.root
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model import preview_base from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.profile import DumbProfileJoiner from bonsai.bim.module.model.profile import DumbProfileJoiner
from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase
from bonsai.tool.cad import VTX_PRECISION from bonsai.tool.cad import VTX_PRECISION
V = lambda *x: Vector([float(i) for i in x]) V = lambda *x: Vector([float(i) for i in x])
@@ -1393,3 +1398,525 @@ class CancelBendPreview(bpy.types.Operator):
return {"CANCELLED"} return {"CANCELLED"}
preview_base.clear_preview_state(props) preview_base.clear_preview_state(props)
return {"FINISHED"} return {"FINISHED"}
# --- MEP segment parametric edit + cursor-anchored operators ---------------
def _segment_world_length(obj: bpy.types.Object) -> float:
"""World-space length of an MEP segment's extrusion axis."""
start, end = tool.Model.get_flow_segment_axis(obj)
return (end - start).length
def _preview_segment_via_scale(
obj: bpy.types.Object,
props_length: float,
snap_length: float,
snap_object_scale_z: float,
) -> None:
"""Scale obj along local Z so the visible segment matches ``props_length``
without touching IFC.
Composes correctly with a non-identity pre-edit ``obj.scale.z``: the
mesh's local-Z extent is ``snap_length / snap_object_scale_z``, so the
new scale.z is ``props_length / mesh_local_length``."""
if snap_length < 1e-6 or snap_object_scale_z < 1e-6:
return
mesh_local_length = snap_length / snap_object_scale_z
obj.scale.z = max(props_length, 0.01) / mesh_local_length
def _restore_segment_scale_to(obj: bpy.types.Object, scale_z: float) -> None:
"""Restore obj's local-Z scale. Cancel passes the pre-edit
``snap_object_scale_z``; finish passes ``1.0`` because ``set_depth`` has
already rebuilt the mesh 1:1 with the new IFC length."""
obj.scale.z = scale_z
def regenerate_pipe_segment_mesh_from_props(obj: bpy.types.Object) -> None:
"""Live-preview hook for ``BIMPipeSegmentProperties.length`` drags."""
props = tool.Model.get_pipe_segment_props(obj)
_preview_segment_via_scale(obj, props.length, props.snap_length, props.snap_object_scale_z)
props.mesh_dirty = True
def regenerate_duct_segment_mesh_from_props(obj: bpy.types.Object) -> None:
"""Live-preview hook for ``BIMDuctSegmentProperties.length`` drags."""
props = tool.Model.get_duct_segment_props(obj)
_preview_segment_via_scale(obj, props.length, props.snap_length, props.snap_object_scale_z)
props.mesh_dirty = True
def _restore_segment_mesh_if_dirty(props, obj: bpy.types.Object) -> None:
"""Restore obj's preview scale to the pre-edit value if dirty.
Restoring to ``snap_object_scale_z`` (not 1.0) avoids zeroing a user's
non-identity pre-edit scale."""
if not props.mesh_dirty:
return
_restore_segment_scale_to(obj, props.snap_object_scale_z)
props.mesh_dirty = False
class _MEPSegmentEditMixin(ParametricEditMixinBase):
"""MEP segment edit lifecycle (length-only).
Segment editing has no BBIM pset — the length lives in the IFC
extrusion depth and is rewritten by ``DumbProfileJoiner.set_depth``. The
``snap_object_scale_z`` field on the PropertyGroup records pre-edit
scale so Cancel and no-op Finish restore the segment exactly to its
pre-edit visual state. Finish dispatches ``bim.regenerate_distribution_element``
on length-change to re-align adjacent fittings."""
pset_name = "" # MEP segments carry no BBIM_<Type> pset.
@classmethod
def _enable_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
_element, props = resolved
# Commit any pre-edit matrix_world drift before snap_length is captured
# from _segment_world_length. Otherwise set_depth at Finish would write
# representation coords relative to a stale ObjectPlacement.
cls._handle_drift_on_enable(obj)
current_length = _segment_world_length(obj)
props.snap_object_scale_z = obj.scale.z
props.snap_length = current_length
props.length = current_length
props.mesh_dirty = False
props.is_editing = True
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> tuple[bool, bool]:
"""Returns ``(resolved, committed)``: ``resolved`` is False when the
target is no longer this MEP segment type; ``committed`` is True when
a length change was written through ``set_depth``."""
resolved = cls._resolve(obj)
if resolved is None:
return False, False
_element, props = resolved
committed = False
if props.length != props.snap_length:
# set_depth rebuilds the representation 1:1 with the new length, so
# reset scale to 1.0 or any preview stretch would double-apply.
DumbProfileJoiner().set_depth(obj, props.length)
_restore_segment_scale_to(obj, 1.0)
props.mesh_dirty = False
committed = True
else:
_restore_segment_mesh_if_dirty(props, obj)
cls._handle_drift_on_finish(obj)
props.is_editing = False
return True, committed
@classmethod
def _cancel_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
# Disable editing first so the length-restore below doesn't fire one
# more preview pass.
props.is_editing = False
props.length = props.snap_length
_restore_segment_mesh_if_dirty(props, obj)
cls._handle_drift_on_cancel(obj, element)
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
if obj is None:
return {"CANCELLED"}
# Resolve pre-flight to map a non-matching active object to CANCELLED
# rather than the silent no-op the per-target classmethod would produce.
resolved = self._resolve(obj)
if resolved is None:
return {"CANCELLED"}
self._enable_one(obj)
return {"FINISHED"}
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
if obj is None:
return {"CANCELLED"}
resolved_ok, committed = self._finish_one(obj, context)
if not resolved_ok:
return {"CANCELLED"}
if committed:
# Re-align adjacent fittings + segments to follow the port move;
# failure here doesn't roll back the length commit (primary intent).
try:
bpy.ops.bim.regenerate_distribution_element()
except Exception as e:
self.report({"WARNING"}, f"Length committed but auto-regenerate failed: {e}")
return {"FINISHED"}
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
if obj is None:
return {"CANCELLED"}
self._cancel_one(obj)
return {"FINISHED"}
class _PipeSegmentEditMixin(_MEPSegmentEditMixin):
@classmethod
def _is_element_type(cls, element):
return tool.Parametric.is_pipe_segment(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_pipe_segment_props(obj)
class _DuctSegmentEditMixin(_MEPSegmentEditMixin):
@classmethod
def _is_element_type(cls, element):
return tool.Parametric.is_duct_segment(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_duct_segment_props(obj)
EnableEditingPipeSegment, FinishEditingPipeSegment, CancelEditingPipeSegment = tool.Parametric.build_edit_lifecycle(
"pipe_segment",
_PipeSegmentEditMixin,
labels=(
("Edit Pipe Segment", ""),
("Apply Pipe Segment Edits", ""),
("Discard Pipe Segment Edits", ""),
),
module_name=__name__,
)
EnableEditingDuctSegment, FinishEditingDuctSegment, CancelEditingDuctSegment = tool.Parametric.build_edit_lifecycle(
"duct_segment",
_DuctSegmentEditMixin,
labels=(
("Edit Duct Segment", ""),
("Apply Duct Segment Edits", ""),
("Discard Duct Segment Edits", ""),
),
module_name=__name__,
)
def _project_cursor_to_segment_local_z(context, *, is_pipe: bool) -> tuple[bpy.types.Object | None, float | None]:
"""Validate the active object is an MEP segment of the requested kind,
commit any in-progress parametric edit, and return ``(obj, cursor_local_z)``.
Returns ``(None, None)`` on precondition failure — callers should treat
that as ``{"CANCELLED"}``."""
obj = context.active_object
if obj is None:
return None, None
element = tool.Ifc.get_entity(obj)
if element is None:
return None, None
predicate = tool.Parametric.is_pipe_segment if is_pipe else tool.Parametric.is_duct_segment
if not predicate(element):
return None, None
# Commit any in-progress edit first so the user's drag-state isn't
# silently discarded — cursor-anchored ops must layer on top of an
# in-progress edit, not overwrite it.
props = tool.Model.get_pipe_segment_props(obj) if is_pipe else tool.Model.get_duct_segment_props(obj)
if props.is_editing:
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
if is_pipe:
bpy.ops.bim.finish_editing_pipe_segment()
else:
bpy.ops.bim.finish_editing_duct_segment()
cursor_world = context.scene.cursor.location
cursor_local = obj.matrix_world.inverted() @ cursor_world
return obj, cursor_local.z
def _extend_segment_to_cursor(context, *, is_pipe: bool) -> set[str]:
"""Extend or trim the nearest endpoint of the segment to the cursor
projection."""
obj, _ = _project_cursor_to_segment_local_z(context, is_pipe=is_pipe)
if obj is None:
return {"CANCELLED"}
DumbProfileJoiner().join_E(obj, context.scene.cursor.location)
return {"FINISHED"}
class ExtendPipeSegmentToCursor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_pipe_segment_to_cursor"
bl_label = "Extend Pipe Segment to Cursor"
bl_description = (
"Extend or trim the active pipe segment so its nearest endpoint reaches the 3D cursor's projection "
"on the segment axis"
)
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
return _extend_segment_to_cursor(context, is_pipe=True)
class ExtendDuctSegmentToCursor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_duct_segment_to_cursor"
bl_label = "Extend Duct Segment to Cursor"
bl_description = (
"Extend or trim the active duct segment so its nearest endpoint reaches the 3D cursor's projection "
"on the segment axis"
)
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
return _extend_segment_to_cursor(context, is_pipe=False)
def split_mep_segment(obj: bpy.types.Object, cut_local_z: float) -> bpy.types.Object | None:
"""Split an MEP segment at ``cut_local_z`` along its local +Z axis,
producing two connected segments where there was one.
Snapshots the downstream end-port connection, duplicates the segment via
``bonsai.core.root.copy_class``, positions the new segment so its start
coincides with the original's new end, calls ``DumbProfileJoiner.set_depth``
on both halves, then reconnects ports: original-end ↔ new-start, and
if a downstream connection existed: new-end ↔ snapshotted downstream
with the preserved direction. Rejects splits within 0.01m of either
endpoint."""
from bonsai.tool.system import direction_from_port_pair
element = tool.Ifc.get_entity(obj)
if element is None or not tool.System.is_mep_element(element):
return None
start_world, end_world = tool.Model.get_flow_segment_axis(obj)
original_length = (end_world - start_world).length
if cut_local_z < 0.01 or cut_local_z > original_length - 0.01:
return None
segment_data = MEPGenerator().get_segment_data(element)
end_port = segment_data.get("end_port")
downstream_port = None
downstream_direction = "NOTDEFINED"
if end_port is not None:
downstream_port = tool.System.get_connected_port(end_port)
if downstream_port is not None:
downstream_direction = direction_from_port_pair(end_port, downstream_port)
new_obj = obj.copy()
if obj.data is not None:
new_obj.data = obj.data.copy()
for collection in obj.users_collection:
collection.objects.link(new_obj)
new_element = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
if new_element is None:
bpy.data.objects.remove(new_obj, do_unlink=True)
return None
local_z = obj.matrix_world.to_3x3() @ Vector((0.0, 0.0, 1.0))
local_z.normalize()
new_obj.matrix_world.translation = obj.matrix_world.translation + local_z * cut_local_z
joiner = DumbProfileJoiner()
joiner.set_depth(obj, cut_local_z)
joiner.set_depth(new_obj, original_length - cut_local_z)
gen = MEPGenerator()
seg1_data = gen.get_segment_data(element)
seg2_data = gen.get_segment_data(new_element)
seg1_end = seg1_data.get("end_port")
seg2_start = seg2_data.get("start_port")
seg2_end = seg2_data.get("end_port")
if seg1_end is not None and seg2_start is not None:
try:
tool.Ifc.run(
"system.connect_port",
port1=seg1_end,
port2=seg2_start,
direction="NOTDEFINED",
)
except Exception as e:
print(f"Bonsai: split_mep_segment failed to connect halves at cut: {e}")
if downstream_port is not None and seg2_end is not None:
try:
tool.Ifc.run(
"system.connect_port",
port1=seg2_end,
port2=downstream_port,
direction=downstream_direction,
)
except Exception as e:
print(f"Bonsai: split_mep_segment failed to restore downstream connection: {e}")
return new_obj
def _split_segment_at_cursor(operator, context, *, is_pipe: bool) -> set[str]:
"""Split the active MEP segment at the cursor's projection on its axis."""
obj, cursor_local_z = _project_cursor_to_segment_local_z(context, is_pipe=is_pipe)
if obj is None or cursor_local_z is None:
return {"CANCELLED"}
new_obj = split_mep_segment(obj, cursor_local_z)
if new_obj is None:
operator.report(
{"WARNING"},
"Split cancelled — cursor projection must lie between segment endpoints (>=0.01 m from each).",
)
return {"CANCELLED"}
return {"FINISHED"}
class SplitPipeSegmentAtCursor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.split_pipe_segment_at_cursor"
bl_label = "Split Pipe Segment at Cursor"
bl_description = (
"Split the active pipe segment at the 3D cursor's projection on the segment axis, "
"producing two connected segments"
)
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
return _split_segment_at_cursor(self, context, is_pipe=True)
class SplitDuctSegmentAtCursor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.split_duct_segment_at_cursor"
bl_label = "Split Duct Segment at Cursor"
bl_description = (
"Split the active duct segment at the 3D cursor's projection on the segment axis, "
"producing two connected segments"
)
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
return _split_segment_at_cursor(self, context, is_pipe=False)
class _MEPSegmentEditionMixin:
"""Shared element-specific scaffolding for the two MEP-segment gizmo
groups: an extend-to-cursor icon at the cursor's projection on the
segment axis plus a split icon stacked above it. Cursor-anchored, always
visible when the parametric gizmo group polls."""
_extend_operator: str = ""
_split_operator: str = ""
CURSOR_STACK_OFFSET: ClassVar[float] = 0.4
def setup_element_specific_gizmos(self, context):
default_color, highlight_color = self.get_decoration_colors()
self.extend_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_extend",
default_color,
self._extend_operator,
highlight_color,
)
warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences())
self.split_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_split",
default_color,
self._split_operator,
warning_color,
)
if context.region is not None:
type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self)
def _refresh_element_specific(self, context, mw, props):
if not hasattr(self, "extend_gizmo"):
return
cursor_world = context.scene.cursor.location
cursor_local = mw.inverted() @ cursor_world
projected_local = Vector((0.0, 0.0, cursor_local.z))
projected_world = mw @ projected_local
billboard_rot = self._frame_billboard_rot or gizmo.get_billboard_rotation(context)
gz = self.extend_gizmo
gz.hide = self.is_gizmo_hidden_by_modal(gz)
gz.matrix_basis = gizmo.billboarded_at(projected_world, billboard_rot)
if gizmo.should_flip_extend_arrow(projected_world, mw.translation, billboard_rot):
gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_X
if hasattr(self, "split_gizmo"):
split_gz = self.split_gizmo
obj = context.active_object
if obj is None or not obj.bound_box:
split_gz.hide = True
else:
# Endpoint-cut threshold matches split_mep_segment's rejection
# window so the icon never offers an invalid affordance.
current_length = max(c[2] for c in obj.bound_box)
in_range = 0.01 < cursor_local.z < (current_length - 0.01)
if not in_range or self.is_gizmo_hidden_by_modal(split_gz):
split_gz.hide = True
else:
split_gz.hide = False
offset_world = billboard_rot @ Vector((0.0, self.CURSOR_STACK_OFFSET, 0.0))
split_gz.matrix_basis = gizmo.billboarded_at(projected_world + offset_world, billboard_rot)
# Dimension config shared between pipe and duct segments. ``matrix_position``
# routing through ``compose_gizmo_matrix`` rotates the +X line to ``axis`` so
# the dimension renders along the segment's extrusion direction.
_MEP_SEGMENT_LENGTH_DIMENSION = DimensionGizmoConfig(
attr_name="length",
axis=(0, 0, 1),
matrix_position=lambda _props: Vector((0.0, 0.0, 0.0)),
min_value=0.01,
show_start_arrow=True,
show_end_arrow=True,
)
class GizmoPipeSegmentEdition(bpy.types.GizmoGroup, _MEPSegmentEditionMixin, gizmo.BaseParametricGizmoGroup):
"""Parametric-edit gizmo for IfcPipeSegment."""
bl_idname = "OBJECT_GGT_bim_pipe_segment_edition"
bl_label = "Pipe Segment Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_pipe_segment"
finish_editing_operator = "bim.finish_editing_pipe_segment"
cancel_editing_operator = "bim.cancel_editing_pipe_segment"
cycle_type_operator = ""
props_getter = tool.Model.get_pipe_segment_props
gizmo_pref_name = "pipe_segment"
_extend_operator = "bim.extend_pipe_segment_to_cursor"
_split_operator = "bim.split_pipe_segment_at_cursor"
dimension_gizmo_props = [_MEP_SEGMENT_LENGTH_DIMENSION]
_active_instances: ClassVar["dict[int, weakref.ReferenceType[GizmoPipeSegmentEdition]]"] = {}
@classmethod
def is_element_type(cls, element):
return tool.Parametric.is_pipe_segment(element)
class GizmoDuctSegmentEdition(bpy.types.GizmoGroup, _MEPSegmentEditionMixin, gizmo.BaseParametricGizmoGroup):
"""Parametric-edit gizmo for IfcDuctSegment."""
bl_idname = "OBJECT_GGT_bim_duct_segment_edition"
bl_label = "Duct Segment Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_duct_segment"
finish_editing_operator = "bim.finish_editing_duct_segment"
cancel_editing_operator = "bim.cancel_editing_duct_segment"
cycle_type_operator = ""
props_getter = tool.Model.get_duct_segment_props
gizmo_pref_name = "duct_segment"
_extend_operator = "bim.extend_duct_segment_to_cursor"
_split_operator = "bim.split_duct_segment_at_cursor"
dimension_gizmo_props = [_MEP_SEGMENT_LENGTH_DIMENSION]
_active_instances: ClassVar["dict[int, weakref.ReferenceType[GizmoDuctSegmentEdition]]"] = {}
@classmethod
def is_element_type(cls, element):
return tool.Parametric.is_duct_segment(element)
+100
View File
@@ -242,6 +242,20 @@ def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
_get_updater("roof", "update_roof_modifier_bmesh")(obj) _get_updater("roof", "update_roof_modifier_bmesh")(obj)
def update_pipe_segment(self: "BIMPipeSegmentProperties", context: bpy.types.Context) -> None:
"""Regenerate pipe-segment preview mesh from props during edit. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("mep", "regenerate_pipe_segment_mesh_from_props")(obj)
def update_duct_segment(self: "BIMDuctSegmentProperties", context: bpy.types.Context) -> None:
"""Regenerate duct-segment preview mesh from props during edit. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("mep", "regenerate_duct_segment_mesh_from_props")(obj)
class BIMModelProperties(PropertyGroup): class BIMModelProperties(PropertyGroup):
ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class) ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class)
relating_type_id: bpy.props.EnumProperty( relating_type_id: bpy.props.EnumProperty(
@@ -1924,6 +1938,92 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None] sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
class BIMPipeSegmentProperties(PropertyGroup):
"""Transient draft state for parametric pipe-segment gizmo editing."""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while pipe-segment parametric edit mode is active.",
)
mesh_dirty: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
description=(
"True while the visible mesh is the preview shape; cleared once the "
"real IFC-derived geometry is restored (on commit or cancel)."
),
)
length: bpy.props.FloatProperty(
name="Length",
default=1.0,
min=0.01,
subtype="DISTANCE",
update=update_pipe_segment,
description="Pipe-segment extrusion length (preview value; committed on finish).",
)
snap_length: bpy.props.FloatProperty(
description="Snapshot of length at edit-enable; commit skips no-op writes.",
)
snap_object_scale_z: bpy.props.FloatProperty(
default=1.0,
description=(
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
"this exact value so a user's non-identity pre-edit scale isn't silently "
"zeroed by the scale-based preview."
),
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
snap_length: float
snap_object_scale_z: float
class BIMDuctSegmentProperties(PropertyGroup):
"""Transient draft state for parametric duct-segment gizmo editing."""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while duct-segment parametric edit mode is active.",
)
mesh_dirty: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
description=(
"True while the visible mesh is the preview shape; cleared once the "
"real IFC-derived geometry is restored (on commit or cancel)."
),
)
length: bpy.props.FloatProperty(
name="Length",
default=1.0,
min=0.01,
subtype="DISTANCE",
update=update_duct_segment,
description="Duct-segment extrusion length (preview value; committed on finish).",
)
snap_length: bpy.props.FloatProperty(
description="Snapshot of length at edit-enable; commit skips no-op writes.",
)
snap_object_scale_z: bpy.props.FloatProperty(
default=1.0,
description=(
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
"this exact value so a user's non-identity pre-edit scale isn't silently "
"zeroed by the scale-based preview."
),
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
snap_length: float
snap_object_scale_z: float
class BIMBendPreviewProperties(PropertyGroup): class BIMBendPreviewProperties(PropertyGroup):
"""Scene-level pending state for the bend-creation preview flow. """Scene-level pending state for the bend-creation preview flow.
+4
View File
@@ -291,6 +291,8 @@ class GizmoPreferences(bpy.types.PropertyGroup):
railing: BoolProperty(name="Railing", default=True) railing: BoolProperty(name="Railing", default=True)
roof: BoolProperty(name="Roof", default=True) roof: BoolProperty(name="Roof", default=True)
array: BoolProperty(name="Array", default=True) array: BoolProperty(name="Array", default=True)
pipe_segment: BoolProperty(name="Pipe Segment", default=True)
duct_segment: BoolProperty(name="Duct Segment", default=True)
wall: BoolProperty(name="Wall", default=True) wall: BoolProperty(name="Wall", default=True)
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -301,6 +303,8 @@ class GizmoPreferences(bpy.types.PropertyGroup):
railing: bool railing: bool
roof: bool roof: bool
array: bool array: bool
pipe_segment: bool
duct_segment: bool
wall: bool wall: bool
+10
View File
@@ -75,8 +75,10 @@ if TYPE_CHECKING:
from bonsai.bim.module.model.prop import ( from bonsai.bim.module.model.prop import (
BIMArrayProperties, BIMArrayProperties,
BIMDoorProperties, BIMDoorProperties,
BIMDuctSegmentProperties,
BIMExternalParametricGeometryProperties, BIMExternalParametricGeometryProperties,
BIMModelProperties, BIMModelProperties,
BIMPipeSegmentProperties,
BIMPolylineProperties, BIMPolylineProperties,
BIMRailingProperties, BIMRailingProperties,
BIMRoofProperties, BIMRoofProperties,
@@ -116,6 +118,14 @@ class Model(bonsai.core.tool.Model):
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties: def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue] return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties:
return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties:
return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod @classmethod
def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties: def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties:
return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue] return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue]
+4 -4
View File
@@ -147,10 +147,6 @@ class Parametric(bonsai.core.tool.Parametric):
self._data.clear() self._data.clear()
self._gen = None self._gen = None
# FIXME(PR5): pipe_segment / duct_segment land with their finish/cancel
# operators in the MEP slice of PR5 (PR5d). Until then they stay out of
# EDIT_TYPES so auto-commit-on-save doesn't try to dispatch a
# non-existent operator.
EDIT_TYPES: list[ParametricObject] = [ EDIT_TYPES: list[ParametricObject] = [
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True), ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True), ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
@@ -158,6 +154,8 @@ class Parametric(bonsai.core.tool.Parametric):
ParametricObject("railing", supports_build_edit_lifecycle=True), ParametricObject("railing", supports_build_edit_lifecycle=True),
ParametricObject("roof", supports_build_edit_lifecycle=True), ParametricObject("roof", supports_build_edit_lifecycle=True),
ParametricObject("array", supports_build_edit_lifecycle=True), ParametricObject("array", supports_build_edit_lifecycle=True),
ParametricObject("pipe_segment", supports_build_edit_lifecycle=True),
ParametricObject("duct_segment", supports_build_edit_lifecycle=True),
ParametricObject("wall"), ParametricObject("wall"),
] ]
@@ -170,6 +168,8 @@ class Parametric(bonsai.core.tool.Parametric):
RAILING: ClassVar[ParametricObject] RAILING: ClassVar[ParametricObject]
ROOF: ClassVar[ParametricObject] ROOF: ClassVar[ParametricObject]
ARRAY: ClassVar[ParametricObject] ARRAY: ClassVar[ParametricObject]
PIPE_SEGMENT: ClassVar[ParametricObject]
DUCT_SEGMENT: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject] WALL: ClassVar[ParametricObject]
_geom_generation: int = 0 _geom_generation: int = 0
@@ -0,0 +1,426 @@
# 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 tests for the pipe/duct segment parametric-edit scaffolding.
Covers three surfaces that ship together as the first MEP dimension-gizmo
feature:
- ``tool.Parametric.is_pipe_segment`` / ``is_duct_segment`` predicates
(registry contract must be total).
- ``_segment_world_length`` / ``_preview_segment_via_scale`` /
``_restore_segment_scale`` pure helpers driving the live preview.
- ``GizmoPipeSegmentEdition`` / ``GizmoDuctSegmentEdition`` class wiring
(bl_idname, operator bindings, dimension_gizmo_props, is_element_type).
Full operator round-trips (enable drag finish IFC commit) need a real
Blender + IFC scene and are deferred to a later integration session."""
from unittest.mock import Mock, patch
import bpy
import ifcopenshell
import pytest
from mathutils import Matrix, Vector
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# Predicates — total over arbitrary IFC entity input
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"ifc_class,is_pipe_expected,is_duct_expected",
[
("IfcPipeSegment", True, False),
("IfcDuctSegment", False, True),
("IfcFlowSegment", False, False), # base class — neither pipe nor duct alone
("IfcPipeFitting", False, False), # fitting, not a segment
("IfcDuctFitting", False, False),
("IfcWall", False, False),
("IfcAnnotation", False, False), # bare schema element with no MEP semantics
],
)
def test_is_pipe_or_duct_segment_predicate_truth_table(ifc_class, is_pipe_expected, is_duct_expected):
"""The two predicates must classify every IFC class correctly AND
return False (not raise) on classes that have nothing to do with MEP.
Pinned alongside the registry-wide predicate-totality test so a
regression in either direction surfaces in this file too."""
from bonsai import tool
probe = ifcopenshell.file(schema="IFC4").create_entity(ifc_class)
assert tool.Parametric.is_pipe_segment(probe) is is_pipe_expected
assert tool.Parametric.is_duct_segment(probe) is is_duct_expected
# ---------------------------------------------------------------------------
# _segment_world_length — pure geometric helper
# ---------------------------------------------------------------------------
def test_segment_world_length_returns_axis_magnitude():
"""The length read here drives both the dimension gizmo's display and
the snap_length captured on enable. Pin the math on a known axis."""
from bonsai.bim.module.model.mep import _segment_world_length
fake_obj = object()
axis = (Vector((1.0, 2.0, 3.0)), Vector((1.0, 2.0, 5.5)))
with patch("bonsai.tool.Model.get_flow_segment_axis", return_value=axis):
assert _segment_world_length(fake_obj) == pytest.approx(2.5)
# ---------------------------------------------------------------------------
# Preview helpers — obj.scale.z manipulation
# ---------------------------------------------------------------------------
class _FakeObj:
"""Stand-in for bpy.types.Object exposing only ``scale`` — enough for
the preview helpers, which never touch IFC."""
def __init__(self):
self.scale = Vector((1.0, 1.0, 1.0))
def test_preview_segment_via_scale_sets_z_to_ratio():
"""The visible-stretch ratio composes ``props_length / mesh_local_length`` where
``mesh_local_length = snap_length / snap_object_scale_z``."""
from bonsai.bim.module.model.mep import _preview_segment_via_scale
obj = _FakeObj()
_preview_segment_via_scale(obj, props_length=2.0, snap_length=1.0, snap_object_scale_z=1.0)
assert obj.scale.z == pytest.approx(2.0)
_preview_segment_via_scale(obj, props_length=0.5, snap_length=1.0, snap_object_scale_z=1.0)
assert obj.scale.z == pytest.approx(0.5)
def test_preview_segment_via_scale_floors_at_min_value():
"""``props.length`` is clamped at FloatProperty min=0.01; the helper still
defends against zero / negative so a runaway value can't invert the segment."""
from bonsai.bim.module.model.mep import _preview_segment_via_scale
obj = _FakeObj()
_preview_segment_via_scale(obj, props_length=0.0, snap_length=1.0, snap_object_scale_z=1.0)
assert obj.scale.z == pytest.approx(0.01)
def test_preview_segment_via_scale_skips_when_snap_is_zero():
"""A zero ``snap_length`` would divide by zero — helper skips silently."""
from bonsai.bim.module.model.mep import _preview_segment_via_scale
obj = _FakeObj()
obj.scale.z = 3.0
_preview_segment_via_scale(obj, props_length=1.0, snap_length=0.0, snap_object_scale_z=1.0)
# No change.
assert obj.scale.z == pytest.approx(3.0)
def test_restore_segment_scale_resets_z_to_target():
"""Pin that the reset only touches Z; X/Y stay whatever the user set."""
from bonsai.bim.module.model.mep import _restore_segment_scale_to
obj = _FakeObj()
obj.scale = Vector((0.5, 0.7, 4.2))
_restore_segment_scale_to(obj, 1.0)
assert obj.scale.x == pytest.approx(0.5)
assert obj.scale.y == pytest.approx(0.7)
assert obj.scale.z == pytest.approx(1.0)
# ---------------------------------------------------------------------------
# Gizmo group class wiring — registration and config
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"gizmo_cls_name,bl_idname,is_element_predicate",
[
("GizmoPipeSegmentEdition", "OBJECT_GGT_bim_pipe_segment_edition", "is_pipe_segment"),
("GizmoDuctSegmentEdition", "OBJECT_GGT_bim_duct_segment_edition", "is_duct_segment"),
],
)
def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicate):
"""Each gizmo group must:
- declare the expected ``bl_idname`` (so it actually registers under that name);
- have the matching ``is_element_type`` delegate to the right predicate
(so it polls in for the right IFC class).
"""
from bonsai import tool
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert cls.bl_idname == bl_idname
# The element_type predicate must delegate to the matching tool.Parametric.is_*.
predicate = getattr(tool.Parametric, is_element_predicate)
fake_element = Mock()
fake_element.is_a.return_value = True
with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p:
cls.is_element_type(fake_element)
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
@pytest.mark.parametrize(
"gizmo_cls_name,enable_op,finish_op,cancel_op",
[
(
"GizmoPipeSegmentEdition",
"bim.enable_editing_pipe_segment",
"bim.finish_editing_pipe_segment",
"bim.cancel_editing_pipe_segment",
),
(
"GizmoDuctSegmentEdition",
"bim.enable_editing_duct_segment",
"bim.finish_editing_duct_segment",
"bim.cancel_editing_duct_segment",
),
],
)
def test_gizmo_lifecycle_bindings_reference_registered_operators(gizmo_cls_name, enable_op, finish_op, cancel_op):
"""Catches the silent-regression where the gizmo's enable/finish/cancel
string drifts away from the actual operator ``bl_idname``."""
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert cls.enable_editing_operator == enable_op
assert cls.finish_editing_operator == finish_op
assert cls.cancel_editing_operator == cancel_op
# And the operators are actually registered.
for op in (enable_op, finish_op, cancel_op):
namespace, _, verb = op.partition(".")
assert hasattr(
getattr(bpy.ops, namespace), verb
), f"{gizmo_cls_name} references {op!r} which is not a registered operator"
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
def test_gizmo_dimension_gizmo_props_has_single_length_entry(gizmo_cls_name):
"""Phase 1 ships a single dimension (segment length). Pin the shape so
a Phase 2 addition (diameter / width / height) is an intentional
expansion rather than a drive-by edit."""
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert len(cls.dimension_gizmo_props) == 1
config = cls.dimension_gizmo_props[0]
assert isinstance(config, DimensionGizmoConfig)
assert config.attr_name == "length"
assert tuple(config.axis) == (0, 0, 1)
assert config.min_value == pytest.approx(0.01)
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
def test_length_dimension_has_matrix_position_so_rotation_is_respected(gizmo_cls_name):
"""Regression guard for "edit-mode length dimension doesn't take local
object rotation". Without ``matrix_position`` set, ``update_dimension_gizmos``
falls back to ``base_matrix = Identity`` and the gizmo's intrinsic +X
visual line is never rotated to the configured ``axis`` the dimension
renders perpendicular to the segment on a rotated pipe. Setting
``matrix_position`` (even to ``(0, 0, 0)``) routes through
``compose_gizmo_matrix`` which applies ``get_axis_rotation_matrix(axis)``
so the line aligns with the segment's local +Z (extrusion axis) in
world space."""
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
config = cls.dimension_gizmo_props[0]
assert config.matrix_position is not None, (
f"{gizmo_cls_name} length dimension is missing matrix_position — the gizmo will "
"render along the object's local +X axis instead of the segment's local +Z."
)
# ---------------------------------------------------------------------------
# Extend-to-cursor — operator + element-specific gizmo wiring
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"gizmo_cls_name,extend_operator",
[
("GizmoPipeSegmentEdition", "bim.extend_pipe_segment_to_cursor"),
("GizmoDuctSegmentEdition", "bim.extend_duct_segment_to_cursor"),
],
)
def test_extend_operator_binding(gizmo_cls_name, extend_operator):
"""Each segment gizmo group must reference the matching extend operator
AND that operator must actually be registered. Catches the silent
regression where someone renames the extend bl_idname without updating
the gizmo group's ``_extend_operator`` class attribute."""
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert cls._extend_operator == extend_operator
namespace, _, verb = extend_operator.partition(".")
assert hasattr(
getattr(bpy.ops, namespace), verb
), f"{gizmo_cls_name} references {extend_operator!r} which is not a registered operator"
@pytest.mark.parametrize("feature_attr", ["pipe_segment", "duct_segment"])
def test_gizmo_preferences_field_exists(feature_attr):
"""``GizmoPreferences`` must carry pipe_segment + duct_segment PointerProperties
so ``get_gizmo_prefs()`` on the MEP gizmo groups resolves to a real PropertyGroup."""
import bonsai.bim.ui as ui
assert feature_attr in ui.GizmoPreferences.__annotations__, (
f"GizmoPreferences is missing the {feature_attr} PointerProperty; "
f"MEP gizmo groups' get_gizmo_prefs() would raise AttributeError."
)
# ---------------------------------------------------------------------------
# Lifecycle operators are registered
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"op",
[
"bim.enable_editing_pipe_segment",
"bim.finish_editing_pipe_segment",
"bim.cancel_editing_pipe_segment",
"bim.extend_pipe_segment_to_cursor",
"bim.enable_editing_duct_segment",
"bim.finish_editing_duct_segment",
"bim.cancel_editing_duct_segment",
"bim.extend_duct_segment_to_cursor",
],
)
def test_segment_operators_are_registered(op):
"""Smoke test mirroring ``test_parametric_registry``'s
``test_every_entry_has_enable_op_registered`` for the operators added
in this round. Catches the silent regression where the classes tuple
in ``__init__.py`` drops one of them."""
namespace, _, verb = op.partition(".")
assert hasattr(getattr(bpy.ops, namespace), verb), f"Operator {op!r} is not registered."
# ---------------------------------------------------------------------------
# Lifecycle drift handling — Enable / Finish / Cancel must commit / restore
# matrix_world ↔ IFC ObjectPlacement at the appropriate lifecycle points.
# The AST forward-compat guard pins "a drift hook IS called somewhere"; these
# tests pin "the hook is called in the right branch with the right args."
# ---------------------------------------------------------------------------
def _make_segment_context(length=2.0, snap_length=2.0, scale_z=1.0):
"""Build (context, props, obj, element) fakes for the MEP edit-lifecycle bases.
The bases access ``self.__class__._predicate`` / ``_props_getter`` so
callers must instantiate a concrete test subclass and call
``instance._execute(context)`` rather than passing a Mock as ``self``."""
obj = Mock(name="obj")
obj.scale = Vector((1.0, 1.0, scale_z))
element = Mock(name="element")
props = Mock(name="props")
props.length = length
props.snap_length = snap_length
props.snap_object_scale_z = scale_z
props.mesh_dirty = False
context = Mock(name="context")
context.active_object = obj
return context, props, obj, element
def _concrete_mep_mixin(props):
"""Build a concrete ``_MEPSegmentEditMixin`` subclass that bypasses the
IFC predicate gate and returns the supplied ``props`` from ``_get_props``.
The unified mixin replaced the three-base-class lifecycle pattern; tests now
target the single mixin and override the two ParametricEditMixinBase
hooks instead of class-level ``_predicate`` / ``_props_getter``."""
from bonsai.bim.module.model.mep import _MEPSegmentEditMixin
class _ConcreteMEPMixin(_MEPSegmentEditMixin):
@classmethod
def _is_element_type(cls, element):
return True
@classmethod
def _get_props(cls, obj):
return props
return _ConcreteMEPMixin
def test_enable_pipe_segment_commits_pre_edit_placement_drift():
"""Enable must call ``commit_placement_if_moved(obj, apply_scale=False)``
BEFORE ``_segment_world_length`` captures ``snap_length``. Without the
commit, snap_length is read from a dragged matrix_world while the IFC
ObjectPlacement is stale Finish's set_depth would then write
representation coords relative to the wrong origin."""
context, props, obj, element = _make_segment_context()
cls = _concrete_mep_mixin(props)
with (
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
patch("bonsai.bim.module.model.mep._segment_world_length", return_value=2.0),
):
mock_tool.Ifc.get_entity.return_value = element
cls()._enable_targets(context)
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj, apply_scale=False)
def test_finish_pipe_segment_commits_drift_when_no_length_change():
"""Finish without a length change must STILL commit matrix_world drift —
the bug class that motivated this guard. The conditional ``set_depth``
branch covers the length-changed path transitively; the unconditional
``commit_placement_if_moved`` after the if/else closes the silent-drop
path."""
# length == snap_length → no-op session.
context, props, obj, element = _make_segment_context(length=2.0, snap_length=2.0)
cls = _concrete_mep_mixin(props)
with (
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
patch("bonsai.bim.module.model.mep.DumbProfileJoiner") as mock_joiner,
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
patch("bonsai.bim.module.model.mep._restore_segment_scale_to"),
):
mock_tool.Ifc.get_entity.return_value = element
cls()._finish_targets(context)
mock_joiner.return_value.set_depth.assert_not_called() # no-length branch
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj)
def test_cancel_pipe_segment_delegates_to_restore_or_rebaseline():
"""Cancel must call ``tool.Geometry.restore_or_rebaseline_placement`` so
matrix_world reverts in lockstep with the props draft. The helper owns
the is_moved / ObjectPlacement gate."""
context, props, obj, element = _make_segment_context()
cls = _concrete_mep_mixin(props)
with (
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
):
mock_tool.Ifc.get_entity.return_value = element
cls()._cancel_targets(context)
mock_tool.Geometry.restore_or_rebaseline_placement.assert_called_once_with(obj, element)