Merge pull request #8173 from Gorgious56/bonsai/wall-slab-gizmos

Bonsai/wall slab gizmos
This commit is contained in:
Gorgious56
2026-06-15 16:00:56 +02:00
committed by GitHub
29 changed files with 2743 additions and 110 deletions
@@ -0,0 +1,457 @@
# 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_rels_for_element — single-element entry point
# ---------------------------------------------------------------------------
def test_find_rels_for_element_returns_kind_and_partner_per_rel():
"""Cascade-on-delete needs every rel touching one element plus the partner
element on the other side of each rel — that's the cleanup target."""
elem = _elem()
partner_a = _elem()
partner_b = _elem()
rel_path = _rel("IfcRelConnectsPathElements", related=partner_a, rel_id=1)
rel_top = _rel("IfcRelConnectsElements", relating=partner_b, description="TOP", rel_id=2)
elem.ConnectedTo = [rel_path]
elem.ConnectedFrom = [rel_top]
result = tool.Connection.find_rels_for_element(elem)
assert (rel_path, "path", partner_a) in result
assert (rel_top, "element-top", partner_b) in result
assert len(result) == 2
def test_find_rels_for_element_dedups_by_rel_id():
elem = _elem()
partner = _elem()
rel = _rel("IfcRelConnectsPathElements", related=partner, relating=partner, rel_id=1)
elem.ConnectedTo = [rel]
elem.ConnectedFrom = [rel]
result = tool.Connection.find_rels_for_element(elem)
assert len(result) == 1
def test_find_rels_for_element_skips_rels_without_partner():
"""Defensive: a malformed rel missing the opposite-side attribute should not
crash — record nothing for it rather than emit a (rel, kind, None) triple
that would later trip a None-deref in the dispatch."""
elem = _elem()
bad = _rel("IfcRelConnectsPathElements", related=None, rel_id=1)
elem.ConnectedTo = [bad]
assert tool.Connection.find_rels_for_element(elem) == []
# ---------------------------------------------------------------------------
# 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_dispatches_one_call_per_rel():
"""Operator forwards every rel returned by find_rels to disconnect_rel,
in order — the operator is a thin wrapper; per-kind cleanup logic lives
in core.connection.disconnect_rel and is tested separately."""
from bonsai.bim.module.model.wall import DisconnectElements
elem_a = Mock()
elem_b = Mock()
rel1 = Mock()
rel2 = 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, "element-top")],
), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch, patch(
"bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()
), patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
), patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
):
DisconnectElements._perform(op, context=MagicMock())
assert dispatch.call_count == 2
# Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation
# — orient_element_top inside disconnect_rel recovers the wall/slab roles.
for call, expected_rel, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
kw = call.kwargs
assert kw["rel"] is expected_rel
assert kw["kind"] == expected_kind
assert kw["elem"] is elem_a
assert kw["partner"] is elem_b
op.report.assert_not_called()
def test_disconnect_resyncs_path_objs_once_for_path_kind():
"""For path rels the operator collects both endpoint objects and resyncs
drafts once at the end — a Blender-side concern that doesn't belong in
the core dispatch."""
from bonsai.bim.module.model.wall import DisconnectElements
elem_a = Mock()
elem_b = Mock()
obj_a = Mock()
obj_b = Mock()
rel = 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=[(rel, "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.connection.disconnect_rel"
), patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
) as resync, patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
):
DisconnectElements._perform(op, context=MagicMock())
resync.assert_called_once_with([obj_a, obj_b])
def test_disconnect_skips_resync_for_non_path_kind():
"""element-top / element kinds don't need wall-draft resync — that's a
path-specific concern (DumbWallJoiner geometry refresh)."""
from bonsai.bim.module.model.wall import DisconnectElements
elem_a = Mock()
elem_b = Mock()
rel = 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=[(rel, "element-top")]
), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch(
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
), patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
) as resync, patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
):
DisconnectElements._perform(op, context=MagicMock())
resync.assert_not_called()
def test_disconnect_gizmo_direction_symmetry():
"""The wall-selected gizmo dispatches with element_a=wall, element_b=slab.
The slab-selected gizmo dispatches with element_a=slab, element_b=wall.
Both routes hit disconnect_rel with the same (rel, kind) pair — orientation
recovery happens inside the dispatch, not at the operator layer."""
from bonsai.bim.module.model.wall import DisconnectElements
wall = Mock(name="wall")
slab = Mock(name="slab")
rel = Mock()
ifc_file = MagicMock()
op = _make_op()
def _run_with_guids(a, b):
ifc_file.by_guid.side_effect = lambda g: {a: wall if a == "WALL" else slab, b: slab if b == "SLAB" else wall}[g]
op.element_a_guid = a
op.element_b_guid = 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", return_value=[(rel, "element-top")]
), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch(
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
) as dispatch, patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
), patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
):
DisconnectElements._perform(op, context=MagicMock())
return dispatch.call_args.kwargs
wall_first = _run_with_guids("WALL", "SLAB")
slab_first = _run_with_guids("SLAB", "WALL")
# disconnect_rel sees (rel, "element-top") in both runs; elem/partner swap
# by argument order but orient_element_top inside disconnect_rel resolves
# the wall/slab roles symmetrically.
assert wall_first["rel"] is rel and slab_first["rel"] is rel
assert wall_first["kind"] == slab_first["kind"] == "element-top"
assert {wall_first["elem"], wall_first["partner"]} == {wall, slab}
assert {slab_first["elem"], slab_first["partner"]} == {wall, slab}
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"
def test_disconnect_refuses_path_kind_when_either_side_is_fillet():
"""The fillet corner's join with its source walls defines its identity
— unjoining there would tear down the chord axis reference. The
operator reports an INFO directing the user to delete the corner
wall and skips the dispatch entirely."""
from bonsai.bim.module.model.wall import DisconnectElements
fillet = Mock(name="fillet_corner")
wall = Mock(name="source_wall")
rel = Mock()
ifc_file = MagicMock()
ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": wall}[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, "path")]
), patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall",
side_effect=lambda e: e is fillet,
), patch(
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
) as dispatch:
DisconnectElements._perform(op, context=MagicMock())
dispatch.assert_not_called()
op.report.assert_called_once()
args, _ = op.report.call_args
assert args[0] == {"INFO"}
def test_disconnect_allows_slab_kind_even_when_wall_is_fillet():
"""The fillet ↔ slab underside clip is a different relationship from
the fillet ↔ source-wall path join. Slab disconnect must remain
available while the corner is in preview."""
from bonsai.bim.module.model.wall import DisconnectElements
fillet = Mock(name="fillet_corner")
slab = Mock(name="slab")
rel = Mock()
ifc_file = MagicMock()
ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "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.Parametric.is_fillet_corner_wall",
side_effect=lambda e: e is fillet,
), patch(
"bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()
), patch(
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
) as dispatch, patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
):
DisconnectElements._perform(op, context=MagicMock())
dispatch.assert_called_once()
@@ -0,0 +1,87 @@
# 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.
"""Pins the active-is-survivor merge convention.
``bim.merge_wall`` must consume the non-active selection into the active
one — matching Blender's ``OBJECT_OT_join`` / ``MESH_OT_merge`` "at
last" convention. Users following Ctrl+J muscle-memory click the
surviving wall last; the operator must align with that expectation."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
pytestmark = pytest.mark.wall
def _run_perform(active, other):
"""Invoke ``MergeWall._perform`` as an unbound function with the
two wall stubs in the selection, patching the heavy IFC / Blender
side effects. Returns the ``(merger_arg_1, merger_arg_2)`` actually
passed to ``DumbWallJoiner.merge``."""
from bonsai.bim.module.model.wall import MergeWall
context = SimpleNamespace(active_object=active)
captured_call = {}
def _capture_merge(self, a, b):
captured_call["wall1"] = a
captured_call["wall2"] = b
fake_self = SimpleNamespace()
with (
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
patch("bonsai.bim.module.model.wall.tool.Model.get_selected_mesh_objects", return_value=[active, other]),
patch("bonsai.bim.module.model.wall.DumbWallJoiner.__init__", return_value=None),
patch("bonsai.bim.module.model.wall.DumbWallJoiner.merge", new=_capture_merge),
patch("bonsai.bim.module.model.wall._maybe_resync_wall_props_from_ifc"),
patch("bonsai.bim.module.model.wall._regenerate_walls") as regen_walls,
):
result = MergeWall._perform(fake_self, context)
return captured_call, regen_walls, result
def test_active_wall_is_passed_as_survivor_to_merge():
"""The first argument to ``DumbWallJoiner.merge`` is the survivor;
the active object must occupy that slot so the wall the user clicked
last absorbs the other."""
active = SimpleNamespace(name="active")
other = SimpleNamespace(name="other")
captured, _regen, _ = _run_perform(active, other)
assert captured["wall1"] is active
assert captured["wall2"] is other
def test_post_merge_resync_targets_active_not_consumed():
"""After the merge ``_regenerate_walls`` rebuilds the survivor's
body. Targeting the consumed wall would crash on a freed ``bpy_struct``;
the survivor (active) is the only valid target."""
active = SimpleNamespace(name="active")
other = SimpleNamespace(name="other")
_, regen_walls, _ = _run_perform(active, other)
regen_walls.assert_called_once_with([active])
@@ -0,0 +1,85 @@
# 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.
"""Pins the branching contract of ``tool.Model.regenerate_wall``.
The body rebuild always runs (extrusion + openings); the slab re-clip only
runs when an ``IfcRelConnectsElements(TOP)`` rel survives. A wall without
either feature still completes without crashing."""
from unittest.mock import Mock, patch
import pytest
import bonsai.tool as tool
pytestmark = pytest.mark.model
def test_regenerate_wall_rebuilds_body_and_reclips_when_connected():
"""Wall with a TOP connection: body rebuilt first, then re-clipped."""
element = Mock()
obj = Mock()
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
tool.Model, "recreate_wall"
) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=True), patch(
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
) as regen:
tool.Model.regenerate_wall(obj)
recreate.assert_called_once_with(element, obj)
regen.assert_called_once()
args, _ = regen.call_args
assert args[3] == [obj]
def test_regenerate_wall_skips_reclip_when_no_top_rel():
"""Wall without a TOP connection: body rebuilt; re-clip skipped."""
element = Mock()
obj = Mock()
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
tool.Model, "recreate_wall"
) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=False), patch(
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
) as regen:
tool.Model.regenerate_wall(obj)
recreate.assert_called_once_with(element, obj)
regen.assert_not_called()
def test_regenerate_wall_noops_when_obj_has_no_ifc_entity():
"""Non-IFC objects (e.g. a freshly created Blender mesh before
`tool.Ifc.run("root.create_entity")` runs) return None from get_entity;
the helper must return without touching the body or any rels."""
obj = Mock()
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=None), patch.object(
tool.Model, "recreate_wall"
) as recreate, patch.object(tool.Model, "has_underside_connection") as has_top, patch(
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
) as regen:
tool.Model.regenerate_wall(obj)
recreate.assert_not_called()
has_top.assert_not_called()
regen.assert_not_called()
@@ -26,6 +26,8 @@ Allow-list (gizmos intentionally outside the rule):
- ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base
parametric poll already filters array children.
- ``GizmoSlabEdition`` — same as ``GizmoWallEdition`` (inherits
``BaseParametricGizmoGroup`` whose poll filters array children).
- ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire
WHILE its own preview is active; routing it through the topology gate
would self-block it.
@@ -47,9 +49,11 @@ pytestmark = pytest.mark.model
# Wall gizmo groups intentionally outside the rule. Add a new entry only
# with the in-code reasoning above.
_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
_ALLOWLIST = frozenset({"GizmoSlabEdition", "GizmoWallEdition", "GizmoWallFilletPreview"})
_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
_REQUIRED_CALLEES = frozenset(
{"_wall_topology_gizmo_poll_gate", "_slab_connection_gizmo_poll_gate", "any_selected_is_array_child"}
)
def _wall_module_source():
@@ -50,12 +50,15 @@ def _make_context(active, selected):
return SimpleNamespace(active_object=active, selected_objects=list(selected))
def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
def _patch_tools(
prefs_on, selected, active_element, other_element, active_usage, other_usage, other_is_path_connectable=None
):
"""Return a stack of patches that simulate one selection / IFC state for poll().
``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
selection set, the IFC entity lookup, and the usage-type lookup are stubbed
so the test only depends on the predicate ordering in poll()."""
selection set, the IFC entity lookup, the usage-type lookup, and the
path-connectable-wall predicate are stubbed so the test only depends on
the predicate ordering in poll()."""
prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
entity_map = {}
@@ -67,12 +70,18 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
usage_map[id(active_element)] = active_usage
usage_map[id(other_element)] = other_usage
if other_is_path_connectable is None:
other_is_path_connectable = other_usage == "LAYER2"
def get_entity(obj):
return entity_map.get(id(obj))
def get_usage_type(element):
return usage_map.get(id(element))
def is_path_connectable_wall(element):
return element is other_element and other_is_path_connectable
from bonsai import tool
return [
@@ -80,6 +89,7 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=is_path_connectable_wall),
# The array-child filter is pinned by its own test file; stub it here
# so these poll tests stay focused on the count / layer-usage gates
# and don't have to scaffold the memoization cache key.
@@ -87,7 +97,15 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
]
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
def _run_poll(
prefs_on,
active_is_in_selected,
len_override,
active_usage,
other_usage,
active_has_entity=True,
other_is_path_connectable=None,
):
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
slab_obj = _Obj("slab")
@@ -103,7 +121,15 @@ def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other
slab_element = object() if active_has_entity else None
wall_element = object()
patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
patches = _patch_tools(
prefs_on,
selected,
slab_element,
wall_element,
active_usage,
other_usage,
other_is_path_connectable=other_is_path_connectable,
)
for p in patches:
p.start()
try:
@@ -189,6 +215,23 @@ def test_poll_rejects_when_other_is_not_layer2_wall():
)
def test_poll_accepts_fillet_corner_wall_partner():
# Fillet-corner walls carry no LAYER2 usage by spec but the extend-to-
# underside operator handles them just like a parametric LAYER2 wall —
# the gizmo must surface for the slab + fillet-corner selection too.
assert (
_run_poll(
prefs_on=True,
active_is_in_selected=True,
len_override=None,
active_usage="LAYER3",
other_usage=None,
other_is_path_connectable=True,
)
is True
)
# ----------------------------------------------------------------------------
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
# ----------------------------------------------------------------------------
@@ -0,0 +1,276 @@
# 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.
"""Pins the contract that ``DumbWallJoiner.merge`` re-hosts openings from
the discarded wall to the survivor before the cascade delete tears down
``element2.HasOpenings`` and any filling that references them.
``edit_object_placement`` preserves the opening's world position when the
two walls have different placements a ``PlacementRelTo`` swap alone
would shift the opening as the relative offset changes."""
from unittest.mock import MagicMock, Mock, patch
import numpy as np
import pytest
pytestmark = pytest.mark.wall
def _opening_rel(opening_id: int, placement_matrix: np.ndarray):
"""Build a stub ``IfcRelVoidsElement`` carrying an opening with a known
placement. ``RelatingBuildingElement`` is settable so the test can
observe the re-host."""
opening = Mock(name=f"opening_{opening_id}")
opening.id.return_value = opening_id
opening.ObjectPlacement = Mock(name=f"opening_placement_{opening_id}")
rel = Mock(name=f"voids_rel_{opening_id}")
rel.RelatedOpeningElement = opening
rel.RelatingBuildingElement = None
return rel, opening, placement_matrix
def _merge_inputs(*, has_openings):
"""Stage the minimum wall1 + wall2 + element1 + element2 surface that
``DumbWallJoiner.merge`` reads. The reference lines and placements are
rigged so the collinearity guard passes and execution reaches the
opening-migration loop."""
wall1 = Mock(name="wall1")
wall2 = Mock(name="wall2")
element1 = Mock(name="element1")
element2 = Mock(name="element2")
element1.ObjectPlacement = Mock(name="elem1_placement")
element2.ObjectPlacement = Mock(name="elem2_placement")
element1.ConnectedTo = []
element1.ConnectedFrom = []
element2.ConnectedTo = []
element2.ConnectedFrom = []
element2.HasOpenings = list(has_openings)
return wall1, wall2, element1, element2
def _run_merge(wall1, wall2, element1, element2, opening_matrices, captured_edit_calls):
"""Invoke ``DumbWallJoiner().merge`` against the staged inputs with
every heavy IFC / Blender side effect patched out. ``opening_matrices``
maps an opening id to its captured world matrix; ``captured_edit_calls``
is appended to whenever ``edit_object_placement`` fires."""
from bonsai.bim.module.model.wall import DumbWallJoiner
def fake_get_local_placement(placement):
for rel in element2.HasOpenings:
if rel.RelatedOpeningElement.ObjectPlacement is placement:
return opening_matrices[rel.RelatedOpeningElement.id()]
return np.eye(4)
def fake_get_entity(obj):
return {wall1: element1, wall2: element2}[obj]
def fake_edit_object_placement(ifc_file, *, product, matrix, is_si, should_transform_children):
captured_edit_calls.append(
{
"product": product,
"matrix": matrix,
"is_si": is_si,
"should_transform_children": should_transform_children,
}
)
p1 = np.array([0.0, 0.0])
p2 = np.array([5.0, 0.0])
p3 = np.array([5.0, 0.0])
p4 = np.array([10.0, 0.0])
with (
patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
patch(
"bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
),
patch(
"bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
side_effect=fake_get_local_placement,
),
patch(
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.edit_object_placement",
side_effect=fake_edit_object_placement,
),
patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object") as delete_ifc_object,
patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
):
DumbWallJoiner().merge(wall1, wall2)
return delete_ifc_object
def test_merge_rehosts_each_opening_to_survivor():
"""Every void rel on the discarded wall is rebound to the survivor so
the cascade delete doesn't take them down with element2."""
matrix_a = np.eye(4)
matrix_a[0, 3] = 1.0
matrix_b = np.eye(4)
matrix_b[0, 3] = 3.0
rel_a, opening_a, _ = _opening_rel(opening_id=101, placement_matrix=matrix_a)
rel_b, opening_b, _ = _opening_rel(opening_id=102, placement_matrix=matrix_b)
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel_a, rel_b])
_run_merge(
wall1,
wall2,
element1,
element2,
opening_matrices={101: matrix_a, 102: matrix_b},
captured_edit_calls=[],
)
assert rel_a.RelatingBuildingElement is element1
assert rel_b.RelatingBuildingElement is element1
def test_merge_preserves_opening_world_placement():
"""``edit_object_placement`` re-applies the opening's pre-merge world
matrix so the void doesn't drift when the two walls have different
placements the regression a ``PlacementRelTo`` swap alone would
fail."""
matrix = np.eye(4)
matrix[:3, 3] = (2.5, 0.0, 0.0)
rel, opening, _ = _opening_rel(opening_id=42, placement_matrix=matrix)
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
captured: list[dict] = []
_run_merge(
wall1,
wall2,
element1,
element2,
opening_matrices={42: matrix},
captured_edit_calls=captured,
)
edit_calls_for_opening = [call for call in captured if call["product"] is opening]
assert len(edit_calls_for_opening) == 1
np.testing.assert_allclose(edit_calls_for_opening[0]["matrix"], matrix, atol=1e-9)
assert edit_calls_for_opening[0]["should_transform_children"] is False
def test_merge_rehosts_before_delete():
"""Order matters: ``delete_ifc_object`` cascades through
``element2.HasOpenings`` and would destroy the void if it ran before
the re-host. Assert the survivor was rebound before delete fires."""
matrix = np.eye(4)
rel, opening, _ = _opening_rel(opening_id=7, placement_matrix=matrix)
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
delete_ifc_object = _run_merge(
wall1,
wall2,
element1,
element2,
opening_matrices={7: matrix},
captured_edit_calls=[],
)
assert rel.RelatingBuildingElement is element1
delete_ifc_object.assert_called_once_with(wall2)
def test_merge_skips_non_path_connection_rels():
"""``ConnectedTo`` / ``ConnectedFrom`` carry both
``IfcRelConnectsPathElements`` (wall-wall joins) AND
``IfcRelConnectsElements`` (slab underside clips). Only the path rels
expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
accessing those attributes on an element rel raises ``AttributeError``.
The migration loop must filter on the rel class so a wall with a slab
clip can still be merged."""
from bonsai.bim.module.model.wall import DumbWallJoiner
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[])
path_rel = Mock(name="path_rel")
path_rel.is_a = lambda c: c == "IfcRelConnectsPathElements"
path_rel.RelatingElement = Mock(name="rel_relating")
path_rel.RelatedElement = Mock(name="rel_related")
path_rel.RelatingConnectionType = "ATSTART"
path_rel.RelatedConnectionType = "ATEND"
slab_rel = Mock(name="slab_rel")
slab_rel.is_a = lambda c: c == "IfcRelConnectsElements"
slab_rel.Description = "TOP"
# ``RelatedConnectionType`` is what the merge loop reads from
# ``ConnectedFrom``; the real ``IfcRelConnectsElements`` schema has
# no such attribute, so wire the stub to raise like ifcopenshell does.
type(slab_rel).RelatedConnectionType = property(
lambda self: (_ for _ in ()).throw(AttributeError("RelatedConnectionType"))
)
type(slab_rel).RelatingConnectionType = property(
lambda self: (_ for _ in ()).throw(AttributeError("RelatingConnectionType"))
)
element2.ConnectedFrom = [slab_rel, path_rel]
captured_disconnects = []
captured_connects = []
def fake_disconnect_path(*args, **kwargs):
captured_disconnects.append(kwargs)
def fake_connect_path(*args, **kwargs):
captured_connects.append(kwargs)
p1 = np.array([0.0, 0.0])
p2 = np.array([5.0, 0.0])
p3 = np.array([5.0, 0.0])
p4 = np.array([10.0, 0.0])
def fake_get_entity(obj):
return {wall1: element1, wall2: element2}[obj]
with (
patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
patch(
"bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
),
patch(
"bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
return_value=np.eye(4),
),
patch(
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_path",
side_effect=fake_disconnect_path,
),
patch(
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.connect_path",
side_effect=fake_connect_path,
),
patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object"),
patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
):
# The bug pre-fix: the slab rel's ``RelatedConnectionType`` access
# raised AttributeError and crashed merge. With the filter, this
# call must complete cleanly.
DumbWallJoiner().merge(wall1, wall2)
assert len(captured_disconnects) == 1
assert len(captured_connects) == 1
assert captured_disconnects[0]["connection_type"] == "ATEND"
@@ -0,0 +1,64 @@
# 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.
"""Pins the contract that the three dimension-mutating wall operators —
``bim.change_extrusion_depth``, ``bim.change_extrusion_x_angle``,
``bim.change_layer_length`` re-prime ``BIMWallProperties`` from the
post-mutation IFC at the end of ``_execute``.
Without the resync, ``props.height`` / ``props.length`` / ``props.x_angle``
stay at their pre-mutation values; gizmo icons that position from
``props.height`` then sit at the old elevation even though the wall mesh
shows the new one."""
import inspect
import pytest
pytestmark = pytest.mark.wall
def _execute_source(operator_cls):
return inspect.getsource(operator_cls._execute)
def test_change_extrusion_depth_resyncs_wall_props():
"""Height mutation must re-prime ``BIMWallProperties.height`` so
gizmo icons positioned from ``props.height`` track the post-mutation
wall top in the same redraw."""
from bonsai.bim.module.model.wall import ChangeExtrusionDepth
assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionDepth)
def test_change_extrusion_x_angle_resyncs_wall_props():
"""Slope mutation must re-prime ``BIMWallProperties.x_angle`` so
slope-driven gizmo positions track the new angle."""
from bonsai.bim.module.model.wall import ChangeExtrusionXAngle
assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionXAngle)
def test_change_layer_length_resyncs_wall_props():
"""Length mutation must re-prime ``BIMWallProperties.length`` so
horizontal gizmo X positions track the new axis extent."""
from bonsai.bim.module.model.wall import ChangeLayerLength
assert "_resync_walls_after_mutation" in _execute_source(ChangeLayerLength)
@@ -0,0 +1,213 @@
# 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 wall-slab connection helpers on tool.Wall.
Pins the rel-shape contract (IfcRelConnectsElements with Description=="TOP")
the underside-extension feature creates, and the icon placement contract the
new wall-slab connection gizmo group reads."""
from unittest.mock import Mock, patch
import pytest
from mathutils import Matrix, Vector
import bonsai.tool as tool
pytestmark = pytest.mark.model
def _rel(klass: str = "IfcRelConnectsElements", description: str = "TOP", relating=None, related=None):
rel = Mock()
rel.is_a = lambda c: c == klass
rel.Description = description
rel.RelatingElement = relating
rel.RelatedElement = related
return rel
def _wall_with_rels(*rels) -> Mock:
wall = Mock()
wall.ConnectedFrom = list(rels)
return wall
def _slab_with_rels(*rels) -> Mock:
slab = Mock()
slab.ConnectedTo = list(rels)
return slab
# ---------------------------------------------------------------------------
# iter_wall_slab_connections — yields (slab, rel) for TOP rels
# ---------------------------------------------------------------------------
def test_iter_wall_slab_connections_yields_top_rels():
slab_a = Mock(name="slab_a")
slab_b = Mock(name="slab_b")
wall = _wall_with_rels(
_rel(relating=slab_a),
_rel(relating=slab_b),
)
result = list(tool.Wall.iter_wall_slab_connections(wall))
assert result == [(slab_a, wall.ConnectedFrom[0]), (slab_b, wall.ConnectedFrom[1])]
def test_iter_wall_slab_connections_skips_non_top_description():
"""Only TOP-described rels count; BOTTOM / SIDE / arbitrary strings are
skipped so other RelConnectsElements semantics aren't confused with the
underside-extension contract."""
slab = Mock()
wall = _wall_with_rels(
_rel(description="BOTTOM", relating=slab),
_rel(description="TOP", relating=slab),
)
result = list(tool.Wall.iter_wall_slab_connections(wall))
assert len(result) == 1
assert result[0][0] is slab
def test_iter_wall_slab_connections_skips_non_connectselements_rels():
"""Path-connections to other walls show up on ConnectedFrom too — the
helper must filter on rel class, not just presence."""
slab = Mock()
wall = _wall_with_rels(
_rel(klass="IfcRelConnectsPathElements", relating=slab),
_rel(klass="IfcRelConnectsElements", relating=slab),
)
result = list(tool.Wall.iter_wall_slab_connections(wall))
assert len(result) == 1
def test_iter_wall_slab_connections_handles_none_relating():
"""A malformed rel with RelatingElement=None is skipped rather than
raising defensive against partially-loaded IFC files."""
wall = _wall_with_rels(_rel(relating=None))
result = list(tool.Wall.iter_wall_slab_connections(wall))
assert result == []
def test_iter_wall_slab_connections_empty_when_no_connectedfrom():
wall = Mock()
wall.ConnectedFrom = []
assert list(tool.Wall.iter_wall_slab_connections(wall)) == []
# ---------------------------------------------------------------------------
# iter_slab_wall_connections — mirror, walks slab.ConnectedTo
# ---------------------------------------------------------------------------
def test_iter_slab_wall_connections_yields_top_rels():
wall_a = Mock()
wall_b = Mock()
slab = _slab_with_rels(
_rel(related=wall_a),
_rel(related=wall_b),
)
result = list(tool.Wall.iter_slab_wall_connections(slab))
assert [w for w, _ in result] == [wall_a, wall_b]
def test_iter_slab_wall_connections_skips_non_top():
wall = Mock()
slab = _slab_with_rels(
_rel(description="BOTTOM", related=wall),
_rel(description="TOP", related=wall),
)
result = list(tool.Wall.iter_slab_wall_connections(slab))
assert len(result) == 1
# ---------------------------------------------------------------------------
# find_wall_slab_rel — locate specific rel between wall + slab
# ---------------------------------------------------------------------------
def test_find_wall_slab_rel_returns_match():
slab_a = Mock(name="slab_a")
slab_b = Mock(name="slab_b")
rel_a = _rel(relating=slab_a)
rel_b = _rel(relating=slab_b)
wall = _wall_with_rels(rel_a, rel_b)
assert tool.Wall.find_wall_slab_rel(wall, slab_b) is rel_b
def test_find_wall_slab_rel_returns_none_when_unconnected():
slab_a = Mock(name="slab_a")
other_slab = Mock(name="other_slab")
wall = _wall_with_rels(_rel(relating=slab_a))
assert tool.Wall.find_wall_slab_rel(wall, other_slab) is None
# ---------------------------------------------------------------------------
# wall_slab_connection_location_world — icon anchor point
# ---------------------------------------------------------------------------
def test_wall_slab_connection_location_perches_above_wall_top():
"""Icon X/Y comes from the wall axis midpoint; Z from the wall's mesh
bbox top in world space plus WALL_SLAB_CONNECTION_Z_CLEARANCE so the
icon sits above the extend-vertical / slope gizmo at the wall top."""
wall_obj = Mock()
wall_obj.matrix_world = Matrix.Identity(4)
wall_obj.bound_box = [
(-0.1, -0.1, 0.0),
(0.1, -0.1, 0.0),
(-0.1, 0.1, 0.0),
(0.1, 0.1, 0.0),
(-0.1, -0.1, 3.0),
(0.1, -0.1, 3.0),
(-0.1, 0.1, 3.0),
(0.1, 0.1, 3.0),
]
slab_obj = Mock()
ref_line = (Vector((1.0, 0.0, 0.0)), Vector((3.0, 0.0, 0.0)))
with patch.object(tool.Wall, "get_world_reference_line", return_value=ref_line):
loc = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
expected_z = 3.0 + tool.Wall.WALL_SLAB_CONNECTION_Z_CLEARANCE
assert loc == Vector((2.0, 0.0, expected_z))
def test_wall_slab_connection_location_returns_none_for_axisless_wall():
"""A wall without an IFC Axis representation has no reference line; the
helper returns None so callers can skip rather than guess a location."""
wall_obj = Mock()
slab_obj = Mock()
with patch.object(tool.Wall, "get_world_reference_line", return_value=None):
assert tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) is None
@@ -0,0 +1,67 @@
# 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.
"""Pins two contracts in ``DumbWallJoiner.split``'s filled-opening branch:
1. Side classification reads the opening's axis-projected midpoint, not
the filling's ``matrix_world.translation``. The filling origin is
flip-fragile flipping rotates the filler 180° + translates so the
bbox stays visually in place, which would mis-classify a flipped door
centred over the cut.
2. When the void straddles the cut and the filling moves to element2,
the void copy for element1 is taken from the ORIGINAL opening (whose
``ObjectPlacement`` still references element1), not the rebound
``new_opening`` (whose ``PlacementRelTo`` was swapped to element2)."""
import inspect
import pytest
pytestmark = pytest.mark.wall
def _split_source():
from bonsai.bim.module.model.wall import DumbWallJoiner
return inspect.getsource(DumbWallJoiner.split)
def test_side_classification_uses_opening_midpoint_not_filling_origin():
"""Side classification must read the opening's axis-projected
midpoint, not the filling's world translation — the latter shifts
under flipping and would mis-classify a flipped door centred over
the cut."""
source = _split_source()
assert "opening_midpoint" in source
assert "filling_obj.matrix_world.translation" not in source
def test_void_copy_reads_from_original_opening_before_remove():
"""When the filling moves to element2 and the void straddles the
cut, element1's pure-void copy must come from the original opening
BEFORE the cleanup that destroys it the rebound ``new_opening``
references element2's frame and would shift the void to element1's
origin in element2's local coords."""
source = _split_source()
branch_start = source.index("if opening_midpoint > cut_percentage:")
branch = source[branch_start:]
add_idx = branch.index("_add_void_copy(element1, opening)")
remove_idx = branch.index("feature.remove_feature(tool.Ifc.get(), feature=opening)")
assert add_idx < remove_idx
+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",
)
@@ -0,0 +1,123 @@
# 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.
"""Forward-compat AST contract: ``core.connection.disconnect_rel`` must have a
branch for every rel ``kind`` emitted by ``tool.connection.Connection`` lookups.
Adding a new rel kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to
``find_rels`` / ``find_rels_for_element`` without extending ``disconnect_rel``
would silently regress the disconnect operator and the cascade-on-delete: a new
kind would reach the dispatch, hit the ``raise ValueError("Unknown rel kind")``
fallback, and either crash the operator or leave the cascade half-done. This
guard makes the symmetry mandatory at test time."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.model
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
TOOL_CONNECTION = BONSAI_ROOT / "tool" / "connection.py"
CORE_CONNECTION = BONSAI_ROOT / "core" / "connection.py"
def _find_function(tree: ast.Module, name: str) -> ast.FunctionDef:
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == name:
return node
raise AssertionError(f"Function {name!r} not found")
def _find_method(tree: ast.Module, class_name: str, method_name: str) -> ast.FunctionDef:
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == class_name:
for child in node.body:
if isinstance(child, ast.FunctionDef) and child.name == method_name:
return child
raise AssertionError(f"Method {class_name}.{method_name} not found")
def _kinds_emitted_by(method: ast.FunctionDef) -> set[str]:
"""Extract every kind label this method emits.
Looks at exactly two narrow patterns to avoid false positives from
docstrings or type-annotation strings:
- ``_record(rel, "<kind>", )`` positional string at index 1, the
conventional emit shape in ``find_rels`` / ``find_rels_for_element``.
- ``kind = "<a>" if else "<b>"`` and chained variants string
literals on either branch of an ``ast.IfExp`` assigned to ``kind``.
"""
kinds: set[str] = set()
for node in ast.walk(method):
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id == "_record" and len(node.args) >= 2:
arg = node.args[1]
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
kinds.add(arg.value)
elif isinstance(arg, ast.IfExp):
for branch in (arg.body, arg.orelse):
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
kinds.add(branch.value)
elif isinstance(node, ast.Assign):
targets = [t for t in node.targets if isinstance(t, ast.Name) and t.id == "kind"]
if not targets or not isinstance(node.value, ast.IfExp):
continue
for branch in (node.value.body, node.value.orelse):
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
kinds.add(branch.value)
return kinds
def _kind_branches_in_disconnect_rel(tree: ast.Module) -> set[str]:
"""Return every kind matched by ``disconnect_rel``'s ``kind == ""`` branches."""
fn = _find_function(tree, "disconnect_rel")
kinds: set[str] = set()
for node in ast.walk(fn):
if isinstance(node, ast.Compare) and len(node.ops) == 1 and isinstance(node.ops[0], ast.Eq):
left = node.left
right = node.comparators[0]
if isinstance(left, ast.Name) and left.id == "kind":
if isinstance(right, ast.Constant) and isinstance(right.value, str):
kinds.add(right.value)
return kinds
def test_disconnect_rel_handles_every_kind_emitted_by_connection_lookups() -> None:
tool_tree = ast.parse(TOOL_CONNECTION.read_text(encoding="utf-8"))
core_tree = ast.parse(CORE_CONNECTION.read_text(encoding="utf-8"))
emitted = _kinds_emitted_by(_find_method(tool_tree, "Connection", "find_rels")) | _kinds_emitted_by(
_find_method(tool_tree, "Connection", "find_rels_for_element")
)
handled = _kind_branches_in_disconnect_rel(core_tree)
assert emitted, "Sanity check: no kinds extracted — emit pattern may have changed"
missing = emitted - handled
assert not missing, (
f"core.connection.disconnect_rel is missing branches for kinds {missing}. "
f"Every kind returned by Connection.find_rels / find_rels_for_element "
f"must have a matching if/elif branch in the dispatch."
)
+31
View File
@@ -135,6 +135,37 @@ class TestGetRepresentationData(NewFile):
assert subject.get_representation_data(representation) == data
class TestGetActiveRepresentation(NewFile):
def test_returns_representation_for_live_id(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
representation = ifc.createIfcShapeRepresentation()
mesh = bpy.data.meshes.new("Mesh")
obj = bpy.data.objects.new("Object", mesh)
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = representation.id()
assert subject.get_active_representation(obj) == representation
def test_returns_none_when_mesh_has_no_id(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh"))
assert subject.get_active_representation(obj) is None
def test_returns_none_when_id_is_stale(self):
"""A representation rebuild can free the old entity while obj.data
still tracks its id. Returning ``None`` keeps every UI redraw alive
instead of spamming ``RuntimeError`` from the by_id lookup."""
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
representation = ifc.createIfcShapeRepresentation()
mesh = bpy.data.meshes.new("Mesh")
obj = bpy.data.objects.new("Object", mesh)
stale_id = representation.id()
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = stale_id
ifc.remove(representation)
assert subject.get_active_representation(obj) is None
class TestGetRepresentationId(NewFile):
def test_run(self):
ifc = ifcopenshell.file()