From 8374dd6d46396b196472c36611c17c8bc66c5ee7 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 8 Jun 2026 15:01:03 +0200 Subject: [PATCH] Add MEP pipe / duct segment edit gizmos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../bonsai/bim/module/model/__init__.py | 14 + src/bonsai/bonsai/bim/module/model/mep.py | 527 ++++++++++++++++++ src/bonsai/bonsai/bim/module/model/prop.py | 100 ++++ src/bonsai/bonsai/bim/ui.py | 4 + src/bonsai/bonsai/tool/model.py | 10 + src/bonsai/bonsai/tool/parametric.py | 8 +- .../module/model/test_mep_segment_edition.py | 426 ++++++++++++++ 7 files changed, 1085 insertions(+), 4 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_mep_segment_edition.py diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index ff7ceda05c..de4398f7d7 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -184,6 +184,8 @@ classes = ( prop.BIMRailingProperties, prop.BIMRoofProperties, prop.BIMWallProperties, + prop.BIMPipeSegmentProperties, + prop.BIMDuctSegmentProperties, prop.BIMPolylineProperties, prop.BIMExternalParametricGeometryProperties, prop.BIMBendPreviewProperties, @@ -267,6 +269,18 @@ classes = ( mep.EnableBendPreview, mep.FinishBendPreview, 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, ) diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index 084499c956..c57d19495e 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -19,8 +19,10 @@ import collections.abc import json import re +import weakref from copy import copy from math import cos, degrees, pi, radians, sin, tan +from typing import ClassVar import bpy import ifcopenshell.api.geometry @@ -38,8 +40,11 @@ from mathutils import Matrix, Vector import bonsai.core.root 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.profile import DumbProfileJoiner +from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase from bonsai.tool.cad import VTX_PRECISION V = lambda *x: Vector([float(i) for i in x]) @@ -1393,3 +1398,525 @@ class CancelBendPreview(bpy.types.Operator): return {"CANCELLED"} preview_base.clear_preview_state(props) 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_ 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) diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index a3033deba5..d8dc146fd6 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -242,6 +242,20 @@ def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None: _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): ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class) relating_type_id: bpy.props.EnumProperty( @@ -1924,6 +1938,92 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup): 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): """Scene-level pending state for the bend-creation preview flow. diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 2b17990220..5797e05425 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -291,6 +291,8 @@ class GizmoPreferences(bpy.types.PropertyGroup): railing: BoolProperty(name="Railing", default=True) roof: BoolProperty(name="Roof", 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) if TYPE_CHECKING: @@ -301,6 +303,8 @@ class GizmoPreferences(bpy.types.PropertyGroup): railing: bool roof: bool array: bool + pipe_segment: bool + duct_segment: bool wall: bool diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index faab228b23..b012d933e9 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -75,8 +75,10 @@ if TYPE_CHECKING: from bonsai.bim.module.model.prop import ( BIMArrayProperties, BIMDoorProperties, + BIMDuctSegmentProperties, BIMExternalParametricGeometryProperties, BIMModelProperties, + BIMPipeSegmentProperties, BIMPolylineProperties, BIMRailingProperties, BIMRoofProperties, @@ -116,6 +118,14 @@ class Model(bonsai.core.tool.Model): def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties: 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 def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties: return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue] diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index 9cc8aff24f..3c3fba66f5 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -147,10 +147,6 @@ class Parametric(bonsai.core.tool.Parametric): self._data.clear() 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] = [ ParametricObject("door", 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("roof", 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"), ] @@ -170,6 +168,8 @@ class Parametric(bonsai.core.tool.Parametric): RAILING: ClassVar[ParametricObject] ROOF: ClassVar[ParametricObject] ARRAY: ClassVar[ParametricObject] + PIPE_SEGMENT: ClassVar[ParametricObject] + DUCT_SEGMENT: ClassVar[ParametricObject] WALL: ClassVar[ParametricObject] _geom_generation: int = 0 diff --git a/src/bonsai/test/bim/module/model/test_mep_segment_edition.py b/src/bonsai/test/bim/module/model/test_mep_segment_edition.py new file mode 100644 index 0000000000..d07d563e29 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_segment_edition.py @@ -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 . +# +# 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)