mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-14 19:34:34 +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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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