feat: auto-assign containers to root aggregates and organize parts in outliner

When assigning a container to an aggregated element, automatically promote
the operation to the root aggregate and move all nested parts to the
container's collection in the Blender outliner.

Changes:
- AssignContainer now traverses the aggregate hierarchy to find the root
  aggregate when a user selects any nested part
- All parts and sub-aggregates are moved to the container's collection in
  the outliner while preserving IFC aggregate relationships
- Parts remain aggregated in IFC (not directly contained), only their
  Blender collection membership changes
- RefreshLinkedAggregate now also moves all parts to the correct container
  collection when restoring original data

This provides a more intuitive UX - users can select any part and the entire
assembly moves together, properly organized under the spatial container.

Fixes the previous behavior where:
- Aggregated elements were skipped with a warning
- Parts weren't organized under the container in the outliner
- Aggregate nesting was broken after container assignment
This commit is contained in:
Ryan Schultz
2026-01-13 11:45:34 -06:00
parent 249e68f163
commit 9adbd47181
2 changed files with 95 additions and 21 deletions
@@ -1715,19 +1715,40 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
# Only assign container if element is not already aggregated under another element
# Aggregated elements should not be in the spatial structure
if not ifcopenshell.util.element.get_aggregate(element):
container = original_data[matching_group_id][index]["Container"]
bonsai.core.spatial.assign_container(
tool.Ifc,
tool.Collector,
tool.Spatial,
container=original_data[matching_group_id][index]["Container"],
container=container,
element_obj=obj,
)
for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)):
tool.Collector.assign(tool.Ifc.get_object(part))
assignments = original_data[matching_group_id][index]["Assignment"]
if assignments:
assign_to_annotations(obj, assignments)
# Get the container's collection for moving parts in the outliner
container_obj = tool.Ifc.get_object(container)
container_collection = container_obj.BIMObjectProperties.collection if container_obj else None
# Move all parts to the container's collection in the outliner
if container_collection:
for part in ifcopenshell.util.element.get_parts(element):
part_obj = tool.Ifc.get_object(part)
if part_obj:
# Remove from all previous collections
for col in part_obj.users_collection[:]:
col.objects.unlink(part_obj)
# Link to container collection
if part_obj.name not in container_collection.objects:
container_collection.objects.link(part_obj)
# Recursively handle nested parts
for nested_part in ifcopenshell.util.element.get_parts(part):
nested_part_obj = tool.Ifc.get_object(nested_part)
if nested_part_obj:
for col in nested_part_obj.users_collection[:]:
col.objects.unlink(nested_part_obj)
if nested_part_obj.name not in container_collection.objects:
container_collection.objects.link(nested_part_obj)
else:
try:
obj.name = original_data[matching_group_id][index]["Name"]
@@ -175,31 +175,84 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator):
else:
return
def get_root_aggregate(element):
"""Traverse up the aggregate hierarchy to find the top-most aggregate"""
current = element
root = None
while aggregate := ifcopenshell.util.element.get_aggregate(current):
root = aggregate
current = aggregate
return root
def get_all_parts_recursive(element):
"""Recursively get all parts of an aggregate"""
parts = []
for part in ifcopenshell.util.element.get_parts(element):
parts.append(part)
# Recursively get nested parts
parts.extend(get_all_parts_recursive(part))
return parts
objs: list[bpy.types.Object] = []
# In IFC element can be either contained of aggregated,
# tehrefore we skip aggregated elements here to prevent confusion.
# Can't handle it in `poll` since user might just select bunch of elements
# and try to assign a container to them
# and excluding aggregates because of the `poll` failing might get awkward.
skipped_aggregates = 0
processed_elements = set() # Track elements we've already handled (by IFC ID)
promoted_parts = 0 # Count how many parts were promoted to their root aggregate
for obj in tool.Blender.get_selected_objects():
if not (element := tool.Ifc.get_entity(obj)):
continue
if ifcopenshell.util.element.get_aggregate(element):
skipped_aggregates += 1
continue
objs.append(obj)
# Check if element is part of an aggregate (at any level)
if root_aggregate := get_root_aggregate(element):
# Skip if we've already processed this root aggregate
if root_aggregate.id() in processed_elements:
continue
# Get the root aggregate object and add it instead
if root_aggregate_obj := tool.Ifc.get_object(root_aggregate):
objs.append(root_aggregate_obj)
processed_elements.add(root_aggregate.id())
if root_aggregate != element: # Only count as promoted if different from selected
promoted_parts += 1
else:
# Element is not part of any aggregate
if element.id() not in processed_elements:
objs.append(obj)
processed_elements.add(element.id())
# Get the container's collection
container_obj = tool.Ifc.get_object(container)
container_collection = container_obj.BIMObjectProperties.collection if container_obj else None
for element_obj in objs:
element = tool.Ifc.get_entity(element_obj)
# Only assign container to the ROOT aggregate (this updates IFC relationships)
if self.remove_from_other_containers:
for col in element_obj.users_collection[:]:
col.objects.unlink(element_obj)
core.assign_container(tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj)
# For parts, only move them in Blender collections (don't change IFC relationships)
if container_collection:
all_parts = get_all_parts_recursive(element)
for part in all_parts:
if part_obj := tool.Ifc.get_object(part):
# Always remove from ALL previous collections when moving to new container
for col in part_obj.users_collection[:]:
col.objects.unlink(part_obj)
# Link to new container collection (Blender-only, no IFC change)
if part_obj.name not in container_collection.objects:
container_collection.objects.link(part_obj)
aggregates_msg = ""
if skipped_aggregates:
aggregates_msg = f" {skipped_aggregates} aggregated elements skipped."
self.report({"INFO"}, f"{len(objs)} elements assigned.{aggregates_msg}")
# Disable editing mode for all selected objects
for obj in tool.Blender.get_selected_objects():
core.disable_editing_container(tool.Spatial, obj=obj)
promoted_msg = ""
if promoted_parts:
promoted_msg = f" {promoted_parts} nested parts promoted to their root aggregates."
self.report({"INFO"}, f"{len(objs)} elements assigned.{promoted_msg}")
class EnableEditingContainer(bpy.types.Operator):