mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +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
|
||||
|
||||
Reference in New Issue
Block a user