mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user