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
@@ -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