mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 10:57:49 +00:00
44c0c2916c
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.
138 lines
5.3 KiB
Python
138 lines
5.3 KiB
Python
# Bonsai - OpenBIM Blender Add-on
|
|
# Copyright (C) 2022 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/>.
|
|
|
|
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:
|
|
return bpy.context.scene.BIMPatchProperties
|
|
|
|
@classmethod
|
|
def run_migrate_patch(cls, infile: str, outfile: str, schema: str) -> None:
|
|
output = ifcpatch.execute(
|
|
{"input": infile, "file": ifcopenshell.open(infile), "recipe": "Migrate", "arguments": [schema]}
|
|
)
|
|
ifcpatch.write(output, outfile)
|
|
|
|
@classmethod
|
|
def is_filepath_argument(cls, arg_info: ifcpatch.InputDoc) -> bool:
|
|
# There is probably a more explicit way to do this
|
|
return "filepath" in arg_info["name"] or arg_info["name"].endswith("_dir") or "filter_glob" in arg_info
|
|
|
|
@classmethod
|
|
def does_patch_has_output(cls, recipe: str) -> bool:
|
|
return recipe not in (
|
|
"Ifc2Sql",
|
|
"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":
|
|
query = args[0]
|
|
assert isinstance(query, str)
|
|
if "bpy.data.texts" in query:
|
|
text_name = query.split("bpy.data.texts")[1][2:-2]
|
|
args[0] = bpy.data.texts[text_name].as_string()
|
|
return args
|