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.
This commit is contained in:
CyrilWaechter
2026-08-03 18:37:21 +02:00
parent a7da317c30
commit ca08d0e12d
4 changed files with 93 additions and 11 deletions
+42 -4
View File
@@ -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)
+25 -6
View File
@@ -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)
@@ -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)
@@ -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")