From 538bb492d0e25f9fe82106085931821f0921ef93 Mon Sep 17 00:00:00 2001 From: CyrilWaechter Date: Mon, 3 Aug 2026 20:55:16 +0200 Subject: [PATCH] Fix rotated space placement localization during regeneration set_space_representation_from_polygon was localising the footprint and clipping planes by subtracting only the object origin. For spaces with a rotated ObjectPlacement (e.g. Space 5710 in the test IFC) the footprint was not rotated into the space's local coordinate system, so the regenerated mesh was rotated by the placement angle and appeared at the wrong world location. Now the polygon and planes are transformed with the full inverse of the object's placement matrix, and the plane normals are also rotated. The local mesh is therefore aligned with the object's local axes and appears in the correct world position when the placement is applied. Added regression tests for Space 5710 (rotated placement) and Space 2363 (identity placement) using the real HouseWithGarage_AC22_IFC2X3.ifc fixture. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/spatial.py | 21 +++++++- src/bonsai/test/tool/test_spatial.py | 74 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 696c714392..a33c058ecb 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -1363,12 +1363,29 @@ class Spatial(bonsai.core.tool.Spatial): # Build the geometry in the space's local coordinate system so the IFC # representation is relative to the object's ObjectPlacement. - local_poly_si = shapely.affinity.translate(poly_si, -origin.x, -origin.y) + # Use the full inverse of the object's placement matrix so rotated spaces + # keep the correct footprint orientation. + matrix_inv = np.array(obj.matrix_world.inverted()) + # shapely.affine_transform expects [a, b, d, e, xoff, yoff] + # where x' = a*x + b*y + xoff, y' = d*x + e*y + yoff. + affine_params = [ + matrix_inv[0, 0], + matrix_inv[0, 1], + matrix_inv[1, 0], + matrix_inv[1, 1], + matrix_inv[0, 3], + matrix_inv[1, 3], + ] + local_poly_si = shapely.affinity.affine_transform(poly_si, affine_params) local_base_z = base_z - origin.z def localize_plane(plane): point, normal = plane - return (np.array(point) - np.array([origin.x, origin.y, origin.z]), normal) + local_point = matrix_inv @ np.array([*point, 1.0]) + rotation_inv = matrix_inv[:3, :3] + local_normal = rotation_inv @ np.array(normal) + local_normal = local_normal / np.linalg.norm(local_normal) + return (local_point[:3], local_normal) local_top_planes = [localize_plane(p) for p in (top_planes or [])] local_bottom_planes = [localize_plane(p) for p in (bottom_planes or [])] diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py index 50a3523f6f..986f5da041 100644 --- a/src/bonsai/test/tool/test_spatial.py +++ b/src/bonsai/test/tool/test_spatial.py @@ -16,6 +16,8 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from pathlib import Path + import bpy import ifcopenshell import ifcopenshell.api @@ -610,6 +612,78 @@ class TestSpaceVolumeStrategy(NewFile): assert len(bottom) == 0 +class TestRegenerateSpaceFromRealIfc2x3(NewFile): + def load_house_with_garage(self): + filepath = ( + Path(__file__).parents[3] + / "ifcopenshell-python" + / "test" + / "IfcRelSpaceBoundary_TestFiles" + / "IfcRelSpaceBoundary2ndLevel" + / "HouseWithGarage_AC22_IFC2X3.ifc" + ).resolve() + bpy.ops.bim.load_project(filepath=filepath.as_posix()) + ifc = tool.Ifc.get() + return ifc + + def _regenerate_space(self, ifc, space_id): + space = ifc.by_id(space_id) + obj = tool.Ifc.get_object(space) + assert obj + import numpy as np + + original_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices]) + original_bounds = ( + original_verts[:, 0].min(), + original_verts[:, 0].max(), + original_verts[:, 1].min(), + original_verts[:, 1].max(), + original_verts[:, 2].min(), + original_verts[:, 2].max(), + ) + + # Delete existing related IfcRelSpaceBoundary as in the manual repro. + for b in list(space.BoundedBy or []): + ifcopenshell.api.boundary.remove_boundary(ifc, b) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.update() + + # Patch Spatial helpers so generate_space uses the active IfcSpace. + original_get_selected_objects = tool.Spatial.get_selected_objects + original_get_active_obj = tool.Spatial.get_active_obj + try: + tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj]) + tool.Spatial.get_active_obj = classmethod(lambda cls: obj) + bpy.ops.bim.generate_space() + finally: + tool.Spatial.get_selected_objects = original_get_selected_objects + tool.Spatial.get_active_obj = original_get_active_obj + + regen_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices]) + return original_bounds, ( + regen_verts[:, 0].min(), + regen_verts[:, 0].max(), + regen_verts[:, 1].min(), + regen_verts[:, 1].max(), + regen_verts[:, 2].min(), + regen_verts[:, 2].max(), + ) + + def test_regenerate_space_5710_keeps_world_location(self): + ifc = self.load_house_with_garage() + original, regen = self._regenerate_space(ifc, 5710) + for o, r in zip(original, regen): + assert r == pytest.approx(o, abs=0.02) + + def test_regenerate_space_2363_keeps_world_location(self): + ifc = self.load_house_with_garage() + original, regen = self._regenerate_space(ifc, 2363) + for o, r in zip(original, regen): + assert r == pytest.approx(o, abs=0.02) + + class TestGenerateSpaceLocation(NewFile): def test_generate_space_at_non_zero_cursor_location(self): bpy.ops.bim.create_project()