mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-18 06:21:40 +00:00
Merge pull request #8173 from Gorgious56/bonsai/wall-slab-gizmos
Bonsai/wall slab gizmos
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user