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.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,
)
+527
View File
@@ -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_<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)
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.
+4
View File
@@ -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
+10
View File
@@ -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]
+4 -4
View File
@@ -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
@@ -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)