Add bim.disconnect_wall_slab operator

Counterpart to UnjoinWallPathConnection on the wall-slab side: takes
a wall + slab GlobalId pair, locates the IfcRelConnectsElements(TOP)
between them via tool.Wall.find_wall_slab_rel, removes it via
ifcopenshell.api.geometry.disconnect_element, then re-runs
core.regenerate_wall_to_underside so the wall re-clips against any
remaining connected slabs (the disconnected slab is excluded
naturally because the helper walks tool.Model.get_connected_slab_objs
which filters by the rel set).

Defensive reports replace silent CANCELLED on three error paths the
UI can hit when the gizmo dispatches against stale state: unknown
GlobalIds, wall entity without a Blender object, no rel found
between the resolved pair.

Tests cover all four control flows (happy path + three error paths)
plus a registration smoke that catches a forgotten classes-tuple
update.

A follow-up commit will retrofit this + UnjoinWallPathConnection +
the MEP port disconnects through a unified bim.disconnect_elements
dispatcher with a small connection-type registry; that lands as a
separate single-concern commit so the typed operator can be
reviewed first.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-12 08:19:04 +02:00
parent f6590d8be2
commit b0eb55cc38
3 changed files with 216 additions and 0 deletions
@@ -120,6 +120,7 @@ classes = (
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.DisconnectWallSlab,
wall.UnjoinWallPathConnection,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
@@ -349,6 +349,57 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
_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")
@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):
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
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.")
return
wall_obj = tool.Ifc.get_object(wall)
if wall_obj is None:
self.report({"ERROR"}, "Wall has no Blender object.")
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])
class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_walls_to_underside"
bl_label = "Extend Walls To Underside"
@@ -0,0 +1,164 @@
# 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"