Merge remote-tracking branch 'origin/v0.8.0' into ifcviewer-wgpu

This commit is contained in:
Thomas Krijnen
2026-07-09 13:21:39 +02:00
373 changed files with 22411 additions and 4242 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
@@ -41,7 +41,14 @@ class Patcher(ifcpatch.BasePatcher):
to a new IFC file. For example, you might want to extract only the walls
in a model and save it as a new model.
:param query: A query to select the subset of IFC elements.
:param query: A query to select the subset of IFC elements, using the
ifcopenshell.util.selector.filter_elements grammar. Supports
exclusion (blacklist) via '!' on entity classes and '!=' on
attribute / pset / material / classification / location / group
facets. Entity-class exclusion does not auto-seed from "all
elements", so a bare '! IfcSlab' query returns nothing — start
with a broad include (e.g. 'IfcProduct', 'IfcElement') and
subtract from it.
:param assume_asset_uniqueness_by_name: Avoid adding assets (profiles, materials, styles)
with the same name multiple times. Which helps in avoiding duplicated assets.
-----
@@ -63,6 +70,12 @@ class Patcher(ifcpatch.BasePatcher):
# Extract all walls and slabs
ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, IfcSlab"]})
# Extract everything except slabs (seed with a broad include, then subtract)
ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcProduct, ! IfcSlab"]})
# Extract walls whose Name is not "Foo"
ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, attribute.Name != \"Foo\""]})
"""
super().__init__(file, logger)
self.query = query
@@ -96,7 +109,11 @@ class Patcher(ifcpatch.BasePatcher):
except:
pass
if element.is_a("IfcProject"):
return self.new.add(element)
proj = self.new.add(element)
for ctx in element.RepresentationContexts or ():
for coop in getattr(ctx, 'HasCoordinateOperation', ()):
self.new.add(coop)
return proj
return ifcopenshell.api.project.append_asset(
self.new,
library=self.file,
@@ -190,6 +190,8 @@ class Patcher(ifcpatch.BasePatcher):
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file)
for curve in self.file.by_type("IfcIndexedPolyCurve"):
if curve.Segments is None:
continue
if True in [s.is_a("IfcArcIndex") for s in curve.Segments]:
shape = ifcopenshell.geom.create_shape(settings, curve)
e = shape.edges
+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
+38 -1
View File
@@ -20,9 +20,13 @@ import os
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.georeference
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.util.element
import numpy
import pytest
import ifcpatch
@@ -74,11 +78,44 @@ class TestExtractElements(test.bootstrap.IFC4):
assert ifcopenshell.util.element.get_container(assembly).GlobalId == container.GlobalId
def test_getting_the_psets_of_a_product_as_a_dictionary(self):
ifc = ifcopenshell.open(os.path.join(os.getcwd(), "test", "files", "basic.ifc"))
ifc = ifcopenshell.open(os.path.join(os.path.dirname(__file__), "files", "basic.ifc"))
output = ifcpatch.execute({"file": ifc, "recipe": "ExtractElements", "arguments": ["IfcWall"]})
assert output.by_type("IfcWall")
assert not output.by_type("IfcSlab")
def test_preserving_georeferencing(self):
# Regression test for #8199: ExtractElements must carry IfcMapConversion
# and IfcProjectedCRS into the output. Without the fix these entities are
# silently dropped because they reference the IfcGeometricRepresentationContext
# via an inverse attribute and are therefore not reachable through the
# IfcProject forward-attribute walk used by self.new.add().
if self.file.schema == "IFC2X3":
pytest.skip("IfcMapConversion does not exist in IFC2X3")
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
ifcopenshell.api.context.add_context(self.file, context_type="Model")
ifcopenshell.api.georeference.add_georeferencing(self.file)
ifcopenshell.api.georeference.edit_georeferencing(
self.file,
coordinate_operation={"Eastings": 100000.0, "Northings": 200000.0},
)
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
matrix = numpy.eye(4)
matrix[:3, 3] = [5.0, 10.0, 2.0]
ifcopenshell.api.geometry.edit_object_placement(self.file, product=wall, matrix=matrix)
output = ifcpatch.execute({"file": self.file, "recipe": "ExtractElements", "arguments": ["IfcWall"]})
assert len(output.by_type("IfcMapConversion")) == 1
assert len(output.by_type("IfcProjectedCRS")) == 1
conversion = output.by_type("IfcMapConversion")[0]
assert conversion.Eastings == 100000.0
assert conversion.Northings == 200000.0
# Placements must be copied verbatim: extraction must not bake map
# coordinates (or any other georeferencing transform) into the local
# placements of the extracted elements.
wall_new = output.by_type("IfcWall")[0]
assert wall_new.ObjectPlacement.RelativePlacement.Location.Coordinates == (5.0, 10.0, 2.0)
@pytest.mark.skipif(
"IFC4X3" not in ifcopenshell.ifcopenshell_wrapper.schema_names(),
reason=(
+22
View File
@@ -177,6 +177,28 @@ class TestMergeProjects(test.bootstrap.IFC4):
assert np.any(np.all(np.isclose(np.array((17.847, 24.707, 3.0)), verts, atol=1e-3), axis=1))
assert np.any(np.all(np.isclose(np.array((20.410, 25.902, 5.0)), verts, atol=1e-3), axis=1))
def test_merging_three_or_more_projects(self):
# Regression test for #7973: merging N>2 models must keep every
# project's elements and must not leave duplicated geometric contexts
# behind (which makes later disciplines appear "not merged" in viewers).
self.file = self.setup_project(self.file)
second_file = self.setup_project()
third_file = self.setup_project()
output = ifcpatch.execute(
{
"file": self.file,
"recipe": "MergeProjects",
"arguments": [[second_file, third_file]],
}
)
assert self.file == output
# Every model contributed exactly one wall.
assert len(output.by_type("IfcWall")) == 3
# A single merged project must remain.
assert len(output.by_type("IfcProject")) == 1
# Contexts must be reused, not accumulated: one Model + one Body.
assert len(output.by_type("IfcGeometricRepresentationContext")) == 2
class TestMergeProjectsIFC2X3(test.bootstrap.IFC2X3, TestMergeProjects):
pass
+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