From ca08d0e12dada3e062c5b7d0ec179b09a860bc6e Mon Sep 17 00:00:00 2001 From: CyrilWaechter Date: Mon, 3 Aug 2026 18:37:21 +0200 Subject: [PATCH] Fix space generation location and IFC2X3 B-rep Two fixes for space generation: 1. IFC2X3 schema: build_brep_space now falls back to plain IfcRelSpaceBoundary because IfcRelSpaceBoundary1stLevel does not exist in IFC2X3. 2. Geometry location: set_space_representation_from_polygon now aligns the IFC ObjectPlacement with the Blender object, converts base_z/planes and the footprint polygon to the object's local coordinate system before building, and fixes the base_z unit scale. The centred-cube regeneration test was updated to check world bounds because the mesh is now placed relative to the object placement. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/spatial.py | 46 +++++++++++++++++-- src/bonsai/test/tool/test_spatial.py | 31 ++++++++++--- .../ifcopenshell/util/space.py | 5 +- .../test/util/test_space.py | 22 +++++++++ 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index fc55f77d84..696c714392 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -42,6 +42,7 @@ import ifcopenshell.util.type import ifcopenshell.util.unit import numpy as np import shapely +import shapely.affinity import shapely.ops from mathutils import Matrix, Vector from natsort import natsorted @@ -1329,7 +1330,22 @@ class Spatial(bonsai.core.tool.Spatial): unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) ifc_file = tool.Ifc.get() x, y, z = obj.matrix_world.translation - base_z = z / unit_scale + origin = obj.matrix_world.translation # Blender SI + # The space builders expect base_z and polygon in SI (world) units. + base_z = z + poly_si = poly if polygon_is_si else shapely.affinity.scale(poly, unit_scale, unit_scale, origin=(0, 0)) + + # Ensure the IFC entity has an ObjectPlacement matching the Blender object, + # so the generated representation is in the correct local coordinate system. + bpy.context.view_layer.update() + matrix = np.array(obj.matrix_world) + ifcopenshell.api.geometry.edit_object_placement( + ifc_file, + product=element, + matrix=matrix, + is_si=True, + ) + if cls.get_spatial_props().force_space_height: cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si) return @@ -1341,15 +1357,37 @@ class Spatial(bonsai.core.tool.Spatial): for wall in ifc_file.by_type("IfcWall"): if wall in ifcopenshell.util.element.get_decomposition(container): bounding_walls.append(wall) - strategy, top_planes, bottom_planes = cls.get_space_volume_strategy(poly, base_z, bounding_walls, container) + + # Detect planes in world SI (same coordinate system as the geom cache). + strategy, top_planes, bottom_planes = cls.get_space_volume_strategy(poly_si, base_z, bounding_walls, container) + + # 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) + 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_top_planes = [localize_plane(p) for p in (top_planes or [])] + local_bottom_planes = [localize_plane(p) for p in (bottom_planes or [])] + if strategy == "EXTRUDE_CLIP" and top_planes: item = ifcopenshell.util.space.build_extruded_clipped_space( - ifc_file, poly, base_z, top_planes, bottom_planes or [] + ifc_file, local_poly_si, local_base_z, local_top_planes, local_bottom_planes ) cls.set_brep_representation_from_mesh(obj, element, item) else: + shapes = cls.get_or_build_geom_cache()["shapes"] + local_shapes = {} + for shape_id, shape_data in shapes.items(): + local_shape_data = dict(shape_data) + local_shape_data["top_z"] = shape_data["top_z"] - origin.z + local_shape_data["bottom_z"] = shape_data["bottom_z"] - origin.z + local_shapes[shape_id] = local_shape_data item = ifcopenshell.util.space.build_brep_space( - ifc_file, element, cls.get_or_build_geom_cache()["shapes"], poly, base_z + ifc_file, element, local_shapes, local_poly_si, local_base_z ) if item is None: cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si) diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py index a540e0fd8b..50a3523f6f 100644 --- a/src/bonsai/test/tool/test_spatial.py +++ b/src/bonsai/test/tool/test_spatial.py @@ -26,6 +26,7 @@ import ifcopenshell.api.root import ifcopenshell.api.spatial import ifcopenshell.util.representation import numpy as np +import pytest import shapely from mathutils import Matrix, Vector @@ -497,11 +498,10 @@ class TestGenerateSpace(NewFile): mesh = obj.data assert isinstance(mesh, bpy.types.Mesh) - verts = [v.co.z for v in mesh.vertices] - min_z = min(verts) - max_z = max(verts) - assert min_z >= 0, f"Expected extrusion to start at local z>=0, got min_z={min_z}" - assert max_z > 0, f"Expected extrusion to have positive height, got max_z={max_z}" + world_verts = [obj.matrix_world @ v.co for v in mesh.vertices] + world_zs = [v.z for v in world_verts] + assert min(world_zs) >= -0.1, f"Expected space world bottom near z>=0, got {min(world_zs)}" + assert max(world_zs) > 0, f"Expected space to have positive height, got {max(world_zs)}" assert np.isclose(obj.location.z, 4.5, atol=0.01), f"Expected location.z=4.5, got {obj.location.z}" @@ -609,4 +609,23 @@ class TestSpaceVolumeStrategy(NewFile): assert len(top) == 1 assert len(bottom) == 0 -class TestGenerateSpaceSlopedRoof(NewFile): + +class TestGenerateSpaceLocation(NewFile): + def test_generate_space_at_non_zero_cursor_location(self): + bpy.ops.bim.create_project() + ifc = tool.Ifc.get() + # 4 thin walls forming a hollow box around (10, 20). + _BlockHelper.create_thin_wall(ifc, 10.0, 20.0 + 4.8, 10.0, 0.4) + _BlockHelper.create_thin_wall(ifc, 10.0, 20.0 - 4.8, 10.0, 0.4) + _BlockHelper.create_thin_wall(ifc, 10.0 + 4.8, 20.0, 0.4, 10.0) + _BlockHelper.create_thin_wall(ifc, 10.0 - 4.8, 20.0, 0.4, 10.0) + bpy.context.scene.cursor.location = (10, 20, 0) + + bpy.ops.bim.generate_space() + space = bpy.data.objects["IfcSpace/Space"] + mesh = space.data + assert isinstance(mesh, bpy.types.Mesh) + world_verts = np.array([space.matrix_world @ v.co for v in mesh.vertices]) + center = (world_verts.min(axis=0) + world_verts.max(axis=0)) / 2 + assert center[0] == pytest.approx(10.0, abs=0.1) + assert center[1] == pytest.approx(20.0, abs=0.1) diff --git a/src/ifcopenshell-python/ifcopenshell/util/space.py b/src/ifcopenshell-python/ifcopenshell/util/space.py index 86c9c42eb4..d918628f10 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/space.py +++ b/src/ifcopenshell-python/ifcopenshell/util/space.py @@ -565,8 +565,11 @@ def build_brep_space( ifcopenshell.api.geometry.assign_representation(ifc_file, product=space, representation=seed_rep) local_shapes = _build_local_shapes(ifc_file) + boundary_class = "IfcRelSpaceBoundary1stLevel" + if ifc_file.schema == "IFC2X3": + boundary_class = "IfcRelSpaceBoundary" boundaries = ifcopenshell.util.boundary.auto_generate_boundaries( - ifc_file, space, local_shapes, boundary_class="IfcRelSpaceBoundary1stLevel" + ifc_file, space, local_shapes, boundary_class=boundary_class ) if isinstance(boundaries, str) or not boundaries: ifcopenshell.api.geometry.remove_representation(ifc_file, representation=seed_rep) diff --git a/src/ifcopenshell-python/test/util/test_space.py b/src/ifcopenshell-python/test/util/test_space.py index 84152e2d93..c41ca3883a 100644 --- a/src/ifcopenshell-python/test/util/test_space.py +++ b/src/ifcopenshell-python/test/util/test_space.py @@ -24,6 +24,7 @@ import ifcopenshell.util.shape import ifcopenshell.util.space as subject import math import numpy as np +import os import pytest import shapely import test.bootstrap @@ -320,3 +321,24 @@ class TestBuildBrepSpace(test.bootstrap.IFC4): item = subject.build_brep_space(self.file, space, shapes, shapely.box(-4, -4, 4, 4), 0.0) assert item is not None assert item.is_a("IfcFacetedBrep") or item.is_a("IfcPolygonalFaceSet") + + +class TestBuildBrepSpaceIfc2x3: + def test_build_brep_space_on_ifc2x3_uses_rel_space_boundary(self): + # IFC2X3 does not have IfcRelSpaceBoundary1stLevel; build_brep_space must + # fall back to plain IfcRelSpaceBoundary without raising a schema error. + # We use the real IFC2X3 fixture because the installed wrapper in this + # environment has a quirk with synthetic IFC2X3 geometry creation. + path = os.path.join( + os.path.dirname(__file__), + "..", + "IfcRelSpaceBoundary_TestFiles", + "IfcRelSpaceBoundary2ndLevel", + "HouseWithGarage_AC22_IFC2X3.ifc", + ) + ifc_file = ifcopenshell.open(path) + space = ifc_file.by_type("IfcSpace")[0] + shapes = _build_shapes_dict(ifc_file, ifc_file.by_type("IfcWall") + ifc_file.by_type("IfcSlab")) + item = subject.build_brep_space(ifc_file, space, shapes, shapely.box(-1, -1, 1, 1), 0.0) + assert item is not None + assert item.is_a("IfcFacetedBrep") or item.is_a("IfcPolygonalFaceSet")