Add tool.Wall slab-connection helpers + tests

Four classmethods enable the new wall-slab connection gizmo work:

- iter_wall_slab_connections(wall): yields (slab, rel) tuples for
  every IfcRelConnectsElements(TOP) on wall.ConnectedFrom — the rel
  kind extend_walls_to_underside creates.
- iter_slab_wall_connections(slab): mirror, walks slab.ConnectedTo
  so a slab-side gizmo can enumerate every wall clipped to its
  underside.
- find_wall_slab_rel(wall, slab): locates the specific rel between
  a wall + slab pair so a disconnect operator knows what to remove.
- wall_slab_connection_location_world(wall_obj, slab_obj): returns
  the world-space icon anchor — wall axis midpoint X/Y lifted to
  the slab's mesh-bbox underside Z. Approximate (uses slab bbox vs
  reconstructing the slab's clip plane) but adequate for icon
  placement on a wall whose top meets the slab; returns None when
  the wall has no IFC Axis representation.

Tests (11) pin the rel-shape contract (class + Description=="TOP",
non-TOP and non-IfcRelConnectsElements rels skipped, None relating
defensively skipped) plus the icon-anchor math (axis-mid lifted to
slab-bbox bottom; None for axisless walls).

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-12 08:02:50 +02:00
parent 92aa890add
commit f6590d8be2
2 changed files with 267 additions and 0 deletions
+56
View File
@@ -242,6 +242,62 @@ class Wall(bonsai.core.tool.Wall):
local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0))
return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2
@classmethod
def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance):
"""Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)``
connecting a slab to this wall — the rel kind ``extend_walls_to_underside``
creates. Walks ``wall.ConnectedFrom`` because the slab is the relating
side of the TOP rel."""
for rel in getattr(wall, "ConnectedFrom", []) or ():
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
continue
slab = rel.RelatingElement
if slab is None:
continue
yield slab, rel
@classmethod
def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance):
"""Yield ``(wall, rel)`` tuples for every wall clipped to this slab's
underside. Mirror of ``iter_wall_slab_connections`` from the slab side
— walks ``slab.ConnectedTo``."""
for rel in getattr(slab, "ConnectedTo", []) or ():
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
continue
wall = rel.RelatedElement
if wall is None:
continue
yield wall, rel
@classmethod
def find_wall_slab_rel(
cls, wall: ifcopenshell.entity_instance, slab: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance | None:
"""Return the single ``IfcRelConnectsElements(TOP)`` between ``wall``
and ``slab``, or ``None`` if none exists. Used by the disconnect
operator to find the specific rel to remove."""
for s, rel in cls.iter_wall_slab_connections(wall):
if s == slab:
return rel
return None
@classmethod
def wall_slab_connection_location_world(
cls, wall_obj: bpy.types.Object, slab_obj: bpy.types.Object
) -> Vector | None:
"""World-space point where a wall is clipped by a slab — the wall's
axis midpoint lifted to the slab's underside Z. Approximate: uses the
slab's mesh bbox bottom in world space rather than reconstructing the
slab's clip plane. Adequate for icon placement on a wall whose top
meets the slab; returns ``None`` when the wall has no reference line."""
ref = cls.get_world_reference_line(wall_obj)
if ref is None:
return None
axis_mid_world = (ref[0] + ref[1]) * 0.5
slab_bottom_local_z = min(c[2] for c in slab_obj.bound_box)
slab_bottom_world_z = (slab_obj.matrix_world @ Vector((0.0, 0.0, slab_bottom_local_z))).z
return Vector((axis_mid_world.x, axis_mid_world.y, slab_bottom_world_z))
@classmethod
def walk_connected_walls(
cls,
@@ -0,0 +1,211 @@
# 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_lifts_axis_mid_to_slab_underside():
"""The icon sits at the wall's axis midpoint X/Y lifted to the slab's
underside Z so it reads as a marker on the slab cut line."""
wall_obj = Mock()
slab_obj = Mock()
slab_obj.matrix_world = Matrix.Translation(Vector((0.0, 0.0, 3.0)))
slab_obj.bound_box = [
(-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.0, 0.2),
(1.0, -1.0, 0.2),
(-1.0, 1.0, 0.2),
(1.0, 1.0, 0.2),
]
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)
assert loc == Vector((2.0, 0.0, 3.0))
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