diff --git a/src/blenderbim/blenderbim/bim/__init__.py b/src/blenderbim/blenderbim/bim/__init__.py index c3f80faedc..952e46b1a7 100644 --- a/src/blenderbim/blenderbim/bim/__init__.py +++ b/src/blenderbim/blenderbim/bim/__init__.py @@ -110,6 +110,8 @@ if bpy is not None: for cls in classes: bpy.utils.register_class(cls) bpy.app.handlers.depsgraph_update_post.append(on_register) + bpy.app.handlers.undo_post.append(handler.undo_post) + bpy.app.handlers.redo_post.append(handler.redo_post) bpy.app.handlers.load_post.append(handler.setDefaultProperties) bpy.app.handlers.load_post.append(handler.loadIfcStore) bpy.app.handlers.save_pre.append(handler.ensureIfcExported) diff --git a/src/blenderbim/blenderbim/bim/handler.py b/src/blenderbim/blenderbim/bim/handler.py index 11e86bea3b..d9f27dd3b9 100644 --- a/src/blenderbim/blenderbim/bim/handler.py +++ b/src/blenderbim/blenderbim/bim/handler.py @@ -40,7 +40,7 @@ def name_callback(obj, data): collection = obj.users_collection[0] collection.name = obj.name if element.is_a("IfcGrid"): - axis_obj = IfcStore.id_map[element.UAxes[0].id()] + axis_obj = IfcStore.get_element(element.UAxes[0].id()) axis_collection = axis_obj.users_collection[0] grid_collection = None for collection in bpy.data.collections: @@ -103,14 +103,26 @@ def loadIfcStore(scene): if not ifc_file: return IfcStore.get_schema() - [ - IfcStore.link_element(ifc_file.by_id(o.BIMObjectProperties.ifc_definition_id), o) - for o in bpy.data.objects - if o.BIMObjectProperties.ifc_definition_id - ] + IfcStore.reload_linked_elements() purge_module_data() +@persistent +def undo_post(scene): + if IfcStore.last_transaction != bpy.context.scene.BIMProperties.last_transaction: + IfcStore.last_transaction = bpy.context.scene.BIMProperties.last_transaction + IfcStore.undo() + IfcStore.reload_linked_elements(should_reload_selected=True) + + +@persistent +def redo_post(scene): + if IfcStore.last_transaction != bpy.context.scene.BIMProperties.last_transaction: + IfcStore.last_transaction = bpy.context.scene.BIMProperties.last_transaction + IfcStore.redo() + IfcStore.reload_linked_elements(should_reload_selected=True) + + @persistent def ensureIfcExported(scene): if IfcStore.get_file() and not bpy.context.scene.BIMProperties.ifc_file: diff --git a/src/blenderbim/blenderbim/bim/ifc.py b/src/blenderbim/blenderbim/bim/ifc.py index 6f5cc2b384..d35b6e2d75 100644 --- a/src/blenderbim/blenderbim/bim/ifc.py +++ b/src/blenderbim/blenderbim/bim/ifc.py @@ -1,4 +1,5 @@ import bpy +import uuid import ifcopenshell import blenderbim.bim.handler @@ -15,6 +16,9 @@ class IfcStore: library_path = "" library_file = None element_listeners = set() + last_transaction = "" + history = [] + future = [] @staticmethod def purge(): @@ -65,6 +69,21 @@ class IfcStore: def add_element_listener(callback): IfcStore.element_listeners.add(callback) + @staticmethod + def reload_linked_elements(should_reload_selected=False): + file = IfcStore.get_file() + if not file: + return + if should_reload_selected: + objects = bpy.context.selected_objects + [bpy.context.active_object] + else: + objects = bpy.data.objects + [ + IfcStore.link_element(file.by_id(obj.BIMObjectProperties.ifc_definition_id), obj) + for obj in objects + if obj.BIMObjectProperties.ifc_definition_id + ] + @staticmethod def link_element(element, obj): IfcStore.id_map[element.id()] = obj @@ -100,3 +119,43 @@ class IfcStore: if obj: obj.BIMObjectProperties.ifc_definition_id = 0 + + @staticmethod + def generate_transaction_key(operator): + if not getattr(operator, "transaction_key", None): + setattr(operator, "transaction_key", str(uuid.uuid4())) + + @staticmethod + def add_transaction(operator, rollback=None, commit=None): + IfcStore.generate_transaction_key(operator) + key = getattr(operator, "transaction_key", None) + data = getattr(operator, "transaction_data", None) + bpy.context.scene.BIMProperties.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]["transactions"].append({"rollback": rollback, "commit": commit, "data": data}) + else: + IfcStore.history.append( + {"key": key, "transactions": [{"rollback": rollback, "commit": commit, "data": data}]} + ) + IfcStore.future = [] + + @staticmethod + def undo(): + if not IfcStore.history: + return + event = IfcStore.history.pop() + for transaction in event["transactions"][::-1]: + transaction["rollback"](transaction["data"]) + IfcStore.future.append(event) + + @staticmethod + def redo(): + if not IfcStore.future: + return + event = IfcStore.future.pop() + for transaction in event["transactions"]: + transaction["commit"](transaction["data"]) + IfcStore.history.append(event) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index effb080d83..170cbf2a56 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -704,7 +704,7 @@ class IfcImporter: product = self.file.by_id(shape.guid) # Facetation is to accommodate broken Revit files # See https://forums.buildingsmart.org/t/suggestions-on-how-to-improve-clarity-of-representation-context-usage-in-documentation/3663/6?u=moult - if shape.context not in ["Body", "Facetation"] and shape.guid in IfcStore.guid_map: + if shape.context not in ["Body", "Facetation"] and IfcStore.get_element(shape.guid): # We only load a single context, and we prioritise the Body context. See #1290. pass elif product.is_a("IfcAnnotation") and product.ObjectType == "DRAWING": diff --git a/src/blenderbim/blenderbim/bim/module/attribute/operator.py b/src/blenderbim/blenderbim/bim/module/attribute/operator.py index 0aeae19782..d52632d93f 100644 --- a/src/blenderbim/blenderbim/bim/module/attribute/operator.py +++ b/src/blenderbim/blenderbim/bim/module/attribute/operator.py @@ -9,6 +9,7 @@ from ifcopenshell.api.attribute.data import Data class EnableEditingAttributes(bpy.types.Operator): bl_idname = "bim.enable_editing_attributes" bl_label = "Enable Editing Attributes" + bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() obj_type: bpy.props.StringProperty() @@ -45,6 +46,7 @@ class EnableEditingAttributes(bpy.types.Operator): class DisableEditingAttributes(bpy.types.Operator): bl_idname = "bim.disable_editing_attributes" bl_label = "Disable Editing Attributes" + bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() obj_type: bpy.props.StringProperty() @@ -61,6 +63,8 @@ class DisableEditingAttributes(bpy.types.Operator): class EditAttributes(bpy.types.Operator): bl_idname = "bim.edit_attributes" bl_label = "Edit Attributes" + bl_options = {"REGISTER", "UNDO"} + transaction_key: bpy.props.StringProperty() obj: bpy.props.StringProperty() obj_type: bpy.props.StringProperty() @@ -95,9 +99,11 @@ class EditAttributes(bpy.types.Operator): elif attribute["type"] == "enum": attributes[attribute["name"]] = blender_attribute.enum_value product = self.file.by_id(oprops.ifc_definition_id) + self.file.begin_transaction() ifcopenshell.api.run( "attribute.edit_attributes", self.file, **{"product": product, "attributes": attributes} ) + self.file.end_transaction() if "Name" in attributes: new_name = "{}/{}".format(product.is_a(), product.Name or "Unnamed") collection = bpy.data.collections.get(obj.name) @@ -106,12 +112,23 @@ class EditAttributes(bpy.types.Operator): obj.name = new_name Data.load(IfcStore.get_file(), oprops.ifc_definition_id) bpy.ops.bim.disable_editing_attributes(obj=obj.name, obj_type=self.obj_type) + self.transaction_data = {"ifc_definition_id": oprops.ifc_definition_id} + IfcStore.add_transaction(self) return {"FINISHED"} + def rollback(self, data): + IfcStore.get_file().undo() + Data.load(IfcStore.get_file(), data["ifc_definition_id"]) + + def commit(self, data): + IfcStore.get_file().redo() + Data.load(IfcStore.get_file(), data["ifc_definition_id"]) + class GenerateGlobalId(bpy.types.Operator): bl_idname = "bim.generate_global_id" bl_label = "Regenerate GlobalId" + bl_options = {"REGISTER", "UNDO"} def execute(self, context): index = bpy.context.active_object.BIMAttributeProperties.attributes.find("GlobalId") diff --git a/src/blenderbim/blenderbim/bim/module/debug/__init__.py b/src/blenderbim/blenderbim/bim/module/debug/__init__.py index fe7f2b8037..69026d78be 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/debug/__init__.py @@ -2,6 +2,7 @@ import bpy from . import ui, prop, operator classes = ( + operator.ValidateIfcFile, operator.ProfileImportIFC, operator.CreateAllShapes, operator.CreateShapeFromStepId, diff --git a/src/blenderbim/blenderbim/bim/module/debug/operator.py b/src/blenderbim/blenderbim/bim/module/debug/operator.py index 26fd951e5f..780725c7e1 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/operator.py +++ b/src/blenderbim/blenderbim/bim/module/debug/operator.py @@ -5,6 +5,19 @@ import blenderbim.bim.import_ifc as import_ifc from blenderbim.bim.ifc import IfcStore +class ValidateIfcFile(bpy.types.Operator): + bl_idname = "bim.validate_ifc_file" + bl_label = "Validate IFC File" + + def execute(self, context): + import ifcopenshell.validate + + logger = logging.getLogger("validate") + logger.setLevel(logging.DEBUG) + ifcopenshell.validate.validate(IfcStore.get_file(), logger) + return {"FINISHED"} + + class ProfileImportIFC(bpy.types.Operator): bl_idname = "bim.profile_import_ifc" bl_label = "Profile Import IFC" diff --git a/src/blenderbim/blenderbim/bim/module/project/__init__.py b/src/blenderbim/blenderbim/bim/module/project/__init__.py index f2bca894bc..c01b78d775 100644 --- a/src/blenderbim/blenderbim/bim/module/project/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/project/__init__.py @@ -4,7 +4,6 @@ from . import ui, prop, operator classes = ( operator.CreateProject, operator.CreateProjectLibrary, - operator.ValidateIfcFile, operator.SelectLibraryFile, operator.ChangeLibraryElement, operator.RefreshLibrary, diff --git a/src/blenderbim/blenderbim/bim/module/project/operator.py b/src/blenderbim/blenderbim/bim/module/project/operator.py index 7aec4e1b07..48e0d2e082 100644 --- a/src/blenderbim/blenderbim/bim/module/project/operator.py +++ b/src/blenderbim/blenderbim/bim/module/project/operator.py @@ -2,6 +2,7 @@ import bpy import logging import ifcopenshell import ifcopenshell.api +import ifcopenshell.util.representation import bpy import blenderbim.bim.handler from blenderbim.bim.ifc import IfcStore @@ -11,6 +12,8 @@ from blenderbim.bim import import_ifc class CreateProject(bpy.types.Operator): bl_idname = "bim.create_project" bl_label = "Create Project" + bl_options = {"REGISTER", "UNDO"} + transaction_key: bpy.props.StringProperty() def execute(self, context): self.file = IfcStore.get_file() @@ -22,6 +25,9 @@ class CreateProject(bpy.types.Operator): ) self.file = IfcStore.get_file() + self.transaction_data = {"file": self.file} + IfcStore.add_transaction(self) + bpy.ops.bim.add_person() bpy.ops.bim.add_organisation() @@ -30,7 +36,7 @@ class CreateProject(bpy.types.Operator): building = bpy.data.objects.new("My Building", None) building_storey = bpy.data.objects.new("Ground Floor", None) - bpy.ops.bim.assign_class(obj=project.name, ifc_class="IfcProject") + bpy.ops.bim.assign_class(transaction_key=self.transaction_key, obj=project.name, ifc_class="IfcProject") bpy.ops.bim.assign_unit() bpy.ops.bim.add_subcontext(context="Model") bpy.ops.bim.add_subcontext(context="Model", subcontext="Body", target_view="MODEL_VIEW") @@ -38,24 +44,35 @@ class CreateProject(bpy.types.Operator): bpy.ops.bim.add_subcontext(context="Plan") bpy.ops.bim.add_subcontext(context="Plan", subcontext="Annotation", target_view="PLAN_VIEW") - for subcontext in self.file.by_type("IfcGeometricRepresentationSubContext"): - if subcontext.ContextIdentifier == "Body": - bpy.context.scene.BIMProperties.contexts = str(subcontext.id()) - break + bpy.context.scene.BIMProperties.contexts = str( + ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW").id() + ) - bpy.ops.bim.assign_class(obj=site.name, ifc_class="IfcSite") - bpy.ops.bim.assign_class(obj=building.name, ifc_class="IfcBuilding") - bpy.ops.bim.assign_class(obj=building_storey.name, ifc_class="IfcBuildingStorey") + bpy.ops.bim.assign_class(transaction_key=self.transaction_key, obj=site.name, ifc_class="IfcSite") + bpy.ops.bim.assign_class(transaction_key=self.transaction_key, obj=building.name, ifc_class="IfcBuilding") + bpy.ops.bim.assign_class( + transaction_key=self.transaction_key, obj=building_storey.name, ifc_class="IfcBuildingStorey" + ) bpy.ops.bim.assign_object(related_object=site.name, relating_object=project.name) bpy.ops.bim.assign_object(related_object=building.name, relating_object=site.name) bpy.ops.bim.assign_object(related_object=building_storey.name, relating_object=building.name) - # Data.load() + return {"FINISHED"} + def rollback(self, data): + IfcStore.file = None + blenderbim.bim.handler.purge_module_data() + + def commit(self, data): + blenderbim.bim.handler.purge_module_data() + IfcStore.file = data["file"] + class CreateProjectLibrary(bpy.types.Operator): bl_idname = "bim.create_project_library" bl_label = "Create Project Library" + bl_options = {"REGISTER", "UNDO"} + transaction_key: bpy.props.StringProperty() def execute(self, context): self.file = IfcStore.get_file() @@ -67,45 +84,62 @@ class CreateProjectLibrary(bpy.types.Operator): ) self.file = IfcStore.get_file() + self.transaction_data = {"file": self.file} + IfcStore.add_transaction(self) + if self.file.schema == "IFC2X3": bpy.ops.bim.add_person() bpy.ops.bim.add_organisation() project_library = bpy.data.objects.new("My Project Library", None) - bpy.ops.bim.assign_class(obj=project_library.name, ifc_class="IfcProjectLibrary") + bpy.ops.bim.assign_class( + transaction_key=self.transaction_key, obj=project_library.name, ifc_class="IfcProjectLibrary" + ) bpy.ops.bim.assign_unit() return {"FINISHED"} + def rollback(self, data): + IfcStore.file = None + blenderbim.bim.handler.purge_module_data() -class ValidateIfcFile(bpy.types.Operator): - bl_idname = "bim.validate_ifc_file" - bl_label = "Validate IFC File" - - def execute(self, context): - import ifcopenshell.validate - - logger = logging.getLogger("validate") - logger.setLevel(logging.DEBUG) - ifcopenshell.validate.validate(IfcStore.get_file(), logger) - return {"FINISHED"} + def commit(self, data): + blenderbim.bim.handler.purge_module_data() + IfcStore.file = data["file"] class SelectLibraryFile(bpy.types.Operator): bl_idname = "bim.select_library_file" bl_label = "Select Library File" + bl_options = {"REGISTER", "UNDO"} filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}) def execute(self, context): + old_filepath = IfcStore.library_path IfcStore.library_path = self.filepath IfcStore.library_file = ifcopenshell.open(self.filepath) bpy.ops.bim.refresh_library() + + self.transaction_data = {"old_filepath": old_filepath, "filepath": self.filepath} + IfcStore.add_transaction(self) return {"FINISHED"} def invoke(self, context, event): context.window_manager.fileselect_add(self) return {"RUNNING_MODAL"} + def rollback(self, data): + if data["old_filepath"]: + IfcStore.library_path = data["old_filepath"] + IfcStore.library_file = ifcopenshell.open(data["old_filepath"]) + else: + IfcStore.library_path = "" + IfcStore.library_file = None + + def commit(self, data): + IfcStore.library_path = data["filepath"] + IfcStore.library_file = ifcopenshell.open(data["filepath"]) + class RefreshLibrary(bpy.types.Operator): bl_idname = "bim.refresh_library" @@ -132,6 +166,7 @@ class RefreshLibrary(bpy.types.Operator): class ChangeLibraryElement(bpy.types.Operator): bl_idname = "bim.change_library_element" bl_label = "Change Library Element" + bl_options = {"REGISTER", "UNDO"} element_name: bpy.props.StringProperty() def execute(self, context): @@ -163,6 +198,7 @@ class ChangeLibraryElement(bpy.types.Operator): class RewindLibrary(bpy.types.Operator): bl_idname = "bim.rewind_library" bl_label = "Rewind Library" + bl_options = {"REGISTER", "UNDO"} def execute(self, context): self.props = context.scene.BIMProjectProperties @@ -180,40 +216,62 @@ class RewindLibrary(bpy.types.Operator): class AssignLibraryDeclaration(bpy.types.Operator): bl_idname = "bim.assign_library_declaration" bl_label = "Assign Library Declaration" + bl_options = {"REGISTER", "UNDO"} definition: bpy.props.IntProperty() def execute(self, context): self.props = context.scene.BIMProjectProperties + self.file = IfcStore.library_file + self.file.begin_transaction() ifcopenshell.api.run( "project.assign_declaration", - IfcStore.library_file, - definition=IfcStore.library_file.by_id(self.definition), - relating_context=IfcStore.library_file.by_type("IfcProjectLibrary")[0], + self.file, + definition=self.file.by_id(self.definition), + relating_context=self.file.by_type("IfcProjectLibrary")[0], ) + self.file.end_transaction() element_name = self.props.active_library_element bpy.ops.bim.rewind_library() - bpy.ops.bim.change_library_element(element_name = element_name) + bpy.ops.bim.change_library_element(element_name=element_name) + IfcStore.add_transaction(self) return {"FINISHED"} + def rollback(self, data): + IfcStore.library_file.undo() + + def commit(self, data): + IfcStore.library_file.redo() + class UnassignLibraryDeclaration(bpy.types.Operator): bl_idname = "bim.unassign_library_declaration" bl_label = "Unassign Library Declaration" + bl_options = {"REGISTER", "UNDO"} definition: bpy.props.IntProperty() def execute(self, context): self.props = context.scene.BIMProjectProperties + self.file = IfcStore.library_file + self.file.begin_transaction() ifcopenshell.api.run( "project.unassign_declaration", - IfcStore.library_file, - definition=IfcStore.library_file.by_id(self.definition), - relating_context=IfcStore.library_file.by_type("IfcProjectLibrary")[0], + self.file, + definition=self.file.by_id(self.definition), + relating_context=self.file.by_type("IfcProjectLibrary")[0], ) + self.file.end_transaction() element_name = self.props.active_library_element bpy.ops.bim.rewind_library() - bpy.ops.bim.change_library_element(element_name = element_name) + bpy.ops.bim.change_library_element(element_name=element_name) + IfcStore.add_transaction(self) return {"FINISHED"} + def rollback(self, data): + IfcStore.library_file.undo() + + def commit(self, data): + IfcStore.library_file.redo() + class SaveLibraryFile(bpy.types.Operator): bl_idname = "bim.save_library_file" @@ -227,17 +285,22 @@ class SaveLibraryFile(bpy.types.Operator): class AppendLibraryElement(bpy.types.Operator): bl_idname = "bim.append_library_element" bl_label = "Append Library Element" + bl_options = {"REGISTER", "UNDO"} definition: bpy.props.IntProperty() def execute(self, context): + self.file = IfcStore.get_file() + self.file.begin_transaction() element = ifcopenshell.api.run( "project.append_asset", - IfcStore.get_file(), + self.file, library=IfcStore.library_file, element=IfcStore.library_file.by_id(self.definition), ) + self.file.end_transaction() self.import_type_from_ifc(element) blenderbim.bim.handler.purge_module_data() + IfcStore.add_transaction(self) return {"FINISHED"} def import_type_from_ifc(self, element): @@ -259,10 +322,17 @@ class AppendLibraryElement(bpy.types.Operator): ifc_importer.create_type_product(element) ifc_importer.place_objects_in_spatial_tree() + def rollback(self, data): + IfcStore.get_file().undo() + + def commit(self, data): + IfcStore.get_file().redo() + class EnableEditingHeader(bpy.types.Operator): bl_idname = "bim.enable_editing_header" bl_label = "Enable Editing Header" + bl_options = {"REGISTER", "UNDO"} def execute(self, context): self.file = IfcStore.get_file() @@ -294,23 +364,53 @@ class EnableEditingHeader(bpy.types.Operator): class EditHeader(bpy.types.Operator): bl_idname = "bim.edit_header" bl_label = "Edit Header" + bl_options = {"REGISTER", "UNDO"} def execute(self, context): self.file = IfcStore.get_file() props = context.scene.BIMProjectProperties props.is_editing = True - self.file.wrapped_data.header.file_description.description = (f'ViewDefinition[{props.mvd}]',) + self.transaction_data = {} + self.transaction_data["old"] = self.record_state() + + self.file.wrapped_data.header.file_description.description = (f"ViewDefinition[{props.mvd}]",) self.file.wrapped_data.header.file_name.author = (props.author_name, props.author_email) self.file.wrapped_data.header.file_name.organization = (props.organisation_name, props.organisation_email) self.file.wrapped_data.header.file_name.authorization = props.authorisation bpy.ops.bim.disable_editing_header() + + self.transaction_data["new"] = self.record_state() + IfcStore.add_transaction(self) return {"FINISHED"} + def record_state(self): + return { + "description": self.file.wrapped_data.header.file_description.description, + "author": self.file.wrapped_data.header.file_name.author, + "organisation": self.file.wrapped_data.header.file_name.organization, + "authorisation": self.file.wrapped_data.header.file_name.authorization, + } + + def rollback(self, data): + file = IfcStore.get_file() + file.wrapped_data.header.file_description.description = data["old"]["description"] + file.wrapped_data.header.file_name.author = data["old"]["author"] + file.wrapped_data.header.file_name.organization = data["old"]["organisation"] + file.wrapped_data.header.file_name.authorization = data["old"]["authorisation"] + + def commit(self, data): + file = IfcStore.get_file() + file.wrapped_data.header.file_description.description = data["new"]["description"] + file.wrapped_data.header.file_name.author = data["new"]["author"] + file.wrapped_data.header.file_name.organization = data["new"]["organisation"] + file.wrapped_data.header.file_name.authorization = data["new"]["authorisation"] + class DisableEditingHeader(bpy.types.Operator): bl_idname = "bim.disable_editing_header" bl_label = "Disable Editing Header" + bl_options = {"REGISTER", "UNDO"} def execute(self, context): context.scene.BIMProjectProperties.is_editing = False diff --git a/src/blenderbim/blenderbim/bim/module/root/operator.py b/src/blenderbim/blenderbim/bim/module/root/operator.py index 0ef6e43ecb..11cdfa2c78 100644 --- a/src/blenderbim/blenderbim/bim/module/root/operator.py +++ b/src/blenderbim/blenderbim/bim/module/root/operator.py @@ -78,6 +78,7 @@ class AssignClass(bpy.types.Operator): bl_idname = "bim.assign_class" bl_label = "Assign IFC Class" bl_options = {"REGISTER", "UNDO"} + transaction_key: bpy.props.StringProperty() obj: bpy.props.StringProperty() ifc_class: bpy.props.StringProperty() predefined_type: bpy.props.StringProperty() @@ -87,6 +88,7 @@ class AssignClass(bpy.types.Operator): ifc_representation_class: bpy.props.StringProperty() def execute(self, context): + self.transaction_data = [] objects = [bpy.data.objects.get(self.obj)] if self.obj else bpy.context.selected_objects self.file = IfcStore.get_file() self.declaration = IfcStore.get_schema().declaration_by_name(self.ifc_class) @@ -96,11 +98,13 @@ class AssignClass(bpy.types.Operator): predefined_type = None for obj in objects: self.assign_class(context, obj) + IfcStore.add_transaction(self) return {"FINISHED"} def assign_class(self, context, obj): if obj.BIMObjectProperties.ifc_definition_id: return + self.file.begin_transaction() product = ifcopenshell.api.run( "root.create_entity", self.file, @@ -110,8 +114,10 @@ class AssignClass(bpy.types.Operator): "name": obj.name, }, ) + self.file.end_transaction() obj.name = "{}/{}".format(product.is_a(), obj.name) IfcStore.link_element(product, obj) + self.transaction_data.append({"element": product.id(), "obj": obj.name}) if self.should_add_representation: bpy.ops.bim.add_representation( @@ -171,6 +177,20 @@ class AssignClass(bpy.types.Operator): ) break + def rollback(self, data): + for linked_element in data: + IfcStore.unlink_element( + IfcStore.get_file().by_id(linked_element["element"]), bpy.data.objects.get(linked_element["obj"]) + ) + IfcStore.get_file().undo() + + def commit(self, data): + IfcStore.get_file().redo() + for linked_element in data: + IfcStore.link_element( + IfcStore.get_file().by_id(linked_element["element"]), bpy.data.objects.get(linked_element["obj"]) + ) + class UnassignClass(bpy.types.Operator): bl_idname = "bim.unassign_class" diff --git a/src/blenderbim/blenderbim/bim/module/sequence/prop.py b/src/blenderbim/blenderbim/bim/module/sequence/prop.py index ca8ec24d03..02e6e6cbde 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/prop.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/prop.py @@ -301,7 +301,7 @@ class BIMWorkScheduleProperties(PropertyGroup): class BIMTaskTreeProperties(PropertyGroup): # This belongs by itself for performance reasons. https://developer.blender.org/T87737 - # In Blender if you add thousands of tasks it makes other property access in the same group really slow. + # In Blender if you add many collection items it makes other property access in the same group really slow. tasks: CollectionProperty(name="Tasks", type=Task) diff --git a/src/blenderbim/blenderbim/bim/prop.py b/src/blenderbim/blenderbim/bim/prop.py index 5a2a0c421e..cb7e983b81 100644 --- a/src/blenderbim/blenderbim/bim/prop.py +++ b/src/blenderbim/blenderbim/bim/prop.py @@ -174,9 +174,8 @@ class BIMProperties(PropertyGroup): default=os.path.join(cwd, "data") + os.path.sep, name="Data Directory", update=updateDataDir ) ifc_file: StringProperty(name="IFC File", update=updateIfcFile) - id_map: StringProperty(name="ID Map") - guid_map: StringProperty(name="GUID Map") export_schema: EnumProperty(items=[("IFC4", "IFC4", ""), ("IFC2X3", "IFC2X3", "")], name="IFC Schema") + last_transaction: StringProperty(name="Last Transaction") contexts: EnumProperty(items=getContexts, name="Contexts") available_contexts: EnumProperty(items=[("Model", "Model", ""), ("Plan", "Plan", "")], name="Available Contexts") available_subcontexts: EnumProperty(items=getSubcontexts, name="Available Subcontexts") diff --git a/src/blenderbim/docs/blenderbim/installation.rst b/src/blenderbim/docs/blenderbim/installation.rst index f1d8e5101e..7514ed0397 100644 --- a/src/blenderbim/docs/blenderbim/installation.rst +++ b/src/blenderbim/docs/blenderbim/installation.rst @@ -1,91 +1,219 @@ Installation ============ -BlenderBIM is packaged as a Blender add-on, so installation is the same as any -other Blender add-on. The full instructions for end-user installation is -available at the `Get BlenderBIM `_ -website. +There are different methods of installation, depending on your situation. + +1. **Packaged installation** is recommended for regular users. +2. **Daily build installation** is recommended for power users helping with testing. +3. **Unpackaged installation** is recommended for package managers. +4. **Source installation** is recommended for developers. + +Packaged installation +--------------------- + +The BlenderBIM Add-on is packaged like a regular Blender add-on, so installation +is the same as any other Blender add-on. The full instructions for end-user +installation is available at the `Get BlenderBIM +`__ website. The latest release is +typically updated every few weeks. If you downloaded Blender as a ``.zip`` file without running an installer, you -will find the BlenderBIM plug-in installed in: +will find the BlenderBIM plug-in installed in the following directory, where +``2.XX`` is the Blender version: :: - /path/to/blender/2.81/scripts/addons/ + /path/to/blender/2.XX/scripts/addons/ Otherwise, if you installed Blender using an installation package, the add-ons folder depends on which operating system you use. On Linux: :: - ~/.config/blender/2.81/scripts/addons/ + ~/.config/blender/2.XX/scripts/addons/ On Mac: :: - /Users/{YOUR_USER}/Library/Application Support/Blender/2.81/ + /Users/{YOUR_USER}/Library/Application Support/Blender/2.XX/ On Windows: :: - C:\Users\{YOUR_USER}\AppData\Roaming\Blender Foundation\2.81\scripts\addons + C:\Users\{YOUR_USER}\AppData\Roaming\Blender Foundation\2.XX\scripts\addons -Upon installation, a series of files will be created. This is necessary as -BlenderBIM has a variety of complex dependencies. A full list is below: +Upon installation, the BlenderBIM Add-on is stored in the ``blenderbim/`` +directory. + +Daily build installation +------------------------ + +Daily builds are almost the same as **Packaged installation**, except that they +are typically updated every day. Simply download a daily build from the `Github +releases page `__, then +follow the same instructions as a packaged installation. + +You will need to choose which daily build to download. + +- If you are on Blender <2.93, choose py37 +- If you are on Blender >=2.93, choose py39 +- Choose linux, macos, or win depending on your operating system + +Daily builds are not always stable. Sometimes, a build may be delayed, or +contain broken code. We try to avoid this, but it happens. + +Unpackaged installation +----------------------- + +The BlenderBIM Add-on is fully contained in the ``blenderbim/`` subfolder of the +Blender add-ons directory. This is typically distributed as a zipfile as per +Blender add-on conventions. Within this folder, you'll find the following file +structure: :: - blenderbim/ - ifcopenshell/ - OCC/ - pystache/ - svgwrite/ - deepdiff/ - jsonpickle/ - lib/ # Note: this only exists on MacOS and Linux - ordered_set.py - pyparsing.py + bim/ (core code) + libs/ (dependencies) + __init__.py -If you are not on Windows, when BlenderBIM first launches, it will create a -bunch of library files in the ``2.81/`` folder too. This is a non-standard -location to place files, but is a hack to allow people to use precompiled builds -from Conda. +This corresponds to the structure found in the source code `here +`__. + +The BlenderBIM Add-on is complex, and requires many dependencies, including +Python modules, binaries, and static assets. These dependencies are bundled with +the add-on for convenience in the **Packaged installation** and **Daily build +installation** methods. + +If you choose to install the BlenderBIM Add-on and use your own system +dependencies, the source of truth for how dependencies are bundled are found in +the `Makefile +`__. + +Required Python modules to be stored in ``libs/site/packages/`` are: +:: + + ifcopenshell + bcf + ifcclash + bimtester + ifccobie + ifcdiff + ifccsv + ifcpatch + ifcp6 + pystache + svgwrite + dateutil + isodate + networkx + deepdiff + jsonpickle + ordered_set + pyparsing + xmlschema + elementpath + six + lark-parser + fcl + behave + parse + parse_type + xlsxwriter + odfpy + defusedxml + boto3 + botocore + jmespath + s3transfer + ifcjson + +Notes: + +1. ``ifcopenshell`` almost always requires the latest version due to the fast paced nature of the add-on development. +2. ``fcl`` is not bundled for MacOS, due to lack of maintained community build. This is required for clash detection. +3. ``behave`` requires `patches `__. +4. ``ifcjson`` can be found `here `__. + +Required binaries are: +:: + + libs/IfcConvert + +Required static assets are: +:: + + bim/data/gantt/jsgantt.js (from jsgantt-improved) + bim/data/gantt/jsgantt.css (from jsgantt-improved) If you receive an error when enabling the add-on, you may have installed the package for the wrong platform. -Updating --------- - -It is recommended to uninstall the current BlenderBIM add-on before installing -the latest version to ensure the update goes well. - -From Source ------------ +Source installation +------------------- It is possible to run the latest bleeding edge version of BlenderBIM without having to wait for an official release, since BlenderBIM is coded in Python and -doesn't require any compilation. First, install the latest official release, and -then `download the latest source code -`_. If you know -how to use Git, you can also stay up to date like so: +doesn't require any compilation. + +You can create your own package by using the Makefile as shown below. You can +choose between a ``PLATFORM`` of ``linux``, ``macos``, and ``win``. You can +choose between a ``PYVERSION`` of ``py39`` and ``py37``. +:: + + $ cd src/blenderbim + $ make dist PLATFORM=linux PYVERSION=py39 + $ ls dist/ + +However, creating a build, uninstalling the old add-on, and installing a new +build is a slow process. A more rapid approach is to follow the **Daily build +installation** method, as this provides all dependencies for you out of the box. +Then, we can replace certain Python files that tend to be updated frequently +with those from the Git repository. We're going to use symlinks (Windows user +can use ``mklink``), so we can code in our Git repository, and see the changes +in our Blender installation. + +In addition, we're also going to replace the Python code of the IfcOpenShell +dependency with our Git repository, since most of the BlenderBIM Add-on +functionality is agnostic of Blender, and is actually part of IfcOpenShell. +Therefore, we need to keep this dependency highly updated as well. + +The downside with this approach is that if a new dependency is added, or a +compiled dependency version requirement has changed, or the build system +changes, you'll need to fix your setup manually. But this is relatively rare. + :: $ git clone https://github.com/IfcOpenShell/IfcOpenShell.git - $ cd IfcOpenShell - $ git checkout v0.6.0 -Then, just copy the files from the source code's -``src/blenderbim/blenderbim/`` folder and replace the files in your -Blender add-on's ``blenderbim/`` folder. + # Remove the Blender add-on Python code + $ rm -r /path/to/blender/2.XX/scripts/addons/blenderbim/bim/ -Restart Blender for the changes to take effect. In ``Edit > Preferences > -Add-ons`` you will see that the version number of BlenderBIM has changed to -``0.0.999999``, which represents an un-versioned BlenderBIM. + # Replace them with links to the Git repository + $ ln -s src/blenderbim/blenderbim/bim /path/to/blender/2.XX/scripts/addons/blenderbim/bim + + # Remove the IfcOpenShell dependency Python code + $ rm -r /path/to/blender/2.XX/scripts/addons/blenderbim/libs/site/packages/ifcopenshell/api + $ rm -r /path/to/blender/2.XX/scripts/addons/blenderbim/libs/site/packages/ifcopenshell/util + + # Replace them with links to the Git repository + $ ln -s src/ifcopenshell-python/ifcopenshell/api /path/to/blender/2.XX/scripts/addons/blenderbim/libs/site/packages/ifcopenshell/api + $ ln -s src/ifcopenshell-python/ifcopenshell/util /path/to/blender/2.XX/scripts/addons/blenderbim/libs/site/packages/ifcopenshell/util + +After you modify your code in the Git repository, you will need to restart +Blender for the changes to take effect. In ``Edit > Preferences > Add-ons`` you +will see that the version number of BlenderBIM has changed to ``0.0.999999``, +which represents an un-versioned BlenderBIM. + +Updating +-------- + +First uninstall the current BlenderBIM add-on, then install the latest version. Uninstalling ------------ -You can remove all of the files added by BlenderBIM in the Blender add-ons -folder and then remove the add-on using the Blender interface through ``Edit > -Preferences > Add-ons`` just like any other add-on. +Navigate to ``Edit > Preferences > Add-ons``. Due to a limitation in Blender, +you have to first disable the BlenderBIM Add-on in your Blender preferences by +pressing the checkbox next to the add-on, then restart Blender. After +restarting, you can uninstall the BlenderBIM Add-on by pressing the ``Remove`` +button in the Blender preferences window. -If you are not on Windows, then ensure that all library files are deleted in the -``2.81/`` directory. Do not delete any of Blender's own folders. +Alternatively, you may uninstall manually by deleting the ``blenderbim/`` +directory in your Blender add-ons directory. diff --git a/src/ifcopenshell-python/ifcopenshell/entity_instance.py b/src/ifcopenshell-python/ifcopenshell/entity_instance.py index 32096f20ec..86e6c3dfba 100644 --- a/src/ifcopenshell-python/ifcopenshell/entity_instance.py +++ b/src/ifcopenshell-python/ifcopenshell/entity_instance.py @@ -48,20 +48,21 @@ class entity_instance(object): >>> #423=IfcProductDefinitionShape($,$,(#409,#421)) """ - def __init__(self, e): + def __init__(self, e, file): if isinstance(e, tuple): e = ifcopenshell_wrapper.new_IfcBaseClass(*e) super(entity_instance, self).__setattr__("wrapped_data", e) + self.wrapped_data.file = file def __getattr__(self, name): INVALID, FORWARD, INVERSE = range(3) attr_cat = self.wrapped_data.get_attribute_category(name) if attr_cat == FORWARD: return entity_instance.wrap_value( - self.wrapped_data.get_argument(self.wrapped_data.get_argument_index(name)) + self.wrapped_data.get_argument(self.wrapped_data.get_argument_index(name)), self.wrapped_data.file ) elif attr_cat == INVERSE: - return entity_instance.wrap_value(self.wrapped_data.get_inverse(name)) + return entity_instance.wrap_value(self.wrapped_data.get_inverse(name), self.wrapped_data.file) else: raise AttributeError( "entity instance of type '%s' has no attribute '%s'" % (self.wrapped_data.is_a(), name) @@ -77,9 +78,9 @@ class entity_instance(object): return value @staticmethod - def wrap_value(v): + def wrap_value(v, file): def wrap(e): - return entity_instance(e) + return entity_instance(e, file) def is_instance(e): return isinstance(e, ifcopenshell_wrapper.entity_instance) @@ -116,12 +117,15 @@ class entity_instance(object): return self.wrapped_data.get_argument_name(attr_idx) def __setattr__(self, key, value): - self[self.wrapped_data.get_argument_index(key)] = value + index = self.wrapped_data.get_argument_index(key) + if self.wrapped_data.file.transaction: + self.wrapped_data.file.transaction.store_edit(self, index, value) + self[index] = value def __getitem__(self, key): if key < 0 or key >= len(self): raise IndexError("Attribute index {} out of range for instance of type {}".format(key, self.is_a())) - return entity_instance.wrap_value(self.wrapped_data.get_argument(key)) + return entity_instance.wrap_value(self.wrapped_data.get_argument(key), self.wrapped_data.file) def __setitem__(self, idx, value): attr_type = real_attr_type = self.attribute_type(idx).title().replace(" ", "") @@ -271,7 +275,7 @@ class entity_instance(object): return return_type(_()) __dict__ = property(get_info) - + def get_info_2(self, include_identifier=True, recursive=False, return_type=dict, ignore=()): assert include_identifier assert recursive diff --git a/src/ifcopenshell-python/ifcopenshell/file.py b/src/ifcopenshell-python/ifcopenshell/file.py index 0bf9344c80..7df5e14231 100644 --- a/src/ifcopenshell-python/ifcopenshell/file.py +++ b/src/ifcopenshell-python/ifcopenshell/file.py @@ -35,6 +35,105 @@ except NameError: basestring = (str, bytes) +class Transaction: + def __init__(self, ifc_file): + self.file = ifc_file + self.operations = [] + + def serialise_entity_instance(self, element): + info = element.get_info() + for key, value in info.items(): + info[key] = self.serialise_value(element, value) + return info + + def serialise_value(self, element, value): + return element.walk(lambda v: isinstance(v, entity_instance), lambda v: {"id": v.id()}, value) + + def unserialise_value(self, element, value): + return element.walk(lambda v: isinstance(v, dict), lambda v: self.file.by_id(v["id"]), value) + + def store_create(self, element): + self.operations.append({"action": "create", "value": self.serialise_entity_instance(element)}) + + def store_edit(self, element, index, value): + self.operations.append( + { + "action": "edit", + "id": element.id(), + "index": index, + "old": self.serialise_value(element, element[index]), + "new": self.serialise_value(element, value), + } + ) + + def store_delete(self, element): + inverses = {} + for inverse in self.file.get_inverse(element): + inverse_references = [] + for i, attribute in enumerate(inverse): + if attribute == element: + inverse_references.append((i, "single")) + elif isinstance(attribute, tuple) and element in attribute: + inverse_references.append((i, "multiple")) + inverses[inverse.id()] = inverse_references + self.operations.append( + {"action": "delete", "inverses": inverses, "value": self.serialise_entity_instance(element)} + ) + + def rollback(self): + for operation in self.operations[::-1]: + if operation["action"] == "create": + element = self.file.by_id(operation["value"]["id"]) + if hasattr(element, "GlobalId"): + # hack, otherwise ifcopenshell gets upset + element.GlobalId = "x" + self.file.remove(element) + elif operation["action"] == "edit": + element = self.file.by_id(operation["id"]) + try: + element[operation["index"]] = self.unserialise_value(element, operation["old"]) + except: + # Catch discrepancy where IfcOpenShell creates but doesn't allow editing of invalid values + pass + elif operation["action"] == "delete": + e = self.file.create_entity(operation["value"]["type"], id=operation["value"]["id"]) + for k, v in operation["value"].items(): + try: + setattr(e, k, self.unserialise_value(e, v)) + except: + # Catch discrepancy where IfcOpenShell creates but doesn't allow editing of invalid values + pass + for inverse_id, data in operation["inverses"].items(): + inverse = self.file.by_id(inverse_id) + for index, data_type in data: + if data_type == "single": + inverse[index] = e + elif data_type == "multiple": + if inverse[index] is None: + inverse[index] = e + else: + new = list(inverse[index]) + new.append(e) + inverse[index] = new + + def commit(self): + for operation in self.operations: + if operation["action"] == "create": + e = self.file.create_entity(operation["value"]["type"], id=operation["value"]["id"]) + for k, v in operation["value"].items(): + try: + setattr(e, k, self.unserialise_value(e, v)) + except: + # Catch discrepancy where IfcOpenShell creates but doesn't allow editing of invalid values + pass + elif operation["action"] == "edit": + element = self.file.by_id(operation["id"]) + element[operation["index"]] = self.unserialise_value(element, operation["new"]) + elif operation["action"] == "delete": + element = self.file.by_id(operation["value"]["id"]) + self.file.remove(element) + + class file(object): """Base class for containing IFC files. @@ -59,6 +158,45 @@ class file(object): args = filter(None, [schema]) args = map(ifcopenshell_wrapper.schema_by_name, args) self.wrapped_data = ifcopenshell_wrapper.file(*args) + self.history_size = 64 + self.history = [] + self.future = [] + self.transaction = None + + def set_history_size(self, size): + self.history_size = size + while len(self.history) > self.history_size: + self.history.pop(0) + + def begin_transaction(self): + self.transaction = Transaction(self) + + def end_transaction(self): + if self.transaction: + self.history.append(self.transaction) + if len(self.history) > self.history_size: + self.history.pop(0) + self.future = [] + self.transaction = None + + def discard_transaction(self): + if self.transaction: + self.transaction.rollback() + self.transaction = None + + def undo(self): + if not self.history: + return + transaction = self.history.pop() + transaction.rollback() + self.future.append(transaction) + + def redo(self): + if not self.future: + return + transaction = self.future.pop() + transaction.commit() + self.history.append(transaction) def create_entity(self, type, *args, **kwargs): """Create a new IFC entity in the file. @@ -82,14 +220,17 @@ class file(object): """ eid = -1 try: - eid = kwargs.pop("_id", -1) - except: pass - e = entity_instance((self.schema, type)) + eid = kwargs.pop("id", -1) + except: + pass + e = entity_instance((self.schema, type), self) self.wrapped_data.add(e.wrapped_data, eid) e.wrapped_data.this.disown() attrs = list(enumerate(args)) + [(e.wrapped_data.get_argument_index(name), arg) for name, arg in kwargs.items()] for idx, arg in attrs: e[idx] = arg + if self.transaction: + self.transaction.store_create(e) return e def __getattr__(self, attr): @@ -100,9 +241,9 @@ class file(object): def __getitem__(self, key): if isinstance(key, numbers.Integral): - return entity_instance(self.wrapped_data.by_id(key)) + return entity_instance(self.wrapped_data.by_id(key), self) elif isinstance(key, basestring): - return entity_instance(self.wrapped_data.by_guid(str(key))) + return entity_instance(self.wrapped_data.by_guid(str(key)), self) def by_id(self, id): """Return an IFC entity instance filtered by IFC ID. @@ -129,7 +270,7 @@ class file(object): If the entity already exists, it is not re-added.""" inst.wrapped_data.this.disown() - return entity_instance(self.wrapped_data.add(inst.wrapped_data, -1 if _id is None else _id)) + return entity_instance(self.wrapped_data.add(inst.wrapped_data, -1 if _id is None else _id), self) def by_type(self, type, include_subtypes=True): """Return IFC objects filtered by IFC Type and wrapped with the entity_instance class. @@ -144,8 +285,8 @@ class file(object): :rtype: list """ if include_subtypes: - return [entity_instance(e) for e in self.wrapped_data.by_type(type)] - return [entity_instance(e) for e in self.wrapped_data.by_type_excl_subtypes(type)] + return [entity_instance(e, self) for e in self.wrapped_data.by_type(type)] + return [entity_instance(e, self) for e in self.wrapped_data.by_type_excl_subtypes(type)] def traverse(self, inst, max_levels=None): """Get a list of all referenced instances for a particular instance including itself @@ -159,7 +300,7 @@ class file(object): """ if max_levels is None: max_levels = -1 - return [entity_instance(e) for e in self.wrapped_data.traverse(inst.wrapped_data, max_levels)] + return [entity_instance(e, self) for e in self.wrapped_data.traverse(inst.wrapped_data, max_levels)] def get_inverse(self, inst): """Return a list of entities that reference this entity @@ -169,7 +310,7 @@ class file(object): :returns: A list of ifcopenshell.entity_instance.entity_instance objects :rtype: list """ - return [entity_instance(e) for e in self.wrapped_data.get_inverse(inst.wrapped_data)] + return [entity_instance(e, self) for e in self.wrapped_data.get_inverse(inst.wrapped_data)] def remove(self, inst): """Deletes an IFC object in the file. @@ -182,12 +323,14 @@ class file(object): :type inst: ifcopenshell.entity_instance.entity_instance :rtype: None """ + if self.transaction: + self.transaction.store_delete(inst) return self.wrapped_data.remove(inst.wrapped_data) - + def batch(self): """Low-level mechanism to speed up deletion of large subgraphs""" return self.wrapped_data.batch() - + def unbatch(self): """Low-level mechanism to speed up deletion of large subgraphs""" return self.wrapped_data.unbatch()