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:
Gorgious56
2026-06-23 09:48:54 +02:00
parent 2ab5ca9222
commit 44c0c2916c
9 changed files with 415 additions and 0 deletions
@@ -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"