Add space volume strategy detection

Detect whether a space can be built as a clipped extrusion or needs
a B-rep fallback, based on wall face orientation and top/bottom
bounding planes.

Generated with the assistance of an AI coding tool.
This commit is contained in:
CyrilWaechter
2026-08-03 11:31:41 +02:00
parent 6ec8cce498
commit 1025eb91cc
2 changed files with 103 additions and 2 deletions
@@ -345,3 +345,60 @@ def get_vertical_bounding_planes(
planes.append((anchor, mean_normal))
return "EXTRUDE_CLIP", planes
def detect_space_volume_strategy(
ifc_file: ifcopenshell.file,
shapes: dict,
tree: ifcopenshell.geom.tree,
space_polygon: shapely.Polygon,
base_z: float,
bounding_walls: list[ifcopenshell.entity_instance],
start_z: Optional[float] = None,
) -> tuple[str, Optional[list], Optional[list]]:
"""Decide whether a space can be represented as a clipped extrusion.
A space is "EXTRUDE_CLIP" when all bounding walls have vertical side faces
and the detected top/bottom bounding planes are few and piecewise-planar
(0-2 top planes, 0-1 bottom plane). Otherwise it is "BREP".
:param ifc_file: The IFC file.
:param shapes: Cached element shapes.
:param tree: Geometry tree with bounding elements.
:param space_polygon: Space footprint in world XY.
:param base_z: Base elevation in SI.
:param bounding_walls: List of wall elements bounding the space.
:param start_z: Ray-cast origin elevation (RL cut level) in SI.
:return: ("EXTRUDE_CLIP", top_planes, bottom_planes) or ("BREP", None, None).
"""
tol = 0.02
for wall in bounding_walls:
shape_data = shapes.get(wall.id())
if not shape_data:
continue
verts = shape_data["verts"]
faces = shape_data["faces"]
if len(verts) == 0 or len(faces) == 0:
continue
v1 = verts[faces[:, 1]] - verts[faces[:, 0]]
v2 = verts[faces[:, 2]] - verts[faces[:, 0]]
normals = np.cross(v1, v2)
norms = np.linalg.norm(normals, axis=1)
normals = normals[norms > 1e-8]
if len(normals) == 0:
continue
normals = normals / np.linalg.norm(normals, axis=1)[:, np.newaxis]
side_mask = np.abs(normals[:, 2]) < 0.5
if np.any(side_mask) and np.mean(np.abs(normals[side_mask, 2])) > tol:
return "BREP", None, None
_, top_planes = get_vertical_bounding_planes(ifc_file, shapes, tree, space_polygon, base_z, "UP", start_z=start_z)
_, bottom_planes = get_vertical_bounding_planes(
ifc_file, shapes, tree, space_polygon, base_z, "DOWN", start_z=start_z
)
if len(top_planes) > 2 or len(bottom_planes) > 1:
return "BREP", None, None
return "EXTRUDE_CLIP", top_planes, bottom_planes
@@ -48,7 +48,7 @@ def _build_shapes_dict(ifc_file, elements):
return shapes
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0, direction=(0.0, 0.0, 1.0)):
"""Add a body representation (extruded polyline) to an element."""
if not ifc_file.by_type("IfcProject"):
ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject")
@@ -76,7 +76,7 @@ def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
)
direction = ifc_file.createIfcDirection((0.0, 0.0, 1.0))
direction = ifc_file.createIfcDirection(direction)
solid = ifc_file.createIfcExtrudedAreaSolid(profile, placement, direction, depth)
rep = ifc_file.create_entity(
"IfcShapeRepresentation",
@@ -221,3 +221,47 @@ class TestGetVerticalBoundingPlanes(test.bootstrap.IFC4):
assert strategy == "EXTRUDE_CLIP"
assert len(planes) == 1
assert np.allclose(planes[0][0], [0.0, 0.0, 3.0], atol=0.1)
class TestDetectSpaceVolumeStrategy(test.bootstrap.IFC4):
def test_vertical_walls_flat_slab_returns_extrude_clip(self):
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
shapes = _build_shapes_dict(self.file, [wall, slab])
tree = ifcopenshell.geom.tree(self.file)
tree.add_file(self.file, ifcopenshell.geom.settings())
strategy, top, bottom = subject.detect_space_volume_strategy(
self.file, shapes, tree, shapely.box(-5, -5, 5, 5), 0.0, [wall]
)
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
def test_vertical_walls_flat_slab_returns_extrude_clip_from_rl_origin(self):
# Same as above but rays cast from an RL cut elevation (z=1.0).
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
shapes = _build_shapes_dict(self.file, [wall, slab])
tree = ifcopenshell.geom.tree(self.file)
tree.add_file(self.file, ifcopenshell.geom.settings())
strategy, top, bottom = subject.detect_space_volume_strategy(
self.file, shapes, tree, shapely.box(-5, -5, 5, 5), 0.0, [wall], start_z=1.0
)
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
def test_sloped_wall_returns_brep(self):
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 6.0, direction=(0.0, 0.5, 1.0))
shapes = _build_shapes_dict(self.file, [wall])
tree = ifcopenshell.geom.tree(self.file)
tree.add_file(self.file, ifcopenshell.geom.settings())
strategy, _, _ = subject.detect_space_volume_strategy(
self.file, shapes, tree, shapely.box(-4, -4, 4, 4), 0.0, [wall]
)
assert strategy == "BREP"