Merge pull request #8195 from Gorgious56/bonsai/ifc-migrate-ifc2x3-downgrade

Bonsai/ifc migrate ifc2x3 downgrade + patch preset system
This commit is contained in:
Gorgious56
2026-06-23 10:15:00 +02:00
committed by GitHub
19 changed files with 1461 additions and 31 deletions
@@ -21,6 +21,7 @@ import bpy
from . import operator, prop, ui
classes = (
operator.AddIfcPatchPreset,
operator.ExecuteIfcPatch,
operator.ExtractSelectedElements,
operator.RunMigratePatch,
@@ -28,6 +29,7 @@ classes = (
operator.SelectIfcPatchOutput,
operator.UpdateIfcPatchArguments,
prop.BIMPatchProperties,
ui.BIM_MT_ifc_patch_presets,
ui.BIM_PT_patch,
)
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast
import bpy
import ifcopenshell
import ifcpatch
from bl_operators.presets import AddPresetBase
from bpy_extras.io_utils import ExportHelper, ImportHelper
import bonsai.bim.handler
@@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator):
return False
return True
def invoke(self, context, event):
# Migrating IFC4 → IFC2X3 is lossy (enum drops, IFC4-only classes
# become IfcBuildingElementProxy, tessellated meshes get rebuilt as
# IfcFacetedBrep). Confirm before running so the user knows.
if tool.Patch.migration_is_lossy_downgrade():
return context.window_manager.invoke_props_dialog(self, width=480)
return self.execute(context)
def draw(self, context):
layout = self.layout
layout.label(text="Downgrading to IFC2X3 is lossy.", icon="ERROR")
column = layout.column(align=True)
column.label(text="Geometry will be preserved as faithfully as possible:")
column.label(text="• IfcIndexedPolyCurve → IfcPolyline (arcs approximated by chords)")
column.label(text="• IfcPolygonalFaceSet / IfcTriangulatedFaceSet → IfcFacetedBrep")
column.separator()
column.label(text="The following information is lost:")
column.label(text="• IFC4-only classes (IfcLamp, IfcPipeSegment, …) → IfcBuildingElementProxy")
column.label(text="• PredefinedType enum values absent from IFC2X3 are dropped")
column.label(text=" (original class + enum saved as ObjectType, e.g. 'IfcLamp/COMPACTFLUORESCENT')")
def execute(self, context):
props = tool.Patch.get_patch_props()
recipe_name = props.ifc_patch_recipes
@@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator):
query = tool.Search.get_query_for_selected_elements()
props.ifc_patch_args_attr[0].string_value = query
return {"FINISHED"}
class AddIfcPatchPreset(AddPresetBase, bpy.types.Operator):
"""Save / remove ifc-patch argument presets, scoped per recipe.
Presets live in the standard Blender preset directory under
``bonsai/ifc_patch/<recipe>/`` so a preset created for ``ExtractElements``
does not pollute the preset list for ``Migrate``. Persistence across files
and sessions is inherited from Blender's preset system."""
bl_idname = "bim.add_ifc_patch_preset"
bl_label = "Add IFC Patch Preset"
preset_menu = "BIM_MT_ifc_patch_presets"
preset_defines = ["props = bpy.context.scene.BIMPatchProperties"]
@property
def preset_subdir(self) -> str:
return tool.Patch.get_preset_subdir()
@property
def preset_values(self) -> list[str]:
# `Attribute.get_value_name()` returns the storage field for the
# argument's data_type (string_value, bool_value, …). For file
# arguments it returns the wrapping PointerProperty (`filepath_value`)
# — the scalar path the preset needs is `.single_file` on that.
props = tool.Patch.get_patch_props()
values = []
for i, arg in enumerate(props.ifc_patch_args_attr):
field = arg.get_value_name()
if not field:
continue
if arg.data_type == "file":
field = f"{field}.single_file"
values.append(f"props.ifc_patch_args_attr[{i}].{field}")
return values
@@ -71,6 +71,15 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context)
def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None:
bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes)
# Blender's script.execute_preset mutates the menu class's bl_label to
# the loaded preset's display name (used as a "currently selected"
# indicator). The label persists across recipe changes — making the new
# recipe's menu falsely show the previous recipe's preset name. Reset
# the label to the menu's canonical title so it always matches the
# active recipe's preset list.
menu_cls = getattr(bpy.types, "BIM_MT_ifc_patch_presets", None)
if menu_cls is not None:
menu_cls.bl_label = "IFC Patch Presets"
class BIMPatchProperties(PropertyGroup):
+19
View File
@@ -29,6 +29,20 @@ if TYPE_CHECKING:
from bonsai.bim.prop import Attribute
class BIM_MT_ifc_patch_presets(bpy.types.Menu):
"""Lists ifc-patch presets for the currently selected recipe.
``preset_subdir`` is resolved per draw so switching recipes swaps the
preset list without re-registering the menu."""
bl_label = "IFC Patch Presets"
preset_operator = "script.execute_preset"
def draw(self, context: bpy.types.Context) -> None:
self.preset_subdir = tool.Patch.get_preset_subdir()
bpy.types.Menu.draw_preset(self, context)
class BIM_PT_patch(bpy.types.Panel):
bl_label = "Patch"
bl_idname = "BIM_PT_patch"
@@ -66,6 +80,11 @@ class BIM_PT_patch(bpy.types.Panel):
row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER")
if props.ifc_patch_args_attr:
preset_row = layout.row(heading="Preset", align=True)
preset_row.menu("BIM_MT_ifc_patch_presets", text=BIM_MT_ifc_patch_presets.bl_label)
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="ADD")
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="REMOVE").remove_active = True
draw_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None
draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback)
+72
View File
@@ -18,18 +18,33 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell
import ifcopenshell.util.schema
import ifcpatch
import bonsai.core.tool
import bonsai.tool
if TYPE_CHECKING:
from bonsai.bim.module.patch.prop import BIMPatchProperties
# Lower index = older schema. Used to detect downgrades vs upgrades.
_SCHEMA_AGE = {"IFC2X3": 0, "IFC4": 1, "IFC4X3": 2}
# Pretty-printed argument name for the ``Migrate`` recipe's schema parameter
# (see UpdateIfcPatchArguments.pretty_arg_name in bim/module/patch/operator.py).
_MIGRATE_SCHEMA_ARG_NAME = "Schema"
# Match a STEP-encoded FILE_SCHEMA header: ``FILE_SCHEMA(('IFC4'));`` and the
# IFC4X3_ADD2 / IFC2X3_TC1 variants. Captures the bare schema identifier.
_IFC_FILE_SCHEMA_RE = re.compile(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", re.IGNORECASE)
class Patch(bonsai.core.tool.Patch):
@classmethod
def get_patch_props(cls) -> BIMPatchProperties:
@@ -54,6 +69,63 @@ class Patch(bonsai.core.tool.Patch):
"SplitByBuildingStorey",
)
@classmethod
def get_preset_subdir(cls) -> str:
"""Resolve the preset subdirectory for the currently selected recipe.
Returns a stable string for the ``-`` placeholder so the menu and save
operator remain usable when no real recipe has been picked yet."""
recipe = cls.get_patch_props().ifc_patch_recipes or "-"
return f"bonsai/ifc_patch/{recipe}"
@classmethod
def migration_is_lossy_downgrade(cls) -> bool:
"""``True`` when the currently configured patch is the ``Migrate``
recipe targeting an older schema than the input file. Used to gate
the destructive-migration confirmation dialog."""
props = cls.get_patch_props()
if props.ifc_patch_recipes != "Migrate":
return False
target_schema = next(
(arg.get_value() for arg in props.ifc_patch_args_attr if arg.name == _MIGRATE_SCHEMA_ARG_NAME),
None,
)
if not target_schema:
return False
source_schema = cls._patch_source_schema()
if not source_schema:
return False
return _SCHEMA_AGE.get(target_schema, -1) < _SCHEMA_AGE.get(source_schema, -1)
@classmethod
def _patch_source_schema(cls) -> str:
"""Resolve the IFC schema of the configured input without parsing the
full file. For loaded-from-memory the schema is in the entity_instance
wrapper; for disk paths we read only the STEP file header (first ~2KB)
rather than ``ifcopenshell.open`` which parses the whole file."""
props = cls.get_patch_props()
if props.should_load_from_memory:
ifc_file = bonsai.tool.Ifc.get()
return ifc_file.schema if ifc_file else ""
if not props.ifc_patch_input:
return ""
try:
with open(props.ifc_patch_input, "rb") as f:
header = f.read(2048).decode("utf-8", errors="ignore")
except OSError:
return ""
match = _IFC_FILE_SCHEMA_RE.search(header)
if not match:
return ""
# Collapse IFC4X3_ADD2 / IFC2X3_TC1 / IFC4_ADD2 / IFC4X1 etc. to their
# base via the canonical normaliser — handles longest-prefix-first
# ordering correctly (IFC4X3 before IFC4) so we don't misclassify
# IFC4X3 files as IFC4.
try:
return ifcopenshell.util.schema.get_fallback_schema(match.group(1).upper())
except AssertionError:
return ""
@classmethod
def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]:
if recipe == "ExtractElements":
@@ -0,0 +1,70 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import tempfile
from pathlib import Path
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.patch
class TestExecuteIfcPatchDowngradeEndToEnd(NewFile):
"""Drives the full panel flow the user sees: load IFC4 in memory, pick
Migrate + IFC2X3, click Execute, get an IFC2X3 file on disk with the
expected IfcBuildingElementProxy fallback + ObjectType encoding.
A regression here means a real user clicking Execute either crashes
Blender, produces a broken file, or silently drops type information
that the recipe is supposed to preserve via ObjectType."""
def test_ifc4_with_ifclamp_downgrades_to_ifc2x3_with_proxy_and_object_type(self):
ifc = ifcopenshell.file(schema="IFC4")
ifc.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT")
tool.Ifc.set(ifc)
props = tool.Patch.get_patch_props()
props.should_load_from_memory = True
props.ifc_patch_recipes = "Migrate"
next(a for a in props.ifc_patch_args_attr if a.name == "Schema").enum_value = "IFC2X3"
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "downgraded.ifc"
props.ifc_patch_output = str(output_path)
result = bpy.ops.bim.execute_ifc_patch()
assert result == {"FINISHED"}
assert output_path.exists(), "Recipe ran but no output file was written"
written = ifcopenshell.open(str(output_path))
assert written.schema == "IFC2X3"
proxies = written.by_type("IfcBuildingElementProxy")
assert len(proxies) == 1, "IfcLamp should fall back to a single IfcBuildingElementProxy"
assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT", (
"Original class + PredefinedType must be encoded into ObjectType "
"so the downgrade isn't a total information loss"
)
assert proxies[0].GlobalId == "2K6Z3DR8X37AS9XFvX8GcW"
@@ -0,0 +1,131 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import tempfile
from pathlib import Path
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.patch
def _set_patch_state(*, recipe: str, target_schema: str | None, source_ifc: ifcopenshell.file | None = None) -> None:
"""Drive the BIMPatchProperties into the configuration that a user produces
by picking Recipe + Schema in the panel + checking "Load from memory".
Setting the recipe fires UpdateIfcPatchArguments which builds the dynamic
args collection — only then can we assign the schema arg's enum_value."""
props = tool.Patch.get_patch_props()
if source_ifc is not None:
tool.Ifc.set(source_ifc)
props.should_load_from_memory = True
props.ifc_patch_recipes = recipe # update callback builds ifc_patch_args_attr
if target_schema is not None:
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
schema_arg.enum_value = target_schema
class TestMigrationIsLossyDowngrade(NewFile):
"""Pins the predicate that gates ``ExecuteIfcPatch.invoke``'s
confirmation popup. Every row of the truth table corresponds to a real
user-facing flow — wrong answers either nag the user on safe migrations
or silently let lossy ones through with no warning."""
def test_ifc4_to_ifc2x3_in_memory_is_lossy(self):
ifc = ifcopenshell.file(schema="IFC4")
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is True
def test_ifc4x3_to_ifc2x3_in_memory_is_lossy(self):
# Regression for the gate that originally only fired for self.file.schema == "IFC4",
# silently leaving IFC4X3 sources crashing on IFC4-only geometry.
ifc = ifcopenshell.file(schema="IFC4X3")
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is True
def test_ifc2x3_to_ifc4_upgrade_is_not_lossy(self):
ifc = ifcopenshell.file(schema="IFC2X3")
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is False
def test_ifc4_to_ifc4_same_schema_is_not_lossy(self):
ifc = ifcopenshell.file(schema="IFC4")
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is False
def test_non_migrate_recipe_is_not_lossy(self):
# The popup only ever applies to the Migrate recipe — other recipes
# (ExtractElements, TessellateElements, …) handle their own warnings.
ifc = ifcopenshell.file(schema="IFC4")
_set_patch_state(recipe="ExtractElements", target_schema=None, source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is False
def test_no_source_set_is_not_lossy(self):
# Without an input file or in-memory IFC, the predicate cannot tell
# what the source schema is — defaults to False so the popup doesn't
# block harmless cases where the user is still configuring the panel.
props = tool.Patch.get_patch_props()
props.ifc_patch_recipes = "Migrate"
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
schema_arg.enum_value = "IFC2X3"
assert tool.Patch.migration_is_lossy_downgrade() is False
class TestPatchSourceSchemaSniff(NewFile):
"""End-to-end pin on the header-only schema parsing. The IFC4X3 misdetection
bug originally lived in this code path — a raw startswith(\"IFC4\") loop
matching IFC4X3_ADD2 before the IFC4X3 base check was reached."""
def test_in_memory_ifc4x3_source_resolves_to_ifc4x3(self):
ifc = ifcopenshell.file(schema="IFC4X3")
tool.Ifc.set(ifc)
props = tool.Patch.get_patch_props()
props.should_load_from_memory = True
assert tool.Patch._patch_source_schema() == "IFC4X3"
def test_file_path_ifc4x3_add2_source_resolves_to_ifc4x3(self):
# Writes a real .ifc file with IFC4X3_ADD2 in the FILE_SCHEMA header
# and confirms the regex + get_fallback_schema normaliser correctly
# collapse it to IFC4X3, not IFC4.
with tempfile.TemporaryDirectory() as tmpdir:
ifc_path = Path(tmpdir) / "sample.ifc"
ifc_path.write_text(
"ISO-10303-21;\n"
"HEADER;\n"
"FILE_DESCRIPTION((''),'2;1');\n"
"FILE_NAME('','2026',(''),(''),'','','');\n"
"FILE_SCHEMA(('IFC4X3_ADD2'));\n"
"ENDSEC;\n"
"DATA;\nENDSEC;\nEND-ISO-10303-21;\n"
)
props = tool.Patch.get_patch_props()
props.should_load_from_memory = False
props.ifc_patch_input = str(ifc_path)
assert tool.Patch._patch_source_schema() == "IFC4X3"
def test_missing_input_returns_empty_string(self):
props = tool.Patch.get_patch_props()
props.should_load_from_memory = False
props.ifc_patch_input = ""
assert tool.Patch._patch_source_schema() == ""
@@ -0,0 +1,55 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import bpy
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.patch
class TestPresetMenuLabelResetsOnRecipeChange(NewFile):
"""Blender's ``script.execute_preset`` mutates the menu class's bl_label
to the loaded preset's display name as a "currently-selected" indicator.
Without a recipe-change callback, that label persists into the next
recipe's menu — falsely advertising a preset that belongs to a
different recipe's subdir and isn't selectable from the new menu."""
def test_changing_recipe_restores_canonical_label(self):
# Simulate the state Blender leaves after the user picked a preset
# for the previous recipe.
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
menu_cls.bl_label = "Structural"
# Switching the recipe must fire update_ifc_patch_recipe, which
# resets the menu label.
props = tool.Patch.get_patch_props()
props.ifc_patch_recipes = "Migrate"
assert menu_cls.bl_label == "IFC Patch Presets"
def test_canonical_label_is_used_when_no_preset_was_loaded(self):
# Fresh state — label is the bl_label-default from the class declaration.
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
props = tool.Patch.get_patch_props()
props.ifc_patch_recipes = "ExtractElements"
assert menu_cls.bl_label == "IFC Patch Presets"
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import functools
import json
import os
import time
@@ -148,6 +149,57 @@ def get_subtypes(
return get_classes(declaration)
def _enum_value_outside_target(attribute: ifcopenshell_wrapper.attribute, value: Any) -> bool:
"""``True`` when ``attribute`` is an enumeration and the string ``value``
is not in its declared items. Used by the Migrator to silently skip enum
values that exist in the source schema but not the target — without
parsing C++ wrapper error strings."""
if not isinstance(value, str):
return False
try:
enum_items = ifcopenshell.util.attribute.get_enum_items(attribute)
except (AssertionError, AttributeError):
return False
return value not in enum_items
@functools.cache
def geometry_classes_introduced_after(target_schema: IFC_SCHEMA, source_schema: IFC_SCHEMA = "IFC4") -> frozenset[str]:
"""``IfcRepresentationItem`` subclasses present in ``source_schema`` but
missing in ``target_schema``.
Derived from the loaded schema declarations once per (source, target) pair
and cached. The result is the canonical set of geometry classes a
downgrade from ``source_schema`` to ``target_schema`` must convert
(``IfcPolygonalFaceSet``, ``IfcTriangulatedFaceSet``, ``IfcAdvancedBrep``,
B-splines, advanced surfaces, alignment curves on IFC4X3 → 2X3, …) or
purge. Defaults match the IFC4 → IFC2X3 case for backwards compatibility
with the original caller."""
source = ifcopenshell_wrapper.schema_by_name(source_schema)
target = ifcopenshell_wrapper.schema_by_name(target_schema)
target_names = {decl.name() for decl in target.entities()}
result: set[str] = set()
for decl in source.entities():
if decl.name() in target_names:
continue
cursor: Any = decl
while cursor is not None:
if cursor.name() == "IfcRepresentationItem":
result.add(decl.name())
break
cursor = cursor.supertype()
return frozenset(result)
def ifc4_only_geometry_classes() -> frozenset[str]:
"""Backwards-compatible alias for the IFC4 → IFC2X3 geometry-gap set.
New code should call :func:`geometry_classes_introduced_after` with the
explicit (target, source) pair so IFC4X3 → IFC2X3 downgrades pick up the
additional IFC4X3-only geometry classes."""
return geometry_classes_introduced_after("IFC2X3", "IFC4")
def reassign_class(
ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, new_class: str
) -> ifcopenshell.entity_instance:
@@ -263,7 +315,20 @@ class Migrator:
migrated_ids: dict[int, int]
attribute_overrides: dict[int, dict[int, str]]
def __init__(self):
def __init__(self, *, fallback_element_to_proxy: bool = False) -> None:
"""Construct a schema migrator.
:param fallback_element_to_proxy: When ``True`` and the target schema is
IFC2X3, IFC4 entity classes that have no direct IFC2X3 equivalent
but inherit from ``IfcElement`` / ``IfcElementType`` are migrated as
``IfcBuildingElementProxy`` / ``IfcBuildingElementProxyType``
respectively, instead of raising. Caller code is then responsible
for preserving the lost original class information out-of-band (the
``Migrate`` ifcpatch recipe encodes it into ``ObjectType``).
Defaults to ``False`` so non-recipe callers keep the strict
failure-on-unmappable contract.
"""
self.fallback_element_to_proxy = fallback_element_to_proxy
self.migrated_ids = {}
self.attribute_overrides = {}
self.class_4_to_2x3 = json.load(open(os.path.join(cwd, "class_4_to_2x3.json"), "r"))
@@ -379,6 +444,17 @@ class Migrator:
self.migrated_ids[element.id()] = new_element.id()
return new_element
@staticmethod
def _is_subclass_of(ifc_class: str, ancestor: str, source_file: ifcopenshell.file) -> bool:
schema = ifcopenshell_wrapper.schema_by_name(source_file.schema_identifier)
try:
return is_a(schema.declaration_by_name(ifc_class), ancestor)
except RuntimeError:
# Class doesn't exist in the source schema — happens for cross-schema
# introspection of an entity created with a name the wrapper doesn't
# recognise. Treat as "not a subclass".
return False
def migrate_class(
self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file
) -> ifcopenshell.entity_instance:
@@ -389,15 +465,44 @@ class Migrator:
if isinstance(value, float):
ifc_class = "IfcQuantityNumber"
try:
new_element = new_file.create_entity(ifc_class)
return new_file.create_entity(ifc_class)
except:
# The element does not exist in this schema
# Complex migration is not yet supported (e.g. polygonal face set to faceted brep)
if new_file.schema == "IFC2X3":
new_element = new_file.create_entity(self.class_4_to_2x3[ifc_class])
elif new_file.schema == "IFC4":
new_element = new_file.create_entity(self.class_2x3_to_4[ifc_class])
return new_element
pass
# The class does not exist in the target schema — look up an equivalent.
# The lookup tables use empty-string as a sentinel meaning "no direct
# equivalent, needs geometric translation" (e.g. polygonal face set →
# faceted brep). Callers that want a clean downgrade are expected to
# preprocess such carriers before calling the Migrator; see the
# `Migrate` ifcpatch recipe.
if new_file.schema == "IFC2X3":
equivalent = self.class_4_to_2x3.get(ifc_class, None)
elif new_file.schema == "IFC4":
equivalent = self.class_2x3_to_4.get(ifc_class, None)
else:
equivalent = None
# IfcBuildingElementProxy fallback is opt-in (see constructor) — only
# the IfcElement / IfcElementType subtrees have a meaningful generic
# IFC2X3 stand-in; non-element IFC4-only classes (rels, geometry items,
# materials, times) still raise below.
if not equivalent and new_file.schema == "IFC2X3" and self.fallback_element_to_proxy:
if self._is_subclass_of(ifc_class, "IfcElement", element.wrapped_data.file):
equivalent = "IfcBuildingElementProxy"
elif self._is_subclass_of(ifc_class, "IfcElementType", element.wrapped_data.file):
equivalent = "IfcBuildingElementProxyType"
if not equivalent:
inverses = element.wrapped_data.file.get_inverse(element)
inverse_hint = ", ".join(f"#{i.id()}={i.is_a()}" for i in list(inverses)[:3])
if len(inverses) > 3:
inverse_hint += f", … (+{len(inverses) - 3} more)"
raise NotImplementedError(
f"Cannot migrate #{element.id()}={ifc_class} to schema "
f"{new_file.schema}: no direct equivalent exists. "
f"Referenced by: {inverse_hint or '(no inverses)'}."
)
return new_file.create_entity(equivalent)
def migrate_attributes(
self,
@@ -526,11 +631,40 @@ class Migrator:
new_value.append(self.migrate(item, new_file))
value = new_value
if value is not None:
if _enum_value_outside_target(attribute, value):
# Enum value present in source schema but missing in target
# (typically a downgrade after a cross-class fallback, e.g.
# IfcLamp.PredefinedType=COMPACTFLUORESCENT copied onto
# IfcBuildingElementProxy.CompositionType whose enum is
# IfcElementCompositionEnum). Leave the attribute unset rather
# than abort the whole entity's migration. Detected
# structurally so other RuntimeError causes (type mismatches,
# invalid values) still propagate.
return
setattr(new_element, attribute.name(), value)
def generate_default_value(self, attribute: ifcopenshell_wrapper.attribute, new_file: ifcopenshell.file) -> Any:
if attribute.name() in self.default_values:
return self.default_values[attribute.name()]
elif attribute.name() == "Position":
# IFC4 relaxed Position to OPTIONAL for many profile defs; IFC2X3
# still requires it. Synthesize a unit placement at origin so
# IfcIShapeProfileDef and friends downgrade without crashing
# downstream validators.
try:
type_name = attribute.type_of_attribute().as_named_type().declared_type().name()
except Exception:
type_name = None
if type_name == "IfcAxis2Placement2D":
return new_file.create_entity(
"IfcAxis2Placement2D",
Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0)),
)
if type_name == "IfcAxis2Placement3D":
return new_file.create_entity(
"IfcAxis2Placement3D",
Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0, 0.0)),
)
elif attribute.name() == "OwnerHistory":
self.default_entities[attribute.name()] = new_file.create_entity(
"IfcOwnerHistory",
@@ -21,7 +21,7 @@ from __future__ import annotations
import collections.abc
from collections.abc import Sequence
from itertools import chain
from math import atan, cos, degrees, pi, radians, sin, sqrt, tan
from math import atan, atan2, cos, degrees, hypot, isclose, pi, radians, sin, sqrt, tan
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import numpy as np
@@ -301,6 +301,130 @@ def intersect_x_axis_2d(p1: VectorType, p2: VectorType, y=0) -> Optional[float]:
return x1 + t * (x2 - x1)
def arc_to_polyline_points(
start: VectorType, mid: VectorType, end: VectorType, subdivisions: int = 16
) -> list[tuple[float, ...]]:
"""Approximate a circular arc through (start, mid, end) with chord points.
The arc is determined uniquely by three points — a circle is fit in the
XY plane and the angle is walked from start through mid to end, sampling
``subdivisions + 1`` points inclusive of the endpoints. Falls back to a
straight chord ``[start, end]`` for collinear / degenerate inputs.
Only planar arcs in the XY plane are supported. For 3D inputs (length 3
tuples), the Z coordinate of each output point is held constant at
``start[2]``. Inputs where start/mid/end have differing Z values raise
``ValueError`` rather than silently project — caller should rotate the
arc into the XY plane first if it lives in a non-axis-aligned plane.
:raises ValueError: if subdivisions < 1, or if 3D inputs have mismatched
Z coordinates (non-planar arc).
"""
if subdivisions < 1:
raise ValueError(f"subdivisions must be >= 1, got {subdivisions}")
if len(start) >= 3:
# Tolerance accommodates floating-point noise from kernel transforms
# — IFC point coordinates that the author wrote as the same Z value
# may diverge by ~1e-15 after placement-matrix round-trips.
z_tol = 1e-9
if not (isclose(start[2], mid[2], abs_tol=z_tol) and isclose(start[2], end[2], abs_tol=z_tol)):
raise ValueError(
f"arc_to_polyline_points only handles arcs in the XY plane; "
f"got mismatched Z coordinates ({start[2]}, {mid[2]}, {end[2]})."
)
sx, sy = start[0], start[1]
mx, my = mid[0], mid[1]
ex, ey = end[0], end[1]
d = 2 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my))
if abs(d) < 1e-12:
return [tuple(start), tuple(end)]
cx = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy) + (ex**2 + ey**2) * (sy - my)) / d
cy = ((sx**2 + sy**2) * (ex - mx) + (mx**2 + my**2) * (sx - ex) + (ex**2 + ey**2) * (mx - sx)) / d
a_start = atan2(sy - cy, sx - cx)
a_mid = atan2(my - cy, mx - cx)
a_end = atan2(ey - cy, ex - cx)
sweep = _signed_sweep_through_mid(a_start, a_mid, a_end)
radius = hypot(sx - cx, sy - cy)
pts: list[tuple[float, ...]] = []
for i in range(subdivisions + 1):
t = i / subdivisions
angle = a_start + sweep * t
x = cx + radius * cos(angle)
y = cy + radius * sin(angle)
if len(start) == 2:
pts.append((x, y))
else:
pts.append((x, y, start[2]))
return pts
def _signed_sweep_through_mid(a_start: float, a_mid: float, a_end: float) -> float:
"""Total angle (radians) from a_start to a_end going through a_mid."""
two_pi = 2 * pi
ccw_total = (a_end - a_start) % two_pi
ccw_to_mid = (a_mid - a_start) % two_pi
if ccw_to_mid <= ccw_total:
return ccw_total
return -((a_start - a_end) % two_pi)
def polygonal_face_set_to_faceted_brep(face_set: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
"""Convert an ``IfcPolygonalFaceSet`` or ``IfcTriangulatedFaceSet`` into an
``IfcFacetedBrep`` in the same file, preserving vertex coordinates and face
topology (including inner voids on ``IfcIndexedPolygonalFaceWithVoids``).
The returned brep is the canonical IFC2X3-compatible form of these IFC4
tessellated representations. The caller is responsible for rewiring inverse
references and removing the source face set when downgrading.
:raises TypeError: if ``face_set`` is not an ``IfcPolygonalFaceSet`` or
``IfcTriangulatedFaceSet``.
:raises ValueError: if ``face_set.Coordinates`` is missing or any face's
coordinate index references a vertex outside the coordinate list.
"""
if not (face_set.is_a("IfcPolygonalFaceSet") or face_set.is_a("IfcTriangulatedFaceSet")):
raise TypeError(
f"polygonal_face_set_to_faceted_brep expected IfcPolygonalFaceSet or "
f"IfcTriangulatedFaceSet, got {face_set.is_a()}."
)
if face_set.Coordinates is None:
raise ValueError(f"{face_set.is_a()} #{face_set.id()} has no Coordinates point list.")
ifc_file = face_set.file
coords = face_set.Coordinates.CoordList
vertex_count = len(coords)
ifc_points = [ifc_file.createIfcCartesianPoint(tuple(c)) for c in coords]
def _resolve(indices: Sequence[int]) -> list[ifcopenshell.entity_instance]:
# IfcIndexedPolygonalFace.CoordIndex / IfcTriangulatedFaceSet.CoordIndex
# are 1-based. Out-of-range hits early with a clear message rather
# than the cryptic IndexError from list[i-1].
out = []
for index in indices:
if not 1 <= index <= vertex_count:
raise ValueError(
f"{face_set.is_a()} #{face_set.id()} face references vertex {index}, "
f"outside CoordList range 1..{vertex_count}."
)
out.append(ifc_points[index - 1])
return out
ifc_faces: list[ifcopenshell.entity_instance] = []
if face_set.is_a("IfcTriangulatedFaceSet"):
for triangle in face_set.CoordIndex:
loop = ifc_file.createIfcPolyLoop(_resolve(triangle))
ifc_faces.append(ifc_file.createIfcFace([ifc_file.createIfcFaceOuterBound(loop, True)]))
else: # IfcPolygonalFaceSet
for indexed_face in face_set.Faces:
outer_loop = ifc_file.createIfcPolyLoop(_resolve(indexed_face.CoordIndex))
bounds = [ifc_file.createIfcFaceOuterBound(outer_loop, True)]
if indexed_face.is_a("IfcIndexedPolygonalFaceWithVoids"):
for inner in indexed_face.InnerCoordIndices or ():
bounds.append(ifc_file.createIfcFaceBound(ifc_file.createIfcPolyLoop(_resolve(inner)), True))
ifc_faces.append(ifc_file.createIfcFace(bounds))
return ifc_file.createIfcFacetedBrep(ifc_file.createIfcClosedShell(ifc_faces))
# Note: using ShapeBuilder try not to reuse IFC elements in the process
# otherwise you might run into situation where builder.mirror or other operation
# is applied twice during one run to the same element
@@ -16,6 +16,9 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import pytest
import ifcopenshell
import ifcopenshell.api.project
import ifcopenshell.util.schema as subject
import test.bootstrap
@@ -119,6 +122,157 @@ END-ISO-10303-21;
assert isinstance(qt_float_count_measure_ifc4x3[3], float)
assert qt_float_count_measure_ifc4x3[3] == 723.0
def test_migrate_class_raises_clear_error_for_ifc4_only_non_element_class_to_ifc2x3(self):
"""IFC4-only non-element classes (geometry items, etc.) have no
IfcBuildingElementProxy fallback and must surface a clear error naming
the failing class — not the cryptic 'Entity name not found in schema'."""
ifc4_file = ifcopenshell.api.project.create_file()
point_list = ifc4_file.create_entity("IfcCartesianPointList2D", CoordList=((0.0, 0.0), (1.0, 0.0)))
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
migrator = subject.Migrator()
with pytest.raises(NotImplementedError) as exc_info:
migrator.migrate(point_list, ifc2x3_file)
message = str(exc_info.value)
assert "IfcCartesianPointList2D" in message
assert "IFC2X3" in message
def test_migrate_class_falls_back_to_ifcbuildingelementproxy_when_opt_in(self):
"""With ``fallback_element_to_proxy=True``, IFC4-only IfcElement
subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, …) migrate
as IfcBuildingElementProxy instead of raising. Default behavior
(no opt-in) raises so non-recipe callers keep the strict contract."""
ifc4_file = ifcopenshell.api.project.create_file()
lamp = ifc4_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW")
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
# Default migrator raises (strict contract preserved).
with pytest.raises(NotImplementedError, match="IfcLamp"):
subject.Migrator().migrate(lamp, ifc2x3_file)
# Opt-in migrator substitutes IfcBuildingElementProxy.
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
new_lamp = subject.Migrator(fallback_element_to_proxy=True).migrate(lamp, ifc2x3_file)
assert new_lamp.is_a("IfcBuildingElementProxy")
class TestGetFallbackSchema:
"""Pins the schema-identifier normalisation contract relied on by callers
that need to map upstream variants (IFC4X3_ADD2, IFC2X3_TC1, IFC4_ADD2, …)
to a base schema name for compatibility tables / downgrade detection."""
def test_ifc4x3_variants_collapse_to_ifc4x3(self):
# Longest-prefix-first: IFC4X3_ADD2 must NOT be misclassified as IFC4
# — the function checks IFC4X3 before IFC4.
assert subject.get_fallback_schema("IFC4X3") == "IFC4X3"
assert subject.get_fallback_schema("IFC4X3_ADD1") == "IFC4X3"
assert subject.get_fallback_schema("IFC4X3_ADD2") == "IFC4X3"
assert subject.get_fallback_schema("IFC4X3_RC1") == "IFC4X3"
def test_ifc4_variants_collapse_to_ifc4(self):
assert subject.get_fallback_schema("IFC4") == "IFC4"
assert subject.get_fallback_schema("IFC4_ADD1") == "IFC4"
assert subject.get_fallback_schema("IFC4_ADD2") == "IFC4"
# IFC4X1 / IFC4X2 are draft schemas — collapse to IFC4 by design.
assert subject.get_fallback_schema("IFC4X1") == "IFC4"
assert subject.get_fallback_schema("IFC4X2") == "IFC4"
def test_ifc2x3_variants_collapse_to_ifc2x3(self):
assert subject.get_fallback_schema("IFC2X3") == "IFC2X3"
assert subject.get_fallback_schema("IFC2X3_TC1") == "IFC2X3"
assert subject.get_fallback_schema("IFC2X3_FINAL") == "IFC2X3"
def test_unknown_version_asserts(self):
# Asserts under non-optimised Python; in -O mode would return the
# unmodified input. Caller should guard accordingly.
with pytest.raises(AssertionError):
subject.get_fallback_schema("IFC10")
class TestIfc4OnlyGeometryClasses:
def test_known_ifc4_only_classes_present(self):
result = subject.ifc4_only_geometry_classes()
# Classes that genuinely don't exist in IFC2X3 and inherit
# IfcRepresentationItem in IFC4.
for name in (
"IfcPolygonalFaceSet",
"IfcTriangulatedFaceSet",
"IfcIndexedPolyCurve",
"IfcCartesianPointList3D",
"IfcAdvancedBrep",
):
assert name in result, f"{name} should be classified as IFC4-only geometry"
def test_ifc2x3_compatible_classes_absent(self):
result = subject.ifc4_only_geometry_classes()
# Classes that exist in both schemas — must NOT be flagged.
for name in ("IfcPolyline", "IfcFacetedBrep", "IfcCartesianPoint", "IfcExtrudedAreaSolid"):
assert name not in result, f"{name} exists in IFC2X3, should not be IFC4-only"
def test_non_geometry_ifc4_only_classes_absent(self):
result = subject.ifc4_only_geometry_classes()
# IFC4-only but not IfcRepresentationItem subclasses — out of scope.
for name in ("IfcEvent", "IfcWorkCalendar", "IfcLamp"):
assert name not in result, f"{name} is not an IfcRepresentationItem subclass"
def test_result_is_cached_frozenset(self):
first = subject.ifc4_only_geometry_classes()
second = subject.ifc4_only_geometry_classes()
assert first is second # @functools.cache returns the same object
class TestGeometryClassesIntroducedAfter:
"""Generalised version of ``ifc4_only_geometry_classes`` — pins the
schema-aware contract that supports IFC4X3 → IFC2X3 downgrades, not just
IFC4 → IFC2X3."""
def test_ifc4_to_ifc2x3_matches_legacy_helper(self):
# The legacy ``ifc4_only_geometry_classes`` is now a thin alias.
assert subject.geometry_classes_introduced_after("IFC2X3", "IFC4") == subject.ifc4_only_geometry_classes()
def test_ifc4x3_to_ifc2x3_is_superset_of_ifc4_to_ifc2x3(self):
# IFC4X3 is a superset of IFC4 — every IFC4-only geometry class is
# also missing from IFC2X3 when the source is IFC4X3, plus any new
# IFC4X3-only geometry (alignment curves, distance expressions, …).
ifc4_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4")
ifc4x3_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4X3")
assert ifc4_gap <= ifc4x3_gap
def test_ifc4_to_ifc4x3_is_empty(self):
# IFC4X3 contains every IFC4 IfcRepresentationItem subclass — no
# IFC4 class is missing from IFC4X3.
assert subject.geometry_classes_introduced_after("IFC4X3", "IFC4") == frozenset()
class TestEnumValueOutsideTarget:
@staticmethod
def _attr(class_name: str, attr_name: str):
schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name("IFC2X3")
decl = schema.declaration_by_name(class_name)
return next(a for a in decl.all_attributes() if a.name() == attr_name)
def test_enum_value_present_in_target_returns_false(self):
# IfcCovering.PredefinedType is IfcCoveringTypeEnum — CEILING is valid.
attr = self._attr("IfcCovering", "PredefinedType")
assert subject._enum_value_outside_target(attr, "CEILING") is False
def test_enum_value_missing_in_target_returns_true(self):
# IfcCoveringTypeEnum has no COMPACTFLUORESCENT (an IfcLampTypeEnum value).
attr = self._attr("IfcCovering", "PredefinedType")
assert subject._enum_value_outside_target(attr, "COMPACTFLUORESCENT") is True
def test_non_enum_attribute_returns_false(self):
# IfcCovering.Name is IfcLabel — not an enum, so the helper must return False.
attr = self._attr("IfcCovering", "Name")
assert subject._enum_value_outside_target(attr, "anything") is False
def test_non_string_value_returns_false(self):
attr = self._attr("IfcCovering", "PredefinedType")
assert subject._enum_value_outside_target(attr, 42) is False
class TestExtendedMaterialProperties(test.bootstrap.IFC4):
def test_migrate_extended_material_properties_ifc2x3_ifc4(self):
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
material = ifc2x3_file.createIfcMaterial(Name="Material")
@@ -16,7 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from math import degrees, radians
from math import degrees, radians, sqrt
from typing import Any, Union
import numpy as np
@@ -28,6 +28,7 @@ import test.bootstrap
from ifcopenshell.util.shape_builder import (
ShapeBuilder,
V,
arc_to_polyline_points,
is_x,
np_angle,
np_angle_signed,
@@ -36,9 +37,116 @@ from ifcopenshell.util.shape_builder import (
np_normal,
np_rotation_matrix,
np_to_3d,
polygonal_face_set_to_faceted_brep,
)
class TestArcToPolylinePoints:
def test_quarter_arc_2d_samples_n_plus_one_points(self):
# Quarter arc from (1,0) through (cos45°, sin45°) to (0,1) — unit circle.
sqrt_half = sqrt(0.5)
points = arc_to_polyline_points((1.0, 0.0), (sqrt_half, sqrt_half), (0.0, 1.0), 8)
assert len(points) == 9
assert points[0] == pytest.approx((1.0, 0.0), abs=1e-9)
assert points[-1] == pytest.approx((0.0, 1.0), abs=1e-9)
for x, y in points:
assert x * x + y * y == pytest.approx(1.0, abs=1e-9)
def test_collinear_inputs_fall_back_to_straight_chord(self):
points = arc_to_polyline_points((0.0, 0.0), (1.0, 0.0), (2.0, 0.0), 16)
assert points == [(0.0, 0.0), (2.0, 0.0)]
def test_3d_inputs_with_constant_z_preserved(self):
points = arc_to_polyline_points((1.0, 0.0, 5.0), (0.7071, 0.7071, 5.0), (0.0, 1.0, 5.0), 4)
assert len(points) == 5
assert all(p[2] == 5.0 for p in points)
def test_3d_inputs_with_mismatched_z_raises(self):
with pytest.raises(ValueError, match="XY plane"):
arc_to_polyline_points((1.0, 0.0, 0.0), (0.0, 1.0, 1.0), (-1.0, 0.0, 0.0))
def test_3d_inputs_with_near_equal_z_pass_within_tolerance(self):
# Real IFC files often have float noise of ~1e-15 in Z values that the
# author meant to be identical — kernel transforms introduce it. The
# planar check tolerates this rather than rejecting valid input.
sqrt_half = sqrt(0.5)
points = arc_to_polyline_points(
(1.0, 0.0, 5.0), (sqrt_half, sqrt_half, 5.0 + 1e-15), (0.0, 1.0, 5.0 - 2e-16), 4
)
assert len(points) == 5
def test_subdivisions_zero_raises(self):
with pytest.raises(ValueError, match="subdivisions"):
arc_to_polyline_points((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 0)
class TestPolygonalFaceSetToFacetedBrep(test.bootstrap.IFC4):
def test_triangulated_face_set_preserves_coordinates(self):
coords = self.file.create_entity(
"IfcCartesianPointList3D",
CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)),
)
face_set = self.file.create_entity(
"IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 2, 4), (2, 3, 4), (3, 1, 4), (1, 3, 2)]
)
brep = polygonal_face_set_to_faceted_brep(face_set)
assert brep.is_a("IfcFacetedBrep")
assert len(brep.Outer.CfsFaces) == 4
# Every CoordList vertex appears in the brep at the same coordinate.
brep_points = {tuple(p.Coordinates) for f in brep.Outer.CfsFaces for p in f.Bounds[0].Bound.Polygon}
assert (0.0, 0.0, 0.0) in brep_points
assert (1.0, 0.0, 0.0) in brep_points
assert (0.0, 1.0, 0.0) in brep_points
assert (0.5, 0.5, 1.0) in brep_points
def test_polygonal_face_set_with_voids_preserves_inner_bounds(self):
# Quad with a triangular hole through it.
coords = self.file.create_entity(
"IfcCartesianPointList3D",
CoordList=(
(0.0, 0.0, 0.0),
(4.0, 0.0, 0.0),
(4.0, 4.0, 0.0),
(0.0, 4.0, 0.0),
(1.0, 1.0, 0.0),
(3.0, 1.0, 0.0),
(2.0, 3.0, 0.0),
),
)
face = self.file.create_entity(
"IfcIndexedPolygonalFaceWithVoids",
CoordIndex=(1, 2, 3, 4),
InnerCoordIndices=[(5, 6, 7)],
)
face_set = self.file.create_entity("IfcPolygonalFaceSet", Coordinates=coords, Faces=[face])
brep = polygonal_face_set_to_faceted_brep(face_set)
assert len(brep.Outer.CfsFaces) == 1
bounds = brep.Outer.CfsFaces[0].Bounds
# Outer + 1 inner bound.
assert len(bounds) == 2
outer = next(b for b in bounds if b.is_a("IfcFaceOuterBound"))
inner = next(b for b in bounds if not b.is_a("IfcFaceOuterBound"))
assert len(outer.Bound.Polygon) == 4
assert len(inner.Bound.Polygon) == 3
def test_wrong_class_raises_typeerror(self):
# An IfcCartesianPointList3D is not a face set.
not_a_face_set = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),))
with pytest.raises(TypeError, match="IfcPolygonalFaceSet"):
polygonal_face_set_to_faceted_brep(not_a_face_set)
def test_out_of_range_index_raises_valueerror(self):
coords = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),))
# CoordIndex 5 doesn't exist in a 1-vertex coord list.
face_set = self.file.create_entity("IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 1, 5)])
with pytest.raises(ValueError, match="outside CoordList range"):
polygonal_face_set_to_faceted_brep(face_set)
class TestMathutilsCompatibleMethods(test.bootstrap.IFC4):
def test_np_rotation_matrix(self):
from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
@@ -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
@@ -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
+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