Extract MEP bend preview + refine port operators

Three concerns bundled by file boundary (all in mep.py):

- Extract bend preview operators + GizmoBendPreview into a focused
  mep_bend_preview.py module; preview_base.py grows the shared helper
  set both bend and other previews now consume; classes tuple in
  model/__init__.py updated to register the new module.
- Surface ERROR reports on five silent CANCELLED returns in
  MEPUnjoinAtPort / MEPRemoveTerminalFitting / MEPUnjoinPair so a
  degenerate IFC file ("fitting has no Blender object", "connected
  port leads nowhere") shows up in the popup instead of looking like
  a no-op.
- DRY: _resolve_active_mep_segment + _require_port_state factor the
  segment-id-or-active-object resolve + port-state guard out of every
  port operator's prologue; _wire_anchored_icon_targets pulls the
  GizmoMEPActions setup() body into an exercise-without-MRO helper so
  the wiring-contract tests can hit it without instantiating the
  GizmoGroup.

Drops the now-unused preview_base import that the extraction left
behind.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-10 12:24:05 +02:00
committed by Thomas Krijnen
parent 38ea9c0ac3
commit 9e35db593e
5 changed files with 651 additions and 491 deletions
@@ -33,6 +33,7 @@ from . import (
handler,
host_add_opening_gizmo,
mep,
mep_bend_preview,
opening,
product,
profile,
@@ -273,11 +274,11 @@ classes = (
mep.MEPUnjoinPair,
mep.SelectMEPPathMembers,
mep.MEPJoinSegments,
mep.EnableBendPreview,
mep.FinishBendPreview,
mep.CancelBendPreview,
mep.EnableBendPreviewFromBend,
mep.GizmoBendPreview,
mep_bend_preview.EnableBendPreview,
mep_bend_preview.FinishBendPreview,
mep_bend_preview.CancelBendPreview,
mep_bend_preview.EnableBendPreviewFromBend,
mep_bend_preview.GizmoBendPreview,
mep.EnableEditingPipeSegment,
mep.FinishEditingPipeSegment,
mep.CancelEditingPipeSegment,
+161 -486
View File
@@ -42,7 +42,6 @@ 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, IconActionConfig
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
@@ -50,6 +49,16 @@ from bonsai.tool.cad import VTX_PRECISION
V = lambda *x: Vector([float(i) for i in x])
def _is_multiple_of_pi(value: float) -> bool:
n = round(value / pi)
return tool.Cad.is_x(abs(value - n * pi), 0)
def _segment_port(segment, at_segment_start: bool):
port_key = "start_port" if at_segment_start else "end_port"
return MEPGenerator.get_segment_data(segment).get(port_key)
class RegenerateDistributionElement(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.regenerate_distribution_element"
bl_description = (
@@ -312,7 +321,8 @@ class MEPGenerator:
profile_joiner = DumbProfileJoiner()
profile_joiner.set_depth(connected_obj, connected_element_length)
def get_segment_data(self, segment):
@staticmethod
def get_segment_data(segment):
"""returns points data is in world space"""
ports = tool.System.get_ports(segment)
segment_object = tool.Ifc.get_object(segment)
@@ -619,9 +629,7 @@ def find_obstruction_at_port(segment, at_segment_start):
"""Return the OBSTRUCTION fitting connected at the segment's named port, or ``None``."""
if not segment.is_a("IfcFlowSegment"):
return None
port_key = "start_port" if at_segment_start else "end_port"
segment_data = MEPGenerator().get_segment_data(segment)
related_port = segment_data.get(port_key)
related_port = _segment_port(segment, at_segment_start)
if related_port is None:
return None
connected_port = tool.System.get_connected_port(related_port)
@@ -654,8 +662,7 @@ def port_connection_state(segment, at_segment_start):
Returns ``PORT_FREE`` defensively for non-segment or unconnected inputs."""
if not segment.is_a("IfcFlowSegment"):
return PORT_FREE
port_key = "start_port" if at_segment_start else "end_port"
related_port = MEPGenerator().get_segment_data(segment).get(port_key)
related_port = _segment_port(segment, at_segment_start)
if related_port is None:
return PORT_FREE
connected_port = tool.System.get_connected_port(related_port)
@@ -682,8 +689,7 @@ def get_connected_element_at_segment_port(segment, at_segment_start):
daisy-chains), or ``None`` if unconnected or malformed."""
if not segment.is_a("IfcFlowSegment"):
return None
port_key = "start_port" if at_segment_start else "end_port"
related_port = MEPGenerator().get_segment_data(segment).get(port_key)
related_port = _segment_port(segment, at_segment_start)
if related_port is None:
return None
connected_port = tool.System.get_connected_port(related_port)
@@ -713,6 +719,41 @@ def find_fitting_between_segments(segment_a, segment_b):
return None
def _resolve_active_mep_segment(operator, context):
"""Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting.
Reads ``operator.segment_id`` when set, otherwise the active object. Shared
dispatch shape for the port operators."""
if operator.segment_id:
element = tool.Ifc.get().by_id(operator.segment_id)
else:
element = tool.Ifc.get_entity(context.active_object)
if element is None or not element.is_a("IfcFlowSegment"):
operator.report({"ERROR"}, "Active object is not a MEP segment.")
return None
return element
def _require_port_state(operator, context, required_state: str, fitting_label: str):
"""Shared port-action prologue: resolve the target segment, derive
``at_segment_start`` from ``operator.position``, and verify the named
port is in ``required_state``. Returns ``(element, at_segment_start)``
or ``None`` after reporting; callers turn ``None`` into ``{'CANCELLED'}``.
``fitting_label`` (e.g. ``"joining"`` / ``"terminal"``) is interpolated
into the rejection message so each caller's phrasing reads naturally."""
element = _resolve_active_mep_segment(operator, context)
if element is None:
return None
at_segment_start = operator.position == "START"
state = port_connection_state(element, at_segment_start)
if state != required_state:
end_label = "start" if at_segment_start else "end"
operator.report({"ERROR"}, f"No {fitting_label} fitting at the {end_label} port (state: {state}).")
return None
return element, at_segment_start
class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.mep_add_obstruction"
bl_label = "Add Obstruction"
@@ -742,15 +783,8 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
)
def _execute(self, context):
if self.segment_id:
element = tool.Ifc.get().by_id(self.segment_id)
else:
element = tool.Ifc.get_entity(context.active_object)
if not element:
return {"CANCELLED"}
if not element.is_a("IfcFlowSegment"):
self.report({"ERROR"}, f"Failed to add obstruction - object is not a MEP segment: {element.is_a()}.")
element = _resolve_active_mep_segment(self, context)
if element is None:
return {"CANCELLED"}
if self.position == "CURSOR":
@@ -804,23 +838,14 @@ class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator):
)
def _execute(self, context):
if self.segment_id:
element = tool.Ifc.get().by_id(self.segment_id)
else:
element = tool.Ifc.get_entity(context.active_object)
if element is None or not element.is_a("IfcFlowSegment"):
self.report({"ERROR"}, "Active object is not a MEP segment.")
return {"CANCELLED"}
at_segment_start = self.position == "START"
state = port_connection_state(element, at_segment_start)
if state != PORT_JOINED:
end_label = "start" if at_segment_start else "end"
self.report({"ERROR"}, f"No joining fitting at the {end_label} port (state: {state}).")
resolved = _require_port_state(self, context, PORT_JOINED, "joining")
if resolved is None:
return {"CANCELLED"}
element, at_segment_start = resolved
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
self.report({"ERROR"}, "Connected port does not lead to a fitting.")
return {"CANCELLED"}
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
@@ -828,6 +853,7 @@ class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator):
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
@@ -856,23 +882,14 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
)
def _execute(self, context):
if self.segment_id:
element = tool.Ifc.get().by_id(self.segment_id)
else:
element = tool.Ifc.get_entity(context.active_object)
if element is None or not element.is_a("IfcFlowSegment"):
self.report({"ERROR"}, "Active object is not a MEP segment.")
return {"CANCELLED"}
at_segment_start = self.position == "START"
state = port_connection_state(element, at_segment_start)
if state != PORT_TERMINAL:
end_label = "start" if at_segment_start else "end"
self.report({"ERROR"}, f"No terminal fitting at the {end_label} port (state: {state}).")
resolved = _require_port_state(self, context, PORT_TERMINAL, "terminal")
if resolved is None:
return {"CANCELLED"}
element, at_segment_start = resolved
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None:
self.report({"ERROR"}, "Terminal port does not lead to a fitting.")
return {"CANCELLED"}
# OBSTRUCTION predefined-type value is IFC4+; IFC2X3 files fall through
@@ -888,6 +905,7 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
@@ -925,6 +943,7 @@ class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator):
return {"CANCELLED"}
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
@@ -1053,11 +1072,7 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator):
start_object.matrix_world.to_quaternion().rotation_difference(end_object_rotation).to_euler().z
)
def is_multiple_of_pi(value):
n = round(value / pi)
return tool.Cad.is_x(abs(value - n * pi), 0)
if not is_multiple_of_pi(rotation_difference_z):
if not _is_multiple_of_pi(rotation_difference_z):
self.report(
{"ERROR"},
"There is some rotation difference between profiles by local Z axis: "
@@ -1066,8 +1081,8 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator):
return {"CANCELLED"}
# setup start / end points
start_segment_data = MEPGenerator().get_segment_data(start_element)
end_segment_data = MEPGenerator().get_segment_data(end_element)
start_segment_data = MEPGenerator.get_segment_data(start_element)
end_segment_data = MEPGenerator.get_segment_data(end_element)
points_ports_map = {
start_segment_data["start_point"]: start_segment_data["start_port"],
start_segment_data["end_point"]: start_segment_data["end_port"],
@@ -1298,11 +1313,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
start_object.matrix_world.to_quaternion().rotation_difference(end_object_rotation).to_euler()
)
def is_multiple_of_pi(value):
n = round(value / pi)
return tool.Cad.is_x(abs(value - n * pi), 0)
if not is_multiple_of_pi(rotation_difference.z):
if not _is_multiple_of_pi(rotation_difference.z):
error_msg = (
"There is some rotation difference between profiles by local Z axis: "
f"{round(degrees(rotation_difference.z))} deg, adding a bend is not possible."
@@ -1346,8 +1357,8 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
# setup start / end points
start_object_rotation = start_object.matrix_world.to_quaternion().to_matrix()
start_segment_data = MEPGenerator().get_segment_data(start_element)
end_segment_data = MEPGenerator().get_segment_data(end_element)
start_segment_data = MEPGenerator.get_segment_data(start_element)
end_segment_data = MEPGenerator.get_segment_data(end_element)
# use id() to match by the exact vector objects and not by their values
# since vectors position could match
points_ports_map = {
@@ -1843,211 +1854,6 @@ class MEPJoinSegments(bpy.types.Operator):
return bpy.ops.bim.enable_bend_preview()
class EnableBendPreview(bpy.types.Operator):
"""Enter bend-preview mode for two selected MEP segments. Populates
scene.BIMPreviewProperties.bend with segment IFC ids and default
start_length / end_length / radius; no IFC mutation until finish."""
bl_idname = "bim.enable_bend_preview"
bl_label = "Enter Bend Preview"
bl_description = "Begin tuning bend parameters before committing the bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments to bend.")
return False
return True
def execute(self, context):
selected = tool.Blender.get_selected_objects()
active = context.active_object
if active is None or active not in selected:
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
return {"CANCELLED"}
other = next((o for o in selected if o is not active), None)
if other is None:
self.report({"ERROR"}, "Two MEP segments must be selected.")
return {"CANCELLED"}
active_element = tool.Ifc.get_entity(active)
other_element = tool.Ifc.get_entity(other)
if active_element is None or other_element is None:
self.report({"ERROR"}, "Both selected objects must be IFC elements.")
return {"CANCELLED"}
if segments_are_parallel(active, other):
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
return {"CANCELLED"}
# Pre-check the same preconditions MEPAddBend enforces so the user
# sees the rejection here rather than after tuning a doomed preview.
precondition_error = validate_bend_preconditions(active_element, other_element)
if precondition_error is not None:
self.report({"ERROR"}, precondition_error)
return {"CANCELLED"}
preview_base.sync_uncommitted_moves([active, other])
props = preview_base.get_preview_props(context, "bend")
# Auto-cancel any prior preview so re-clicking join on a different
# pair doesn't silently commit the previous tuning.
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
props.start_segment_id = active_element.id()
props.end_segment_id = other_element.id()
props.start_length = 0.1
props.end_length = 0.1
props.radius = 0.2
props.is_active = True
return {"FINISHED"}
class FinishBendPreview(bpy.types.Operator):
"""Commit the previewed bend with the tuned parameters and exit preview.
Preview state survives a failed commit so the user can re-tune without
re-selecting."""
bl_idname = "bim.finish_bend_preview"
bl_label = "Apply Bend"
bl_description = "Commit the bend with the previewed parameters"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.screen is None:
return {"CANCELLED"}
props = preview_base.get_preview_props(context, "bend")
if props is None or not props.is_active:
return {"CANCELLED"}
if tool.Ifc.get() is None:
self.report({"ERROR"}, "No IFC file loaded.")
return {"CANCELLED"}
# bpy.ops promotes ``self.report({"ERROR"}) + return CANCELLED`` from
# the dispatched operator to RuntimeError. Catch it so this operator
# returns cleanly instead of leaving Blender's operator state
# half-broken (which would silently disable downstream gizmo polls).
try:
result = bpy.ops.bim.mep_add_bend(
start_segment_id=props.start_segment_id,
end_segment_id=props.end_segment_id,
start_length=props.start_length,
end_length=props.end_length,
radius=props.radius,
editing_bend_id=props.editing_bend_id,
)
except RuntimeError as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
if "FINISHED" in result:
preview_base.clear_preview_state(props)
return result
class CancelBendPreview(bpy.types.Operator):
"""Exit bend preview without committing."""
bl_idname = "bim.cancel_bend_preview"
bl_label = "Cancel Bend"
bl_description = "Discard the previewed bend"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.screen is None:
return {"CANCELLED"}
props = preview_base.get_preview_props(context, "bend")
if props is None or not props.is_active:
return {"CANCELLED"}
preview_base.clear_preview_state(props)
return {"FINISHED"}
class EnableBendPreviewFromBend(bpy.types.Operator):
"""Re-open the bend preview on an existing bend fitting.
Resolves the two connected segments via the bend's ports +
``IfcRelConnectsPorts``, reads parametric values back from the bend's
``BBIM_Fitting`` pset, and flags the preview so committing replaces
the existing bend in place."""
bl_idname = "bim.enable_bend_preview_from_bend"
bl_label = "Edit Bend"
bl_description = "Re-open the bend preview to retune an existing bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
active = context.active_object
if active is None:
cls.poll_message_set("No active object.")
return False
element = tool.Ifc.get_entity(active)
if element is None or not _is_bend_fitting(element):
cls.poll_message_set("Active object must be a bend fitting.")
return False
return True
def execute(self, context):
active = context.active_object
bend_element = tool.Ifc.get_entity(active)
if bend_element is None or not _is_bend_fitting(bend_element):
self.report({"ERROR"}, "Active object is not a bend fitting.")
return {"CANCELLED"}
connected_segments: list = []
for port in tool.System.get_ports(bend_element):
connected_port = tool.System.get_connected_port(port)
if connected_port is None:
continue
related = tool.System.get_port_relating_element(connected_port)
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
connected_segments.append(related)
if len(connected_segments) != 2:
self.report(
{"ERROR"},
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
)
return {"CANCELLED"}
# Read parametric values from the bend type's BBIM_Fitting pset. The
# type carries the canonical parameters; querying the occurrence
# would force a get_type round-trip and miss user-edited types.
bend_type = ifcopenshell.util.element.get_type(bend_element)
if bend_type is None:
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
return {"CANCELLED"}
bend_type_obj = tool.Ifc.get_object(bend_type)
if bend_type_obj is None:
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
return {"CANCELLED"}
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
if bbim is None:
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
return {"CANCELLED"}
data = bbim.get("data_dict", {})
props = preview_base.get_preview_props(context, "bend")
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
# Segment order is load-bearing: the bend's lateral sign and z-axis
# flip are derived from which segment is "start" vs "end". Re-edit
# must reuse the same pairing as the original create so the recreate
# lands at the same orientation.
start_segment, end_segment = connected_segments
props.start_segment_id = start_segment.id()
props.end_segment_id = end_segment.id()
# Pset values are in IFC native units; scene units come from si_conversion.
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
props.radius = float(data.get("radius", 0.2)) * si_conversion
props.editing_bend_id = bend_element.id()
props.is_active = True
return {"FINISHED"}
def _is_bend_fitting(element) -> bool:
"""True iff ``element`` is an ``IfcFlowFitting`` whose type carries
``PredefinedType="BEND"``."""
@@ -2179,6 +1985,47 @@ def compute_bend_preview_polylines(
}
# Single-entry memo: the bend-preview decorator and GizmoBendPreview both call
# the polyline math every redraw, so without this the quaternion sweep + axis
# intersection run twice per frame. Only one bend preview is active at a time
# (enforced by BIMBendPreviewProperties.is_active), so single-entry is enough.
_bend_preview_memo: "tuple[tuple, dict] | None" = None
def cached_compute_bend_preview_polylines(
start_object,
end_object,
start_length: float,
end_length: float,
radius: float,
arc_resolution: int = 24,
):
"""Per-frame-safe wrapper over ``compute_bend_preview_polylines``.
Reuses the most recent result when inputs (object identities, world
matrices, the three tuned dimensions, arc resolution, and the global IFC
geometry generation) are unchanged. The commit operator path still uses
``compute_bend_preview_polylines`` directly — there's no point caching a
one-shot call."""
global _bend_preview_memo
key = (
start_object.name,
tuple(map(tuple, start_object.matrix_world)),
end_object.name,
tuple(map(tuple, end_object.matrix_world)),
start_length,
end_length,
radius,
arc_resolution,
tool.Parametric.get_geom_generation(),
)
if _bend_preview_memo is not None and _bend_preview_memo[0] == key:
return _bend_preview_memo[1]
result = compute_bend_preview_polylines(start_object, end_object, start_length, end_length, radius, arc_resolution)
_bend_preview_memo = (key, result)
return result
def _bend_profile_cross_section(profile, n_circle: int = 16) -> "list[tuple[float, float]] | None":
"""Return the segment's cross-section profile as a list of 2D points in
the (right, up) sweep plane. Circle → ``n_circle`` evenly-spaced ring
@@ -2268,210 +2115,6 @@ def _sweep_profile_along_polyline(
return verts, faces
def _bend_preview_segments(context):
"""Resolve the two segment objects from the scene-level preview props.
Re-resolves by IFC id each frame so undo / file reload during preview
never dangles a stale bpy reference."""
props = context.scene.BIMPreviewProperties.bend
ifc_file = tool.Ifc.get()
if ifc_file is None or not props.is_active:
return None, None
try:
start_element = ifc_file.by_id(props.start_segment_id)
end_element = ifc_file.by_id(props.end_segment_id)
except Exception:
return None, None
start_obj = tool.Ifc.get_object(start_element) if start_element else None
end_obj = tool.Ifc.get_object(end_element) if end_element else None
return start_obj, end_obj
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
draws + drags along local +X by convention."""
x = x_direction.normalized()
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
y = (seed - x * seed.dot(x)).normalized()
z = x.cross(y)
mat = Matrix.Identity(4)
mat[0][:3] = (x.x, y.x, z.x)
mat[1][:3] = (x.y, y.y, z.y)
mat[2][:3] = (x.z, y.z, z.z)
mat.translation = location
return mat
class GizmoBendPreview(bpy.types.GizmoGroup):
"""Interactive gizmo group for the bend preview flow.
Three dimension widgets drag start_length / end_length / radius; two
icon gizmos commit or cancel. When the geometry is degenerate the
dimensions and validate hide but cancel stays visible so the user
always has an exit."""
bl_idname = "OBJECT_GGT_bim_bend_preview"
bl_label = "Bend Preview Gizmos"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE: ClassVar[float] = 0.375
ICON_SPACING_X: ClassVar[float] = 0.4
ICON_Z_OFFSET: ClassVar[float] = 1.5
@classmethod
def poll(cls, context):
preview = getattr(context.scene, "BIMPreviewProperties", None)
props = preview.bend if preview is not None else None
if props is None or not props.is_active:
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
ifc_file = tool.Ifc.get()
if ifc_file is None:
return False
try:
ifc_file.by_id(props.start_segment_id)
ifc_file.by_id(props.end_segment_id)
except (RuntimeError, KeyError):
return False
return True
def setup(self, context):
prefs = tool.Blender.get_addon_preferences()
default_color = tuple(prefs.decorations_colour[:3])
highlight_color = tuple(prefs.decorator_color_selected[:3])
_props = preview_base.make_props_callback("bend")
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
gz.axis = Vector((1, 0, 0))
gz.invert_delta = invert_delta
gz.delta_scale = 1.0
gz.prop_name = prop_name
gz.gizmo_group = self
gz.color = default_color
gz.color_highlight = highlight_color
gz.alpha = 1.0
gz.use_draw_modal = True
gz.use_draw_scale = False
gz.text_offset_sign = 1
gz.text_alignment = gizmo.TextAlignment.CENTER
gz.show_start_arrow = False
gz.show_end_arrow = True
gz.show_extension_lines = False
gz.text_formatter = None
return gz
self.start_dim = setup_dimension("start_length", "Start Length")
self.end_dim = setup_dimension("end_length", "End Length")
self.radius_dim = setup_dimension("radius", "Radius")
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
self.validate_icon.use_draw_scale = False
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
self.validate_icon.color_highlight = highlight_color
self.validate_icon.target_set_operator("bim.finish_bend_preview")
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
self.cancel_icon.use_draw_scale = False
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
self.cancel_icon.color_highlight = highlight_color
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
def refresh(self, context):
self._position_gizmos(context)
def draw_prepare(self, context):
self._position_gizmos(context)
def _position_gizmos(self, context):
"""Place gizmos at the bend intersection using the current scene
props. Cancel stays visible on degenerate geometry so the user
always has an exit; the other widgets hide when there's no defined
tangent / arc to anchor them on."""
start_obj, end_obj = _bend_preview_segments(context)
if start_obj is None or end_obj is None:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = True
return
props = context.scene.BIMPreviewProperties.bend
preview = compute_bend_preview_polylines(start_obj, end_obj, props.start_length, props.end_length, props.radius)
if not preview["valid"]:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
gz.hide = True
self.cancel_icon.hide = False
axes = preview.get("invalid_axes") or []
if axes:
intersection_point = axes[0][1]
billboard_rot = gizmo.get_billboard_rotation(context)
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
return
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = False
leg_a_far, leg_a_end = preview["leg_a"]
leg_b_far, leg_b_end = preview["leg_b"]
toward_bend_a = (
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
)
toward_bend_b = (
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
)
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
# axis is set in world space every frame so the drag projection
# matches the visual regardless of either segment's matrix_world.
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
self.start_dim.axis = -toward_bend_a
self.start_dim.set_dimension_length(props.start_length)
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
self.end_dim.axis = -toward_bend_b
self.end_dim.set_dimension_length(props.end_length)
arc = preview["arc"]
if len(arc) >= 3:
mid = len(arc) // 2
chord_mid = (arc[0] + arc[-1]) * 0.5
toward_mid = arc[mid] - chord_mid
if toward_mid.length > 1e-6:
toward_mid = toward_mid.normalized()
half_chord = (arc[-1] - arc[0]).length * 0.5
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
arc_center = chord_mid - toward_mid * center_dist
radial_out = arc[mid] - arc_center
if radial_out.length > 1e-6:
radial_out.normalize()
inward = -radial_out
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
self.radius_dim.axis = inward
self.radius_dim.set_dimension_length(props.radius)
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
billboard_rot = gizmo.get_billboard_rotation(context)
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
# --- MEP segment parametric edit + cursor-anchored operators ---------------
@@ -2765,7 +2408,7 @@ def split_mep_segment(obj: bpy.types.Object, cut_local_z: float) -> bpy.types.Ob
if cut_local_z < 0.01 or cut_local_z > original_length - 0.01:
return None
segment_data = MEPGenerator().get_segment_data(element)
segment_data = MEPGenerator.get_segment_data(element)
end_port = segment_data.get("end_port")
downstream_port = None
downstream_direction = "NOTDEFINED"
@@ -2792,9 +2435,8 @@ def split_mep_segment(obj: bpy.types.Object, cut_local_z: float) -> bpy.types.Ob
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_data = MEPGenerator.get_segment_data(element)
seg2_data = MEPGenerator.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")
@@ -3158,11 +2800,17 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
def setup(self, context: bpy.types.Context) -> None:
super().setup(context)
# Pre-fill ``position`` (and ``mode`` for the open-lock obstruction
# add) on each anchored icon so the click goes to the right end
# without a per-frame property write.
for config_name, (_icon, position_arg) in self.LOCK_ICON_CONFIGS.items():
gz = getattr(self, f"action_{config_name}_gizmo", None)
self._wire_anchored_icon_targets(self)
@classmethod
def _wire_anchored_icon_targets(cls, group) -> None:
"""Pre-fill ``position`` (and ``mode`` for open-lock) on each anchored
icon so a click dispatches to the right port without a per-frame
property write; apply the warning-red hover colour to destructive
icons. Takes any object with ``action_<name>_gizmo`` attributes so
tests can exercise the wiring without instantiating the GizmoGroup."""
for config_name, (_icon, position_arg) in cls.LOCK_ICON_CONFIGS.items():
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
is_open = config_name.endswith("_open")
@@ -3175,15 +2823,15 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
op_props.position = position_arg
for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")):
gz = getattr(self, f"action_{config_name}_gizmo", None)
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
op_props = gz.target_set_operator("bim.mep_unjoin_at_port")
op_props.position = position_arg
warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences())
for config_name in self.UNJOIN_CONFIGS:
gz = getattr(self, f"action_{config_name}_gizmo", None)
for config_name in cls.UNJOIN_CONFIGS:
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
gz.color_highlight = warning_color
@@ -3201,12 +2849,29 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
z_top = max((c[2] for c in obj.bound_box), default=0.0)
z_anchor = z_top + self.ICON_ROW_Z_OFFSET
segment_endpoints: tuple[Vector, Vector] | None = None
bend_anchor: Vector | None = None
port_state_at: dict[str, str] = {}
# pair_fitting tri-state: None = not computed; False = computed, no
# fitting joins the pair; <entity> = the joining fitting.
pair_fitting: object = None
# Restore last frame's IFC-derived state when the cache key is still
# valid (same active + same selection signature + same IFC generation).
# Camera-dependent state (billboard_rot, matrix_basis) is still rebuilt
# every frame below — only the expensive port/fitting/axis lookups are
# cached.
current_gen = tool.Parametric.get_geom_generation()
selection_sig = tuple(sorted(o.name for o in tool.Blender.get_selected_objects()))
cache_key = (obj.name, selection_sig, current_gen)
if getattr(self, "_mep_state_cache_key", None) == cache_key:
cache = self._mep_state_cache
port_state_at = cache["port_state_at"]
pair_fitting = cache["pair_fitting"]
segment_endpoints = cache["segment_endpoints"]
bend_anchor = cache["bend_anchor"]
bend_anchor_attempted = cache["bend_anchor_attempted"]
else:
segment_endpoints = None
bend_anchor = None
bend_anchor_attempted = False
port_state_at = {}
# pair_fitting tri-state: None = not computed; False = computed, no
# fitting joins the pair; <entity> = the joining fitting.
pair_fitting = None
row_index = 0
for config in self.action_configs:
@@ -3259,8 +2924,9 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
gz.hide = True
continue
if bend_anchor is None:
if not bend_anchor_attempted:
bend_anchor = compute_mep_join_location()
bend_anchor_attempted = True
if bend_anchor is None:
gz.hide = True
continue
@@ -3271,6 +2937,15 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=scale)
row_index += 1
self._mep_state_cache_key = cache_key
self._mep_state_cache = {
"port_state_at": port_state_at,
"pair_fitting": pair_fitting,
"segment_endpoints": segment_endpoints,
"bend_anchor": bend_anchor,
"bend_anchor_attempted": bend_anchor_attempted,
}
def _scale_for_config(self, name: str) -> float:
if name in self.UNJOIN_CONFIGS:
return gizmo.DEFAULT_BILLBOARD_SCALE
@@ -0,0 +1,444 @@
# 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.
"""Bend-preview lifecycle for MEP segment joins.
Holds the four lifecycle operators (Enable / Finish / Cancel /
EnableFromBend) and the ``GizmoBendPreview`` group that surfaces the
tunable dimensions and validate/cancel icons during preview. Draft state
lives at ``Scene.BIMPreviewProperties.bend`` per CLAUDE.md §2.9 (Scene
for cross-element previews).
The geometry math (``compute_bend_preview_polylines``,
``_bend_profile_cross_section``, ``_sweep_profile_along_polyline``)
stays in ``mep.py`` because the commit operator ``MEPAddBend`` reuses
it; this module imports the polyline helper for per-frame gizmo
positioning. The GPU lines themselves are drawn by
``decorator.BendPreviewDecorator``, kept in ``decorator.py`` with its
sibling decorators."""
from typing import ClassVar
import bpy
import ifcopenshell.util.element
import ifcopenshell.util.unit
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.mep import (
_is_bend_fitting,
_n_mep_selected,
cached_compute_bend_preview_polylines,
segments_are_parallel,
validate_bend_preconditions,
)
class EnableBendPreview(bpy.types.Operator):
"""Enter bend-preview mode for two selected MEP segments. Populates
scene.BIMPreviewProperties.bend with segment IFC ids and default
start_length / end_length / radius; no IFC mutation until finish."""
bl_idname = "bim.enable_bend_preview"
bl_label = "Enter Bend Preview"
bl_description = "Begin tuning bend parameters before committing the bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments to bend.")
return False
return True
def execute(self, context):
selected = tool.Blender.get_selected_objects()
active = context.active_object
if active is None or active not in selected:
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
return {"CANCELLED"}
other = next((o for o in selected if o is not active), None)
if other is None:
self.report({"ERROR"}, "Two MEP segments must be selected.")
return {"CANCELLED"}
active_element = tool.Ifc.get_entity(active)
other_element = tool.Ifc.get_entity(other)
if active_element is None or other_element is None:
self.report({"ERROR"}, "Both selected objects must be IFC elements.")
return {"CANCELLED"}
if segments_are_parallel(active, other):
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
return {"CANCELLED"}
# Pre-check the same preconditions MEPAddBend enforces so the user
# sees the rejection here rather than after tuning a doomed preview.
precondition_error = validate_bend_preconditions(active_element, other_element)
if precondition_error is not None:
self.report({"ERROR"}, precondition_error)
return {"CANCELLED"}
preview_base.sync_uncommitted_moves([active, other])
props = preview_base.get_preview_props(context, "bend")
# Auto-cancel any prior preview so re-clicking join on a different
# pair doesn't silently commit the previous tuning.
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
props.start_segment_id = active_element.id()
props.end_segment_id = other_element.id()
props.start_length = 0.1
props.end_length = 0.1
props.radius = 0.2
props.is_active = True
return {"FINISHED"}
class FinishBendPreview(bpy.types.Operator):
"""Commit the previewed bend with the tuned parameters and exit preview.
Preview state survives a failed commit so the user can re-tune without
re-selecting."""
bl_idname = "bim.finish_bend_preview"
bl_label = "Apply Bend"
bl_description = "Commit the bend with the previewed parameters"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
return preview_base.commit_preview(
self,
context,
"bend",
"mep_add_bend",
("start_segment_id", "end_segment_id", "start_length", "end_length", "radius", "editing_bend_id"),
)
class CancelBendPreview(bpy.types.Operator):
"""Exit bend preview without committing."""
bl_idname = "bim.cancel_bend_preview"
bl_label = "Cancel Bend"
bl_description = "Discard the previewed bend"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.screen is None:
return {"CANCELLED"}
props = preview_base.get_preview_props(context, "bend")
if props is None or not props.is_active:
return {"CANCELLED"}
preview_base.clear_preview_state(props)
return {"FINISHED"}
class EnableBendPreviewFromBend(bpy.types.Operator):
"""Re-open the bend preview on an existing bend fitting.
Resolves the two connected segments via the bend's ports +
``IfcRelConnectsPorts``, reads parametric values back from the bend's
``BBIM_Fitting`` pset, and flags the preview so committing replaces
the existing bend in place."""
bl_idname = "bim.enable_bend_preview_from_bend"
bl_label = "Edit Bend"
bl_description = "Re-open the bend preview to retune an existing bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
active = context.active_object
if active is None:
cls.poll_message_set("No active object.")
return False
element = tool.Ifc.get_entity(active)
if element is None or not _is_bend_fitting(element):
cls.poll_message_set("Active object must be a bend fitting.")
return False
return True
def execute(self, context):
active = context.active_object
bend_element = tool.Ifc.get_entity(active)
if bend_element is None or not _is_bend_fitting(bend_element):
self.report({"ERROR"}, "Active object is not a bend fitting.")
return {"CANCELLED"}
connected_segments: list = []
for port in tool.System.get_ports(bend_element):
connected_port = tool.System.get_connected_port(port)
if connected_port is None:
continue
related = tool.System.get_port_relating_element(connected_port)
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
connected_segments.append(related)
if len(connected_segments) != 2:
self.report(
{"ERROR"},
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
)
return {"CANCELLED"}
# Read parametric values from the bend type's BBIM_Fitting pset. The
# type carries the canonical parameters; querying the occurrence
# would force a get_type round-trip and miss user-edited types.
bend_type = ifcopenshell.util.element.get_type(bend_element)
if bend_type is None:
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
return {"CANCELLED"}
bend_type_obj = tool.Ifc.get_object(bend_type)
if bend_type_obj is None:
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
return {"CANCELLED"}
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
if bbim is None:
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
return {"CANCELLED"}
data = bbim.get("data_dict", {})
props = preview_base.get_preview_props(context, "bend")
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
# Segment order is load-bearing: the bend's lateral sign and z-axis
# flip are derived from which segment is "start" vs "end". Re-edit
# must reuse the same pairing as the original create so the recreate
# lands at the same orientation.
start_segment, end_segment = connected_segments
props.start_segment_id = start_segment.id()
props.end_segment_id = end_segment.id()
# Pset values are in IFC native units; scene units come from si_conversion.
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
props.radius = float(data.get("radius", 0.2)) * si_conversion
props.editing_bend_id = bend_element.id()
props.is_active = True
return {"FINISHED"}
def _bend_preview_segments(context):
"""Resolve the two segment objects from the scene-level preview props.
Re-resolves by IFC id each frame so undo / file reload during preview
never dangles a stale bpy reference."""
props = context.scene.BIMPreviewProperties.bend
ifc_file = tool.Ifc.get()
if ifc_file is None or not props.is_active:
return None, None
try:
start_element = ifc_file.by_id(props.start_segment_id)
end_element = ifc_file.by_id(props.end_segment_id)
except Exception:
return None, None
start_obj = tool.Ifc.get_object(start_element) if start_element else None
end_obj = tool.Ifc.get_object(end_element) if end_element else None
return start_obj, end_obj
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
draws + drags along local +X by convention."""
x = x_direction.normalized()
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
y = (seed - x * seed.dot(x)).normalized()
z = x.cross(y)
mat = Matrix.Identity(4)
mat[0][:3] = (x.x, y.x, z.x)
mat[1][:3] = (x.y, y.y, z.y)
mat[2][:3] = (x.z, y.z, z.z)
mat.translation = location
return mat
class GizmoBendPreview(bpy.types.GizmoGroup):
"""Interactive gizmo group for the bend preview flow.
Three dimension widgets drag start_length / end_length / radius; two
icon gizmos commit or cancel. When the geometry is degenerate the
dimensions and validate hide but cancel stays visible so the user
always has an exit."""
bl_idname = "OBJECT_GGT_bim_bend_preview"
bl_label = "Bend Preview Gizmos"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE: ClassVar[float] = 0.375
ICON_SPACING_X: ClassVar[float] = 0.4
ICON_Z_OFFSET: ClassVar[float] = 1.5
@classmethod
def poll(cls, context):
preview = getattr(context.scene, "BIMPreviewProperties", None)
props = preview.bend if preview is not None else None
if props is None or not props.is_active:
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
ifc_file = tool.Ifc.get()
if ifc_file is None:
return False
try:
ifc_file.by_id(props.start_segment_id)
ifc_file.by_id(props.end_segment_id)
except (RuntimeError, KeyError):
return False
return True
def setup(self, context):
prefs = tool.Blender.get_addon_preferences()
default_color = tuple(prefs.decorations_colour[:3])
highlight_color = tuple(prefs.decorator_color_selected[:3])
_props = preview_base.make_props_callback("bend")
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
gz.axis = Vector((1, 0, 0))
gz.invert_delta = invert_delta
gz.delta_scale = 1.0
gz.prop_name = prop_name
gz.gizmo_group = self
gz.color = default_color
gz.color_highlight = highlight_color
gz.alpha = 1.0
gz.use_draw_modal = True
gz.use_draw_scale = False
gz.text_offset_sign = 1
gz.text_alignment = gizmo.TextAlignment.CENTER
gz.show_start_arrow = False
gz.show_end_arrow = True
gz.show_extension_lines = False
gz.text_formatter = None
return gz
self.start_dim = setup_dimension("start_length", "Start Length")
self.end_dim = setup_dimension("end_length", "End Length")
self.radius_dim = setup_dimension("radius", "Radius")
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
self.validate_icon.use_draw_scale = False
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
self.validate_icon.color_highlight = highlight_color
self.validate_icon.target_set_operator("bim.finish_bend_preview")
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
self.cancel_icon.use_draw_scale = False
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
self.cancel_icon.color_highlight = highlight_color
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
def refresh(self, context):
self._position_gizmos(context)
def draw_prepare(self, context):
self._position_gizmos(context)
def _position_gizmos(self, context):
"""Place gizmos at the bend intersection using the current scene
props. Cancel stays visible on degenerate geometry so the user
always has an exit; the other widgets hide when there's no defined
tangent / arc to anchor them on."""
start_obj, end_obj = _bend_preview_segments(context)
if start_obj is None or end_obj is None:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = True
return
props = context.scene.BIMPreviewProperties.bend
preview = cached_compute_bend_preview_polylines(
start_obj, end_obj, props.start_length, props.end_length, props.radius
)
if not preview["valid"]:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
gz.hide = True
self.cancel_icon.hide = False
axes = preview.get("invalid_axes") or []
if axes:
intersection_point = axes[0][1]
billboard_rot = gizmo.get_billboard_rotation(context)
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
return
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = False
leg_a_far, leg_a_end = preview["leg_a"]
leg_b_far, leg_b_end = preview["leg_b"]
toward_bend_a = (
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
)
toward_bend_b = (
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
)
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
# axis is set in world space every frame so the drag projection
# matches the visual regardless of either segment's matrix_world.
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
self.start_dim.axis = -toward_bend_a
self.start_dim.set_dimension_length(props.start_length)
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
self.end_dim.axis = -toward_bend_b
self.end_dim.set_dimension_length(props.end_length)
arc = preview["arc"]
if len(arc) >= 3:
mid = len(arc) // 2
chord_mid = (arc[0] + arc[-1]) * 0.5
toward_mid = arc[mid] - chord_mid
if toward_mid.length > 1e-6:
toward_mid = toward_mid.normalized()
half_chord = (arc[-1] - arc[0]).length * 0.5
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
arc_center = chord_mid - toward_mid * center_dist
radial_out = arc[mid] - arc_center
if radial_out.length > 1e-6:
radial_out.normalize()
inward = -radial_out
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
self.radius_dim.axis = inward
self.radius_dim.set_dimension_length(props.radius)
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
billboard_rot = gizmo.get_billboard_rotation(context)
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
@@ -176,6 +176,46 @@ def clear_preview_state(props: bpy.types.PropertyGroup) -> None:
setattr(props, name, 0)
# --- Standard Finish flow ----------------------------------------------------
def commit_preview(
operator: bpy.types.Operator,
context: bpy.types.Context,
attr: str,
target_op_name: str,
kwarg_names: tuple[str, ...],
) -> set[str]:
"""Standard Finish-Preview dispatch: validate context + active preview,
read kwargs off the draft, call ``bpy.ops.bim.<target_op_name>(**kwargs)``,
and clear the preview on success.
The dispatched operator's own ``self.report({"ERROR"})`` paths are promoted
by ``bpy.ops`` to ``RuntimeError`` catching it here surfaces the message
to the user via ``operator.report`` rather than leaving Blender's operator
state half-broken (which silently disables downstream gizmo polls).
Returns the dispatched operator's result set verbatim so callers can
pass it straight back from their own ``execute``."""
if context.screen is None:
return {"CANCELLED"}
props = get_preview_props(context, attr)
if props is None or not props.is_active:
return {"CANCELLED"}
if tool.Ifc.get() is None:
operator.report({"ERROR"}, "No IFC file loaded.")
return {"CANCELLED"}
kwargs = {name: getattr(props, name) for name in kwarg_names}
try:
result = getattr(bpy.ops.bim, target_op_name)(**kwargs)
except RuntimeError as exc:
operator.report({"ERROR"}, str(exc))
return {"CANCELLED"}
if "FINISHED" in result:
clear_preview_state(props)
return result
# --- Esc dispatch ------------------------------------------------------------
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (