mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-14 03:14:23 +00:00
Merge remote-tracking branch 'origin/v0.8.0' into ifcviewer-wgpu
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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=(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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