From 2d10c6fcb94b4583645d08915cffe6937d36b729 Mon Sep 17 00:00:00 2001 From: CyrilWaechter Date: Mon, 3 Aug 2026 12:56:01 +0200 Subject: [PATCH] Add B-rep fallback space builder Build a faceted B-rep space from auto-generated boundary faces when sloped or curved bounding elements make a clipped extrusion unsuitable. Generated with the assistance of an AI coding tool. --- .../ifcopenshell/util/space.py | 132 ++++++++++++++++++ .../test/util/test_space.py | 11 ++ 2 files changed, 143 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/util/space.py b/src/ifcopenshell-python/ifcopenshell/util/space.py index 599931022f..906aad08d0 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/space.py +++ b/src/ifcopenshell-python/ifcopenshell/util/space.py @@ -30,7 +30,10 @@ from typing import Literal, Optional, Union import ifcopenshell import ifcopenshell.api +import ifcopenshell.util.boundary import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.representation import ifcopenshell.util.shape import ifcopenshell.util.shape_builder import ifcopenshell.util.unit @@ -467,3 +470,132 @@ def build_extruded_clipped_space( ) return result + + +def _build_local_shapes(ifc_file: ifcopenshell.file) -> dict: + """Build the local-coordinates shapes dict required by auto_generate_boundaries. + + Keys are element ids; values have ``verts`` (local), ``faces``, ``edges`` + and ``matrix`` as produced by ``ifcopenshell.geom.iterator``. + """ + settings = ifcopenshell.geom.settings() + settings.set("disable-opening-subtractions", True) + shapes = {} + iterator = ifcopenshell.geom.iterator(settings, ifc_file) + if iterator.initialize(): + while True: + shape = iterator.get() + shapes[shape.id] = { + "verts": ifcopenshell.util.shape.get_vertices(shape.geometry), + "faces": ifcopenshell.util.shape.get_faces(shape.geometry), + "edges": ifcopenshell.util.shape.get_edges(shape.geometry), + "matrix": ifcopenshell.util.shape.get_shape_matrix(shape), + } + if not iterator.next(): + break + return shapes + + +def build_brep_space( + ifc_file: ifcopenshell.file, + space: ifcopenshell.entity_instance, + shapes: dict, + space_polygon: shapely.Polygon, + base_z: float, +) -> Union[ifcopenshell.entity_instance, None]: + """Build a closed-shell B-rep space from auto-generated boundary faces. + + Seeds the space with a temporary extrusion spanning the bounding elements' + vertical extent, generates 1st-level boundaries with + ``auto_generate_boundaries``, then merges the boundary polygons into a + closed mesh (``IfcFacetedBrep``/``IfcPolygonalFaceSet``). + + :param ifc_file: The IFC file. + :param space: The IfcSpace entity. + :param shapes: Cached element shapes (world coords) for the z-extent. + :param space_polygon: Footprint polygon in world XY. + :param base_z: Base elevation in SI. + :return: IfcFacetedBrep or IfcPolygonalFaceSet, or None if boundaries + cannot be resolved. + """ + all_z = [base_z] + for shape_data in shapes.values(): + all_z.append(shape_data["top_z"]) + all_z.append(shape_data["bottom_z"]) + min_z = min(all_z) + max_z = max(all_z) + height = max_z - min_z + + builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file) + centroid = np.array([space_polygon.centroid.x, space_polygon.centroid.y]) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + exterior = [ + (float(p[0] - centroid[0]) / unit_scale, float(p[1] - centroid[1]) / unit_scale) + for p in space_polygon.exterior.coords[:-1] + ] + outer_curve = builder.polyline(exterior, closed=True) + profile = builder.profile(outer_curve) + + ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + if ctx is None: + return None + + seed = builder.extrude( + profile, + magnitude=height / unit_scale, + position=[centroid[0] / unit_scale, centroid[1] / unit_scale, min_z / unit_scale], + ) + seed_rep = builder.get_representation(ctx, seed) + ifcopenshell.api.geometry.assign_representation(ifc_file, product=space, representation=seed_rep) + + local_shapes = _build_local_shapes(ifc_file) + boundaries = ifcopenshell.util.boundary.auto_generate_boundaries( + ifc_file, space, local_shapes, boundary_class="IfcRelSpaceBoundary1stLevel" + ) + if isinstance(boundaries, str) or not boundaries: + ifcopenshell.api.geometry.remove_representation(ifc_file, representation=seed_rep) + return None + + points = [] + point_index = {} + faces = [] + + def add_point(p): + key = tuple(np.round(p, 5)) + if key not in point_index: + point_index[key] = len(points) + points.append([float(c) for c in p]) + return point_index[key] + + for boundary in boundaries: + connection = boundary.ConnectionGeometry + if connection is None: + continue + surface = connection.SurfaceOnRelatingElement + if surface is None or not surface.is_a("IfcCurveBoundedPlane"): + continue + outer = surface.OuterBoundary + if outer is None or not outer.is_a("IfcPolyline"): + continue + matrix = ifcopenshell.util.placement.get_axis2placement(surface.BasisSurface.Position) + poly_points = [] + for loop_point in outer.Points: + local = np.array([float(c) for c in loop_point.Coordinates]) + if len(local) == 2: + local = np.array([*local, 0.0]) + world = np.delete(matrix @ np.array([*local, 1.0]), 3) + poly_points.append(world) + if len(poly_points) >= 3: + faces.append([add_point(p) for p in poly_points]) + + ifcopenshell.api.geometry.remove_representation(ifc_file, representation=seed_rep) + + if not faces: + return None + + tri_faces = [] + for face in faces: + for i in range(1, len(face) - 1): + tri_faces.append([face[0], face[i], face[i + 1]]) + + return builder.mesh(points, tri_faces) diff --git a/src/ifcopenshell-python/test/util/test_space.py b/src/ifcopenshell-python/test/util/test_space.py index 0be0ba608f..b757eafd2e 100644 --- a/src/ifcopenshell-python/test/util/test_space.py +++ b/src/ifcopenshell-python/test/util/test_space.py @@ -278,3 +278,14 @@ class TestBuildExtrudedClippedSpace(test.bootstrap.IFC4): # Chain: bottom clip -> top clip -> IfcExtrudedAreaSolid. assert item.FirstOperand.is_a("IfcBooleanClippingResult") assert item.FirstOperand.FirstOperand.is_a("IfcExtrudedAreaSolid") + + +class TestBuildBrepSpace(test.bootstrap.IFC4): + def test_sloped_wall_brep_has_faces(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)) + space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace") + shapes = _build_shapes_dict(self.file, [wall]) + 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")