diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema.py b/src/ifcopenshell-python/ifcopenshell/util/schema.py
index bdd6d489d0..e755095bdf 100644
--- a/src/ifcopenshell-python/ifcopenshell/util/schema.py
+++ b/src/ifcopenshell-python/ifcopenshell/util/schema.py
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see .
+import functools
import json
import os
import time
@@ -148,6 +149,57 @@ def get_subtypes(
return get_classes(declaration)
+def _enum_value_outside_target(attribute: ifcopenshell_wrapper.attribute, value: Any) -> bool:
+ """``True`` when ``attribute`` is an enumeration and the string ``value``
+ is not in its declared items. Used by the Migrator to silently skip enum
+ values that exist in the source schema but not the target — without
+ parsing C++ wrapper error strings."""
+ if not isinstance(value, str):
+ return False
+ try:
+ enum_items = ifcopenshell.util.attribute.get_enum_items(attribute)
+ except (AssertionError, AttributeError):
+ return False
+ return value not in enum_items
+
+
+@functools.cache
+def geometry_classes_introduced_after(target_schema: IFC_SCHEMA, source_schema: IFC_SCHEMA = "IFC4") -> frozenset[str]:
+ """``IfcRepresentationItem`` subclasses present in ``source_schema`` but
+ missing in ``target_schema``.
+
+ Derived from the loaded schema declarations once per (source, target) pair
+ and cached. The result is the canonical set of geometry classes a
+ downgrade from ``source_schema`` to ``target_schema`` must convert
+ (``IfcPolygonalFaceSet``, ``IfcTriangulatedFaceSet``, ``IfcAdvancedBrep``,
+ B-splines, advanced surfaces, alignment curves on IFC4X3 → 2X3, …) or
+ purge. Defaults match the IFC4 → IFC2X3 case for backwards compatibility
+ with the original caller."""
+ source = ifcopenshell_wrapper.schema_by_name(source_schema)
+ target = ifcopenshell_wrapper.schema_by_name(target_schema)
+ target_names = {decl.name() for decl in target.entities()}
+ result: set[str] = set()
+ for decl in source.entities():
+ if decl.name() in target_names:
+ continue
+ cursor: Any = decl
+ while cursor is not None:
+ if cursor.name() == "IfcRepresentationItem":
+ result.add(decl.name())
+ break
+ cursor = cursor.supertype()
+ return frozenset(result)
+
+
+def ifc4_only_geometry_classes() -> frozenset[str]:
+ """Backwards-compatible alias for the IFC4 → IFC2X3 geometry-gap set.
+
+ New code should call :func:`geometry_classes_introduced_after` with the
+ explicit (target, source) pair so IFC4X3 → IFC2X3 downgrades pick up the
+ additional IFC4X3-only geometry classes."""
+ return geometry_classes_introduced_after("IFC2X3", "IFC4")
+
+
def reassign_class(
ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, new_class: str
) -> ifcopenshell.entity_instance:
@@ -263,7 +315,20 @@ class Migrator:
migrated_ids: dict[int, int]
attribute_overrides: dict[int, dict[int, str]]
- def __init__(self):
+ def __init__(self, *, fallback_element_to_proxy: bool = False) -> None:
+ """Construct a schema migrator.
+
+ :param fallback_element_to_proxy: When ``True`` and the target schema is
+ IFC2X3, IFC4 entity classes that have no direct IFC2X3 equivalent
+ but inherit from ``IfcElement`` / ``IfcElementType`` are migrated as
+ ``IfcBuildingElementProxy`` / ``IfcBuildingElementProxyType``
+ respectively, instead of raising. Caller code is then responsible
+ for preserving the lost original class information out-of-band (the
+ ``Migrate`` ifcpatch recipe encodes it into ``ObjectType``).
+ Defaults to ``False`` so non-recipe callers keep the strict
+ failure-on-unmappable contract.
+ """
+ self.fallback_element_to_proxy = fallback_element_to_proxy
self.migrated_ids = {}
self.attribute_overrides = {}
self.class_4_to_2x3 = json.load(open(os.path.join(cwd, "class_4_to_2x3.json"), "r"))
@@ -379,6 +444,17 @@ class Migrator:
self.migrated_ids[element.id()] = new_element.id()
return new_element
+ @staticmethod
+ def _is_subclass_of(ifc_class: str, ancestor: str, source_file: ifcopenshell.file) -> bool:
+ schema = ifcopenshell_wrapper.schema_by_name(source_file.schema_identifier)
+ try:
+ return is_a(schema.declaration_by_name(ifc_class), ancestor)
+ except RuntimeError:
+ # Class doesn't exist in the source schema — happens for cross-schema
+ # introspection of an entity created with a name the wrapper doesn't
+ # recognise. Treat as "not a subclass".
+ return False
+
def migrate_class(
self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file
) -> ifcopenshell.entity_instance:
@@ -389,15 +465,44 @@ class Migrator:
if isinstance(value, float):
ifc_class = "IfcQuantityNumber"
try:
- new_element = new_file.create_entity(ifc_class)
+ return new_file.create_entity(ifc_class)
except:
- # The element does not exist in this schema
- # Complex migration is not yet supported (e.g. polygonal face set to faceted brep)
- if new_file.schema == "IFC2X3":
- new_element = new_file.create_entity(self.class_4_to_2x3[ifc_class])
- elif new_file.schema == "IFC4":
- new_element = new_file.create_entity(self.class_2x3_to_4[ifc_class])
- return new_element
+ pass
+
+ # The class does not exist in the target schema — look up an equivalent.
+ # The lookup tables use empty-string as a sentinel meaning "no direct
+ # equivalent, needs geometric translation" (e.g. polygonal face set →
+ # faceted brep). Callers that want a clean downgrade are expected to
+ # preprocess such carriers before calling the Migrator; see the
+ # `Migrate` ifcpatch recipe.
+ if new_file.schema == "IFC2X3":
+ equivalent = self.class_4_to_2x3.get(ifc_class, None)
+ elif new_file.schema == "IFC4":
+ equivalent = self.class_2x3_to_4.get(ifc_class, None)
+ else:
+ equivalent = None
+
+ # IfcBuildingElementProxy fallback is opt-in (see constructor) — only
+ # the IfcElement / IfcElementType subtrees have a meaningful generic
+ # IFC2X3 stand-in; non-element IFC4-only classes (rels, geometry items,
+ # materials, times) still raise below.
+ if not equivalent and new_file.schema == "IFC2X3" and self.fallback_element_to_proxy:
+ if self._is_subclass_of(ifc_class, "IfcElement", element.wrapped_data.file):
+ equivalent = "IfcBuildingElementProxy"
+ elif self._is_subclass_of(ifc_class, "IfcElementType", element.wrapped_data.file):
+ equivalent = "IfcBuildingElementProxyType"
+
+ if not equivalent:
+ inverses = element.wrapped_data.file.get_inverse(element)
+ inverse_hint = ", ".join(f"#{i.id()}={i.is_a()}" for i in list(inverses)[:3])
+ if len(inverses) > 3:
+ inverse_hint += f", … (+{len(inverses) - 3} more)"
+ raise NotImplementedError(
+ f"Cannot migrate #{element.id()}={ifc_class} to schema "
+ f"{new_file.schema}: no direct equivalent exists. "
+ f"Referenced by: {inverse_hint or '(no inverses)'}."
+ )
+ return new_file.create_entity(equivalent)
def migrate_attributes(
self,
@@ -526,11 +631,40 @@ class Migrator:
new_value.append(self.migrate(item, new_file))
value = new_value
if value is not None:
+ if _enum_value_outside_target(attribute, value):
+ # Enum value present in source schema but missing in target
+ # (typically a downgrade after a cross-class fallback, e.g.
+ # IfcLamp.PredefinedType=COMPACTFLUORESCENT copied onto
+ # IfcBuildingElementProxy.CompositionType whose enum is
+ # IfcElementCompositionEnum). Leave the attribute unset rather
+ # than abort the whole entity's migration. Detected
+ # structurally so other RuntimeError causes (type mismatches,
+ # invalid values) still propagate.
+ return
setattr(new_element, attribute.name(), value)
def generate_default_value(self, attribute: ifcopenshell_wrapper.attribute, new_file: ifcopenshell.file) -> Any:
if attribute.name() in self.default_values:
return self.default_values[attribute.name()]
+ elif attribute.name() == "Position":
+ # IFC4 relaxed Position to OPTIONAL for many profile defs; IFC2X3
+ # still requires it. Synthesize a unit placement at origin so
+ # IfcIShapeProfileDef and friends downgrade without crashing
+ # downstream validators.
+ try:
+ type_name = attribute.type_of_attribute().as_named_type().declared_type().name()
+ except Exception:
+ type_name = None
+ if type_name == "IfcAxis2Placement2D":
+ return new_file.create_entity(
+ "IfcAxis2Placement2D",
+ Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0)),
+ )
+ if type_name == "IfcAxis2Placement3D":
+ return new_file.create_entity(
+ "IfcAxis2Placement3D",
+ Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0, 0.0)),
+ )
elif attribute.name() == "OwnerHistory":
self.default_entities[attribute.name()] = new_file.create_entity(
"IfcOwnerHistory",
diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py
index d9d18f0b5f..eb5bbbdcdb 100644
--- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py
+++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py
@@ -21,7 +21,7 @@ from __future__ import annotations
import collections.abc
from collections.abc import Sequence
from itertools import chain
-from math import atan, cos, degrees, pi, radians, sin, sqrt, tan
+from math import atan, atan2, cos, degrees, hypot, isclose, pi, radians, sin, sqrt, tan
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import numpy as np
@@ -301,6 +301,130 @@ def intersect_x_axis_2d(p1: VectorType, p2: VectorType, y=0) -> Optional[float]:
return x1 + t * (x2 - x1)
+def arc_to_polyline_points(
+ start: VectorType, mid: VectorType, end: VectorType, subdivisions: int = 16
+) -> list[tuple[float, ...]]:
+ """Approximate a circular arc through (start, mid, end) with chord points.
+
+ The arc is determined uniquely by three points — a circle is fit in the
+ XY plane and the angle is walked from start through mid to end, sampling
+ ``subdivisions + 1`` points inclusive of the endpoints. Falls back to a
+ straight chord ``[start, end]`` for collinear / degenerate inputs.
+
+ Only planar arcs in the XY plane are supported. For 3D inputs (length 3
+ tuples), the Z coordinate of each output point is held constant at
+ ``start[2]``. Inputs where start/mid/end have differing Z values raise
+ ``ValueError`` rather than silently project — caller should rotate the
+ arc into the XY plane first if it lives in a non-axis-aligned plane.
+
+ :raises ValueError: if subdivisions < 1, or if 3D inputs have mismatched
+ Z coordinates (non-planar arc).
+ """
+ if subdivisions < 1:
+ raise ValueError(f"subdivisions must be >= 1, got {subdivisions}")
+ if len(start) >= 3:
+ # Tolerance accommodates floating-point noise from kernel transforms
+ # — IFC point coordinates that the author wrote as the same Z value
+ # may diverge by ~1e-15 after placement-matrix round-trips.
+ z_tol = 1e-9
+ if not (isclose(start[2], mid[2], abs_tol=z_tol) and isclose(start[2], end[2], abs_tol=z_tol)):
+ raise ValueError(
+ f"arc_to_polyline_points only handles arcs in the XY plane; "
+ f"got mismatched Z coordinates ({start[2]}, {mid[2]}, {end[2]})."
+ )
+ sx, sy = start[0], start[1]
+ mx, my = mid[0], mid[1]
+ ex, ey = end[0], end[1]
+ d = 2 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my))
+ if abs(d) < 1e-12:
+ return [tuple(start), tuple(end)]
+ cx = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy) + (ex**2 + ey**2) * (sy - my)) / d
+ cy = ((sx**2 + sy**2) * (ex - mx) + (mx**2 + my**2) * (sx - ex) + (ex**2 + ey**2) * (mx - sx)) / d
+ a_start = atan2(sy - cy, sx - cx)
+ a_mid = atan2(my - cy, mx - cx)
+ a_end = atan2(ey - cy, ex - cx)
+ sweep = _signed_sweep_through_mid(a_start, a_mid, a_end)
+ radius = hypot(sx - cx, sy - cy)
+ pts: list[tuple[float, ...]] = []
+ for i in range(subdivisions + 1):
+ t = i / subdivisions
+ angle = a_start + sweep * t
+ x = cx + radius * cos(angle)
+ y = cy + radius * sin(angle)
+ if len(start) == 2:
+ pts.append((x, y))
+ else:
+ pts.append((x, y, start[2]))
+ return pts
+
+
+def _signed_sweep_through_mid(a_start: float, a_mid: float, a_end: float) -> float:
+ """Total angle (radians) from a_start to a_end going through a_mid."""
+ two_pi = 2 * pi
+ ccw_total = (a_end - a_start) % two_pi
+ ccw_to_mid = (a_mid - a_start) % two_pi
+ if ccw_to_mid <= ccw_total:
+ return ccw_total
+ return -((a_start - a_end) % two_pi)
+
+
+def polygonal_face_set_to_faceted_brep(face_set: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
+ """Convert an ``IfcPolygonalFaceSet`` or ``IfcTriangulatedFaceSet`` into an
+ ``IfcFacetedBrep`` in the same file, preserving vertex coordinates and face
+ topology (including inner voids on ``IfcIndexedPolygonalFaceWithVoids``).
+
+ The returned brep is the canonical IFC2X3-compatible form of these IFC4
+ tessellated representations. The caller is responsible for rewiring inverse
+ references and removing the source face set when downgrading.
+
+ :raises TypeError: if ``face_set`` is not an ``IfcPolygonalFaceSet`` or
+ ``IfcTriangulatedFaceSet``.
+ :raises ValueError: if ``face_set.Coordinates`` is missing or any face's
+ coordinate index references a vertex outside the coordinate list.
+ """
+ if not (face_set.is_a("IfcPolygonalFaceSet") or face_set.is_a("IfcTriangulatedFaceSet")):
+ raise TypeError(
+ f"polygonal_face_set_to_faceted_brep expected IfcPolygonalFaceSet or "
+ f"IfcTriangulatedFaceSet, got {face_set.is_a()}."
+ )
+ if face_set.Coordinates is None:
+ raise ValueError(f"{face_set.is_a()} #{face_set.id()} has no Coordinates point list.")
+ ifc_file = face_set.file
+ coords = face_set.Coordinates.CoordList
+ vertex_count = len(coords)
+ ifc_points = [ifc_file.createIfcCartesianPoint(tuple(c)) for c in coords]
+
+ def _resolve(indices: Sequence[int]) -> list[ifcopenshell.entity_instance]:
+ # IfcIndexedPolygonalFace.CoordIndex / IfcTriangulatedFaceSet.CoordIndex
+ # are 1-based. Out-of-range hits early with a clear message rather
+ # than the cryptic IndexError from list[i-1].
+ out = []
+ for index in indices:
+ if not 1 <= index <= vertex_count:
+ raise ValueError(
+ f"{face_set.is_a()} #{face_set.id()} face references vertex {index}, "
+ f"outside CoordList range 1..{vertex_count}."
+ )
+ out.append(ifc_points[index - 1])
+ return out
+
+ ifc_faces: list[ifcopenshell.entity_instance] = []
+ if face_set.is_a("IfcTriangulatedFaceSet"):
+ for triangle in face_set.CoordIndex:
+ loop = ifc_file.createIfcPolyLoop(_resolve(triangle))
+ ifc_faces.append(ifc_file.createIfcFace([ifc_file.createIfcFaceOuterBound(loop, True)]))
+ else: # IfcPolygonalFaceSet
+ for indexed_face in face_set.Faces:
+ outer_loop = ifc_file.createIfcPolyLoop(_resolve(indexed_face.CoordIndex))
+ bounds = [ifc_file.createIfcFaceOuterBound(outer_loop, True)]
+ if indexed_face.is_a("IfcIndexedPolygonalFaceWithVoids"):
+ for inner in indexed_face.InnerCoordIndices or ():
+ bounds.append(ifc_file.createIfcFaceBound(ifc_file.createIfcPolyLoop(_resolve(inner)), True))
+ ifc_faces.append(ifc_file.createIfcFace(bounds))
+
+ return ifc_file.createIfcFacetedBrep(ifc_file.createIfcClosedShell(ifc_faces))
+
+
# Note: using ShapeBuilder try not to reuse IFC elements in the process
# otherwise you might run into situation where builder.mirror or other operation
# is applied twice during one run to the same element
diff --git a/src/ifcopenshell-python/test/util/test_schema.py b/src/ifcopenshell-python/test/util/test_schema.py
index cb45b6e8db..802e426936 100644
--- a/src/ifcopenshell-python/test/util/test_schema.py
+++ b/src/ifcopenshell-python/test/util/test_schema.py
@@ -16,6 +16,9 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see .
+import pytest
+
+import ifcopenshell
import ifcopenshell.api.project
import ifcopenshell.util.schema as subject
import test.bootstrap
@@ -119,6 +122,157 @@ END-ISO-10303-21;
assert isinstance(qt_float_count_measure_ifc4x3[3], float)
assert qt_float_count_measure_ifc4x3[3] == 723.0
+ def test_migrate_class_raises_clear_error_for_ifc4_only_non_element_class_to_ifc2x3(self):
+ """IFC4-only non-element classes (geometry items, etc.) have no
+ IfcBuildingElementProxy fallback and must surface a clear error naming
+ the failing class — not the cryptic 'Entity name not found in schema'."""
+ ifc4_file = ifcopenshell.api.project.create_file()
+ point_list = ifc4_file.create_entity("IfcCartesianPointList2D", CoordList=((0.0, 0.0), (1.0, 0.0)))
+ ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
+
+ migrator = subject.Migrator()
+ with pytest.raises(NotImplementedError) as exc_info:
+ migrator.migrate(point_list, ifc2x3_file)
+
+ message = str(exc_info.value)
+ assert "IfcCartesianPointList2D" in message
+ assert "IFC2X3" in message
+
+ def test_migrate_class_falls_back_to_ifcbuildingelementproxy_when_opt_in(self):
+ """With ``fallback_element_to_proxy=True``, IFC4-only IfcElement
+ subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, …) migrate
+ as IfcBuildingElementProxy instead of raising. Default behavior
+ (no opt-in) raises so non-recipe callers keep the strict contract."""
+ ifc4_file = ifcopenshell.api.project.create_file()
+ lamp = ifc4_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW")
+ ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
+
+ # Default migrator raises (strict contract preserved).
+ with pytest.raises(NotImplementedError, match="IfcLamp"):
+ subject.Migrator().migrate(lamp, ifc2x3_file)
+
+ # Opt-in migrator substitutes IfcBuildingElementProxy.
+ ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
+ new_lamp = subject.Migrator(fallback_element_to_proxy=True).migrate(lamp, ifc2x3_file)
+ assert new_lamp.is_a("IfcBuildingElementProxy")
+
+
+class TestGetFallbackSchema:
+ """Pins the schema-identifier normalisation contract relied on by callers
+ that need to map upstream variants (IFC4X3_ADD2, IFC2X3_TC1, IFC4_ADD2, …)
+ to a base schema name for compatibility tables / downgrade detection."""
+
+ def test_ifc4x3_variants_collapse_to_ifc4x3(self):
+ # Longest-prefix-first: IFC4X3_ADD2 must NOT be misclassified as IFC4
+ # — the function checks IFC4X3 before IFC4.
+ assert subject.get_fallback_schema("IFC4X3") == "IFC4X3"
+ assert subject.get_fallback_schema("IFC4X3_ADD1") == "IFC4X3"
+ assert subject.get_fallback_schema("IFC4X3_ADD2") == "IFC4X3"
+ assert subject.get_fallback_schema("IFC4X3_RC1") == "IFC4X3"
+
+ def test_ifc4_variants_collapse_to_ifc4(self):
+ assert subject.get_fallback_schema("IFC4") == "IFC4"
+ assert subject.get_fallback_schema("IFC4_ADD1") == "IFC4"
+ assert subject.get_fallback_schema("IFC4_ADD2") == "IFC4"
+ # IFC4X1 / IFC4X2 are draft schemas — collapse to IFC4 by design.
+ assert subject.get_fallback_schema("IFC4X1") == "IFC4"
+ assert subject.get_fallback_schema("IFC4X2") == "IFC4"
+
+ def test_ifc2x3_variants_collapse_to_ifc2x3(self):
+ assert subject.get_fallback_schema("IFC2X3") == "IFC2X3"
+ assert subject.get_fallback_schema("IFC2X3_TC1") == "IFC2X3"
+ assert subject.get_fallback_schema("IFC2X3_FINAL") == "IFC2X3"
+
+ def test_unknown_version_asserts(self):
+ # Asserts under non-optimised Python; in -O mode would return the
+ # unmodified input. Caller should guard accordingly.
+ with pytest.raises(AssertionError):
+ subject.get_fallback_schema("IFC10")
+
+
+class TestIfc4OnlyGeometryClasses:
+ def test_known_ifc4_only_classes_present(self):
+ result = subject.ifc4_only_geometry_classes()
+ # Classes that genuinely don't exist in IFC2X3 and inherit
+ # IfcRepresentationItem in IFC4.
+ for name in (
+ "IfcPolygonalFaceSet",
+ "IfcTriangulatedFaceSet",
+ "IfcIndexedPolyCurve",
+ "IfcCartesianPointList3D",
+ "IfcAdvancedBrep",
+ ):
+ assert name in result, f"{name} should be classified as IFC4-only geometry"
+
+ def test_ifc2x3_compatible_classes_absent(self):
+ result = subject.ifc4_only_geometry_classes()
+ # Classes that exist in both schemas — must NOT be flagged.
+ for name in ("IfcPolyline", "IfcFacetedBrep", "IfcCartesianPoint", "IfcExtrudedAreaSolid"):
+ assert name not in result, f"{name} exists in IFC2X3, should not be IFC4-only"
+
+ def test_non_geometry_ifc4_only_classes_absent(self):
+ result = subject.ifc4_only_geometry_classes()
+ # IFC4-only but not IfcRepresentationItem subclasses — out of scope.
+ for name in ("IfcEvent", "IfcWorkCalendar", "IfcLamp"):
+ assert name not in result, f"{name} is not an IfcRepresentationItem subclass"
+
+ def test_result_is_cached_frozenset(self):
+ first = subject.ifc4_only_geometry_classes()
+ second = subject.ifc4_only_geometry_classes()
+ assert first is second # @functools.cache returns the same object
+
+
+class TestGeometryClassesIntroducedAfter:
+ """Generalised version of ``ifc4_only_geometry_classes`` — pins the
+ schema-aware contract that supports IFC4X3 → IFC2X3 downgrades, not just
+ IFC4 → IFC2X3."""
+
+ def test_ifc4_to_ifc2x3_matches_legacy_helper(self):
+ # The legacy ``ifc4_only_geometry_classes`` is now a thin alias.
+ assert subject.geometry_classes_introduced_after("IFC2X3", "IFC4") == subject.ifc4_only_geometry_classes()
+
+ def test_ifc4x3_to_ifc2x3_is_superset_of_ifc4_to_ifc2x3(self):
+ # IFC4X3 is a superset of IFC4 — every IFC4-only geometry class is
+ # also missing from IFC2X3 when the source is IFC4X3, plus any new
+ # IFC4X3-only geometry (alignment curves, distance expressions, …).
+ ifc4_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4")
+ ifc4x3_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4X3")
+ assert ifc4_gap <= ifc4x3_gap
+
+ def test_ifc4_to_ifc4x3_is_empty(self):
+ # IFC4X3 contains every IFC4 IfcRepresentationItem subclass — no
+ # IFC4 class is missing from IFC4X3.
+ assert subject.geometry_classes_introduced_after("IFC4X3", "IFC4") == frozenset()
+
+
+class TestEnumValueOutsideTarget:
+ @staticmethod
+ def _attr(class_name: str, attr_name: str):
+ schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name("IFC2X3")
+ decl = schema.declaration_by_name(class_name)
+ return next(a for a in decl.all_attributes() if a.name() == attr_name)
+
+ def test_enum_value_present_in_target_returns_false(self):
+ # IfcCovering.PredefinedType is IfcCoveringTypeEnum — CEILING is valid.
+ attr = self._attr("IfcCovering", "PredefinedType")
+ assert subject._enum_value_outside_target(attr, "CEILING") is False
+
+ def test_enum_value_missing_in_target_returns_true(self):
+ # IfcCoveringTypeEnum has no COMPACTFLUORESCENT (an IfcLampTypeEnum value).
+ attr = self._attr("IfcCovering", "PredefinedType")
+ assert subject._enum_value_outside_target(attr, "COMPACTFLUORESCENT") is True
+
+ def test_non_enum_attribute_returns_false(self):
+ # IfcCovering.Name is IfcLabel — not an enum, so the helper must return False.
+ attr = self._attr("IfcCovering", "Name")
+ assert subject._enum_value_outside_target(attr, "anything") is False
+
+ def test_non_string_value_returns_false(self):
+ attr = self._attr("IfcCovering", "PredefinedType")
+ assert subject._enum_value_outside_target(attr, 42) is False
+
+
+class TestExtendedMaterialProperties(test.bootstrap.IFC4):
def test_migrate_extended_material_properties_ifc2x3_ifc4(self):
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
material = ifc2x3_file.createIfcMaterial(Name="Material")
diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py
index 6a01a74201..d024ffc13a 100644
--- a/src/ifcopenshell-python/test/util/test_shape_builder.py
+++ b/src/ifcopenshell-python/test/util/test_shape_builder.py
@@ -16,7 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see .
-from math import degrees, radians
+from math import degrees, radians, sqrt
from typing import Any, Union
import numpy as np
@@ -28,6 +28,7 @@ import test.bootstrap
from ifcopenshell.util.shape_builder import (
ShapeBuilder,
V,
+ arc_to_polyline_points,
is_x,
np_angle,
np_angle_signed,
@@ -36,9 +37,116 @@ from ifcopenshell.util.shape_builder import (
np_normal,
np_rotation_matrix,
np_to_3d,
+ polygonal_face_set_to_faceted_brep,
)
+class TestArcToPolylinePoints:
+ def test_quarter_arc_2d_samples_n_plus_one_points(self):
+ # Quarter arc from (1,0) through (cos45°, sin45°) to (0,1) — unit circle.
+ sqrt_half = sqrt(0.5)
+ points = arc_to_polyline_points((1.0, 0.0), (sqrt_half, sqrt_half), (0.0, 1.0), 8)
+ assert len(points) == 9
+ assert points[0] == pytest.approx((1.0, 0.0), abs=1e-9)
+ assert points[-1] == pytest.approx((0.0, 1.0), abs=1e-9)
+ for x, y in points:
+ assert x * x + y * y == pytest.approx(1.0, abs=1e-9)
+
+ def test_collinear_inputs_fall_back_to_straight_chord(self):
+ points = arc_to_polyline_points((0.0, 0.0), (1.0, 0.0), (2.0, 0.0), 16)
+ assert points == [(0.0, 0.0), (2.0, 0.0)]
+
+ def test_3d_inputs_with_constant_z_preserved(self):
+ points = arc_to_polyline_points((1.0, 0.0, 5.0), (0.7071, 0.7071, 5.0), (0.0, 1.0, 5.0), 4)
+ assert len(points) == 5
+ assert all(p[2] == 5.0 for p in points)
+
+ def test_3d_inputs_with_mismatched_z_raises(self):
+ with pytest.raises(ValueError, match="XY plane"):
+ arc_to_polyline_points((1.0, 0.0, 0.0), (0.0, 1.0, 1.0), (-1.0, 0.0, 0.0))
+
+ def test_3d_inputs_with_near_equal_z_pass_within_tolerance(self):
+ # Real IFC files often have float noise of ~1e-15 in Z values that the
+ # author meant to be identical — kernel transforms introduce it. The
+ # planar check tolerates this rather than rejecting valid input.
+ sqrt_half = sqrt(0.5)
+ points = arc_to_polyline_points(
+ (1.0, 0.0, 5.0), (sqrt_half, sqrt_half, 5.0 + 1e-15), (0.0, 1.0, 5.0 - 2e-16), 4
+ )
+ assert len(points) == 5
+
+ def test_subdivisions_zero_raises(self):
+ with pytest.raises(ValueError, match="subdivisions"):
+ arc_to_polyline_points((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 0)
+
+
+class TestPolygonalFaceSetToFacetedBrep(test.bootstrap.IFC4):
+ def test_triangulated_face_set_preserves_coordinates(self):
+ coords = self.file.create_entity(
+ "IfcCartesianPointList3D",
+ CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)),
+ )
+ face_set = self.file.create_entity(
+ "IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 2, 4), (2, 3, 4), (3, 1, 4), (1, 3, 2)]
+ )
+
+ brep = polygonal_face_set_to_faceted_brep(face_set)
+
+ assert brep.is_a("IfcFacetedBrep")
+ assert len(brep.Outer.CfsFaces) == 4
+ # Every CoordList vertex appears in the brep at the same coordinate.
+ brep_points = {tuple(p.Coordinates) for f in brep.Outer.CfsFaces for p in f.Bounds[0].Bound.Polygon}
+ assert (0.0, 0.0, 0.0) in brep_points
+ assert (1.0, 0.0, 0.0) in brep_points
+ assert (0.0, 1.0, 0.0) in brep_points
+ assert (0.5, 0.5, 1.0) in brep_points
+
+ def test_polygonal_face_set_with_voids_preserves_inner_bounds(self):
+ # Quad with a triangular hole through it.
+ coords = self.file.create_entity(
+ "IfcCartesianPointList3D",
+ CoordList=(
+ (0.0, 0.0, 0.0),
+ (4.0, 0.0, 0.0),
+ (4.0, 4.0, 0.0),
+ (0.0, 4.0, 0.0),
+ (1.0, 1.0, 0.0),
+ (3.0, 1.0, 0.0),
+ (2.0, 3.0, 0.0),
+ ),
+ )
+ face = self.file.create_entity(
+ "IfcIndexedPolygonalFaceWithVoids",
+ CoordIndex=(1, 2, 3, 4),
+ InnerCoordIndices=[(5, 6, 7)],
+ )
+ face_set = self.file.create_entity("IfcPolygonalFaceSet", Coordinates=coords, Faces=[face])
+
+ brep = polygonal_face_set_to_faceted_brep(face_set)
+
+ assert len(brep.Outer.CfsFaces) == 1
+ bounds = brep.Outer.CfsFaces[0].Bounds
+ # Outer + 1 inner bound.
+ assert len(bounds) == 2
+ outer = next(b for b in bounds if b.is_a("IfcFaceOuterBound"))
+ inner = next(b for b in bounds if not b.is_a("IfcFaceOuterBound"))
+ assert len(outer.Bound.Polygon) == 4
+ assert len(inner.Bound.Polygon) == 3
+
+ def test_wrong_class_raises_typeerror(self):
+ # An IfcCartesianPointList3D is not a face set.
+ not_a_face_set = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),))
+ with pytest.raises(TypeError, match="IfcPolygonalFaceSet"):
+ polygonal_face_set_to_faceted_brep(not_a_face_set)
+
+ def test_out_of_range_index_raises_valueerror(self):
+ coords = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),))
+ # CoordIndex 5 doesn't exist in a 1-vertex coord list.
+ face_set = self.file.create_entity("IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 1, 5)])
+ with pytest.raises(ValueError, match="outside CoordList range"):
+ polygonal_face_set_to_faceted_brep(face_set)
+
+
class TestMathutilsCompatibleMethods(test.bootstrap.IFC4):
def test_np_rotation_matrix(self):
from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]