mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 17:58:20 +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,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]
|
||||
|
||||
Reference in New Issue
Block a user