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()