Cascade connection cleanup on element delete

Deleting a slab that was connected to a wall via IfcRelConnectsElements(TOP)
left the wall holding orphan IfcBooleanResult items + a stale BBIM_Boolean
pset. The disconnect operator already runs the right cleanup; element delete
just never invoked it.

Extract the per-kind cleanup into core.connection.disconnect_rel so the
operator (bim.disconnect_elements) and a new cascade in
tool.Geometry.delete_ifc_object share one dispatch table. Adding a future
rel kind to tool.Connection.find_rels now flows into both call sites
automatically; an AST forward-compat guard enforces coverage.

Other adjustments:
- regenerate_wall_to_underside zero-slab branch now removes stale clip
  booleans instead of silently skipping, so disconnecting the last TOP
  slab also reverts the wall correctly.
- duplicate_ifc_objects (Shift+D) calls strip_underside_booleans on copied
  walls so the duplicate doesn't carry over the source's slab trim, then
  reloads the body representation when something was stripped so the
  viewport reflects the change without waiting on Shift+G.
- batch_being_deleted_ids threads through OverrideDelete so the cascade
  can suppress partner-side regenerate when both endpoints are queued for
  deletion in the same batch.

This file was generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-12 11:11:07 +02:00
parent c7d5d6c498
commit bb8681a954
11 changed files with 742 additions and 32 deletions
+7
View File
@@ -60,6 +60,13 @@ def collector():
prophet.verify()
@pytest.fixture
def connection():
prophet = Prophecy(bonsai.core.tool.Connection)
yield prophet
prophet.verify()
@pytest.fixture
def context():
prophet = Prophecy(bonsai.core.tool.Context)
+218
View File
@@ -0,0 +1,218 @@
# 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.
"""Dispatch tests for ``core.connection.disconnect_rel``.
The dispatch is the single source of truth for per-kind cleanup shared by the
explicit ``bim.disconnect_elements`` operator and the implicit cascade in
``tool.Geometry.delete_ifc_object``. Each kind has one test that pins which
helpers must be called; the AST forward-compat guard in
``test_connection_forward_compat.py`` then asserts the dispatch table covers
every kind ``Connection.find_rels`` can emit.
Uses ``unittest.mock`` directly (rather than the Prophecy fixtures) because
the dispatch passes IFC rel entities with attribute access (``rel.RelatingElement``)
that Prophecy's JSON call recorder can't serialize.
"""
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
import bonsai.core.connection as subject
def _rel(relating="slab", related="wall"):
return SimpleNamespace(RelatingElement=relating, RelatedElement=related)
def _ifc_with_objects(mapping):
ifc = Mock()
ifc.get_object.side_effect = lambda e: mapping.get(e)
ifc.run = Mock()
return ifc
class TestDisconnectRelPath:
def test_removes_connection_and_recreates_both_walls(self):
ifc = _ifc_with_objects({"elem_a": "obj_a", "elem_b": "obj_b"})
geometry = Mock()
model = Mock()
connection = Mock()
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",
)
remove.assert_called_once_with(geometry, connection="rel")
model.recreate_wall.assert_any_call("elem_a", "obj_a")
model.recreate_wall.assert_any_call("elem_b", "obj_b")
assert model.recreate_wall.call_count == 2
def test_skip_elem_recreate_suppresses_elem_side(self):
"""Cascade case: elem is being deleted — don't recreate it."""
ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"})
geometry = Mock()
model = Mock()
connection = Mock()
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",
skip_elem_recreate=True,
)
model.recreate_wall.assert_called_once_with("partner", "partner_obj")
def test_skip_partner_recreate_suppresses_partner_side(self):
ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"})
geometry = Mock()
model = Mock()
connection = Mock()
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",
skip_partner_recreate=True,
)
model.recreate_wall.assert_called_once_with("elem", "elem_obj")
def test_both_skips_means_only_remove_rel(self):
ifc = _ifc_with_objects({})
geometry = Mock()
model = Mock()
connection = Mock()
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",
skip_elem_recreate=True,
skip_partner_recreate=True,
)
remove.assert_called_once()
model.recreate_wall.assert_not_called()
class TestDisconnectRelElementTop:
def test_disconnects_then_regenerates_wall(self):
"""Operator case (no skip flags): both sides survive, so the wall gets
re-clipped against currently-connected slabs."""
rel = _rel()
ifc = _ifc_with_objects({"wall": "wall_obj"})
geometry = Mock()
model = Mock()
connection = Mock()
connection.orient_element_top.return_value = ("wall", "slab")
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.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):
"""When slab is being deleted (elem=slab), wall survives and must
re-clip against remaining connections — the cascade's main purpose."""
rel = _rel()
ifc = _ifc_with_objects({"wall": "wall_obj"})
connection = Mock()
connection.orient_element_top.return_value = ("wall", "slab")
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",
skip_elem_recreate=True, # slab is being deleted
)
regen.assert_called_once()
def test_wall_delete_cascade_skips_wall_regen(self):
"""When the wall itself is being deleted, regenerating its body moments
before remove_product wipes it is wasted work — skip."""
rel = _rel()
ifc = _ifc_with_objects({"wall": "wall_obj"})
connection = Mock()
connection.orient_element_top.return_value = ("wall", "slab")
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",
skip_elem_recreate=True, # wall is being deleted
)
regen.assert_not_called()
ifc.run.assert_called_once() # rel still removed
def test_both_in_batch_skips_wall_regen(self):
"""Batch delete of both endpoints, processing slab first: partner (wall)
also queued for deletion → skip wall regen."""
rel = _rel()
ifc = _ifc_with_objects({"wall": "wall_obj"})
connection = Mock()
connection.orient_element_top.return_value = ("wall", "slab")
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",
skip_elem_recreate=True,
skip_partner_recreate=True, # wall also in batch
)
regen.assert_not_called()
class TestDisconnectRelElement:
def test_just_removes_the_rel(self):
rel = _rel(relating="A", related="B")
ifc = Mock()
subject.disconnect_rel(
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"
)
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",
)