From a2dafc9cebce230eb53738c00e36f68a781f95a9 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 09:23:25 +0200 Subject: [PATCH 1/4] 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. --- .../ifcopenshell/util/schema.py | 152 ++++++++++++++++- .../ifcopenshell/util/shape_builder.py | 126 +++++++++++++- .../test/util/test_schema.py | 154 ++++++++++++++++++ .../test/util/test_shape_builder.py | 110 ++++++++++++- 4 files changed, 531 insertions(+), 11 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema.py b/src/ifcopenshell-python/ifcopenshell/util/schema.py index bdd6d489d0..e755095bdf 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema.py +++ b/src/ifcopenshell-python/ifcopenshell/util/schema.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +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", diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index d9d18f0b5f..eb5bbbdcdb 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -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 diff --git a/src/ifcopenshell-python/test/util/test_schema.py b/src/ifcopenshell-python/test/util/test_schema.py index cb45b6e8db..802e426936 100644 --- a/src/ifcopenshell-python/test/util/test_schema.py +++ b/src/ifcopenshell-python/test/util/test_schema.py @@ -16,6 +16,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +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") diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py index 6a01a74201..d024ffc13a 100644 --- a/src/ifcopenshell-python/test/util/test_shape_builder.py +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . -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] From f710929e9e1914f2680c7783009c5969e9515a61 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 09:33:03 +0200 Subject: [PATCH 2/4] ifcpatch Migrate: defensive IFC4/IFC4X3 -> IFC2X3 downgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Migrate recipe previously crashed mid-loop with the cryptic `RuntimeError: Entity with name '' not found in schema 'IFC2X3'` when asked to downgrade an IFC4 or IFC4X3 file to IFC2X3 — the class_4_to_2x3 mapping marks IFC4-only geometry / element classes with an empty-string sentinel and the old code blindly forwarded that to create_entity. Real files routinely contain IfcPolygonalFaceSet, IfcTriangulatedFaceSet, IfcIndexedPolyCurve, IfcLamp, IfcPipeSegment, IfcGeographicElement, etc. The recipe now runs a preprocessing pipeline when the target is IFC2X3 and the source is IFC4 or IFC4X3: - DowngradeIndexedPolyCurve flattens IfcIndexedPolyCurve to IfcPolyline for the whole file (arcs included — see below). - IfcPolygonalFaceSet / IfcTriangulatedFaceSet are converted directly to IfcFacetedBrep at the entity level via ifcopenshell.util.shape_builder.polygonal_face_set_to_faceted_brep, preserving topology including IfcIndexedPolygonalFaceWithVoids inner bounds. IfcShapeRepresentation carriers have their RepresentationType tag updated from "Tessellation" to "Brep". - Orphan source-only geometry instances (left over after the rewires) are purged iteratively via geometry_classes_introduced_after(target, source). The Migrator is invoked with fallback_element_to_proxy=True so IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, ...) become IfcBuildingElementProxy in the output. A post-pass encodes "/" into ObjectType (e.g. "IfcLamp/COMPACTFLUORESCENT") when ObjectType is empty, so the lost subclass identity survives the downgrade as searchable text. The migration loop now collects per-entity failures into a list rather than crashing on the first; a summary RuntimeError fires at end if any failed, naming up to 20 with their inverse references. Successful migrations log a single count line via self.logger. DowngradeIndexedPolyCurve extended: - Arc segments (IfcArcIndex) are flattened via ifcopenshell.util.shape_builder.arc_to_polyline_points with ARC_SUBDIVISION=16 chord points per arc. - Multi-index IfcLineIndex segments handled correctly. - Absent Segments list (IFC4 polyline-through-all-coords case) handled. Test coverage: 11 tests across the two recipes covering all four preprocessing branches, the IFC4X3 source gate, the ObjectType encoding (incl. author-supplied ObjectType preservation), the summary RuntimeError shape, and the arc subdivision. Generated with the assistance of an AI coding tool. --- .../recipes/DowngradeIndexedPolyCurve.py | 60 ++++-- src/ifcpatch/ifcpatch/recipes/Migrate.py | 140 +++++++++++++- .../test/test_DowngradeIndexedPolyCurve.py | 137 +++++++++++++ src/ifcpatch/test/test_Migrate.py | 181 ++++++++++++++++++ 4 files changed, 499 insertions(+), 19 deletions(-) create mode 100644 src/ifcpatch/test/test_DowngradeIndexedPolyCurve.py diff --git a/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py b/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py index 4ae36f26b8..259fc7aa18 100644 --- a/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py +++ b/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py @@ -17,6 +17,12 @@ # along with IfcPatch. If not, see . import ifcopenshell.util.element +import ifcopenshell.util.shape_builder + +# Number of straight chords used to approximate one IfcArcIndex when flattening +# an IfcIndexedPolyCurve to an IfcPolyline. Higher values track the true arc +# more closely at the cost of file weight. +ARC_SUBDIVISION = 16 class Patcher: @@ -34,6 +40,9 @@ class Patcher: an IFC4 model (IFC2X3 does not have this geometry type) to help compatibility in viewers like Navisworks. + Arc segments (``IfcArcIndex``) are approximated by a chord polyline + through ``ARC_SUBDIVISION`` evenly-spaced points along the arc. + Example: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}) @@ -47,19 +56,46 @@ class Patcher: curve_map = {} for curve in self.file.by_type("IfcIndexedPolyCurve"): - if "IfcArcIndex" in [s.is_a() for s in curve.Segments]: - print("Could not convert curve due to arcs", curve) - continue coordinates = curve.Points.CoordList - points = [] - for i, segment in enumerate(curve.Segments): - segment = segment.wrappedValue - if i == 0: - points.append(self.file.createIfcCartesianPoint(coordinates[segment[0] - 1])) - points.append(self.file.createIfcCartesianPoint(coordinates[segment[1] - 1])) - polyline = self.file.create_entity("IfcPolyline", points) + segments = curve.Segments + if segments is None: + # IFC4: an absent Segments list means the curve is a polyline + # through every CoordList point in declared order. + points = [tuple(c) for c in coordinates] + else: + points = self._segments_to_points(segments, coordinates) + if points is None: + continue + ifc_points = [self.file.createIfcCartesianPoint(p) for p in points] + polyline = self.file.create_entity("IfcPolyline", ifc_points) curve_map[curve] = polyline for curve, polyline in curve_map.items(): - for inverse in self.file.get_inverse(curve): - ifcopenshell.util.element.replace_attribute(inverse, curve, polyline) + ifcopenshell.util.element.replace_element(curve, polyline) + + def _segments_to_points(self, segments, coordinates): + points: list[tuple[float, ...]] = [] + for i, segment in enumerate(segments): + indices = segment.wrappedValue + if segment.is_a("IfcArcIndex"): + if len(indices) != 3: + return None + arc_points = ifcopenshell.util.shape_builder.arc_to_polyline_points( + coordinates[indices[0] - 1], + coordinates[indices[1] - 1], + coordinates[indices[2] - 1], + ARC_SUBDIVISION, + ) + if i == 0: + points.append(tuple(arc_points[0])) + points.extend(tuple(p) for p in arc_points[1:]) + else: + # IfcLineIndex is LIST [2:?] OF IfcPositiveInteger — a polyline + # through every listed index. Skip the first index on non-leading + # segments since it duplicates the previous segment's endpoint. + seg_points = [tuple(coordinates[idx - 1]) for idx in indices] + if i == 0: + points.extend(seg_points) + else: + points.extend(seg_points[1:]) + return points diff --git a/src/ifcpatch/ifcpatch/recipes/Migrate.py b/src/ifcpatch/ifcpatch/recipes/Migrate.py index 627342f096..c7479a6121 100644 --- a/src/ifcpatch/ifcpatch/recipes/Migrate.py +++ b/src/ifcpatch/ifcpatch/recipes/Migrate.py @@ -20,7 +20,9 @@ from logging import Logger from typing import Union import ifcopenshell +import ifcopenshell.util.element import ifcopenshell.util.schema +import ifcopenshell.util.shape_builder import ifcpatch @@ -32,10 +34,39 @@ class Patcher(ifcpatch.BasePatcher): logger: Union[Logger, None] = None, schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4", ): - """Migrate from one IFC version to another + """Migrate from one IFC version to another. - Note that this is experimental and will try to preserve as much data as - possible. Upgrading to IFC4 is more stable than downgrading to IFC2X3. + The recipe iterates every entity in the source file and rewrites it + into a new file with the target schema, delegating per-entity class / + attribute translation to :class:`ifcopenshell.util.schema.Migrator`. + Upgrades (IFC2X3 → IFC4, IFC4 → IFC4X3) are best supported because the + target schema is a superset; downgrades are lossy by definition (see + below). Entities that fail to migrate are collected; on completion a + summary ``RuntimeError`` is raised listing up to 20 failures. + + IFC4 → IFC2X3 downgrade additionally runs a preprocessing pipeline so + IFC4-only geometry and element classes survive the schema gap: + + - ``IfcIndexedPolyCurve`` (including arc segments, approximated by a + chord polyline) is flattened to ``IfcPolyline``. + - ``IfcPolygonalFaceSet`` and ``IfcTriangulatedFaceSet`` are converted + directly to ``IfcFacetedBrep`` at the entity level, preserving the + original mesh topology. + - Orphan IFC4-only geometry instances left over after the rewires are + purged so the migration loop does not trip on them. + - IFC4-only ``IfcElement`` subclasses (``IfcLamp``, ``IfcPipeSegment``, + ``IfcGeographicElement``, …) fall back to ``IfcBuildingElementProxy`` + via the Migrator's ``fallback_element_to_proxy`` opt-in. The + original class and ``PredefinedType`` are encoded into + ``ObjectType`` (e.g. ``"IfcLamp/COMPACTFLUORESCENT"``) when + ``ObjectType`` is empty, so the type information survives the + downgrade. + + Non-element IFC4-only entities (relationships, geometry items outside + any product, …) that have no direct equivalent still raise + ``NotImplementedError`` from the Migrator with the failing class and + inverse references named, instead of the cryptic + ``Entity with name '' not found in schema 'IFC2X3'``. :param schema: The schema identifier of the IFC version to migrate to. @@ -50,10 +81,105 @@ class Patcher(ifcpatch.BasePatcher): self.schema = schema def patch(self): + # IFC4 and IFC4X3 both have geometry / element classes absent in + # IFC2X3, so both source schemas need the downgrade preprocessing + + # IfcBuildingElementProxy fallback when targeting IFC2X3. + is_downgrade_to_ifc2x3 = self.schema == "IFC2X3" and self.file.schema in ("IFC4", "IFC4X3") + if is_downgrade_to_ifc2x3: + self._prepare_for_downgrade() + self.file_patched = ifcopenshell.file(schema=self.schema) - migrator = ifcopenshell.util.schema.Migrator() + migrator = ifcopenshell.util.schema.Migrator(fallback_element_to_proxy=is_downgrade_to_ifc2x3) migrator.preprocess(self.file, self.file_patched) + + migrated = 0 + failures: list[tuple[ifcopenshell.entity_instance, Exception]] = [] for element in self.file: - new_element = migrator.migrate(element, self.file_patched) - print("Migrating", element) - print("Successfully converted to", new_element) + try: + migrator.migrate(element, self.file_patched) + migrated += 1 + except Exception as exc: + failures.append((element, exc)) + + if is_downgrade_to_ifc2x3: + self._encode_fallback_class_into_object_type(migrator) + + # BasePatcher.__init__ guarantees self.logger is non-None + # (ensure_logger falls back to logging.getLogger("IFCPatch")). + self.logger.info(f"Migrated {migrated} entities to {self.schema}.") + if failures: + summary = [f"{len(failures)} entities could not be migrated to {self.schema}:"] + for element, exc in failures[:20]: + summary.append(f" #{element.id()}={element.is_a()}: {exc}") + if len(failures) > 20: + summary.append(f" … (+{len(failures) - 20} more)") + raise RuntimeError("\n".join(summary)) + + def _prepare_for_downgrade(self) -> None: + from ifcpatch.recipes.DowngradeIndexedPolyCurve import Patcher as DowngradePolyCurve + + DowngradePolyCurve(self.file, self.logger).patch() + self._convert_face_sets_to_faceted_brep() + self._purge_orphaned_ifc4_only_entities() + + def _convert_face_sets_to_faceted_brep(self) -> None: + face_sets = list(self.file.by_type("IfcPolygonalFaceSet")) + list(self.file.by_type("IfcTriangulatedFaceSet")) + if not face_sets: + return + + # IfcShapeRepresentations carrying these face sets need their type tag + # updated from "Tessellation" (IFC4) to "Brep" (IFC2X3-compatible). + # Snapshot the relevant inverses before rewiring — the inverse set is + # invalidated once replace_element runs. + touched_reps: set[int] = set() + for face_set in face_sets: + faceted_brep = ifcopenshell.util.shape_builder.polygonal_face_set_to_faceted_brep(face_set) + touched_reps.update( + inv.id() for inv in self.file.get_inverse(face_set) if inv.is_a("IfcShapeRepresentation") + ) + ifcopenshell.util.element.replace_element(face_set, faceted_brep) + + for rep_id in touched_reps: + self.file.by_id(rep_id).RepresentationType = "Brep" + + def _purge_orphaned_ifc4_only_entities(self) -> None: + # Preprocessing rewires references away from source-schema-only + # carriers but does not delete the now-unreferenced instances + # themselves. Sweep iteratively so cascades collapse leaf-first + # (curves → point lists, face sets → indexed faces → point lists). + # Scoped to the actual source schema so IFC4X3 → IFC2X3 downgrades + # also catch IFC4X3-only geometry (IfcAlignmentCurve etc.), not just + # the IFC4 gap. + targets = ifcopenshell.util.schema.geometry_classes_introduced_after( + self.schema, source_schema=self.file.schema + ) + while True: + removed = False + for ifc_class in targets: + for entity in list(self.file.by_type(ifc_class)): + if not self.file.get_inverse(entity): + self.file.remove(entity) + removed = True + if not removed: + break + + def _encode_fallback_class_into_object_type(self, migrator: ifcopenshell.util.schema.Migrator) -> None: + # IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, …) migrate + # as IfcBuildingElementProxy. The subclass identity + its PredefinedType + # would otherwise be silently lost — IFC2X3 IfcBuildingElementProxy has + # no slot for them. Encode "/" into + # ObjectType when empty (don't trample author-supplied values). + for source_id, new_id in migrator.migrated_ids.items(): + try: + source = self.file.by_id(source_id) + new = self.file_patched.by_id(new_id) + except RuntimeError: + continue + if not new.is_a("IfcBuildingElementProxy"): + continue + if source.is_a("IfcBuildingElementProxy"): + continue + if getattr(new, "ObjectType", None): + continue + predef = getattr(source, "PredefinedType", None) + new.ObjectType = f"{source.is_a()}/{predef}" if predef else source.is_a() diff --git a/src/ifcpatch/test/test_DowngradeIndexedPolyCurve.py b/src/ifcpatch/test/test_DowngradeIndexedPolyCurve.py new file mode 100644 index 0000000000..adeda0b669 --- /dev/null +++ b/src/ifcpatch/test/test_DowngradeIndexedPolyCurve.py @@ -0,0 +1,137 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Bonsai Contributors +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import ifcpatch +import test.bootstrap + + +class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4): + def _make_curve(self, segments=None): + point_list = self.file.create_entity( + "IfcCartesianPointList2D", + CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], + ) + curve = self.file.create_entity( + "IfcIndexedPolyCurve", + Points=point_list, + Segments=segments, + ) + self.file.create_entity( + "IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve + ) + return curve + + def test_run_without_segments(self): + """An IfcIndexedPolyCurve with no Segments must downgrade to an + IfcPolyline through every CoordList point in order — IFC4 defines + the implicit-polyline meaning of an absent Segments list, and the + ifcopenshell shape builder emits this form for simple open curves.""" + self._make_curve(segments=None) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + assert len(polylines[0].Points) == 3 + + def test_run_with_line_segments(self): + """Line-segmented IfcIndexedPolyCurves downgrade to an equivalent IfcPolyline.""" + segments = [ + self.file.createIfcLineIndex((1, 2)), + self.file.createIfcLineIndex((2, 3)), + ] + self._make_curve(segments=segments) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + assert len(polylines[0].Points) == 3 + + def test_run_with_multi_index_line_segment(self): + """An IfcLineIndex with >2 indices encodes a polyline through every + index — the downgraded IfcPolyline must include every one of them. + This is the canonical form Bonsai's shape builder emits for closed + rectangle profiles (e.g. parametric wall body outlines), serialised + as ``IfcIndexedPolyCurve(Points, (IfcLineIndex((1,2,3,4,1))))``.""" + point_list = self.file.create_entity( + "IfcCartesianPointList2D", + CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], + ) + curve = self.file.create_entity( + "IfcIndexedPolyCurve", + Points=point_list, + Segments=[self.file.createIfcLineIndex((1, 2, 3, 4, 1))], + ) + self.file.create_entity( + "IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve + ) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + assert len(polylines[0].Points) == 5 + coords = [p.Coordinates for p in polylines[0].Points] + assert coords[0] == coords[-1] == (0.0, 0.0) + assert coords[1] == (1.0, 0.0) + assert coords[2] == (1.0, 1.0) + assert coords[3] == (0.0, 1.0) + + def test_run_with_chained_multi_index_segments(self): + """When two IfcLineIndex segments are chained, the shared endpoint + between them must appear once, not twice.""" + point_list = self.file.create_entity( + "IfcCartesianPointList2D", + CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], + ) + curve = self.file.create_entity( + "IfcIndexedPolyCurve", + Points=point_list, + Segments=[ + self.file.createIfcLineIndex((1, 2, 3)), + self.file.createIfcLineIndex((3, 4)), + ], + ) + self.file.create_entity( + "IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve + ) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + coords = [p.Coordinates for p in polylines[0].Points] + assert coords == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + + def test_run_facets_arc_segments(self): + """Arc-segmented IfcIndexedPolyCurves are downgraded by sampling the + circular arc into a chord polyline. The chord count is fixed by + the recipe's subdivision parameter.""" + from ifcpatch.recipes.DowngradeIndexedPolyCurve import ARC_SUBDIVISION + + segments = [self.file.createIfcArcIndex((1, 2, 3))] + self._make_curve(segments=segments) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + assert len(polylines[0].Points) == ARC_SUBDIVISION + 1 diff --git a/src/ifcpatch/test/test_Migrate.py b/src/ifcpatch/test/test_Migrate.py index 85e60ff09f..23930e6283 100644 --- a/src/ifcpatch/test/test_Migrate.py +++ b/src/ifcpatch/test/test_Migrate.py @@ -17,6 +17,9 @@ # along with IfcOpenShell. If not, see . +import pytest + +import ifcopenshell.api.project import ifcpatch import test.bootstrap @@ -27,3 +30,181 @@ class TestMigrate(test.bootstrap.IFC4): old_file.header.file_name.name = "test" new_file = ifcpatch.execute({"file": old_file, "recipe": "Migrate", "arguments": ["IFC4"]}) assert new_file.header.file_name.name == "test" + + def test_migrate_ifc4_to_ifc2x3_flattens_indexed_polycurve(self): + """Downgrade IFC4 → IFC2X3 should auto-run DowngradeIndexedPolyCurve on + IfcIndexedPolyCurve carriers, so the migrated file uses IfcPolyline (which + exists in IFC2X3) instead of crashing on the IFC4-only curve class.""" + ifc4_file = self.file + point_list = ifc4_file.create_entity( + "IfcCartesianPointList2D", + CoordList=((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)), + ) + segments = [ + ifc4_file.create_entity("IfcLineIndex", (1, 2)), + ifc4_file.create_entity("IfcLineIndex", (2, 3)), + ifc4_file.create_entity("IfcLineIndex", (3, 4)), + ifc4_file.create_entity("IfcLineIndex", (4, 1)), + ] + curve = ifc4_file.create_entity( + "IfcIndexedPolyCurve", Points=point_list, Segments=segments, SelfIntersect=False + ) + ifc4_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve) + + new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + assert new_file.schema == "IFC2X3" + new_profile = new_file.by_type("IfcArbitraryClosedProfileDef")[0] + assert new_profile.OuterCurve.is_a("IfcPolyline") + # The preprocessing step should have purged orphaned IFC4-only entities + # from the source before the migration loop reached them. + assert not ifc4_file.by_type("IfcIndexedPolyCurve") + assert not ifc4_file.by_type("IfcCartesianPointList2D") + + def test_migrate_ifc4_to_ifc2x3_encodes_fallback_class_in_object_type(self): + """IfcLamp / IfcPipeSegment / IfcGeographicElement fall back to + IfcBuildingElementProxy on downgrade. The original class and + PredefinedType are encoded into ObjectType so the type info survives + — but only when ObjectType is empty (author-supplied values stay).""" + ifc4_file = self.file + ifc4_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT") + ifc4_file.create_entity("IfcPipeSegment", GlobalId="0_bkftCTnBCOOZeUxtJngE") + ifc4_file.create_entity( + "IfcGeographicElement", + GlobalId="3_b4gD1aP3ARmIm2ePijXi", + ObjectType="Terrain Mesh", # author-supplied, must not be overwritten + PredefinedType="TERRAIN", + ) + + new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + proxies = {p.GlobalId: p for p in new_file.by_type("IfcBuildingElementProxy")} + # IfcLamp with no author ObjectType: encoded as IfcLamp/COMPACTFLUORESCENT. + assert proxies["2K6Z3DR8X37AS9XFvX8GcW"].ObjectType == "IfcLamp/COMPACTFLUORESCENT" + # IfcPipeSegment with no PredefinedType set: just the class name. + assert proxies["0_bkftCTnBCOOZeUxtJngE"].ObjectType == "IfcPipeSegment" + # IfcGeographicElement with author ObjectType: preserved as-is. + assert proxies["3_b4gD1aP3ARmIm2ePijXi"].ObjectType == "Terrain Mesh" + + def test_migrate_ifc4_to_ifc2x3_converts_polygonal_face_set_to_faceted_brep(self): + """IfcPolygonalFaceSet has no IFC2X3 equivalent. Direct entity-level + conversion produces an IfcFacetedBrep with the same topology, regardless + of which representation context the source lived in.""" + ifc4_file = self.file + coords = ifc4_file.create_entity( + "IfcCartesianPointList3D", + CoordList=( + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 0.0), + (0.5, 0.5, 1.0), + ), + ) + # Square base + 4 triangle sides — a simple pyramid. + faces = [ + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(1, 2, 3, 4)), + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(1, 2, 5)), + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(2, 3, 5)), + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(3, 4, 5)), + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(4, 1, 5)), + ] + face_set = ifc4_file.create_entity("IfcPolygonalFaceSet", Coordinates=coords, Faces=faces) + context = ifc4_file.create_entity( + "IfcGeometricRepresentationContext", + ContextType="Model", + CoordinateSpaceDimension=3, + Precision=0.01, + WorldCoordinateSystem=ifc4_file.createIfcAxis2Placement3D( + Location=ifc4_file.createIfcCartesianPoint((0.0, 0.0, 0.0)) + ), + ) + ifc4_file.create_entity( + "IfcShapeRepresentation", + ContextOfItems=context, + RepresentationIdentifier="Body", + RepresentationType="Tessellation", + Items=[face_set], + ) + + new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + assert new_file.schema == "IFC2X3" + breps = new_file.by_type("IfcFacetedBrep") + assert len(breps) == 1 + brep = breps[0] + assert len(brep.Outer.CfsFaces) == 5 + # Coordinates from the source CartesianPointList3D must appear in the + # resulting brep's loop points — otherwise the conversion silently + # corrupted geometry. + brep_coords = {tuple(p.Coordinates) for face in brep.Outer.CfsFaces for p in face.Bounds[0].Bound.Polygon} + for expected in ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)): + assert expected in brep_coords, f"vertex {expected} missing from converted brep" + rep = new_file.by_type("IfcShapeRepresentation")[0] + assert rep.RepresentationType == "Brep" + assert rep.Items[0].is_a("IfcFacetedBrep") + + def test_migrate_ifc4_to_ifc2x3_summarises_unmappable_entities(self): + """When an IFC4-only entity that cannot be auto-substituted survives + preprocessing, the recipe must surface a summary RuntimeError naming + the failing class — not the cryptic ``RuntimeError: Entity with name + '' not found``. + + Uses ``IfcWorkCalendar`` as the fixture — an IFC4 entity that + (a) is not an IfcRepresentationItem (skips the geometry purge), + (b) is not an IfcElement (skips the proxy fallback), + (c) has no IFC2X3 equivalent in ``class_4_to_2x3.json`` (mapped to ``""``). + These three conditions together guarantee it always reaches the + unmappable error path, independent of future schema additions.""" + ifc4_file = self.file + ifc4_file.create_entity("IfcWorkCalendar", GlobalId="2K6Z3DR8X37AS9XFvX8GcW") + + with pytest.raises(RuntimeError) as exc_info: + ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + message = str(exc_info.value) + assert "IfcWorkCalendar" in message + + def test_migrate_ifc4x3_to_ifc2x3_runs_downgrade_preprocessing(self): + """IFC4X3 → IFC2X3 must trigger the same downgrade preprocessing as + IFC4 → IFC2X3: curve flatten, face-set → brep, IfcBuildingElementProxy + fallback, ObjectType encoding. Pins the gate at + ``self.file.schema in ('IFC4', 'IFC4X3')`` — a narrower check would + silently leave IFC4X3 sources crashing on IFC4-only geometry.""" + ifc4x3_file = ifcopenshell.api.project.create_file(version="IFC4X3") + ifc4x3_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT") + + new_file = ifcpatch.execute({"file": ifc4x3_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + assert new_file.schema == "IFC2X3" + proxies = new_file.by_type("IfcBuildingElementProxy") + assert len(proxies) == 1 + # ObjectType encoding ran — same as the IFC4 → IFC2X3 case. + assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT" + + def test_migrate_ifc4_to_ifc2x3_flattens_arc_bearing_indexed_polycurve(self): + """An IfcIndexedPolyCurve with IfcArcIndex segments is approximated + with a chord polyline rather than skipped, so the parent profile def + and its representations stay parametric (no fallback to tessellation).""" + ifc4_file = self.file + point_list = ifc4_file.create_entity( + "IfcCartesianPointList2D", + CoordList=((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)), + ) + # Two half-arcs forming a circle: (1,0)→(0,1)→(-1,0)→(0,-1)→(1,0). + segments = [ + ifc4_file.create_entity("IfcArcIndex", (1, 2, 3)), + ifc4_file.create_entity("IfcArcIndex", (3, 4, 1)), + ] + curve = ifc4_file.create_entity( + "IfcIndexedPolyCurve", Points=point_list, Segments=segments, SelfIntersect=False + ) + ifc4_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve) + + new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + assert new_file.schema == "IFC2X3" + new_profile = new_file.by_type("IfcArbitraryClosedProfileDef")[0] + assert new_profile.OuterCurve.is_a("IfcPolyline") + # Arc subdivision should produce many more points than the 4 input coords. + assert len(new_profile.OuterCurve.Points) > 4 From 2ab5ca9222913be0f26733aecbbfaddebbb4ae3b Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 09:46:31 +0200 Subject: [PATCH 3/4] ifcpatch: small recipe polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExtractElements: expand the `query` docstring to cover the exclusion syntax (`!` on entity classes, `!=` on attribute / pset / material / classification / location / group facets) and the "seed with a broad include before subtracting" gotcha — entity-class exclusion does not auto-seed from "all elements", so a bare `! IfcSlab` query returns nothing. FixArchiCADToRevitDoorSwings: guard the `IfcIndexedPolyCurve.Segments` loop against the IFC4 case where Segments is absent (a polyline through all coords in declared order). Previously crashed on `None.__iter__`. Generated with the assistance of an AI coding tool. --- src/ifcpatch/ifcpatch/recipes/ExtractElements.py | 15 ++++++++++++++- .../recipes/FixArchiCADToRevitDoorSwings.py | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py index 10d8b23330..132d86d436 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py @@ -41,7 +41,14 @@ class Patcher(ifcpatch.BasePatcher): to a new IFC file. For example, you might want to extract only the walls in a model and save it as a new model. - :param query: A query to select the subset of IFC elements. + :param query: A query to select the subset of IFC elements, using the + ifcopenshell.util.selector.filter_elements grammar. Supports + exclusion (blacklist) via '!' on entity classes and '!=' on + attribute / pset / material / classification / location / group + facets. Entity-class exclusion does not auto-seed from "all + elements", so a bare '! IfcSlab' query returns nothing — start + with a broad include (e.g. 'IfcProduct', 'IfcElement') and + subtract from it. :param assume_asset_uniqueness_by_name: Avoid adding assets (profiles, materials, styles) with the same name multiple times. Which helps in avoiding duplicated assets. ----- @@ -63,6 +70,12 @@ class Patcher(ifcpatch.BasePatcher): # Extract all walls and slabs ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, IfcSlab"]}) + + # Extract everything except slabs (seed with a broad include, then subtract) + ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcProduct, ! IfcSlab"]}) + + # Extract walls whose Name is not "Foo" + ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, attribute.Name != \"Foo\""]}) """ super().__init__(file, logger) self.query = query diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py index c2523c7e8f..2666b4611f 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py @@ -190,6 +190,8 @@ class Patcher(ifcpatch.BasePatcher): settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) for curve in self.file.by_type("IfcIndexedPolyCurve"): + if curve.Segments is None: + continue if True in [s.is_a("IfcArcIndex") for s in curve.Segments]: shape = ifcopenshell.geom.create_shape(settings, curve) e = shape.edges From 44c0c2916ccf32deaefeec4e73137f8ad6b45039 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 09:48:54 +0200 Subject: [PATCH 4/4] Bonsai patch: lossy-downgrade popup + per-recipe preset menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new UX features in the IFC Patch panel, both backed by helpers on bonsai.tool.Patch. Lossy-downgrade confirmation popup. When the user picks the Migrate recipe with a target schema older than the source's (IFC4 -> IFC2X3, IFC4X3 -> IFC2X3), ExecuteIfcPatch.invoke shows a properties dialog listing what's preserved vs lost: IfcIndexedPolyCurve flattened with arcs approximated, IfcPolygonalFaceSet / IfcTriangulatedFaceSet converted to IfcFacetedBrep, IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, ...) demoted to IfcBuildingElementProxy with the original class + PredefinedType encoded into ObjectType, and PredefinedType enum values absent from IFC2X3 dropped. The user explicitly approves before the recipe runs. The popup is gated on tool.Patch.migration_is_lossy_downgrade() which resolves the source schema via header-only parsing (tool.Patch._patch_source_schema reads the first ~2KB and matches a FILE_SCHEMA regex, then normalises via ifcopenshell.util.schema. get_fallback_schema). Avoids a full ifcopenshell.open() on every Execute click — multi-second saving on large files. The target schema is looked up by argument name rather than position so it survives recipe-parameter reordering. Per-recipe preset menu. New BIM_MT_ifc_patch_presets + AddIfcPatchPreset wire Blender's standard preset system into the panel. Each recipe gets its own preset subdirectory (bonsai/ifc_patch//), so a preset saved for ExtractElements does not pollute the Migrate preset list. The preset operator uses Attribute.get_value_name() (single source of truth for data_type -> storage-field mapping) to build the preset_values list dynamically per recipe. The recipe-change callback resets BIM_MT_ifc_patch_presets.bl_label to the canonical title — Blender's script.execute_preset mutates the menu's bl_label to the loaded preset's name as a "currently-selected" indicator, and without an explicit reset the previous recipe's preset name would falsely advertise itself in the new recipe's menu. tool.Patch gains get_preset_subdir, migration_is_lossy_downgrade, _patch_source_schema as cross-cutting helpers. _SCHEMA_AGE module constant provides the ordering used by the downgrade-detection predicate. Test coverage: 12 bim-lane tests under test/bim/module/patch/. The truth table for migration_is_lossy_downgrade covers IFC4/IFC4X3 source x downgrade/upgrade/same-schema target x Migrate/non-Migrate recipe. The schema-sniffing tests write a real IFC4X3_ADD2 file to disk and assert the helper resolves it to IFC4X3 (regression for the original startswith iteration-order bug). An end-to-end test drives bpy.ops.bim.execute_ifc_patch with an in-memory IfcLamp source and verifies the on-disk IFC2X3 file contains a single IfcBuildingElementProxy with ObjectType "IfcLamp/COMPACTFLUORESCENT" and the original GlobalId preserved. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/patch/__init__.py | 2 + .../bonsai/bim/module/patch/operator.py | 57 ++++++++ src/bonsai/bonsai/bim/module/patch/prop.py | 9 ++ src/bonsai/bonsai/bim/module/patch/ui.py | 19 +++ src/bonsai/bonsai/tool/patch.py | 72 ++++++++++ src/bonsai/test/bim/module/patch/__init__.py | 0 .../patch/test_execute_downgrade_e2e.py | 70 ++++++++++ .../bim/module/patch/test_lossy_downgrade.py | 131 ++++++++++++++++++ .../module/patch/test_preset_label_reset.py | 55 ++++++++ 9 files changed, 415 insertions(+) create mode 100644 src/bonsai/test/bim/module/patch/__init__.py create mode 100644 src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py create mode 100644 src/bonsai/test/bim/module/patch/test_lossy_downgrade.py create mode 100644 src/bonsai/test/bim/module/patch/test_preset_label_reset.py diff --git a/src/bonsai/bonsai/bim/module/patch/__init__.py b/src/bonsai/bonsai/bim/module/patch/__init__.py index fd5de30d38..903e29da6d 100644 --- a/src/bonsai/bonsai/bim/module/patch/__init__.py +++ b/src/bonsai/bonsai/bim/module/patch/__init__.py @@ -21,6 +21,7 @@ import bpy from . import operator, prop, ui classes = ( + operator.AddIfcPatchPreset, operator.ExecuteIfcPatch, operator.ExtractSelectedElements, operator.RunMigratePatch, @@ -28,6 +29,7 @@ classes = ( operator.SelectIfcPatchOutput, operator.UpdateIfcPatchArguments, prop.BIMPatchProperties, + ui.BIM_MT_ifc_patch_presets, ui.BIM_PT_patch, ) diff --git a/src/bonsai/bonsai/bim/module/patch/operator.py b/src/bonsai/bonsai/bim/module/patch/operator.py index 99459b99e9..531b7c581c 100644 --- a/src/bonsai/bonsai/bim/module/patch/operator.py +++ b/src/bonsai/bonsai/bim/module/patch/operator.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast import bpy import ifcopenshell import ifcpatch +from bl_operators.presets import AddPresetBase from bpy_extras.io_utils import ExportHelper, ImportHelper import bonsai.bim.handler @@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator): return False return True + def invoke(self, context, event): + # Migrating IFC4 → IFC2X3 is lossy (enum drops, IFC4-only classes + # become IfcBuildingElementProxy, tessellated meshes get rebuilt as + # IfcFacetedBrep). Confirm before running so the user knows. + if tool.Patch.migration_is_lossy_downgrade(): + return context.window_manager.invoke_props_dialog(self, width=480) + return self.execute(context) + + def draw(self, context): + layout = self.layout + layout.label(text="Downgrading to IFC2X3 is lossy.", icon="ERROR") + column = layout.column(align=True) + column.label(text="Geometry will be preserved as faithfully as possible:") + column.label(text="• IfcIndexedPolyCurve → IfcPolyline (arcs approximated by chords)") + column.label(text="• IfcPolygonalFaceSet / IfcTriangulatedFaceSet → IfcFacetedBrep") + column.separator() + column.label(text="The following information is lost:") + column.label(text="• IFC4-only classes (IfcLamp, IfcPipeSegment, …) → IfcBuildingElementProxy") + column.label(text="• PredefinedType enum values absent from IFC2X3 are dropped") + column.label(text=" (original class + enum saved as ObjectType, e.g. 'IfcLamp/COMPACTFLUORESCENT')") + def execute(self, context): props = tool.Patch.get_patch_props() recipe_name = props.ifc_patch_recipes @@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator): query = tool.Search.get_query_for_selected_elements() props.ifc_patch_args_attr[0].string_value = query return {"FINISHED"} + + +class AddIfcPatchPreset(AddPresetBase, bpy.types.Operator): + """Save / remove ifc-patch argument presets, scoped per recipe. + + Presets live in the standard Blender preset directory under + ``bonsai/ifc_patch//`` so a preset created for ``ExtractElements`` + does not pollute the preset list for ``Migrate``. Persistence across files + and sessions is inherited from Blender's preset system.""" + + bl_idname = "bim.add_ifc_patch_preset" + bl_label = "Add IFC Patch Preset" + preset_menu = "BIM_MT_ifc_patch_presets" + preset_defines = ["props = bpy.context.scene.BIMPatchProperties"] + + @property + def preset_subdir(self) -> str: + return tool.Patch.get_preset_subdir() + + @property + def preset_values(self) -> list[str]: + # `Attribute.get_value_name()` returns the storage field for the + # argument's data_type (string_value, bool_value, …). For file + # arguments it returns the wrapping PointerProperty (`filepath_value`) + # — the scalar path the preset needs is `.single_file` on that. + props = tool.Patch.get_patch_props() + values = [] + for i, arg in enumerate(props.ifc_patch_args_attr): + field = arg.get_value_name() + if not field: + continue + if arg.data_type == "file": + field = f"{field}.single_file" + values.append(f"props.ifc_patch_args_attr[{i}].{field}") + return values diff --git a/src/bonsai/bonsai/bim/module/patch/prop.py b/src/bonsai/bonsai/bim/module/patch/prop.py index e14bb3b1ef..ae9793c01d 100644 --- a/src/bonsai/bonsai/bim/module/patch/prop.py +++ b/src/bonsai/bonsai/bim/module/patch/prop.py @@ -71,6 +71,15 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context) def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None: bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes) + # Blender's script.execute_preset mutates the menu class's bl_label to + # the loaded preset's display name (used as a "currently selected" + # indicator). The label persists across recipe changes — making the new + # recipe's menu falsely show the previous recipe's preset name. Reset + # the label to the menu's canonical title so it always matches the + # active recipe's preset list. + menu_cls = getattr(bpy.types, "BIM_MT_ifc_patch_presets", None) + if menu_cls is not None: + menu_cls.bl_label = "IFC Patch Presets" class BIMPatchProperties(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/module/patch/ui.py b/src/bonsai/bonsai/bim/module/patch/ui.py index c3101b4705..98c262bb1b 100644 --- a/src/bonsai/bonsai/bim/module/patch/ui.py +++ b/src/bonsai/bonsai/bim/module/patch/ui.py @@ -29,6 +29,20 @@ if TYPE_CHECKING: from bonsai.bim.prop import Attribute +class BIM_MT_ifc_patch_presets(bpy.types.Menu): + """Lists ifc-patch presets for the currently selected recipe. + + ``preset_subdir`` is resolved per draw so switching recipes swaps the + preset list without re-registering the menu.""" + + bl_label = "IFC Patch Presets" + preset_operator = "script.execute_preset" + + def draw(self, context: bpy.types.Context) -> None: + self.preset_subdir = tool.Patch.get_preset_subdir() + bpy.types.Menu.draw_preset(self, context) + + class BIM_PT_patch(bpy.types.Panel): bl_label = "Patch" bl_idname = "BIM_PT_patch" @@ -66,6 +80,11 @@ class BIM_PT_patch(bpy.types.Panel): row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER") if props.ifc_patch_args_attr: + preset_row = layout.row(heading="Preset", align=True) + preset_row.menu("BIM_MT_ifc_patch_presets", text=BIM_MT_ifc_patch_presets.bl_label) + preset_row.operator("bim.add_ifc_patch_preset", text="", icon="ADD") + preset_row.operator("bim.add_ifc_patch_preset", text="", icon="REMOVE").remove_active = True + draw_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback) diff --git a/src/bonsai/bonsai/tool/patch.py b/src/bonsai/bonsai/tool/patch.py index 6ae06b5e10..6d8ca11939 100644 --- a/src/bonsai/bonsai/tool/patch.py +++ b/src/bonsai/bonsai/tool/patch.py @@ -18,18 +18,33 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, Any import bpy import ifcopenshell +import ifcopenshell.util.schema import ifcpatch import bonsai.core.tool +import bonsai.tool if TYPE_CHECKING: from bonsai.bim.module.patch.prop import BIMPatchProperties +# Lower index = older schema. Used to detect downgrades vs upgrades. +_SCHEMA_AGE = {"IFC2X3": 0, "IFC4": 1, "IFC4X3": 2} + +# Pretty-printed argument name for the ``Migrate`` recipe's schema parameter +# (see UpdateIfcPatchArguments.pretty_arg_name in bim/module/patch/operator.py). +_MIGRATE_SCHEMA_ARG_NAME = "Schema" + +# Match a STEP-encoded FILE_SCHEMA header: ``FILE_SCHEMA(('IFC4'));`` and the +# IFC4X3_ADD2 / IFC2X3_TC1 variants. Captures the bare schema identifier. +_IFC_FILE_SCHEMA_RE = re.compile(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", re.IGNORECASE) + + class Patch(bonsai.core.tool.Patch): @classmethod def get_patch_props(cls) -> BIMPatchProperties: @@ -54,6 +69,63 @@ class Patch(bonsai.core.tool.Patch): "SplitByBuildingStorey", ) + @classmethod + def get_preset_subdir(cls) -> str: + """Resolve the preset subdirectory for the currently selected recipe. + + Returns a stable string for the ``-`` placeholder so the menu and save + operator remain usable when no real recipe has been picked yet.""" + recipe = cls.get_patch_props().ifc_patch_recipes or "-" + return f"bonsai/ifc_patch/{recipe}" + + @classmethod + def migration_is_lossy_downgrade(cls) -> bool: + """``True`` when the currently configured patch is the ``Migrate`` + recipe targeting an older schema than the input file. Used to gate + the destructive-migration confirmation dialog.""" + props = cls.get_patch_props() + if props.ifc_patch_recipes != "Migrate": + return False + target_schema = next( + (arg.get_value() for arg in props.ifc_patch_args_attr if arg.name == _MIGRATE_SCHEMA_ARG_NAME), + None, + ) + if not target_schema: + return False + source_schema = cls._patch_source_schema() + if not source_schema: + return False + return _SCHEMA_AGE.get(target_schema, -1) < _SCHEMA_AGE.get(source_schema, -1) + + @classmethod + def _patch_source_schema(cls) -> str: + """Resolve the IFC schema of the configured input without parsing the + full file. For loaded-from-memory the schema is in the entity_instance + wrapper; for disk paths we read only the STEP file header (first ~2KB) + rather than ``ifcopenshell.open`` which parses the whole file.""" + props = cls.get_patch_props() + if props.should_load_from_memory: + ifc_file = bonsai.tool.Ifc.get() + return ifc_file.schema if ifc_file else "" + if not props.ifc_patch_input: + return "" + try: + with open(props.ifc_patch_input, "rb") as f: + header = f.read(2048).decode("utf-8", errors="ignore") + except OSError: + return "" + match = _IFC_FILE_SCHEMA_RE.search(header) + if not match: + return "" + # Collapse IFC4X3_ADD2 / IFC2X3_TC1 / IFC4_ADD2 / IFC4X1 etc. to their + # base via the canonical normaliser — handles longest-prefix-first + # ordering correctly (IFC4X3 before IFC4) so we don't misclassify + # IFC4X3 files as IFC4. + try: + return ifcopenshell.util.schema.get_fallback_schema(match.group(1).upper()) + except AssertionError: + return "" + @classmethod def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]: if recipe == "ExtractElements": diff --git a/src/bonsai/test/bim/module/patch/__init__.py b/src/bonsai/test/bim/module/patch/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py b/src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py new file mode 100644 index 0000000000..aa9600a045 --- /dev/null +++ b/src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py @@ -0,0 +1,70 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import tempfile +from pathlib import Path + +import bpy +import ifcopenshell +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.patch + + +class TestExecuteIfcPatchDowngradeEndToEnd(NewFile): + """Drives the full panel flow the user sees: load IFC4 in memory, pick + Migrate + IFC2X3, click Execute, get an IFC2X3 file on disk with the + expected IfcBuildingElementProxy fallback + ObjectType encoding. + + A regression here means a real user clicking Execute either crashes + Blender, produces a broken file, or silently drops type information + that the recipe is supposed to preserve via ObjectType.""" + + def test_ifc4_with_ifclamp_downgrades_to_ifc2x3_with_proxy_and_object_type(self): + ifc = ifcopenshell.file(schema="IFC4") + ifc.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT") + tool.Ifc.set(ifc) + + props = tool.Patch.get_patch_props() + props.should_load_from_memory = True + props.ifc_patch_recipes = "Migrate" + next(a for a in props.ifc_patch_args_attr if a.name == "Schema").enum_value = "IFC2X3" + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "downgraded.ifc" + props.ifc_patch_output = str(output_path) + + result = bpy.ops.bim.execute_ifc_patch() + + assert result == {"FINISHED"} + assert output_path.exists(), "Recipe ran but no output file was written" + + written = ifcopenshell.open(str(output_path)) + assert written.schema == "IFC2X3" + proxies = written.by_type("IfcBuildingElementProxy") + assert len(proxies) == 1, "IfcLamp should fall back to a single IfcBuildingElementProxy" + assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT", ( + "Original class + PredefinedType must be encoded into ObjectType " + "so the downgrade isn't a total information loss" + ) + assert proxies[0].GlobalId == "2K6Z3DR8X37AS9XFvX8GcW" diff --git a/src/bonsai/test/bim/module/patch/test_lossy_downgrade.py b/src/bonsai/test/bim/module/patch/test_lossy_downgrade.py new file mode 100644 index 0000000000..03150ac7f7 --- /dev/null +++ b/src/bonsai/test/bim/module/patch/test_lossy_downgrade.py @@ -0,0 +1,131 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import tempfile +from pathlib import Path + +import bpy +import ifcopenshell +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.patch + + +def _set_patch_state(*, recipe: str, target_schema: str | None, source_ifc: ifcopenshell.file | None = None) -> None: + """Drive the BIMPatchProperties into the configuration that a user produces + by picking Recipe + Schema in the panel + checking "Load from memory". + Setting the recipe fires UpdateIfcPatchArguments which builds the dynamic + args collection — only then can we assign the schema arg's enum_value.""" + props = tool.Patch.get_patch_props() + if source_ifc is not None: + tool.Ifc.set(source_ifc) + props.should_load_from_memory = True + props.ifc_patch_recipes = recipe # update callback builds ifc_patch_args_attr + if target_schema is not None: + schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema") + schema_arg.enum_value = target_schema + + +class TestMigrationIsLossyDowngrade(NewFile): + """Pins the predicate that gates ``ExecuteIfcPatch.invoke``'s + confirmation popup. Every row of the truth table corresponds to a real + user-facing flow — wrong answers either nag the user on safe migrations + or silently let lossy ones through with no warning.""" + + def test_ifc4_to_ifc2x3_in_memory_is_lossy(self): + ifc = ifcopenshell.file(schema="IFC4") + _set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is True + + def test_ifc4x3_to_ifc2x3_in_memory_is_lossy(self): + # Regression for the gate that originally only fired for self.file.schema == "IFC4", + # silently leaving IFC4X3 sources crashing on IFC4-only geometry. + ifc = ifcopenshell.file(schema="IFC4X3") + _set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is True + + def test_ifc2x3_to_ifc4_upgrade_is_not_lossy(self): + ifc = ifcopenshell.file(schema="IFC2X3") + _set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is False + + def test_ifc4_to_ifc4_same_schema_is_not_lossy(self): + ifc = ifcopenshell.file(schema="IFC4") + _set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is False + + def test_non_migrate_recipe_is_not_lossy(self): + # The popup only ever applies to the Migrate recipe — other recipes + # (ExtractElements, TessellateElements, …) handle their own warnings. + ifc = ifcopenshell.file(schema="IFC4") + _set_patch_state(recipe="ExtractElements", target_schema=None, source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is False + + def test_no_source_set_is_not_lossy(self): + # Without an input file or in-memory IFC, the predicate cannot tell + # what the source schema is — defaults to False so the popup doesn't + # block harmless cases where the user is still configuring the panel. + props = tool.Patch.get_patch_props() + props.ifc_patch_recipes = "Migrate" + schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema") + schema_arg.enum_value = "IFC2X3" + assert tool.Patch.migration_is_lossy_downgrade() is False + + +class TestPatchSourceSchemaSniff(NewFile): + """End-to-end pin on the header-only schema parsing. The IFC4X3 misdetection + bug originally lived in this code path — a raw startswith(\"IFC4\") loop + matching IFC4X3_ADD2 before the IFC4X3 base check was reached.""" + + def test_in_memory_ifc4x3_source_resolves_to_ifc4x3(self): + ifc = ifcopenshell.file(schema="IFC4X3") + tool.Ifc.set(ifc) + props = tool.Patch.get_patch_props() + props.should_load_from_memory = True + assert tool.Patch._patch_source_schema() == "IFC4X3" + + def test_file_path_ifc4x3_add2_source_resolves_to_ifc4x3(self): + # Writes a real .ifc file with IFC4X3_ADD2 in the FILE_SCHEMA header + # and confirms the regex + get_fallback_schema normaliser correctly + # collapse it to IFC4X3, not IFC4. + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = Path(tmpdir) / "sample.ifc" + ifc_path.write_text( + "ISO-10303-21;\n" + "HEADER;\n" + "FILE_DESCRIPTION((''),'2;1');\n" + "FILE_NAME('','2026',(''),(''),'','','');\n" + "FILE_SCHEMA(('IFC4X3_ADD2'));\n" + "ENDSEC;\n" + "DATA;\nENDSEC;\nEND-ISO-10303-21;\n" + ) + props = tool.Patch.get_patch_props() + props.should_load_from_memory = False + props.ifc_patch_input = str(ifc_path) + assert tool.Patch._patch_source_schema() == "IFC4X3" + + def test_missing_input_returns_empty_string(self): + props = tool.Patch.get_patch_props() + props.should_load_from_memory = False + props.ifc_patch_input = "" + assert tool.Patch._patch_source_schema() == "" diff --git a/src/bonsai/test/bim/module/patch/test_preset_label_reset.py b/src/bonsai/test/bim/module/patch/test_preset_label_reset.py new file mode 100644 index 0000000000..3b900e8290 --- /dev/null +++ b/src/bonsai/test/bim/module/patch/test_preset_label_reset.py @@ -0,0 +1,55 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import bpy +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.patch + + +class TestPresetMenuLabelResetsOnRecipeChange(NewFile): + """Blender's ``script.execute_preset`` mutates the menu class's bl_label + to the loaded preset's display name as a "currently-selected" indicator. + Without a recipe-change callback, that label persists into the next + recipe's menu — falsely advertising a preset that belongs to a + different recipe's subdir and isn't selectable from the new menu.""" + + def test_changing_recipe_restores_canonical_label(self): + # Simulate the state Blender leaves after the user picked a preset + # for the previous recipe. + menu_cls = bpy.types.BIM_MT_ifc_patch_presets + menu_cls.bl_label = "Structural" + + # Switching the recipe must fire update_ifc_patch_recipe, which + # resets the menu label. + props = tool.Patch.get_patch_props() + props.ifc_patch_recipes = "Migrate" + + assert menu_cls.bl_label == "IFC Patch Presets" + + def test_canonical_label_is_used_when_no_preset_was_loaded(self): + # Fresh state — label is the bl_label-default from the class declaration. + menu_cls = bpy.types.BIM_MT_ifc_patch_presets + props = tool.Patch.get_patch_props() + props.ifc_patch_recipes = "ExtractElements" + assert menu_cls.bl_label == "IFC Patch Presets"