From f710929e9e1914f2680c7783009c5969e9515a61 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 09:33:03 +0200 Subject: [PATCH] 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