diff --git a/pype/plugins/blender/create/create_animation.py b/pype/plugins/blender/create/create_animation.py new file mode 100644 index 0000000000..cfe569f918 --- /dev/null +++ b/pype/plugins/blender/create/create_animation.py @@ -0,0 +1,30 @@ +import bpy + +from avalon import api +from avalon.blender import Creator, lib + + +class CreateAnimation(Creator): + """Animation output for character rigs""" + + name = "animationMain" + label = "Animation" + family = "animation" + icon = "male" + + def process(self): + import pype.blender + + asset = self.data["asset"] + subset = self.data["subset"] + name = pype.blender.plugin.asset_name(asset, subset) + collection = bpy.data.collections.new(name=name) + bpy.context.scene.collection.children.link(collection) + self.data['task'] = api.Session.get('AVALON_TASK') + lib.imprint(collection, self.data) + + if (self.options or {}).get("useSelection"): + for obj in lib.get_selection(): + collection.objects.link(obj) + + return collection diff --git a/pype/plugins/blender/load/load_animation.py b/pype/plugins/blender/load/load_animation.py new file mode 100644 index 0000000000..5b527e1717 --- /dev/null +++ b/pype/plugins/blender/load/load_animation.py @@ -0,0 +1,274 @@ +"""Load an animation in Blender.""" + +import logging +from pathlib import Path +from pprint import pformat +from typing import Dict, List, Optional + +import avalon.blender.pipeline +import bpy +import pype.blender +from avalon import api + +logger = logging.getLogger("pype").getChild("blender").getChild("load_model") + + +class BlendAnimationLoader(pype.blender.AssetLoader): + """Load animations from a .blend file. + + Because they come from a .blend file we can simply link the collection that + contains the model. There is no further need to 'containerise' it. + + Warning: + Loading the same asset more then once is not properly supported at the + moment. + """ + + families = ["animation"] + representations = ["blend"] + + label = "Link Animation" + icon = "code-fork" + color = "orange" + + 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"] + lib_container = pype.blender.plugin.asset_name(asset, subset) + container_name = pype.blender.plugin.asset_name( + asset, subset, namespace + ) + relative = bpy.context.preferences.filepaths.use_relative_paths + + container = bpy.data.collections.new(lib_container) + container.name = container_name + avalon.blender.pipeline.containerise_existing( + container, + name, + namespace, + context, + self.__class__.__name__, + ) + + container_metadata = container.get( + avalon.blender.pipeline.AVALON_PROPERTY) + + container_metadata["libpath"] = libpath + container_metadata["lib_container"] = lib_container + + with bpy.data.libraries.load( + libpath, link=True, relative=relative + ) as (_, data_to): + data_to.collections = [lib_container] + + scene = bpy.context.scene + + scene.collection.children.link(bpy.data.collections[lib_container]) + + animation_container = scene.collection.children[lib_container].make_local() + + meshes = [obj for obj in animation_container.objects if obj.type == 'MESH'] + armatures = [ + obj for obj in animation_container.objects if obj.type == 'ARMATURE'] + + objects_list = [] + + # Link meshes first, then armatures. + # The armature is unparented for all the non-local meshes, + # when it is made local. + for obj in meshes + armatures: + + obj = obj.make_local() + + obj.data.make_local() + + if not obj.get(avalon.blender.pipeline.AVALON_PROPERTY): + + obj[avalon.blender.pipeline.AVALON_PROPERTY] = dict() + + avalon_info = obj[avalon.blender.pipeline.AVALON_PROPERTY] + avalon_info.update({"container_name": container_name}) + + objects_list.append(obj) + + # Save the list of objects in the metadata container + container_metadata["objects"] = objects_list + + bpy.ops.object.select_all(action='DESELECT') + + nodes = list(container.objects) + nodes.append(container) + 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! + """ + + collection = bpy.data.collections.get( + container["objectName"] + ) + + libpath = Path(api.get_representation_path(representation)) + extension = libpath.suffix.lower() + + logger.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert collection, ( + 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']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + assert extension in pype.blender.plugin.VALID_EXTENSIONS, ( + f"Unsupported file: {libpath}" + ) + + collection_metadata = collection.get( + avalon.blender.pipeline.AVALON_PROPERTY) + + collection_libpath = collection_metadata["libpath"] + normalized_collection_libpath = ( + str(Path(bpy.path.abspath(collection_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + logger.debug( + "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_collection_libpath, + normalized_libpath, + ) + if normalized_collection_libpath == normalized_libpath: + logger.info("Library already loaded, not updating...") + return + + # Get the armature of the rig + armatures = [obj for obj in collection_metadata["objects"] + if obj.type == 'ARMATURE'] + assert(len(armatures) == 1) + + for obj in collection_metadata["objects"]: + + if obj.type == 'ARMATURE': + bpy.data.armatures.remove(obj.data) + elif obj.type == 'MESH': + bpy.data.meshes.remove(obj.data) + + lib_container = collection_metadata["lib_container"] + + bpy.data.collections.remove(bpy.data.collections[lib_container]) + + relative = bpy.context.preferences.filepaths.use_relative_paths + with bpy.data.libraries.load( + str(libpath), link=True, relative=relative + ) as (_, data_to): + data_to.collections = [lib_container] + + scene = bpy.context.scene + + scene.collection.children.link(bpy.data.collections[lib_container]) + + animation_container = scene.collection.children[lib_container].make_local() + + meshes = [obj for obj in animation_container.objects if obj.type == 'MESH'] + armatures = [ + obj for obj in animation_container.objects if obj.type == 'ARMATURE'] + objects_list = [] + + assert(len(armatures) == 1) + + # Link meshes first, then armatures. + # The armature is unparented for all the non-local meshes, + # when it is made local. + for obj in meshes + armatures: + + obj = obj.make_local() + + obj.data.make_local() + + if not obj.get(avalon.blender.pipeline.AVALON_PROPERTY): + + obj[avalon.blender.pipeline.AVALON_PROPERTY] = dict() + + avalon_info = obj[avalon.blender.pipeline.AVALON_PROPERTY] + avalon_info.update({"container_name": collection.name}) + objects_list.append(obj) + + # Save the list of objects in the metadata container + collection_metadata["objects"] = objects_list + collection_metadata["libpath"] = str(libpath) + collection_metadata["representation"] = str(representation["_id"]) + + bpy.ops.object.select_all(action='DESELECT') + + def remove(self, container: Dict) -> bool: + """Remove an existing container from a Blender scene. + + Arguments: + container (avalon-core: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! + """ + + collection = bpy.data.collections.get( + container["objectName"] + ) + if not collection: + return False + assert not (collection.children), ( + "Nested collections are not supported." + ) + + collection_metadata = collection.get( + avalon.blender.pipeline.AVALON_PROPERTY) + objects = collection_metadata["objects"] + lib_container = collection_metadata["lib_container"] + + for obj in objects: + + if obj.type == 'ARMATURE': + bpy.data.armatures.remove(obj.data) + elif obj.type == 'MESH': + bpy.data.meshes.remove(obj.data) + + bpy.data.collections.remove(bpy.data.collections[lib_container]) + bpy.data.collections.remove(collection) + + return True diff --git a/pype/plugins/blender/publish/collect_animation.py b/pype/plugins/blender/publish/collect_animation.py new file mode 100644 index 0000000000..9bc0b02227 --- /dev/null +++ b/pype/plugins/blender/publish/collect_animation.py @@ -0,0 +1,53 @@ +import typing +from typing import Generator + +import bpy + +import avalon.api +import pyblish.api +from avalon.blender.pipeline import AVALON_PROPERTY + + +class CollectAnimation(pyblish.api.ContextPlugin): + """Collect the data of an animation.""" + + hosts = ["blender"] + label = "Collect Animation" + order = pyblish.api.CollectorOrder + + @staticmethod + def get_animation_collections() -> Generator: + """Return all 'animation' collections. + + Check if the family is 'animation' and if it doesn't have the + representation set. If the representation is set, it is a loaded rig + and we don't want to publish it. + """ + for collection in bpy.data.collections: + avalon_prop = collection.get(AVALON_PROPERTY) or dict() + if (avalon_prop.get('family') == 'animation' + and not avalon_prop.get('representation')): + yield collection + + def process(self, context): + """Collect the animations from the current Blender scene.""" + collections = self.get_animation_collections() + for collection in collections: + avalon_prop = collection[AVALON_PROPERTY] + asset = avalon_prop['asset'] + family = avalon_prop['family'] + subset = avalon_prop['subset'] + task = avalon_prop['task'] + name = f"{asset}_{subset}" + instance = context.create_instance( + name=name, + family=family, + families=[family], + subset=subset, + asset=asset, + task=task, + ) + members = list(collection.objects) + members.append(collection) + instance[:] = members + self.log.debug(instance.data) diff --git a/pype/plugins/blender/publish/extract_animation.py b/pype/plugins/blender/publish/extract_animation.py new file mode 100644 index 0000000000..dbfe29af83 --- /dev/null +++ b/pype/plugins/blender/publish/extract_animation.py @@ -0,0 +1,47 @@ +import os +import avalon.blender.workio + +import pype.api + + +class ExtractAnimation(pype.api.Extractor): + """Extract as animation.""" + + label = "Animation" + hosts = ["blender"] + families = ["animation"] + optional = True + + def process(self, instance): + # Define extract output file path + + stagingdir = self.staging_dir(instance) + filename = f"{instance.name}.blend" + filepath = os.path.join(stagingdir, filename) + + # Perform extraction + self.log.info("Performing extraction..") + + # Just save the file to a temporary location. At least for now it's no + # problem to have (possibly) extra stuff in the file. + avalon.blender.workio.save_file(filepath, copy=True) + # + # # Store reference for integration + # if "files" not in instance.data: + # instance.data["files"] = list() + # + # # instance.data["files"].append(filename) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'blend', + 'ext': 'blend', + 'files': filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + + + self.log.info("Extracted instance '%s' to: %s", instance.name, representation)