mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-18 14:31:39 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user