Unify wall disconnect ops via bim.disconnect_elements

Single generic dispatcher replaces UnjoinWallPathConnection +
DisconnectWallSlab. Takes two GlobalIds, looks up every supported
rel between them via tool.Connection.find_rels, dispatches the right
cleanup by rel kind:

- path (IfcRelConnectsPathElements): remove_connection on every rel
  in both orientations + recreate both walls + resync drafts.
- element-top (IfcRelConnectsElements with Description=="TOP"):
  disconnect_element + regenerate_wall_to_underside on the wall side
  via orient_element_top to recover which input is wall vs slab.
- element (other IfcRelConnectsElements): plain disconnect_element.

tool.Connection lands as a new tool module with two helpers:
- find_rels(a, b): every supported rel between two elements, walking
  both ConnectedTo + ConnectedFrom (catches both authoring
  orientations and dedups by id).
- find_rel(a, b): first-match convenience.
- orient_element_top(rel, a, b): recovers (wall, slab) from a TOP
  rel regardless of which input came first.

Updates GizmoWallUnjoinSingle to target bim.disconnect_elements with
both element_a_guid + element_b_guid pre-filled per icon. Adds the
single registration in tool/__init__.py and the classes-tuple entry
in bim/module/model/__init__.py. Drops the two retired classes.

Tests cover both cleanup branches (path + element-top), missing
endpoints, no-rel-found, and registration smoke.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-12 09:06:09 +02:00
parent b0eb55cc38
commit a3593ed58b
6 changed files with 442 additions and 267 deletions
@@ -120,8 +120,7 @@ classes = (
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.DisconnectWallSlab,
wall.UnjoinWallPathConnection,
wall.DisconnectElements,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
wall.FinishWallFilletPreview,
+62 -101
View File
@@ -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
+1
View File
@@ -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
+113
View File
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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"
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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"