diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 4d82fb3f76..f3cf2b88cd 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -44,7 +44,7 @@ def get_unique_number( def prepare_data(data, container_name): name = data.name local_data = data.make_local() - local_data.name = f"{name}:{container_name}" + local_data.name = f"{container_name}:{name}" return local_data diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 4248cffd69..7261e002f1 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -4,9 +4,14 @@ from pathlib import Path from pprint import pformat from typing import Dict, List, Optional -from avalon import api, blender import bpy -import openpype.hosts.blender.api.plugin as plugin + +from avalon import api +from avalon.blender import lib +from avalon.blender.pipeline import AVALON_CONTAINERS +from avalon.blender.pipeline import AVALON_CONTAINER_ID +from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api import plugin class CacheModelLoader(plugin.AssetLoader): @@ -21,59 +26,94 @@ class CacheModelLoader(plugin.AssetLoader): families = ["model", "pointcache"] representations = ["abc"] - label = "Link Alembic" + label = "Load Alembic" icon = "code-fork" color = "orange" - def _remove(self, objects, container): - for obj in list(objects): + def _remove(self, asset_group): + objects = list(asset_group.children) + empties = [] + + for obj in objects: if obj.type == 'MESH': + for material_slot in list(obj.material_slots): + bpy.data.materials.remove(material_slot.material) bpy.data.meshes.remove(obj.data) elif obj.type == 'EMPTY': - bpy.data.objects.remove(obj) + objects.extend(obj.children) + empties.append(obj) - bpy.data.collections.remove(container) + for empty in empties: + bpy.data.objects.remove(empty) - def _process(self, libpath, container_name, parent_collection): + def _process(self, libpath, asset_group, group_name): bpy.ops.object.select_all(action='DESELECT') - view_layer = bpy.context.view_layer - view_layer_collection = view_layer.active_layer_collection.collection + collection = bpy.context.view_layer.active_layer_collection.collection relative = bpy.context.preferences.filepaths.use_relative_paths + context = plugin.create_blender_context() bpy.ops.wm.alembic_import( + context, filepath=libpath, relative_path=relative ) - parent = parent_collection + parent = bpy.context.scene.collection - if parent is None: - parent = bpy.context.scene.collection + imported = lib.get_selection() - model_container = bpy.data.collections.new(container_name) - parent.children.link(model_container) - for obj in bpy.context.selected_objects: - model_container.objects.link(obj) - view_layer_collection.objects.unlink(obj) + empties = [obj for obj in imported if obj.type == 'EMPTY'] + container = None + + for empty in empties: + if not empty.parent: + container = empty + break + + assert container, "No asset group found" + + # Children must be linked before parents, + # otherwise the hierarchy will break + objects = [] + nodes = list(container.children) + + for obj in nodes: + obj.parent = asset_group + + bpy.data.objects.remove(container) + + for obj in nodes: + objects.append(obj) + nodes.extend(list(obj.children)) + + objects.reverse() + + for obj in objects: + parent.objects.link(obj) + collection.objects.unlink(obj) + + for obj in objects: name = obj.name - obj.name = f"{name}:{container_name}" + obj.name = f"{group_name}:{name}" + if obj.type != 'EMPTY': + name_data = obj.data.name + obj.data.name = f"{group_name}:{name_data}" - # Groups are imported as Empty objects in Blender - if obj.type == 'MESH': - data_name = obj.data.name - obj.data.name = f"{data_name}:{container_name}" + for material_slot in obj.material_slots: + name_mat = material_slot.material.name + material_slot.material.name = f"{group_name}:{name_mat}" - if not obj.get(blender.pipeline.AVALON_PROPERTY): - obj[blender.pipeline.AVALON_PROPERTY] = dict() + if not obj.get(AVALON_PROPERTY): + obj[AVALON_PROPERTY] = dict() - avalon_info = obj[blender.pipeline.AVALON_PROPERTY] - avalon_info.update({"container_name": container_name}) + avalon_info = obj[AVALON_PROPERTY] + avalon_info.update({"container_name": group_name}) bpy.ops.object.select_all(action='DESELECT') - return model_container + return objects def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, @@ -91,43 +131,37 @@ class CacheModelLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - lib_container = plugin.asset_name( - asset, subset - ) - unique_number = plugin.get_unique_number( - asset, subset - ) + asset_name = plugin.asset_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.asset_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" - container_name = plugin.asset_name( - asset, subset, unique_number - ) - container = bpy.data.collections.new(lib_container) - container.name = container_name - blender.pipeline.containerise_existing( - container, - name, - namespace, - context, - self.__class__.__name__, - ) + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) + asset_group = bpy.data.objects.new(group_name, object_data=None) + avalon_container.objects.link(asset_group) - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container + objects = self._process(libpath, asset_group, group_name) - obj_container = self._process( - libpath, container_name, None) + bpy.context.scene.collection.objects.link(asset_group) - container_metadata["obj_container"] = obj_container + asset_group[AVALON_PROPERTY] = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"] + } - # Save the list of objects in the metadata container - container_metadata["objects"] = obj_container.all_objects - - nodes = list(container.objects) - nodes.append(container) + nodes = objects self[:] = nodes return nodes @@ -143,9 +177,8 @@ class CacheModelLoader(plugin.AssetLoader): Warning: No nested collections are supported at the moment! """ - collection = bpy.data.collections.get( - container["objectName"] - ) + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) libpath = Path(api.get_representation_path(representation)) extension = libpath.suffix.lower() @@ -155,12 +188,9 @@ class CacheModelLoader(plugin.AssetLoader): pformat(representation, indent=2), ) - assert collection, ( + assert asset_group, ( f"The asset is not loaded: {container['objectName']}" ) - assert not (collection.children), ( - "Nested collections are not supported." - ) assert libpath, ( "No existing library file found for {container['objectName']}" ) @@ -171,43 +201,32 @@ class CacheModelLoader(plugin.AssetLoader): f"Unsupported file: {libpath}" ) - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) - collection_libpath = collection_metadata["libpath"] + metadata = asset_group.get(AVALON_PROPERTY) + group_libpath = metadata["libpath"] - obj_container = plugin.get_local_collection_with_name( - collection_metadata["obj_container"].name - ) - objects = obj_container.all_objects - - container_name = obj_container.name - - normalized_collection_libpath = ( - str(Path(bpy.path.abspath(collection_libpath)).resolve()) + normalized_group_libpath = ( + str(Path(bpy.path.abspath(group_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) self.log.debug( - "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", - normalized_collection_libpath, + "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_group_libpath, normalized_libpath, ) - if normalized_collection_libpath == normalized_libpath: + if normalized_group_libpath == normalized_libpath: self.log.info("Library already loaded, not updating...") return - parent = plugin.get_parent_collection(obj_container) + mat = asset_group.matrix_basis.copy() + self._remove(asset_group) - self._remove(objects, obj_container) + self._process(str(libpath), asset_group, object_name) + asset_group.matrix_basis = mat - obj_container = self._process( - str(libpath), container_name, parent) - - collection_metadata["obj_container"] = obj_container - collection_metadata["objects"] = obj_container.all_objects - collection_metadata["libpath"] = str(libpath) - collection_metadata["representation"] = str(representation["_id"]) + metadata["libpath"] = str(libpath) + metadata["representation"] = str(representation["_id"]) def remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. @@ -222,25 +241,14 @@ class CacheModelLoader(plugin.AssetLoader): Warning: No nested collections are supported at the moment! """ - collection = bpy.data.collections.get( - container["objectName"] - ) - if not collection: + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + + if not asset_group: return False - assert not (collection.children), ( - "Nested collections are not supported." - ) - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) + self._remove(asset_group) - obj_container = plugin.get_local_collection_with_name( - collection_metadata["obj_container"].name - ) - objects = obj_container.all_objects - - self._remove(objects, obj_container) - - bpy.data.collections.remove(collection) + bpy.data.objects.remove(asset_group) return True diff --git a/openpype/hosts/blender/plugins/load/load_fbx_model.py b/openpype/hosts/blender/plugins/load/load_fbx_model.py new file mode 100644 index 0000000000..776217e5f1 --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_fbx_model.py @@ -0,0 +1,250 @@ +"""Load an asset in Blender from an Alembic file.""" + +from pathlib import Path +from pprint import pformat +from typing import Dict, List, Optional + +import bpy + +from avalon import api +from avalon.blender import lib +from avalon.blender.pipeline import AVALON_CONTAINERS +from avalon.blender.pipeline import AVALON_CONTAINER_ID +from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api import plugin + + +class FbxModelLoader(plugin.AssetLoader): + """Load FBX models. + + Stores the imported asset in an empty named after the asset. + """ + + families = ["model"] + representations = ["fbx"] + + label = "Load FBX" + icon = "code-fork" + color = "orange" + + def _remove(self, asset_group): + objects = list(asset_group.children) + empties = [] + + for obj in objects: + if obj.type == 'MESH': + for material_slot in list(obj.material_slots): + if material_slot.material: + bpy.data.materials.remove(material_slot.material) + bpy.data.meshes.remove(obj.data) + elif obj.type == 'EMPTY': + objects.extend(obj.children) + empties.append(obj) + + for empty in empties: + bpy.data.objects.remove(empty) + + def _process(self, libpath, asset_group, group_name): + bpy.ops.object.select_all(action='DESELECT') + + collection = bpy.context.view_layer.active_layer_collection.collection + + context = plugin.create_blender_context() + bpy.ops.import_scene.fbx( + context, + filepath=libpath + ) + + parent = bpy.context.scene.collection + + imported = lib.get_selection() + + empties = [obj for obj in imported if obj.type == 'EMPTY'] + + container = None + + for empty in empties: + if not empty.parent: + container = empty + break + + assert container, "No asset group found" + + # Children must be linked before parents, + # otherwise the hierarchy will break + objects = [] + nodes = list(container.children) + + for obj in nodes: + obj.parent = asset_group + + bpy.data.objects.remove(container) + + for obj in nodes: + objects.append(obj) + nodes.extend(list(obj.children)) + + objects.reverse() + + for obj in objects: + parent.objects.link(obj) + collection.objects.unlink(obj) + + for obj in objects: + name = obj.name + obj.name = f"{group_name}:{name}" + if obj.type != 'EMPTY': + name_data = obj.data.name + obj.data.name = f"{group_name}:{name_data}" + + for material_slot in obj.material_slots: + name_mat = material_slot.material.name + material_slot.material.name = f"{group_name}:{name_mat}" + + if not obj.get(AVALON_PROPERTY): + obj[AVALON_PROPERTY] = dict() + + avalon_info = obj[AVALON_PROPERTY] + avalon_info.update({"container_name": group_name}) + + bpy.ops.object.select_all(action='DESELECT') + + return objects + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + + libpath = self.fname + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + asset_name = plugin.asset_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.asset_name(asset, subset, unique_number) + namespace = namespace or f"{asset}_{unique_number}" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) + + asset_group = bpy.data.objects.new(group_name, object_data=None) + avalon_container.objects.link(asset_group) + + objects = self._process(libpath, asset_group, group_name) + + bpy.context.scene.collection.objects.link(asset_group) + + asset_group[AVALON_PROPERTY] = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"] + } + + nodes = objects + self[:] = nodes + return nodes + + def update(self, container: Dict, representation: Dict): + """Update the loaded asset. + + This will remove all objects of the current collection, load the new + ones and add them to the collection. + If the objects of the collection are used in another collection they + will not be removed, only unlinked. Normally this should not be the + case though. + + Warning: + No nested collections are supported at the moment! + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + libpath = Path(api.get_representation_path(representation)) + extension = libpath.suffix.lower() + + self.log.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert asset_group, ( + f"The asset is not loaded: {container['objectName']}" + ) + assert libpath, ( + "No existing library file found for {container['objectName']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + assert extension in plugin.VALID_EXTENSIONS, ( + f"Unsupported file: {libpath}" + ) + + metadata = asset_group.get(AVALON_PROPERTY) + group_libpath = metadata["libpath"] + + normalized_group_libpath = ( + str(Path(bpy.path.abspath(group_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + self.log.debug( + "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_group_libpath, + normalized_libpath, + ) + if normalized_group_libpath == normalized_libpath: + self.log.info("Library already loaded, not updating...") + return + + mat = asset_group.matrix_basis.copy() + self._remove(asset_group) + + self._process(str(libpath), asset_group, object_name) + asset_group.matrix_basis = mat + + metadata["libpath"] = str(libpath) + metadata["representation"] = str(representation["_id"]) + + def remove(self, container: Dict) -> bool: + """Remove an existing container from a Blender scene. + + Arguments: + container (openpype:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted. + + Warning: + No nested collections are supported at the moment! + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + + if not asset_group: + return False + + self._remove(asset_group) + + bpy.data.objects.remove(asset_group) + + return True diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index 7e757f5433..dd1b76034b 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -200,9 +200,11 @@ class BlendModelLoader(plugin.AssetLoader): self.log.info("Library already loaded, not updating...") return + mat = asset_group.matrix_basis.copy() self._remove(asset_group) self._process(str(libpath), asset_group, object_name) + asset_group.matrix_basis = mat metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index 97123c5967..4696da3db4 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -47,7 +47,7 @@ class ExtractABC(api.Extractor): context, filepath=filepath, selected=True, - flatten=True + flatten=False ) bpy.ops.object.select_all(action='DESELECT')