mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
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:
@@ -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"
|
||||
Reference in New Issue
Block a user