Fix dangerous bug with bim.override_outliner_delete

Essentialy outliner.delete should do the same as object.delete, but in our case the main implementation was in object.delete while outliner.delete was missing very important parts - e.g. handling arrays.

So if user could have remove part of an array without errors, leaving pset data invalid. See #6590, #6547.
Found this by trying to debug issues above.

Now outliner.delete will call object.delete explicitly, so the two stay connected.

I also had an idea that may be it somehow can produce #6632/#6619, but couldn't find a way to reproduce it. So it's still uncertain.
This commit is contained in:
Andrej730
2025-05-02 17:44:02 +05:00
parent 2382f8d7f8
commit fad35b14f2
@@ -46,7 +46,7 @@ from mathutils import Vector, Matrix, Quaternion
from time import time
from bonsai.bim.ifc import IfcStore
from ifcopenshell.util.shape_builder import ShapeBuilder
from typing import Any, Union, Literal, get_args, TYPE_CHECKING, assert_never
from typing import Any, Union, Literal, get_args, TYPE_CHECKING, assert_never, NamedTuple
from bonsai.bim.module.model.decorator import ProfileDecorator
if TYPE_CHECKING:
@@ -813,9 +813,14 @@ class OverrideDelete(bpy.types.Operator):
if self.is_batch:
ifcopenshell.util.element.batch_remove_deep2(tool.Ifc.get())
# Very important to create a copy of selected objects before objects might get deleted.
# Acessing it after might produce a crash in Blender <4.4 and `None` values in >=4.4.
# See https://projects.blender.org/blender/blender/issues/138325
objects_to_remove = context.selected_objects
self.process_arrays(context)
clear_active_object = True
objects_to_remove = context.selected_objects
for i, obj in enumerate(objects_to_remove, 1):
# Log time.
time_since_start = time() - start_time
@@ -903,7 +908,12 @@ class OverrideDelete(bpy.types.Operator):
break # allows to remove only n last layers of an array
class OverrideOutlinerDelete(bpy.types.Operator):
class SelectedIdsData(NamedTuple):
objects: set[bpy.types.Object]
collections: set[bpy.types.Collection]
class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.override_outliner_delete"
bl_label = "IFC Delete"
bl_options = {"REGISTER", "UNDO"}
@@ -911,7 +921,7 @@ class OverrideOutlinerDelete(bpy.types.Operator):
is_batch: bpy.props.BoolProperty(name="Is Batch", default=False)
@classmethod
def poll(cls, context):
def poll(cls, context) -> bool:
return len(getattr(context, "selected_ids", [])) > 0
def execute(self, context):
@@ -924,24 +934,16 @@ class OverrideOutlinerDelete(bpy.types.Operator):
if tool.Ifc.get():
return IfcStore.execute_ifc_operator(self, context)
# https://blender.stackexchange.com/questions/203729/python-get-selected-objects-in-outliner
objects_to_delete: set[bpy.types.Object] = set()
collections_to_delete: set[bpy.types.Collection] = set()
for item in context.selected_ids:
if item.bl_rna.identifier == "Collection":
collection = bpy.data.collections.get(item.name)
collection_data = self.get_collection_objects_and_children(collection)
objects_to_delete |= collection_data["objects"]
collections_to_delete |= collection_data["children"]
collections_to_delete.add(collection)
elif item.bl_rna.identifier == "Object":
objects_to_delete.add(bpy.data.objects.get(item.name))
for obj in objects_to_delete:
# TODO: replace with outliner.delete?
selected_ids_data = self.get_selected_ids_data(context)
for obj in selected_ids_data.objects:
bpy.data.objects.remove(obj)
for collection in collections_to_delete:
for collection in selected_ids_data.collections:
bpy.data.collections.remove(collection)
return {"FINISHED"}
def invoke(self, context, event):
# TODO: move to common method.
if tool.Ifc.get():
total_elements = len(tool.Ifc.get().wrapped_data.entity_names())
total_polygons = sum([len(o.data.polygons) for o in context.selected_objects if o.type == "MESH"])
@@ -960,56 +962,38 @@ class OverrideOutlinerDelete(bpy.types.Operator):
row.label(text="Warning: Faster deletion will use more memory.", icon="ERROR")
def _execute(self, context):
if self.is_batch:
ifcopenshell.util.element.batch_remove_deep2(tool.Ifc.get())
objects_to_delete = set()
collections_to_delete = set()
for item in context.selected_ids:
if item.bl_rna.identifier == "Collection":
collection = bpy.data.collections.get(item.name)
collection_data = self.get_collection_objects_and_children(collection)
objects_to_delete |= collection_data["objects"]
collections_to_delete |= collection_data["children"]
collections_to_delete.add(collection)
elif item.bl_rna.identifier == "Object":
objects_to_delete.add(bpy.data.objects.get(item.name))
for obj in objects_to_delete:
if element := tool.Ifc.get_entity(obj):
if tool.Geometry.is_locked(element):
self.report({"ERROR"}, lock_error_message(obj.name))
if collection := tool.Blender.get_object_bim_props(obj).collection:
collections_to_delete.discard(collection)
continue
tool.Geometry.delete_ifc_object(obj)
else:
bpy.data.objects.remove(obj)
for collection in collections_to_delete:
selected_ids_data = self.get_selected_ids_data(context)
with context.temp_override(selected_objects=list(selected_ids_data.objects)):
bpy.ops.bim.override_object_delete(is_batch=self.is_batch)
for collection in selected_ids_data.collections:
# Removing an aggregate object would also remove it's collection
# making the collection data-block invalid.
if not tool.Blender.is_valid_data_block(collection):
continue
if collection.all_objects:
continue
bpy.data.collections.remove(collection)
if self.is_batch:
old_file = tool.Ifc.get()
old_file.end_transaction()
new_file = ifcopenshell.util.element.unbatch_remove_deep2(tool.Ifc.get())
new_file.begin_transaction()
tool.Ifc.set(new_file)
self.transaction_data = {"old_file": old_file, "new_file": new_file}
IfcStore.add_transaction_operation(self)
return {"FINISHED"}
def get_collection_objects_and_children(self, collection: bpy.types.Collection) -> dict[str, Any]:
@classmethod
def get_selected_ids_data(cls, context: bpy.types.Context) -> SelectedIdsData:
objects_to_delete: set[bpy.types.Object] = set()
collections_to_delete: set[bpy.types.Collection] = set()
for item in context.selected_ids:
if isinstance(item, bpy.types.Collection):
collection_data = cls.get_collection_objects_and_children(item)
objects_to_delete |= collection_data["objects"]
collections_to_delete |= collection_data["children"]
collections_to_delete.add(item)
elif isinstance(item, bpy.types.Object):
objects_to_delete.add(item)
return SelectedIdsData(objects_to_delete, collections_to_delete)
@staticmethod
def get_collection_objects_and_children(collection: bpy.types.Collection) -> dict[str, Any]:
return {"objects": set(collection.all_objects), "children": set(collection.children_recursive)}
def rollback(self, data):
tool.Ifc.set(data["old_file"])
data["old_file"].undo()
def commit(self, data):
data["old_file"].redo()
tool.Ifc.set(data["new_file"])
class OverrideDuplicateMoveMacro(bpy.types.Macro):
bl_idname = "bim.override_object_duplicate_move_macro"