mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Merge pull request #8195 from Gorgious56/bonsai/ifc-migrate-ifc2x3-downgrade
Bonsai/ifc migrate ifc2x3 downgrade + patch preset system
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,7 +20,9 @@ from logging import Logger
|
||||
from typing import Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.schema
|
||||
import ifcopenshell.util.shape_builder
|
||||
|
||||
import ifcpatch
|
||||
|
||||
@@ -32,10 +34,39 @@ class Patcher(ifcpatch.BasePatcher):
|
||||
logger: Union[Logger, None] = None,
|
||||
schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4",
|
||||
):
|
||||
"""Migrate from one IFC version to another
|
||||
"""Migrate from one IFC version to another.
|
||||
|
||||
Note that this is experimental and will try to preserve as much data as
|
||||
possible. Upgrading to IFC4 is more stable than downgrading to IFC2X3.
|
||||
The recipe iterates every entity in the source file and rewrites it
|
||||
into a new file with the target schema, delegating per-entity class /
|
||||
attribute translation to :class:`ifcopenshell.util.schema.Migrator`.
|
||||
Upgrades (IFC2X3 → IFC4, IFC4 → IFC4X3) are best supported because the
|
||||
target schema is a superset; downgrades are lossy by definition (see
|
||||
below). Entities that fail to migrate are collected; on completion a
|
||||
summary ``RuntimeError`` is raised listing up to 20 failures.
|
||||
|
||||
IFC4 → IFC2X3 downgrade additionally runs a preprocessing pipeline so
|
||||
IFC4-only geometry and element classes survive the schema gap:
|
||||
|
||||
- ``IfcIndexedPolyCurve`` (including arc segments, approximated by a
|
||||
chord polyline) is flattened to ``IfcPolyline``.
|
||||
- ``IfcPolygonalFaceSet`` and ``IfcTriangulatedFaceSet`` are converted
|
||||
directly to ``IfcFacetedBrep`` at the entity level, preserving the
|
||||
original mesh topology.
|
||||
- Orphan IFC4-only geometry instances left over after the rewires are
|
||||
purged so the migration loop does not trip on them.
|
||||
- IFC4-only ``IfcElement`` subclasses (``IfcLamp``, ``IfcPipeSegment``,
|
||||
``IfcGeographicElement``, …) fall back to ``IfcBuildingElementProxy``
|
||||
via the Migrator's ``fallback_element_to_proxy`` opt-in. The
|
||||
original class and ``PredefinedType`` are encoded into
|
||||
``ObjectType`` (e.g. ``"IfcLamp/COMPACTFLUORESCENT"``) when
|
||||
``ObjectType`` is empty, so the type information survives the
|
||||
downgrade.
|
||||
|
||||
Non-element IFC4-only entities (relationships, geometry items outside
|
||||
any product, …) that have no direct equivalent still raise
|
||||
``NotImplementedError`` from the Migrator with the failing class and
|
||||
inverse references named, instead of the cryptic
|
||||
``Entity with name '' not found in schema 'IFC2X3'``.
|
||||
|
||||
:param schema: The schema identifier of the IFC version to migrate to.
|
||||
|
||||
@@ -50,10 +81,105 @@ class Patcher(ifcpatch.BasePatcher):
|
||||
self.schema = schema
|
||||
|
||||
def patch(self):
|
||||
# IFC4 and IFC4X3 both have geometry / element classes absent in
|
||||
# IFC2X3, so both source schemas need the downgrade preprocessing +
|
||||
# IfcBuildingElementProxy fallback when targeting IFC2X3.
|
||||
is_downgrade_to_ifc2x3 = self.schema == "IFC2X3" and self.file.schema in ("IFC4", "IFC4X3")
|
||||
if is_downgrade_to_ifc2x3:
|
||||
self._prepare_for_downgrade()
|
||||
|
||||
self.file_patched = ifcopenshell.file(schema=self.schema)
|
||||
migrator = ifcopenshell.util.schema.Migrator()
|
||||
migrator = ifcopenshell.util.schema.Migrator(fallback_element_to_proxy=is_downgrade_to_ifc2x3)
|
||||
migrator.preprocess(self.file, self.file_patched)
|
||||
|
||||
migrated = 0
|
||||
failures: list[tuple[ifcopenshell.entity_instance, Exception]] = []
|
||||
for element in self.file:
|
||||
new_element = migrator.migrate(element, self.file_patched)
|
||||
print("Migrating", element)
|
||||
print("Successfully converted to", new_element)
|
||||
try:
|
||||
migrator.migrate(element, self.file_patched)
|
||||
migrated += 1
|
||||
except Exception as exc:
|
||||
failures.append((element, exc))
|
||||
|
||||
if is_downgrade_to_ifc2x3:
|
||||
self._encode_fallback_class_into_object_type(migrator)
|
||||
|
||||
# BasePatcher.__init__ guarantees self.logger is non-None
|
||||
# (ensure_logger falls back to logging.getLogger("IFCPatch")).
|
||||
self.logger.info(f"Migrated {migrated} entities to {self.schema}.")
|
||||
if failures:
|
||||
summary = [f"{len(failures)} entities could not be migrated to {self.schema}:"]
|
||||
for element, exc in failures[:20]:
|
||||
summary.append(f" #{element.id()}={element.is_a()}: {exc}")
|
||||
if len(failures) > 20:
|
||||
summary.append(f" … (+{len(failures) - 20} more)")
|
||||
raise RuntimeError("\n".join(summary))
|
||||
|
||||
def _prepare_for_downgrade(self) -> None:
|
||||
from ifcpatch.recipes.DowngradeIndexedPolyCurve import Patcher as DowngradePolyCurve
|
||||
|
||||
DowngradePolyCurve(self.file, self.logger).patch()
|
||||
self._convert_face_sets_to_faceted_brep()
|
||||
self._purge_orphaned_ifc4_only_entities()
|
||||
|
||||
def _convert_face_sets_to_faceted_brep(self) -> None:
|
||||
face_sets = list(self.file.by_type("IfcPolygonalFaceSet")) + list(self.file.by_type("IfcTriangulatedFaceSet"))
|
||||
if not face_sets:
|
||||
return
|
||||
|
||||
# IfcShapeRepresentations carrying these face sets need their type tag
|
||||
# updated from "Tessellation" (IFC4) to "Brep" (IFC2X3-compatible).
|
||||
# Snapshot the relevant inverses before rewiring — the inverse set is
|
||||
# invalidated once replace_element runs.
|
||||
touched_reps: set[int] = set()
|
||||
for face_set in face_sets:
|
||||
faceted_brep = ifcopenshell.util.shape_builder.polygonal_face_set_to_faceted_brep(face_set)
|
||||
touched_reps.update(
|
||||
inv.id() for inv in self.file.get_inverse(face_set) if inv.is_a("IfcShapeRepresentation")
|
||||
)
|
||||
ifcopenshell.util.element.replace_element(face_set, faceted_brep)
|
||||
|
||||
for rep_id in touched_reps:
|
||||
self.file.by_id(rep_id).RepresentationType = "Brep"
|
||||
|
||||
def _purge_orphaned_ifc4_only_entities(self) -> None:
|
||||
# Preprocessing rewires references away from source-schema-only
|
||||
# carriers but does not delete the now-unreferenced instances
|
||||
# themselves. Sweep iteratively so cascades collapse leaf-first
|
||||
# (curves → point lists, face sets → indexed faces → point lists).
|
||||
# Scoped to the actual source schema so IFC4X3 → IFC2X3 downgrades
|
||||
# also catch IFC4X3-only geometry (IfcAlignmentCurve etc.), not just
|
||||
# the IFC4 gap.
|
||||
targets = ifcopenshell.util.schema.geometry_classes_introduced_after(
|
||||
self.schema, source_schema=self.file.schema
|
||||
)
|
||||
while True:
|
||||
removed = False
|
||||
for ifc_class in targets:
|
||||
for entity in list(self.file.by_type(ifc_class)):
|
||||
if not self.file.get_inverse(entity):
|
||||
self.file.remove(entity)
|
||||
removed = True
|
||||
if not removed:
|
||||
break
|
||||
|
||||
def _encode_fallback_class_into_object_type(self, migrator: ifcopenshell.util.schema.Migrator) -> None:
|
||||
# IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, …) migrate
|
||||
# as IfcBuildingElementProxy. The subclass identity + its PredefinedType
|
||||
# would otherwise be silently lost — IFC2X3 IfcBuildingElementProxy has
|
||||
# no slot for them. Encode "<OriginalClass>/<PredefinedType>" into
|
||||
# ObjectType when empty (don't trample author-supplied values).
|
||||
for source_id, new_id in migrator.migrated_ids.items():
|
||||
try:
|
||||
source = self.file.by_id(source_id)
|
||||
new = self.file_patched.by_id(new_id)
|
||||
except RuntimeError:
|
||||
continue
|
||||
if not new.is_a("IfcBuildingElementProxy"):
|
||||
continue
|
||||
if source.is_a("IfcBuildingElementProxy"):
|
||||
continue
|
||||
if getattr(new, "ObjectType", None):
|
||||
continue
|
||||
predef = getattr(source, "PredefinedType", None)
|
||||
new.ObjectType = f"{source.is_a()}/{predef}" if predef else source.is_a()
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Bonsai Contributors
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import ifcpatch
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
|
||||
def _make_curve(self, segments=None):
|
||||
point_list = self.file.create_entity(
|
||||
"IfcCartesianPointList2D",
|
||||
CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)],
|
||||
)
|
||||
curve = self.file.create_entity(
|
||||
"IfcIndexedPolyCurve",
|
||||
Points=point_list,
|
||||
Segments=segments,
|
||||
)
|
||||
self.file.create_entity(
|
||||
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
|
||||
)
|
||||
return curve
|
||||
|
||||
def test_run_without_segments(self):
|
||||
"""An IfcIndexedPolyCurve with no Segments must downgrade to an
|
||||
IfcPolyline through every CoordList point in order — IFC4 defines
|
||||
the implicit-polyline meaning of an absent Segments list, and the
|
||||
ifcopenshell shape builder emits this form for simple open curves."""
|
||||
self._make_curve(segments=None)
|
||||
ifcpatch.execute(
|
||||
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
|
||||
)
|
||||
polylines = self.file.by_type("IfcPolyline")
|
||||
assert len(polylines) == 1
|
||||
assert len(polylines[0].Points) == 3
|
||||
|
||||
def test_run_with_line_segments(self):
|
||||
"""Line-segmented IfcIndexedPolyCurves downgrade to an equivalent IfcPolyline."""
|
||||
segments = [
|
||||
self.file.createIfcLineIndex((1, 2)),
|
||||
self.file.createIfcLineIndex((2, 3)),
|
||||
]
|
||||
self._make_curve(segments=segments)
|
||||
ifcpatch.execute(
|
||||
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
|
||||
)
|
||||
polylines = self.file.by_type("IfcPolyline")
|
||||
assert len(polylines) == 1
|
||||
assert len(polylines[0].Points) == 3
|
||||
|
||||
def test_run_with_multi_index_line_segment(self):
|
||||
"""An IfcLineIndex with >2 indices encodes a polyline through every
|
||||
index — the downgraded IfcPolyline must include every one of them.
|
||||
This is the canonical form Bonsai's shape builder emits for closed
|
||||
rectangle profiles (e.g. parametric wall body outlines), serialised
|
||||
as ``IfcIndexedPolyCurve(Points, (IfcLineIndex((1,2,3,4,1))))``."""
|
||||
point_list = self.file.create_entity(
|
||||
"IfcCartesianPointList2D",
|
||||
CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
)
|
||||
curve = self.file.create_entity(
|
||||
"IfcIndexedPolyCurve",
|
||||
Points=point_list,
|
||||
Segments=[self.file.createIfcLineIndex((1, 2, 3, 4, 1))],
|
||||
)
|
||||
self.file.create_entity(
|
||||
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
|
||||
)
|
||||
ifcpatch.execute(
|
||||
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
|
||||
)
|
||||
polylines = self.file.by_type("IfcPolyline")
|
||||
assert len(polylines) == 1
|
||||
assert len(polylines[0].Points) == 5
|
||||
coords = [p.Coordinates for p in polylines[0].Points]
|
||||
assert coords[0] == coords[-1] == (0.0, 0.0)
|
||||
assert coords[1] == (1.0, 0.0)
|
||||
assert coords[2] == (1.0, 1.0)
|
||||
assert coords[3] == (0.0, 1.0)
|
||||
|
||||
def test_run_with_chained_multi_index_segments(self):
|
||||
"""When two IfcLineIndex segments are chained, the shared endpoint
|
||||
between them must appear once, not twice."""
|
||||
point_list = self.file.create_entity(
|
||||
"IfcCartesianPointList2D",
|
||||
CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
)
|
||||
curve = self.file.create_entity(
|
||||
"IfcIndexedPolyCurve",
|
||||
Points=point_list,
|
||||
Segments=[
|
||||
self.file.createIfcLineIndex((1, 2, 3)),
|
||||
self.file.createIfcLineIndex((3, 4)),
|
||||
],
|
||||
)
|
||||
self.file.create_entity(
|
||||
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
|
||||
)
|
||||
ifcpatch.execute(
|
||||
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
|
||||
)
|
||||
polylines = self.file.by_type("IfcPolyline")
|
||||
assert len(polylines) == 1
|
||||
coords = [p.Coordinates for p in polylines[0].Points]
|
||||
assert coords == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
|
||||
|
||||
def test_run_facets_arc_segments(self):
|
||||
"""Arc-segmented IfcIndexedPolyCurves are downgraded by sampling the
|
||||
circular arc into a chord polyline. The chord count is fixed by
|
||||
the recipe's subdivision parameter."""
|
||||
from ifcpatch.recipes.DowngradeIndexedPolyCurve import ARC_SUBDIVISION
|
||||
|
||||
segments = [self.file.createIfcArcIndex((1, 2, 3))]
|
||||
self._make_curve(segments=segments)
|
||||
ifcpatch.execute(
|
||||
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
|
||||
)
|
||||
polylines = self.file.by_type("IfcPolyline")
|
||||
assert len(polylines) == 1
|
||||
assert len(polylines[0].Points) == ARC_SUBDIVISION + 1
|
||||
@@ -17,6 +17,9 @@
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.project
|
||||
import ifcpatch
|
||||
import test.bootstrap
|
||||
|
||||
@@ -27,3 +30,181 @@ class TestMigrate(test.bootstrap.IFC4):
|
||||
old_file.header.file_name.name = "test"
|
||||
new_file = ifcpatch.execute({"file": old_file, "recipe": "Migrate", "arguments": ["IFC4"]})
|
||||
assert new_file.header.file_name.name == "test"
|
||||
|
||||
def test_migrate_ifc4_to_ifc2x3_flattens_indexed_polycurve(self):
|
||||
"""Downgrade IFC4 → IFC2X3 should auto-run DowngradeIndexedPolyCurve on
|
||||
IfcIndexedPolyCurve carriers, so the migrated file uses IfcPolyline (which
|
||||
exists in IFC2X3) instead of crashing on the IFC4-only curve class."""
|
||||
ifc4_file = self.file
|
||||
point_list = ifc4_file.create_entity(
|
||||
"IfcCartesianPointList2D",
|
||||
CoordList=((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)),
|
||||
)
|
||||
segments = [
|
||||
ifc4_file.create_entity("IfcLineIndex", (1, 2)),
|
||||
ifc4_file.create_entity("IfcLineIndex", (2, 3)),
|
||||
ifc4_file.create_entity("IfcLineIndex", (3, 4)),
|
||||
ifc4_file.create_entity("IfcLineIndex", (4, 1)),
|
||||
]
|
||||
curve = ifc4_file.create_entity(
|
||||
"IfcIndexedPolyCurve", Points=point_list, Segments=segments, SelfIntersect=False
|
||||
)
|
||||
ifc4_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
|
||||
|
||||
new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
|
||||
|
||||
assert new_file.schema == "IFC2X3"
|
||||
new_profile = new_file.by_type("IfcArbitraryClosedProfileDef")[0]
|
||||
assert new_profile.OuterCurve.is_a("IfcPolyline")
|
||||
# The preprocessing step should have purged orphaned IFC4-only entities
|
||||
# from the source before the migration loop reached them.
|
||||
assert not ifc4_file.by_type("IfcIndexedPolyCurve")
|
||||
assert not ifc4_file.by_type("IfcCartesianPointList2D")
|
||||
|
||||
def test_migrate_ifc4_to_ifc2x3_encodes_fallback_class_in_object_type(self):
|
||||
"""IfcLamp / IfcPipeSegment / IfcGeographicElement fall back to
|
||||
IfcBuildingElementProxy on downgrade. The original class and
|
||||
PredefinedType are encoded into ObjectType so the type info survives
|
||||
— but only when ObjectType is empty (author-supplied values stay)."""
|
||||
ifc4_file = self.file
|
||||
ifc4_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT")
|
||||
ifc4_file.create_entity("IfcPipeSegment", GlobalId="0_bkftCTnBCOOZeUxtJngE")
|
||||
ifc4_file.create_entity(
|
||||
"IfcGeographicElement",
|
||||
GlobalId="3_b4gD1aP3ARmIm2ePijXi",
|
||||
ObjectType="Terrain Mesh", # author-supplied, must not be overwritten
|
||||
PredefinedType="TERRAIN",
|
||||
)
|
||||
|
||||
new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
|
||||
|
||||
proxies = {p.GlobalId: p for p in new_file.by_type("IfcBuildingElementProxy")}
|
||||
# IfcLamp with no author ObjectType: encoded as IfcLamp/COMPACTFLUORESCENT.
|
||||
assert proxies["2K6Z3DR8X37AS9XFvX8GcW"].ObjectType == "IfcLamp/COMPACTFLUORESCENT"
|
||||
# IfcPipeSegment with no PredefinedType set: just the class name.
|
||||
assert proxies["0_bkftCTnBCOOZeUxtJngE"].ObjectType == "IfcPipeSegment"
|
||||
# IfcGeographicElement with author ObjectType: preserved as-is.
|
||||
assert proxies["3_b4gD1aP3ARmIm2ePijXi"].ObjectType == "Terrain Mesh"
|
||||
|
||||
def test_migrate_ifc4_to_ifc2x3_converts_polygonal_face_set_to_faceted_brep(self):
|
||||
"""IfcPolygonalFaceSet has no IFC2X3 equivalent. Direct entity-level
|
||||
conversion produces an IfcFacetedBrep with the same topology, regardless
|
||||
of which representation context the source lived in."""
|
||||
ifc4_file = self.file
|
||||
coords = ifc4_file.create_entity(
|
||||
"IfcCartesianPointList3D",
|
||||
CoordList=(
|
||||
(0.0, 0.0, 0.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(0.5, 0.5, 1.0),
|
||||
),
|
||||
)
|
||||
# Square base + 4 triangle sides — a simple pyramid.
|
||||
faces = [
|
||||
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(1, 2, 3, 4)),
|
||||
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(1, 2, 5)),
|
||||
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(2, 3, 5)),
|
||||
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(3, 4, 5)),
|
||||
ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(4, 1, 5)),
|
||||
]
|
||||
face_set = ifc4_file.create_entity("IfcPolygonalFaceSet", Coordinates=coords, Faces=faces)
|
||||
context = ifc4_file.create_entity(
|
||||
"IfcGeometricRepresentationContext",
|
||||
ContextType="Model",
|
||||
CoordinateSpaceDimension=3,
|
||||
Precision=0.01,
|
||||
WorldCoordinateSystem=ifc4_file.createIfcAxis2Placement3D(
|
||||
Location=ifc4_file.createIfcCartesianPoint((0.0, 0.0, 0.0))
|
||||
),
|
||||
)
|
||||
ifc4_file.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
ContextOfItems=context,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="Tessellation",
|
||||
Items=[face_set],
|
||||
)
|
||||
|
||||
new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
|
||||
|
||||
assert new_file.schema == "IFC2X3"
|
||||
breps = new_file.by_type("IfcFacetedBrep")
|
||||
assert len(breps) == 1
|
||||
brep = breps[0]
|
||||
assert len(brep.Outer.CfsFaces) == 5
|
||||
# Coordinates from the source CartesianPointList3D must appear in the
|
||||
# resulting brep's loop points — otherwise the conversion silently
|
||||
# corrupted geometry.
|
||||
brep_coords = {tuple(p.Coordinates) for face in brep.Outer.CfsFaces for p in face.Bounds[0].Bound.Polygon}
|
||||
for expected in ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)):
|
||||
assert expected in brep_coords, f"vertex {expected} missing from converted brep"
|
||||
rep = new_file.by_type("IfcShapeRepresentation")[0]
|
||||
assert rep.RepresentationType == "Brep"
|
||||
assert rep.Items[0].is_a("IfcFacetedBrep")
|
||||
|
||||
def test_migrate_ifc4_to_ifc2x3_summarises_unmappable_entities(self):
|
||||
"""When an IFC4-only entity that cannot be auto-substituted survives
|
||||
preprocessing, the recipe must surface a summary RuntimeError naming
|
||||
the failing class — not the cryptic ``RuntimeError: Entity with name
|
||||
'' not found``.
|
||||
|
||||
Uses ``IfcWorkCalendar`` as the fixture — an IFC4 entity that
|
||||
(a) is not an IfcRepresentationItem (skips the geometry purge),
|
||||
(b) is not an IfcElement (skips the proxy fallback),
|
||||
(c) has no IFC2X3 equivalent in ``class_4_to_2x3.json`` (mapped to ``""``).
|
||||
These three conditions together guarantee it always reaches the
|
||||
unmappable error path, independent of future schema additions."""
|
||||
ifc4_file = self.file
|
||||
ifc4_file.create_entity("IfcWorkCalendar", GlobalId="2K6Z3DR8X37AS9XFvX8GcW")
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "IfcWorkCalendar" in message
|
||||
|
||||
def test_migrate_ifc4x3_to_ifc2x3_runs_downgrade_preprocessing(self):
|
||||
"""IFC4X3 → IFC2X3 must trigger the same downgrade preprocessing as
|
||||
IFC4 → IFC2X3: curve flatten, face-set → brep, IfcBuildingElementProxy
|
||||
fallback, ObjectType encoding. Pins the gate at
|
||||
``self.file.schema in ('IFC4', 'IFC4X3')`` — a narrower check would
|
||||
silently leave IFC4X3 sources crashing on IFC4-only geometry."""
|
||||
ifc4x3_file = ifcopenshell.api.project.create_file(version="IFC4X3")
|
||||
ifc4x3_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT")
|
||||
|
||||
new_file = ifcpatch.execute({"file": ifc4x3_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
|
||||
|
||||
assert new_file.schema == "IFC2X3"
|
||||
proxies = new_file.by_type("IfcBuildingElementProxy")
|
||||
assert len(proxies) == 1
|
||||
# ObjectType encoding ran — same as the IFC4 → IFC2X3 case.
|
||||
assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT"
|
||||
|
||||
def test_migrate_ifc4_to_ifc2x3_flattens_arc_bearing_indexed_polycurve(self):
|
||||
"""An IfcIndexedPolyCurve with IfcArcIndex segments is approximated
|
||||
with a chord polyline rather than skipped, so the parent profile def
|
||||
and its representations stay parametric (no fallback to tessellation)."""
|
||||
ifc4_file = self.file
|
||||
point_list = ifc4_file.create_entity(
|
||||
"IfcCartesianPointList2D",
|
||||
CoordList=((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)),
|
||||
)
|
||||
# Two half-arcs forming a circle: (1,0)→(0,1)→(-1,0)→(0,-1)→(1,0).
|
||||
segments = [
|
||||
ifc4_file.create_entity("IfcArcIndex", (1, 2, 3)),
|
||||
ifc4_file.create_entity("IfcArcIndex", (3, 4, 1)),
|
||||
]
|
||||
curve = ifc4_file.create_entity(
|
||||
"IfcIndexedPolyCurve", Points=point_list, Segments=segments, SelfIntersect=False
|
||||
)
|
||||
ifc4_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
|
||||
|
||||
new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]})
|
||||
|
||||
assert new_file.schema == "IFC2X3"
|
||||
new_profile = new_file.by_type("IfcArbitraryClosedProfileDef")[0]
|
||||
assert new_profile.OuterCurve.is_a("IfcPolyline")
|
||||
# Arc subdivision should produce many more points than the 4 input coords.
|
||||
assert len(new_profile.OuterCurve.Points) > 4
|
||||
|
||||
Reference in New Issue
Block a user