From 23ba9e4db0c2b0f8b7cc0e31f033a5252e217659 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Mon, 23 Mar 2026 23:00:12 +0000 Subject: [PATCH] Add geometry.clip_solid, clip_solid_bounded, and copy_representation APIs (#7843) * Add geometry.clip_solid API * Add geometry.clip_solid_bounded API * Add geometry.copy_representation API Deep-copies the named representation from a source element to a target element. Generated with the assistance of an AI coding tool. --- .../ifcopenshell/api/geometry/__init__.py | 6 + .../ifcopenshell/api/geometry/clip_solid.py | 86 +++++++++ .../api/geometry/clip_solid_bounded.py | 116 ++++++++++++ .../api/geometry/copy_representation.py | 76 ++++++++ .../test/api/geometry/test_clip_solid.py | 154 +++++++++++++++ .../api/geometry/test_clip_solid_bounded.py | 179 ++++++++++++++++++ .../api/geometry/test_copy_representation.py | 134 +++++++++++++ 7 files changed, 751 insertions(+) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py create mode 100644 src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py create mode 100644 src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py create mode 100644 src/ifcopenshell-python/test/api/geometry/test_clip_solid.py create mode 100644 src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py create mode 100644 src/ifcopenshell-python/test/api/geometry/test_copy_representation.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py index 44728c2902..a8a5ca609b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py @@ -26,6 +26,8 @@ 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 .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 @@ -50,6 +52,7 @@ from .disconnect_element import disconnect_element from .disconnect_path import disconnect_path from .edit_object_placement import edit_object_placement from .map_representation import map_representation +from .copy_representation import copy_representation from .regenerate_wall_representation import regenerate_wall_representation from .remove_boolean import remove_boolean from .remove_representation import remove_representation @@ -61,6 +64,9 @@ wrap_usecases(__path__, __name__) __all__ = [ "add_axis_representation", "add_boolean", + "clip_solid", + "clip_solid_bounded", + "copy_representation", "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..a83fd4385f --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py @@ -0,0 +1,86 @@ +# 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 + +import json +from typing import Optional, Sequence + +import ifcopenshell.api.pset +import ifcopenshell.util.element +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], + element: Optional[ifcopenshell.entity_instance] = None, +) -> 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. This is the same convention used by the ``clippings`` + parameter of :func:`add_wall_representation`. + + .. warning:: + + The ``normal`` points toward the **removed** material (the discarded + side), not toward the kept material. For a slope clip the normal + points upward into the removed wedge above the slope line. For a + side mitre the normal points outward away from the wall body. + + 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 + (see warning above). + :param element: If provided, the resulting ``IfcBooleanClippingResult`` is + registered in the element's ``BBIM_Boolean`` property set so that + :func:`regenerate_wall_representation` preserves it during regeneration. + :return: The resulting ``IfcBooleanClippingResult``. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + clipping = Clipping(location=tuple(location), normal=tuple(normal)) + result = clipping.apply(file, item, unit_scale) + if element is not None: + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean") + if pset_data: + pset = file.by_id(pset_data["id"]) + data = list(set(json.loads(pset_data["Data"]) + [result.id()])) + else: + pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean") + data = [result.id()] + ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)}) + return result 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..ac2e39c741 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py @@ -0,0 +1,116 @@ +# 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 + +import json +from typing import Optional, Sequence + +import numpy as np + +import ifcopenshell.api.pset +import ifcopenshell.util.element +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), + element: Optional[ifcopenshell.entity_instance] = None, +) -> 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. + :param element: If provided, the resulting ``IfcBooleanClippingResult`` is + registered in the element's ``BBIM_Boolean`` property set so that + :func:`regenerate_wall_representation` preserves it during regeneration. + :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) + result = file.create_entity("IfcBooleanClippingResult", "DIFFERENCE", item, half_space) + if element is not None: + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean") + if pset_data: + pset = file.by_id(pset_data["id"]) + data = list(set(json.loads(pset_data["Data"]) + [result.id()])) + else: + pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean") + data = [result.id()] + ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)}) + return result diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py new file mode 100644 index 0000000000..c2b4c485a9 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py @@ -0,0 +1,76 @@ +# 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 Optional + +import ifcopenshell.api.geometry +import ifcopenshell.util.element +import ifcopenshell.util.representation + + +def copy_representation( + file: ifcopenshell.file, + source: ifcopenshell.entity_instance, + target: ifcopenshell.entity_instance, + context_identifier: str = "Body", +) -> Optional[ifcopenshell.entity_instance]: + """Copy a geometric representation from one element to another. + + Finds the named representation on ``source``, deep-copies its entity + graph (geometry items, profiles, placements, etc.), and assigns the copy + to ``target``. Representation contexts are shared rather than copied. + If ``target`` already has a matching representation it is removed and + replaced. + + If no matching representation is found on ``source``, returns ``None`` + and leaves ``target`` unchanged. + + :param source: The element to copy the representation from. + :param target: The element to assign the copied representation to. + :param context_identifier: The RepresentationIdentifier to look up on + ``source`` (e.g. ``"Body"``, ``"Axis"``, ``"Box"``). + Defaults to ``"Body"``. + :return: The newly created IfcShapeRepresentation, or None if no + matching representation was found on ``source``. + + Example: + + .. code:: python + + wall_a = model.by_id(1) + wall_b = model.by_id(2) + + # Give wall_b the same body geometry as wall_a. + ifcopenshell.api.geometry.copy_representation(model, + source=wall_a, target=wall_b) + """ + source_rep = ifcopenshell.util.representation.get_representation(source, "Model", context_identifier) + if source_rep is None: + return None + + new_rep = ifcopenshell.util.element.copy_deep(file, source_rep, exclude=["IfcGeometricRepresentationContext"]) + + existing_rep = ifcopenshell.util.representation.get_representation(target, "Model", context_identifier) + if existing_rep: + ifcopenshell.api.geometry.unassign_representation(file, product=target, representation=existing_rep) + ifcopenshell.api.geometry.remove_representation(file, representation=existing_rep) + + ifcopenshell.api.geometry.assign_representation(file, product=target, representation=new_rep) + return new_rep 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..512bfcc92e --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py @@ -0,0 +1,154 @@ +# 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 json + +import ifcopenshell.api.geometry +import ifcopenshell.util.element +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") + + def test_element_registers_result_in_bbim_boolean(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + element=wall, + ) + pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + assert pset is not None + assert result.id() in json.loads(pset["Data"]) + + def test_element_appends_to_existing_bbim_boolean(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + first = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + element=wall, + ) + second = ifcopenshell.api.geometry.clip_solid( + self.file, + item=first, + location=[0.0, 0.0, 1.0], + normal=[0.0, 0.0, -1.0], + element=wall, + ) + pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + ids = json.loads(pset["Data"]) + assert first.id() in ids + assert second.id() in ids + + def test_no_element_does_not_create_pset(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None + + +class TestClipSolidIFC2X3(test.bootstrap.IFC2X3, TestClipSolid): + pass 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..3dae335cad --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py @@ -0,0 +1,179 @@ +# 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 json + +import ifcopenshell.api.geometry +import ifcopenshell.util.element +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 + + def test_element_registers_result_in_bbim_boolean(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + 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]], + element=wall, + ) + pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + assert pset is not None + assert result.id() in json.loads(pset["Data"]) + + def test_no_element_does_not_create_pset(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + 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 ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None + + +class TestClipSolidBoundedIFC2X3(test.bootstrap.IFC2X3, TestClipSolidBounded): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py new file mode 100644 index 0000000000..8faf07baa9 --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py @@ -0,0 +1,134 @@ +# 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.api.root +import ifcopenshell.util.representation +import ifcopenshell.util.shape_builder +import test.bootstrap + + +class TestCopyRepresentation(test.bootstrap.IFC4): + def _body_context(self): + body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") + if body is None: + model = self.file.createIfcGeometricRepresentationContext( + ContextType="Model", + CoordinateSpaceDimension=3, + Precision=1e-5, + WorldCoordinateSystem=self.file.createIfcAxis2Placement3D( + self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)) + ), + ) + body = self.file.createIfcGeometricRepresentationSubContext( + ContextIdentifier="Body", + ContextType="Model", + TargetView="MODEL_VIEW", + ParentContext=model, + ) + return body + + def _add_body_rep(self, element): + body = self._body_context() + rep = ifcopenshell.api.geometry.add_wall_representation( + self.file, context=body, length=5.0, height=3.0, thickness=0.2 + ) + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) + return rep + + def test_copy_to_empty_target(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + self._add_body_rep(wall_a) + + result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert result is not None + assert result.is_a("IfcShapeRepresentation") + target_rep = ifcopenshell.util.representation.get_representation(wall_b, "Model", "Body") + assert target_rep is not None + assert target_rep == result + + def test_source_rep_entities_are_distinct(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + source_rep = self._add_body_rep(wall_a) + + new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert new_rep.id() != source_rep.id() + assert new_rep.Items[0].id() != source_rep.Items[0].id() + + def test_context_is_shared_not_copied(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + source_rep = self._add_body_rep(wall_a) + + new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert new_rep.ContextOfItems.id() == source_rep.ContextOfItems.id() + + def test_replaces_existing_target_rep(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + self._add_body_rep(wall_a) + old_rep = self._add_body_rep(wall_b) + old_rep_id = old_rep.id() + + ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + try: + self.file.by_id(old_rep_id) + assert False, "old representation still exists" + except RuntimeError: + pass # entity was removed, as expected + + def test_source_unchanged_after_copy(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + source_rep = self._add_body_rep(wall_a) + source_rep_id = source_rep.id() + + ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert self.file.by_id(source_rep_id) is not None # source must still exist + assert ifcopenshell.util.representation.get_representation(wall_a, "Model", "Body") is not None + + def test_returns_none_when_no_matching_rep(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + + result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert result is None + + def test_custom_context_identifier(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + self._add_body_rep(wall_a) + + # "Axis" doesn't exist on wall_a, so should return None + result = ifcopenshell.api.geometry.copy_representation( + self.file, source=wall_a, target=wall_b, context_identifier="Axis" + ) + + assert result is None + + +class TestCopyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestCopyRepresentation): + pass