Merge remote-tracking branch 'origin/v0.8.0' into ifcviewer-wgpu

This commit is contained in:
Thomas Krijnen
2026-07-09 13:21:39 +02:00
373 changed files with 22411 additions and 4242 deletions
@@ -355,7 +355,8 @@ def get_cost_rate(
class CostValueUnserialiser:
def parse(self, formula: str):
l = lark.Lark("""start: formula
l = lark.Lark(
"""start: formula
formula: operand (operator operand)*
operand: value | category "(" formula ")"
value: NUMBER?
@@ -392,7 +393,8 @@ class CostValueUnserialiser:
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
""")
"""
)
start = l.parse(formula)
return self.get_formula(start.children[0])
@@ -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",
@@ -39,7 +39,8 @@ import ifcopenshell.util.shape
import ifcopenshell.util.system
import ifcopenshell.util.unit
filter_elements_grammar = lark.Lark("""start: filter_group
filter_elements_grammar = lark.Lark(
"""start: filter_group
filter_group: facet_list ("+" facet_list)*
facet_list: facet ("," facet)*
@@ -110,9 +111,11 @@ filter_elements_grammar = lark.Lark("""start: filter_group
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
""")
"""
)
get_element_grammar = lark.Lark("""start: keys
get_element_grammar = lark.Lark(
"""start: keys
keys: key ("." key)*
key: quoted_string | regex_string | unquoted_string
@@ -127,9 +130,11 @@ get_element_grammar = lark.Lark("""start: keys
WS: /[ \\t\\f\\r\\n]/+
%ignore WS // Disregard spaces in text
""")
"""
)
format_grammar = lark.Lark("""start: expression
format_grammar = lark.Lark(
"""start: expression
?expression: add_sub
?add_sub: mul_div
@@ -188,7 +193,8 @@ format_grammar = lark.Lark("""start: expression
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
""")
"""
)
class FormatTransformer(lark.Transformer):
@@ -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
@@ -912,6 +912,13 @@ def convert_file_length_units(ifc_file: ifcopenshell.file, target_units: str = "
new_value = convert_value(val)
setattr(element, attr.name(), new_value)
# IfcGeometricRepresentationContext.Precision is typed as a plain IfcReal
# but is interpreted in the project length unit, so it must be scaled too.
# Subcontexts derive Precision from their parent and cannot be set.
for context in file_patched.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if context.Precision is not None:
context.Precision = convert_unit(context.Precision, old_length, new_length)
has_map_unit = False
if (
ifc_file.schema == "IFC2X3"