From 21d62667d64a3bfbc1d81746622f93bbaa715587 Mon Sep 17 00:00:00 2001 From: Ahmed Hindy Date: Sun, 31 May 2026 13:26:25 +0300 Subject: [PATCH] Add Maya material integration --- scripts/validate_maya2024_runtime.py | 19 +- src/materials_processor/dcc/maya/adapters.py | 79 ++++ src/materials_processor/dcc/maya/recreator.py | 209 +++++++++++ src/materials_processor/dcc/maya/runtime.py | 138 +++++++ src/materials_processor/dcc/maya/traverser.py | 346 ++++++++++++++++++ src/materials_processor/mappings.py | 97 ++++- tests/test_maya_runtime.py | 34 ++ tests/test_maya_support.py | 295 +++++++++++++++ tests/test_public_imports.py | 3 + 9 files changed, 1218 insertions(+), 2 deletions(-) create mode 100644 src/materials_processor/dcc/maya/adapters.py create mode 100644 src/materials_processor/dcc/maya/recreator.py create mode 100644 src/materials_processor/dcc/maya/traverser.py create mode 100644 tests/test_maya_support.py diff --git a/scripts/validate_maya2024_runtime.py b/scripts/validate_maya2024_runtime.py index 4031562..8ab36f7 100644 --- a/scripts/validate_maya2024_runtime.py +++ b/scripts/validate_maya2024_runtime.py @@ -5,7 +5,11 @@ import argparse from pathlib import Path -from materials_processor.dcc.maya.runtime import resolve_maya_runtime, validate_maya_runtime +from materials_processor.dcc.maya.runtime import ( + resolve_maya_runtime, + validate_maya_material_smoke, + validate_maya_runtime, +) def main() -> int: @@ -14,11 +18,24 @@ def main() -> int: parser.add_argument("--root", help="Maya 2024 installation root.") parser.add_argument("--src", default=str(Path(__file__).resolve().parents[1] / "src"), help="Package src path.") parser.add_argument("--timeout", default=120, type=int, help="Validation timeout in seconds.") + parser.add_argument( + "--smoke-material", + action="store_true", + help="Also run a tiny traversal/standardization/recreation smoke test inside Maya.", + ) args = parser.parse_args() runtime = resolve_maya_runtime(version="2024", root=args.root) validated = validate_maya_runtime(runtime=runtime, package_src=args.src, timeout=args.timeout) print(f"Maya {validated.version} runtime OK: {validated.mayapy_exe} (API {validated.api_version})") + + if args.smoke_material: + result = validate_maya_material_smoke(runtime=validated, package_src=args.src, timeout=args.timeout) + print( + "Maya material smoke OK: " + f"{result['node_count']} standardized node(s), " + f"{result['output_count']} output connection(s)" + ) return 0 diff --git a/src/materials_processor/dcc/maya/adapters.py b/src/materials_processor/dcc/maya/adapters.py new file mode 100644 index 0000000..fefcd37 --- /dev/null +++ b/src/materials_processor/dcc/maya/adapters.py @@ -0,0 +1,79 @@ +"""Core adapter implementations for Maya materials.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from materials_processor.core.graph import MaterialData, MaterialGraph +from materials_processor.dcc.maya.recreator import MayaNodeRecreator +from materials_processor.dcc.maya.traverser import MayaNodeTraverser +from materials_processor.standardizer import NodeStandardizer + + +class MayaMaterialReader: + """Read Maya shading networks into the neutral material graph.""" + + def read(self, native_material: Any) -> MaterialGraph: + """Read a Maya shader or shadingEngine node into a standardized graph. + + Args: + native_material: Maya shader or shadingEngine node name. + + Returns: + Standardized material graph. + """ + material_node = str(native_material) + traversed_nodes, output_nodes = MayaNodeTraverser(material_node).run() + nodeinfo_list, output_connections = NodeStandardizer( + traversed_nodes_dict=traversed_nodes, + output_nodes_dict=output_nodes, + material_type="maya", + source_type="maya_nodes", + ).run() + + material_name = next(iter(output_nodes.values()))["node_name"] if output_nodes else material_node + return MaterialData( + material_name=material_name, + material_path=f"/maya/{material_name}", + nodeinfo_list=nodeinfo_list, + output_connections=output_connections, + ) + + +class MayaMaterialWriter: + """Write neutral material graphs into Maya shading networks.""" + + def write(self, graph: MaterialGraph, target_context: Any = None) -> str: + """Write a material graph into Maya. + + Args: + graph: Standardized material graph to recreate. + target_context: Existing shadingEngine node, a mapping containing + ``shading_engine`` or ``material_name``, a material name string, + or ``None`` to derive the target name from ``graph``. + + Returns: + Created or reused Maya shadingEngine node name. + + Raises: + RuntimeError: If recreation fails. + """ + material_name = self._material_name(graph, target_context) + shading_engine = MayaNodeRecreator( + nodeinfo_list=graph.nodeinfo_list, + output_connections=graph.output_connections, + target_context=target_context, + material_name=material_name, + ).run() + if not shading_engine: + raise RuntimeError(f"Failed to recreate Maya material '{material_name}'.") + return shading_engine + + @staticmethod + def _material_name(graph: MaterialGraph, target_context: Any) -> str: + if isinstance(target_context, Mapping): + return str(target_context.get("material_name") or target_context.get("name") or graph.material_name) + if isinstance(target_context, str): + return target_context + return graph.material_name diff --git a/src/materials_processor/dcc/maya/recreator.py b/src/materials_processor/dcc/maya/recreator.py new file mode 100644 index 0000000..fc1d52d --- /dev/null +++ b/src/materials_processor/dcc/maya/recreator.py @@ -0,0 +1,209 @@ +"""Recreate neutral material graphs as Maya shading networks.""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +from materials_processor.core.graph import NodeInfo +from materials_processor.mappings import REGULAR_PARAM_NAMES_TO_GENERIC, convert_generic + +logger = logging.getLogger(__name__) + +try: + import maya.cmds as cmds +except ImportError: + logger.warning("materials_processor running outside of Maya.") + cmds = None + + +def _require_cmds(): + if cmds is None: + raise RuntimeError("Maya commands are only available inside Maya or mayapy.") + return cmds + + +def _node_path(material_name: str, node_name: str) -> str: + return f"/maya/{material_name}/{node_name}" + + +class MayaNodeRecreator: + """Recreate a standardized material graph as Maya DG shader nodes.""" + + def __init__(self, nodeinfo_list, output_connections, target_context=None, material_name=None): + """Initialize the Maya recreator. + + Args: + nodeinfo_list: Standardized node descriptions. + output_connections: Standardized output connection mapping. + target_context: Existing shadingEngine node, material name string, + mapping with ``shading_engine`` or ``material_name``, or ``None``. + material_name: Optional fallback material name. + """ + self.nodeinfo_list = nodeinfo_list + self.orig_output_connections = output_connections + self.target_context = target_context + self.material_name = material_name or self._material_name_from_context(target_context) or "convertedMaterial" + self.cmds = _require_cmds() + self.old_new_node_map = {} + self.created_shading_engine = None + + @staticmethod + def _material_name_from_context(target_context: Any) -> str | None: + if isinstance(target_context, Mapping): + return target_context.get("material_name") or target_context.get("name") + if isinstance(target_context, str): + return target_context + return None + + def _target_shading_engine(self): + if isinstance(self.target_context, Mapping): + shading_engine = self.target_context.get("shading_engine") + if shading_engine and self.cmds.objExists(shading_engine): + return shading_engine + + if isinstance(self.target_context, str) and self.cmds.objExists(self.target_context): + if self.cmds.nodeType(self.target_context) == "shadingEngine": + return self.target_context + + shading_engine_name = f"{self.material_name}SG" + return self.cmds.sets( + renderable=True, + noSurfaceShader=True, + empty=True, + name=shading_engine_name, + ) + + @staticmethod + def _maya_node_type(node_type: str) -> str: + if not node_type: + return "network" + return convert_generic(node_type, "maya", profile="maya_nodes") or "network" + + def _create_node(self, node_info: NodeInfo): + maya_type = self._maya_node_type(node_info.node_type) + node = self.cmds.createNode(maya_type, name=node_info.node_name) + self._apply_parameters(node, node_info.parameters) + self.old_new_node_map[node_info.node_path] = { + "node_name": node, + "node_path": _node_path(self.material_name, node), + } + return node + + def _attr_exists(self, node: str, attr: str) -> bool: + try: + return bool(self.cmds.attributeQuery(attr, node=node, exists=True)) + except Exception: + return bool(self.cmds.objExists(f"{node}.{attr}")) + + def _set_attr(self, node: str, attr: str, value): + if value is None or not self._attr_exists(node, attr): + return + + plug = f"{node}.{attr}" + try: + if isinstance(value, str): + self.cmds.setAttr(plug, value, type="string") + elif isinstance(value, (list, tuple)): + self.cmds.setAttr(plug, *value) + else: + self.cmds.setAttr(plug, value) + except Exception as exc: + logger.warning("Failed to set Maya attribute '%s' on node '%s': %s", attr, node, exc) + + def _apply_parameters(self, node: str, parameters): + if not parameters: + return + + node_type = self.cmds.nodeType(node) + std_parm_map = REGULAR_PARAM_NAMES_TO_GENERIC.get(node_type, {}) + for param in parameters: + if param.direction != "input" or not param.generic_name: + continue + maya_names = [key for key, val in std_parm_map.items() if val == param.generic_name] + if not maya_names: + continue + self._set_attr(node, maya_names[0], param.value) + + def _create_nodes_recursive(self, nested_nodes_info, processed_nodes=None): + if processed_nodes is None: + processed_nodes = set() + + for node_info in nested_nodes_info: + if node_info.node_path in processed_nodes: + continue + if node_info.node_type != "GENERIC::output_node": + self._create_node(node_info) + processed_nodes.add(node_info.node_path) + self._create_nodes_recursive(node_info.children_list, processed_nodes) + + def _connection_attr(self, node: str, generic_attr: str) -> str: + node_type = self.cmds.nodeType(node) + std_parm_map = REGULAR_PARAM_NAMES_TO_GENERIC.get(node_type, {}) + attr_names = [key for key, val in std_parm_map.items() if val == generic_attr] + return attr_names[0] if attr_names else generic_attr + + def _connect_pair(self, src_node: str, dest_node: str, src_attr: str, dest_attr: str) -> bool: + src_plug = f"{src_node}.{src_attr}" + dest_plug = f"{dest_node}.{dest_attr}" + if not (self.cmds.objExists(src_plug) and self.cmds.objExists(dest_plug)): + logger.warning("Skipping missing Maya connection plug: %s -> %s", src_plug, dest_plug) + return False + try: + self.cmds.connectAttr(src_plug, dest_plug, force=True) + return True + except Exception as exc: + logger.warning("Failed to connect Maya plugs '%s' -> '%s': %s", src_plug, dest_plug, exc) + return False + + def _connect_nodes_recursive(self, nested_nodes_info, processed_connections=None): + if processed_connections is None: + processed_connections = set() + + for node_info in nested_nodes_info: + for connection in node_info.connection_info.values(): + connection_key = f"{connection.input.node_path}->{connection.output.node_path}:{connection.output.parm_name}" + if connection_key in processed_connections: + continue + + src_info = self.old_new_node_map.get(connection.input.node_path) + dest_info = self.old_new_node_map.get(connection.output.node_path) + if src_info and dest_info: + src_node = src_info["node_name"] + dest_node = dest_info["node_name"] + src_attr = self._connection_attr(src_node, connection.input.parm_name) + dest_attr = self._connection_attr(dest_node, connection.output.parm_name) + self._connect_pair(src_node, dest_node, src_attr, dest_attr) + + processed_connections.add(connection_key) + + self._connect_nodes_recursive(node_info.children_list, processed_connections) + + def _register_output_node(self, shading_engine: str): + for output_connection in self.orig_output_connections.values(): + self.old_new_node_map[output_connection.node_path] = { + "node_name": shading_engine, + "node_path": _node_path(self.material_name, shading_engine), + "is_output": True, + } + + def _wire_outputs(self): + for generic_output_type, output_connection in self.orig_output_connections.items(): + if generic_output_type != "GENERIC::output_surface": + continue + src_info = self.old_new_node_map.get(output_connection.connected_node_path) + if not src_info: + continue + src_node = src_info["node_name"] + src_attr = self._connection_attr(src_node, output_connection.connected_output_name) + self._connect_pair(src_node, self.created_shading_engine, src_attr, "surfaceShader") + + def run(self): + """Recreate Maya shader nodes and connect them to a shadingEngine.""" + self.created_shading_engine = self._target_shading_engine() + self._register_output_node(self.created_shading_engine) + self._create_nodes_recursive(self.nodeinfo_list) + self._connect_nodes_recursive(self.nodeinfo_list) + self._wire_outputs() + return self.created_shading_engine diff --git a/src/materials_processor/dcc/maya/runtime.py b/src/materials_processor/dcc/maya/runtime.py index 52a5679..bae2fa4 100644 --- a/src/materials_processor/dcc/maya/runtime.py +++ b/src/materials_processor/dcc/maya/runtime.py @@ -15,6 +15,7 @@ } VALIDATION_RESULT_PREFIX = "MATERIALS_PROCESSOR_MAYA_RUNTIME=" +MATERIAL_SMOKE_RESULT_PREFIX = "MATERIALS_PROCESSOR_MAYA_MATERIAL_SMOKE=" @dataclass(frozen=True) @@ -107,6 +108,78 @@ def _validation_code() -> str: """.strip() +def _material_smoke_code() -> str: + return f""" +import json + +import maya.cmds as cmds +import maya.standalone + +maya.standalone.initialize(name="python") +try: + from materials_processor.core.conversion import ConversionService + from materials_processor.dcc.maya.adapters import MayaMaterialReader, MayaMaterialWriter + + shader = cmds.shadingNode("standardSurface", asShader=True, name="materials_processor_smoke_surface") + shading_engine = cmds.sets( + renderable=True, + noSurfaceShader=True, + empty=True, + name="materials_processor_smoke_SG", + ) + texture = cmds.shadingNode("file", asTexture=True, name="materials_processor_smoke_file") + placement = cmds.shadingNode("place2dTexture", asUtility=True, name="materials_processor_smoke_place2d") + bump = cmds.shadingNode("bump2d", asUtility=True, name="materials_processor_smoke_bump") + + cmds.setAttr(shader + ".base", 1.0) + cmds.setAttr(shader + ".baseColor", 0.8, 0.2, 0.1, type="double3") + cmds.setAttr(texture + ".fileTextureName", "C:/textures/maya_smoke_basecolor.png", type="string") + cmds.connectAttr(shader + ".outColor", shading_engine + ".surfaceShader", force=True) + cmds.connectAttr(texture + ".outColor", shader + ".baseColor", force=True) + cmds.connectAttr(texture + ".outAlpha", bump + ".bumpValue", force=True) + cmds.connectAttr(bump + ".outNormal", shader + ".normalCamera", force=True) + cmds.connectAttr(placement + ".outUV", texture + ".uvCoord", force=True) + + reader = MayaMaterialReader() + graph = reader.read(shading_engine) + converted_shading_engine = ConversionService(reader, MayaMaterialWriter()).convert( + shading_engine, + {{"material_name": "materials_processor_smoke_target"}}, + ) + target_connections = cmds.listConnections( + converted_shading_engine + ".surfaceShader", + source=True, + destination=False, + plugs=True, + ) or [] + target_nodes = set([converted_shading_engine]) + pending = [plug.split(".", 1)[0] for plug in target_connections] + while pending: + node = pending.pop() + if node in target_nodes: + continue + target_nodes.add(node) + upstream = cmds.listConnections(node, source=True, destination=False) or [] + pending.extend(upstream) + target_node_types = sorted(set(cmds.nodeType(node) for node in target_nodes)) + + result = {{ + "converted_shading_engine": converted_shading_engine, + "material_name": graph.material_name, + "node_count": len(graph.nodeinfo_list), + "output_count": len(graph.output_connections), + "recreated": bool(target_connections), + "target_node_types": target_node_types, + }} + print({MATERIAL_SMOKE_RESULT_PREFIX!r} + json.dumps(result, sort_keys=True)) +finally: + try: + maya.standalone.uninitialize() + except Exception: + pass +""".strip() + + def _with_pythonpath(env: dict[str, str], package_src: Path) -> dict[str, str]: current_pythonpath = env.get("PYTHONPATH") src_path = str(package_src.resolve()) @@ -121,6 +194,13 @@ def _parse_validation_output(stdout: str) -> dict[str, str]: raise RuntimeError(f"Maya validation did not produce a runtime result. stdout:\n{stdout}") +def _parse_prefixed_output(stdout: str, stderr: str, prefix: str, label: str) -> dict: + for line in stdout.splitlines(): + if line.startswith(prefix): + return json.loads(line[len(prefix):]) + raise RuntimeError(f"Maya {label} did not produce a runtime result.\nstdout:\n{stdout}\nstderr:\n{stderr}") + + def _default_package_src() -> Path: return Path(__file__).resolve().parents[3] @@ -185,3 +265,61 @@ def validate_maya_runtime( version=result["version"], api_version=result["api_version"], ) + + +def _run_mayapy(runtime: MayaRuntime, code: str, package_src: Path, timeout: int) -> subprocess.CompletedProcess: + env = _with_pythonpath(os.environ.copy(), package_src) + with tempfile.TemporaryDirectory(prefix="materials_processor_maya_app_") as maya_app_dir: + env["MAYA_APP_DIR"] = maya_app_dir + try: + return subprocess.run( + [str(runtime.mayapy_exe), "-c", code], + check=False, + capture_output=True, + env=env, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"Maya validation timed out after {timeout} seconds.") from exc + + +def validate_maya_material_smoke( + runtime: MayaRuntime | None = None, + package_src: str | os.PathLike[str] | None = None, + timeout: int = 120, +) -> dict: + """Validate Maya traversal/standardization/recreation in mayapy. + + Args: + runtime: Optional pre-resolved Maya runtime. + package_src: Source directory to prepend to ``PYTHONPATH``. + timeout: Maximum seconds to wait for ``mayapy``. + + Returns: + Summary data from the material smoke validation. + + Raises: + RuntimeError: If Maya fails, times out, or the material cannot be + traversed and recreated. + """ + runtime = runtime or resolve_maya_runtime() + package_src_path = Path(package_src) if package_src is not None else _default_package_src() + completed = _run_mayapy(runtime, _material_smoke_code(), package_src_path, timeout) + if completed.returncode != 0: + raise RuntimeError( + "Maya material smoke validation failed with exit code " + f"{completed.returncode}.\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + + result = _parse_prefixed_output( + completed.stdout, + completed.stderr, + MATERIAL_SMOKE_RESULT_PREFIX, + "material smoke validation", + ) + if not result["recreated"]: + raise RuntimeError(f"Maya material smoke validation did not recreate the material: {result}") + if "standardSurface" not in result["target_node_types"]: + raise RuntimeError(f"Maya material smoke validation did not create a standardSurface: {result}") + return result diff --git a/src/materials_processor/dcc/maya/traverser.py b/src/materials_processor/dcc/maya/traverser.py new file mode 100644 index 0000000..a5f35b4 --- /dev/null +++ b/src/materials_processor/dcc/maya/traverser.py @@ -0,0 +1,346 @@ +"""Traverse Maya shading networks into dictionaries for standardization.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +try: + import maya.cmds as cmds +except ImportError: + logger.warning("materials_processor running outside of Maya.") + cmds = None + + +MAYA_INPUT_ATTRS = { + "standardSurface": [ + "base", + "baseColor", + "diffuseRoughness", + "metalness", + "specular", + "specularColor", + "specularRoughness", + "specularIOR", + "specularAnisotropy", + "specularRotation", + "transmission", + "transmissionColor", + "transmissionExtraRoughness", + "subsurface", + "subsurfaceColor", + "subsurfaceScale", + "emission", + "emissionColor", + "coat", + "coatColor", + "coatRoughness", + "coatIOR", + "coatNormal", + "opacity", + "normalCamera", + ], + "aiStandardSurface": [ + "base", + "baseColor", + "diffuseRoughness", + "metalness", + "specular", + "specularColor", + "specularRoughness", + "specularIOR", + "specularAnisotropy", + "specularRotation", + "transmission", + "transmissionColor", + "transmissionExtraRoughness", + "subsurface", + "subsurfaceColor", + "subsurfaceScale", + "emission", + "emissionColor", + "coat", + "coatColor", + "coatRoughness", + "coatIOR", + "coatNormal", + "opacity", + "normalCamera", + ], + "file": ["fileTextureName", "colorSpace", "uvCoord"], + "place2dTexture": ["coverage", "translateFrame", "repeatUV", "offset", "rotateUV"], + "bump2d": ["bumpValue", "bumpDepth", "bumpInterp"], +} + +MAYA_OUTPUT_ATTRS = { + "standardSurface": ["outColor"], + "aiStandardSurface": ["outColor"], + "file": ["outColor", "outAlpha"], + "place2dTexture": ["outUV", "outUvFilterSize"], + "bump2d": ["outNormal"], +} + +MAYA_COLOR_ATTRS = { + "baseColor", + "specularColor", + "transmissionColor", + "subsurfaceColor", + "emissionColor", + "coatColor", + "opacity", +} +MAYA_VECTOR_ATTRS = {"normalCamera", "coatNormal", "outNormal"} +MAYA_VECTOR2_ATTRS = {"uvCoord", "coverage", "translateFrame", "repeatUV", "offset", "outUV", "outUvFilterSize"} +MAYA_STRING_ATTRS = {"fileTextureName", "colorSpace"} + + +def _require_cmds(): + if cmds is None: + raise RuntimeError("Maya commands are only available inside Maya or mayapy.") + return cmds + + +def _node_path(material_name: str, node_name: str) -> str: + return f"/maya/{material_name}/{node_name}" + + +def _split_plug(plug: str) -> tuple[str, str]: + node_name, attr_name = plug.split(".", 1) + return node_name, attr_name + + +def _plug(node: str, attr: str) -> str: + return f"{node}.{attr}" + + +def _maya_attr_type(attr_name: str, value=None) -> str: + if attr_name in MAYA_STRING_ATTRS: + return "string1" + if attr_name in MAYA_COLOR_ATTRS: + return "color3" + if attr_name in MAYA_VECTOR2_ATTRS: + return "vector2" + if attr_name in MAYA_VECTOR_ATTRS: + return "vector3" + if isinstance(value, bool): + return "bool1" + if isinstance(value, int): + return "int1" + return "float1" + + +def _normalize_value(value): + if isinstance(value, (list, tuple)): + if len(value) == 1 and isinstance(value[0], (list, tuple)): + return list(value[0]) + return list(value) + return value + + +class MayaNodeTraverser: + """Traverse Maya surface shader DG networks.""" + + def __init__(self, material_node: str, material_type: str = "maya"): + """Initialize a Maya graph traverser. + + Args: + material_node: Maya shader or shadingEngine node name. + material_type: Standard material type label. + """ + self.material_node = material_node + self.material_type = material_type + self.cmds = _require_cmds() + + def _attr_exists(self, node: str, attr: str) -> bool: + try: + return bool(self.cmds.attributeQuery(attr, node=node, exists=True)) + except Exception: + return bool(self.cmds.objExists(_plug(node, attr))) + + def _surface_shader_from_shading_engine(self, shading_engine: str) -> tuple[str | None, str | None]: + sources = self.cmds.listConnections( + _plug(shading_engine, "surfaceShader"), + source=True, + destination=False, + plugs=True, + ) or [] + if not sources: + return None, None + source_node, source_attr = _split_plug(sources[0]) + return source_node, source_attr + + def _shading_engine_from_shader(self, shader_node: str) -> tuple[str | None, str]: + node_type = self.cmds.nodeType(shader_node) + output_attrs = MAYA_OUTPUT_ATTRS.get(node_type, ["outColor"]) + for output_attr in output_attrs: + if not self._attr_exists(shader_node, output_attr): + continue + destinations = self.cmds.listConnections( + _plug(shader_node, output_attr), + source=False, + destination=True, + plugs=True, + ) or [] + for destination in destinations: + dest_node, dest_attr = _split_plug(destination) + if dest_attr == "surfaceShader" and self.cmds.nodeType(dest_node) == "shadingEngine": + return dest_node, output_attr + return None, output_attrs[0] + + def _resolve_surface(self) -> tuple[str, str, str]: + node_type = self.cmds.nodeType(self.material_node) + if node_type == "shadingEngine": + shader_node, shader_attr = self._surface_shader_from_shading_engine(self.material_node) + if not shader_node: + raise ValueError(f"Shading engine '{self.material_node}' has no surface shader connection.") + return self.material_node, shader_node, shader_attr or "outColor" + + shading_engine, shader_attr = self._shading_engine_from_shader(self.material_node) + return shading_engine or f"{self.material_node}SG", self.material_node, shader_attr + + def create_output_dict(self) -> dict: + """Detect Maya shadingEngine surface output metadata.""" + shading_engine, shader_node, shader_output_attr = self._resolve_surface() + return { + "surface": { + "node_name": shading_engine, + "node_path": _node_path(shading_engine, shading_engine), + "connected_node_name": shader_node, + "connected_node_path": _node_path(shading_engine, shader_node), + "connected_input_index": 0, + "connected_input_name": "surfaceShader", + "connected_output_name": shader_output_attr, + "generic_type": "GENERIC::output_surface", + } + } + + def _connection_to_parent(self, node: str, parent_node: str, material_name: str) -> dict: + connections_dict = {} + node_type = self.cmds.nodeType(node) + connection_idx = 0 + for output_attr in MAYA_OUTPUT_ATTRS.get(node_type, []): + if not self._attr_exists(node, output_attr): + continue + destinations = self.cmds.listConnections( + _plug(node, output_attr), + source=False, + destination=True, + plugs=True, + ) or [] + for destination in destinations: + dest_node, dest_attr = _split_plug(destination) + if dest_node != parent_node: + continue + connections_dict[f"connection_{connection_idx}"] = { + "input": { + "node_name": node, + "node_path": _node_path(material_name, node), + "node_type": node_type, + "node_index": 0, + "parm_name": output_attr, + "data_type": _maya_attr_type(output_attr), + }, + "output": { + "node_name": parent_node, + "node_path": _node_path(material_name, parent_node), + "node_type": self.cmds.nodeType(parent_node), + "node_index": 0, + "parm_name": dest_attr, + "data_type": _maya_attr_type(dest_attr), + }, + } + connection_idx += 1 + return connections_dict + + def _input_source_plug(self, node: str, attr: str) -> str | None: + sources = self.cmds.listConnections( + _plug(node, attr), + source=True, + destination=False, + plugs=True, + ) or [] + return sources[0] if sources else None + + def _convert_parms_to_dict(self, node: str) -> dict: + parms = {"input": [], "output": []} + node_type = self.cmds.nodeType(node) + + for attr in MAYA_INPUT_ATTRS.get(node_type, []): + if not self._attr_exists(node, attr): + continue + if self._input_source_plug(node, attr): + continue + + try: + value = self.cmds.getAttr(_plug(node, attr)) + except Exception: + continue + value = _normalize_value(value) + parms["input"].append( + { + "generic_name": attr, + "value": value, + "type": _maya_attr_type(attr, value), + "direction": "input", + } + ) + + for attr in MAYA_OUTPUT_ATTRS.get(node_type, []): + if not self._attr_exists(node, attr): + continue + parms["output"].append( + { + "generic_name": attr, + "value": None, + "type": _maya_attr_type(attr), + "direction": "output", + } + ) + return parms + + def _traverse_recursively(self, node: str, material_name: str, parent_node: str | None = None, active_paths=None): + if active_paths is None: + active_paths = set() + + node_path = _node_path(material_name, node) + if node_path in active_paths: + logger.warning("Skipping recursive Maya material traversal cycle at '%s'.", node_path) + return {} + + active_paths = active_paths | {node_path} + node_type = self.cmds.nodeType(node) + node_dict = { + "node_name": node, + "node_path": node_path, + "node_type": node_type, + "node_position": (0.0, 0.0), + "node_parms": self._convert_parms_to_dict(node), + "connections_dict": self._connection_to_parent(node, parent_node, material_name) if parent_node else {}, + "children_list": [], + } + + for input_attr in MAYA_INPUT_ATTRS.get(node_type, []): + if not self._attr_exists(node, input_attr): + continue + source_plug = self._input_source_plug(node, input_attr) + if not source_plug: + continue + source_node, _ = _split_plug(source_plug) + child_dict = self._traverse_recursively(source_node, material_name, node, active_paths) + child_entry = child_dict.get(_node_path(material_name, source_node)) + if child_entry is not None: + node_dict["children_list"].append(child_entry) + + return {node_path: node_dict} + + def run(self): + """Traverse the Maya material graph. + + Returns: + Tuple[Dict, Dict]: Node tree dictionary and output tree dictionary. + """ + output_tree = self.create_output_dict() + shader_node = output_tree["surface"]["connected_node_name"] + material_name = output_tree["surface"]["node_name"] + return self._traverse_recursively(shader_node, material_name), output_tree diff --git a/src/materials_processor/mappings.py b/src/materials_processor/mappings.py index f474cee..1453cac 100644 --- a/src/materials_processor/mappings.py +++ b/src/materials_processor/mappings.py @@ -2,7 +2,7 @@ ###################################### CONSTANTS ###################################### -STANDARDIZER_SUPPORTED_SOURCE_TYPES = ['hou_vop_nodes', 'usd_prims', 'blender_shader_nodes'] +STANDARDIZER_SUPPORTED_SOURCE_TYPES = ['hou_vop_nodes', 'usd_prims', 'blender_shader_nodes', 'maya_nodes'] PRINCIPLED_NATIVE_NODE_TYPE = 'principledshader::2.0' OPENPBR_NODE_TYPE = 'mtlxopen_pbr_surface' @@ -322,6 +322,17 @@ }, }, + 'maya': { + 'maya_nodes': { + 'aiStandardSurface': 'GENERIC::standard_surface', + 'standardSurface': 'GENERIC::standard_surface', + 'file': 'GENERIC::image', + 'place2dTexture': 'GENERIC::uvmap', + 'bump2d': 'GENERIC::displacement', + 'shadingEngine': 'GENERIC::output_node', + }, + }, + } @@ -732,6 +743,90 @@ def convert_generic(node_type: str, 'Strength': 'scale', 'Normal': 'out', }, + + # maya parms: + 'standardSurface': { + 'base': 'base', + 'baseColor': 'base_color', + 'diffuseRoughness': 'diffuse_roughness', + 'metalness': 'metalness', + 'specular': 'specular', + 'specularColor': 'specular_color', + 'specularRoughness': 'specular_roughness', + 'specularIOR': 'specular_IOR', + 'specularAnisotropy': 'specular_anisotropy', + 'specularRotation': 'specular_rotation', + 'transmission': 'transmission', + 'transmissionColor': 'transmission_color', + 'transmissionExtraRoughness': 'transmission_extra_roughness', + 'subsurface': 'subsurface', + 'subsurfaceColor': 'subsurface_color', + 'subsurfaceScale': 'subsurface_radius_scale', + 'emission': 'emission', + 'emissionColor': 'emission_color', + 'coat': 'coat', + 'coatColor': 'coat_color', + 'coatRoughness': 'coat_roughness', + 'coatIOR': 'coat_IOR', + 'coatNormal': 'coat_normal', + 'opacity': 'opacity', + 'normalCamera': 'normal', + 'outColor': 'surface', + }, + 'aiStandardSurface': { + 'base': 'base', + 'baseColor': 'base_color', + 'diffuseRoughness': 'diffuse_roughness', + 'metalness': 'metalness', + 'specular': 'specular', + 'specularColor': 'specular_color', + 'specularRoughness': 'specular_roughness', + 'specularIOR': 'specular_IOR', + 'specularAnisotropy': 'specular_anisotropy', + 'specularRotation': 'specular_rotation', + 'transmission': 'transmission', + 'transmissionColor': 'transmission_color', + 'transmissionExtraRoughness': 'transmission_extra_roughness', + 'subsurface': 'subsurface', + 'subsurfaceColor': 'subsurface_color', + 'subsurfaceScale': 'subsurface_radius_scale', + 'emission': 'emission', + 'emissionColor': 'emission_color', + 'coat': 'coat', + 'coatColor': 'coat_color', + 'coatRoughness': 'coat_roughness', + 'coatIOR': 'coat_IOR', + 'coatNormal': 'coat_normal', + 'opacity': 'opacity', + 'normalCamera': 'normal', + 'outColor': 'surface', + }, + 'file': { + 'fileTextureName': 'filename', + 'colorSpace': 'colorspace', + 'uvCoord': 'texcoord', + 'outColor': 'rgb', + 'outAlpha': 'alpha', + }, + 'place2dTexture': { + 'coverage': 'coverage', + 'translateFrame': 'translate', + 'repeatUV': 'repeat', + 'offset': 'offset', + 'rotateUV': 'rotate', + 'outUV': 'vector', + 'outUvFilterSize': 'uv_filter_size', + }, + 'bump2d': { + 'bumpValue': 'displacement', + 'bumpDepth': 'scale', + 'bumpInterp': 'bump_interp', + 'outNormal': 'out', + }, + 'shadingEngine': { + 'surfaceShader': 'surface', + 'displacementShader': 'displacement', + }, } FORMAT_CHOICES = { diff --git a/tests/test_maya_runtime.py b/tests/test_maya_runtime.py index ea73ec6..621dcc0 100644 --- a/tests/test_maya_runtime.py +++ b/tests/test_maya_runtime.py @@ -7,10 +7,12 @@ from materials_processor.dcc.maya.runtime import ( EXPECTED_API_VERSIONS, + MATERIAL_SMOKE_RESULT_PREFIX, VALIDATION_RESULT_PREFIX, MayaRuntime, _default_package_src, resolve_maya_runtime, + validate_maya_material_smoke, validate_maya_runtime, ) @@ -100,6 +102,35 @@ def fake_run(command, check, capture_output, env, text, timeout): assert "materials_processor_maya_app_" in captured["env"]["MAYA_APP_DIR"] +def test_validate_maya_material_smoke_parses_recreation_result(tmp_path, monkeypatch): + maya_root = _fake_maya_root(tmp_path / "Maya2024") + runtime = resolve_maya_runtime(root=maya_root) + package_src = tmp_path / "src" + package_src.mkdir() + + def fake_run(command, check, capture_output, env, text, timeout): + result = { + "converted_shading_engine": "materials_processor_smoke_targetSG", + "material_name": "materials_processor_smoke_SG", + "node_count": 1, + "output_count": 1, + "recreated": True, + "target_node_types": ["file", "place2dTexture", "shadingEngine", "standardSurface"], + } + return SimpleNamespace( + returncode=0, + stdout=f"{MATERIAL_SMOKE_RESULT_PREFIX}{json.dumps(result)}\n", + stderr="", + ) + + monkeypatch.setattr("materials_processor.dcc.maya.runtime.subprocess.run", fake_run) + + result = validate_maya_material_smoke(runtime=runtime, package_src=package_src, timeout=3) + + assert result["recreated"] is True + assert "standardSurface" in result["target_node_types"] + + def test_validate_maya_runtime_default_package_src_points_to_src(): assert _default_package_src() == ROOT / "src" @@ -111,6 +142,9 @@ def test_validate_local_maya2024_runtime_when_available(): runtime = resolve_maya_runtime(root=LOCAL_MAYAPY_2024.parents[1]) validated = validate_maya_runtime(runtime=runtime, package_src=ROOT / "src", timeout=120) + smoke = validate_maya_material_smoke(runtime=validated, package_src=ROOT / "src", timeout=120) assert validated.version == "2024" assert validated.api_version == "20240000" + assert smoke["recreated"] is True + assert "standardSurface" in smoke["target_node_types"] diff --git a/tests/test_maya_support.py b/tests/test_maya_support.py new file mode 100644 index 0000000..01edacc --- /dev/null +++ b/tests/test_maya_support.py @@ -0,0 +1,295 @@ +"""Unit tests for Maya material support traversal and recreation.""" + +from materials_processor import mappings, standardizer +from materials_processor.core.conversion import ConversionService +from materials_processor.core.graph import MaterialGraph, NodeInfo, NodeParameter, OutputConnection +from materials_processor.dcc.maya.adapters import MayaMaterialReader, MayaMaterialWriter +from materials_processor.dcc.maya.recreator import MayaNodeRecreator +from materials_processor.dcc.maya.traverser import MayaNodeTraverser + + +MAYA_NODE_ATTRS = { + "shadingEngine": {"surfaceShader"}, + "standardSurface": { + "base", + "baseColor", + "metalness", + "specularRoughness", + "normalCamera", + "outColor", + }, + "file": {"fileTextureName", "colorSpace", "uvCoord", "outColor", "outAlpha"}, + "place2dTexture": {"outUV", "outUvFilterSize", "coverage", "translateFrame", "repeatUV", "offset", "rotateUV"}, + "bump2d": {"bumpValue", "bumpDepth", "bumpInterp", "outNormal"}, +} + + +class FakeMayaCmds: + """Small Maya cmds fake for shader graph traversal tests.""" + + def __init__(self): + self.nodes = { + "mayaSmokeSG": { + "type": "shadingEngine", + "attrs": {"surfaceShader": None}, + }, + "mayaSmokeSurface": { + "type": "standardSurface", + "attrs": { + "base": 1.0, + "baseColor": [(0.8, 0.2, 0.1)], + "metalness": 0.25, + "specularRoughness": 0.4, + "normalCamera": [(0.0, 0.0, 1.0)], + "outColor": None, + }, + }, + "mayaSmokeFile": { + "type": "file", + "attrs": { + "fileTextureName": "C:/textures/basecolor.png", + "colorSpace": "sRGB", + "uvCoord": [(0.0, 0.0)], + "outColor": None, + "outAlpha": None, + }, + }, + "mayaSmokePlace2d": { + "type": "place2dTexture", + "attrs": { + "coverage": [(1.0, 1.0)], + "translateFrame": [(0.0, 0.0)], + "repeatUV": [(1.0, 1.0)], + "offset": [(0.0, 0.0)], + "rotateUV": 0.0, + "outUV": None, + "outUvFilterSize": None, + }, + }, + "mayaSmokeBump": { + "type": "bump2d", + "attrs": { + "bumpValue": 0.0, + "bumpDepth": 1.0, + "bumpInterp": 1, + "outNormal": None, + }, + }, + } + self.connections = { + "mayaSmokeSG.surfaceShader": "mayaSmokeSurface.outColor", + "mayaSmokeSurface.baseColor": "mayaSmokeFile.outColor", + "mayaSmokeSurface.normalCamera": "mayaSmokeBump.outNormal", + "mayaSmokeFile.uvCoord": "mayaSmokePlace2d.outUV", + "mayaSmokeBump.bumpValue": "mayaSmokeFile.outAlpha", + } + + def nodeType(self, node): + return self.nodes[node]["type"] + + def objExists(self, name): + if "." not in name: + return name in self.nodes + node, attr = name.split(".", 1) + return node in self.nodes and attr in self.nodes[node]["attrs"] + + def attributeQuery(self, attr, node, exists): + return bool(exists and node in self.nodes and attr in self.nodes[node]["attrs"]) + + def getAttr(self, plug): + node, attr = plug.split(".", 1) + return self.nodes[node]["attrs"][attr] + + def listConnections(self, plug, source, destination, plugs=False, **kwargs): + if source and not destination: + source_plug = self.connections.get(plug) + if not source_plug: + return [] + return [source_plug if plugs else source_plug.split(".", 1)[0]] + + if destination and not source: + destinations = [ + dest_plug if plugs else dest_plug.split(".", 1)[0] + for dest_plug, source_plug in self.connections.items() + if source_plug == plug + ] + node_type = kwargs.get("type") + if node_type: + destinations = [ + destination_plug + for destination_plug in destinations + if self.nodeType(destination_plug.split(".", 1)[0]) == node_type + ] + return destinations + return [] + + +class FakeCreateMayaCmds(FakeMayaCmds): + """Maya cmds fake for recreation tests.""" + + def __init__(self): + self.nodes = {} + self.connections = {} + self.set_attrs = {} + + def _attrs_for_type(self, node_type): + return {attr: None for attr in MAYA_NODE_ATTRS[node_type]} + + def sets(self, renderable, noSurfaceShader, empty, name): + self.nodes[name] = {"type": "shadingEngine", "attrs": self._attrs_for_type("shadingEngine")} + return name + + def createNode(self, node_type, name): + unique_name = name + suffix = 1 + while unique_name in self.nodes: + unique_name = f"{name}{suffix}" + suffix += 1 + self.nodes[unique_name] = {"type": node_type, "attrs": self._attrs_for_type(node_type)} + return unique_name + + def setAttr(self, plug, *values, type=None): + self.set_attrs[plug] = values[0] if len(values) == 1 else values + node, attr = plug.split(".", 1) + self.nodes[node]["attrs"][attr] = self.set_attrs[plug] + + def connectAttr(self, src_plug, dest_plug, force): + self.connections[dest_plug] = src_plug + + +def _iter_nodeinfos(nodeinfos): + for nodeinfo in nodeinfos: + yield nodeinfo + yield from _iter_nodeinfos(nodeinfo.children_list) + + +def test_maya_profile_maps_generic_nodes_without_becoming_houdini_target(): + assert "maya_nodes" in mappings.STANDARDIZER_SUPPORTED_SOURCE_TYPES + assert mappings.convert_generic( + "GENERIC::standard_surface", + "maya", + profile="maya_nodes", + ) == "standardSurface" + assert mappings.convert_generic( + "GENERIC::image", + "maya", + profile="maya_nodes", + ) == "file" + assert mappings.convert_generic( + "GENERIC::uvmap", + "maya", + profile="maya_nodes", + ) == "place2dTexture" + assert "maya" not in mappings.FORMAT_CHOICES + + +def test_maya_traverser_preserves_texture_and_bump_graph(monkeypatch, caplog): + fake_cmds = FakeMayaCmds() + monkeypatch.setattr("materials_processor.dcc.maya.traverser.cmds", fake_cmds) + + nodes_dict, output_dict = MayaNodeTraverser("mayaSmokeSG").run() + nodeinfo_list, output_connections = standardizer.NodeStandardizer( + traversed_nodes_dict=nodes_dict, + output_nodes_dict=output_dict, + material_type="maya", + source_type="maya_nodes", + ).run() + + all_nodes = list(_iter_nodeinfos(nodeinfo_list)) + assert {node.node_type for node in all_nodes} >= { + "GENERIC::standard_surface", + "GENERIC::image", + "GENERIC::uvmap", + "GENERIC::displacement", + } + assert output_connections["GENERIC::output_surface"].connected_node_name == "mayaSmokeSurface" + + image_node = next(node for node in all_nodes if node.node_type == "GENERIC::image") + image_params = {param.generic_name: param for param in image_node.parameters} + assert image_params["filename"].value == "C:/textures/basecolor.png" + assert image_params["colorspace"].value == "sRGB" + + connections = [ + connection + for node in all_nodes + for connection in node.connection_info.values() + ] + assert any( + connection.input.parm_name == "rgb" and connection.output.parm_name == "base_color" + for connection in connections + ) + assert any( + connection.input.parm_name == "alpha" and connection.output.parm_name == "displacement" + for connection in connections + ) + assert any( + connection.input.parm_name == "vector" and connection.output.parm_name == "texcoord" + for connection in connections + ) + assert "No generic type was found for node type" not in caplog.text + + +def test_maya_material_reader_returns_material_graph(monkeypatch): + monkeypatch.setattr("materials_processor.dcc.maya.traverser.cmds", FakeMayaCmds()) + + graph = MayaMaterialReader().read("mayaSmokeSG") + + assert isinstance(graph, MaterialGraph) + assert graph.material_name == "mayaSmokeSG" + assert graph.material_path == "/maya/mayaSmokeSG" + assert graph.nodeinfo_list[0].node_type == "GENERIC::standard_surface" + assert graph.output_connections["GENERIC::output_surface"].connected_node_name == "mayaSmokeSurface" + + +def test_maya_recreator_simple(monkeypatch): + fake_cmds = FakeCreateMayaCmds() + monkeypatch.setattr("materials_processor.dcc.maya.recreator.cmds", fake_cmds) + + node_info = NodeInfo( + node_type="GENERIC::standard_surface", + node_name="mayaSurface", + node_path="/maya/source/mayaSurface", + parameters=[ + NodeParameter("base_color", "color3", "input", [0.2, 0.4, 0.8]), + ], + connection_info={}, + children_list=[], + ) + output_connection = OutputConnection( + node_name="sourceSG", + node_path="/maya/source/sourceSG", + connected_node_name="mayaSurface", + connected_node_path="/maya/source/mayaSurface", + connected_input_index=0, + connected_input_name="surfaceShader", + connected_output_name="surface", + ) + + shading_engine = MayaNodeRecreator( + nodeinfo_list=[node_info], + output_connections={"GENERIC::output_surface": output_connection}, + target_context={"material_name": "target"}, + ).run() + + assert shading_engine == "targetSG" + assert fake_cmds.nodes["mayaSurface"]["type"] == "standardSurface" + assert fake_cmds.set_attrs["mayaSurface.baseColor"] == (0.2, 0.4, 0.8) + assert fake_cmds.connections["targetSG.surfaceShader"] == "mayaSurface.outColor" + + +def test_maya_conversion_service_round_trips_through_adapters(monkeypatch): + read_cmds = FakeMayaCmds() + write_cmds = FakeCreateMayaCmds() + monkeypatch.setattr("materials_processor.dcc.maya.traverser.cmds", read_cmds) + monkeypatch.setattr("materials_processor.dcc.maya.recreator.cmds", write_cmds) + + converted = ConversionService(MayaMaterialReader(), MayaMaterialWriter()).convert( + "mayaSmokeSG", + {"material_name": "converted"}, + ) + + assert converted == "convertedSG" + assert "convertedSG.surfaceShader" in write_cmds.connections + assert any(node["type"] == "file" for node in write_cmds.nodes.values()) + assert any(node["type"] == "place2dTexture" for node in write_cmds.nodes.values()) + assert any(node["type"] == "bump2d" for node in write_cmds.nodes.values()) diff --git a/tests/test_public_imports.py b/tests/test_public_imports.py index 4030d98..c213307 100644 --- a/tests/test_public_imports.py +++ b/tests/test_public_imports.py @@ -21,7 +21,10 @@ def test_public_core_and_houdini_modules_import(): "materials_processor.dcc.houdini.recreator", "materials_processor.dcc.houdini.traverser", "materials_processor.dcc.maya", + "materials_processor.dcc.maya.adapters", + "materials_processor.dcc.maya.recreator", "materials_processor.dcc.maya.runtime", + "materials_processor.dcc.maya.traverser", "materials_processor.io", "materials_processor.mappings", "materials_processor.qt",