mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 19:07:57 +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:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user