diff --git a/src/bonsai/bonsai/bim/module/patch/__init__.py b/src/bonsai/bonsai/bim/module/patch/__init__.py index fd5de30d38..903e29da6d 100644 --- a/src/bonsai/bonsai/bim/module/patch/__init__.py +++ b/src/bonsai/bonsai/bim/module/patch/__init__.py @@ -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, ) diff --git a/src/bonsai/bonsai/bim/module/patch/operator.py b/src/bonsai/bonsai/bim/module/patch/operator.py index 99459b99e9..531b7c581c 100644 --- a/src/bonsai/bonsai/bim/module/patch/operator.py +++ b/src/bonsai/bonsai/bim/module/patch/operator.py @@ -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//`` 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 diff --git a/src/bonsai/bonsai/bim/module/patch/prop.py b/src/bonsai/bonsai/bim/module/patch/prop.py index e14bb3b1ef..ae9793c01d 100644 --- a/src/bonsai/bonsai/bim/module/patch/prop.py +++ b/src/bonsai/bonsai/bim/module/patch/prop.py @@ -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): diff --git a/src/bonsai/bonsai/bim/module/patch/ui.py b/src/bonsai/bonsai/bim/module/patch/ui.py index c3101b4705..98c262bb1b 100644 --- a/src/bonsai/bonsai/bim/module/patch/ui.py +++ b/src/bonsai/bonsai/bim/module/patch/ui.py @@ -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) diff --git a/src/bonsai/bonsai/tool/patch.py b/src/bonsai/bonsai/tool/patch.py index 6ae06b5e10..6d8ca11939 100644 --- a/src/bonsai/bonsai/tool/patch.py +++ b/src/bonsai/bonsai/tool/patch.py @@ -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": diff --git a/src/bonsai/test/bim/module/patch/__init__.py b/src/bonsai/test/bim/module/patch/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py b/src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py new file mode 100644 index 0000000000..aa9600a045 --- /dev/null +++ b/src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py @@ -0,0 +1,70 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . +# +# 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" diff --git a/src/bonsai/test/bim/module/patch/test_lossy_downgrade.py b/src/bonsai/test/bim/module/patch/test_lossy_downgrade.py new file mode 100644 index 0000000000..03150ac7f7 --- /dev/null +++ b/src/bonsai/test/bim/module/patch/test_lossy_downgrade.py @@ -0,0 +1,131 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . +# +# 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() == "" diff --git a/src/bonsai/test/bim/module/patch/test_preset_label_reset.py b/src/bonsai/test/bim/module/patch/test_preset_label_reset.py new file mode 100644 index 0000000000..3b900e8290 --- /dev/null +++ b/src/bonsai/test/bim/module/patch/test_preset_label_reset.py @@ -0,0 +1,55 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . +# +# 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"