mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +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.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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user