diff --git a/client/ayon_maya/api/chasers/README.md b/client/ayon_maya/api/chasers/README.md new file mode 100644 index 00000000..4b26307f --- /dev/null +++ b/client/ayon_maya/api/chasers/README.md @@ -0,0 +1,14 @@ +# AYON Maya USD Chasers + +This folder contains AYON Maya USD python import and export chasers to be +registered on Maya startup. These chasers have the ability to influence how +USD data is imported and exported in Maya. + +For example, the Filter Properties export chaser allows to filter properties +in the exported USD file to only those that match by the specified pattern +using a SideFX Houdini style pattern matching. + +The chasers are registered in the `MayaHost.install` method on Maya launch. + +See also the [Maya USD Import Chaser documentation](https://github.com/Autodesk/maya-usd/blob/dev/lib/mayaUsd/commands/Readme.md#import-chasers) +and [Maya USD Export Chaser documentation](https://github.com/Autodesk/maya-usd/blob/dev/lib/mayaUsd/commands/Readme.md#export-chasers-advanced). \ No newline at end of file diff --git a/client/ayon_maya/api/chasers/export_filter_properties.py b/client/ayon_maya/api/chasers/export_filter_properties.py new file mode 100644 index 00000000..85e199d3 --- /dev/null +++ b/client/ayon_maya/api/chasers/export_filter_properties.py @@ -0,0 +1,138 @@ +import re +import fnmatch +import logging +from typing import List + +import mayaUsd.lib as mayaUsdLib +from pxr import Sdf + + +def log_errors(fn): + """Decorator to log errors on error""" + + def wrap(*args, **kwargs): + + try: + return fn(*args, **kwargs) + except Exception as exc: + logging.error(exc, exc_info=True) + raise + + return wrap + + +def remove_spec(spec: Sdf.Spec): + """Remove Sdf.Spec authored opinion.""" + if spec.expired: + return + + if isinstance(spec, Sdf.PrimSpec): + # PrimSpec + parent = spec.nameParent + if parent: + view = parent.nameChildren + else: + # Assume PrimSpec is root prim + view = spec.layer.rootPrims + del view[spec.name] + + elif isinstance(spec, Sdf.PropertySpec): + # Relationship and Attribute specs + del spec.owner.properties[spec.name] + + elif isinstance(spec, Sdf.VariantSetSpec): + # Owner is Sdf.PrimSpec (or can also be Sdf.VariantSpec) + del spec.owner.variantSets[spec.name] + + elif isinstance(spec, Sdf.VariantSpec): + # Owner is Sdf.VariantSetSpec + spec.owner.RemoveVariant(spec) + + else: + raise TypeError(f"Unsupported spec type: {spec}") + + +def remove_layer_specs(layer: Sdf.Layer, spec_paths: List[Sdf.Path]): + # Iterate in reverse so we iterate the highest paths + # first, so when removing a spec the children specs + # are already removed + for spec_path in reversed(spec_paths): + spec = layer.GetObjectAtPath(spec_path) + if not spec or spec.expired: + continue + remove_spec(spec) + + +def match_pattern(name: str, text_pattern: str) -> bool: + """SideFX Houdini like pattern matching""" + patterns = text_pattern.split(" ") + is_match = False + for pattern in patterns: + # * means any character + # ? means any single character + # [abc] means a, b, or c + pattern = pattern.strip(" ") + if not pattern: + continue + + excludes = pattern[0] == "^" + + # If name is already matched against earlier pattern in the text + # pattern, then we can skip the pattern if it is not an exclude pattern + if is_match and not excludes: + continue + + if excludes: + pattern = pattern[1:] + + regex = fnmatch.translate(pattern) + match = re.match(regex, name) + if match: + is_match = not excludes + return is_match + + + +class FilterPropertiesExportChaser(mayaUsdLib.ExportChaser): + """Remove property specs based on pattern""" + + name = "AYON_filterProperties" + + def __init__(self, factoryContext, *args, **kwargs): + super().__init__(factoryContext, *args, **kwargs) + self.log = logging.getLogger(self.__class__.__name__) + self.stage = factoryContext.GetStage() + self.job_args = factoryContext.GetJobArgs() + + @log_errors + def PostExport(self): + + chaser_args = self.job_args.allChaserArgs[self.name] + # strip all or use user-specified pattern + pattern = chaser_args.get("pattern", "*") + for layer in self.stage.GetLayerStack(): + + specs_to_remove = [] + + def find_attribute_specs_to_remove(path: Sdf.Path): + if not path.IsPropertyPath(): + return + + spec = layer.GetObjectAtPath(path) + if not spec: + return + + if not isinstance(spec, Sdf.PropertySpec): + return + + if not match_pattern(spec.name, pattern): + self.log.debug("Removing spec: %s", path) + specs_to_remove.append(path) + else: + self.log.debug("Keeping spec: %s", path) + + layer.Traverse("/", find_attribute_specs_to_remove) + + remove_layer_specs(layer, specs_to_remove) + + return True \ No newline at end of file diff --git a/client/ayon_maya/api/pipeline.py b/client/ayon_maya/api/pipeline.py index ad00bdcf..f436acb0 100644 --- a/client/ayon_maya/api/pipeline.py +++ b/client/ayon_maya/api/pipeline.py @@ -129,6 +129,8 @@ def install(self): ) register_event_callback("workfile.save.after", after_workfile_save) + self._register_maya_usd_chasers() + def open_workfile(self, filepath): return open_file(filepath) @@ -239,6 +241,25 @@ def _register_callbacks(self): self.log.info("Installed event handler _check_lock_file..") self.log.info("Installed event handler _before_close_maya..") + def _register_maya_usd_chasers(self): + """Register Maya USD chasers if Maya USD libraries are available.""" + + try: + import mayaUsd.lib # noqa + except ImportError: + # Do not register if Maya USD is not available + return + + self.log.info("Installing AYON Maya USD chasers..") + + from .chasers import export_filter_properties # noqa + + for export_chaser in [ + export_filter_properties.FilterPropertiesExportChaser + ]: + mayaUsd.lib.ExportChaser.Register(export_chaser, + export_chaser.name) + def _set_project(): """Sets the maya project to the current Session's work directory. diff --git a/client/ayon_maya/plugins/publish/collect_maya_usd_export_filter_properties.py b/client/ayon_maya/plugins/publish/collect_maya_usd_export_filter_properties.py new file mode 100644 index 00000000..9a20820b --- /dev/null +++ b/client/ayon_maya/plugins/publish/collect_maya_usd_export_filter_properties.py @@ -0,0 +1,50 @@ +import pyblish.api + +from ayon_core.lib import TextDef +from ayon_core.pipeline.publish import AYONPyblishPluginMixin +from ayon_maya.api import plugin + + +class CollectMayaUsdFilterProperties(plugin.MayaInstancePlugin, + AYONPyblishPluginMixin): + + order = pyblish.api.CollectorOrder + label = "Maya USD Export Chaser: Filter Properties" + families = ["mayaUsd"] + + default_filter = "" + + @classmethod + def get_attribute_defs(cls): + return [ + TextDef( + "filter_properties", + label="USD Filter Properties", + tooltip=( + "Filter USD properties using a pattern:\n" + "- Only include xforms: xformOp*\n" + "- All but xforms: * ^xformOp*\n" + "- All but mesh point data: * ^extent ^points " + "^faceVertex* ^primvars*\n\n" + "The pattern matching is very similar to SideFX Houdini's " + "Pattern Matching in Parameters." + ), + placeholder="* ^xformOp* ^points", + default=cls.default_filter + ) + ] + + def process(self, instance): + attr_values = self.get_attr_values_from_data(instance.data) + filter_pattern = attr_values.get("filter_properties") + if not filter_pattern: + return + + self.log.debug( + "Enabling USD filter properties chaser " + f"with pattern {filter_pattern}" + ) + instance.data.setdefault("chaser", []).append("AYON_filterProperties") + instance.data.setdefault("chaserArgs", []).append( + ("AYON_filterProperties", "pattern", filter_pattern) + ) diff --git a/client/ayon_maya/plugins/publish/extract_maya_usd.py b/client/ayon_maya/plugins/publish/extract_maya_usd.py index e8906fc4..45ebdfd5 100644 --- a/client/ayon_maya/plugins/publish/extract_maya_usd.py +++ b/client/ayon_maya/plugins/publish/extract_maya_usd.py @@ -165,6 +165,8 @@ def options(self): # TODO: Support more `mayaUSDExport` parameters return { + "chaser": (list, None), # optional list + "chaserArgs": (list, None), # optional list "defaultUSDFormat": str, "stripNamespaces": bool, "mergeTransformAndShape": bool, @@ -191,6 +193,8 @@ def default_options(self): # TODO: Support more `mayaUSDExport` parameters return { + "chaser": None, + "chaserArgs": None, "defaultUSDFormat": "usdc", "stripNamespaces": False, "mergeTransformAndShape": True, @@ -234,6 +238,11 @@ def parse_overrides(self, instance, options): options[key] = value + # Do not pass None values + for key, value in options.copy().items(): + if value is None: + del options[key] + return options def filter_members(self, members): @@ -300,7 +309,7 @@ def process(self, instance): options["filterTypes"] = ["constraint"] def parse_attr_str(attr_str): - """Return list of strings from `a,b,c,,d` to `[a, b, c, d]`. + """Return list of strings from `a,b,c,d` to `[a, b, c, d]`. Args: attr_str (str): Concatenated attributes by comma diff --git a/server/settings/publishers.py b/server/settings/publishers.py index 49067f4d..fde7d841 100644 --- a/server/settings/publishers.py +++ b/server/settings/publishers.py @@ -157,6 +157,24 @@ class CollectGLTFModel(BaseSettingsModel): enabled: bool = SettingsField(title="CollectGLTF") +class CollectMayaUsdFilterPropertiesModel(BaseSettingsModel): + enabled: bool = SettingsField(title="Maya USD Export Chaser: Filter Properties") + default_filter: str = SettingsField( + title="Default Filter", + description=( + "Set the default filter for USD properties to export. It uses" + " [SideFX Houdini Pattern Matching in Parameters]" + "(https://www.sidefx.com/docs/houdini/network/patterns.html)." + "\nSome examples would include:\n" + "- Only include xforms: `xformOp*`\n" + "- Everything but xforms: `* ^xformOp*`\n" + "- Everything but mesh point data: `* ^extent ^points" + " ^faceVertexCounts ^faceVertexIndices ^primvars*`" + ), + default="" + ) + + class ValidateFrameRangeModel(BaseSettingsModel): enabled: bool = SettingsField(title="ValidateFrameRange") optional: bool = SettingsField(title="Optional") @@ -620,6 +638,12 @@ class PublishersModel(BaseSettingsModel): default_factory=CollectGLTFModel, title="Collect Assets for GLB/GLTF export" ) + CollectMayaUsdFilterProperties: CollectMayaUsdFilterPropertiesModel = ( + SettingsField( + default_factory=CollectMayaUsdFilterPropertiesModel, + title="Maya USD Export Chaser: Filter Properties" + ) + ) ValidateInstanceInContext: BasicValidateModel = SettingsField( default_factory=BasicValidateModel, title="Validate Instance In Context", @@ -1083,6 +1107,10 @@ class PublishersModel(BaseSettingsModel): "CollectGLTF": { "enabled": False }, + "CollectMayaUsdFilterProperties": { + "enabled": False, + "default_filter": "" + }, "ValidateInstanceInContext": { "enabled": True, "optional": True,