diff --git a/src/bonsai/bonsai/tool/wall.py b/src/bonsai/bonsai/tool/wall.py index c982b15371..6f471e0c8a 100644 --- a/src/bonsai/bonsai/tool/wall.py +++ b/src/bonsai/bonsai/tool/wall.py @@ -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, diff --git a/src/bonsai/test/bim/module/model/test_wall_slab_connections.py b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py new file mode 100644 index 0000000000..5bd9704e3c --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py @@ -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 . +# +# 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