diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py index 44728c2902..df75612d43 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py @@ -26,6 +26,7 @@ geometry extrusions). 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 .add_door_representation import add_door_representation from .add_footprint_representation import add_footprint_representation from .add_mesh_representation import add_mesh_representation @@ -61,6 +62,7 @@ wrap_usecases(__path__, __name__) __all__ = [ "add_axis_representation", "add_boolean", + "clip_solid", "add_door_representation", "add_footprint_representation", "add_mesh_representation", diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py new file mode 100644 index 0000000000..283727737e --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py @@ -0,0 +1,62 @@ +# 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 ifcopenshell.util.unit +from ifcopenshell.util.data import Clipping + + +def clip_solid( + file: ifcopenshell.file, + item: ifcopenshell.entity_instance, + location: Sequence[float], + normal: Sequence[float], +) -> ifcopenshell.entity_instance: + """Clip a solid with a half-space plane, returning an IfcBooleanClippingResult. + + Convenience wrapper around :class:`ifcopenshell.util.data.Clipping` for + use with any solid. The ``normal`` points toward the **removed** material + (the void side); the kept region is on the opposite side. This is the same + convention used by the ``clippings`` parameter of :func:`add_wall_representation`. + + After clipping, set the parent ``IfcShapeRepresentation`` + ``RepresentationType`` to ``"Clipping"``. + + Example — trim an extruded solid to a lean-to slope (removed material is + above the slope):: + + bcr = ifcopenshell.api.run( + "geometry.clip_solid", model, + item=extrusion, + location=[0.0, 0.0, 3.26], + normal=[0.419, 0.0, 0.908], # points UP toward removed material + ) + + :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. + :return: The resulting ``IfcBooleanClippingResult``. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + clipping = Clipping(location=tuple(location), normal=tuple(normal)) + return clipping.apply(file, item, unit_scale) diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 524f9fed98..e8a32111af 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1038,7 +1038,7 @@ class ShapeBuilder: ) -> ifcopenshell.entity_instance: """ :param plane: The IfcPlane representing the half space. - :param agreement_flag: False if +Z represents the void + :param agreement_flag: If False (default), the plane normal points toward the **removed** material (the void). The kept region is on the opposite side from the normal. :return: IfcHalfSpaceSolid """ return self.file.createIfcHalfSpaceSolid(plane, AgreementFlag=agreement_flag) diff --git a/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py b/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py new file mode 100644 index 0000000000..93a161ac4c --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py @@ -0,0 +1,104 @@ +# 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 TestClipSolid(test.bootstrap.IFC4): + def make_extrusion(self): + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + rect = builder.rectangle(size=(1.0, 1.0)) + return builder.extrude(rect, magnitude=4.0) + + def test_returns_boolean_clipping_result(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.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( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert result.FirstOperand == extrusion + + def test_second_operand_is_half_space_solid(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert result.SecondOperand.is_a("IfcHalfSpaceSolid") + + def test_clip_plane_location_matches(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + plane = result.SecondOperand.BaseSurface + coords = plane.Position.Location.Coordinates + assert list(coords) == [0.0, 0.0, 3.0] + + def test_chaining_two_clips(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], + ) + second_clip = ifcopenshell.api.geometry.clip_solid( + self.file, + item=first_clip, + location=[0.0, 0.0, 1.0], + normal=[0.0, 0.0, -1.0], + ) + assert second_clip.is_a("IfcBooleanClippingResult") + assert second_clip.FirstOperand == first_clip + assert first_clip.FirstOperand == extrusion + + def test_angled_clip_plane(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.26], + normal=[0.419, 0.0, 0.908], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.SecondOperand.is_a("IfcHalfSpaceSolid") + + +class TestClipSolidIFC2X3(test.bootstrap.IFC2X3, TestClipSolid): + pass