Add MEP bend preview Scene properties + lifecycle

MEPAddBend exists on the main flow but commits bend geometry with
hardcoded defaults (start_length=0.1, end_length=0.1, radius=0.2)
with no opportunity to tune before commit. The new scene-level
BIMBendPreviewProperties hosts a draft (start_segment_id,
end_segment_id, start_length, end_length, radius); EnableBendPreview
populates it from the two selected MEP segments after asserting they
are non-parallel, FinishBendPreview dispatches MEPAddBend with the
tuned values and clears the draft, CancelBendPreview discards it.
Scene-level placement follows CLAUDE.md 2.9: a bend creates a new
fitting entity between two segments, so neither segment alone owns
the draft. Foundation for the upcoming bend preview gizmo group and
decorator.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-08 10:17:30 +02:00
committed by Thomas Krijnen
parent adf2603c0e
commit 0fc5c97eea
3 changed files with 192 additions and 0 deletions
@@ -186,6 +186,7 @@ classes = (
prop.BIMWallProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
prop.BIMBendPreviewProperties,
prop.BIMWallFilletPreviewProperties,
prop.BIMPreviewProperties,
ui.BIM_PT_array,
@@ -263,6 +264,9 @@ classes = (
mep.MEPAddObstruction,
mep.MEPAddTransition,
mep.MEPAddBend,
mep.EnableBendPreview,
mep.FinishBendPreview,
mep.CancelBendPreview,
external.ApplyExternalParametricGeometry,
)
+134
View File
@@ -38,6 +38,7 @@ from mathutils import Matrix, Vector
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.profile import DumbProfileJoiner
from bonsai.tool.cad import VTX_PRECISION
@@ -1263,3 +1264,136 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
self.report({"INFO"}, f"Success!.. kind of. The angle was {round(bend_data['angle'])}")
return {"FINISHED"}
def _n_mep_selected(n: int) -> bool:
selected = tool.Blender.get_selected_objects()
if len(selected) != n:
return False
for selected_obj in selected:
element = tool.Ifc.get_entity(selected_obj)
if element is None or not tool.System.is_mep_element(element):
return False
return True
def segments_are_parallel(start_object, end_object) -> bool:
"""True iff the two MEP segments' axes are parallel (or collinear)."""
start_axis = tool.Model.get_flow_segment_axis(start_object)
end_axis = tool.Model.get_flow_segment_axis(end_object)
return tool.Cad.are_edges_parallel(start_axis, end_axis)
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"}
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,
)
except RuntimeError as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
if "FINISHED" in result:
props.is_active = False
props.start_segment_id = 0
props.end_segment_id = 0
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"}
props.is_active = False
props.start_segment_id = 0
props.end_segment_id = 0
return {"FINISHED"}
@@ -1924,6 +1924,58 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
class BIMBendPreviewProperties(PropertyGroup):
"""Scene-level pending state for the bend-creation preview flow.
Scene-level (not per-object) because the bend involves two segments by
IFC id — neither alone owns the draft."""
is_active: bpy.props.BoolProperty(
default=False,
options={"SKIP_SAVE"},
description="True while the bend-creation preview flow is active.",
)
start_segment_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the start (active) segment.",
)
end_segment_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the end (other selected) segment.",
)
start_length: bpy.props.FloatProperty(
name="Start Length",
default=0.1,
min=0.001,
subtype="DISTANCE",
description="Length of the bend fitting's tangent leg on the start (active) segment side",
)
end_length: bpy.props.FloatProperty(
name="End Length",
default=0.1,
min=0.001,
subtype="DISTANCE",
description="Length of the bend fitting's tangent leg on the end (other) segment side",
)
radius: bpy.props.FloatProperty(
name="Radius",
default=0.2,
min=0.001,
subtype="DISTANCE",
description="Inner radius of the bend curve",
)
if TYPE_CHECKING:
is_active: bool
start_segment_id: int
end_segment_id: int
start_length: float
end_length: float
radius: float
class BIMWallFilletPreviewProperties(PropertyGroup):
"""Scene-level pending state for the wall-fillet preview flow.
@@ -1980,7 +2032,9 @@ class BIMWallFilletPreviewProperties(PropertyGroup):
class BIMPreviewProperties(PropertyGroup):
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
bend: bpy.props.PointerProperty(type=BIMBendPreviewProperties)
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
if TYPE_CHECKING:
bend: BIMBendPreviewProperties
wall_fillet: BIMWallFilletPreviewProperties