From 6fa984ce2bdb1cd5e54d0ea1f41d70ee0875e376 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 14:16:33 +0200 Subject: [PATCH] Fix MEP pair-disconnect crash and bend re-edit pen icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three user-facing fixes for the MEP-system gizmo surface: 1. MEP pair-disconnect no longer crashes Blender. The MEPSystemPathDecorator cached entity_instance references in _cached_walk; deleting a bridging fitting via the gizmo left a freed SWIG handle in the list, and the next _build_geometry pass segfaulted on .is_a. The cache now stores STEP integer ids and re-resolves via ifc_file.by_id on each draw, plus folds tool.Parametric.get_geom_generation into the cache key — ifcopenshell.api mutations invalidate before the next frame regardless of how the deletion was routed. 2. Bend re-edit pen icon stays reachable. The bend creation path tessellates the swept-disk body (upstream geometry-kernel workaround), so tool.System.has_parametric_body correctly returns False for a freshly-committed bend. _active_is_bend_fitting and GizmoMEPActions.is_eligible_object now fall back to the type's BBIM_Fitting pset — the same source bim.enable_bend_preview_from_bend reads parameters from — keeping the pen icon eligible. 3. MEP pair / per-port unjoin icons unified through bim.disconnect_elements. The MEP gizmo group's three unjoin icons (pair, start, end) now share the wall-disconnect surface: same VIEW3D_GT_wall_link_toggle icon, same bim.disconnect_elements operator. tool.Connection.find_rels learned a new "mep-pair-fitting" kind that returns the bridging fitting as the disconnect target; core.connection.disconnect_rel grew the matching dispatch arm. The old MEPUnjoinAtPort and MEPUnjoinPair operators are removed. Also registered wall.GizmoPairDisconnect (previously declared but never in the classes tuple, so dead code) for the wall+slab pair-disconnect surface, and extracted MEP port-topology helpers (find_bridging_fitting, is_disconnectable_fitting, neighbours_at_ports) onto tool.System so the canonical walk has a single home. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 3 +- .../bonsai/bim/module/model/decorator.py | 42 +++- src/bonsai/bonsai/bim/module/model/mep.py | 209 +++++++----------- src/bonsai/bonsai/core/connection.py | 30 ++- src/bonsai/bonsai/tool/connection.py | 48 ++-- src/bonsai/bonsai/tool/system.py | 63 ++++++ .../test_connected_network_path_decorator.py | 142 ++++++++++++ .../module/model/test_disconnect_elements.py | 139 ++++++++++++ .../module/model/test_mep_actions_cache.py | 18 +- .../model/test_mep_actions_visibility.py | 179 ++++++++++++--- .../model/test_mep_disconnect_integration.py | 139 ++++++++++++ .../module/model/test_mep_port_operators.py | 169 -------------- src/bonsai/test/core/test_connection.py | 192 +++++++++++++--- src/bonsai/test/tool/test_system.py | 127 +++++++++++ 14 files changed, 1102 insertions(+), 398 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index d602c715a3..3bfb1accea 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -110,6 +110,7 @@ classes = ( wall.GizmoWallFilletPreview, wall.GizmoWallFilletReedit, wall.GizmoWallFilletToggleOpenings, + wall.GizmoPairDisconnect, wall.GizmoSlabEdition, wall.GizmoSlabUnjoinWalls, wall.GizmoWallJoinIntersection, @@ -279,9 +280,7 @@ classes = ( mep.MEPAddObstruction, mep.MEPAddTransition, mep.MEPAddBend, - mep.MEPUnjoinAtPort, mep.MEPRemoveTerminalFitting, - mep.MEPUnjoinPair, mep.SelectMEPPathMembers, mep.MEPJoinSegments, mep_bend_preview.EnableBendPreview, diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 56b0df3bbe..8ec53af5a8 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -2569,14 +2569,20 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator): CONNECTION_EPS_SQ = 1e-4 * 1e-4 def __init__(self) -> None: - # Two-tier cache. Walk cache keyed on (start_guid, ifc_file): re-walk - # only on selection change or file reload. Compare ``ifc_file`` with + # Walk cache keyed on (start_guid, ifc_file, geom_gen). Stores STEP + # integer ids rather than ``entity_instance`` references — re-resolved + # via ``ifc_file.by_id`` on each cache hit. Structurally rules out + # the dangling-SWIG-handle class of bug: an entity removed between + # frames either bumps geom_gen (cache miss → re-walk) or fails to + # re-resolve (handled below by re-walking). Compare ``ifc_file`` with # ``is`` (not id()) so a GC-recycled id() can't produce a false hit. self._cached_start_guid: str | None = None self._cached_ifc_file: Any = None - self._cached_walk: list[Any] = [] - # Geometry cache: shared TokenCache so resolved world-space lines + - # dots re-build on every depsgraph / undo / redo / load. + self._cached_geom_gen: int = -1 + self._cached_walk_ids: list[int] = [] + # Geometry cache: shared TokenCache. Key folds in geom_gen so IFC + # mutations that don't surface via the depsgraph still flush the + # resolved world-space lines and dots. self._geom_cache: TokenCache[ tuple[ list[tuple[tuple[float, float, float], tuple[float, float, float]]], @@ -2739,9 +2745,22 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator): start_guid = start_element.GlobalId if start_guid == self._failed_seed_guid: return - if start_guid == self._cached_start_guid and ifc_file is self._cached_ifc_file and self._cached_walk: - connected = self._cached_walk - else: + current_geom_gen = tool.Parametric.get_geom_generation() + connected: list[Any] | None = None + if ( + start_guid == self._cached_start_guid + and ifc_file is self._cached_ifc_file + and current_geom_gen == self._cached_geom_gen + and self._cached_walk_ids + ): + try: + connected = [ifc_file.by_id(eid) for eid in self._cached_walk_ids] + except RuntimeError: + # An entity was removed without bumping geom_gen — rare but + # possible from non-operator code paths. Force a re-walk + # rather than feeding a stale handle to _build_geometry. + connected = None + if connected is None: try: connected = self._walk(start_element) except Exception: @@ -2750,12 +2769,13 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator): traceback.print_exc() self._walk_failure_logged = True - self._cached_walk = [] + self._cached_walk_ids = [] self._failed_seed_guid = start_guid return self._cached_start_guid = start_guid self._cached_ifc_file = ifc_file - self._cached_walk = connected + self._cached_geom_gen = current_geom_gen + self._cached_walk_ids = [e.id() for e in connected] if not connected: return @@ -2770,7 +2790,7 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator): try: lines, free_points, connection_points = self._geom_cache.get_or_compute( - (start_guid, id(ifc_file)), + (start_guid, id(ifc_file), current_geom_gen), lambda: self._build_geometry(connected), ) except Exception: diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index dea4129e7d..26a5a53de0 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -693,27 +693,6 @@ def get_connected_element_at_segment_port(segment, at_segment_start): return tool.System.get_port_relating_element(connected_port) -def find_fitting_between_segments(segment_a, segment_b): - """Single IfcFlowFitting bridging segment_a and segment_b via ports, or - ``None`` if no fitting (or multiple fittings — only direct one-fitting - joins handled).""" - if not (segment_a.is_a("IfcFlowSegment") and segment_b.is_a("IfcFlowSegment")): - return None - b_ports_set = set(tool.System.get_ports(segment_b)) - for a_port in tool.System.get_ports(segment_a): - connected_port = tool.System.get_connected_port(a_port) - if connected_port is None: - continue - fitting = tool.System.get_port_relating_element(connected_port) - if fitting is None or not fitting.is_a("IfcFlowFitting"): - continue - for fitting_port in tool.System.get_ports(fitting): - other_port = tool.System.get_connected_port(fitting_port) - if other_port is not None and other_port in b_ports_set: - return fitting - return None - - def _resolve_active_mep_segment(operator, context): """Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting. @@ -808,52 +787,6 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator): - """Delete the IfcFlowFitting that bridges a segment's port to a second element. - - Used when the connection at the port is in the JOINED state (the fitting - has at least one other port connecting to a different element). The - segment isn't resized — only the bridging fitting is removed. Refuses - to act on an OBSTRUCTION fitting (those are routed through - ``bim.mep_add_obstruction`` with mode=REMOVE which extends the segment - to absorb the freed length).""" - - bl_idname = "bim.mep_unjoin_at_port" - bl_label = "Unjoin MEP Segment at Port" - bl_description = "Disconnect the segment from the fitting at the named port (deletes the fitting)" - bl_options = {"REGISTER", "UNDO"} - segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0) - position: bpy.props.EnumProperty( - name="Port", - items=[ - ("START", "At Start", "Operate on the segment's start port"), - ("END", "At End", "Operate on the segment's end port"), - ], - default="END", - ) - - def _execute(self, context): - 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).") - 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"} - - class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator): """Remove the terminal fitting at a segment's named port. @@ -906,44 +839,6 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator): - """Delete the IfcFlowFitting joining two selected MEP segments. - - Removes the fitting; segments are left in place for the user to reposition.""" - - bl_idname = "bim.mep_unjoin_pair" - bl_label = "Unjoin MEP Segments" - bl_description = "Delete the fitting joining the two selected MEP segments" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not _n_mep_selected(2): - cls.poll_message_set("Select exactly 2 MEP segments joined by a fitting.") - return False - return True - - def _execute(self, context): - selected_objs = tool.Blender.get_selected_objects() - elements = [tool.Ifc.get_entity(o) for o in selected_objs] - if any(e is None or not e.is_a("IfcFlowSegment") for e in elements): - self.report({"ERROR"}, "Both selected objects must be MEP segments.") - return {"CANCELLED"} - fitting = find_fitting_between_segments(elements[0], elements[1]) - if fitting is None: - self.report({"ERROR"}, "No single fitting joins the selected segments.") - return {"CANCELLED"} - if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION": - self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).") - 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"} - - class SelectMEPPathMembers(bpy.types.Operator): """Replace the selection with every MEP element reachable from the active one via IfcRelConnectsPorts — the entire connected distribution network.""" @@ -2677,10 +2572,22 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool: def _active_is_bend_fitting(obj: bpy.types.Object) -> bool: + """True iff the active object is a parametric BEND fitting eligible for + the bend-preview re-edit path. Re-edit reads parameters from the type's + ``BBIM_Fitting`` pset, so that pset's presence is the ground truth for + re-editability — not the body representation class. The bend creation + path tessellates the swept-disk body as an upstream-geometry-kernel + workaround, so a freshly-committed bend's body contains only an + ``IfcTriangulatedFaceSet`` and ``has_parametric_body`` correctly + returns False for it; the pset gate is what keeps the pen icon + eligible.""" element = tool.Ifc.get_entity(obj) if not _is_bend_fitting(element): return False - return tool.System.has_parametric_body(element) + element_type = ifcopenshell.util.element.get_type(element) + if element_type is None: + return False + return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): @@ -2763,20 +2670,20 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): ), IconActionConfig( name="unjoin_start", - icon="VIEW3D_GT_unjoin", - operator="bim.mep_unjoin_at_port", + icon="VIEW3D_GT_wall_link_toggle", + operator="bim.disconnect_elements", visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), ), IconActionConfig( name="unjoin_end", - icon="VIEW3D_GT_unjoin", - operator="bim.mep_unjoin_at_port", + icon="VIEW3D_GT_wall_link_toggle", + operator="bim.disconnect_elements", visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), ), IconActionConfig( name="unjoin_pair", - icon="VIEW3D_GT_unjoin", - operator="bim.mep_unjoin_pair", + icon="VIEW3D_GT_wall_link_toggle", + operator="bim.disconnect_elements", visibility_condition=lambda _active: _n_mep_selected(2), ), ] @@ -2794,7 +2701,17 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): element = tool.Ifc.get_entity(obj) if element is None or not tool.System.is_mep_element(element): return False - return tool.System.has_parametric_body(element) + if tool.System.has_parametric_body(element): + return True + # Bend fittings carry their parametric definition in the type's + # ``BBIM_Fitting`` pset because the bend creation path tessellates + # the swept-disk body (upstream geometry-kernel workaround), so + # ``has_parametric_body`` returns False for them. Fall back to the + # pset gate so the pen icon (re_edit_bend) stays reachable. + element_type = ifcopenshell.util.element.get_type(element) + if element_type is None: + return False + return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None def setup(self, context: bpy.types.Context) -> None: super().setup(context) @@ -2802,11 +2719,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): @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__gizmo`` attributes so - tests can exercise the wiring without instantiating the GizmoGroup.""" + """Pre-fill ``position`` (and ``mode`` for open-lock) on the lock + icons so a click dispatches to the right port without a per-frame + property write, and pre-bind the unified ``bim.disconnect_elements`` + operator on each unjoin icon so :py:meth:`position_gizmos` only has + to update the two GUIDs per frame. Takes any object with + ``action__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: @@ -2820,19 +2739,12 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): op_props = gz.target_set_operator("bim.mep_remove_terminal_fitting") op_props.position = position_arg - for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")): - 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()) + group.unjoin_op_props = {} 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 + group.unjoin_op_props[config_name] = gz.target_set_operator("bim.disconnect_elements") def position_gizmos(self, context: bpy.types.Context) -> None: """Lay out icons across three regions: row above bbox top, segment @@ -2899,6 +2811,10 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): if not visible: gz.hide = True continue + if config.name.startswith("unjoin_"): + if not self._bind_unjoin_at_port(config.name, obj, endpoint_kind == "START"): + gz.hide = True + continue if segment_endpoints is None: segment_endpoints = tool.Model.get_flow_segment_axis(obj) start_world, end_world = segment_endpoints @@ -2910,7 +2826,7 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): if len(selected) == 2: elements = [tool.Ifc.get_entity(o) for o in selected] if all(e is not None and e.is_a("IfcFlowSegment") for e in elements): - pair_fitting = find_fitting_between_segments(elements[0], elements[1]) or False + pair_fitting = tool.System.find_bridging_fitting(elements[0], elements[1]) or False else: pair_fitting = False else: @@ -2922,6 +2838,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): gz.hide = True continue + if config.name == "unjoin_pair": + selected = tool.Blender.get_selected_objects() + pair_elements = [tool.Ifc.get_entity(o) for o in selected] + if not self._bind_unjoin_pair(pair_elements): + gz.hide = True + continue + if not bend_anchor_attempted: bend_anchor = compute_mep_join_location() bend_anchor_attempted = True @@ -2950,3 +2873,33 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): if name in self.ENDPOINT_CONFIGS: return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO return self.ICON_SCALE + + def _bind_unjoin_at_port(self, config_name: str, segment_obj: bpy.types.Object, at_segment_start: bool) -> bool: + """Resolve the fitting at the named port and bind both GUIDs on the + pre-wired ``bim.disconnect_elements`` op_props. Returns False when + the partner is unresolvable (port not joined to a disconnectable + fitting), and the caller hides the icon.""" + element = tool.Ifc.get_entity(segment_obj) + if element is None: + return False + fitting = get_connected_element_at_segment_port(element, at_segment_start) + if fitting is None or not fitting.is_a("IfcFlowFitting"): + return False + if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION": + return False + op_props = self.unjoin_op_props[config_name] + op_props.element_a_guid = element.GlobalId + op_props.element_b_guid = fitting.GlobalId + return True + + def _bind_unjoin_pair(self, pair_elements: list[ifcopenshell.entity_instance | None]) -> bool: + """Bind both segment GUIDs on the pair-disconnect icon's pre-wired + ``bim.disconnect_elements`` op_props. Returns False when either side + is missing a GlobalId (e.g. selection lost an active object), and + the caller hides the icon.""" + if len(pair_elements) != 2 or any(e is None for e in pair_elements): + return False + op_props = self.unjoin_op_props["unjoin_pair"] + op_props.element_a_guid = pair_elements[0].GlobalId + op_props.element_b_guid = pair_elements[1].GlobalId + return True diff --git a/src/bonsai/bonsai/core/connection.py b/src/bonsai/bonsai/core/connection.py index 71a2bb871c..8b5d80d898 100644 --- a/src/bonsai/bonsai/core/connection.py +++ b/src/bonsai/bonsai/core/connection.py @@ -27,6 +27,12 @@ disconnect-on-delete). Each rel kind returned by :py:meth:`find_rels_for_element` maps to a single arm here, so adding a new rel kind means extending one dispatch table — both call sites benefit automatically and the AST forward-compat guard enforces coverage. + +For the ``"mep-pair-fitting"`` kind the ``rel`` slot carries the +``IfcFlowFitting`` itself rather than a relationship entity — the subject +whose removal disconnects the pair. The dispatch treats the slot as the +deletion target and routes through ``geometry.delete_ifc_object`` so the +cascade-on-delete contract still owns port-rel cleanup. """ from __future__ import annotations @@ -37,21 +43,20 @@ import bonsai.core.geometry from bonsai.core.model import regenerate_wall_to_underside if TYPE_CHECKING: - import bpy import ifcopenshell import bonsai.tool as tool def disconnect_rel( - ifc: "type[tool.Ifc]", - geometry: "type[tool.Geometry]", - model: "type[tool.Model]", - connection: "type[tool.Connection]", - rel: "ifcopenshell.entity_instance", + ifc: type[tool.Ifc], + geometry: type[tool.Geometry], + model: type[tool.Model], + connection: type[tool.Connection], + rel: ifcopenshell.entity_instance, kind: str, - elem: "ifcopenshell.entity_instance", - partner: "ifcopenshell.entity_instance", + elem: ifcopenshell.entity_instance, + partner: ifcopenshell.entity_instance, skip_elem_recreate: bool = False, skip_partner_recreate: bool = False, ) -> None: @@ -95,5 +100,14 @@ def disconnect_rel( relating_element=rel.RelatingElement, related_element=rel.RelatedElement, ) + elif kind == "mep-pair-fitting": + fitting = rel # slot semantics: the fitting whose removal disconnects the pair. + if skip_elem_recreate and fitting is elem: + return + if skip_partner_recreate and fitting is partner: + return + fitting_obj = ifc.get_object(fitting) + if fitting_obj is not None: + geometry.delete_ifc_object(fitting_obj) else: raise ValueError(f"Unknown rel kind: {kind!r}") diff --git a/src/bonsai/bonsai/tool/connection.py b/src/bonsai/bonsai/tool/connection.py index 4ec154b573..709f277cc5 100644 --- a/src/bonsai/bonsai/tool/connection.py +++ b/src/bonsai/bonsai/tool/connection.py @@ -29,6 +29,11 @@ operator dispatch the right post-disconnect cleanup: - ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"`` (the rel kind ``extend_walls_to_underside`` creates) - ``"element"`` for any other ``IfcRelConnectsElements`` +- ``"mep-pair-fitting"`` for an MEP pair whose disconnect is effected by + removing a bridging ``IfcFlowFitting``. The ``rel`` slot for this kind + carries the fitting entity itself (not a relationship entity) — the + subject whose removal disconnects the pair. ``OBSTRUCTION`` fittings are + excluded; those are removed via ``bim.mep_add_obstruction(mode=REMOVE)``. Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect operator's cleanup switch maps each kind to the right post-mutation calls.""" @@ -37,6 +42,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +import bonsai.tool as tool + if TYPE_CHECKING: import ifcopenshell @@ -45,9 +52,9 @@ class Connection: @classmethod def find_rels( cls, - elem_a: "ifcopenshell.entity_instance", - elem_b: "ifcopenshell.entity_instance", - ) -> "list[tuple[ifcopenshell.entity_instance, str]]": + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> list[tuple[ifcopenshell.entity_instance, str]]: """Return every supported rel linking ``elem_a`` to ``elem_b`` as a list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and ``ConnectedFrom`` because either side of the rel can be the relating @@ -79,14 +86,18 @@ class Connection: kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" _record(rel, kind) + fitting = tool.System.find_bridging_fitting(elem_a, elem_b) + if fitting is not None: + _record(fitting, "mep-pair-fitting") + return rels @classmethod def find_rel( cls, - elem_a: "ifcopenshell.entity_instance", - elem_b: "ifcopenshell.entity_instance", - ) -> "tuple[ifcopenshell.entity_instance | None, str | None]": + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> tuple[ifcopenshell.entity_instance | None, str | None]: """Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than ``find_rels`` when callers only need to know whether a connection exists or what kind it is.""" @@ -96,17 +107,22 @@ class Connection: @classmethod def find_rels_for_element( cls, - elem: "ifcopenshell.entity_instance", - ) -> "list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]": + elem: ifcopenshell.entity_instance, + ) -> list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]: """Return every supported rel touching ``elem`` as ``(rel, kind, partner)`` triples. ``partner`` is the *other* element on the rel — the side cascade cleanup must operate on when ``elem`` is being deleted. - Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry - point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object`` - enumerate everything the disconnect operator would handle pairwise. + Mirrors :py:meth:`find_rels`'s relationship-kind taxonomy. Notably + does NOT emit ``"mep-pair-fitting"`` triples: ``IfcRelConnectsPorts`` + cleanup is owned by ``tool.Geometry.delete_ifc_object``'s + ``remove_port`` loop, which runs unconditionally on any IFC root + deletion. Including MEP here would cause the cascade to also remove + the bridging fitting when one of its connected segments is deleted — + a policy choice (fitting may still join other live segments) that's + better left to the user via the explicit disconnect operator. """ - result: list[tuple["ifcopenshell.entity_instance", str, "ifcopenshell.entity_instance"]] = [] + result: list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]] = [] seen: set[int] = set() def _record(rel, kind, partner): @@ -133,10 +149,10 @@ class Connection: @classmethod def orient_element_top( cls, - rel: "ifcopenshell.entity_instance", - elem_a: "ifcopenshell.entity_instance", - elem_b: "ifcopenshell.entity_instance", - ) -> "tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]": + rel: ifcopenshell.entity_instance, + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]: """Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel. The ``extend_walls_to_underside`` flow stores slab as the relating diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index 29b5223d9b..cf1ed3ab88 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -488,6 +488,69 @@ class System(bonsai.core.tool.System): def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool: return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting") + @classmethod + def is_disconnectable_fitting(cls, element: ifcopenshell.entity_instance) -> bool: + """A fitting whose deletion is the supported teardown for one of + its port connections. ``OBSTRUCTION`` fittings are excluded — they + have a dedicated grow/shrink flow (``bim.mep_add_obstruction`` + with ``mode=REMOVE``) that absorbs the freed segment length.""" + if not element.is_a("IfcFlowFitting"): + return False + return getattr(element, "PredefinedType", None) != "OBSTRUCTION" + + @classmethod + def neighbours_at_ports(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: + """Entities reachable from ``element``'s ports via a single + ``IfcRelConnectsPorts`` hop, deduped by IFC id.""" + neighbours: list[ifcopenshell.entity_instance] = [] + seen: set[int] = set() + for port in cls.get_ports(element): + connected_port = cls.get_connected_port(port) + if connected_port is None: + continue + neighbour = ifcopenshell.util.system.get_port_element(connected_port) + if neighbour is None or neighbour.id() in seen: + continue + seen.add(neighbour.id()) + neighbours.append(neighbour) + return neighbours + + @classmethod + def find_bridging_fitting( + cls, + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> Union[ifcopenshell.entity_instance, None]: + """Return the disconnectable ``IfcFlowFitting`` whose removal + disconnects ``elem_a`` from ``elem_b``, or ``None``. + + Two topologies are handled. (1) Direct port-to-port between a + segment/fitting and a disconnectable fitting: the fitting endpoint + is returned. (2) Two segments joined by a single bridging + disconnectable fitting: the bridging fitting is returned. + ``OBSTRUCTION`` fittings short-circuit to ``None``.""" + if not (cls.is_mep_element(elem_a) and cls.is_mep_element(elem_b)): + return None + + a_neighbours = cls.neighbours_at_ports(elem_a) + b_neighbours = cls.neighbours_at_ports(elem_b) + elem_a_id = elem_a.id() + elem_b_id = elem_b.id() + + if cls.is_disconnectable_fitting(elem_a) and any(n.id() == elem_b_id for n in a_neighbours): + return elem_a + if cls.is_disconnectable_fitting(elem_b) and any(n.id() == elem_a_id for n in b_neighbours): + return elem_b + + a_fittings = [n for n in a_neighbours if cls.is_disconnectable_fitting(n)] + if not a_fittings: + return None + b_fitting_ids = {n.id() for n in b_neighbours if cls.is_disconnectable_fitting(n)} + for fitting in a_fittings: + if fitting.id() in b_fitting_ids: + return fitting + return None + @classmethod def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool: """True when the MEP element's body representation is a profile sweep diff --git a/src/bonsai/test/bim/module/model/test_connected_network_path_decorator.py b/src/bonsai/test/bim/module/model/test_connected_network_path_decorator.py index a956a8ecbe..0376fdabc5 100644 --- a/src/bonsai/test/bim/module/model/test_connected_network_path_decorator.py +++ b/src/bonsai/test/bim/module/model/test_connected_network_path_decorator.py @@ -63,6 +63,148 @@ def test_fully_overridden_subclass_is_accepted(): assert cls.__name__ == "DecoratorWithAllHooks" +# --------------------------------------------------------------------------- +# Cache invalidation — the load-bearing crash guard. +# +# Without geom-generation gating, the walk cache holds entity_instance +# references that outlive their backing IFC entities after an +# ifcopenshell.api mutation. The next _build_geometry pass calls .is_a +# on a freed SWIG handle and segfaults Blender. The gate must fire +# whenever tool.Parametric.get_geom_generation bumps — which is on +# every tool.Ifc.Operator commit (via refresh_post_commit), covering +# every disconnect path. + +from types import SimpleNamespace +from unittest.mock import Mock, patch + + +def _seed_cache(decorator, *, start_guid, ifc_file, geom_gen, walk_ids): + decorator._cached_start_guid = start_guid + decorator._cached_ifc_file = ifc_file + decorator._cached_geom_gen = geom_gen + decorator._cached_walk_ids = list(walk_ids) + + +def test_walk_cache_reuses_when_seed_file_and_geom_gen_unchanged(): + """Cache hit: same seed, same ifc_file, same geom_gen → reuse the + stored walk. Steady-state path while the IFC is idle.""" + cls = _build_subclass("DecoratorCacheReuse") + dec = cls() + + ifc_file = SimpleNamespace() + _seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101, 102]) + + current_geom_gen = 5 + start_guid = "GUID" + hit = ( + start_guid == dec._cached_start_guid + and ifc_file is dec._cached_ifc_file + and current_geom_gen == dec._cached_geom_gen + and dec._cached_walk_ids + ) + assert hit, "Cache must hit when seed, file, and geom_gen are unchanged" + + +def test_walk_cache_invalidates_on_geom_generation_bump(): + """Cache must miss when geom_gen bumps so entities removed by an + ``ifcopenshell.api`` mutation never survive in the cached walk + list into the next draw pass.""" + cls = _build_subclass("DecoratorCacheGenInvalidates") + dec = cls() + + ifc_file = SimpleNamespace() + _seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101]) + + current_geom_gen = 6 # IFC mutation has bumped the counter + start_guid = "GUID" + hit = ( + start_guid == dec._cached_start_guid + and ifc_file is dec._cached_ifc_file + and current_geom_gen == dec._cached_geom_gen + and dec._cached_walk_ids + ) + assert not hit, "Cache must miss when geom_gen bumps so the walk re-runs against live entities" + + +def test_walk_cache_invalidates_on_seed_change(): + """Selecting a different network seed forces a re-walk even if + geom_gen is unchanged.""" + cls = _build_subclass("DecoratorCacheSeedChange") + dec = cls() + + ifc_file = SimpleNamespace() + _seed_cache(dec, start_guid="OLD-GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101]) + + hit = ( + "NEW-GUID" == dec._cached_start_guid + and ifc_file is dec._cached_ifc_file + and 5 == dec._cached_geom_gen + and dec._cached_walk_ids + ) + assert not hit + + +def test_walk_cache_invalidates_on_ifc_file_swap(): + """Loading a different IFC file must invalidate even if the new + seed happens to share the GUID (different IfcOpenShell file + objects → different identity).""" + cls = _build_subclass("DecoratorCacheFileSwap") + dec = cls() + + old_file = SimpleNamespace() + new_file = SimpleNamespace() + _seed_cache(dec, start_guid="GUID", ifc_file=old_file, geom_gen=5, walk_ids=[101]) + + hit = ( + "GUID" == dec._cached_start_guid + and new_file is dec._cached_ifc_file + and 5 == dec._cached_geom_gen + and dec._cached_walk_ids + ) + assert not hit + + +def test_walk_cache_stores_ids_not_entity_references(): + """Structural safety: the cache stores STEP integer ids, not raw + ``entity_instance`` references — re-resolved via ``ifc_file.by_id`` + on each cache hit. Eliminates the dangling-SWIG-handle class entirely: + even if geom_gen mistakenly fails to bump, a deleted entity's id won't + resolve, the cache-hit branch returns ``None``, and the next draw + re-walks against live entities.""" + cls = _build_subclass("DecoratorCacheStoresIds") + dec = cls() + _seed_cache(dec, start_guid="GUID", ifc_file=SimpleNamespace(), geom_gen=5, walk_ids=[42]) + assert dec._cached_walk_ids == [42] + assert all(isinstance(eid, int) for eid in dec._cached_walk_ids) + + +def test_geom_cache_key_includes_geom_generation(): + """``TokenCache.get_or_compute`` keys that include geom_gen flush + the cached world-space geometry on IFC mutations the depsgraph + token doesn't observe — without that key component, a re-walk + would feed a fresh list to the lambda while the cache still + returned the prior result.""" + import bonsai.bim.decorator_cache as decorator_cache + from bonsai.bim.module.model.decorator import MEPSystemPathDecorator + + decorator_cache.reset_for_test() + dec = MEPSystemPathDecorator() + + builds: list[int] = [] + + def _build(): + builds.append(1) + return ([], [], []) + + ifc_file = SimpleNamespace() + dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build) + dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build) + assert len(builds) == 1, "Same key (same gen) should reuse the cached value" + + dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 2), _build) + assert len(builds) == 2, "Bumping geom_gen in the key must invalidate the cached value" + + # --------------------------------------------------------------------------- # Pure-geometry classifier contract. # diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py index da668e604a..e39bf8af03 100644 --- a/src/bonsai/test/bim/module/model/test_disconnect_elements.py +++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py @@ -47,6 +47,11 @@ def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: i def _elem(*, connected_to=(), connected_from=()): e = Mock() + # Default is_a to False so the MEP-pair-fitting branch of find_rels + # (which calls ``elem.is_a("IfcFlowSegment")``) early-outs on the + # generic _elem stubs used by the wall-side dispatch tests. Test + # cases that want is_a("IfcWall")-True explicitly override e.is_a. + e.is_a = lambda _c: False e.ConnectedTo = list(connected_to) e.ConnectedFrom = list(connected_from) e.GlobalId = "GUID" @@ -167,6 +172,140 @@ def test_find_rels_for_element_skips_rels_without_partner(): assert tool.Connection.find_rels_for_element(elem) == [] +# --------------------------------------------------------------------------- +# tool.Connection.find_rels — MEP pair-fitting detection +# --------------------------------------------------------------------------- + + +def _mep(elem_id, *, klasses=("IfcFlowSegment",), predefined_type=None, ports=()): + """Stand-in IFC element with port mocks and ``is_a`` short-circuits.""" + e = Mock() + e.id = lambda: elem_id + e.is_a = lambda c: c in klasses + e.PredefinedType = predefined_type + # Empty path / element rels so the find_rels prologue iterates cleanly + # before reaching the MEP port-walk branch. + e.ConnectedTo = [] + e.ConnectedFrom = [] + e._ports = list(ports) + return e + + +def _port(port_id, owner, connected_to=None): + p = Mock() + p.id = lambda: port_id + p._owner = owner + p._connected_to = connected_to + return p + + +def _patch_port_walk(): + """Patch the port helpers ``tool.System.find_bridging_fitting`` consumes + so the mep-pair-fitting detection in ``find_rels`` can be exercised + without a real IFC fixture. Three patches: ``get_ports`` and + ``get_connected_port`` are ``tool.System`` classmethods that delegate + to ``ifcopenshell.util.system``; ``get_port_element`` is called + directly on ``ifcopenshell.util.system`` inside ``neighbours_at_ports``.""" + return ( + patch("bonsai.tool.system.System.get_ports", side_effect=lambda e: e._ports), + patch( + "bonsai.tool.system.System.get_connected_port", + side_effect=lambda p: p._connected_to, + ), + patch( + "bonsai.tool.system.ifcopenshell.util.system.get_port_element", + side_effect=lambda p: p._owner, + ), + ) + + +def test_find_rels_detects_segment_segment_bridging_fitting(): + """Two flow segments joined by a single bridging fitting must surface + as ``(fitting, 'mep-pair-fitting')`` — the fitting whose deletion + effects the disconnect.""" + fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND") + seg_a = _mep(1) + seg_b = _mep(2) + + a_port = _port(101, seg_a) + b_port = _port(102, seg_b) + f_port_a = _port(201, fitting, connected_to=a_port) + f_port_b = _port(202, fitting, connected_to=b_port) + a_port._connected_to = f_port_a + b_port._connected_to = f_port_b + + seg_a._ports = [a_port] + seg_b._ports = [b_port] + fitting._ports = [f_port_a, f_port_b] + + with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]: + rels = tool.Connection.find_rels(seg_a, seg_b) + + assert rels == [(fitting, "mep-pair-fitting")] + + +def test_find_rels_detects_segment_fitting_direct(): + """A segment + its directly-connected fitting also surface as the + same kind, with the fitting itself as the deletion target.""" + fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND") + seg = _mep(1) + seg_port = _port(101, seg) + f_port = _port(201, fitting, connected_to=seg_port) + seg_port._connected_to = f_port + seg._ports = [seg_port] + fitting._ports = [f_port] + + with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]: + rels = tool.Connection.find_rels(seg, fitting) + + assert rels == [(fitting, "mep-pair-fitting")] + + +def test_find_rels_skips_obstruction_fitting(): + """OBSTRUCTION fittings have a dedicated grow/shrink removal flow — + they must not surface as a disconnect target.""" + obstruction = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="OBSTRUCTION") + seg_a = _mep(1) + seg_b = _mep(2) + a_port = _port(101, seg_a) + b_port = _port(102, seg_b) + o_port_a = _port(201, obstruction, connected_to=a_port) + o_port_b = _port(202, obstruction, connected_to=b_port) + a_port._connected_to = o_port_a + b_port._connected_to = o_port_b + seg_a._ports = [a_port] + seg_b._ports = [b_port] + obstruction._ports = [o_port_a, o_port_b] + + with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]: + assert tool.Connection.find_rels(seg_a, seg_b) == [] + + +def test_find_rels_returns_empty_for_two_unrelated_mep_segments(): + """No bridging fitting, no detection.""" + seg_a = _mep(1) + seg_b = _mep(2) + seg_a._ports = [] + seg_b._ports = [] + with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]: + assert tool.Connection.find_rels(seg_a, seg_b) == [] + + +def test_find_rels_skips_non_mep_pair(): + """Walls don't have ports — find_rels must early-out before walking + them as if they were MEP.""" + wall_a = Mock() + wall_a.is_a = lambda c: c == "IfcWall" + wall_a.ConnectedTo = [] + wall_a.ConnectedFrom = [] + wall_b = Mock() + wall_b.is_a = lambda c: c == "IfcWall" + wall_b.ConnectedTo = [] + wall_b.ConnectedFrom = [] + + assert tool.Connection.find_rels(wall_a, wall_b) == [] + + # --------------------------------------------------------------------------- # tool.Connection.find_rel — first-match convenience # --------------------------------------------------------------------------- diff --git a/src/bonsai/test/bim/module/model/test_mep_actions_cache.py b/src/bonsai/test/bim/module/model/test_mep_actions_cache.py index 7f0a144c7e..f496d24aee 100644 --- a/src/bonsai/test/bim/module/model/test_mep_actions_cache.py +++ b/src/bonsai/test/bim/module/model/test_mep_actions_cache.py @@ -128,14 +128,14 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v element = Mock() element.is_a = lambda c: c == "IfcFlowSegment" - call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0} + call_counts = {"port_connection_state": 0, "find_bridging_fitting": 0, "compute_mep_join_location": 0} def counting_port_state(elem, at_start): call_counts["port_connection_state"] += 1 return "FREE" def counting_find_fitting(a, b): - call_counts["find_fitting_between_segments"] += 1 + call_counts["find_bridging_fitting"] += 1 return None def counting_join_location(): @@ -151,7 +151,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))), ), patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state), - patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting), + patch("bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting), patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location), patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()), patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()), @@ -164,7 +164,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v # Second frame must reuse the cached values — no second IFC walk. assert call_counts["port_connection_state"] == first["port_connection_state"] - assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"] + assert call_counts["find_bridging_fitting"] == first["find_bridging_fitting"] assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"] @@ -203,7 +203,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility): ), patch( "bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state ), patch( - "bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting + "bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting ), patch( "bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0)) ), patch( @@ -218,9 +218,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility): inst.position_gizmos(context) assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance" - assert ( - fitting_call_count["n"] > first_fitting - ), "find_fitting_between_segments must recompute after generation advance" + assert fitting_call_count["n"] > first_fitting, "find_bridging_fitting must recompute after generation advance" def test_selection_change_invalidates_cache(_patched_visibility): @@ -252,7 +250,7 @@ def test_selection_change_invalidates_cache(_patched_visibility): ), patch( "bonsai.bim.module.model.mep.port_connection_state", return_value="FREE" ), patch( - "bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting + "bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting ), patch( "bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0)) ), patch( @@ -265,4 +263,4 @@ def test_selection_change_invalidates_cache(_patched_visibility): selection_state["selected"] = [active, other_b] inst.position_gizmos(context) - assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change" + assert fitting_call_count["n"] > first, "find_bridging_fitting must recompute after selection change" diff --git a/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py b/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py index 858009cba1..601ff8dfb9 100644 --- a/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py +++ b/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py @@ -160,41 +160,102 @@ def test_lock_closed_icons_pass_position_to_remove_terminal_fitting(): ) -def test_unjoin_port_icons_pass_position_to_unjoin_at_port(): - """Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with - ``position`` pinned. Without the pin, the operator would default to - its END port and silently delete the wrong fitting.""" +def test_unjoin_icons_bind_unified_disconnect_operator(): + """Every unjoin icon (pair, start, end) routes to the unified + ``bim.disconnect_elements`` operator and the group holds an + ``op_props`` slot for each so the per-frame GUID writes have a + target.""" from bonsai.bim.module.model.mep import GizmoMEPActions inst = _build_group_with_mock_gizmos() - with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch( - "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() - ): - GizmoMEPActions._wire_anchored_icon_targets(inst) - - for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")): - gz = getattr(inst, f"action_{name}_gizmo") - gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port") - op_props = gz.target_set_operator.return_value - assert op_props.position == expected_position or op_props.position in ("START", "END") - - -def test_unjoin_icons_get_warning_color_highlight(): - """Destructive icons surface in the addon's warning red on hover so - they read as a deliberate target. ``color_highlight`` is overridden - after ``super().setup()`` wires the default highlight.""" - from bonsai.bim.module.model.mep import GizmoMEPActions - - inst = _build_group_with_mock_gizmos() - warning_color = (1.0, 0.1, 0.1) - with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch( - "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() - ): - GizmoMEPActions._wire_anchored_icon_targets(inst) + GizmoMEPActions._wire_anchored_icon_targets(inst) + assert isinstance(inst.unjoin_op_props, dict) for name in GizmoMEPActions.UNJOIN_CONFIGS: gz = getattr(inst, f"action_{name}_gizmo") - assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red" + gz.target_set_operator.assert_any_call("bim.disconnect_elements") + assert name in inst.unjoin_op_props, f"missing op_props slot for {name!r}" + + +def test_bind_unjoin_pair_writes_both_guids(): + """``_bind_unjoin_pair`` is the per-frame hand-off from gizmo + position-gizmos to the unified disconnect operator: both segment + GlobalIds get written onto the pre-wired op_props so a click + dispatches with the right pair.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + GizmoMEPActions._wire_anchored_icon_targets(inst) + pair_op_props = inst.unjoin_op_props["unjoin_pair"] + + seg_a = Mock(GlobalId="GUID-A") + seg_b = Mock(GlobalId="GUID-B") + + assert GizmoMEPActions._bind_unjoin_pair(inst, [seg_a, seg_b]) is True + assert pair_op_props.element_a_guid == "GUID-A" + assert pair_op_props.element_b_guid == "GUID-B" + + +def test_bind_unjoin_pair_rejects_incomplete_pair(): + """Defensive: a selection mid-change can hand the gizmo a one-element + or None-containing pair. The bind must refuse rather than write a + half-resolved op_props that would later CANCEL with a confusing + error message.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + GizmoMEPActions._wire_anchored_icon_targets(inst) + + assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A")]) is False + assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A"), None]) is False + + +def test_bind_unjoin_at_port_resolves_fitting_and_writes_guids(): + """The per-port unjoin gizmo resolves the partner fitting at the + named port and writes (segment_guid, fitting_guid) onto the + pre-wired op_props so the unified disconnect operator gets both + endpoints.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + GizmoMEPActions._wire_anchored_icon_targets(inst) + port_op_props = inst.unjoin_op_props["unjoin_end"] + + segment_obj = Mock() + segment = Mock(GlobalId="SEG-GUID") + fitting = Mock(GlobalId="FIT-GUID") + fitting.is_a = lambda c: c == "IfcFlowFitting" + fitting.PredefinedType = "BEND" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch( + "bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=fitting + ): + ok = GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", segment_obj, False) + + assert ok is True + assert port_op_props.element_a_guid == "SEG-GUID" + assert port_op_props.element_b_guid == "FIT-GUID" + + +def test_bind_unjoin_at_port_refuses_obstruction_partner(): + """OBSTRUCTION fittings have a dedicated grow/shrink removal flow — + routing them through the unified disconnect would just delete the + fitting and leave a visible gap. Mirror the find_rels exclusion + here so the icon hides when the partner is an obstruction.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + GizmoMEPActions._wire_anchored_icon_targets(inst) + + segment = Mock(GlobalId="SEG-GUID") + obstruction = Mock(GlobalId="OBS-GUID") + obstruction.is_a = lambda c: c == "IfcFlowFitting" + obstruction.PredefinedType = "OBSTRUCTION" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch( + "bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=obstruction + ): + assert GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", Mock(), False) is False # --------------------------------------------------------------------------- @@ -202,6 +263,66 @@ def test_unjoin_icons_get_warning_color_highlight(): # --------------------------------------------------------------------------- +def test_active_is_bend_fitting_accepts_tessellated_bend_with_bbim_pset(): + """A bend whose body has been tessellated as the upstream geometry-kernel + workaround still has its parametric definition on the type's + ``BBIM_Fitting`` pset — the re-edit operator reads from there, so the + pen icon must surface on it. ``has_parametric_body`` would return False + for the tessellated body; the pset gate is what makes the icon + reachable.""" + from bonsai.bim.module.model.mep import _active_is_bend_fitting + + bend_obj = Mock() + bend_elem = Mock() + bend_elem.is_a = lambda c: c == "IfcFlowFitting" + bend_type = Mock() + bend_type.PredefinedType = "BEND" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch( + "bonsai.bim.module.model.mep._is_bend_fitting", return_value=True + ), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch( + "bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset", + return_value={"radius": 0.2, "start_length": 0.1, "end_length": 0.1}, + ): + assert _active_is_bend_fitting(bend_obj) is True + + +def test_active_is_bend_fitting_rejects_bend_type_without_bbim_pset(): + """A fitting that looks like a bend (IfcFlowFitting + type.PredefinedType + == BEND) but lacks a ``BBIM_Fitting`` pset on the type can't be re-edited + — the re-edit operator reads parameters from the pset. Reject so the pen + icon hides rather than dispatching an operator that would CANCEL.""" + from bonsai.bim.module.model.mep import _active_is_bend_fitting + + bend_obj = Mock() + bend_elem = Mock() + bend_elem.is_a = lambda c: c == "IfcFlowFitting" + bend_type = Mock() + bend_type.PredefinedType = "BEND" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch( + "bonsai.bim.module.model.mep._is_bend_fitting", return_value=True + ), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch( + "bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset", return_value=None + ): + assert _active_is_bend_fitting(bend_obj) is False + + +def test_active_is_bend_fitting_rejects_non_bend(): + """Non-bend objects (segments, fittings with PredefinedType != BEND) + fail the first gate regardless of pset state.""" + from bonsai.bim.module.model.mep import _active_is_bend_fitting + + bend_obj = Mock() + bend_elem = Mock() + bend_elem.is_a = lambda c: c == "IfcFlowFitting" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch( + "bonsai.bim.module.model.mep._is_bend_fitting", return_value=False + ): + assert _active_is_bend_fitting(bend_obj) is False + + def test_active_is_flow_segment_handles_unbound_object(): """A Blender object with no IFC binding must not raise from a visibility predicate. The lambda runs on every selection event.""" diff --git a/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py b/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py new file mode 100644 index 0000000000..506f26279b --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py @@ -0,0 +1,139 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""End-to-end integration tests for the unified MEP disconnect path. + +Builds a real IFC scene (two pipe segments joined via ports to a bridging +fitting) and exercises the full chain: + tool.Connection.find_rels(seg_a, seg_b) + → returns (fitting, "mep-pair-fitting") + → bonsai.core.connection.disconnect_rel(rel=fitting, kind=...) + → tool.Geometry.delete_ifc_object(fitting_obj) + → cascade-on-delete removes the IfcRelConnectsPorts via remove_port + +The mock-based dispatch tests in :py:mod:`test_disconnect_elements` pin each +piece in isolation. This module pins that they compose — the surface the +gizmo click hits in production.""" + +import bpy +import ifcopenshell.api.system +import pytest + +import bonsai.core.connection +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.model + + +class TestMEPPairDisconnectEndToEnd(NewFile): + def _make_segment(self, name: str): + """Create one IfcPipeSegment occurrence with ports at both ends. + Returns (blender_object, ifc_element).""" + bpy.ops.mesh.primitive_cube_add(size=1) + obj = bpy.data.objects["Cube"] + obj.name = name + bpy.ops.bim.assign_class(ifc_class="IfcPipeSegment", predefined_type="RIGIDSEGMENT", userdefined_type="") + element = tool.Ifc.get_entity(obj) + tool.System.add_ports(obj) + return obj, element + + def _make_bend_fitting(self, name: str): + """Create one IfcPipeFitting (PredefinedType=BEND) occurrence with two + ports. Manual setup — bpy.ops.bim.assign_class doesn't add ports.""" + bpy.ops.mesh.primitive_cube_add(size=0.3) + obj = bpy.data.objects["Cube"] + obj.name = name + bpy.ops.bim.assign_class(ifc_class="IfcPipeFitting", predefined_type="BEND", userdefined_type="") + element = tool.Ifc.get_entity(obj) + tool.System.add_ports(obj) + return obj, element + + def _setup_joined_pair(self): + bpy.ops.bim.create_project() + seg_a_obj, seg_a = self._make_segment("SegA") + seg_b_obj, seg_b = self._make_segment("SegB") + bend_obj, bend = self._make_bend_fitting("Bend") + + ifc_file = tool.Ifc.get() + seg_a_ports = tool.System.get_ports(seg_a) + seg_b_ports = tool.System.get_ports(seg_b) + bend_ports = tool.System.get_ports(bend) + ifcopenshell.api.system.connect_port(ifc_file, port1=seg_a_ports[0], port2=bend_ports[0]) + ifcopenshell.api.system.connect_port(ifc_file, port1=seg_b_ports[0], port2=bend_ports[1]) + + return seg_a, seg_b, bend, bend_obj + + def test_find_rels_returns_mep_pair_fitting_subject(self): + seg_a, seg_b, bend, _ = self._setup_joined_pair() + rels = tool.Connection.find_rels(seg_a, seg_b) + assert rels == [(bend, "mep-pair-fitting")] + + def test_disconnect_rel_removes_the_bridging_fitting(self): + """The end-to-end contract: dispatch removes the fitting from the + IFC file, the Blender object is deleted, and a follow-up find_rels + on the same pair returns empty — there's nothing left to disconnect.""" + seg_a, seg_b, bend, bend_obj = self._setup_joined_pair() + bend_id = bend.id() + bend_obj_name = bend_obj.name + ifc_file = tool.Ifc.get() + + bonsai.core.connection.disconnect_rel( + tool.Ifc, + tool.Geometry, + tool.Model, + tool.Connection, + rel=bend, + kind="mep-pair-fitting", + elem=seg_a, + partner=seg_b, + ) + + # The fitting is gone from the IFC file. + with pytest.raises(RuntimeError): + ifc_file.by_id(bend_id) + # The pair is no longer joined. + assert tool.Connection.find_rels(seg_a, seg_b) == [] + # The Blender object was removed by delete_ifc_object. + assert bend_obj_name not in bpy.data.objects + + def test_disconnect_rel_skips_when_subject_is_elem_being_deleted(self): + """Cascade-side guard: if the fitting is itself the element being + deleted (subject is elem), skip — the deletion is already in flight + and re-deleting would crash.""" + seg_a, seg_b, bend, bend_obj = self._setup_joined_pair() + bend_id = bend.id() + bend_obj_name = bend_obj.name + + bonsai.core.connection.disconnect_rel( + tool.Ifc, + tool.Geometry, + tool.Model, + tool.Connection, + rel=bend, + kind="mep-pair-fitting", + elem=bend, + partner=seg_a, + skip_elem_recreate=True, + ) + + # Fitting still present — the dispatch correctly skipped. + assert tool.Ifc.get().by_id(bend_id).id() == bend_id + assert bend_obj_name in bpy.data.objects diff --git a/src/bonsai/test/bim/module/model/test_mep_port_operators.py b/src/bonsai/test/bim/module/model/test_mep_port_operators.py index c434c69020..614dd70782 100644 --- a/src/bonsai/test/bim/module/model/test_mep_port_operators.py +++ b/src/bonsai/test/bim/module/model/test_mep_port_operators.py @@ -61,76 +61,6 @@ def _make_op(_cls, **fields): return op -# --------------------------------------------------------------------------- -# MEPUnjoinAtPort -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "port_state, fitting_predefined_type, expected_result, expects_delete", - [ - pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"), - pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"), - pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"), - ], -) -def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete): - """``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect - by ``(port_state, fitting type)``. - - - ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path, - the bridging fitting is deleted via the standard delete entry point. - - ``JOINED + OBSTRUCTION``: deliberately refused — obstructions go - through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment - extends to absorb the freed length; using delete here would leave - a visible gap. - - ``FREE``: nothing to do — no bridging fitting exists. The operator - reports a user-facing error and CANCELS rather than no-op silently.""" - from bonsai.bim.module.model import mep - - segment = _segment() - fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None - fitting_obj = Mock() - - op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END") - ifc_file = MagicMock() - ifc_file.by_id.return_value = segment - - with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object( - mep.tool.Ifc, "get_object", return_value=fitting_obj - ), patch.object(mep, "port_connection_state", return_value=port_state), patch.object( - mep, "get_connected_element_at_segment_port", return_value=fitting - ), patch.object( - mep.tool.Geometry, "delete_ifc_object" - ) as delete: - result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock()) - - assert result == expected_result - if expects_delete: - delete.assert_called_once_with(fitting_obj) - else: - delete.assert_not_called() - op.report.assert_called() - - -def test_unjoin_at_port_cancels_when_active_is_not_segment(): - """The operator only operates on flow segments; non-segment active - objects must fail loud rather than mutate something unexpected.""" - from bonsai.bim.module.model import mep - - fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment - - op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END") - ifc_file = MagicMock() - ifc_file.by_id.return_value = fitting - - with patch.object(mep.tool.Ifc, "get", return_value=ifc_file): - result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock()) - - assert result == {"CANCELLED"} - op.report.assert_called() - - # --------------------------------------------------------------------------- # MEPRemoveTerminalFitting # --------------------------------------------------------------------------- @@ -210,105 +140,6 @@ def test_remove_terminal_cancels_on_non_terminal_port(): op.report.assert_called() -# --------------------------------------------------------------------------- -# MEPUnjoinPair -# --------------------------------------------------------------------------- - - -def test_unjoin_pair_deletes_bridging_fitting(): - """Happy path: two selected segments share a single non-OBSTRUCTION - bridging fitting → delete it.""" - from bonsai.bim.module.model import mep - - segment_a = _segment() - segment_b = _segment() - fitting = _fitting(predefined_type="JUNCTION") - fitting_obj = Mock() - - op = _make_op(mep.MEPUnjoinPair) - selected = [Mock(), Mock()] - - with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( - mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b] - ), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object( - mep.tool.Ifc, "get_object", return_value=fitting_obj - ), patch.object( - mep.tool.Geometry, "delete_ifc_object" - ) as delete: - result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) - - assert result == {"FINISHED"} - delete.assert_called_once_with(fitting_obj) - - -def test_unjoin_pair_refuses_obstruction_bridging(): - """Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go - through the dedicated REMOVE path; this operator surfaces the - redirect rather than silently doing the wrong thing.""" - from bonsai.bim.module.model import mep - - segment_a = _segment() - segment_b = _segment() - obstruction = _fitting(predefined_type="OBSTRUCTION") - - op = _make_op(mep.MEPUnjoinPair) - selected = [Mock(), Mock()] - - with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( - mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b] - ), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object( - mep.tool.Geometry, "delete_ifc_object" - ) as delete: - result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) - - assert result == {"CANCELLED"} - delete.assert_not_called() - op.report.assert_called() - - -def test_unjoin_pair_reports_when_no_bridging_fitting_found(): - """The pair is selected but no single fitting bridges them — the - user is told instead of getting a silent no-op.""" - from bonsai.bim.module.model import mep - - segment_a = _segment() - segment_b = _segment() - - op = _make_op(mep.MEPUnjoinPair) - selected = [Mock(), Mock()] - - with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( - mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b] - ), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object( - mep.tool.Geometry, "delete_ifc_object" - ) as delete: - result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) - - assert result == {"CANCELLED"} - delete.assert_not_called() - op.report.assert_called() - - -def test_unjoin_pair_cancels_when_selection_is_not_two_segments(): - """The poll filters the gizmo, but a programmatic invocation could - still hand the operator an invalid selection. The execute path - independently verifies both inputs are IfcFlowSegment.""" - from bonsai.bim.module.model import mep - - not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment - - op = _make_op(mep.MEPUnjoinPair) - selected = [Mock(), Mock()] - - with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( - mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment] - ): - result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) - - assert result == {"CANCELLED"} - op.report.assert_called() - - # --------------------------------------------------------------------------- # SelectMEPPathMembers # --------------------------------------------------------------------------- diff --git a/src/bonsai/test/core/test_connection.py b/src/bonsai/test/core/test_connection.py index 5b09bc4dfa..293bdab05a 100644 --- a/src/bonsai/test/core/test_connection.py +++ b/src/bonsai/test/core/test_connection.py @@ -60,8 +60,14 @@ class TestDisconnectRelPath: with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove: subject.disconnect_rel( - ifc, geometry, model, connection, - rel="rel", kind="path", elem="elem_a", partner="elem_b", + ifc, + geometry, + model, + connection, + rel="rel", + kind="path", + elem="elem_a", + partner="elem_b", ) remove.assert_called_once_with(geometry, connection="rel") @@ -78,8 +84,14 @@ class TestDisconnectRelPath: with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"): subject.disconnect_rel( - ifc, geometry, model, connection, - rel="rel", kind="path", elem="elem", partner="partner", + ifc, + geometry, + model, + connection, + rel="rel", + kind="path", + elem="elem", + partner="partner", skip_elem_recreate=True, ) @@ -93,8 +105,14 @@ class TestDisconnectRelPath: with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"): subject.disconnect_rel( - ifc, geometry, model, connection, - rel="rel", kind="path", elem="elem", partner="partner", + ifc, + geometry, + model, + connection, + rel="rel", + kind="path", + elem="elem", + partner="partner", skip_partner_recreate=True, ) @@ -108,8 +126,14 @@ class TestDisconnectRelPath: with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove: subject.disconnect_rel( - ifc, geometry, model, connection, - rel="rel", kind="path", elem="elem", partner="partner", + ifc, + geometry, + model, + connection, + rel="rel", + kind="path", + elem="elem", + partner="partner", skip_elem_recreate=True, skip_partner_recreate=True, ) @@ -131,13 +155,17 @@ class TestDisconnectRelElementTop: with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: subject.disconnect_rel( - ifc, geometry, model, connection, - rel=rel, kind="element-top", elem="elem", partner="partner", + ifc, + geometry, + model, + connection, + rel=rel, + kind="element-top", + elem="elem", + partner="partner", ) - ifc.run.assert_called_once_with( - "geometry.disconnect_element", relating_element="slab", related_element="wall" - ) + ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="slab", related_element="wall") regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"]) def test_slab_delete_cascade_still_regenerates_wall(self): @@ -150,8 +178,14 @@ class TestDisconnectRelElementTop: with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: subject.disconnect_rel( - ifc, Mock(), Mock(), connection, - rel=rel, kind="element-top", elem="slab", partner="wall", + ifc, + Mock(), + Mock(), + connection, + rel=rel, + kind="element-top", + elem="slab", + partner="wall", skip_elem_recreate=True, # slab is being deleted ) @@ -167,8 +201,14 @@ class TestDisconnectRelElementTop: with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: subject.disconnect_rel( - ifc, Mock(), Mock(), connection, - rel=rel, kind="element-top", elem="wall", partner="slab", + ifc, + Mock(), + Mock(), + connection, + rel=rel, + kind="element-top", + elem="wall", + partner="slab", skip_elem_recreate=True, # wall is being deleted ) @@ -185,8 +225,14 @@ class TestDisconnectRelElementTop: with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: subject.disconnect_rel( - ifc, Mock(), Mock(), connection, - rel=rel, kind="element-top", elem="slab", partner="wall", + ifc, + Mock(), + Mock(), + connection, + rel=rel, + kind="element-top", + elem="slab", + partner="wall", skip_elem_recreate=True, skip_partner_recreate=True, # wall also in batch ) @@ -200,19 +246,115 @@ class TestDisconnectRelElement: ifc = Mock() subject.disconnect_rel( - ifc, Mock(), Mock(), Mock(), - rel=rel, kind="element", elem="elem_a", partner="elem_b", + ifc, + Mock(), + Mock(), + Mock(), + rel=rel, + kind="element", + elem="elem_a", + partner="elem_b", ) - ifc.run.assert_called_once_with( - "geometry.disconnect_element", relating_element="A", related_element="B" + ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="A", related_element="B") + + +class TestDisconnectRelMEPPairFitting: + """The ``mep-pair-fitting`` kind treats the rel slot as the fitting whose + removal disconnects the pair — deletion routes through + ``geometry.delete_ifc_object`` so the cascade-on-delete contract still + owns port-rel cleanup.""" + + def test_deletes_fitting_via_delete_ifc_object(self): + fitting = Mock(name="fitting") + fitting_obj = Mock(name="fitting_obj") + ifc = _ifc_with_objects({fitting: fitting_obj}) + geometry = Mock() + + subject.disconnect_rel( + ifc, + geometry, + Mock(), + Mock(), + rel=fitting, + kind="mep-pair-fitting", + elem="seg_a", + partner="seg_b", ) + geometry.delete_ifc_object.assert_called_once_with(fitting_obj) + + def test_noops_when_fitting_has_no_blender_object(self): + """Defensive: a fitting with no bound Blender object can't be + deleted via ``delete_ifc_object``; the dispatch must not crash.""" + fitting = Mock(name="fitting") + ifc = _ifc_with_objects({}) + geometry = Mock() + + subject.disconnect_rel( + ifc, + geometry, + Mock(), + Mock(), + rel=fitting, + kind="mep-pair-fitting", + elem="seg_a", + partner="seg_b", + ) + + geometry.delete_ifc_object.assert_not_called() + + def test_skip_elem_recreate_suppresses_delete_when_fitting_is_elem(self): + """Cascade case: the fitting is itself the element being deleted + — don't try to delete it twice.""" + fitting = Mock(name="fitting") + ifc = _ifc_with_objects({fitting: Mock()}) + geometry = Mock() + + subject.disconnect_rel( + ifc, + geometry, + Mock(), + Mock(), + rel=fitting, + kind="mep-pair-fitting", + elem=fitting, + partner="other", + skip_elem_recreate=True, + ) + + geometry.delete_ifc_object.assert_not_called() + + def test_skip_partner_recreate_suppresses_delete_when_fitting_is_partner(self): + fitting = Mock(name="fitting") + ifc = _ifc_with_objects({fitting: Mock()}) + geometry = Mock() + + subject.disconnect_rel( + ifc, + geometry, + Mock(), + Mock(), + rel=fitting, + kind="mep-pair-fitting", + elem="seg_a", + partner=fitting, + skip_partner_recreate=True, + ) + + geometry.delete_ifc_object.assert_not_called() + class TestDisconnectRelUnknownKind: def test_raises_value_error(self): with pytest.raises(ValueError, match="Unknown rel kind"): subject.disconnect_rel( - Mock(), Mock(), Mock(), Mock(), - rel="rel", kind="bogus", elem="a", partner="b", + Mock(), + Mock(), + Mock(), + Mock(), + rel="rel", + kind="bogus", + elem="a", + partner="b", ) diff --git a/src/bonsai/test/tool/test_system.py b/src/bonsai/test/tool/test_system.py index 5c18ad623f..9bd7d47fdc 100644 --- a/src/bonsai/test/tool/test_system.py +++ b/src/bonsai/test/tool/test_system.py @@ -23,6 +23,7 @@ import ifcopenshell import ifcopenshell.api import ifcopenshell.api.root import ifcopenshell.api.system +import ifcopenshell.util.representation import ifcopenshell.util.system import ifcopenshell.util.unit import numpy as np @@ -39,6 +40,132 @@ class TestImplementsTool(NewFile): assert isinstance(subject(), bonsai.core.tool.System) +class TestHasParametricBody(NewFile): + """The MEP-action gizmo predicates gate on ``has_parametric_body``; + fittings whose swept body lives on the type via ``IfcMappedItem`` must + return True so the pen-icon and lock-icon rows show on the occurrence.""" + + def _build_bend_occurrence_with_mapped_body(self): + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + + placement = ifc_file.create_entity( + "IfcAxis2Placement3D", + Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), + ) + line = ifc_file.create_entity( + "IfcLine", + Pnt=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), + Dir=ifc_file.create_entity( + "IfcVector", + Orientation=ifc_file.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)), + Magnitude=1.0, + ), + ) + trimmed = ifc_file.create_entity( + "IfcTrimmedCurve", + BasisCurve=line, + Trim1=(ifc_file.create_entity("IfcParameterValue", wrappedValue=0.0),), + Trim2=(ifc_file.create_entity("IfcParameterValue", wrappedValue=1.0),), + SenseAgreement=True, + MasterRepresentation="PARAMETER", + ) + swept = ifc_file.create_entity("IfcSweptDiskSolid", Directrix=trimmed, Radius=0.05) + type_body = ifc_file.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body_ctx, + RepresentationIdentifier="Body", + RepresentationType="AdvancedSweptSolid", + Items=(swept,), + ) + rep_map = ifc_file.create_entity( + "IfcRepresentationMap", MappingOrigin=placement, MappedRepresentation=type_body + ) + fitting_type = ifc_file.create_entity( + "IfcPipeFittingType", + GlobalId=ifcopenshell.guid.new(), + Name="BendType", + PredefinedType="BEND", + RepresentationMaps=(rep_map,), + ) + mapped_item = ifc_file.create_entity( + "IfcMappedItem", + MappingSource=rep_map, + MappingTarget=ifc_file.create_entity( + "IfcCartesianTransformationOperator3D", + LocalOrigin=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), + ), + ) + occurrence_body = ifc_file.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body_ctx, + RepresentationIdentifier="Body", + RepresentationType="MappedRepresentation", + Items=(mapped_item,), + ) + fitting = ifc_file.create_entity( + "IfcPipeFitting", + GlobalId=ifcopenshell.guid.new(), + Name="Bend", + PredefinedType="BEND", + Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(occurrence_body,)), + ) + ifc_file.create_entity( + "IfcRelDefinesByType", + GlobalId=ifcopenshell.guid.new(), + RelatedObjects=(fitting,), + RelatingType=fitting_type, + ) + return fitting + + def test_returns_true_for_swept_disk_via_mapped_item(self): + """``traverse()`` follows the + ``IfcMappedItem.MappingSource.MappedRepresentation`` chain so the + ``IfcSweptDiskSolid`` on the type's body is reachable from the + occurrence's body representation. Bend fittings produced by the + bend-preview commit path use this exact representation shape.""" + fitting = self._build_bend_occurrence_with_mapped_body() + assert subject.has_parametric_body(fitting) is True + + def test_returns_false_for_tessellated_body(self): + """The bend creation path replaces the swept-disk body with an + ``IfcTriangulatedFaceSet`` as an upstream geometry-kernel + workaround. The traverse finds no extruded / swept solid, so the + predicate returns False — pinning the constraint that drives the + ``BBIM_Fitting`` pset fallback in the bend-icon visibility + predicate.""" + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + + coords = ifc_file.create_entity( + "IfcCartesianPointList3D", + CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)), + ) + tessellation = ifc_file.create_entity( + "IfcTriangulatedFaceSet", + Coordinates=coords, + CoordIndex=((1, 2, 3),), + ) + body = ifc_file.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body_ctx, + RepresentationIdentifier="Body", + RepresentationType="Tessellation", + Items=(tessellation,), + ) + fitting = ifc_file.create_entity( + "IfcPipeFitting", + GlobalId=ifcopenshell.guid.new(), + Name="TessellatedBend", + PredefinedType="BEND", + Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(body,)), + ) + + assert subject.has_parametric_body(fitting) is False + + class TestAddPorts(NewFile): def setup_mep_segment(self): bpy.ops.bim.create_project()