diff --git a/client/ayon_core/hosts/blender/api/lib.py b/client/ayon_core/hosts/blender/api/lib.py index 32137f0fcd..ea37f17d05 100644 --- a/client/ayon_core/hosts/blender/api/lib.py +++ b/client/ayon_core/hosts/blender/api/lib.py @@ -365,3 +365,62 @@ def maintained_time(): yield finally: bpy.context.scene.frame_current = current_time + + +def get_all_parents(obj): + """Get all recursive parents of object. + + Arguments: + obj (bpy.types.Object): Object to get all parents for. + + Returns: + List[bpy.types.Object]: All parents of object + + """ + result = [] + while True: + obj = obj.parent + if not obj: + break + result.append(obj) + return result + + +def get_highest_root(objects): + """Get the highest object (the least parents) among the objects. + + If multiple objects have the same amount of parents (or no parents) the + first object found in the input iterable will be returned. + + Note that this will *not* return objects outside of the input list, as + such it will not return the root of node from a child node. It is purely + intended to find the highest object among a list of objects. To instead + get the root from one object use, e.g. `get_all_parents(obj)[-1]` + + Arguments: + objects (List[bpy.types.Object]): Objects to find the highest root in. + + Returns: + Optional[bpy.types.Object]: First highest root found or None if no + `bpy.types.Object` found in input list. + + """ + included_objects = {obj.name_full for obj in objects} + num_parents_to_obj = {} + for obj in objects: + if isinstance(obj, bpy.types.Object): + parents = get_all_parents(obj) + # included parents + parents = [parent for parent in parents if + parent.name_full in included_objects] + if not parents: + # A node without parents must be a highest root + return obj + + num_parents_to_obj.setdefault(len(parents), obj) + + if not num_parents_to_obj: + return + + minimum_parent = min(num_parents_to_obj) + return num_parents_to_obj[minimum_parent] diff --git a/client/ayon_core/hosts/blender/api/plugin.py b/client/ayon_core/hosts/blender/api/plugin.py index 4a13d16805..e00e127f70 100644 --- a/client/ayon_core/hosts/blender/api/plugin.py +++ b/client/ayon_core/hosts/blender/api/plugin.py @@ -26,7 +26,8 @@ from .ops import ( ) from .lib import imprint -VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx"] +VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx", + ".usd", ".usdc", ".usda"] def prepare_scene_name( diff --git a/client/ayon_core/hosts/blender/plugins/create/create_usd.py b/client/ayon_core/hosts/blender/plugins/create/create_usd.py new file mode 100644 index 0000000000..2c2d0c46c6 --- /dev/null +++ b/client/ayon_core/hosts/blender/plugins/create/create_usd.py @@ -0,0 +1,30 @@ +"""Create a USD Export.""" + +from ayon_core.hosts.blender.api import plugin, lib + + +class CreateUSD(plugin.BaseCreator): + """Create USD Export""" + + identifier = "io.openpype.creators.blender.usd" + name = "usdMain" + label = "USD" + product_type = "usd" + icon = "gears" + + def create( + self, product_name: str, instance_data: dict, pre_create_data: dict + ): + # Run parent create method + collection = super().create( + product_name, instance_data, pre_create_data + ) + + if pre_create_data.get("use_selection"): + objects = lib.get_selection() + for obj in objects: + collection.objects.link(obj) + if obj.type == 'EMPTY': + objects.extend(obj.children) + + return collection diff --git a/client/ayon_core/hosts/blender/plugins/load/load_abc.py b/client/ayon_core/hosts/blender/plugins/load/load_cache.py similarity index 94% rename from client/ayon_core/hosts/blender/plugins/load/load_abc.py rename to client/ayon_core/hosts/blender/plugins/load/load_cache.py index c074b5ed13..30c847f89d 100644 --- a/client/ayon_core/hosts/blender/plugins/load/load_abc.py +++ b/client/ayon_core/hosts/blender/plugins/load/load_cache.py @@ -26,10 +26,10 @@ class CacheModelLoader(plugin.AssetLoader): Note: At least for now it only supports Alembic files. """ - product_types = {"model", "pointcache", "animation"} - representations = {"abc"} + product_types = {"model", "pointcache", "animation", "usd"} + representations = {"abc", "usd"} - label = "Load Alembic" + label = "Load Cache" icon = "code-fork" color = "orange" @@ -53,10 +53,21 @@ class CacheModelLoader(plugin.AssetLoader): plugin.deselect_all() relative = bpy.context.preferences.filepaths.use_relative_paths - bpy.ops.wm.alembic_import( - filepath=libpath, - relative_path=relative - ) + + if any(libpath.lower().endswith(ext) + for ext in [".usd", ".usda", ".usdc"]): + # USD + bpy.ops.wm.usd_import( + filepath=libpath, + relative_path=relative + ) + + else: + # Alembic + bpy.ops.wm.alembic_import( + filepath=libpath, + relative_path=relative + ) imported = lib.get_selection() diff --git a/client/ayon_core/hosts/blender/plugins/publish/collect_instance.py b/client/ayon_core/hosts/blender/plugins/publish/collect_instance.py index d47c69a270..314ffd368a 100644 --- a/client/ayon_core/hosts/blender/plugins/publish/collect_instance.py +++ b/client/ayon_core/hosts/blender/plugins/publish/collect_instance.py @@ -12,7 +12,7 @@ class CollectBlenderInstanceData(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder hosts = ["blender"] families = ["model", "pointcache", "animation", "rig", "camera", "layout", - "blendScene"] + "blendScene", "usd"] label = "Collect Instance" def process(self, instance): diff --git a/client/ayon_core/hosts/blender/plugins/publish/extract_usd.py b/client/ayon_core/hosts/blender/plugins/publish/extract_usd.py new file mode 100644 index 0000000000..1d4fa3d7ac --- /dev/null +++ b/client/ayon_core/hosts/blender/plugins/publish/extract_usd.py @@ -0,0 +1,90 @@ +import os + +import bpy + +from ayon_core.pipeline import publish +from ayon_core.hosts.blender.api import plugin, lib + + +class ExtractUSD(publish.Extractor): + """Extract as USD.""" + + label = "Extract USD" + hosts = ["blender"] + families = ["usd"] + + def process(self, instance): + + # Ignore runtime instances (e.g. USD layers) + # TODO: This is better done via more specific `families` + if not instance.data.get("transientData", {}).get("instance_node"): + return + + # Define extract output file path + stagingdir = self.staging_dir(instance) + filename = f"{instance.name}.usd" + filepath = os.path.join(stagingdir, filename) + + # Perform extraction + self.log.debug("Performing extraction..") + + # Select all members to "export selected" + plugin.deselect_all() + + selected = [] + for obj in instance: + if isinstance(obj, bpy.types.Object): + obj.select_set(True) + selected.append(obj) + + root = lib.get_highest_root(objects=instance[:]) + if not root: + instance_node = instance.data["transientData"]["instance_node"] + raise publish.KnownPublishError( + f"No root object found in instance: {instance_node.name}" + ) + self.log.debug(f"Exporting using active root: {root.name}") + + context = plugin.create_blender_context( + active=root, selected=selected) + + # Export USD + with bpy.context.temp_override(**context): + bpy.ops.wm.usd_export( + filepath=filepath, + selected_objects_only=True, + export_textures=False, + relative_paths=False, + export_animation=False, + export_hair=False, + export_uvmaps=True, + # TODO: add for new version of Blender (4+?) + # export_mesh_colors=True, + export_normals=True, + export_materials=True, + use_instancing=True + ) + + plugin.deselect_all() + + # Add representation + representation = { + 'name': 'usd', + 'ext': 'usd', + 'files': filename, + "stagingDir": stagingdir, + } + instance.data.setdefault("representations", []).append(representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, representation) + + +class ExtractModelUSD(ExtractUSD): + """Extract model as USD.""" + + label = "Extract USD (Model)" + hosts = ["blender"] + families = ["model"] + + # Driven by settings + optional = True diff --git a/server_addon/blender/package.py b/server_addon/blender/package.py index 667076e533..d2c02a4909 100644 --- a/server_addon/blender/package.py +++ b/server_addon/blender/package.py @@ -1,3 +1,3 @@ name = "blender" title = "Blender" -version = "0.1.8" +version = "0.1.9" diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index e998d7b057..8db8c5be46 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -151,6 +151,10 @@ class PublishPluginsModel(BaseSettingsModel): default_factory=ExtractPlayblastModel, title="Extract Playblast" ) + ExtractModelUSD: ValidatePluginModel = SettingsField( + default_factory=ValidatePluginModel, + title="Extract Model USD" + ) DEFAULT_BLENDER_PUBLISH_SETTINGS = { @@ -348,5 +352,10 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { }, indent=4 ) + }, + "ExtractModelUSD": { + "enabled": True, + "optional": True, + "active": True } }