mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-19 14:41:25 +00:00
626 lines
24 KiB
Python
626 lines
24 KiB
Python
# Bonsai - OpenBIM Blender Add-on
|
|
# Copyright (C) 2020, 2021, 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 hashlib
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import traceback
|
|
import uuid
|
|
import zipfile
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Literal, NotRequired, Optional, TypedDict, Union
|
|
|
|
import bpy
|
|
import ifcopenshell
|
|
import ifcopenshell.geom
|
|
import ifcopenshell.ifcopenshell_wrapper
|
|
from ifcopenshell.file import UndoSystemError
|
|
|
|
import bonsai
|
|
import bonsai.bim.handler
|
|
import bonsai.tool as tool
|
|
from bonsai.tool.brick import BrickStore
|
|
|
|
IFC_CONNECTED_TYPE = Union[bpy.types.Material, bpy.types.Object]
|
|
|
|
|
|
class OperationData(TypedDict):
|
|
id: int
|
|
guid: NotRequired[str]
|
|
obj: str
|
|
|
|
|
|
class EditObjectOperationData(TypedDict):
|
|
obj: str
|
|
|
|
|
|
class Operation(TypedDict):
|
|
rollback: Callable
|
|
commit: Callable
|
|
data: Union[OperationData, EditObjectOperationData, None]
|
|
|
|
|
|
class TransactionStep(TypedDict):
|
|
key: str
|
|
operations: list[Operation]
|
|
|
|
|
|
class IfcStore:
|
|
path: str = ""
|
|
"""Should be set only using ``tool.Ifc.set_path``."""
|
|
|
|
file: Optional[ifcopenshell.file] = None
|
|
"""Should be set only using ``tool.Ifc.set``."""
|
|
|
|
schema: Optional[ifcopenshell.ifcopenshell_wrapper.schema_definition] = None
|
|
cache: Optional[ifcopenshell.ifcopenshell_wrapper.HdfSerializer] = None
|
|
cache_path: Optional[str] = None
|
|
id_map: dict[int, IFC_CONNECTED_TYPE] = {}
|
|
guid_map: dict[str, IFC_CONNECTED_TYPE] = {}
|
|
edited_objs: set[bpy.types.Object] = set()
|
|
pset_template_path: str = ""
|
|
pset_template_file: Optional[ifcopenshell.file] = None
|
|
classification_path: str = ""
|
|
classification_file: Optional[ifcopenshell.file] = None
|
|
library_path: str = ""
|
|
library_file: Optional[ifcopenshell.file] = None
|
|
current_transaction = ""
|
|
last_transaction = ""
|
|
history: list[TransactionStep] = []
|
|
future: list[TransactionStep] = []
|
|
schema_identifiers = ["IFC4", "IFC2X3", "IFC4X3_ADD2"]
|
|
session_files: dict[str, ifcopenshell.file] = {}
|
|
|
|
@staticmethod
|
|
def purge():
|
|
IfcStore.path = ""
|
|
IfcStore.file = None
|
|
IfcStore.schema = None
|
|
IfcStore.cache = None
|
|
IfcStore.cache_path = None
|
|
IfcStore.id_map = {}
|
|
IfcStore.guid_map = {}
|
|
IfcStore.edited_objs = set()
|
|
IfcStore.pset_template_path = ""
|
|
IfcStore.pset_template_file = None
|
|
IfcStore.library_path = ""
|
|
IfcStore.library_file = None
|
|
IfcStore.last_transaction = ""
|
|
IfcStore.history = []
|
|
IfcStore.future = []
|
|
IfcStore.schema_identifiers = ["IFC4", "IFC2X3", "IFC4X3_ADD2"]
|
|
IfcStore.session_files = {}
|
|
|
|
@staticmethod
|
|
def get_file() -> ifcopenshell.file | None:
|
|
if IfcStore.file is None:
|
|
props = tool.Blender.get_bim_props()
|
|
IfcStore.set_path(props.ifc_file)
|
|
if IfcStore.path:
|
|
try:
|
|
IfcStore.load_file(IfcStore.path)
|
|
tool.Ifc.after_file_loaded()
|
|
except Exception as e:
|
|
print(f"Failed to load file {IfcStore.path}. Error details: {e}")
|
|
return IfcStore.file
|
|
|
|
@staticmethod
|
|
def set_path(value: str) -> None:
|
|
IfcStore.path = value
|
|
# Interpret relative paths as relative to .blend file.
|
|
if IfcStore.path and not os.path.isabs(IfcStore.path):
|
|
IfcStore.path = os.path.abspath(os.path.join(bpy.path.abspath("//"), IfcStore.path))
|
|
|
|
@staticmethod
|
|
def generate_cache_path() -> str:
|
|
"""Generate cache path based on the active file and it's path."""
|
|
assert IfcStore.file
|
|
ifc_key = IfcStore.path + IfcStore.file.header.file_name.time_stamp
|
|
ifc_hash = hashlib.md5(ifc_key.encode("utf-8")).hexdigest()
|
|
prefs = tool.Blender.get_addon_preferences()
|
|
cache_path = os.path.join(prefs.cache_dir, f"{ifc_hash}.h5")
|
|
return cache_path
|
|
|
|
@staticmethod
|
|
def get_cache() -> ifcopenshell.geom.serializers.hdf5 | None:
|
|
"""Get existing cache for the current file or create a new one.
|
|
|
|
.h5 cache name reflects IFC filepath and it's current header's timestamp.
|
|
"""
|
|
if IfcStore.cache is None and IfcStore.path:
|
|
cache_path = IfcStore.generate_cache_path()
|
|
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
|
IfcStore.cache_path = cache_path
|
|
cache_path = Path(IfcStore.cache_path)
|
|
cache_settings = ifcopenshell.geom.settings()
|
|
serializer_settings = ifcopenshell.geom.serializer_settings()
|
|
cache_preexists = cache_path.exists()
|
|
try:
|
|
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
|
|
IfcStore.cache_path, cache_settings, serializer_settings
|
|
)
|
|
if cache_preexists:
|
|
print(f"Successfully loaded existing cache: {cache_path.name}.")
|
|
else:
|
|
print("New cache was created.")
|
|
except Exception as e:
|
|
if cache_preexists:
|
|
print(f"Failed to create a cache from existing file '{cache_path.name}': {str(e)}.")
|
|
else:
|
|
print(f"Failed to create a cache: {str(e)}.")
|
|
# No point to trying again the same operation.
|
|
return
|
|
|
|
os.remove(IfcStore.cache_path)
|
|
try:
|
|
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
|
|
IfcStore.cache_path, cache_settings, serializer_settings
|
|
)
|
|
print("New cache was created.")
|
|
except Exception as e:
|
|
print(f"Failed to create a cache: {str(e)}.")
|
|
return
|
|
return IfcStore.cache
|
|
|
|
@staticmethod
|
|
def update_cache() -> None:
|
|
"""Update cache filename after timestamp was updated."""
|
|
if not IfcStore.cache:
|
|
return
|
|
assert IfcStore.cache_path
|
|
new_cache_path = IfcStore.generate_cache_path()
|
|
IfcStore.cache = None
|
|
try:
|
|
shutil.move(IfcStore.cache_path, new_cache_path)
|
|
except PermissionError:
|
|
try:
|
|
shutil.copy2(IfcStore.cache_path, new_cache_path)
|
|
except PermissionError:
|
|
pass # Well we tried. No cache for you!
|
|
IfcStore.get_cache()
|
|
|
|
@staticmethod
|
|
def load_file(path: str) -> None:
|
|
if not os.path.isfile(path):
|
|
return
|
|
extension = path.split(".")[-1]
|
|
prefs = tool.Blender.get_addon_preferences()
|
|
if extension.lower() == "ifczip":
|
|
with tempfile.TemporaryDirectory() as unzipped_path:
|
|
with zipfile.ZipFile(path, "r") as zip_ref:
|
|
zip_ref.extractall(unzipped_path)
|
|
for filename in Path(unzipped_path).glob("**/*.ifc"):
|
|
IfcStore.file = ifcopenshell.open(filename)
|
|
return
|
|
elif extension.lower() == "ifcxml":
|
|
IfcStore.file = ifcopenshell.file(ifcopenshell.ifcopenshell_wrapper.parse_ifcxml(path))
|
|
elif prefs.should_stream:
|
|
IfcStore.file = ifcopenshell.open(path, should_stream=True)
|
|
else:
|
|
IfcStore.file = ifcopenshell.open(path)
|
|
tool.Ifc.after_file_loaded()
|
|
|
|
@staticmethod
|
|
def get_schema() -> ifcopenshell.ifcopenshell_wrapper.schema_definition:
|
|
if IfcStore.file is None:
|
|
props = tool.Project.get_project_props()
|
|
IfcStore.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(props.export_schema)
|
|
elif IfcStore.schema is None:
|
|
IfcStore.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(IfcStore.file.schema_identifier)
|
|
return IfcStore.schema
|
|
|
|
@staticmethod
|
|
def get_element(id_or_guid: Union[int, str]) -> Union[IFC_CONNECTED_TYPE, None]:
|
|
if isinstance(id_or_guid, int):
|
|
obj = IfcStore.id_map.get(id_or_guid)
|
|
else:
|
|
obj = IfcStore.guid_map.get(id_or_guid)
|
|
if obj is None:
|
|
return None
|
|
if not tool.Blender.is_valid_data_block(obj):
|
|
return None
|
|
return obj
|
|
|
|
@staticmethod
|
|
def relink_all_objects() -> None:
|
|
if not IfcStore.get_file():
|
|
return
|
|
for obj in bpy.data.objects:
|
|
if obj.library:
|
|
continue
|
|
IfcStore.relink_object(obj)
|
|
for obj in bpy.data.materials:
|
|
if obj.library:
|
|
continue
|
|
IfcStore.relink_object(obj)
|
|
|
|
@staticmethod
|
|
def relink_object(obj: IFC_CONNECTED_TYPE) -> None:
|
|
if not obj:
|
|
return
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element:
|
|
return
|
|
data = OperationData(id=element.id(), obj=obj.name)
|
|
if hasattr(element, "GlobalId"):
|
|
data["guid"] = element.GlobalId
|
|
IfcStore.commit_link_element(data)
|
|
|
|
@staticmethod
|
|
def link_element(
|
|
element: ifcopenshell.entity_instance, obj: Union[IFC_CONNECTED_TYPE, tool.Geometry.TYPES_WITH_MESH_PROPERTIES]
|
|
) -> None:
|
|
# Please use tool.Ifc.link() instead of this method. We want to
|
|
# refactor this class and deprecate usage of IfcStore in favour of
|
|
# tools.
|
|
if not isinstance(obj, (bpy.types.Object, bpy.types.Material)):
|
|
tool.Geometry.get_mesh_props(obj).ifc_definition_id = element.id()
|
|
return
|
|
|
|
existing_obj = IfcStore.id_map.get(element.id(), None)
|
|
if existing_obj == obj:
|
|
return
|
|
# TODO: When does this occur?
|
|
elif existing_obj and tool.Blender.is_valid_data_block(existing_obj):
|
|
IfcStore.unlink_element(obj=existing_obj)
|
|
|
|
IfcStore.id_map[element.id()] = obj
|
|
if global_id := getattr(element, "GlobalId", None):
|
|
IfcStore.guid_map[global_id] = obj
|
|
|
|
if element.is_a("IfcSurfaceStyle"):
|
|
props = tool.Style.get_material_style_props(obj)
|
|
props.ifc_definition_id = element.id()
|
|
else:
|
|
props = tool.Blender.get_object_bim_props(obj)
|
|
props.ifc_definition_id = element.id()
|
|
|
|
tool.Ifc.setup_listeners(obj)
|
|
|
|
if IfcStore.history:
|
|
data = OperationData(id=element.id(), obj=obj.name)
|
|
if global_id:
|
|
data["guid"] = global_id
|
|
IfcStore.history[-1]["operations"].append(
|
|
Operation(rollback=IfcStore.rollback_link_element, commit=IfcStore.commit_link_element, data=data)
|
|
)
|
|
|
|
@staticmethod
|
|
def get_object_by_name(name: str) -> Union[IFC_CONNECTED_TYPE, None]:
|
|
obj = bpy.data.objects.get(name)
|
|
if not obj:
|
|
obj = bpy.data.materials.get(name)
|
|
return obj
|
|
|
|
@staticmethod
|
|
def rollback_link_element(data: OperationData) -> None:
|
|
del IfcStore.id_map[data["id"]]
|
|
if "guid" in data:
|
|
del IfcStore.guid_map[data["guid"]]
|
|
obj = IfcStore.get_object_by_name(data["obj"])
|
|
if obj is None:
|
|
# obj was just created during this step and didn't existed before.
|
|
return
|
|
bpy.msgbus.clear_by_owner(obj)
|
|
|
|
@staticmethod
|
|
def commit_link_element(data: OperationData) -> None:
|
|
obj = IfcStore.get_object_by_name(data["obj"])
|
|
IfcStore.id_map[data["id"]] = obj
|
|
if "guid" in data:
|
|
IfcStore.guid_map[data["guid"]] = obj
|
|
tool.Ifc.setup_listeners(obj)
|
|
|
|
@staticmethod
|
|
def history_edit_object(obj: bpy.types.Object, *, finish_editing: bool) -> None:
|
|
if not IfcStore.history:
|
|
return
|
|
|
|
commit, rollback = IfcStore.commit_edit_object, IfcStore.rollback_edit_object
|
|
if finish_editing:
|
|
commit, rollback = rollback, commit
|
|
|
|
data = EditObjectOperationData(obj=obj.name)
|
|
IfcStore.history[-1]["operations"].append(Operation(rollback=rollback, commit=commit, data=data))
|
|
|
|
@staticmethod
|
|
def commit_edit_object(data: EditObjectOperationData) -> None:
|
|
obj = bpy.data.objects[data["obj"]]
|
|
IfcStore.edited_objs.add(obj)
|
|
|
|
@staticmethod
|
|
def rollback_edit_object(data: EditObjectOperationData) -> None:
|
|
obj = bpy.data.objects[data["obj"]]
|
|
IfcStore.edited_objs.discard(obj)
|
|
|
|
@staticmethod
|
|
def rollback_unlink_element(data: OperationData) -> None:
|
|
if "id" not in data or "obj" not in data:
|
|
return
|
|
obj = IfcStore.get_object_by_name(data["obj"])
|
|
IfcStore.id_map[data["id"]] = obj
|
|
if "guid" in data:
|
|
IfcStore.guid_map[data["guid"]] = obj
|
|
tool.Ifc.setup_listeners(obj)
|
|
|
|
@staticmethod
|
|
def commit_unlink_element(data: OperationData) -> None:
|
|
del IfcStore.id_map[data["id"]]
|
|
if "guid" in data:
|
|
del IfcStore.guid_map[data["guid"]]
|
|
obj = IfcStore.get_object_by_name(data["obj"])
|
|
# obj might be removed after unlink.
|
|
if not obj:
|
|
bpy.msgbus.clear_by_owner(obj)
|
|
|
|
@staticmethod
|
|
def unlink_element(
|
|
element: Optional[ifcopenshell.entity_instance] = None, obj: Optional[IFC_CONNECTED_TYPE] = None
|
|
) -> None:
|
|
"""Unlink IFC `element` or Blender `obj`.
|
|
|
|
If element is provided then it will be unlinked from the related Blender object.
|
|
Blender object's IFC information is also will be purged.
|
|
|
|
If Blender object is provided then all IFC information will be purged from this object.
|
|
Method won't be searching for related IFC elements as Blender object might come from
|
|
different Blender session and there might be an ids/guids clash.
|
|
|
|
Only one argument must be provided and other should be omitted as they have different meaning.
|
|
:raises TypeError: If both arguments or no arguments provided.
|
|
|
|
"""
|
|
if not bool(element) ^ bool(obj):
|
|
raise TypeError("Only one argument must be provided - element or obj.")
|
|
|
|
if obj:
|
|
IfcStore.purge_blender_ifc_data(obj)
|
|
return
|
|
|
|
assert element # Type checker.
|
|
if obj is None:
|
|
try:
|
|
potential_obj = IfcStore.id_map[element.id()]
|
|
if tool.Blender.is_valid_data_block(potential_obj):
|
|
obj = potential_obj
|
|
IfcStore.purge_blender_ifc_data(obj)
|
|
except:
|
|
pass
|
|
|
|
try:
|
|
del IfcStore.id_map[element.id()]
|
|
except:
|
|
pass
|
|
|
|
if global_id := getattr(element, "GlobalId", None):
|
|
try:
|
|
del IfcStore.guid_map[global_id]
|
|
except:
|
|
pass
|
|
|
|
if IfcStore.history:
|
|
data = OperationData(id=element.id())
|
|
if global_id:
|
|
data["guid"] = global_id
|
|
if obj:
|
|
data["obj"] = obj.name
|
|
IfcStore.history[-1]["operations"].append(
|
|
Operation(rollback=IfcStore.rollback_unlink_element, commit=IfcStore.commit_unlink_element, data=data)
|
|
)
|
|
|
|
@staticmethod
|
|
def purge_blender_ifc_data(obj: IFC_CONNECTED_TYPE) -> None:
|
|
if isinstance(obj, bpy.types.Material):
|
|
props = tool.Style.get_material_style_props(obj)
|
|
props.ifc_definition_id = 0
|
|
else: # bpy.types.Object
|
|
props = tool.Blender.get_object_bim_props(obj)
|
|
props.ifc_definition_id = 0
|
|
|
|
@staticmethod
|
|
def get_ifc_file_undo_callback(callback_type: Literal["UNDO", "REDO"]):
|
|
def callback(_) -> None:
|
|
try:
|
|
if callback_type == "UNDO":
|
|
tool.Ifc.get().undo()
|
|
else:
|
|
tool.Ifc.get().redo()
|
|
except Exception as e:
|
|
# Persistent callbacks errors are not visible from UI and we set `last_error`
|
|
# to make it visible.
|
|
error_msg = ""
|
|
# In theory it should always be UndoSystemError, but just to be safe.
|
|
if isinstance(e, UndoSystemError):
|
|
error_msg += "Undo transaction operations:\n"
|
|
transaction = e.transaction
|
|
for operation in transaction.operations:
|
|
error_msg += f"- {str(operation)}\n"
|
|
# Show it in system console too, not just in last message.
|
|
print(error_msg)
|
|
error_msg += traceback.format_exc()
|
|
bonsai.last_error = error_msg
|
|
raise
|
|
|
|
return callback
|
|
|
|
modal_in_progress = False
|
|
|
|
@classmethod
|
|
def execute_ifc_operator(
|
|
cls,
|
|
operator: tool.Ifc.Operator,
|
|
context: bpy.types.Context,
|
|
event=None,
|
|
method: Literal["EXECUTE", "INVOKE", "MODAL"] = "EXECUTE",
|
|
) -> set[str]:
|
|
bonsai.last_actions.append({"type": "operator", "name": operator.bl_idname})
|
|
props = tool.Blender.get_bim_props()
|
|
props.is_dirty = True
|
|
# Modals don't nest, and Blender handles the loop that continuously calls modal()
|
|
# So for modal operators we emulate nesting - first modal call is starting transaction
|
|
# and modal call with FINISHED/CANCELLED result finishes it.
|
|
|
|
# Example call chain for modal operators:
|
|
# Operator1.execute called
|
|
# -> Operator.execute called nested Operator2
|
|
# -> Operator2.execute returned RUNNING_MODAL
|
|
# -> Operator2.execute is finished
|
|
# -> Operator1.execute is finished
|
|
# -> Operator2.modal is called and returned PASS_THROUGH (going to another modal loop)
|
|
# -> Operator2.modal is finished
|
|
# -> Operator2.modal called again and last two steps repeat until FINISHED or CANCELLED
|
|
is_top_level_operator = not bool(IfcStore.current_transaction) and not cls.modal_in_progress
|
|
|
|
if is_top_level_operator:
|
|
IfcStore.begin_transaction(operator)
|
|
if ifc_file := tool.Ifc.get():
|
|
assert (
|
|
ifc_file.transaction is None
|
|
), "Trying to override existing transaction, possible IFC undo data loss."
|
|
ifc_file.begin_transaction()
|
|
if BrickStore.graph is not None: # `if BrickStore.graph` by itself takes ages.
|
|
BrickStore.begin_transaction()
|
|
# This empty transaction ensures that each operator has at least one transaction
|
|
IfcStore.add_transaction_operation(operator, rollback=lambda data: True, commit=lambda data: True)
|
|
if method == "MODAL":
|
|
cls.modal_in_progress = True
|
|
else:
|
|
operator.transaction_key = IfcStore.current_transaction
|
|
|
|
def end_top_level_operator() -> None:
|
|
if is_top_level_operator:
|
|
if tool.Ifc.get():
|
|
tool.Ifc.get().end_transaction()
|
|
IfcStore.add_transaction_operation(
|
|
operator,
|
|
rollback=IfcStore.get_ifc_file_undo_callback("UNDO"),
|
|
commit=IfcStore.get_ifc_file_undo_callback("REDO"),
|
|
)
|
|
if BrickStore.graph is not None: # `if BrickStore.graph` by itself takes ages.
|
|
BrickStore.end_transaction()
|
|
IfcStore.end_transaction(operator)
|
|
bonsai.bim.handler.refresh_ui_data()
|
|
|
|
if method == "MODAL":
|
|
cls.modal_in_progress = False
|
|
|
|
try:
|
|
if method == "EXECUTE":
|
|
result = getattr(operator, "_execute")(context)
|
|
elif method == "INVOKE":
|
|
result = getattr(operator, "_invoke")(context, event)
|
|
elif method == "MODAL":
|
|
result = getattr(operator, "_modal")(context, event)
|
|
except:
|
|
bonsai.last_error = traceback.format_exc()
|
|
# Try to ensure undo will work since Blender undo does work in case of errors.
|
|
# As error come unexpectedly, it's important that user might have a chance to save the file
|
|
# before they got the error and not to lose the work they've done.
|
|
#
|
|
# Also, some users won't stop seeing the error,
|
|
# so we need to try to ensure that undo for further operations will work.
|
|
end_top_level_operator()
|
|
raise
|
|
|
|
if method == "MODAL":
|
|
if result == {"FINISHED"}:
|
|
is_top_level_operator = True
|
|
end_top_level_operator()
|
|
elif result == {"CANCELLED"}:
|
|
is_top_level_operator = True
|
|
# Please read the docs: https://docs.blender.org/api/current/bpy.types.Operator.html
|
|
# > "when an operator returns {'CANCELLED'}, no undo step will be created".
|
|
# This means that if your modal edits IFC data, then the user
|
|
# cancels it, Blender's undo history will not be in sync with
|
|
# Bonsai / IfcOpenShell's undo history. Instead of hoping for
|
|
# Bonsai devs to remember to handle the "cancel" state (i.e.
|
|
# detect escape keypress) and return {"FINISHED"}, we instead
|
|
# always enforce an undo step.
|
|
bpy.ops.ed.undo_push(message=f"Cancel {operator.bl_idname}")
|
|
end_top_level_operator()
|
|
else:
|
|
end_top_level_operator()
|
|
return result
|
|
|
|
@staticmethod
|
|
def begin_transaction(operator: tool.Ifc.Operator) -> None:
|
|
IfcStore.current_transaction = str(uuid.uuid4()) + operator.__class__.__name__
|
|
operator.transaction_key = IfcStore.current_transaction
|
|
|
|
@staticmethod
|
|
def end_transaction(operator: tool.Ifc.Operator) -> None:
|
|
IfcStore.current_transaction = ""
|
|
operator.transaction_key = ""
|
|
|
|
@staticmethod
|
|
def add_transaction_operation(
|
|
operator: tool.Ifc.Operator, rollback: Optional[Callable] = None, commit: Optional[Callable] = None
|
|
) -> None:
|
|
key = getattr(operator, "transaction_key", None)
|
|
data = getattr(operator, "transaction_data", None)
|
|
props = tool.Blender.get_bim_props()
|
|
props.last_transaction = key
|
|
IfcStore.last_transaction = key
|
|
rollback = rollback or getattr(operator, "rollback", lambda data: True)
|
|
commit = commit or getattr(operator, "commit", lambda data: True)
|
|
if IfcStore.history and IfcStore.history[-1]["key"] == key:
|
|
IfcStore.history[-1]["operations"].append(Operation(rollback=rollback, commit=commit, data=data))
|
|
else:
|
|
IfcStore.history.append(
|
|
TransactionStep(key=key, operations=[Operation(rollback=rollback, commit=commit, data=data)])
|
|
)
|
|
IfcStore.future = []
|
|
|
|
@staticmethod
|
|
def undo(until_key: str = None) -> None:
|
|
BrickStore.undo()
|
|
if not IfcStore.history:
|
|
return
|
|
|
|
while IfcStore.history:
|
|
if IfcStore.history[-1]["key"] == until_key:
|
|
return
|
|
|
|
event = IfcStore.history.pop()
|
|
for transaction in event["operations"][::-1]:
|
|
transaction["rollback"](transaction["data"])
|
|
IfcStore.future.append(event)
|
|
|
|
@staticmethod
|
|
def redo(until_key: str = None) -> None:
|
|
BrickStore.redo()
|
|
|
|
if not IfcStore.future:
|
|
return
|
|
|
|
has_encountered_key = False
|
|
while IfcStore.future:
|
|
if has_encountered_key and IfcStore.future[-1]["key"] != until_key:
|
|
return
|
|
elif IfcStore.future[-1]["key"] == until_key:
|
|
has_encountered_key = True
|
|
|
|
event = IfcStore.future.pop()
|
|
for transaction in event["operations"]:
|
|
transaction["commit"](transaction["data"])
|
|
IfcStore.history.append(event)
|