From 1025eb91cc1fb41edd3a5b9e0be0e309dfece4a6 Mon Sep 17 00:00:00 2001 From: CyrilWaechter Date: Mon, 3 Aug 2026 11:31:41 +0200 Subject: [PATCH] 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. --- .../ifcopenshell/util/space.py | 57 +++++++++++++++++++ .../test/util/test_space.py | 48 +++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/space.py b/src/ifcopenshell-python/ifcopenshell/util/space.py index 7253ecde32..3de5ad1dba 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/space.py +++ b/src/ifcopenshell-python/ifcopenshell/util/space.py @@ -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 diff --git a/src/ifcopenshell-python/test/util/test_space.py b/src/ifcopenshell-python/test/util/test_space.py index 03a5331571..52acc61a23 100644 --- a/src/ifcopenshell-python/test/util/test_space.py +++ b/src/ifcopenshell-python/test/util/test_space.py @@ -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"