From 9a8655be1da5a8939a34cf66f71a036b13141fb2 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Sat, 14 Mar 2020 00:41:53 +0100 Subject: [PATCH] publishing unreal static mesh from maya --- pype/hooks/unreal/unreal_prelaunch.py | 2 +- .../global/publish/collect_scene_version.py | 3 + pype/plugins/global/publish/integrate_new.py | 3 +- .../maya/create/create_unreal_staticmesh.py | 11 ++ .../maya/publish/collect_unreal_staticmesh.py | 33 ++++ pype/plugins/maya/publish/extract_fbx.py | 5 +- .../validate_unreal_mesh_triangulated.py | 33 ++++ .../validate_unreal_staticmesh_naming.py | 120 ++++++++++++++ .../maya/publish/validate_unreal_up_axis.py | 25 +++ pype/plugins/unreal/create/create_fbx.py | 14 ++ .../plugins/unreal/load/load_staticmeshfbx.py | 53 ++++++ .../unreal/publish/collect_instances.py | 152 ++++++++++++++++++ pype/unreal/__init__.py | 45 ++++++ pype/unreal/plugin.py | 9 ++ 14 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 pype/plugins/maya/create/create_unreal_staticmesh.py create mode 100644 pype/plugins/maya/publish/collect_unreal_staticmesh.py create mode 100644 pype/plugins/maya/publish/validate_unreal_mesh_triangulated.py create mode 100644 pype/plugins/maya/publish/validate_unreal_staticmesh_naming.py create mode 100644 pype/plugins/maya/publish/validate_unreal_up_axis.py create mode 100644 pype/plugins/unreal/create/create_fbx.py create mode 100644 pype/plugins/unreal/load/load_staticmeshfbx.py create mode 100644 pype/plugins/unreal/publish/collect_instances.py create mode 100644 pype/unreal/plugin.py diff --git a/pype/hooks/unreal/unreal_prelaunch.py b/pype/hooks/unreal/unreal_prelaunch.py index efb5d9157b..5b6b8e08e0 100644 --- a/pype/hooks/unreal/unreal_prelaunch.py +++ b/pype/hooks/unreal/unreal_prelaunch.py @@ -36,7 +36,7 @@ class UnrealPrelaunch(PypeHook): # Unreal is sensitive about project names longer then 20 chars if len(project_name) > 20: self.log.warning((f"Project name exceed 20 characters " - f"[ {project_name} ]!")) + f"({project_name})!")) # Unreal doesn't accept non alphabet characters at the start # of the project name. This is because project name is then used diff --git a/pype/plugins/global/publish/collect_scene_version.py b/pype/plugins/global/publish/collect_scene_version.py index 02e913199b..314a64f550 100644 --- a/pype/plugins/global/publish/collect_scene_version.py +++ b/pype/plugins/global/publish/collect_scene_version.py @@ -16,6 +16,9 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): if "standalonepublisher" in context.data.get("host", []): return + if "unreal" in context.data.get("host", []): + return + filename = os.path.basename(context.data.get('currentFile')) if '' in filename: diff --git a/pype/plugins/global/publish/integrate_new.py b/pype/plugins/global/publish/integrate_new.py index 1d061af173..8935127e9e 100644 --- a/pype/plugins/global/publish/integrate_new.py +++ b/pype/plugins/global/publish/integrate_new.py @@ -80,7 +80,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "matchmove", "image" "source", - "assembly" + "assembly", + "fbx" ] exclude_families = ["clip"] db_representation_context_keys = [ diff --git a/pype/plugins/maya/create/create_unreal_staticmesh.py b/pype/plugins/maya/create/create_unreal_staticmesh.py new file mode 100644 index 0000000000..5a74cb22d5 --- /dev/null +++ b/pype/plugins/maya/create/create_unreal_staticmesh.py @@ -0,0 +1,11 @@ +import avalon.maya + + +class CreateUnrealStaticMesh(avalon.maya.Creator): + name = "staticMeshMain" + label = "Unreal - Static Mesh" + family = "unrealStaticMesh" + icon = "cube" + + def __init__(self, *args, **kwargs): + super(CreateUnrealStaticMesh, self).__init__(*args, **kwargs) diff --git a/pype/plugins/maya/publish/collect_unreal_staticmesh.py b/pype/plugins/maya/publish/collect_unreal_staticmesh.py new file mode 100644 index 0000000000..5ab9643f4b --- /dev/null +++ b/pype/plugins/maya/publish/collect_unreal_staticmesh.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from maya import cmds +import pyblish.api + + +class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): + """Collect unreal static mesh + + Ensures always only a single frame is extracted (current frame). This + also sets correct FBX options for later extraction. + + Note: + This is a workaround so that the `pype.model` family can use the + same pointcache extractor implementation as animation and pointcaches. + This always enforces the "current" frame to be published. + + """ + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Model Data" + families = ["unrealStaticMesh"] + + def process(self, instance): + # add fbx family to trigger fbx extractor + instance.data["families"].append("fbx") + # set fbx overrides on instance + instance.data["smoothingGroups"] = True + instance.data["smoothMesh"] = True + instance.data["triangulate"] = True + + frame = cmds.currentTime(query=True) + instance.data["frameStart"] = frame + instance.data["frameEnd"] = frame diff --git a/pype/plugins/maya/publish/extract_fbx.py b/pype/plugins/maya/publish/extract_fbx.py index 01b58241c2..6a75bfce0e 100644 --- a/pype/plugins/maya/publish/extract_fbx.py +++ b/pype/plugins/maya/publish/extract_fbx.py @@ -212,12 +212,11 @@ class ExtractFBX(pype.api.Extractor): instance.data["representations"] = [] representation = { - 'name': 'mov', - 'ext': 'mov', + 'name': 'fbx', + 'ext': 'fbx', 'files': filename, "stagingDir": stagingDir, } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) diff --git a/pype/plugins/maya/publish/validate_unreal_mesh_triangulated.py b/pype/plugins/maya/publish/validate_unreal_mesh_triangulated.py new file mode 100644 index 0000000000..77f7144c4e --- /dev/null +++ b/pype/plugins/maya/publish/validate_unreal_mesh_triangulated.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- + +from maya import cmds +import pyblish.api +import pype.api + + +class ValidateUnrealMeshTriangulated(pyblish.api.InstancePlugin): + """Validate if mesh is made of triangles for Unreal Engine""" + + order = pype.api.ValidateMeshOder + hosts = ["maya"] + families = ["unrealStaticMesh"] + category = "geometry" + label = "Mesh is Triangulated" + actions = [pype.maya.action.SelectInvalidAction] + + @classmethod + def get_invalid(cls, instance): + invalid = [] + meshes = cmds.ls(instance, type="mesh", long=True) + for mesh in meshes: + faces = cmds.polyEvaluate(mesh, f=True) + tris = cmds.polyEvaluate(mesh, t=True) + if faces != tris: + invalid.append(mesh) + + return invalid + + def process(self, instance): + invalid = self.get_invalid(instance) + assert len(invalid) == 0, ( + "Found meshes without triangles") diff --git a/pype/plugins/maya/publish/validate_unreal_staticmesh_naming.py b/pype/plugins/maya/publish/validate_unreal_staticmesh_naming.py new file mode 100644 index 0000000000..b62a855da9 --- /dev/null +++ b/pype/plugins/maya/publish/validate_unreal_staticmesh_naming.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +from maya import cmds +import pyblish.api +import pype.api +import pype.maya.action +import re + + +class ValidateUnrealStaticmeshName(pyblish.api.InstancePlugin): + """Validate name of Unreal Static Mesh + + Unreals naming convention states that staticMesh sould start with `SM` + prefix - SM_[Name]_## (Eg. SM_sube_01). This plugin also validates other + types of meshes - collision meshes: + + UBX_[RenderMeshName]_##: + Boxes are created with the Box objects type in + Max or with the Cube polygonal primitive in Maya. + You cannot move the vertices around or deform it + in any way to make it something other than a + rectangular prism, or else it will not work. + + UCP_[RenderMeshName]_##: + Capsules are created with the Capsule object type. + The capsule does not need to have many segments + (8 is a good number) at all because it is + converted into a true capsule for collision. Like + boxes, you should not move the individual + vertices around. + + USP_[RenderMeshName]_##: + Spheres are created with the Sphere object type. + The sphere does not need to have many segments + (8 is a good number) at all because it is + converted into a true sphere for collision. Like + boxes, you should not move the individual + vertices around. + + UCX_[RenderMeshName]_##: + Convex objects can be any completely closed + convex 3D shape. For example, a box can also be + a convex object + + This validator also checks if collision mesh [RenderMeshName] matches one + of SM_[RenderMeshName]. + + """ + optional = True + order = pype.api.ValidateContentsOrder + hosts = ["maya"] + families = ["unrealStaticMesh"] + label = "Unreal StaticMesh Name" + actions = [pype.maya.action.SelectInvalidAction] + regex_mesh = r"SM_(?P.*)_(\d{2})" + regex_collision = r"((UBX)|(UCP)|(USP)|(UCX))_(?P.*)_(\d{2})" + + @classmethod + def get_invalid(cls, instance): + + # find out if supplied transform is group or not + def is_group(groupName): + try: + children = cmds.listRelatives(groupName, children=True) + for child in children: + if not cmds.ls(child, transforms=True): + return False + return True + except Exception: + return False + + invalid = [] + content_instance = instance.data.get("setMembers", None) + if not content_instance: + cls.log.error("Instance has no nodes!") + return True + pass + descendants = cmds.listRelatives(content_instance, + allDescendents=True, + fullPath=True) or [] + + descendants = cmds.ls(descendants, noIntermediate=True, long=True) + trns = cmds.ls(descendants, long=False, type=('transform')) + + # filter out groups + filter = [node for node in trns if not is_group(node)] + + # compile regex for testing names + sm_r = re.compile(cls.regex_mesh) + cl_r = re.compile(cls.regex_collision) + + sm_names = [] + col_names = [] + for obj in filter: + sm_m = sm_r.match(obj) + if sm_m is None: + # test if it matches collision mesh + cl_r = sm_r.match(obj) + if cl_r is None: + cls.log.error("invalid mesh name on: {}".format(obj)) + invalid.append(obj) + else: + col_names.append((cl_r.group("renderName"), obj)) + else: + sm_names.append(sm_m.group("renderName")) + + for c_mesh in col_names: + if c_mesh[0] not in sm_names: + cls.log.error(("collision name {} doesn't match any " + "static mesh names.").format(obj)) + invalid.append(c_mesh[1]) + + return invalid + + def process(self, instance): + + invalid = self.get_invalid(instance) + + if invalid: + raise RuntimeError("Model naming is invalid. See log.") diff --git a/pype/plugins/maya/publish/validate_unreal_up_axis.py b/pype/plugins/maya/publish/validate_unreal_up_axis.py new file mode 100644 index 0000000000..6641edb4a5 --- /dev/null +++ b/pype/plugins/maya/publish/validate_unreal_up_axis.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +from maya import cmds +import pyblish.api +import pype.api + + +class ValidateUnrealUpAxis(pyblish.api.ContextPlugin): + """Validate if Z is set as up axis in Maya""" + + optional = True + order = pype.api.ValidateContentsOrder + hosts = ["maya"] + families = ["unrealStaticMesh"] + label = "Unreal Up-Axis check" + actions = [pype.api.RepairAction] + + def process(self, context): + assert cmds.upAxis(q=True, axis=True) == "z", ( + "Invalid axis set as up axis" + ) + + @classmethod + def repair(cls, instance): + cmds.upAxis(axis="z", rotateView=True) diff --git a/pype/plugins/unreal/create/create_fbx.py b/pype/plugins/unreal/create/create_fbx.py new file mode 100644 index 0000000000..0d5b0bf316 --- /dev/null +++ b/pype/plugins/unreal/create/create_fbx.py @@ -0,0 +1,14 @@ +from pype.unreal.plugin import Creator + + +class CreateFbx(Creator): + """Static FBX geometry""" + + name = "modelMain" + label = "Model" + family = "model" + icon = "cube" + asset_types = ["StaticMesh"] + + def __init__(self, *args, **kwargs): + super(CreateFbx, self).__init__(*args, **kwargs) diff --git a/pype/plugins/unreal/load/load_staticmeshfbx.py b/pype/plugins/unreal/load/load_staticmeshfbx.py new file mode 100644 index 0000000000..056c81d54d --- /dev/null +++ b/pype/plugins/unreal/load/load_staticmeshfbx.py @@ -0,0 +1,53 @@ +from avalon import api +from avalon import unreal as avalon_unreal +import unreal +import time + + +class StaticMeshFBXLoader(api.Loader): + """Load Unreal StaticMesh from FBX""" + + families = ["unrealStaticMesh"] + label = "Import FBX Static Mesh" + representations = ["fbx"] + icon = "cube" + color = "orange" + + def load(self, context, name, namespace, data): + + tools = unreal.AssetToolsHelpers().get_asset_tools() + temp_dir, temp_name = tools.create_unique_asset_name( + "/Game/{}".format(name), "_TMP" + ) + + # asset_path = "/Game/{}".format(namespace) + unreal.EditorAssetLibrary.make_directory(temp_dir) + + task = unreal.AssetImportTask() + + task.filename = self.fname + task.destination_path = temp_dir + task.destination_name = name + task.replace_existing = False + task.automated = True + task.save = True + + # set import options here + task.options = unreal.FbxImportUI() + task.options.import_animations = False + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + + imported_assets = unreal.EditorAssetLibrary.list_assets( + temp_dir, recursive=True, include_folder=True + ) + new_dir = avalon_unreal.containerise( + name, namespace, imported_assets, context, self.__class__.__name__) + + asset_content = unreal.EditorAssetLibrary.list_assets( + new_dir, recursive=True, include_folder=True + ) + + unreal.EditorAssetLibrary.delete_directory(temp_dir) + + return asset_content diff --git a/pype/plugins/unreal/publish/collect_instances.py b/pype/plugins/unreal/publish/collect_instances.py new file mode 100644 index 0000000000..fa604f79d3 --- /dev/null +++ b/pype/plugins/unreal/publish/collect_instances.py @@ -0,0 +1,152 @@ +import unreal + +import pyblish.api + + +class CollectInstances(pyblish.api.ContextPlugin): + """Gather instances by objectSet and pre-defined attribute + + This collector takes into account assets that are associated with + an objectSet and marked with a unique identifier; + + Identifier: + id (str): "pyblish.avalon.instance" + + Limitations: + - Does not take into account nodes connected to those + within an objectSet. Extractors are assumed to export + with history preserved, but this limits what they will + be able to achieve and the amount of data available + to validators. An additional collector could also + append this input data into the instance, as we do + for `pype.rig` with collect_history. + + """ + + label = "Collect Instances" + order = pyblish.api.CollectorOrder + hosts = ["unreal"] + + def process(self, context): + + objectset = cmds.ls("*.id", long=True, type="objectSet", + recursive=True, objectsOnly=True) + + context.data['objectsets'] = objectset + for objset in objectset: + + if not cmds.attributeQuery("id", node=objset, exists=True): + continue + + id_attr = "{}.id".format(objset) + if cmds.getAttr(id_attr) != "pyblish.avalon.instance": + continue + + # The developer is responsible for specifying + # the family of each instance. + has_family = cmds.attributeQuery("family", + node=objset, + exists=True) + assert has_family, "\"%s\" was missing a family" % objset + + members = cmds.sets(objset, query=True) + if members is None: + self.log.warning("Skipped empty instance: \"%s\" " % objset) + continue + + self.log.info("Creating instance for {}".format(objset)) + + data = dict() + + # Apply each user defined attribute as data + for attr in cmds.listAttr(objset, userDefined=True) or list(): + try: + value = cmds.getAttr("%s.%s" % (objset, attr)) + except Exception: + # Some attributes cannot be read directly, + # such as mesh and color attributes. These + # are considered non-essential to this + # particular publishing pipeline. + value = None + data[attr] = value + + # temporarily translation of `active` to `publish` till issue has + # been resolved, https://github.com/pyblish/pyblish-base/issues/307 + if "active" in data: + data["publish"] = data["active"] + + # Collect members + members = cmds.ls(members, long=True) or [] + + # `maya.cmds.listRelatives(noIntermediate=True)` only works when + # `shapes=True` argument is passed, since we also want to include + # transforms we filter afterwards. + children = cmds.listRelatives(members, + allDescendents=True, + fullPath=True) or [] + children = cmds.ls(children, noIntermediate=True, long=True) + + parents = [] + if data.get("includeParentHierarchy", True): + # If `includeParentHierarchy` then include the parents + # so they will also be picked up in the instance by validators + parents = self.get_all_parents(members) + members_hierarchy = list(set(members + children + parents)) + + if 'families' not in data: + data['families'] = [data.get('family')] + + # Create the instance + instance = context.create_instance(objset) + instance[:] = members_hierarchy + + # Store the exact members of the object set + instance.data["setMembers"] = members + + + # Define nice label + name = cmds.ls(objset, long=False)[0] # use short name + label = "{0} ({1})".format(name, + data["asset"]) + + # Append start frame and end frame to label if present + if "frameStart" and "frameEnd" in data: + label += " [{0}-{1}]".format(int(data["frameStart"]), + int(data["frameEnd"])) + + instance.data["label"] = label + + instance.data.update(data) + + # Produce diagnostic message for any graphical + # user interface interested in visualising it. + self.log.info("Found: \"%s\" " % instance.data["name"]) + self.log.debug("DATA: \"%s\" " % instance.data) + + + def sort_by_family(instance): + """Sort by family""" + return instance.data.get("families", instance.data.get("family")) + + # Sort/grouped by family (preserving local index) + context[:] = sorted(context, key=sort_by_family) + + return context + + def get_all_parents(self, nodes): + """Get all parents by using string operations (optimization) + + Args: + nodes (list): the nodes which are found in the objectSet + + Returns: + list + """ + + parents = [] + for node in nodes: + splitted = node.split("|") + items = ["|".join(splitted[0:i]) for i in range(2, len(splitted))] + parents.extend(items) + + return list(set(parents)) diff --git a/pype/unreal/__init__.py b/pype/unreal/__init__.py index e69de29bb2..bb8a765a43 100644 --- a/pype/unreal/__init__.py +++ b/pype/unreal/__init__.py @@ -0,0 +1,45 @@ +import os +import logging + +from avalon import api as avalon +from pyblish import api as pyblish + +logger = logging.getLogger("pype.unreal") + +PARENT_DIR = os.path.dirname(__file__) +PACKAGE_DIR = os.path.dirname(PARENT_DIR) +PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins") + +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "unreal", "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "unreal", "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "unreal", "create") + + +def install(): + """Install Unreal configuration for Avalon.""" + print("-=" * 40) + logo = '''. +. + ____________ + / \\ __ \\ + \\ \\ \\/_\\ \\ + \\ \\ _____/ ______ + \\ \\ \\___// \\ \\ + \\ \\____\\ \\ \\_____\\ + \\/_____/ \\/______/ PYPE Club . +. +''' + print(logo) + print("installing Pype for Unreal ...") + print("-=" * 40) + logger.info("installing Pype for Unreal") + pyblish.register_plugin_path(str(PUBLISH_PATH)) + avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH)) + avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH)) + + +def uninstall(): + """Uninstall Unreal configuration for Avalon.""" + pyblish.deregister_plugin_path(str(PUBLISH_PATH)) + avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH)) + avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH)) diff --git a/pype/unreal/plugin.py b/pype/unreal/plugin.py new file mode 100644 index 0000000000..d403417ad1 --- /dev/null +++ b/pype/unreal/plugin.py @@ -0,0 +1,9 @@ +from avalon import api + + +class Creator(api.Creator): + pass + + +class Loader(api.Loader): + pass