diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index b3c24c9610..ff7ceda05c 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -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, ) diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index df34166229..c110734ef7 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -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"} diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index f2777cf743..a3033deba5 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -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