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()