diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 35922acf5e..4028520634 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -120,8 +120,7 @@ classes = ( wall.RotateWall90, wall.SplitWall, wall.SplitWallAtCursor, - wall.DisconnectWallSlab, - wall.UnjoinWallPathConnection, + wall.DisconnectElements, wall.UnjoinWalls, wall.EnableWallFilletPreview, wall.FinishWallFilletPreview, diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index e11f89940b..1058de7151 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -285,89 +285,29 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper _resync_walls_after_mutation(tool.Blender.get_selected_objects()) -class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): - """Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one - specific partner wall, leaving the active wall's other connections intact. The - partner is identified by IFC GlobalId — invariant under Blender-object renames, - file save/reload, and the undo stack — set on the operator properties by the - single-wall unjoin gizmo at click time.""" +class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): + """Disconnect two IFC elements given their GlobalIds — generic dispatcher + that infers the connection rel kind via tool.Connection.find_rels and runs + the right post-disconnect cleanup: - bl_idname = "bim.unjoin_wall_path_connection" - bl_label = "Unjoin Wall Connection" - bl_description = "Disconnect the active wall from a single specific partner wall" + - ``"path"`` (IfcRelConnectsPathElements) → removes every rel between + the pair (catches both orientations) via remove_connection + recreates + both walls + resyncs drafts. + - ``"element-top"`` (IfcRelConnectsElements with Description=="TOP") → + disconnect_element + regenerate_wall_to_underside on the wall side. + - ``"element"`` (other IfcRelConnectsElements) → disconnect_element only. + + Both endpoints by GlobalId so the dispatch survives rename / undo / save. + Replaces the previous typed UnjoinWallPathConnection + DisconnectWallSlab + operators with one entry-point gizmos and shortcuts can bind to.""" + + bl_idname = "bim.disconnect_elements" + bl_label = "Disconnect Elements" + bl_description = "Remove the connection between two IFC elements identified by GlobalId" bl_options = {"REGISTER", "UNDO"} - other_wall_guid: bpy.props.StringProperty(name="Other Wall GlobalId") - - @classmethod - def poll(cls, context): - if not tool.Model.has_selected_ifc_objects(): - cls.poll_message_set("No IFC objects selected.") - return False - if _poll_reject_array_children(cls): - return False - return True - - def _perform(self, context): - active = tool.Blender.get_active_object(is_selected=True) - if not active: - self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") - return - elem_active = tool.Ifc.get_entity(active) - if not elem_active: - self.report({"ERROR"}, "Active object is not bound to an IFC entity.") - return - elem_other = None - if self.other_wall_guid: - try: - elem_other = tool.Ifc.get().by_guid(self.other_wall_guid) - except RuntimeError: - elem_other = None - other = tool.Ifc.get_object(elem_other) if elem_other else None - if not elem_other or not other: - self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") - return - # Walk the inverse graph for the specific IfcRelConnectsPathElements joining - # these two walls and remove only that one. `disconnect_path`'s - # (relating, related) mode only inspects `relating.ConnectedTo`, so a single - # call misses the rel when it was authored with the opposite orientation. - rels = [ - rel - for rel in getattr(elem_active, "ConnectedTo", []) - if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_other - ] + [ - rel - for rel in getattr(elem_active, "ConnectedFrom", []) - if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_other - ] - for rel in rels: - bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) - # Recreate body+axis on both walls so the mesh state matches the IFC mutation - # and stale miter cuts are dropped. - tool.Model.recreate_wall(elem_active, active) - tool.Model.recreate_wall(elem_other, other) - _resync_walls_after_mutation([active, other]) - - -class DisconnectWallSlab(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): - """Disconnect the wall from one specific underside slab — counterpart to - UnjoinWallPathConnection on the wall-slab side. Both endpoints are - identified by IFC GlobalId so the dispatch survives rename / undo / save. - - Drops the IfcRelConnectsElements(TOP) rel + all underside booleans on the - wall, then re-runs regenerate_wall_to_underside which re-clips the wall - to whatever slabs remain connected. The all-booleans-then-regenerate - approach is safe with HEAD's flat BBIM_Boolean pset (no per-slab id - storage); switches to a per-slab boolean removal when PR #8147's - dict-with-slab-guid pset migration lands.""" - - bl_idname = "bim.disconnect_wall_slab" - bl_label = "Disconnect Wall From Slab" - bl_description = "Remove the TOP connection between a wall and one slab and re-clip the wall to remaining slabs" - bl_options = {"REGISTER", "UNDO"} - - wall_guid: bpy.props.StringProperty(name="Wall GlobalId") - slab_guid: bpy.props.StringProperty(name="Slab GlobalId") + element_a_guid: bpy.props.StringProperty(name="Element A GlobalId") + element_b_guid: bpy.props.StringProperty(name="Element B GlobalId") @classmethod def poll(cls, context): @@ -381,23 +321,42 @@ class DisconnectWallSlab(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I def _perform(self, context): ifc_file = tool.Ifc.get() try: - wall = ifc_file.by_guid(self.wall_guid) if self.wall_guid else None - slab = ifc_file.by_guid(self.slab_guid) if self.slab_guid else None + elem_a = ifc_file.by_guid(self.element_a_guid) if self.element_a_guid else None + elem_b = ifc_file.by_guid(self.element_b_guid) if self.element_b_guid else None except RuntimeError: - wall = slab = None - if wall is None or slab is None: - self.report({"ERROR"}, "Could not resolve wall and slab from supplied GlobalIds.") + elem_a = elem_b = None + if elem_a is None or elem_b is None: + self.report({"ERROR"}, "Could not resolve elements from supplied GlobalIds.") return - wall_obj = tool.Ifc.get_object(wall) - if wall_obj is None: - self.report({"ERROR"}, "Wall has no Blender object.") + rels = tool.Connection.find_rels(elem_a, elem_b) + if not rels: + self.report({"ERROR"}, "No connection found between elements.") return - rel = tool.Wall.find_wall_slab_rel(wall, slab) - if rel is None: - self.report({"ERROR"}, "No TOP connection between this wall and slab.") - return - ifcopenshell.api.geometry.disconnect_element(ifc_file, relating_element=slab, related_element=wall) - core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [wall_obj]) + # All rels between a single pair should share a kind in practice; pick + # the first kind for the cleanup dispatch and remove every rel below. + kind = rels[0][1] + if kind == "path": + for rel, _ in rels: + bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) + obj_a = tool.Ifc.get_object(elem_a) + obj_b = tool.Ifc.get_object(elem_b) + if obj_a is not None and obj_b is not None: + tool.Model.recreate_wall(elem_a, obj_a) + tool.Model.recreate_wall(elem_b, obj_b) + _resync_walls_after_mutation([obj_a, obj_b]) + elif kind in ("element-top", "element"): + for rel, _ in rels: + wall, slab = tool.Connection.orient_element_top(rel, elem_a, elem_b) + ifcopenshell.api.geometry.disconnect_element( + ifc_file, relating_element=slab, related_element=wall + ) + if kind == "element-top": + # The TOP rel is what extend_walls_to_underside creates; the + # related side is always the wall. + wall = rels[0][0].RelatedElement + wall_obj = tool.Ifc.get_object(wall) + if wall_obj is not None: + core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [wall_obj]) class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): @@ -3912,9 +3871,10 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated and hidden on a per-frame basis based on the live connection set. - Each visible icon dispatches `bim.unjoin_wall_path_connection` with the partner - wall's GlobalId set on the bound operator properties, so a click removes only - the single rel under that icon — the other connections on the same wall survive. + Each visible icon dispatches `bim.disconnect_elements` with the active wall + + partner wall GlobalIds set on the bound operator properties, so a click removes + only the single rel under that icon — the other connections on the same wall + survive. Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group requires len(selected) == 2; this one requires 1).""" @@ -3962,11 +3922,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix self.unjoin_op_props = [] for _ in range(self.POOL_SIZE): icon = self.setup_icon_gizmo( - "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection" + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements" ) icon.hide = True self.unjoin_icons.append(icon) - self.unjoin_op_props.append(icon.target_set_operator("bim.unjoin_wall_path_connection")) + self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements")) def position_gizmos(self, context: bpy.types.Context) -> None: # Default: hide every pool slot. The visible-set is rebuilt from the live @@ -4009,12 +3969,13 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix icon = self.unjoin_icons[slot_idx] icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE) icon.hide = False - # Only the partner-GlobalId property is rewritten per frame; the operator + # Only the GlobalId properties are rewritten per frame; the operator # binding itself is the long-lived handle set up at setup() time. GlobalId # (not Blender object name) keeps the binding stable across renames, file # save/reload, and any sit-in-the-undo-stack interlude between dispatch # and execute. - self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId + self.unjoin_op_props[slot_idx].element_a_guid = elem.GlobalId + self.unjoin_op_props[slot_idx].element_b_guid = other_elem.GlobalId # Mirror the partner reference onto the icon itself so its draw() # can outline the partner on hover without a Gizmo-side getter on # the bound operator (the API exposes target_set_operator with diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 03716236e1..afdec36b84 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -31,6 +31,7 @@ from bonsai.tool.cad import Cad from bonsai.tool.clash import Clash from bonsai.tool.classification import Classification from bonsai.tool.collector import Collector +from bonsai.tool.connection import Connection from bonsai.tool.context import Context from bonsai.tool.cost import Cost from bonsai.tool.covering import Covering diff --git a/src/bonsai/bonsai/tool/connection.py b/src/bonsai/bonsai/tool/connection.py new file mode 100644 index 0000000000..055eb47a68 --- /dev/null +++ b/src/bonsai/bonsai/tool/connection.py @@ -0,0 +1,113 @@ +# 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. + +"""Generic discovery of the relation linking two IFC elements. + +Used by ``bim.disconnect_elements`` so the operator surface is one operator +per disconnect intent (active vs. partner, identified by GlobalId) rather +than one per rel class. The kind label returned alongside the rel lets the +operator dispatch the right post-disconnect cleanup: + +- ``"path"`` for ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.) +- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"`` + (the rel kind ``extend_walls_to_underside`` creates) +- ``"element"`` for any other ``IfcRelConnectsElements`` + +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.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import ifcopenshell + + +class Connection: + @classmethod + def find_rels( + cls, + 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 + element, and the same pair may carry rels authored with opposite + orientations (``disconnect_path``'s ``(relating, related)`` mode only + inspects ``relating.ConnectedTo``, so a single call would miss the + opposite-orientation rel).""" + rels: list[tuple[ifcopenshell.entity_instance, str]] = [] + seen: set[int] = set() + + def _record(rel, kind): + if rel.id() not in seen: + seen.add(rel.id()) + rels.append((rel, kind)) + + for rel in getattr(elem_a, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatedElement", None) == elem_b: + _record(rel, "path") + for rel in getattr(elem_a, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatingElement", None) == elem_b: + _record(rel, "path") + + for rel in getattr(elem_a, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatingElement", None) == elem_b: + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind) + for rel in getattr(elem_a, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatedElement", None) == elem_b: + kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" + _record(rel, kind) + + return rels + + @classmethod + def find_rel( + cls, + 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.""" + rels = cls.find_rels(elem_a, elem_b) + return rels[0] if rels else (None, None) + + @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]": + """Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel. + + The ``extend_walls_to_underside`` flow stores slab as the relating + side and wall as related — orientation is recovered by checking + which input matches which rel attribute. Callers pass any two + elements; this resolves which is the wall and which is the slab so + post-disconnect cleanup (regenerate-wall-to-underside) targets the + right object.""" + if getattr(rel, "RelatingElement", None) == elem_a: + return elem_b, elem_a + return elem_a, elem_b diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py new file mode 100644 index 0000000000..02464e1625 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py @@ -0,0 +1,265 @@ +# 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. + +"""Behaviour tests for the unified ``bim.disconnect_elements`` operator and +``tool.Connection.find_rels`` registry. + +Pin the dispatch contract: rels are found in either orientation; the kind +label drives cleanup (``path`` recreates both walls + resyncs drafts; +``element-top`` runs ``regenerate_wall_to_underside``); missing endpoints +report ERROR rather than crashing.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +import bonsai.tool as tool + +pytestmark = pytest.mark.model + + +def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: int = 0): + rel = Mock() + rel.is_a = lambda c: c == klass + rel.RelatingElement = relating + rel.RelatedElement = related + rel.Description = description + rel.id = lambda: rel_id + return rel + + +def _elem(*, connected_to=(), connected_from=()): + e = Mock() + e.ConnectedTo = list(connected_to) + e.ConnectedFrom = list(connected_from) + e.GlobalId = "GUID" + return e + + +# --------------------------------------------------------------------------- +# tool.Connection.find_rels — registry behaviour +# --------------------------------------------------------------------------- + + +def test_find_rels_returns_path_rel_in_either_orientation(): + """The same wall pair can carry path rels authored with either orientation; + find_rels must catch both.""" + elem_a = _elem() + elem_b = _elem() + rel_ab = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1) + rel_ba = _rel("IfcRelConnectsPathElements", relating=elem_b, rel_id=2) + elem_a.ConnectedTo = [rel_ab] + elem_a.ConnectedFrom = [rel_ba] + + rels = tool.Connection.find_rels(elem_a, elem_b) + + assert {r.id() for r, _ in rels} == {1, 2} + assert all(k == "path" for _, k in rels) + + +def test_find_rels_classifies_top_element_rel_specifically(): + """IfcRelConnectsElements with Description=='TOP' is the rel kind + extend_walls_to_underside creates. Tag it ``element-top`` so the + operator can dispatch the regenerate-wall-to-underside cleanup.""" + wall = _elem() + slab = _elem() + rel = _rel("IfcRelConnectsElements", relating=slab, description="TOP", rel_id=1) + wall.ConnectedFrom = [rel] + + rels = tool.Connection.find_rels(wall, slab) + + assert rels == [(rel, "element-top")] + + +def test_find_rels_classifies_non_top_element_rel_generically(): + """Other IfcRelConnectsElements descriptions don't get the TOP-specific + cleanup. Tag as plain ``element`` so the operator just removes the rel.""" + elem_a = _elem() + elem_b = _elem() + rel = _rel("IfcRelConnectsElements", relating=elem_b, description="ATTACHMENT", rel_id=1) + elem_a.ConnectedFrom = [rel] + + rels = tool.Connection.find_rels(elem_a, elem_b) + + assert rels == [(rel, "element")] + + +def test_find_rels_returns_empty_when_disconnected(): + elem_a = _elem() + elem_b = _elem() + assert tool.Connection.find_rels(elem_a, elem_b) == [] + + +def test_find_rels_dedups_by_id(): + """A rel that surfaces on both ConnectedTo and ConnectedFrom (in + pathological IFC files) should not be returned twice.""" + elem_a = _elem() + elem_b = _elem() + rel = _rel("IfcRelConnectsPathElements", related=elem_b, relating=elem_b, rel_id=1) + elem_a.ConnectedTo = [rel] + elem_a.ConnectedFrom = [rel] + + rels = tool.Connection.find_rels(elem_a, elem_b) + + assert len(rels) == 1 + + +# --------------------------------------------------------------------------- +# tool.Connection.find_rel — first-match convenience +# --------------------------------------------------------------------------- + + +def test_find_rel_returns_first_match_or_none_none(): + elem_a = _elem() + elem_b = _elem() + rel = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1) + elem_a.ConnectedTo = [rel] + + assert tool.Connection.find_rel(elem_a, elem_b) == (rel, "path") + assert tool.Connection.find_rel(elem_a, _elem()) == (None, None) + + +# --------------------------------------------------------------------------- +# tool.Connection.orient_element_top — wall / slab orientation recovery +# --------------------------------------------------------------------------- + + +def test_orient_element_top_returns_wall_then_slab(): + """The TOP rel stores slab as relating + wall as related; orient_element_top + figures out which input is which regardless of argument order.""" + wall = _elem() + slab = _elem() + rel = _rel("IfcRelConnectsElements", relating=slab, related=wall, description="TOP") + + assert tool.Connection.orient_element_top(rel, wall, slab) == (wall, slab) + assert tool.Connection.orient_element_top(rel, slab, wall) == (wall, slab) + + +# --------------------------------------------------------------------------- +# bim.disconnect_elements — dispatch + cleanup +# --------------------------------------------------------------------------- + + +def _make_op(*, a_guid="A", b_guid="B"): + op = Mock() + op.element_a_guid = a_guid + op.element_b_guid = b_guid + op.report = Mock() + return op + + +def test_disconnect_path_removes_all_rels_then_recreates_walls(): + from bonsai.bim.module.model.wall import DisconnectElements + + elem_a = Mock() + elem_b = Mock() + rel1 = Mock() + rel2 = Mock() + obj_a = Mock() + obj_b = Mock() + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", + return_value=[(rel1, "path"), (rel2, "path")], + ), patch( + "bonsai.bim.module.model.wall.tool.Ifc.get_object", side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e] + ), patch("bonsai.bim.module.model.wall.bonsai.core.geometry.remove_connection") as remove, patch( + "bonsai.bim.module.model.wall.tool.Model.recreate_wall" + ) as recreate, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation") as resync: + DisconnectElements._perform(op, context=MagicMock()) + + assert remove.call_count == 2 + assert recreate.call_count == 2 + resync.assert_called_once_with([obj_a, obj_b]) + op.report.assert_not_called() + + +def test_disconnect_element_top_calls_regenerate(): + from bonsai.bim.module.model.wall import DisconnectElements + + wall = Mock() + slab = Mock() + wall_obj = Mock() + rel = Mock() + rel.RelatedElement = wall + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": wall, "B": slab}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")] + ), patch( + "bonsai.bim.module.model.wall.tool.Connection.orient_element_top", return_value=(wall, slab) + ), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj), patch( + "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" + ) as disc, patch("bonsai.bim.module.model.wall.core.regenerate_wall_to_underside") as regen: + DisconnectElements._perform(op, context=MagicMock()) + + disc.assert_called_once_with(ifc_file, relating_element=slab, related_element=wall) + regen.assert_called_once() + op.report.assert_not_called() + + +def test_disconnect_reports_on_unknown_guids(): + from bonsai.bim.module.model.wall import DisconnectElements + + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = RuntimeError("missing") + op = _make_op(a_guid="MISSING_A", b_guid="MISSING_B") + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels" + ) as find: + DisconnectElements._perform(op, context=MagicMock()) + + find.assert_not_called() + op.report.assert_called_once() + args, _ = op.report.call_args + assert args[0] == {"ERROR"} + + +def test_disconnect_reports_when_no_rel_found(): + from bonsai.bim.module.model.wall import DisconnectElements + + elem_a = Mock() + elem_b = Mock() + ifc_file = MagicMock() + ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g] + op = _make_op() + + with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( + "bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[] + ): + DisconnectElements._perform(op, context=MagicMock()) + + op.report.assert_called_once() + + +def test_disconnect_operator_is_registered(): + from bonsai.bim.module import model + + assert any( + getattr(cls, "bl_idname", None) == "bim.disconnect_elements" for cls in model.classes + ), "DisconnectElements is not in the model classes tuple" diff --git a/src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py b/src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py deleted file mode 100644 index 5c342be644..0000000000 --- a/src/bonsai/test/bim/module/model/test_disconnect_wall_slab.py +++ /dev/null @@ -1,164 +0,0 @@ -# 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. - -"""Behaviour tests for ``bim.disconnect_wall_slab``. - -Pins the dispatch contract: resolves the wall + slab from GlobalIds, finds the -specific ``IfcRelConnectsElements(TOP)`` rel, removes it via the IFC API, then -delegates to ``core.regenerate_wall_to_underside`` to re-clip the wall against -any remaining slab connections.""" - -from unittest.mock import MagicMock, Mock, patch - -import pytest - -pytestmark = pytest.mark.model - - -def _make_op(*, wall_guid="WALL-GUID", slab_guid="SLAB-GUID"): - op = Mock() - op.wall_guid = wall_guid - op.slab_guid = slab_guid - op.report = Mock() - return op - - -def _ifc_file_with(*, walls: dict | None = None, slabs: dict | None = None): - ifc = MagicMock(name="ifc_file") - walls = walls or {} - slabs = slabs or {} - - def _by_guid(guid): - if guid in walls: - return walls[guid] - if guid in slabs: - return slabs[guid] - raise RuntimeError(f"no entity with guid {guid}") - - ifc.by_guid.side_effect = _by_guid - return ifc - - -def test_disconnect_removes_rel_then_regenerates(): - """Happy path: resolve both endpoints, find rel, call disconnect_element, - then regenerate so remaining slabs re-clip cleanly.""" - from bonsai.bim.module.model.wall import DisconnectWallSlab - - wall = Mock(name="wall") - slab = Mock(name="slab") - rel = Mock(name="rel") - wall_obj = Mock(name="wall_obj") - ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab}) - op = _make_op() - - with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( - "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj - ), patch("bonsai.bim.module.model.wall.tool.Wall.find_wall_slab_rel", return_value=rel), patch( - "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" - ) as disconnect, patch( - "bonsai.bim.module.model.wall.core.regenerate_wall_to_underside" - ) as regen: - DisconnectWallSlab._perform(op, context=MagicMock()) - - disconnect.assert_called_once_with(ifc_file, relating_element=slab, related_element=wall) - regen.assert_called_once() - args, _ = regen.call_args - assert args[3] == [wall_obj] - op.report.assert_not_called() - - -def test_disconnect_reports_when_guids_unknown(): - """Stale UI state can dispatch with guids no longer in the file — surface - an ERROR rather than crashing on RuntimeError from by_guid.""" - from bonsai.bim.module.model.wall import DisconnectWallSlab - - ifc_file = _ifc_file_with() - op = _make_op(wall_guid="MISSING", slab_guid="ALSO-MISSING") - - with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( - "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" - ) as disconnect, patch("bonsai.bim.module.model.wall.core.regenerate_wall_to_underside") as regen: - DisconnectWallSlab._perform(op, context=MagicMock()) - - disconnect.assert_not_called() - regen.assert_not_called() - op.report.assert_called_once() - args, _ = op.report.call_args - assert args[0] == {"ERROR"} - - -def test_disconnect_reports_when_rel_missing(): - """find_wall_slab_rel returns None when the rel doesn't exist (UI was - showing a stale icon). Operator reports + skips the mutation.""" - from bonsai.bim.module.model.wall import DisconnectWallSlab - - wall = Mock(name="wall") - slab = Mock(name="slab") - wall_obj = Mock(name="wall_obj") - ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab}) - op = _make_op() - - with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( - "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj - ), patch("bonsai.bim.module.model.wall.tool.Wall.find_wall_slab_rel", return_value=None), patch( - "bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element" - ) as disconnect, patch( - "bonsai.bim.module.model.wall.core.regenerate_wall_to_underside" - ) as regen: - DisconnectWallSlab._perform(op, context=MagicMock()) - - disconnect.assert_not_called() - regen.assert_not_called() - op.report.assert_called_once() - args, _ = op.report.call_args - assert args[0] == {"ERROR"} - - -def test_disconnect_reports_when_wall_obj_missing(): - """The wall entity exists but has no Blender object — surface ERROR - rather than silently no-op (or crash trying to pass None to regen).""" - from bonsai.bim.module.model.wall import DisconnectWallSlab - - wall = Mock(name="wall") - slab = Mock(name="slab") - ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab}) - op = _make_op() - - with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch( - "bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=None - ), patch("bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element") as disconnect, patch( - "bonsai.bim.module.model.wall.core.regenerate_wall_to_underside" - ) as regen: - DisconnectWallSlab._perform(op, context=MagicMock()) - - disconnect.assert_not_called() - regen.assert_not_called() - op.report.assert_called_once() - - -def test_disconnect_operator_is_registered(): - """Catches a forgotten classes-tuple update — the operator file can be - saved cleanly but the class never reaches Blender's registry without - the __init__.py entry.""" - from bonsai.bim.module import model - - assert any( - getattr(cls, "bl_idname", None) == "bim.disconnect_wall_slab" for cls in model.classes - ), "DisconnectWallSlab is not in the model classes tuple"