diff --git a/openpype/hosts/max/plugins/create/create_tycache.py b/openpype/hosts/max/plugins/create/create_tycache.py new file mode 100644 index 0000000000..92d12e012f --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_tycache.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating TyCache.""" +from openpype.hosts.max.api import plugin + + +class CreateTyCache(plugin.MaxCreator): + """Creator plugin for TyCache.""" + identifier = "io.openpype.creators.max.tycache" + label = "TyCache" + family = "tycache" + icon = "gear" diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py new file mode 100644 index 0000000000..41ea267c3d --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -0,0 +1,64 @@ +import os +from openpype.hosts.max.api import lib, maintained_selection +from openpype.hosts.max.api.lib import ( + unique_namespace, + +) +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object, + update_custom_attribute_data +) +from openpype.pipeline import get_representation_path, load + + +class TyCacheLoader(load.LoaderPlugin): + """TyCache Loader.""" + + families = ["tycache"] + representations = ["tyc"] + order = -8 + icon = "code-fork" + color = "green" + + def load(self, context, name=None, namespace=None, data=None): + """Load tyCache""" + from pymxs import runtime as rt + filepath = os.path.normpath(self.filepath_from_context(context)) + obj = rt.tyCache() + obj.filename = filepath + + namespace = unique_namespace( + name + "_", + suffix="_", + ) + obj.name = f"{namespace}:{obj.name}" + + return containerise( + name, [obj], context, + namespace, loader=self.__class__.__name__) + + def update(self, container, representation): + """update the container""" + from pymxs import runtime as rt + + path = get_representation_path(representation) + node = rt.GetNodeByName(container["instance_node"]) + node_list = get_previous_loaded_object(node) + update_custom_attribute_data(node, node_list) + with maintained_selection(): + for tyc in node_list: + tyc.filename = path + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + """remove the container""" + from pymxs import runtime as rt + + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py new file mode 100644 index 0000000000..0351ca45c5 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py @@ -0,0 +1,76 @@ +import pyblish.api + +from openpype.lib import EnumDef, TextDef +from openpype.pipeline.publish import OpenPypePyblishPluginMixin + + +class CollectTyCacheData(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Collect Channel Attributes for TyCache Export""" + + order = pyblish.api.CollectorOrder + 0.02 + label = "Collect tyCache attribute Data" + hosts = ['max'] + families = ["tycache"] + + def process(self, instance): + attr_values = self.get_attr_values_from_data(instance.data) + attributes = {} + for attr_key in attr_values.get("tycacheAttributes", []): + attributes[attr_key] = True + + for key in ["tycacheLayer", "tycacheObjectName"]: + attributes[key] = attr_values.get(key, "") + + # Collect the selected channel data before exporting + instance.data["tyc_attrs"] = attributes + self.log.debug( + f"Found tycache attributes: {attributes}" + ) + + @classmethod + def get_attribute_defs(cls): + # TODO: Support the attributes with maxObject array + tyc_attr_enum = ["tycacheChanAge", "tycacheChanGroups", + "tycacheChanPos", "tycacheChanRot", + "tycacheChanScale", "tycacheChanVel", + "tycacheChanSpin", "tycacheChanShape", + "tycacheChanMatID", "tycacheChanMapping", + "tycacheChanMaterials", "tycacheChanCustomFloat" + "tycacheChanCustomVector", "tycacheChanCustomTM", + "tycacheChanPhysX", "tycacheMeshBackup", + "tycacheCreateObject", + "tycacheCreateObjectIfNotCreated", + "tycacheAdditionalCloth", + "tycacheAdditionalSkin", + "tycacheAdditionalSkinID", + "tycacheAdditionalSkinIDValue", + "tycacheAdditionalTerrain", + "tycacheAdditionalVDB", + "tycacheAdditionalSplinePaths", + "tycacheAdditionalGeo", + "tycacheAdditionalGeoActivateModifiers", + "tycacheSplines", + "tycacheSplinesAdditionalSplines" + ] + tyc_default_attrs = ["tycacheChanGroups", "tycacheChanPos", + "tycacheChanRot", "tycacheChanScale", + "tycacheChanVel", "tycacheChanShape", + "tycacheChanMatID", "tycacheChanMapping", + "tycacheChanMaterials", + "tycacheCreateObjectIfNotCreated"] + return [ + EnumDef("tycacheAttributes", + tyc_attr_enum, + default=tyc_default_attrs, + multiselection=True, + label="TyCache Attributes"), + TextDef("tycacheLayer", + label="TyCache Layer", + tooltip="Name of tycache layer", + default="$(tyFlowLayer)"), + TextDef("tycacheObjectName", + label="TyCache Object Name", + tooltip="TyCache Object Name", + default="$(tyFlowName)_tyCache") + ] diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py new file mode 100644 index 0000000000..9bfe74f679 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -0,0 +1,157 @@ +import os + +import pyblish.api +from pymxs import runtime as rt + +from openpype.hosts.max.api import maintained_selection +from openpype.pipeline import publish + + +class ExtractTyCache(publish.Extractor): + """Extract tycache format with tyFlow operators. + Notes: + - TyCache only works for TyFlow Pro Plugin. + + Methods: + self.get_export_particles_job_args(): sets up all job arguments + for attributes to be exported in MAXscript + + self.get_operators(): get the export_particle operator + + self.get_files(): get the files with tyFlow naming convention + before publishing + """ + + order = pyblish.api.ExtractorOrder - 0.2 + label = "Extract TyCache" + hosts = ["max"] + families = ["tycache"] + + def process(self, instance): + # TODO: let user decide the param + start = int(instance.context.data["frameStart"]) + end = int(instance.context.data.get("frameEnd")) + self.log.debug("Extracting Tycache...") + + stagingdir = self.staging_dir(instance) + filename = "{name}.tyc".format(**instance.data) + path = os.path.join(stagingdir, filename) + filenames = self.get_files(instance, start, end) + additional_attributes = instance.data.get("tyc_attrs", {}) + + with maintained_selection(): + job_args = self.get_export_particles_job_args( + instance.data["members"], + start, end, path, + additional_attributes) + for job in job_args: + rt.Execute(job) + representations = instance.data.setdefault("representations", []) + representation = { + 'name': 'tyc', + 'ext': 'tyc', + 'files': filenames if len(filenames) > 1 else filenames[0], + "stagingDir": stagingdir, + } + representations.append(representation) + + # Get the tyMesh filename for extraction + mesh_filename = f"{instance.name}__tyMesh.tyc" + mesh_repres = { + 'name': 'tyMesh', + 'ext': 'tyc', + 'files': mesh_filename, + "stagingDir": stagingdir, + "outputName": '__tyMesh' + } + representations.append(mesh_repres) + self.log.debug(f"Extracted instance '{instance.name}' to: {filenames}") + + def get_files(self, instance, start_frame, end_frame): + """Get file names for tyFlow in tyCache format. + + Set the filenames accordingly to the tyCache file + naming extension(.tyc) for the publishing purpose + + Actual File Output from tyFlow in tyCache format: + __tyPart_.tyc + + e.g. tycacheMain__tyPart_00000.tyc + + Args: + instance (pyblish.api.Instance): instance. + start_frame (int): Start frame. + end_frame (int): End frame. + + Returns: + filenames(list): list of filenames + + """ + filenames = [] + for frame in range(int(start_frame), int(end_frame) + 1): + filename = f"{instance.name}__tyPart_{frame:05}.tyc" + filenames.append(filename) + return filenames + + def get_export_particles_job_args(self, members, start, end, + filepath, additional_attributes): + """Sets up all job arguments for attributes. + + Those attributes are to be exported in MAX Script. + + Args: + members (list): Member nodes of the instance. + start (int): Start frame. + end (int): End frame. + filepath (str): Output path of the TyCache file. + additional_attributes (dict): channel attributes data + which needed to be exported + + Returns: + list of arguments for MAX Script. + + """ + settings = { + "exportMode": 2, + "frameStart": start, + "frameEnd": end, + "tyCacheFilename": filepath.replace("\\", "/") + } + settings.update(additional_attributes) + + job_args = [] + for operator in self.get_operators(members): + for key, value in settings.items(): + if isinstance(value, str): + # embed in quotes + value = f'"{value}"' + + job_args.append(f"{operator}.{key}={value}") + job_args.append(f"{operator}.exportTyCache()") + return job_args + + @staticmethod + def get_operators(members): + """Get Export Particles Operator. + + Args: + members (list): Instance members. + + Returns: + list of particle operators + + """ + opt_list = [] + for member in members: + obj = member.baseobject + # TODO: see if it can use maxscript instead + anim_names = rt.GetSubAnimNames(obj) + for anim_name in anim_names: + sub_anim = rt.GetSubAnim(obj, anim_name) + boolean = rt.IsProperty(sub_anim, "Export_Particles") + if boolean: + event_name = sub_anim.Name + opt = f"${member.Name}.{event_name}.export_particles" + opt_list.append(opt) + + return opt_list diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py index a336cbd80c..54d6d0f11a 100644 --- a/openpype/hosts/max/plugins/publish/validate_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -14,29 +14,16 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): def process(self, instance): """ Notes: - - 1. Validate the container only include tyFlow objects - 2. Validate if tyFlow operator Export Particle exists - 3. Validate if the export mode of Export Particle is at PRT format - 4. Validate the partition count and range set as default value + 1. Validate if the export mode of Export Particle is at PRT format + 2. Validate the partition count and range set as default value Partition Count : 100 Partition Range : 1 to 1 - 5. Validate if the custom attribute(s) exist as parameter(s) + 3. Validate if the custom attribute(s) exist as parameter(s) of export_particle operator """ report = [] - invalid_object = self.get_tyflow_object(instance) - if invalid_object: - report.append(f"Non tyFlow object found: {invalid_object}") - - invalid_operator = self.get_tyflow_operator(instance) - if invalid_operator: - report.append(( - "tyFlow ExportParticle operator not " - f"found: {invalid_operator}")) - if self.validate_export_mode(instance): report.append("The export mode is not at PRT") @@ -52,46 +39,6 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): if report: raise PublishValidationError(f"{report}") - def get_tyflow_object(self, instance): - invalid = [] - container = instance.data["instance_node"] - self.log.info(f"Validating tyFlow container for {container}") - - selection_list = instance.data["members"] - for sel in selection_list: - sel_tmp = str(sel) - if rt.ClassOf(sel) in [rt.tyFlow, - rt.Editable_Mesh]: - if "tyFlow" not in sel_tmp: - invalid.append(sel) - else: - invalid.append(sel) - - return invalid - - def get_tyflow_operator(self, instance): - invalid = [] - container = instance.data["instance_node"] - self.log.info(f"Validating tyFlow object for {container}") - selection_list = instance.data["members"] - bool_list = [] - for sel in selection_list: - obj = sel.baseobject - anim_names = rt.GetSubAnimNames(obj) - for anim_name in anim_names: - # get all the names of the related tyFlow nodes - sub_anim = rt.GetSubAnim(obj, anim_name) - # check if there is export particle operator - boolean = rt.IsProperty(sub_anim, "Export_Particles") - bool_list.append(str(boolean)) - # if the export_particles property is not there - # it means there is not a "Export Particle" operator - if "True" not in bool_list: - self.log.error("Operator 'Export Particles' not found!") - invalid.append(sel) - - return invalid - def validate_custom_attribute(self, instance): invalid = [] container = instance.data["instance_node"] diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py new file mode 100644 index 0000000000..c0f29422ec --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -0,0 +1,88 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError +from pymxs import runtime as rt + + +class ValidateTyFlowData(pyblish.api.InstancePlugin): + """Validate TyFlow plugins or relevant operators are set correctly.""" + + order = pyblish.api.ValidatorOrder + families = ["pointcloud", "tycache"] + hosts = ["max"] + label = "TyFlow Data" + + def process(self, instance): + """ + Notes: + 1. Validate the container only include tyFlow objects + 2. Validate if tyFlow operator Export Particle exists + + """ + + invalid_object = self.get_tyflow_object(instance) + if invalid_object: + self.log.error(f"Non tyFlow object found: {invalid_object}") + + invalid_operator = self.get_tyflow_operator(instance) + if invalid_operator: + self.log.error( + "Operator 'Export Particles' not found in tyFlow editor.") + if invalid_object or invalid_operator: + raise PublishValidationError( + "issues occurred", + description="Container should only include tyFlow object " + "and tyflow operator 'Export Particle' should be in " + "the tyFlow editor.") + + def get_tyflow_object(self, instance): + """Get the nodes which are not tyFlow object(s) + and editable mesh(es) + + Args: + instance (pyblish.api.Instance): instance + + Returns: + list: invalid nodes which are not tyFlow + object(s) and editable mesh(es). + """ + container = instance.data["instance_node"] + self.log.debug(f"Validating tyFlow container for {container}") + + allowed_classes = [rt.tyFlow, rt.Editable_Mesh] + return [ + member for member in instance.data["members"] + if rt.ClassOf(member) not in allowed_classes + ] + + def get_tyflow_operator(self, instance): + """Check if the Export Particle Operators in the node + connections. + + Args: + instance (str): instance node + + Returns: + invalid(list): list of invalid nodes which do + not consist of Export Particle Operators as parts + of the node connections + """ + invalid = [] + members = instance.data["members"] + for member in members: + obj = member.baseobject + + # There must be at least one animation with export + # particles enabled + has_export_particles = False + anim_names = rt.GetSubAnimNames(obj) + for anim_name in anim_names: + # get name of the related tyFlow node + sub_anim = rt.GetSubAnim(obj, anim_name) + # check if there is export particle operator + if rt.IsProperty(sub_anim, "Export_Particles"): + has_export_particles = True + break + + if not has_export_particles: + invalid.append(member) + return invalid diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index 57a612c5ae..cfb4d63c1b 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -63,7 +63,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "staticMesh", "skeletalMesh", "xgen", - "yeticacheUE" + "yeticacheUE", + "tycache" ] def process(self, instance): diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index f2ae470d40..5bb51a3049 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -141,7 +141,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "online", "uasset", "blendScene", - "yeticacheUE" + "yeticacheUE", + "tycache" ] default_template_name = "publish" diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index e5e535bf19..5766a09100 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -53,6 +53,11 @@ "file": "{originalBasename}<.{@frame}><_{udim}>.{ext}", "path": "{@folder}/{@file}" }, + "tycache": { + "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}", + "file": "{originalBasename}.{ext}", + "path": "{@folder}/{@file}" + }, "source": { "folder": "{root[work]}/{originalDirname}", "file": "{originalBasename}.{ext}", @@ -66,6 +71,7 @@ "simpleUnrealTextureHero": "Simple Unreal Texture - Hero", "simpleUnrealTexture": "Simple Unreal Texture", "online": "online", + "tycache": "tycache", "source": "source", "transient": "transient" } diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 06a595d1c5..9ccf5cae05 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -546,6 +546,17 @@ "task_types": [], "task_names": [], "template_name": "online" + }, + { + "families": [ + "tycache" + ], + "hosts": [ + "max" + ], + "task_types": [], + "task_names": [], + "template_name": "tycache" } ], "hero_template_name_profiles": [ diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index 7befc795e4..d7c7b367b7 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -487,6 +487,17 @@ DEFAULT_TOOLS_VALUES = { "task_types": [], "task_names": [], "template_name": "publish_online" + }, + { + "families": [ + "tycache" + ], + "hosts": [ + "max" + ], + "task_types": [], + "task_names": [], + "template_name": "publish_tycache" } ], "hero_template_name_profiles": [ diff --git a/server_addon/core/server/version.py b/server_addon/core/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/core/server/version.py +++ b/server_addon/core/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3"