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
+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":