From 1dd477d382f4b3f6aad532b851c3beb9de20463c Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 12 Oct 2025 12:17:57 -0500 Subject: [PATCH] Delete aggregate if all its parts are deleted. Prevents having orphaned aggregates, if you do a bulk delete with empties turned off. --- .../bonsai/bim/module/aggregate/operator.py | 29 ++++++++++- .../bonsai/bim/module/geometry/operator.py | 50 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 6e902e76f5..e2727b101a 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -94,6 +94,9 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): + aggregates_to_check = set() + + # First pass: unassign all parts and track their aggregates for obj in tool.Blender.get_selected_objects(): element = tool.Ifc.get_entity(obj) if not element: @@ -101,6 +104,10 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): aggregate = ifcopenshell.util.element.get_aggregate(element) if not aggregate: continue + + # Track this aggregate for later checking + aggregates_to_check.add(aggregate) + core.unassign_object( tool.Ifc, tool.Aggregate, @@ -115,7 +122,27 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): if pset: pset = tool.Ifc.get().by_id(pset["id"]) ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset) - + + # Second pass: delete aggregates that now have no parts + deleted_aggregates = [] + for aggregate in aggregates_to_check: + related_objects = ifcopenshell.util.element.get_parts(aggregate) + if len(related_objects) == 0: + aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}" + deleted_aggregates.append(aggregate_name) + + aggregate_obj = tool.Ifc.get_object(aggregate) + if aggregate_obj: + ifcopenshell.api.root.remove_product(tool.Ifc.get(), product=aggregate) + bpy.data.objects.remove(aggregate_obj, do_unlink=True) + + # Show info message if aggregates were deleted + if deleted_aggregates: + if len(deleted_aggregates) == 1: + self.report({'INFO'}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts") + else: + aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates) + self.report({'INFO'}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts") class BIM_OT_enable_editing_aggregate(bpy.types.Operator): """Enable editing aggregation relationship""" diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index a5218b3dfa..0d5e1bd170 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -787,7 +787,6 @@ def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context is_batch = total_elements > 500000 and total_polygons > 2000 return is_batch - class OverrideDelete(bpy.types.Operator): bl_idname = "bim.override_object_delete" bl_label = "IFC Delete" @@ -858,6 +857,10 @@ class OverrideDelete(bpy.types.Operator): objects_to_remove = context.selected_objects self.process_arrays(context) + + # Track aggregates before deleting their parts + aggregates_to_check = self.track_aggregates(objects_to_remove) + clear_active_object = True for i, obj in enumerate(objects_to_remove, 1): @@ -898,6 +901,9 @@ class OverrideDelete(bpy.types.Operator): else: bpy.data.objects.remove(obj) + # Delete empty aggregates after deleting their parts + self.delete_empty_aggregates(aggregates_to_check) + for opening in tool.Model.get_model_props().openings: if opening.obj is not None and not tool.Ifc.get_entity(opening.obj): bpy.data.objects.remove(opening.obj) @@ -930,6 +936,47 @@ class OverrideDelete(bpy.types.Operator): data["old_file"].redo() tool.Ifc.set(data["new_file"]) + def track_aggregates(self, objects_to_remove): + """Track aggregates that contain objects being deleted""" + aggregates_to_check = set() + for obj in objects_to_remove: + if not tool.Blender.is_valid_data_block(obj): + continue + element = tool.Ifc.get_entity(obj) + if not element: + continue + aggregate = ifcopenshell.util.element.get_aggregate(element) + if aggregate: + aggregates_to_check.add(aggregate) + return aggregates_to_check + + def delete_empty_aggregates(self, aggregates_to_check): + """Delete aggregates that now have no parts""" + deleted_aggregates = [] + for aggregate in aggregates_to_check: + # Check if aggregate still exists (might have been deleted already) + try: + aggregate.id() + except: + continue + + related_objects = ifcopenshell.util.element.get_parts(aggregate) + if len(related_objects) == 0: + aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}" + deleted_aggregates.append(aggregate_name) + + aggregate_obj = tool.Ifc.get_object(aggregate) + if aggregate_obj and tool.Blender.is_valid_data_block(aggregate_obj): + tool.Geometry.delete_ifc_object(aggregate_obj) + + # Show info message if aggregates were deleted + if deleted_aggregates: + if len(deleted_aggregates) == 1: + self.report({'INFO'}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts") + else: + aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates) + self.report({'INFO'}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts") + def process_arrays(self, context: bpy.types.Context) -> None: ifc_file = tool.Ifc.get() selected_objects = set(context.selected_objects) @@ -955,7 +1002,6 @@ class OverrideDelete(bpy.types.Operator): else: break # allows to remove only n last layers of an array - class SelectedIdsData(NamedTuple): objects: set[bpy.types.Object] collections: set[bpy.types.Collection]