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
@@ -21,6 +21,7 @@ import bpy
from . import operator, prop, ui
classes = (
operator.AddIfcPatchPreset,
operator.ExecuteIfcPatch,
operator.ExtractSelectedElements,
operator.RunMigratePatch,
@@ -28,6 +29,7 @@ classes = (
operator.SelectIfcPatchOutput,
operator.UpdateIfcPatchArguments,
prop.BIMPatchProperties,
ui.BIM_MT_ifc_patch_presets,
ui.BIM_PT_patch,
)
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast
import bpy
import ifcopenshell
import ifcpatch
from bl_operators.presets import AddPresetBase
from bpy_extras.io_utils import ExportHelper, ImportHelper
import bonsai.bim.handler
@@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator):
return False
return True
def invoke(self, context, event):
# Migrating IFC4 → IFC2X3 is lossy (enum drops, IFC4-only classes
# become IfcBuildingElementProxy, tessellated meshes get rebuilt as
# IfcFacetedBrep). Confirm before running so the user knows.
if tool.Patch.migration_is_lossy_downgrade():
return context.window_manager.invoke_props_dialog(self, width=480)
return self.execute(context)
def draw(self, context):
layout = self.layout
layout.label(text="Downgrading to IFC2X3 is lossy.", icon="ERROR")
column = layout.column(align=True)
column.label(text="Geometry will be preserved as faithfully as possible:")
column.label(text="• IfcIndexedPolyCurve → IfcPolyline (arcs approximated by chords)")
column.label(text="• IfcPolygonalFaceSet / IfcTriangulatedFaceSet → IfcFacetedBrep")
column.separator()
column.label(text="The following information is lost:")
column.label(text="• IFC4-only classes (IfcLamp, IfcPipeSegment, …) → IfcBuildingElementProxy")
column.label(text="• PredefinedType enum values absent from IFC2X3 are dropped")
column.label(text=" (original class + enum saved as ObjectType, e.g. 'IfcLamp/COMPACTFLUORESCENT')")
def execute(self, context):
props = tool.Patch.get_patch_props()
recipe_name = props.ifc_patch_recipes
@@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator):
query = tool.Search.get_query_for_selected_elements()
props.ifc_patch_args_attr[0].string_value = query
return {"FINISHED"}
class AddIfcPatchPreset(AddPresetBase, bpy.types.Operator):
"""Save / remove ifc-patch argument presets, scoped per recipe.
Presets live in the standard Blender preset directory under
``bonsai/ifc_patch/<recipe>/`` so a preset created for ``ExtractElements``
does not pollute the preset list for ``Migrate``. Persistence across files
and sessions is inherited from Blender's preset system."""
bl_idname = "bim.add_ifc_patch_preset"
bl_label = "Add IFC Patch Preset"
preset_menu = "BIM_MT_ifc_patch_presets"
preset_defines = ["props = bpy.context.scene.BIMPatchProperties"]
@property
def preset_subdir(self) -> str:
return tool.Patch.get_preset_subdir()
@property
def preset_values(self) -> list[str]:
# `Attribute.get_value_name()` returns the storage field for the
# argument's data_type (string_value, bool_value, …). For file
# arguments it returns the wrapping PointerProperty (`filepath_value`)
# — the scalar path the preset needs is `.single_file` on that.
props = tool.Patch.get_patch_props()
values = []
for i, arg in enumerate(props.ifc_patch_args_attr):
field = arg.get_value_name()
if not field:
continue
if arg.data_type == "file":
field = f"{field}.single_file"
values.append(f"props.ifc_patch_args_attr[{i}].{field}")
return values
@@ -71,6 +71,15 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context)
def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None:
bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes)
# Blender's script.execute_preset mutates the menu class's bl_label to
# the loaded preset's display name (used as a "currently selected"
# indicator). The label persists across recipe changes — making the new
# recipe's menu falsely show the previous recipe's preset name. Reset
# the label to the menu's canonical title so it always matches the
# active recipe's preset list.
menu_cls = getattr(bpy.types, "BIM_MT_ifc_patch_presets", None)
if menu_cls is not None:
menu_cls.bl_label = "IFC Patch Presets"
class BIMPatchProperties(PropertyGroup):
+19
View File
@@ -29,6 +29,20 @@ if TYPE_CHECKING:
from bonsai.bim.prop import Attribute
class BIM_MT_ifc_patch_presets(bpy.types.Menu):
"""Lists ifc-patch presets for the currently selected recipe.
``preset_subdir`` is resolved per draw so switching recipes swaps the
preset list without re-registering the menu."""
bl_label = "IFC Patch Presets"
preset_operator = "script.execute_preset"
def draw(self, context: bpy.types.Context) -> None:
self.preset_subdir = tool.Patch.get_preset_subdir()
bpy.types.Menu.draw_preset(self, context)
class BIM_PT_patch(bpy.types.Panel):
bl_label = "Patch"
bl_idname = "BIM_PT_patch"
@@ -66,6 +80,11 @@ class BIM_PT_patch(bpy.types.Panel):
row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER")
if props.ifc_patch_args_attr:
preset_row = layout.row(heading="Preset", align=True)
preset_row.menu("BIM_MT_ifc_patch_presets", text=BIM_MT_ifc_patch_presets.bl_label)
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="ADD")
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="REMOVE").remove_active = True
draw_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None
draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback)
+72
View File
@@ -18,18 +18,33 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell
import ifcopenshell.util.schema
import ifcpatch
import bonsai.core.tool
import bonsai.tool
if TYPE_CHECKING:
from bonsai.bim.module.patch.prop import BIMPatchProperties
# Lower index = older schema. Used to detect downgrades vs upgrades.
_SCHEMA_AGE = {"IFC2X3": 0, "IFC4": 1, "IFC4X3": 2}
# Pretty-printed argument name for the ``Migrate`` recipe's schema parameter
# (see UpdateIfcPatchArguments.pretty_arg_name in bim/module/patch/operator.py).
_MIGRATE_SCHEMA_ARG_NAME = "Schema"
# Match a STEP-encoded FILE_SCHEMA header: ``FILE_SCHEMA(('IFC4'));`` and the
# IFC4X3_ADD2 / IFC2X3_TC1 variants. Captures the bare schema identifier.
_IFC_FILE_SCHEMA_RE = re.compile(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", re.IGNORECASE)
class Patch(bonsai.core.tool.Patch):
@classmethod
def get_patch_props(cls) -> BIMPatchProperties:
@@ -54,6 +69,63 @@ class Patch(bonsai.core.tool.Patch):
"SplitByBuildingStorey",
)
@classmethod
def get_preset_subdir(cls) -> str:
"""Resolve the preset subdirectory for the currently selected recipe.
Returns a stable string for the ``-`` placeholder so the menu and save
operator remain usable when no real recipe has been picked yet."""
recipe = cls.get_patch_props().ifc_patch_recipes or "-"
return f"bonsai/ifc_patch/{recipe}"
@classmethod
def migration_is_lossy_downgrade(cls) -> bool:
"""``True`` when the currently configured patch is the ``Migrate``
recipe targeting an older schema than the input file. Used to gate
the destructive-migration confirmation dialog."""
props = cls.get_patch_props()
if props.ifc_patch_recipes != "Migrate":
return False
target_schema = next(
(arg.get_value() for arg in props.ifc_patch_args_attr if arg.name == _MIGRATE_SCHEMA_ARG_NAME),
None,
)
if not target_schema:
return False
source_schema = cls._patch_source_schema()
if not source_schema:
return False
return _SCHEMA_AGE.get(target_schema, -1) < _SCHEMA_AGE.get(source_schema, -1)
@classmethod
def _patch_source_schema(cls) -> str:
"""Resolve the IFC schema of the configured input without parsing the
full file. For loaded-from-memory the schema is in the entity_instance
wrapper; for disk paths we read only the STEP file header (first ~2KB)
rather than ``ifcopenshell.open`` which parses the whole file."""
props = cls.get_patch_props()
if props.should_load_from_memory:
ifc_file = bonsai.tool.Ifc.get()
return ifc_file.schema if ifc_file else ""
if not props.ifc_patch_input:
return ""
try:
with open(props.ifc_patch_input, "rb") as f:
header = f.read(2048).decode("utf-8", errors="ignore")
except OSError:
return ""
match = _IFC_FILE_SCHEMA_RE.search(header)
if not match:
return ""
# Collapse IFC4X3_ADD2 / IFC2X3_TC1 / IFC4_ADD2 / IFC4X1 etc. to their
# base via the canonical normaliser — handles longest-prefix-first
# ordering correctly (IFC4X3 before IFC4) so we don't misclassify
# IFC4X3 files as IFC4.
try:
return ifcopenshell.util.schema.get_fallback_schema(match.group(1).upper())
except AssertionError:
return ""
@classmethod
def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]:
if recipe == "ExtractElements":
@@ -0,0 +1,70 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import tempfile
from pathlib import Path
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.patch
class TestExecuteIfcPatchDowngradeEndToEnd(NewFile):
"""Drives the full panel flow the user sees: load IFC4 in memory, pick
Migrate + IFC2X3, click Execute, get an IFC2X3 file on disk with the
expected IfcBuildingElementProxy fallback + ObjectType encoding.
A regression here means a real user clicking Execute either crashes
Blender, produces a broken file, or silently drops type information
that the recipe is supposed to preserve via ObjectType."""
def test_ifc4_with_ifclamp_downgrades_to_ifc2x3_with_proxy_and_object_type(self):
ifc = ifcopenshell.file(schema="IFC4")
ifc.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT")
tool.Ifc.set(ifc)
props = tool.Patch.get_patch_props()
props.should_load_from_memory = True
props.ifc_patch_recipes = "Migrate"
next(a for a in props.ifc_patch_args_attr if a.name == "Schema").enum_value = "IFC2X3"
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "downgraded.ifc"
props.ifc_patch_output = str(output_path)
result = bpy.ops.bim.execute_ifc_patch()
assert result == {"FINISHED"}
assert output_path.exists(), "Recipe ran but no output file was written"
written = ifcopenshell.open(str(output_path))
assert written.schema == "IFC2X3"
proxies = written.by_type("IfcBuildingElementProxy")
assert len(proxies) == 1, "IfcLamp should fall back to a single IfcBuildingElementProxy"
assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT", (
"Original class + PredefinedType must be encoded into ObjectType "
"so the downgrade isn't a total information loss"
)
assert proxies[0].GlobalId == "2K6Z3DR8X37AS9XFvX8GcW"
@@ -0,0 +1,131 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import tempfile
from pathlib import Path
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.patch
def _set_patch_state(*, recipe: str, target_schema: str | None, source_ifc: ifcopenshell.file | None = None) -> None:
"""Drive the BIMPatchProperties into the configuration that a user produces
by picking Recipe + Schema in the panel + checking "Load from memory".
Setting the recipe fires UpdateIfcPatchArguments which builds the dynamic
args collection — only then can we assign the schema arg's enum_value."""
props = tool.Patch.get_patch_props()
if source_ifc is not None:
tool.Ifc.set(source_ifc)
props.should_load_from_memory = True
props.ifc_patch_recipes = recipe # update callback builds ifc_patch_args_attr
if target_schema is not None:
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
schema_arg.enum_value = target_schema
class TestMigrationIsLossyDowngrade(NewFile):
"""Pins the predicate that gates ``ExecuteIfcPatch.invoke``'s
confirmation popup. Every row of the truth table corresponds to a real
user-facing flow — wrong answers either nag the user on safe migrations
or silently let lossy ones through with no warning."""
def test_ifc4_to_ifc2x3_in_memory_is_lossy(self):
ifc = ifcopenshell.file(schema="IFC4")
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is True
def test_ifc4x3_to_ifc2x3_in_memory_is_lossy(self):
# Regression for the gate that originally only fired for self.file.schema == "IFC4",
# silently leaving IFC4X3 sources crashing on IFC4-only geometry.
ifc = ifcopenshell.file(schema="IFC4X3")
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is True
def test_ifc2x3_to_ifc4_upgrade_is_not_lossy(self):
ifc = ifcopenshell.file(schema="IFC2X3")
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is False
def test_ifc4_to_ifc4_same_schema_is_not_lossy(self):
ifc = ifcopenshell.file(schema="IFC4")
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is False
def test_non_migrate_recipe_is_not_lossy(self):
# The popup only ever applies to the Migrate recipe — other recipes
# (ExtractElements, TessellateElements, …) handle their own warnings.
ifc = ifcopenshell.file(schema="IFC4")
_set_patch_state(recipe="ExtractElements", target_schema=None, source_ifc=ifc)
assert tool.Patch.migration_is_lossy_downgrade() is False
def test_no_source_set_is_not_lossy(self):
# Without an input file or in-memory IFC, the predicate cannot tell
# what the source schema is — defaults to False so the popup doesn't
# block harmless cases where the user is still configuring the panel.
props = tool.Patch.get_patch_props()
props.ifc_patch_recipes = "Migrate"
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
schema_arg.enum_value = "IFC2X3"
assert tool.Patch.migration_is_lossy_downgrade() is False
class TestPatchSourceSchemaSniff(NewFile):
"""End-to-end pin on the header-only schema parsing. The IFC4X3 misdetection
bug originally lived in this code path — a raw startswith(\"IFC4\") loop
matching IFC4X3_ADD2 before the IFC4X3 base check was reached."""
def test_in_memory_ifc4x3_source_resolves_to_ifc4x3(self):
ifc = ifcopenshell.file(schema="IFC4X3")
tool.Ifc.set(ifc)
props = tool.Patch.get_patch_props()
props.should_load_from_memory = True
assert tool.Patch._patch_source_schema() == "IFC4X3"
def test_file_path_ifc4x3_add2_source_resolves_to_ifc4x3(self):
# Writes a real .ifc file with IFC4X3_ADD2 in the FILE_SCHEMA header
# and confirms the regex + get_fallback_schema normaliser correctly
# collapse it to IFC4X3, not IFC4.
with tempfile.TemporaryDirectory() as tmpdir:
ifc_path = Path(tmpdir) / "sample.ifc"
ifc_path.write_text(
"ISO-10303-21;\n"
"HEADER;\n"
"FILE_DESCRIPTION((''),'2;1');\n"
"FILE_NAME('','2026',(''),(''),'','','');\n"
"FILE_SCHEMA(('IFC4X3_ADD2'));\n"
"ENDSEC;\n"
"DATA;\nENDSEC;\nEND-ISO-10303-21;\n"
)
props = tool.Patch.get_patch_props()
props.should_load_from_memory = False
props.ifc_patch_input = str(ifc_path)
assert tool.Patch._patch_source_schema() == "IFC4X3"
def test_missing_input_returns_empty_string(self):
props = tool.Patch.get_patch_props()
props.should_load_from_memory = False
props.ifc_patch_input = ""
assert tool.Patch._patch_source_schema() == ""
@@ -0,0 +1,55 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import bpy
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.patch
class TestPresetMenuLabelResetsOnRecipeChange(NewFile):
"""Blender's ``script.execute_preset`` mutates the menu class's bl_label
to the loaded preset's display name as a "currently-selected" indicator.
Without a recipe-change callback, that label persists into the next
recipe's menu — falsely advertising a preset that belongs to a
different recipe's subdir and isn't selectable from the new menu."""
def test_changing_recipe_restores_canonical_label(self):
# Simulate the state Blender leaves after the user picked a preset
# for the previous recipe.
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
menu_cls.bl_label = "Structural"
# Switching the recipe must fire update_ifc_patch_recipe, which
# resets the menu label.
props = tool.Patch.get_patch_props()
props.ifc_patch_recipes = "Migrate"
assert menu_cls.bl_label == "IFC Patch Presets"
def test_canonical_label_is_used_when_no_preset_was_loaded(self):
# Fresh state — label is the bl_label-default from the class declaration.
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
props = tool.Patch.get_patch_props()
props.ifc_patch_recipes = "ExtractElements"
assert menu_cls.bl_label == "IFC Patch Presets"