diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 602b3b0ff9..1f0b142ef6 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -49,10 +49,13 @@ def get_unique_number( return f"{count:0>2}" -def prepare_data(data, container_name): +def prepare_data(data, container_name=None): name = data.name local_data = data.make_local() - local_data.name = f"{container_name}:{name}" + if container_name: + local_data.name = f"{container_name}:{name}" + else: + local_data.name = f"{name}" return local_data diff --git a/openpype/hosts/blender/plugins/load/load_layout_blend.py b/openpype/hosts/blender/plugins/load/load_layout_blend.py index dff7ffb9c6..8029c38b4a 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_blend.py +++ b/openpype/hosts/blender/plugins/load/load_layout_blend.py @@ -7,6 +7,7 @@ from typing import Dict, List, Optional import bpy from avalon import api +from openpype import lib from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, @@ -61,7 +62,9 @@ class BlendLayoutLoader(plugin.AssetLoader): library = bpy.data.libraries.get(bpy.path.basename(libpath)) bpy.data.libraries.remove(library) - def _process(self, libpath, asset_group, group_name, actions): + def _process( + self, libpath, asset_group, group_name, asset, representation, actions + ): with bpy.data.libraries.load( libpath, link=True, relative=False ) as (data_from, data_to): @@ -74,7 +77,8 @@ class BlendLayoutLoader(plugin.AssetLoader): container = None for empty in empties: - if empty.get(AVALON_PROPERTY): + if (empty.get(AVALON_PROPERTY) and + empty.get(AVALON_PROPERTY).get('family') == 'layout'): container = empty break @@ -85,12 +89,16 @@ class BlendLayoutLoader(plugin.AssetLoader): objects = [] nodes = list(container.children) - for obj in nodes: - obj.parent = asset_group + allowed_types = ['ARMATURE', 'MESH', 'EMPTY'] for obj in nodes: - objects.append(obj) - nodes.extend(list(obj.children)) + if obj.type in allowed_types: + obj.parent = asset_group + + for obj in nodes: + if obj.type in allowed_types: + objects.append(obj) + nodes.extend(list(obj.children)) objects.reverse() @@ -108,7 +116,7 @@ class BlendLayoutLoader(plugin.AssetLoader): parent.objects.link(obj) for obj in objects: - local_obj = plugin.prepare_data(obj, group_name) + local_obj = plugin.prepare_data(obj) action = None @@ -116,7 +124,7 @@ class BlendLayoutLoader(plugin.AssetLoader): action = actions.get(local_obj.name, None) if local_obj.type == 'MESH': - plugin.prepare_data(local_obj.data, group_name) + plugin.prepare_data(local_obj.data) if obj != local_obj: for constraint in constraints: @@ -125,15 +133,18 @@ class BlendLayoutLoader(plugin.AssetLoader): for material_slot in local_obj.material_slots: if material_slot.material: - plugin.prepare_data(material_slot.material, group_name) + plugin.prepare_data(material_slot.material) elif local_obj.type == 'ARMATURE': - plugin.prepare_data(local_obj.data, group_name) + plugin.prepare_data(local_obj.data) if action is not None: + if local_obj.animation_data is None: + local_obj.animation_data_create() local_obj.animation_data.action = action - elif local_obj.animation_data.action is not None: + elif (local_obj.animation_data and + local_obj.animation_data.action is not None): plugin.prepare_data( - local_obj.animation_data.action, group_name) + local_obj.animation_data.action) # Set link the drivers to the local object if local_obj.data.animation_data: @@ -142,6 +153,21 @@ class BlendLayoutLoader(plugin.AssetLoader): for t in v.targets: t.id = local_obj + elif local_obj.type == 'EMPTY': + creator_plugin = lib.get_creator_by_name("CreateAnimation") + if not creator_plugin: + raise ValueError("Creator plugin \"CreateAnimation\" was " + "not found.") + + api.create( + creator_plugin, + name=local_obj.name.split(':')[-1] + "_animation", + asset=asset, + options={"useSelection": False, + "asset_group": local_obj}, + data={"dependencies": representation} + ) + if not local_obj.get(AVALON_PROPERTY): local_obj[AVALON_PROPERTY] = dict() @@ -150,7 +176,63 @@ class BlendLayoutLoader(plugin.AssetLoader): objects.reverse() - bpy.data.orphans_purge(do_local_ids=False) + armatures = [ + obj for obj in bpy.data.objects + if obj.type == 'ARMATURE' and obj.library is None] + arm_act = {} + + # The armatures with an animation need to be at the center of the + # scene to be hooked correctly by the curves modifiers. + for armature in armatures: + if armature.animation_data and armature.animation_data.action: + arm_act[armature] = armature.animation_data.action + armature.animation_data.action = None + armature.location = (0.0, 0.0, 0.0) + for bone in armature.pose.bones: + bone.location = (0.0, 0.0, 0.0) + bone.rotation_euler = (0.0, 0.0, 0.0) + + curves = [obj for obj in data_to.objects if obj.type == 'CURVE'] + + for curve in curves: + curve_name = curve.name.split(':')[0] + curve_obj = bpy.data.objects.get(curve_name) + + local_obj = plugin.prepare_data(curve) + plugin.prepare_data(local_obj.data) + + # Curves need to reset the hook, but to do that they need to be + # in the view layer. + parent.objects.link(local_obj) + plugin.deselect_all() + local_obj.select_set(True) + bpy.context.view_layer.objects.active = local_obj + if local_obj.library is None: + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.hook_reset() + bpy.ops.object.mode_set(mode='OBJECT') + parent.objects.unlink(local_obj) + + local_obj.use_fake_user = True + + for mod in local_obj.modifiers: + mod.object = bpy.data.objects.get(f"{mod.object.name}") + + if not local_obj.get(AVALON_PROPERTY): + local_obj[AVALON_PROPERTY] = dict() + + avalon_info = local_obj[AVALON_PROPERTY] + avalon_info.update({"container_name": group_name}) + + local_obj.parent = curve_obj + objects.append(local_obj) + + for armature in armatures: + if arm_act.get(armature): + armature.animation_data.action = arm_act[armature] + + while bpy.data.orphans_purge(do_local_ids=False): + pass plugin.deselect_all() @@ -170,6 +252,7 @@ class BlendLayoutLoader(plugin.AssetLoader): libpath = self.fname asset = context["asset"]["name"] subset = context["subset"]["name"] + representation = str(context["representation"]["_id"]) asset_name = plugin.asset_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) @@ -185,7 +268,8 @@ class BlendLayoutLoader(plugin.AssetLoader): asset_group.empty_display_type = 'SINGLE_ARROW' avalon_container.objects.link(asset_group) - objects = self._process(libpath, asset_group, group_name, None) + objects = self._process( + libpath, asset_group, group_name, asset, representation, None) for child in asset_group.children: if child.get(AVALON_PROPERTY): diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 2378ae4807..0a5bdeecaa 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -94,6 +94,10 @@ class JsonLayoutLoader(plugin.AssetLoader): 'animation_asset': asset } + if element.get('animation'): + options['animation_file'] = str(Path(libpath).with_suffix( + '')) + "." + element.get('animation') + # This should return the loaded asset, but the load call will be # added to the queue to run in the Blender main thread, so # at this time it will not return anything. The assets will be @@ -106,20 +110,22 @@ class JsonLayoutLoader(plugin.AssetLoader): options=options ) - # Create the camera asset and the camera instance - creator_plugin = lib.get_creator_by_name("CreateCamera") - if not creator_plugin: - raise ValueError("Creator plugin \"CreateCamera\" was " - "not found.") + # Camera creation when loading a layout is not necessary for now, + # but the code is worth keeping in case we need it in the future. + # # Create the camera asset and the camera instance + # creator_plugin = lib.get_creator_by_name("CreateCamera") + # if not creator_plugin: + # raise ValueError("Creator plugin \"CreateCamera\" was " + # "not found.") - api.create( - creator_plugin, - name="camera", - # name=f"{unique_number}_{subset}_animation", - asset=asset, - options={"useSelection": False} - # data={"dependencies": str(context["representation"]["_id"])} - ) + # api.create( + # creator_plugin, + # name="camera", + # # name=f"{unique_number}_{subset}_animation", + # asset=asset, + # options={"useSelection": False} + # # data={"dependencies": str(context["representation"]["_id"])} + # ) def process_asset(self, context: dict, diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index 861da9b852..04ece0b338 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -83,7 +83,8 @@ class BlendModelLoader(plugin.AssetLoader): plugin.prepare_data(local_obj.data, group_name) for material_slot in local_obj.material_slots: - plugin.prepare_data(material_slot.material, group_name) + if material_slot.material: + plugin.prepare_data(material_slot.material, group_name) if not local_obj.get(AVALON_PROPERTY): local_obj[AVALON_PROPERTY] = dict() @@ -247,7 +248,8 @@ class BlendModelLoader(plugin.AssetLoader): # If it is the last object to use that library, remove it if count == 1: library = bpy.data.libraries.get(bpy.path.basename(group_libpath)) - bpy.data.libraries.remove(library) + if library: + bpy.data.libraries.remove(library) self._process(str(libpath), asset_group, object_name) @@ -255,6 +257,7 @@ class BlendModelLoader(plugin.AssetLoader): metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) + metadata["parent"] = str(representation["parent"]) def exec_remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index b753488144..eb6d273a51 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -7,6 +7,7 @@ from typing import Dict, List, Optional import bpy from avalon import api +from avalon.blender import lib as avalon_lib from openpype import lib from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( @@ -112,6 +113,8 @@ class BlendRigLoader(plugin.AssetLoader): plugin.prepare_data(local_obj.data, group_name) if action is not None: + if local_obj.animation_data is None: + local_obj.animation_data_create() local_obj.animation_data.action = action elif (local_obj.animation_data and local_obj.animation_data.action is not None): @@ -196,12 +199,14 @@ class BlendRigLoader(plugin.AssetLoader): plugin.deselect_all() create_animation = False + anim_file = None if options is not None: parent = options.get('parent') transform = options.get('transform') action = options.get('action') create_animation = options.get('create_animation') + anim_file = options.get('animation_file') if parent and transform: location = transform.get('translation') @@ -254,6 +259,26 @@ class BlendRigLoader(plugin.AssetLoader): plugin.deselect_all() + if anim_file: + bpy.ops.import_scene.fbx(filepath=anim_file, anim_offset=0.0) + + imported = avalon_lib.get_selection() + + armature = [ + o for o in asset_group.children if o.type == 'ARMATURE'][0] + + imported_group = [ + o for o in imported if o.type == 'EMPTY'][0] + + for obj in imported: + if obj.type == 'ARMATURE': + if not armature.animation_data: + armature.animation_data_create() + armature.animation_data.action = obj.animation_data.action + + self._remove(imported_group) + bpy.data.objects.remove(imported_group) + bpy.context.scene.collection.objects.link(asset_group) asset_group[AVALON_PROPERTY] = { @@ -350,6 +375,7 @@ class BlendRigLoader(plugin.AssetLoader): metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) + metadata["parent"] = str(representation["parent"]) def exec_remove(self, container: Dict) -> bool: """Remove an existing asset group from a Blender scene. diff --git a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py index 239ca53f98..4917223331 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py @@ -29,12 +29,13 @@ class ExtractBlendAnimation(openpype.api.Extractor): if isinstance(obj, bpy.types.Object) and obj.type == 'EMPTY': child = obj.children[0] if child and child.type == 'ARMATURE': - if not obj.animation_data: - obj.animation_data_create() - obj.animation_data.action = child.animation_data.action - obj.animation_data_clear() - data_blocks.add(child.animation_data.action) - data_blocks.add(obj) + if child.animation_data and child.animation_data.action: + if not obj.animation_data: + obj.animation_data_create() + obj.animation_data.action = child.animation_data.action + obj.animation_data_clear() + data_blocks.add(child.animation_data.action) + data_blocks.add(obj) bpy.data.libraries.write(filepath, data_blocks) diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py index 26344777a8..3ac66f33a4 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py @@ -50,6 +50,9 @@ class ExtractFBX(api.Extractor): new_materials.append(mat) new_materials_objs.append(obj) + scale_length = bpy.context.scene.unit_settings.scale_length + bpy.context.scene.unit_settings.scale_length = 0.01 + # We export the fbx bpy.ops.export_scene.fbx( context, @@ -60,6 +63,8 @@ class ExtractFBX(api.Extractor): add_leaf_bones=False ) + bpy.context.scene.unit_settings.scale_length = scale_length + plugin.deselect_all() for mat in new_materials: diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py index 50a414c0d6..4b4a92932a 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py @@ -37,13 +37,6 @@ class ExtractAnimationFBX(api.Extractor): armature = [ obj for obj in asset_group.children if obj.type == 'ARMATURE'][0] - asset_group_name = asset_group.name - asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name") - - armature_name = armature.name - original_name = armature_name.split(':')[1] - armature.name = original_name - object_action_pairs = [] original_actions = [] @@ -66,6 +59,13 @@ class ExtractAnimationFBX(api.Extractor): self.log.info("Object have no animation.") return + asset_group_name = asset_group.name + asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name") + + armature_name = armature.name + original_name = armature_name.split(':')[1] + armature.name = original_name + object_action_pairs.append((armature, copy_action)) original_actions.append(curr_action) @@ -123,7 +123,7 @@ class ExtractAnimationFBX(api.Extractor): json_path = os.path.join(stagingdir, json_filename) json_dict = { - "instance_name": asset_group.get(AVALON_PROPERTY).get("namespace") + "instance_name": asset_group.get(AVALON_PROPERTY).get("objectName") } # collection = instance.data.get("name") diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py index 1ecf66099c..cc7c90f4c8 100644 --- a/openpype/hosts/blender/plugins/publish/extract_layout.py +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -2,8 +2,11 @@ import os import json import bpy +import bpy_extras +import bpy_extras.anim_utils from avalon import io +from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY import openpype.api @@ -16,6 +19,99 @@ class ExtractLayout(openpype.api.Extractor): families = ["layout"] optional = True + def _export_animation(self, asset, instance, stagingdir, fbx_count): + n = fbx_count + + for obj in asset.children: + if obj.type != "ARMATURE": + continue + + object_action_pairs = [] + original_actions = [] + + starting_frames = [] + ending_frames = [] + + # For each armature, we make a copy of the current action + curr_action = None + copy_action = None + + if obj.animation_data and obj.animation_data.action: + curr_action = obj.animation_data.action + copy_action = curr_action.copy() + + curr_frame_range = curr_action.frame_range + + starting_frames.append(curr_frame_range[0]) + ending_frames.append(curr_frame_range[1]) + else: + self.log.info("Object have no animation.") + continue + + asset_group_name = asset.name + asset.name = asset.get(AVALON_PROPERTY).get("asset_name") + + armature_name = obj.name + original_name = armature_name.split(':')[1] + obj.name = original_name + + object_action_pairs.append((obj, copy_action)) + original_actions.append(curr_action) + + # We compute the starting and ending frames + max_frame = min(starting_frames) + min_frame = max(ending_frames) + + # We bake the copy of the current action for each object + bpy_extras.anim_utils.bake_action_objects( + object_action_pairs, + frames=range(int(min_frame), int(max_frame)), + do_object=False, + do_clean=False + ) + + for o in bpy.data.objects: + o.select_set(False) + + asset.select_set(True) + obj.select_set(True) + fbx_filename = f"{n:03d}.fbx" + filepath = os.path.join(stagingdir, fbx_filename) + + override = plugin.create_blender_context( + active=asset, selected=[asset, obj]) + bpy.ops.export_scene.fbx( + override, + filepath=filepath, + use_active_collection=False, + use_selection=True, + bake_anim_use_nla_strips=False, + bake_anim_use_all_actions=False, + add_leaf_bones=False, + armature_nodetype='ROOT', + object_types={'EMPTY', 'ARMATURE'} + ) + obj.name = armature_name + asset.name = asset_group_name + asset.select_set(False) + obj.select_set(False) + + # We delete the baked action and set the original one back + for i in range(0, len(object_action_pairs)): + pair = object_action_pairs[i] + action = original_actions[i] + + if action: + pair[0].animation_data.action = action + + if pair[1]: + pair[1].user_clear() + bpy.data.actions.remove(pair[1]) + + return fbx_filename, n + 1 + + return None, n + def process(self, instance): # Define extract output file path stagingdir = self.staging_dir(instance) @@ -23,10 +119,16 @@ class ExtractLayout(openpype.api.Extractor): # Perform extraction self.log.info("Performing extraction..") + if "representations" not in instance.data: + instance.data["representations"] = [] + json_data = [] + fbx_files = [] asset_group = bpy.data.objects[str(instance)] + fbx_count = 0 + for asset in asset_group.children: metadata = asset.get(AVALON_PROPERTY) @@ -34,6 +136,7 @@ class ExtractLayout(openpype.api.Extractor): family = metadata["family"] self.log.debug("Parent: {}".format(parent)) + # Get blend reference blend = io.find_one( { "type": "representation", @@ -41,10 +144,39 @@ class ExtractLayout(openpype.api.Extractor): "name": "blend" }, projection={"_id": True}) - blend_id = blend["_id"] + blend_id = None + if blend: + blend_id = blend["_id"] + # Get fbx reference + fbx = io.find_one( + { + "type": "representation", + "parent": io.ObjectId(parent), + "name": "fbx" + }, + projection={"_id": True}) + fbx_id = None + if fbx: + fbx_id = fbx["_id"] + # Get abc reference + abc = io.find_one( + { + "type": "representation", + "parent": io.ObjectId(parent), + "name": "abc" + }, + projection={"_id": True}) + abc_id = None + if abc: + abc_id = abc["_id"] json_element = {} - json_element["reference"] = str(blend_id) + if blend_id: + json_element["reference"] = str(blend_id) + if fbx_id: + json_element["reference_fbx"] = str(fbx_id) + if abc_id: + json_element["reference_abc"] = str(abc_id) json_element["family"] = family json_element["instance_name"] = asset.name json_element["asset_name"] = metadata["asset_name"] @@ -67,6 +199,16 @@ class ExtractLayout(openpype.api.Extractor): "z": asset.scale.z } } + + # Extract the animation as well + if family == "rig": + f, n = self._export_animation( + asset, instance, stagingdir, fbx_count) + if f: + fbx_files.append(f) + json_element["animation"] = f + fbx_count = n + json_data.append(json_element) json_filename = "{}.json".format(instance.name) @@ -75,16 +217,32 @@ class ExtractLayout(openpype.api.Extractor): with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { + json_representation = { 'name': 'json', 'ext': 'json', 'files': json_filename, "stagingDir": stagingdir, } - instance.data["representations"].append(representation) + instance.data["representations"].append(json_representation) + + self.log.debug(fbx_files) + + if len(fbx_files) == 1: + fbx_representation = { + 'name': 'fbx', + 'ext': '000.fbx', + 'files': fbx_files[0], + "stagingDir": stagingdir, + } + instance.data["representations"].append(fbx_representation) + elif len(fbx_files) > 1: + fbx_representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': fbx_files, + "stagingDir": stagingdir, + } + instance.data["representations"].append(fbx_representation) self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + instance.name, json_representation) diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index 986842d0d6..963ca1398f 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -9,7 +9,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): label = "Increment Workfile Version" optional = True hosts = ["blender"] - families = ["animation", "model", "rig", "action"] + families = ["animation", "model", "rig", "action", "layout"] def process(self, context): diff --git a/openpype/hosts/blender/plugins/publish/validate_object_mode.py b/openpype/hosts/blender/plugins/publish/validate_object_mode.py index 1c82628c1c..90ef0b7c41 100644 --- a/openpype/hosts/blender/plugins/publish/validate_object_mode.py +++ b/openpype/hosts/blender/plugins/publish/validate_object_mode.py @@ -5,15 +5,15 @@ import openpype.hosts.blender.api.action class ValidateObjectIsInObjectMode(pyblish.api.InstancePlugin): - """Validate that the current object is in Object Mode.""" + """Validate that the objects in the instance are in Object Mode.""" order = pyblish.api.ValidatorOrder - 0.01 hosts = ["blender"] - families = ["model", "rig"] + families = ["model", "rig", "layout"] category = "geometry" - label = "Object is in Object Mode" + label = "Validate Object Mode" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] - optional = True + optional = False @classmethod def get_invalid(cls, instance) -> List: diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 481285d603..20baa30847 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -71,8 +71,18 @@ class AnimationFBXLoader(api.Loader): if instance_name: automated = True - actor_name = 'PersistentLevel.' + instance_name - actor = unreal.EditorLevelLibrary.get_actor_reference(actor_name) + # Old method to get the actor + # actor_name = 'PersistentLevel.' + instance_name + # actor = unreal.EditorLevelLibrary.get_actor_reference(actor_name) + actors = unreal.EditorLevelLibrary.get_all_level_actors() + for a in actors: + if a.get_class().get_name() != "SkeletalMeshActor": + continue + if a.get_actor_label() == instance_name: + actor = a + break + if not actor: + raise Exception(f"Could not find actor {instance_name}") skeleton = actor.skeletal_mesh_component.skeletal_mesh.skeleton task.options.set_editor_property('skeleton', skeleton) @@ -173,20 +183,35 @@ class AnimationFBXLoader(api.Loader): task.set_editor_property('destination_name', name) task.set_editor_property('replace_existing', True) task.set_editor_property('automated', True) - task.set_editor_property('save', False) + task.set_editor_property('save', True) # set import options here task.options.set_editor_property( - 'automated_import_should_detect_type', True) + 'automated_import_should_detect_type', False) task.options.set_editor_property( - 'original_import_type', unreal.FBXImportType.FBXIT_ANIMATION) + 'original_import_type', unreal.FBXImportType.FBXIT_SKELETAL_MESH) + task.options.set_editor_property( + 'mesh_type_to_import', unreal.FBXImportType.FBXIT_ANIMATION) task.options.set_editor_property('import_mesh', False) task.options.set_editor_property('import_animations', True) + task.options.set_editor_property('override_full_name', True) - task.options.skeletal_mesh_import_data.set_editor_property( - 'import_content_type', - unreal.FBXImportContentType.FBXICT_SKINNING_WEIGHTS + task.options.anim_sequence_import_data.set_editor_property( + 'animation_length', + unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME ) + task.options.anim_sequence_import_data.set_editor_property( + 'import_meshes_in_bone_hierarchy', False) + task.options.anim_sequence_import_data.set_editor_property( + 'use_default_sample_rate', True) + task.options.anim_sequence_import_data.set_editor_property( + 'import_custom_attribute', True) + task.options.anim_sequence_import_data.set_editor_property( + 'import_bone_tracks', True) + task.options.anim_sequence_import_data.set_editor_property( + 'remove_redundant_keys', True) + task.options.anim_sequence_import_data.set_editor_property( + 'convert_scene', True) skeletal_mesh = unreal.EditorAssetLibrary.load_asset( container.get('namespace') + "/" + container.get('asset_name')) @@ -219,7 +244,7 @@ class AnimationFBXLoader(api.Loader): unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( - parent_path, recursive=False + parent_path, recursive=False, include_folder=True ) if len(asset_content) == 0: diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py new file mode 100644 index 0000000000..19d0b74e3e --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -0,0 +1,544 @@ +import os +import json +from pathlib import Path + +import unreal +from unreal import EditorAssetLibrary +from unreal import EditorLevelLibrary +from unreal import AssetToolsHelpers +from unreal import FBXImportType +from unreal import MathLibrary as umath + +from avalon import api, pipeline +from avalon.unreal import lib +from avalon.unreal import pipeline as unreal_pipeline + + +class LayoutLoader(api.Loader): + """Load Layout from a JSON file""" + + families = ["layout"] + representations = ["json"] + + label = "Load Layout" + icon = "code-fork" + color = "orange" + + def _get_asset_containers(self, path): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + asset_content = EditorAssetLibrary.list_assets( + path, recursive=True) + + asset_containers = [] + + # Get all the asset containers + for a in asset_content: + obj = ar.get_asset_by_object_path(a) + if obj.get_asset().get_class().get_name() == 'AssetContainer': + asset_containers.append(obj) + + return asset_containers + + def _get_fbx_loader(self, loaders, family): + name = "" + if family == 'rig': + name = "SkeletalMeshFBXLoader" + elif family == 'model': + name = "StaticMeshFBXLoader" + elif family == 'camera': + name = "CameraLoader" + + if name == "": + return None + + for loader in loaders: + if loader.__name__ == name: + return loader + + return None + + def _get_abc_loader(self, loaders, family): + name = "" + if family == 'rig': + name = "SkeletalMeshAlembicLoader" + elif family == 'model': + name = "StaticMeshAlembicLoader" + + if name == "": + return None + + for loader in loaders: + if loader.__name__ == name: + return loader + + return None + + def _process_family(self, assets, classname, transform, inst_name=None): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + actors = [] + + for asset in assets: + obj = ar.get_asset_by_object_path(asset).get_asset() + if obj.get_class().get_name() == classname: + actor = EditorLevelLibrary.spawn_actor_from_object( + obj, + transform.get('translation') + ) + if inst_name: + try: + # Rename method leads to crash + # actor.rename(name=inst_name) + + # The label works, although it make it slightly more + # complicated to check for the names, as we need to + # loop through all the actors in the level + actor.set_actor_label(inst_name) + except Exception as e: + print(e) + actor.set_actor_rotation(unreal.Rotator( + umath.radians_to_degrees( + transform.get('rotation').get('x')), + -umath.radians_to_degrees( + transform.get('rotation').get('y')), + umath.radians_to_degrees( + transform.get('rotation').get('z')), + ), False) + actor.set_actor_scale3d(transform.get('scale')) + + actors.append(actor) + + return actors + + def _import_animation( + self, asset_dir, path, instance_name, skeleton, actors_dict, + animation_file): + anim_file = Path(animation_file) + anim_file_name = anim_file.with_suffix('') + + anim_path = f"{asset_dir}/animations/{anim_file_name}" + + # Import animation + task = unreal.AssetImportTask() + task.options = unreal.FbxImportUI() + + task.set_editor_property( + 'filename', str(path.with_suffix(f".{animation_file}"))) + task.set_editor_property('destination_path', anim_path) + task.set_editor_property( + 'destination_name', f"{instance_name}_animation") + task.set_editor_property('replace_existing', False) + task.set_editor_property('automated', True) + task.set_editor_property('save', False) + + # set import options here + task.options.set_editor_property( + 'automated_import_should_detect_type', False) + task.options.set_editor_property( + 'original_import_type', FBXImportType.FBXIT_SKELETAL_MESH) + task.options.set_editor_property( + 'mesh_type_to_import', FBXImportType.FBXIT_ANIMATION) + task.options.set_editor_property('import_mesh', False) + task.options.set_editor_property('import_animations', True) + task.options.set_editor_property('override_full_name', True) + task.options.set_editor_property('skeleton', skeleton) + + task.options.anim_sequence_import_data.set_editor_property( + 'animation_length', + unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME + ) + task.options.anim_sequence_import_data.set_editor_property( + 'import_meshes_in_bone_hierarchy', False) + task.options.anim_sequence_import_data.set_editor_property( + 'use_default_sample_rate', True) + task.options.anim_sequence_import_data.set_editor_property( + 'import_custom_attribute', True) + task.options.anim_sequence_import_data.set_editor_property( + 'import_bone_tracks', True) + task.options.anim_sequence_import_data.set_editor_property( + 'remove_redundant_keys', True) + task.options.anim_sequence_import_data.set_editor_property( + 'convert_scene', True) + + AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + asset_content = unreal.EditorAssetLibrary.list_assets( + anim_path, recursive=False, include_folder=False + ) + + animation = None + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + imported_asset_data = unreal.EditorAssetLibrary.find_asset_data(a) + imported_asset = unreal.AssetRegistryHelpers.get_asset( + imported_asset_data) + if imported_asset.__class__ == unreal.AnimSequence: + animation = imported_asset + break + + if animation: + actor = None + if actors_dict.get(instance_name): + for a in actors_dict.get(instance_name): + if a.get_class().get_name() == 'SkeletalMeshActor': + actor = a + break + + animation.set_editor_property('enable_root_motion', True) + actor.skeletal_mesh_component.set_editor_property( + 'animation_mode', unreal.AnimationMode.ANIMATION_SINGLE_NODE) + actor.skeletal_mesh_component.animation_data.set_editor_property( + 'anim_to_play', animation) + + def _process(self, libpath, asset_dir, loaded=None): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + with open(libpath, "r") as fp: + data = json.load(fp) + + all_loaders = api.discover(api.Loader) + + if not loaded: + loaded = [] + + path = Path(libpath) + + skeleton_dict = {} + actors_dict = {} + + for element in data: + reference = None + if element.get('reference_fbx'): + reference = element.get('reference_fbx') + elif element.get('reference_abc'): + reference = element.get('reference_abc') + + # If reference is None, this element is skipped, as it cannot be + # imported in Unreal + if not reference: + continue + + instance_name = element.get('instance_name') + + skeleton = None + + if reference not in loaded: + loaded.append(reference) + + family = element.get('family') + loaders = api.loaders_from_representation( + all_loaders, reference) + + loader = None + + if reference == element.get('reference_fbx'): + loader = self._get_fbx_loader(loaders, family) + elif reference == element.get('reference_abc'): + loader = self._get_abc_loader(loaders, family) + + if not loader: + continue + + options = { + "asset_dir": asset_dir + } + + assets = api.load( + loader, + reference, + namespace=instance_name, + options=options + ) + + instances = [ + item for item in data + if (item.get('reference_fbx') == reference or + item.get('reference_abc') == reference)] + + for instance in instances: + transform = instance.get('transform') + inst = instance.get('instance_name') + + actors = [] + + if family == 'model': + actors = self._process_family( + assets, 'StaticMesh', transform, inst) + elif family == 'rig': + actors = self._process_family( + assets, 'SkeletalMesh', transform, inst) + actors_dict[inst] = actors + + if family == 'rig': + # Finds skeleton among the imported assets + for asset in assets: + obj = ar.get_asset_by_object_path(asset).get_asset() + if obj.get_class().get_name() == 'Skeleton': + skeleton = obj + if skeleton: + break + + if skeleton: + skeleton_dict[reference] = skeleton + else: + skeleton = skeleton_dict.get(reference) + + animation_file = element.get('animation') + + if animation_file and skeleton: + self._import_animation( + asset_dir, path, instance_name, skeleton, + actors_dict, animation_file) + + def _remove_family(self, assets, components, classname, propname): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + objects = [] + for a in assets: + obj = ar.get_asset_by_object_path(a) + if obj.get_asset().get_class().get_name() == classname: + objects.append(obj) + for obj in objects: + for comp in components: + if comp.get_editor_property(propname) == obj.get_asset(): + comp.get_owner().destroy_actor() + + def _remove_actors(self, path): + asset_containers = self._get_asset_containers(path) + + # Get all the static and skeletal meshes components in the level + components = EditorLevelLibrary.get_all_level_actors_components() + static_meshes_comp = [ + c for c in components + if c.get_class().get_name() == 'StaticMeshComponent'] + skel_meshes_comp = [ + c for c in components + if c.get_class().get_name() == 'SkeletalMeshComponent'] + + # For all the asset containers, get the static and skeletal meshes. + # Then, check the components in the level and destroy the matching + # actors. + for asset_container in asset_containers: + package_path = asset_container.get_editor_property('package_path') + family = EditorAssetLibrary.get_metadata_tag( + asset_container.get_asset(), 'family') + assets = EditorAssetLibrary.list_assets( + str(package_path), recursive=False) + if family == 'model': + self._remove_family( + assets, static_meshes_comp, 'StaticMesh', 'static_mesh') + elif family == 'rig': + self._remove_family( + assets, skel_meshes_comp, 'SkeletalMesh', 'skeletal_mesh') + + def load(self, context, name, namespace, options): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ + # Create directory for asset and avalon container + root = "/Game/Avalon/Assets" + asset = context.get('asset').get('name') + suffix = "_CON" + if asset: + asset_name = "{}_{}".format(asset, name) + else: + asset_name = "{}".format(name) + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + "{}/{}/{}".format(root, asset, name), suffix="") + + container_name += suffix + + EditorAssetLibrary.make_directory(asset_dir) + + self._process(self.fname, asset_dir) + + # Create Asset Container + lib.create_avalon_container( + container=container_name, path=asset_dir) + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": context["representation"]["_id"], + "parent": context["representation"]["parent"], + "family": context["representation"]["context"]["family"] + } + unreal_pipeline.imprint( + "{}/{}".format(asset_dir, container_name), data) + + asset_content = EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=False) + + for a in asset_content: + EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + source_path = api.get_representation_path(representation) + destination_path = container["namespace"] + libpath = Path(api.get_representation_path(representation)) + + self._remove_actors(destination_path) + + # Delete old animations + anim_path = f"{destination_path}/animations/" + EditorAssetLibrary.delete_directory(anim_path) + + with open(source_path, "r") as fp: + data = json.load(fp) + + references = [e.get('reference_fbx') for e in data] + asset_containers = self._get_asset_containers(destination_path) + loaded = [] + + # Delete all the assets imported with the previous version of the + # layout, if they're not in the new layout. + for asset_container in asset_containers: + if asset_container.get_editor_property( + 'asset_name') == container["objectName"]: + continue + ref = EditorAssetLibrary.get_metadata_tag( + asset_container.get_asset(), 'representation') + ppath = asset_container.get_editor_property('package_path') + + if ref not in references: + # If the asset is not in the new layout, delete it. + # Also check if the parent directory is empty, and delete that + # as well, if it is. + EditorAssetLibrary.delete_directory(ppath) + + parent = os.path.dirname(str(ppath)) + parent_content = EditorAssetLibrary.list_assets( + parent, recursive=False, include_folder=True + ) + + if len(parent_content) == 0: + EditorAssetLibrary.delete_directory(parent) + else: + # If the asset is in the new layout, search the instances in + # the JSON file, and create actors for them. + + actors_dict = {} + skeleton_dict = {} + + for element in data: + reference = element.get('reference_fbx') + instance_name = element.get('instance_name') + + skeleton = None + + if reference == ref and ref not in loaded: + loaded.append(ref) + + family = element.get('family') + + assets = EditorAssetLibrary.list_assets( + ppath, recursive=True, include_folder=False) + + instances = [ + item for item in data + if item.get('reference_fbx') == reference] + + for instance in instances: + transform = instance.get('transform') + inst = instance.get('instance_name') + + actors = [] + + if family == 'model': + actors = self._process_family( + assets, 'StaticMesh', transform, inst) + elif family == 'rig': + actors = self._process_family( + assets, 'SkeletalMesh', transform, inst) + actors_dict[inst] = actors + + if family == 'rig': + # Finds skeleton among the imported assets + for asset in assets: + obj = ar.get_asset_by_object_path( + asset).get_asset() + if obj.get_class().get_name() == 'Skeleton': + skeleton = obj + if skeleton: + break + + if skeleton: + skeleton_dict[reference] = skeleton + else: + skeleton = skeleton_dict.get(reference) + + animation_file = element.get('animation') + + if animation_file and skeleton: + self._import_animation( + destination_path, libpath, + instance_name, skeleton, + actors_dict, animation_file) + + self._process(source_path, destination_path, loaded) + + container_path = "{}/{}".format(container["namespace"], + container["objectName"]) + # update metadata + unreal_pipeline.imprint( + container_path, + { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + }) + + asset_content = EditorAssetLibrary.list_assets( + destination_path, recursive=True, include_folder=False) + + for a in asset_content: + EditorAssetLibrary.save_asset(a) + + def remove(self, container): + """ + First, destroy all actors of the assets to be removed. Then, deletes + the asset's directory. + """ + path = container["namespace"] + parent_path = os.path.dirname(path) + + self._remove_actors(path) + + EditorAssetLibrary.delete_directory(path) + + asset_content = EditorAssetLibrary.list_assets( + parent_path, recursive=False, include_folder=True + ) + + if len(asset_content) == 0: + EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index 7f6e31618a..c7d095aa21 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -15,7 +15,7 @@ class SkeletalMeshFBXLoader(api.Loader): icon = "cube" color = "orange" - def load(self, context, name, namespace, data): + def load(self, context, name, namespace, options): """ Load and containerise representation into Content Browser. @@ -40,6 +40,8 @@ class SkeletalMeshFBXLoader(api.Loader): # Create directory for asset and avalon container root = "/Game/Avalon/Assets" + if options and options.get("asset_dir"): + root = options["asset_dir"] asset = context.get('asset').get('name') suffix = "_CON" if asset: diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index d25f84ea69..510c4331ad 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -40,7 +40,7 @@ class StaticMeshFBXLoader(api.Loader): return task - def load(self, context, name, namespace, data): + def load(self, context, name, namespace, options): """ Load and containerise representation into Content Browser. @@ -65,6 +65,8 @@ class StaticMeshFBXLoader(api.Loader): # Create directory for asset and avalon container root = "/Game/Avalon/Assets" + if options and options.get("asset_dir"): + root = options["asset_dir"] asset = context.get('asset').get('name') suffix = "_CON" if asset: