ifcpatch Migrate: defensive IFC4/IFC4X3 -> IFC2X3 downgrade

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 "<OriginalClass>/<PredefinedType>" 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.
This commit is contained in:
Gorgious56
2026-06-23 09:33:03 +02:00
parent a2dafc9ceb
commit f710929e9e
4 changed files with 499 additions and 19 deletions
@@ -17,6 +17,12 @@
# along with IfcPatch. If not, see <http://www.gnu.org/licenses/>.
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
+133 -7
View File
@@ -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 "<OriginalClass>/<PredefinedType>" 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()
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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
+181
View File
@@ -17,6 +17,9 @@
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
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