ifcopenshell.util: schema-aware downgrade helpers

Adds the IFC-library primitives the ifcpatch Migrate recipe needs for a
defensive IFC4 / IFC4X3 -> IFC2X3 downgrade without each caller
reinventing the wheel.

In ifcopenshell.util.schema:
- Migrator(fallback_element_to_proxy=False) opt-in: when True, IFC4-only
  IfcElement subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement,
  ...) migrate to IfcBuildingElementProxy instead of raising. Default
  preserves the strict failure-on-unmappable contract for existing
  callers (classification API, etc.).
- geometry_classes_introduced_after(target, source) derives the
  IfcRepresentationItem subclasses present in `source` but absent in
  `target` directly from the loaded schemas. Cached per pair. Replaces
  hand-curated class lists that drift with each IFC update.
  ifc4_only_geometry_classes() retained as an alias.
- generate_default_value synthesises a unit IfcAxis2Placement2D /
  IfcAxis2Placement3D when downgrading entities whose Position became
  required in the target schema (IfcIShapeProfileDef and friends in
  IFC2X3).
- Enum-mismatch detection upgraded from string-matched RuntimeError to a
  structural check via ifcopenshell.util.attribute.get_enum_items so
  upgrade paths still surface real bugs loudly.

In ifcopenshell.util.shape_builder:
- polygonal_face_set_to_faceted_brep converts IfcPolygonalFaceSet /
  IfcTriangulatedFaceSet (IFC4-only) directly to IfcFacetedBrep,
  preserving topology including IfcIndexedPolygonalFaceWithVoids inner
  bounds. Validates inputs at the boundary.
- arc_to_polyline_points approximates a circular arc through three
  points with a chord polyline of configurable subdivisions. Tolerates
  floating-point noise on planar Z. Raises on non-planar or invalid
  inputs.

Test coverage: 47 unit tests across schema + shape_builder lanes
covering each helper directly (no transitive-only coverage), including
regression pins for the IFC4X3-prefix ordering invariant in
get_fallback_schema and the strict-default Migrator contract.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-23 09:23:25 +02:00
parent 3c9ee4a71f
commit a2dafc9ceb
4 changed files with 531 additions and 11 deletions
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
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",
@@ -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
@@ -16,6 +16,9 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
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")
@@ -16,7 +16,7 @@
# 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 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]