From c23dfcf82e328fbb5a59d850cdf337de04534ffb Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Wed, 18 Mar 2026 23:01:19 +0000 Subject: [PATCH] Add geometry.clip_solid_bounded API Generated with the assistance of an AI coding tool. --- .../ifcopenshell/api/geometry/__init__.py | 2 + .../api/geometry/clip_solid_bounded.py | 99 ++++++++++++ .../api/geometry/test_clip_solid_bounded.py | 149 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py create mode 100644 src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py index df75612d43..5ce20c3803 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py @@ -27,6 +27,7 @@ from .. import wrap_usecases from .add_axis_representation import add_axis_representation from .add_boolean import add_boolean from .clip_solid import clip_solid +from .clip_solid_bounded import clip_solid_bounded from .add_door_representation import add_door_representation from .add_footprint_representation import add_footprint_representation from .add_mesh_representation import add_mesh_representation @@ -63,6 +64,7 @@ __all__ = [ "add_axis_representation", "add_boolean", "clip_solid", + "clip_solid_bounded", "add_door_representation", "add_footprint_representation", "add_mesh_representation", diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py new file mode 100644 index 0000000000..1df242eb28 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py @@ -0,0 +1,99 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +from __future__ import annotations + +from typing import Sequence + +import numpy as np + +import ifcopenshell.util.unit +from ifcopenshell.util.shape_builder import ShapeBuilder + + +def clip_solid_bounded( + file: ifcopenshell.file, + item: ifcopenshell.entity_instance, + location: Sequence[float], + normal: Sequence[float], + boundary_points: Sequence[Sequence[float]], + boundary_position: Sequence[float] = (0.0, 0.0, 0.0), +) -> ifcopenshell.entity_instance: + """Clip a solid with a polygonally bounded half-space, returning an IfcBooleanClippingResult. + + Like :func:`clip_solid`, but the boolean subtraction is restricted to the + region enclosed by ``boundary_points`` rather than extending across the + entire half-space. The clipping plane is still infinite, but material is + only removed within the extruded footprint of the polygon. + + The ``normal`` convention is the same as :func:`clip_solid`: it points + toward the **removed** material. + + After clipping, set the parent ``IfcShapeRepresentation`` + ``RepresentationType`` to ``"Clipping"``. + + Example:: + + bcr = ifcopenshell.api.run( + "geometry.clip_solid_bounded", model, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + + :param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``, + or ``IfcBooleanClippingResult``). + :param location: A point on the clipping plane in the representation's + local coordinate system. + :param normal: Plane normal pointing toward the material to be removed. + :param boundary_points: 2D ``[x, y]`` points defining the closed polygonal + boundary in the coordinate system of ``boundary_position``. The polygon + is automatically closed — do not repeat the first point. + :param boundary_position: 3D origin of the boundary coordinate system + (axes default to the global X/Y/Z directions). Defaults to the origin. + :return: The resulting ``IfcBooleanClippingResult``. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + builder = ShapeBuilder(file) + + normal_arr = np.array(normal) + if np.allclose(normal_arr, [0.0, 0.0, 1.0], atol=1e-2) or np.allclose(normal_arr, [0.0, 0.0, -1.0], atol=1e-2): + arbitrary_vector = np.array([0.0, 1.0, 0.0]) + else: + arbitrary_vector = np.array([0.0, 0.0, 1.0]) + x_axis = np.cross(normal_arr, arbitrary_vector) + x_axis /= np.linalg.norm(x_axis) + + scaled_location = [i / unit_scale for i in location] + plane_placement = builder.create_axis2_placement_3d(scaled_location, normal, x_axis) + plane = file.create_entity("IfcPlane", plane_placement) + + scaled_boundary_position = [i / unit_scale for i in boundary_position] + boundary_pos_entity = file.create_entity( + "IfcAxis2Placement3D", + file.create_entity("IfcCartesianPoint", scaled_boundary_position), + ) + + scaled_pts = [[p[0] / unit_scale, p[1] / unit_scale] for p in boundary_points] + scaled_pts.append(scaled_pts[0]) # close the polygon + ifc_pts = [file.create_entity("IfcCartesianPoint", p) for p in scaled_pts] + boundary = file.createIfcPolyline(ifc_pts) + + half_space = file.create_entity("IfcPolygonalBoundedHalfSpace", plane, False, boundary_pos_entity, boundary) + return file.create_entity("IfcBooleanClippingResult", "DIFFERENCE", item, half_space) diff --git a/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py b/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py new file mode 100644 index 0000000000..0ec448e5f5 --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py @@ -0,0 +1,149 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.geometry +import ifcopenshell.util.shape_builder +import test.bootstrap + + +class TestClipSolidBounded(test.bootstrap.IFC4): + def make_extrusion(self): + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + rect = builder.rectangle(size=(4.0, 1.0)) + return builder.extrude(rect, magnitude=3.0) + + def test_returns_boolean_clipping_result(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.Operator == "DIFFERENCE" + + def test_first_operand_is_the_item(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.FirstOperand == extrusion + + def test_second_operand_is_polygonal_bounded_half_space(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.SecondOperand.is_a("IfcPolygonalBoundedHalfSpace") + + def test_agreement_flag_is_false(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.SecondOperand.AgreementFlag is False + + def test_clip_plane_location_matches(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + plane = result.SecondOperand.BaseSurface + coords = plane.Position.Location.Coordinates + assert list(coords) == [2.5, 0.0, 2.0] + + def test_boundary_is_closed_polyline(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + boundary = result.SecondOperand.PolygonalBoundary + assert boundary.is_a("IfcPolyline") + pts = [list(p.Coordinates) for p in boundary.Points] + assert pts[0] == pts[-1], "polygon should be closed" + assert len(pts) == 5 # 4 unique + closing repeat + + def test_boundary_position_defaults_to_origin(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + pos = result.SecondOperand.Position + assert list(pos.Location.Coordinates) == [0.0, 0.0, 0.0] + + def test_custom_boundary_position(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + boundary_position=[1.0, 2.0, 3.0], + ) + pos = result.SecondOperand.Position + assert list(pos.Location.Coordinates) == [1.0, 2.0, 3.0] + + def test_chaining_with_clip_solid(self): + extrusion = self.make_extrusion() + first_clip = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=first_clip, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.FirstOperand == first_clip + assert first_clip.FirstOperand == extrusion + + +class TestClipSolidBoundedIFC2X3(test.bootstrap.IFC2X3, TestClipSolidBounded): + pass