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.
This commit is contained in:
Bruno Postle
2026-03-23 23:00:12 +00:00
committed by GitHub
parent 1aec991f08
commit 23ba9e4db0
7 changed files with 751 additions and 0 deletions
@@ -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",
@@ -0,0 +1,86 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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
@@ -0,0 +1,116 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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
@@ -0,0 +1,76 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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