diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index b29995c3d4..1b6d57f95f 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -275,6 +275,7 @@ classes = ( mep.EnableBendPreview, mep.FinishBendPreview, mep.CancelBendPreview, + mep.EnableBendPreviewFromBend, mep.GizmoBendPreview, mep.EnableEditingPipeSegment, mep.FinishEditingPipeSegment, diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index a5d569d9f6..fcdba5db37 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -1243,12 +1243,35 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): radius: bpy.props.FloatProperty( name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0 ) + editing_bend_id: bpy.props.IntProperty( + name="Existing Bend Element ID", + default=0, + description="When non-zero, delete this bend fitting + its port connections before creating the new bend.", + ) def _execute(self, context): start_element, end_element = None, None ifc_file = tool.Ifc.get() si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + # Re-edit path: delete the old bend fitting + its port connections so + # the segments are free to be re-joined by a fresh bend below. Runs + # inside the same operator transaction as the recreate so a single + # Ctrl+Z rewinds both. + if self.editing_bend_id: + try: + old_bend = ifc_file.by_id(self.editing_bend_id) + except RuntimeError: + old_bend = None + if old_bend is not None: + for port in tool.System.get_ports(old_bend): + rel = next(iter(port.ConnectedFrom + port.ConnectedTo), None) + if rel is not None and rel.is_a("IfcRelConnectsPorts"): + bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) + old_bend_obj = tool.Ifc.get_object(old_bend) + if old_bend_obj is not None: + tool.Geometry.delete_ifc_object(old_bend_obj) + if self.start_segment_id and self.end_segment_id: start_element = ifc_file.by_id(self.start_segment_id) end_element = ifc_file.by_id(self.end_segment_id) @@ -1911,6 +1934,7 @@ class FinishBendPreview(bpy.types.Operator): 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)) @@ -1938,6 +1962,103 @@ class CancelBendPreview(bpy.types.Operator): 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"``.""" + if element is None or not element.is_a("IfcFlowFitting"): + return False + element_type = ifcopenshell.util.element.get_type(element) + if element_type is None: + return False + return getattr(element_type, "PredefinedType", None) == "BEND" + + def _intersection_past_near(intersection: Vector, near: Vector, far: Vector) -> bool: """True iff ``intersection`` lies past ``near`` away from ``far`` — i.e. on the bend-corner side of the segment. Used to reject configurations @@ -2918,6 +3039,10 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool: return False +def _active_is_bend_fitting(obj: bpy.types.Object) -> bool: + return _is_bend_fitting(tool.Ifc.get_entity(obj)) + + class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): """Icon-action gizmos for the MEP one-shot operators. @@ -2966,6 +3091,12 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): operator="bim.select_mep_path_members", visibility_condition=lambda obj: _selection_size() == 1 and _active_mep_has_connected_neighbor(obj), ), + IconActionConfig( + name="re_edit_bend", + icon="VIEW3D_GT_pen", + operator="bim.enable_bend_preview_from_bend", + visibility_condition=lambda obj: _selection_size() == 1 and _active_is_bend_fitting(obj), + ), IconActionConfig( name="lock_start_open", icon="VIEW3D_GT_lock_open", diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index d8dc146fd6..c91ae1f322 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -2066,6 +2066,16 @@ class BIMBendPreviewProperties(PropertyGroup): subtype="DISTANCE", description="Inner radius of the bend curve", ) + editing_bend_id: bpy.props.IntProperty( + default=0, + options={"SKIP_SAVE"}, + description=( + "IFC element id of an existing bend fitting being re-edited " + "(non-zero only on the pen-icon re-edit flow). The create " + "operator deletes this bend + its port connections before " + "recreating with the new parameters." + ), + ) if TYPE_CHECKING: is_active: bool @@ -2074,6 +2084,7 @@ class BIMBendPreviewProperties(PropertyGroup): start_length: float end_length: float radius: float + editing_bend_id: int class BIMWallFilletPreviewProperties(PropertyGroup): diff --git a/src/bonsai/test/bim/module/model/test_mep_bend_preview.py b/src/bonsai/test/bim/module/model/test_mep_bend_preview.py index 9d3678d68f..8616dca954 100644 --- a/src/bonsai/test/bim/module/model/test_mep_bend_preview.py +++ b/src/bonsai/test/bim/module/model/test_mep_bend_preview.py @@ -266,6 +266,62 @@ def test_bend_preview_decorator_class_present(): assert hasattr(BendPreviewDecorator, "uninstall") +def test_enable_bend_preview_from_bend_is_registered(): + """The re-edit entry point is discoverable via ``bpy.ops.bim`` so the + pen-icon dispatch in ``GizmoMEPActions`` resolves at click time.""" + assert hasattr(bpy.ops.bim, "enable_bend_preview_from_bend") + + +def test_bim_bend_preview_properties_has_editing_bend_id(): + """The re-edit dispatch flag rides on the same preview PropertyGroup as + the rest of the bend draft state. Without this field on the umbrella, + re-edit cancel / commit cleanup would not zero it via + ``clear_preview_state`` (which iterates ``*_id`` IntProperty fields).""" + bend_props = bpy.context.scene.BIMPreviewProperties.bend + assert hasattr(bend_props, "editing_bend_id") + assert bend_props.editing_bend_id == 0 + + +@pytest.mark.parametrize( + "ifc_class,predefined_type,expected", + [ + ("IfcFlowFitting", "BEND", True), + ("IfcFlowFitting", "TRANSITION", False), + ("IfcFlowFitting", "OBSTRUCTION", False), + ("IfcFlowFitting", None, False), + ("IfcFlowSegment", "BEND", False), + ("IfcWall", "BEND", False), + ], +) +def test_is_bend_fitting_predicate_truth_table(ifc_class, predefined_type, expected): + """The predicate classifies each occurrence by walking up to its type's + ``PredefinedType``. Pin the four-way branch: matching class + matching + type, matching class + other type, wrong class, no type at all.""" + from unittest.mock import Mock + + from bonsai.bim.module.model.mep import _is_bend_fitting + + element = Mock() + element.is_a = Mock(side_effect=lambda c: c == ifc_class) + if predefined_type is None: + element_type = None + else: + element_type = Mock() + element_type.PredefinedType = predefined_type + + with patch("ifcopenshell.util.element.get_type", return_value=element_type): + assert _is_bend_fitting(element) is expected + + +def test_is_bend_fitting_predicate_returns_false_on_none(): + """The predicate is total — callers pass it raw ``tool.Ifc.get_entity`` + results which can be ``None`` for unbound Blender objects, and the + visibility-condition lambda must not raise from a gizmo poll.""" + from bonsai.bim.module.model.mep import _is_bend_fitting + + assert _is_bend_fitting(None) is False + + # --------------------------------------------------------------------------- # Finish-catches-RuntimeError contract # --------------------------------------------------------------------------- @@ -294,6 +350,7 @@ def test_finish_bend_preview_catches_runtime_error_from_dispatch(): start_length=0.1, end_length=0.1, radius=0.2, + editing_bend_id=0, ) context = SimpleNamespace( screen=MagicMock(),