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,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]