mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 23:36:20 +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:
@@ -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