mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user