mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-28 19:27:10 +00:00
Bonsai patch: lossy-downgrade popup + per-recipe preset menu
Two new UX features in the IFC Patch panel, both backed by helpers on bonsai.tool.Patch. Lossy-downgrade confirmation popup. When the user picks the Migrate recipe with a target schema older than the source's (IFC4 -> IFC2X3, IFC4X3 -> IFC2X3), ExecuteIfcPatch.invoke shows a properties dialog listing what's preserved vs lost: IfcIndexedPolyCurve flattened with arcs approximated, IfcPolygonalFaceSet / IfcTriangulatedFaceSet converted to IfcFacetedBrep, IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, ...) demoted to IfcBuildingElementProxy with the original class + PredefinedType encoded into ObjectType, and PredefinedType enum values absent from IFC2X3 dropped. The user explicitly approves before the recipe runs. The popup is gated on tool.Patch.migration_is_lossy_downgrade() which resolves the source schema via header-only parsing (tool.Patch._patch_source_schema reads the first ~2KB and matches a FILE_SCHEMA regex, then normalises via ifcopenshell.util.schema. get_fallback_schema). Avoids a full ifcopenshell.open() on every Execute click — multi-second saving on large files. The target schema is looked up by argument name rather than position so it survives recipe-parameter reordering. Per-recipe preset menu. New BIM_MT_ifc_patch_presets + AddIfcPatchPreset wire Blender's standard preset system into the panel. Each recipe gets its own preset subdirectory (bonsai/ifc_patch/<RecipeName>/), so a preset saved for ExtractElements does not pollute the Migrate preset list. The preset operator uses Attribute.get_value_name() (single source of truth for data_type -> storage-field mapping) to build the preset_values list dynamically per recipe. The recipe-change callback resets BIM_MT_ifc_patch_presets.bl_label to the canonical title — Blender's script.execute_preset mutates the menu's bl_label to the loaded preset's name as a "currently-selected" indicator, and without an explicit reset the previous recipe's preset name would falsely advertise itself in the new recipe's menu. tool.Patch gains get_preset_subdir, migration_is_lossy_downgrade, _patch_source_schema as cross-cutting helpers. _SCHEMA_AGE module constant provides the ordering used by the downgrade-detection predicate. Test coverage: 12 bim-lane tests under test/bim/module/patch/. The truth table for migration_is_lossy_downgrade covers IFC4/IFC4X3 source x downgrade/upgrade/same-schema target x Migrate/non-Migrate recipe. The schema-sniffing tests write a real IFC4X3_ADD2 file to disk and assert the helper resolves it to IFC4X3 (regression for the original startswith iteration-order bug). An end-to-end test drives bpy.ops.bim.execute_ifc_patch with an in-memory IfcLamp source and verifies the on-disk IFC2X3 file contains a single IfcBuildingElementProxy with ObjectType "IfcLamp/COMPACTFLUORESCENT" and the original GlobalId preserved. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -21,6 +21,7 @@ import bpy
|
|||||||
from . import operator, prop, ui
|
from . import operator, prop, ui
|
||||||
|
|
||||||
classes = (
|
classes = (
|
||||||
|
operator.AddIfcPatchPreset,
|
||||||
operator.ExecuteIfcPatch,
|
operator.ExecuteIfcPatch,
|
||||||
operator.ExtractSelectedElements,
|
operator.ExtractSelectedElements,
|
||||||
operator.RunMigratePatch,
|
operator.RunMigratePatch,
|
||||||
@@ -28,6 +29,7 @@ classes = (
|
|||||||
operator.SelectIfcPatchOutput,
|
operator.SelectIfcPatchOutput,
|
||||||
operator.UpdateIfcPatchArguments,
|
operator.UpdateIfcPatchArguments,
|
||||||
prop.BIMPatchProperties,
|
prop.BIMPatchProperties,
|
||||||
|
ui.BIM_MT_ifc_patch_presets,
|
||||||
ui.BIM_PT_patch,
|
ui.BIM_PT_patch,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast
|
|||||||
import bpy
|
import bpy
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
import ifcpatch
|
import ifcpatch
|
||||||
|
from bl_operators.presets import AddPresetBase
|
||||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||||
|
|
||||||
import bonsai.bim.handler
|
import bonsai.bim.handler
|
||||||
@@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator):
|
|||||||
return False
|
return False
|
||||||
return True
|
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):
|
def execute(self, context):
|
||||||
props = tool.Patch.get_patch_props()
|
props = tool.Patch.get_patch_props()
|
||||||
recipe_name = props.ifc_patch_recipes
|
recipe_name = props.ifc_patch_recipes
|
||||||
@@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator):
|
|||||||
query = tool.Search.get_query_for_selected_elements()
|
query = tool.Search.get_query_for_selected_elements()
|
||||||
props.ifc_patch_args_attr[0].string_value = query
|
props.ifc_patch_args_attr[0].string_value = query
|
||||||
return {"FINISHED"}
|
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:
|
def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None:
|
||||||
bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes)
|
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):
|
class BIMPatchProperties(PropertyGroup):
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ if TYPE_CHECKING:
|
|||||||
from bonsai.bim.prop import Attribute
|
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):
|
class BIM_PT_patch(bpy.types.Panel):
|
||||||
bl_label = "Patch"
|
bl_label = "Patch"
|
||||||
bl_idname = "BIM_PT_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")
|
row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER")
|
||||||
|
|
||||||
if props.ifc_patch_args_attr:
|
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_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None
|
||||||
draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback)
|
draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback)
|
||||||
|
|
||||||
|
|||||||
@@ -18,18 +18,33 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
|
import ifcopenshell.util.schema
|
||||||
import ifcpatch
|
import ifcpatch
|
||||||
|
|
||||||
import bonsai.core.tool
|
import bonsai.core.tool
|
||||||
|
import bonsai.tool
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bonsai.bim.module.patch.prop import BIMPatchProperties
|
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):
|
class Patch(bonsai.core.tool.Patch):
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_patch_props(cls) -> BIMPatchProperties:
|
def get_patch_props(cls) -> BIMPatchProperties:
|
||||||
@@ -54,6 +69,63 @@ class Patch(bonsai.core.tool.Patch):
|
|||||||
"SplitByBuildingStorey",
|
"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
|
@classmethod
|
||||||
def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]:
|
def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]:
|
||||||
if recipe == "ExtractElements":
|
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"
|
||||||
Reference in New Issue
Block a user