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