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