From 94214e78927d883facb12b7abe2147afc39eebaf Mon Sep 17 00:00:00 2001 From: CyrilWaechter Date: Mon, 3 Aug 2026 11:54:26 +0200 Subject: [PATCH] Add clipped extrusion space builder Build IfcExtrudedAreaSolid clipped by top/bottom half-space planes. Generated with the assistance of an AI coding tool. --- .../ifcopenshell/util/space.py | 65 +++++++++++++++++++ .../test/util/test_space.py | 13 ++++ 2 files changed, 78 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/util/space.py b/src/ifcopenshell-python/ifcopenshell/util/space.py index 3de5ad1dba..599931022f 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/space.py +++ b/src/ifcopenshell-python/ifcopenshell/util/space.py @@ -29,8 +29,11 @@ from __future__ import annotations from typing import Literal, Optional, Union import ifcopenshell +import ifcopenshell.api import ifcopenshell.util.element import ifcopenshell.util.shape +import ifcopenshell.util.shape_builder +import ifcopenshell.util.unit import numpy as np import shapely @@ -402,3 +405,65 @@ def detect_space_volume_strategy( return "BREP", None, None return "EXTRUDE_CLIP", top_planes, bottom_planes + + +def build_extruded_clipped_space( + ifc_file: ifcopenshell.file, + space_polygon: shapely.Polygon, + base_z: float, + top_planes: list[tuple[np.ndarray, np.ndarray]], + bottom_planes: list[tuple[np.ndarray, np.ndarray]], +) -> ifcopenshell.entity_instance: + """Build an IfcExtrudedAreaSolid clipped to top/bottom planes. + + :param ifc_file: The IFC file. + :param space_polygon: Footprint polygon in world XY. + :param base_z: Base elevation in SI. + :param top_planes: List of (point, normal) tuples for top clipping planes. + :param bottom_planes: List of (point, normal) tuples for bottom clipping planes. + :return: IfcBooleanClippingResult chain. + """ + builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + + centroid = np.array([space_polygon.centroid.x, space_polygon.centroid.y]) + exterior = [ + (float(p[0] - centroid[0]) / unit_scale, float(p[1] - centroid[1]) / unit_scale) + for p in space_polygon.exterior.coords[:-1] + ] + inner_curves = [] + for interior in space_polygon.interiors: + inner = [ + (float(p[0] - centroid[0]) / unit_scale, float(p[1] - centroid[1]) / unit_scale) + for p in interior.coords[:-1] + ] + inner_curve = builder.polyline(inner, closed=True) + inner_curves.append(inner_curve) + + outer_curve = builder.polyline(exterior, closed=True) + profile = builder.profile(outer_curve, inner_curves=inner_curves) + + all_z = [base_z] + for point, _ in top_planes + bottom_planes: + all_z.append(float(point[2])) + min_z = min(all_z) + max_z = max(all_z) + height = max_z - min_z + + extrusion = builder.extrude( + profile, + magnitude=height / unit_scale, + position=[(centroid[0] / unit_scale), (centroid[1] / unit_scale), min_z / unit_scale], + ) + + result = extrusion + for point, normal in top_planes + bottom_planes: + # clip_solid takes location in SI; it converts to project units internally. + result = ifcopenshell.api.geometry.clip_solid( + ifc_file, + item=result, + location=[float(point[i]) for i in range(3)], + normal=[float(normal[i]) for i in range(3)], + ) + + return result diff --git a/src/ifcopenshell-python/test/util/test_space.py b/src/ifcopenshell-python/test/util/test_space.py index 52acc61a23..0be0ba608f 100644 --- a/src/ifcopenshell-python/test/util/test_space.py +++ b/src/ifcopenshell-python/test/util/test_space.py @@ -265,3 +265,16 @@ class TestDetectSpaceVolumeStrategy(test.bootstrap.IFC4): self.file, shapes, tree, shapely.box(-4, -4, 4, 4), 0.0, [wall] ) assert strategy == "BREP" + + +class TestBuildExtrudedClippedSpace(test.bootstrap.IFC4): + def test_shed_roof_clips_to_sloped_plane(self): + space_polygon = shapely.box(-5, -5, 5, 5) + top_plane = (np.array([0.0, 0.0, 4.0]), np.array([0.0, 0.0, 1.0])) + bottom_plane = (np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, -1.0])) + item = subject.build_extruded_clipped_space(self.file, space_polygon, 0.0, [top_plane], [bottom_plane]) + assert item.is_a("IfcBooleanClippingResult") + assert item.SecondOperand.is_a("IfcHalfSpaceSolid") + # Chain: bottom clip -> top clip -> IfcExtrudedAreaSolid. + assert item.FirstOperand.is_a("IfcBooleanClippingResult") + assert item.FirstOperand.FirstOperand.is_a("IfcExtrudedAreaSolid")