diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index 769f2b5043f..1b5e9e19324 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -15,6 +15,7 @@ BL_OUTLINER_TYPES, assign_loader_to_datablocks, build_op_basename, + ensure_unique_name, get_all_outliner_children, get_instanced_collections, ) @@ -54,6 +55,11 @@ def create_container( Returns: OpenpypeContainer: Created container """ + # Ensure container name + name = ensure_unique_name( + name, bpy.context.scene.openpype_containers.keys() + ) + container = bpy.context.scene.openpype_containers.add() container.name = name @@ -64,7 +70,7 @@ def create_container( def parse_container( container: Union[bpy.types.Collection, bpy.types.Object], - validate: bool = True + validate: bool = True, ) -> Dict: """Return the container node's full container data. @@ -95,7 +101,8 @@ def parse_container( return data -def update_scene_containers()->List[OpenpypeContainer]: + +def update_scene_containers() -> List[OpenpypeContainer]: """Update containers in scene from datablocks. For example, if a loaded collection has been duplicated using the outliner @@ -110,15 +117,14 @@ def update_scene_containers()->List[OpenpypeContainer]: # Prepare datablocks to skip for containers auto creation # Datablocks already in containers are ignored - datablocks_to_skip = { - d_ref.datablock - for d_ref in chain.from_iterable( + datablocks_to_skip = set( + chain.from_iterable( [ - op_container.datablock_refs + op_container.get_datablocks(only_local=False) for op_container in openpype_containers ] ) - } + ) # Get container datablocks not already correctly created # (e.g collection duplication or linked by hand) @@ -131,7 +137,10 @@ def update_scene_containers()->List[OpenpypeContainer]: all_instanced_collections = get_instanced_collections() container_datablocks = [ datablock - for datablock in sorted(lsattr("id", AVALON_CONTAINER_ID), key=lambda d: datatype_order.get(type(d), 10)) + for datablock in sorted( + lsattr("id", AVALON_CONTAINER_ID), + key=lambda d: datatype_order.get(type(d), 10), + ) if not ( isinstance(datablock, tuple(BL_OUTLINER_TYPES)) and datablock @@ -179,6 +188,13 @@ def update_scene_containers()->List[OpenpypeContainer]: else: container_datablocks = [entity] + # Add mesh datablocks + container_datablocks.extend( + datablock.data + for datablock in container_datablocks + if datablock and isinstance(datablock, bpy.types.Object) + ) + # Add library references of datablocks to avoid duplicates container_datablocks.extend( datablock.override_library.reference @@ -198,8 +214,13 @@ def update_scene_containers()->List[OpenpypeContainer]: ).get(AVALON_PROPERTY) # Get container if already created + container = None representation_id = container_metadata.get("representation") - container = created_containers.get(representation_id) + for c in openpype_containers: + if container_metadata.get("representation") == representation_id: + container = c + break + if container: add_datablocks_to_container(container_datablocks, container) else: @@ -208,8 +229,10 @@ def update_scene_containers()->List[OpenpypeContainer]: container_metadata.get("asset_name"), container_metadata.get("name"), ) - container = create_container(container_name, container_datablocks) - created_containers[representation_id] = container + create_container(container_name, container_datablocks) + # NOTE need to get it this way because memory could have changed + # BUG: https://projects.blender.org/blender/blender/issues/105338 + container = bpy.context.scene.openpype_containers[-1] # Keep objectName for update/switch container_metadata["objectName"] = container.name @@ -221,17 +244,13 @@ def update_scene_containers()->List[OpenpypeContainer]: else entity.library ) - # Keep outliner entity if any - if isinstance(entity, tuple(BL_OUTLINER_TYPES)): - container.outliner_entity = entity - # Clear containers when data has been deleted from the outliner - for container in reversed(openpype_containers): + for i, container in reversed( + list(enumerate(bpy.context.scene.openpype_containers)) + ): # In case all datablocks removed, remove container - if not any({d_ref.datablock for d_ref in container.datablock_refs}): - openpype_containers.remove( - openpype_containers.find(container.name) - ) + if not any(container.get_datablocks(only_local=False)): + openpype_containers.remove(i) return created_containers diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py index 2a568b94e24..2f98ca8a0e2 100644 --- a/openpype/hosts/blender/api/ops.py +++ b/openpype/hosts/blender/api/ops.py @@ -23,14 +23,14 @@ get_asset_by_name, get_assets, ) -from openpype.hosts.blender.api.lib import add_datablocks_to_container, update_scene_containers +from openpype.hosts.blender.api.lib import add_datablocks_to_container from openpype.hosts.blender.api.utils import ( BL_OUTLINER_TYPES, BL_TYPE_DATAPATH, build_op_basename, - get_all_outliner_children, get_parent_collection, link_to_collection, + unlink_from_collection, ) from openpype.pipeline import legacy_io from openpype.pipeline.create.creator_plugins import ( @@ -535,7 +535,7 @@ def invoke(self, context, _event): self.all_assets.add().name = asset_doc["name"] self.asset_name = legacy_io.Session["AVALON_ASSET"] - + # Setup all data _update_entries_preset(self, bpy.context) @@ -582,14 +582,13 @@ def draw(self, context): # Checkbox to gather selected element in outliner draw_gather_into_collection(self, context) - def execute(self, _context): if not self.asset_name: - self.report({"ERROR"}, f"Asset name must be filled!") + self.report({"ERROR"}, "Asset name must be filled!") return {"CANCELLED"} if not self.datablock_name and not self.use_selection: - self.report({"WARNING"}, f"No any datablock to process...") + self.report({"WARNING"}, "No any datablock to process...") # Get creator class Creator = get_legacy_creator_by_name(self.creator_name) @@ -736,7 +735,7 @@ def execute(self, context): def draw_gather_into_collection(self, context): """Draw checkbox to gather selected element in outliner. - + Only if collections are handled by creator family. """ if self.datapath in {BL_TYPE_DATAPATH.get(t) for t in BL_OUTLINER_TYPES} and bpy.types.Collection.__name__ in { @@ -1054,7 +1053,7 @@ class SCENE_OT_MakeContainerPublishable(bpy.types.Operator): convert_to_current_asset: bpy.props.BoolProperty( name="Convert container to current OpenPype asset", - description="It changes the asset but keeps the subset name" + description="It changes the asset but keeps the subset name", ) # NOTE cannot use AVALON_PROPERTY because of circular dependency @@ -1063,11 +1062,16 @@ class SCENE_OT_MakeContainerPublishable(bpy.types.Operator): def invoke(self, context, _event): wm = context.window_manager - # Prefill with outliner collection - matched_container = context.scene.openpype_containers.get( - context.collection.name - ) - if matched_container: + # Try to match the container referencing the current collection + if matched_container := next( + ( + container + for container in context.scene.openpype_containers + if context.collection + in container.get_root_outliner_datablocks() + ), + None, + ): self.container_name = matched_container.name return wm.invoke_props_dialog(self) @@ -1089,15 +1093,11 @@ def execute(self, context): openpype_containers = context.scene.openpype_containers container = openpype_containers.get(self.container_name) avalon_data = dict(container["avalon"]) - container_datablocks = [d_ref.datablock for d_ref in container.datablock_refs] # Expose container content and get neutral outliner entity - bpy.ops.scene.expose_container_content( - container_name=self.container_name - ) - outliner_entity = bpy.data.collections.get( + root_outliner_datablocks = expose_container_content( self.container_name - ) or bpy.data.objects.get(self.container_name) + ) # Get creator name for creator_name, creator_attrs in bpy.context.scene[ @@ -1106,52 +1106,94 @@ def execute(self, context): if creator_attrs["family"] == avalon_data.get("family"): break - # Create instance - create_args = { - "creator_name": creator_name, - "asset_name": legacy_io.Session["AVALON_ASSET"] + # Create new instance + bpy.ops.scene.create_openpype_instance( + creator_name=creator_name, + asset_name=legacy_io.Session["AVALON_ASSET"] if self.convert_to_current_asset else avalon_data["asset_name"], - "subset_name": avalon_data["name"], - "gather_into_collection": isinstance( - outliner_entity, bpy.types.Collection + subset_name=avalon_data["name"], + gather_into_collection=any( + isinstance(datablock, bpy.types.Collection) + for datablock in root_outliner_datablocks ), - } + use_selection=False, + datapath="collections", + datablock_name=root_outliner_datablocks[0].name, + ) - if outliner_entity: - # For collection, allow renaming - if isinstance(outliner_entity, bpy.types.Collection): - # TODO may not be a good design because this value is from source workfile... - outliner_entity.is_openpype_instance = False + # Reassign container datablocks to new instance + add_datablocks_to_container( + [ + d + for d in root_outliner_datablocks + if d + not in bpy.context.scene.openpype_instances[-1].get_datablocks( + bpy.types.Collection + ) + ], + bpy.context.scene.openpype_instances[-1], + ) - datablock_args = { - "datapath": BL_TYPE_DATAPATH.get(type(outliner_entity)), - "datablock_name": outliner_entity.name, - } - else: - datablock_args = { - "datapath": BL_TYPE_DATAPATH.get( - type(container_datablocks[0]) - ), - "datablock_name": container_datablocks[0].name, - } - create_args.update(datablock_args) + return {"FINISHED"} - # Create new instance - bpy.ops.scene.create_openpype_instance(**create_args) - # Reassign container datablocks to new instance - if not outliner_entity: - add_datablocks_to_container( - container_datablocks[1:], - bpy.context.scene.openpype_instances[-1], +def expose_container_content(container_name: str) -> List[bpy.types.ID]: + """Expose container content and return root outliner datablocks. + + Args: + container_name (str): Name of the container to expose. + + Returns: + List[bpy.types.ID]: List of root outliner datablocks. + """ + # Recover required data + openpype_containers = bpy.context.scene.openpype_containers + container = openpype_containers.get(container_name) + + # Remove old container + root_outliner_datablocks = container.get_root_outliner_datablocks() + openpype_containers.remove(openpype_containers.find(container.name)) + + if not root_outliner_datablocks: + return + + new_root_outliner_datablocks = [] + kept_root_outliner_datablocks = [] + for outliner_datablock in root_outliner_datablocks: + # If collection, convert it to regular one + if isinstance(outliner_datablock, bpy.types.Collection): + outliner_datablock.name += ".old" + parent_collection = get_parent_collection(outliner_datablock) + substitute_collection = bpy.data.collections.new(container_name) + + # Link new substitute collection to parent collection + link_to_collection(substitute_collection, parent_collection) + + # Link old collection objects to new substitute collection + link_to_collection( + outliner_datablock.objects, substitute_collection + ) + # Link old collection children to new substitute collection + link_to_collection( + outliner_datablock.children, substitute_collection ) - return {"FINISHED"} + # Unlink entity from scene + unlink_from_collection(outliner_datablock, parent_collection) + outliner_datablock.use_fake_user = False + + # Keep new collection as root outliner datablock + new_root_outliner_datablocks.append(substitute_collection) + else: + kept_root_outliner_datablocks.append(outliner_datablock) + + return new_root_outliner_datablocks + kept_root_outliner_datablocks class SCENE_OT_ExposeContainerContent(bpy.types.Operator): """Container's content is exposed to be accessible in scene inventory.\n""" + """It breaks the possibility to update the target container later""" bl_idname = "scene.expose_container_content" @@ -1165,11 +1207,16 @@ class SCENE_OT_ExposeContainerContent(bpy.types.Operator): def invoke(self, context, _event): wm = context.window_manager - # Prefill with outliner collection - matched_container = context.scene.openpype_containers.get( - context.collection.name - ) - if matched_container: + # Try to match the container referencing the current collection + if matched_container := next( + ( + container + for container in context.scene.openpype_containers + if context.collection + in container.get_root_outliner_datablocks() + ), + None, + ): self.container_name = matched_container.name return wm.invoke_props_dialog(self) @@ -1186,44 +1233,7 @@ def execute(self, context): self.report({"WARNING"}, "No container to make publishable...") return {"CANCELLED"} - # Recover required data - openpype_containers = context.scene.openpype_containers - container = openpype_containers.get(self.container_name) - - # Remove old container - outliner_entity = container.outliner_entity - openpype_containers.remove(openpype_containers.find(container.name)) - - if not outliner_entity: - return {"FINISHED"} - - # If collection, convert it to regular one - parent_collection = get_parent_collection(outliner_entity) - substitute_collection = None - if isinstance(outliner_entity, bpy.types.Collection): - outliner_entity.name += ".old" - substitute_collection = bpy.data.collections.new( - self.container_name - ) - - # Link old collection objects to new substitute collection - link_to_collection(outliner_entity.objects, substitute_collection) - - # Link new substitute collection to parent collection - parent_collection.children.link(substitute_collection) - - # Move objects to either substituted collection or the parent one - link_to_collection( - outliner_entity.children, - substitute_collection or parent_collection, - ) - - # Unlink entity from scene - parent_collection.children.unlink(outliner_entity) - outliner_entity.use_fake_user = False - - # Update containers list - update_scene_containers() + expose_container_content(self.container_name) return {"FINISHED"} diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 84e65b9abd4..07f65e268b3 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -21,8 +21,10 @@ BL_TYPE_DATAPATH, BL_TYPE_ICON, build_op_basename, + ensure_unique_name, get_children_recursive, get_parent_collection, + get_root_datablocks, link_to_collection, transfer_stack, unlink_from_collection, @@ -55,7 +57,7 @@ def get_unique_number( asset: str, subset: str, start_number: Optional[int] = None ) -> str: - """Return a unique number based on the asset name.""" + """Return a unique number based on the asset name.""" # TODO remove this container_names = [c.name for c in bpy.data.collections] container_names += [ obj.name @@ -84,7 +86,7 @@ def context_override( active: Optional[bpy.types.Object] = None, selected: Optional[bpy.types.Object] = None, window: Optional[bpy.types.Window] = None, - area_type: Optional[str] = "VIEW_3D" + area_type: Optional[str] = "VIEW_3D", ): """Create a new Blender context. If an object is passed as parameter, it is set as selected and active. @@ -242,26 +244,6 @@ def get_last_representation(representation_id: str) -> Optional[Dict]: return last_representation -def get_container_objects( - container: Union[bpy.types.Collection, bpy.types.Object] -) -> List[bpy.types.Object]: - """Get recursively all the child objects for the given container collection - or object empty. - - Arguments: - container: The parent container. - - Returns: - All the child objects of the container. - """ - return [ - d_ref.datablock - for d_ref in container.datablock_refs - if type(d_ref.datablock) is bpy.types.Object - and d_ref.datablock != container.outliner_entity - ] - - def get_collections_by_objects( objects: List[bpy.types.Object], collections: Optional[List[bpy.types.Collection]] = None @@ -345,7 +327,7 @@ def deselect_all(): def orphans_purge(): """Purge orphan datablocks and libraries.""" # clear unused datablock - bpy.data.orphans_purge(do_recursive=False) + bpy.data.orphans_purge(do_recursive=True) # clear unused libraries for library in list(bpy.data.libraries): @@ -375,6 +357,7 @@ def make_local(obj, data_local=True): obj.data.make_local() return obj + def exec_process(process_func: Callable): """Decorator to make sure the process function is executed in main thread. @@ -468,7 +451,9 @@ def _process_outliner( bpy.types.Collection: Created container collection """ # Filter outliner datablocks - collections, objects = self._filter_outliner_datablocks(datablocks) + collections, objects = self._filter_outliner_datablocks( + get_root_datablocks(datablocks) + ) # Determine collections to include if all children are included for collection in get_collections_by_objects(objects): @@ -477,23 +462,14 @@ def _process_outliner( # Remove objects to not link them to container objects -= set(collection.all_objects) - container_collection = bpy.data.collections.get(collection_name) - if container_collection is None: + # Bundle into one collection + if len(collections) == 1: # If only one collection handling all objects, use it as container - collections_as_list = list(collections) - if len(collections) == 1 and objects.issubset( - set(collections_as_list[0].objects) - ) and not collections_as_list[0].is_openpype_instance: - container_collection = collections_as_list[0] - container_collection.name = collection_name # Rename - else: - container_collection = bpy.data.collections.new( - collection_name - ) - bpy.context.scene.collection.children.link( - container_collection - ) - container_collection.is_openpype_instance = True + container_collection = list(collections)[0] + container_collection.name = collection_name + else: + container_collection = bpy.data.collections.new(collection_name) + bpy.context.scene.collection.children.link(container_collection) # Remove container collection from collections to link if container_collection in collections: @@ -538,7 +514,10 @@ def process( # Get info from data and create name value. asset = self.data["asset"] subset = self.data["subset"] - name = build_op_basename(asset, subset) + name = ensure_unique_name( + build_op_basename(asset, subset), + bpy.context.scene.openpype_instances.keys(), + ) # Use selected objects if useSelection is True if (self.options or {}).get("useSelection"): @@ -558,10 +537,8 @@ def process( BL_TYPE_ICON.get(t, "NONE") for t in self.bl_types ] op_instance["creator_name"] = self.__class__.__name__ - else: - # If no datablocks, then empty instance is already existing - if not datablocks: - raise RuntimeError(f"This instance already exists: {name}") + elif not datablocks: + raise RuntimeError(f"This instance already exists: {name}") # Add custom property on the instance container with the data. self.data["task"] = legacy_io.Session.get("AVALON_TASK") @@ -631,6 +608,7 @@ def _remove_instance(self, instance_name: str) -> bool: return True + class Loader(LoaderPlugin): """Base class for Loader plug-ins.""" @@ -739,9 +717,9 @@ def _load_library_datablocks( loaded_data_collections.add(data_collection_name) # Convert datablocks names to datablocks references - datablocks = set() + datablocks = [] for collection_name in loaded_data_collections: - datablocks.update(getattr(data_to, collection_name)) + datablocks.extend(getattr(data_to, collection_name)) # Remove fake user from loaded datablocks datacol = getattr(bpy.data, collection_name) @@ -750,82 +728,52 @@ def _load_library_datablocks( ] datacol.foreach_set("use_fake_user", seq) - if self.bl_types & BL_OUTLINER_TYPES: - # Get datablocks to override, which have - # no user in the loaded datablocks (orphan at this point) - datablocks_to_override = { - d - for d, users in bpy.data.user_map( - subset=datablocks - ).items() - if not users & datablocks - } - - # Try to get the right asset container from imported collections. - # TODO this whole outliner entity is a bad idea - # it must be refactored to deal correctly with nested - # outliner entities using user_map() - outliner_entity = next( - ( - entity - for entity in datablocks_to_override - if entity.name.startswith(container_name) - ), - None, - ) - - # Override datablocks if needed - if do_override: - datablocks = set() - for d in datablocks_to_override: - override_datablock = d.override_hierarchy_create( - bpy.context.scene, - bpy.context.view_layer - # NOTE After BL3.4: do_fully_editable=True - ) - - # Update outliner entity - outliner_entity = override_datablock + # Get datablocks to override, which have + # no user in the loaded datablocks (orphan at this point) + datablocks_to_override = { + d + for d, users in bpy.data.user_map(subset=datablocks).items() + if not users & set(datablocks) + } + + # Override datablocks if needed + if link and do_override: + for d in datablocks_to_override: + override_datablock = d.override_hierarchy_create( + bpy.context.scene, + bpy.context.view_layer + # NOTE After BL3.4: do_fully_editable=True + ) - # Update datablocks because could have been renamed - datablocks.add(override_datablock) - if isinstance( - override_datablock, tuple(BL_OUTLINER_TYPES) - ): - datablocks.update( - override_datablock.children_recursive - ) - if isinstance( - override_datablock, bpy.types.Collection - ): - datablocks.update( - set(override_datablock.all_objects) - ) - - # Ensure user override NOTE: will be unecessary after BL3.4 - for d in datablocks: - if hasattr(d.override_library, "is_system_override"): - d.override_library.is_system_override = False - - if outliner_entity: - # Set color - if hasattr(outliner_entity, "color_tag"): - outliner_entity.color_tag = self.color_tag - - # Substitute name in case renamed with .### - container_name = outliner_entity.name - else: - outliner_entity = None + # Update datablocks because could have been renamed + datablocks.append(override_datablock) + if isinstance(override_datablock, tuple(BL_OUTLINER_TYPES)): + datablocks.extend(override_datablock.children_recursive) + if isinstance(override_datablock, bpy.types.Collection): + datablocks.extend(override_datablock.all_objects) + + # Ensure user override NOTE: will be unecessary after BL3.4 + for d in datablocks: + if hasattr(d.override_library, "is_system_override"): + d.override_library.is_system_override = False + + # Add meshes to datablocks + datablocks.extend( + d.data for d in datablocks if d and isinstance(d, bpy.types.Object) + ) # Put into container container = self._containerize_datablocks( container_name, datablocks, container=container ) + # Set color + for d in container.get_root_outliner_datablocks(): + if hasattr(d, "color_tag"): + d.color_tag = self.color_tag + # Set data to container container.library = bpy.data.libraries.get(libpath.name) - if outliner_entity: - container.outliner_entity = outliner_entity return container, datablocks @@ -884,7 +832,7 @@ def _link_blend( libpath: Path, container_name: str, container: OpenpypeContainer = None, - override=True + override=True, ) -> Tuple[OpenpypeContainer, List[bpy.types.ID]]: """Link blend process. @@ -905,8 +853,7 @@ def _link_blend( libpath, container_name, container=container, do_override=override ) - container_collection = container.outliner_entity - if container_collection: + for container_collection in container.get_root_outliner_datablocks(): # If override_hierarchy_create method is not implemented for older # Blender versions we need the following steps. if not hasattr(container_collection, "override_hierarchy_create"): @@ -933,7 +880,7 @@ def _append_blend( self, libpath: Path, container_name: str, - container: OpenpypeContainer = None + container: OpenpypeContainer = None, ) -> Tuple[OpenpypeContainer, List[bpy.types.ID]]: """Append blend process. @@ -953,8 +900,7 @@ def _append_blend( ) # Link loaded collection to scene - container_collection = container.outliner_entity - if container_collection: + for container_collection in container.get_root_outliner_datablocks(): link_to_collection( container_collection, bpy.context.scene.collection ) @@ -965,7 +911,7 @@ def _instance_blend( self, libpath: Path, container_name: str, - container: OpenpypeContainer = None + container: OpenpypeContainer = None, ) -> Tuple[OpenpypeContainer, List[bpy.types.ID]]: """Instance blend process. @@ -986,27 +932,26 @@ def _instance_blend( libpath, container_name, container=container, override=False ) - # Avoid duplicates between instance and collection - if bpy.data.collections.get(container.name): - instance_object_name = f"{container.name}.001" - else: - instance_object_name = container.name + for outliner_datablock in container.get_root_outliner_datablocks(): + # Avoid duplicates between instance and collection + instance_object_name = ensure_unique_name( + container.name, set(bpy.data.collections) + ) - # Create empty object - instance_object = bpy.data.objects.new( - instance_object_name, object_data=None - ) - bpy.context.scene.collection.objects.link(instance_object) + # Create empty object + instance_object = bpy.data.objects.new( + instance_object_name, object_data=None + ) + bpy.context.scene.collection.objects.link(instance_object) - # Instance collection to object - instance_object.instance_collection = container.outliner_entity - instance_object.instance_type = "COLLECTION" - container.outliner_entity = instance_object + # Instance collection to object + instance_object.instance_collection = outliner_datablock + instance_object.instance_type = "COLLECTION" - # Keep instance object as only datablock - container.datablock_refs.clear() - instance_ref = container.datablock_refs.add() - instance_ref.datablock = instance_object + # Keep instance object as only datablock + container.datablock_refs.clear() + instance_ref = container.datablock_refs.add() + instance_ref.datablock = instance_object return container, all_datablocks @@ -1071,23 +1016,22 @@ def load( return container, datablocks # Ensure container metadata - if not container.get(AVALON_PROPERTY): - metadata_update( - container, - { - "schema": "openpype:container-2.0", - "id": AVALON_CONTAINER_ID, - "name": context["subset"]["name"], - "namespace": namespace or "", - "loader": self.__class__.__name__, - "representation": str(context["representation"]["_id"]), - "libpath": libpath.as_posix(), - "asset_name": context["asset"]["name"], - "parent": str(context["representation"]["parent"]), - "family": context["representation"]["context"]["family"], - "objectName": container.name, - }, - ) + metadata_update( + container, + { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": context["subset"]["name"], + "namespace": namespace or "", + "loader": self.__class__.__name__, + "representation": str(context["representation"]["_id"]), + "libpath": libpath.as_posix(), + "asset_name": context["asset"]["name"], + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"], + "objectName": container.name, + }, + ) # Apply options if options is not None: @@ -1115,7 +1059,9 @@ def update( (Container, Datablocks) """ container = self._get_scene_container(container_metadata) - assert container, f"The asset is not loaded: {container_metadata.get('objectName')}" + assert ( + container + ), f"The asset is not loaded: {container_metadata.get('objectName')}" new_libpath = Path(get_representation_path(representation)) assert ( @@ -1129,11 +1075,8 @@ def update( ) # Process container replacement - container_basename = build_op_basename( - container_metadata["asset_name"], container_metadata["name"] - ) container, datablocks = self.replace_container( - container, new_libpath, container_basename + container, new_libpath, container_metadata.get("objectName") ) # update metadata @@ -1142,8 +1085,13 @@ def update( { "libpath": new_libpath.as_posix(), "representation": str(representation["_id"]), + "objectName": container.name, }, ) + + # Clear and purge useless datablocks + orphans_purge() + return container, datablocks def replace_container( @@ -1186,6 +1134,7 @@ def replace_container( if ( same_loader and self.load_type in ("INSTANCE", "LINK") + and container.library and not library_multireferenced ): # Keep current datablocks @@ -1200,11 +1149,13 @@ def replace_container( # Substitute library to keep reference # if purged because duplicate references - if container.outliner_entity: + outliner_datablocks = container.get_root_outliner_datablocks() + if outliner_datablocks: + outliner_datablock = list(outliner_datablocks)[0] container.library = ( - container.outliner_entity.override_library.reference.library + outliner_datablock.override_library.reference.library if self.load_type == "LINK" - else container.outliner_entity.instance_collection.library + else outliner_datablock.instance_collection.library ) datablocks = [ @@ -1213,22 +1164,22 @@ def replace_container( else: # Default behaviour to wipe and reload everything # but keeping same container - if container.outliner_entity: - parent_collection = get_parent_collection( - container.outliner_entity - ) - unlink_from_collection( - container.outliner_entity, parent_collection - ) - else: - parent_collection = None + parent_collections = {} + for outliner_datablock in container.get_root_outliner_datablocks(): + if parent_collection := get_parent_collection( + outliner_datablock + ): + unlink_from_collection( + outliner_datablock, parent_collection + ) + + # Store parent collection by name + parent_collections.setdefault( + parent_collection, [] + ).append(outliner_datablock.name) # Keep current datablocks - old_datablocks = { - d_ref.datablock - for d_ref in container.datablock_refs - if d_ref.datablock - } + old_datablocks = container.get_datablocks(only_local=False) # Clear container datablocks container.datablock_refs.clear() @@ -1237,6 +1188,7 @@ def replace_container( for old_datablock in old_datablocks: old_datablock["original_name"] = old_datablock.name old_datablock.name += ".old" + old_datablock.use_fake_user = False # Load new into same container container, datablocks = load_func( @@ -1245,8 +1197,12 @@ def replace_container( container=container, ) - # Old datablocks remap and deletion + # Old datablocks remap for old_datablock in old_datablocks: + # Skip linked datablocks + if old_datablock.library: + continue + # Find matching new datablock by name without .### new_datablock = next( ( @@ -1315,19 +1271,21 @@ def replace_container( ) # Restore parent collection if existing - if parent_collection: - unlink_from_collection( - container.outliner_entity, bpy.context.scene.collection - ) + for ( + parent_collection, + datablock_names, + ) in parent_collections.items(): + datablocks_to_change_parent = { + d + for d in datablocks + if d and not d.library and d.name in datablock_names + } link_to_collection( - container.outliner_entity, parent_collection + datablocks_to_change_parent, parent_collection ) - # Clear and purge useless datablocks. - orphans_purge() - # Update override library operations from asset objects if available. - for obj in get_container_objects(container): + for obj in container.get_datablocks(bpy.types.Object): if getattr(obj.override_library, "operations_update", None): obj.override_library.operations_update() @@ -1369,7 +1327,14 @@ def switch( # Replace container container, datablocks = self.replace_container( - container, new_libpath, container_basename + container, + new_libpath, + container.name + if container.name.startswith(container_basename) + else ensure_unique_name( + container_basename, + bpy.context.scene.openpype_containers.keys(), + ), ) # update metadata @@ -1390,6 +1355,9 @@ def switch( }, ) + # Clear and purge useless datablocks + orphans_purge() + return container, datablocks @exec_process @@ -1409,12 +1377,14 @@ def remove(self, container: Dict) -> bool: remove_container(scene_container) + # Clear and purge useless datablocks + orphans_purge() + return True class StructDescriptor: - """Generic Descriptor to store and restor properties from blender struct. - """ + """Generic Descriptor to store and restor properties from blender struct.""" _invalid_property_names = [ "__doc__", diff --git a/openpype/hosts/blender/api/properties.py b/openpype/hosts/blender/api/properties.py index c4ff1949eb1..2892c85d4d2 100644 --- a/openpype/hosts/blender/api/properties.py +++ b/openpype/hosts/blender/api/properties.py @@ -1,9 +1,15 @@ """Blender properties.""" +from typing import Iterable, Set, Union import bpy from bpy.types import PropertyGroup from bpy.utils import register_classes_factory +from openpype.hosts.blender.api.utils import ( + BL_OUTLINER_TYPES, + get_root_datablocks, +) + def get_datablock_name(self) -> str: """Get name, ensure to be identical to the referenced datablock's name. @@ -49,7 +55,69 @@ class OpenpypeDatablockRef(PropertyGroup): ) -class OpenpypeInstance(PropertyGroup): +class OpenpypeGroup(PropertyGroup): + datablock_refs: bpy.props.CollectionProperty( + name="OpenPype Datablocks references", + type=OpenpypeDatablockRef, + ) + + def get_datablocks( + self, + types: Union[bpy.types.ID, Iterable[bpy.types.ID]] = None, + only_local=True, + ) -> Set[bpy.types.ID]: + """Get all the datablocks referenced by this container. + + Types can be filtered. + + Args: + types (Iterable): List of types to filter the datablocks + + Returns: + set: Set of datablocks + """ + # Put into iterable if not + if types is not None and not isinstance(types, Iterable): + types = (types,) + + return { + d_ref.datablock + for d_ref in self.datablock_refs + if d_ref + and d_ref.datablock + and (not only_local or not d_ref.datablock.library) + and (types is None or isinstance(d_ref.datablock, tuple(types))) + } + + def get_root_datablocks( + self, types: Union[bpy.types.ID, Iterable[bpy.types.ID]] = None + ) -> Set[bpy.types.ID]: + """Get the root datablocks of the container. + + A root datablock is the first datablock of the hierarchy that is not + referenced by another datablock in the container. + + Args: + types (Iterable): List of types to filter the datablocks + + Returns: + bpy.types.ID: Root datablock + """ + return get_root_datablocks(self.get_datablocks(types)) + + def get_root_outliner_datablocks(self) -> Set[bpy.types.ID]: + """Get the root outliner datablocks of the container. + + A root datablock is the first datablock of the hierarchy that is not + referenced by another datablock in the container. + + Returns: + bpy.types.ID: Root datablock + """ + return self.get_root_datablocks(BL_OUTLINER_TYPES) + + +class OpenpypeInstance(OpenpypeGroup): """An instance references datablocks to be published. The list is exhaustive unless it relates to outliner datablocks, @@ -59,10 +127,6 @@ class OpenpypeInstance(PropertyGroup): """ name: bpy.props.StringProperty(name="OpenPype Instance name") - datablock_refs: bpy.props.CollectionProperty( - name="OpenPype Instance Datablocks references", - type=OpenpypeDatablockRef, - ) datablock_active_index: bpy.props.IntProperty( name="Datablock Active Index" ) @@ -76,31 +140,7 @@ class OpenpypeInstance(PropertyGroup): # "icons" (List): List of the icons names for the authorized types -def get_container_name(self) -> str: - """Get name, apply it to the referenced outliner entity's name. - - Returns: - str: Name - """ - if self.outliner_entity and self.outliner_entity.name != self.get("name"): - self["name"] = self.outliner_entity.name - - return self["name"] - - -def set_container_name(self, value: str): - """Set name, ensure the referenced outliner entity to have the same. - - Args: - value (str): Name - """ - if self.outliner_entity and self.outliner_entity.name != self["name"]: - self.outliner_entity.name = value - - self["name"] = value - - -class OpenpypeContainer(PropertyGroup): +class OpenpypeContainer(OpenpypeGroup): """A container references all the loaded datablocks. In case the container references an outliner entity (collection or object) @@ -109,8 +149,6 @@ class OpenpypeContainer(PropertyGroup): name: bpy.props.StringProperty( name="OpenPype Container name", - get=get_container_name, - set=set_container_name, ) datablock_refs: bpy.props.CollectionProperty( name="OpenPype Container Datablocks references", @@ -119,9 +157,6 @@ class OpenpypeContainer(PropertyGroup): library: bpy.props.PointerProperty( name="OpenPype Container source library", type=bpy.types.Library ) - outliner_entity: bpy.props.PointerProperty( - name="Outliner entity reference", type=bpy.types.ID - ) classes = [ @@ -144,8 +179,6 @@ def register(): bpy.types.Scene.openpype_instance_active_index = bpy.props.IntProperty( name="OpenPype Instance Active Index", options={"HIDDEN"} ) - bpy.types.Collection.is_openpype_instance = bpy.props.BoolProperty() - bpy.types.Scene.openpype_containers = bpy.props.CollectionProperty( name="OpenPype Containers", type=OpenpypeContainer, options={"HIDDEN"} ) @@ -157,6 +190,5 @@ def unregister(): del bpy.types.Scene.openpype_instances del bpy.types.Scene.openpype_instance_active_index - del bpy.types.Collection.is_openpype_instance del bpy.types.Scene.openpype_containers diff --git a/openpype/hosts/blender/api/utils.py b/openpype/hosts/blender/api/utils.py index 44ccdf83310..6fbfefd2732 100644 --- a/openpype/hosts/blender/api/utils.py +++ b/openpype/hosts/blender/api/utils.py @@ -10,7 +10,7 @@ LoaderPlugin, discover_loader_plugins, ) -from openpype.pipeline.load.utils import loaders_from_repre_context +from openpype.pipeline.load.utils import loaders_from_representation # Key for metadata dict @@ -38,6 +38,37 @@ BL_OUTLINER_TYPES = frozenset((bpy.types.Collection, bpy.types.Object)) +def ensure_unique_name(name: str, list_of_existing_names: Set[str]) -> str: + """Return a unique name based on the name passed in. + + Args: + name (str): Name to make unique. + list_of_existing_names (Set[str]): List of existing names. + + Returns: + str: Unique name. + """ + # Cast to set + if not isinstance(list_of_existing_names, set): + list_of_existing_names = set(list_of_existing_names) + + # Guess basename and extension + split_name = name.rsplit(".", 1) + if len(split_name) > 1 and split_name[1].isdigit(): + basename, number = split_name + extension_i = int(number) + 1 + else: + basename = split_name[0] + extension_i = 1 + + # Increment extension based on existing ones + while name in list_of_existing_names: + name = f"{basename}.{extension_i:0>3}" + extension_i += 1 + + return name + + def build_op_basename( asset: str, subset: str, namespace: Optional[str] = None ) -> str: @@ -109,17 +140,17 @@ def get_parent_collection( Optional[bpy.types.Collection]: Parent of entity """ scene_collection = bpy.context.scene.collection - if entity.name in scene_collection.children: + if entity in scene_collection.children.values(): return scene_collection # Entity is a Collection. elif isinstance(entity, bpy.types.Collection): for col in scene_collection.children_recursive: - if entity.name in col.children: + if entity in col.children.values(): return col # Entity is an Object. elif isinstance(entity, bpy.types.Object): for col in scene_collection.children_recursive: - if entity.name in col.objects: + if entity in col.objects.values(): return col @@ -244,12 +275,9 @@ def assign_loader_to_datablocks(datablocks: List[bpy.types.ID]): datablocks_to_skip.update(datablock.all_objects) # Get available loaders - context = { - "subset": {"schema": AVALON_CONTAINER_ID}, - "version": {"data": {"families": [avalon_data["family"]]}}, - "representation": {"name": "blend"}, - } - loaders = loaders_from_repre_context(all_loaders, context) + loaders = loaders_from_representation( + all_loaders, avalon_data.get("representation") + ) if datablock.library or datablock.override_library: # Instance loader, an instance in OP is necessarily a link if datablock in all_instanced_collections: @@ -259,7 +287,7 @@ def assign_loader_to_datablocks(datablocks: List[bpy.types.ID]): loader_name = get_loader_name(loaders, "Link") else: # Append loader loader_name = get_loader_name(loaders, "Append") - datablock[AVALON_PROPERTY]["loader"] = loader_name + datablock[AVALON_PROPERTY]["loader"] = loader_name or "" # Set to related container container = bpy.context.scene.openpype_containers.get(datablock.name) @@ -344,3 +372,30 @@ def make_paths_absolute(source_filepath: Path = None): print(e) bpy.ops.file.make_paths_absolute() + + +def get_root_datablocks( + datablocks: List[bpy.types.ID], + types: Union[bpy.types.ID, Iterable[bpy.types.ID]] = None, +) -> Set[bpy.types.ID]: + """Get the root datablocks from a sequence of datablocks. + + A root datablock is the first datablock of the hierarchy that is not + referenced by another datablock in the given list. + + Args: + types (Iterable): List of types to filter the datablocks + + Returns: + bpy.types.ID: Root datablock + """ + # Put into iterable if not + if types is not None and not isinstance(types, Iterable): + types = (types,) + + return { + d + for d, users in bpy.data.user_map(subset=datablocks).items() + if (types is None or isinstance(d, tuple(types))) + and not users & set(datablocks) + } diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index ff108d54f1e..b8397491668 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -94,7 +94,7 @@ def load(self, *args, **kwargs): task = legacy_io.Session.get("AVALON_TASK") for armature in [ obj - for obj in container.outliner_entity.all_objects + for obj in container.get_datablocks({bpy.types.Object}) if obj.type == "ARMATURE" ]: if armature.animation_data is None: