diff --git a/README.md b/README.md index 7cdd7d2..1d9bd78 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ -# Material Processor -A tool for Ingestion, Standardization, and Conversion tool for all kinds of Material Networks -for complex multi-input/ multi-output materials in various DCCs.\ -Supports USD file format. -Currently, it's in beta with support for Houdini's Arnold, MaterialX, PrincipledShader, -and Redshift as regular nodes and usd prims. +# Materials Processor +A beta tool for ingestion, standardization, and conversion of material networks across DCCs. +It supports Houdini, Blender, and Maya material graph traversal, with USD MaterialX and OpenPBR export paths. + +Current package version: `2.0.0-beta`. @@ -13,29 +12,96 @@ and Redshift as regular nodes and usd prims.
- ### Features -- [x] UI supports Drag and drop for dropping mat nodes from the Application. -- [x] Ingests and converts to Most Materials types: PrincipledShader, Arnold, MaterialX, Redshift. -- [x] pip-standard coding practices as much as possible and with proper logging. +- [x] UI supports drag and drop for dropping material nodes from Houdini. +- [x] Houdini ingestion and conversion for Principled Shader, Arnold, MaterialX, Redshift, and OpenPBR paths. +- [x] Blender scene inspection and USD MaterialX/OpenPBR material export from the command line. +- [x] Maya scene inspection and USD MaterialX/OpenPBR material export from the command line. +- [x] Runtime validation for Blender and Maya. ### Installation + +- For Python development, use `uv --native-tls sync`. - On Windows: run the included batch installer `install_houdini_win.bat`. - Double-click `install_houdini_win.bat` in File Explorer. - The script copies the project folder to `%USERPROFILE%\Documents\HoudiniTools`. - The script also copies `Axe_Material_Processor.json` to `%USERPROFILE%\Documents\houdini21.0\packages`. - To install to a different Houdini version, run `install_houdini_win.bat `, for example `install_houdini_win.bat 20.5`. - - If you encounter permission or auditing errors, run .bat file as Administrator. + - If you encounter permission or auditing errors, run the batch file as Administrator. - After installation, restart Houdini to load the tool. +### CLI Quickstart + +Show the installed package version: + +```powershell +uv --native-tls run materials-processor --version +``` + +Discover local DCC runtimes: + +```powershell +uv --native-tls run materials-processor doctor +``` + +Run deeper runtime checks: + +```powershell +uv --native-tls run materials-processor doctor --validate --material-smoke +``` + +Inspect a Blender scene without writing USD: + +```powershell +uv --native-tls run materials-processor blender inspect "C:\path\to\scene.blend" --report-json "C:\temp\blender_report.json" +``` + +Export Blender materials to USD MaterialX and OpenPBR: + +```powershell +uv --native-tls run materials-processor blender export-usd "C:\path\to\scene.blend" --out-dir "C:\temp\materials" +``` +Use Blender texture remapping when a scene points at missing source paths: + +```powershell +uv --native-tls run materials-processor blender inspect "C:\path\to\scene.blend" --texture-root "D:\textures" --missing-textures error +``` + +Inspect a Maya scene without writing USD: + +```powershell +uv --native-tls run materials-processor maya inspect "C:\path\to\scene.ma" --report-json "C:\temp\maya_report.json" +``` + +Export Maya materials to USD MaterialX and OpenPBR: + +```powershell +uv --native-tls run materials-processor maya export-usd "C:\path\to\scene.ma" --out-dir "C:\temp\maya_materials" +``` + +Validate a single runtime directly: + +```powershell +uv --native-tls run materials-processor runtime validate --dcc blender --material-smoke +uv --native-tls run materials-processor runtime validate --dcc maya --material-smoke +``` + +### Current Limitations + +- Blender node groups are reported as unsupported nodes; they are not expanded into USD yet. +- Blender texture remapping is available, but Maya texture remapping is not implemented yet. +- Maya CLI export currently traverses shading engines with surface shader connections in a saved `.ma` or `.mb` scene. +- Houdini remains the most mature in-DCC workflow. Blender and Maya CLI support are newer beta paths focused on graph extraction and USD material export. +- USD export currently targets MaterialX and OpenPBR material files, not full asset/shot USD assembly. ### Roadmap + - [x] Add support for Solaris and USD files. - [x] Finish implementation for Redshift. +- [x] Add command line support for Blender. +- [x] Add command line support for Maya. +- [ ] Add a user-facing cross-DCC Qt launcher around the CLI workflows. - [ ] Add implementation for Vray and Renderman. -- [ ] Extend support to other apps like Substance Painter, Maya, and blender. - - - +- [ ] Extend support to other apps like Substance Painter. diff --git a/VERSION b/VERSION deleted file mode 100644 index b966e81..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.2.4 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 3f7525d..80e72a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,14 @@ [project] name = "materials-processor" -version = "1.2.4" -description = "Houdini material ingestion, standardization, and conversion tools." +description = "Cross-DCC material ingestion, standardization, and conversion tools." +dynamic = ["version"] requires-python = ">=3.11" dependencies = [] [project.scripts] materials-processor = "materials_processor.cli:main" materials-processor-blender = "materials_processor.dcc.blender.cli:main" +materials-processor-maya = "materials_processor.dcc.maya.cli:main" [build-system] requires = ["hatchling"] @@ -23,6 +24,9 @@ dev = [ [tool.hatch.build.targets.wheel] packages = ["src/materials_processor"] +[tool.hatch.version] +path = "src/materials_processor/_version.py" + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra" diff --git a/src/materials_processor/__init__.py b/src/materials_processor/__init__.py index 5cf71ba..82b9986 100644 --- a/src/materials_processor/__init__.py +++ b/src/materials_processor/__init__.py @@ -2,4 +2,6 @@ import logging +from materials_processor._version import __version__ + logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/src/materials_processor/_version.py b/src/materials_processor/_version.py new file mode 100644 index 0000000..572b33c --- /dev/null +++ b/src/materials_processor/_version.py @@ -0,0 +1,3 @@ +"""Package version metadata.""" + +__version__ = "2.0.0-beta" diff --git a/src/materials_processor/cli.py b/src/materials_processor/cli.py index d608436..16d1f02 100644 --- a/src/materials_processor/cli.py +++ b/src/materials_processor/cli.py @@ -4,10 +4,15 @@ import argparse import json +import os +import shutil +import subprocess import sys from pathlib import Path +from materials_processor import __version__ from materials_processor.dcc.blender import cli as blender_cli +from materials_processor.dcc.maya import cli as maya_cli def _default_package_src() -> Path: @@ -18,6 +23,32 @@ def _print_json(payload: dict) -> None: print(json.dumps(payload, indent=2, sort_keys=True)) +def _add_doctor_parser(subparsers) -> argparse.ArgumentParser: + doctor_parser = subparsers.add_parser("doctor", help="Discover installed DCC runtimes.") + doctor_parser.add_argument("--validate", action="store_true", help="Run DCC runtime validation when available.") + doctor_parser.add_argument("--timeout", type=int, default=120, help="Validation timeout in seconds.") + doctor_parser.add_argument( + "--package-src", + default=None, + help="Source directory to expose to DCC runtimes. Defaults to this checkout's src directory.", + ) + doctor_parser.add_argument("--material-smoke", action="store_true", help="Run DCC material smoke validation.") + + blender_group = doctor_parser.add_argument_group("Blender") + blender_group.add_argument("--blender-exe", help="Explicit path to blender.exe.") + blender_group.add_argument("--blender-root", help="Explicit Blender install root.") + blender_group.add_argument("--blender-version", help="Blender version to discover, e.g. 4.5.") + + maya_group = doctor_parser.add_argument_group("Maya") + maya_group.add_argument("--maya-root", help="Explicit Maya install root.") + maya_group.add_argument("--maya-version", default="2024", help="Maya version to resolve. Default: 2024.") + + houdini_group = doctor_parser.add_argument_group("Houdini") + houdini_group.add_argument("--hython", help="Explicit path to hython.exe.") + + return doctor_parser + + def _add_runtime_parser(subparsers) -> argparse.ArgumentParser: runtime_parser = subparsers.add_parser("runtime", help="Runtime discovery and validation commands.") runtime_subparsers = runtime_parser.add_subparsers(dest="runtime_command", required=True) @@ -57,11 +88,22 @@ def _add_blender_parser(subparsers) -> argparse.ArgumentParser: return blender_parser +def _add_maya_parser(subparsers) -> argparse.ArgumentParser: + maya_parser = subparsers.add_parser("maya", help="Maya scene inspection and export commands.") + maya_subparsers = maya_parser.add_subparsers(dest="maya_command", required=True) + maya_cli.add_maya_export_parser(maya_subparsers) + maya_cli.add_maya_inspect_parser(maya_subparsers) + return maya_parser + + def build_parser() -> argparse.ArgumentParser: """Build the top-level argument parser.""" parser = argparse.ArgumentParser(prog="materials-processor") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", required=True) _add_blender_parser(subparsers) + _add_maya_parser(subparsers) + _add_doctor_parser(subparsers) _add_runtime_parser(subparsers) return parser @@ -124,6 +166,145 @@ def _validate_maya_runtime(args) -> dict: return result +def _resolve_hython(explicit_hython: str | None = None) -> Path | None: + if explicit_hython: + path = Path(explicit_hython).expanduser().resolve() + return path if path.is_file() else None + + env_hython = os.environ.get("MATERIALS_PROCESSOR_HYTHON") + if env_hython: + path = Path(env_hython).expanduser().resolve() + if path.is_file(): + return path + + path_hython = shutil.which("hython") or shutil.which("hython.exe") + if path_hython: + return Path(path_hython).resolve() + + default_hython = Path("C:/Program Files/Side Effects Software/Houdini 21.0.631/bin/hython.exe") + if default_hython.is_file(): + return default_hython.resolve() + + install_root = Path("C:/Program Files/Side Effects Software") + if install_root.is_dir(): + candidates = sorted(install_root.glob("Houdini 21.0*/bin/hython.exe"), reverse=True) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + + return None + + +def _validate_houdini_runtime(hython: Path, timeout: int) -> dict: + code = ( + "import json\n" + "import hou\n" + "print('MATERIALS_PROCESSOR_HOUDINI_RUNTIME=' + json.dumps({" + "'version': hou.applicationVersionString(), " + "'hfs': hou.getenv('HFS')" + "}, sort_keys=True))\n" + ) + completed = subprocess.run( + [str(hython), "-c", code], + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + if completed.returncode != 0: + raise RuntimeError( + "Houdini validation failed with exit code " + f"{completed.returncode}.\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + for line in completed.stdout.splitlines(): + if line.startswith("MATERIALS_PROCESSOR_HOUDINI_RUNTIME="): + return json.loads(line.split("=", 1)[1]) + raise RuntimeError(f"Houdini validation did not report a runtime result.\nstdout:\n{completed.stdout}") + + +def _doctor_entry(name: str, status: str, **extra) -> dict: + return {"dcc": name, "status": status, **extra} + + +def _doctor_blender(args) -> dict: + from materials_processor.dcc.blender.runtime import resolve_blender_runtime + + try: + runtime = resolve_blender_runtime( + version=args.blender_version, + root=args.blender_root, + blender_exe=args.blender_exe, + ) + result = _doctor_entry( + "blender", + "found", + root=str(runtime.root), + blender_exe=str(runtime.blender_exe), + version=runtime.version, + python_version=runtime.python_version, + api_version=runtime.api_version, + ) + if args.validate: + validation = _validate_blender_runtime(args) + result.update(validation) + result["status"] = "valid" + return result + except Exception as exc: + return _doctor_entry("blender", "missing" if isinstance(exc, FileNotFoundError) else "error", error=str(exc)) + + +def _doctor_maya(args) -> dict: + from materials_processor.dcc.maya.runtime import resolve_maya_runtime + + try: + runtime = resolve_maya_runtime(version=args.maya_version, root=args.maya_root) + result = _doctor_entry( + "maya", + "found", + root=str(runtime.root), + maya_exe=str(runtime.maya_exe), + mayapy_exe=str(runtime.mayapy_exe), + version=runtime.version, + api_version=runtime.api_version, + ) + if args.validate: + validation = _validate_maya_runtime(args) + result.update(validation) + result["status"] = "valid" + return result + except Exception as exc: + return _doctor_entry("maya", "missing" if isinstance(exc, FileNotFoundError) else "error", error=str(exc)) + + +def _doctor_houdini(args) -> dict: + try: + hython = _resolve_hython(args.hython) + if hython is None: + return _doctor_entry("houdini", "missing", error="hython was not found.") + result = _doctor_entry("houdini", "found", hython=str(hython)) + if args.validate: + result.update(_validate_houdini_runtime(hython, args.timeout)) + result["status"] = "valid" + return result + except Exception as exc: + return _doctor_entry("houdini", "error", error=str(exc)) + + +def _doctor(args) -> dict: + return { + "package": { + "name": "materials-processor", + "version": __version__, + "package_src": str(Path(args.package_src).resolve() if args.package_src else _default_package_src()), + }, + "runtimes": [ + _doctor_blender(args), + _doctor_maya(args), + _doctor_houdini(args), + ], + } + + def main(argv: list[str] | None = None) -> int: """Run the top-level command line interface.""" parser = build_parser() @@ -138,6 +319,18 @@ def main(argv: list[str] | None = None) -> int: _print_json(blender_cli.run_inspect_from_args(args)) return 0 + if args.command == "maya": + if args.maya_command == "export-usd": + _print_json(maya_cli.run_export_from_args(args)) + return 0 + if args.maya_command == "inspect": + _print_json(maya_cli.run_inspect_from_args(args)) + return 0 + + if args.command == "doctor": + _print_json(_doctor(args)) + return 0 + if args.command == "runtime" and args.runtime_command == "validate": if args.dcc == "blender": _print_json(_validate_blender_runtime(args)) diff --git a/src/materials_processor/dcc/maya/cli.py b/src/materials_processor/dcc/maya/cli.py new file mode 100644 index 0000000..42b5bb0 --- /dev/null +++ b/src/materials_processor/dcc/maya/cli.py @@ -0,0 +1,427 @@ +"""Command line tools for Maya material workflows.""" + +from __future__ import annotations + +import argparse +import json +import sys +import tempfile +from dataclasses import asdict +from pathlib import Path +from typing import Any + +from materials_processor.dcc.maya.runtime import MayaRuntime, _run_mayapy, resolve_maya_runtime +from materials_processor.dcc.usd_cli import DEFAULT_EXPORT_TARGETS, build_usd_material_files, export_targets_from_args + +MAYA_GRAPH_EXPORT_PREFIX = "MATERIALS_PROCESSOR_MAYA_GRAPH_EXPORT=" +MISSING_TEXTURE_POLICIES = ("warn", "error") +DEFAULT_SHADING_ENGINES = ("initialShadingGroup", "initialParticleSE") + + +def _default_package_src() -> Path: + return Path(__file__).resolve().parents[3] + + +def _iter_nodeinfos(nodes): + for node in nodes: + yield node + yield from _iter_nodeinfos(node.children_list) + + +def _node_summary(node) -> dict[str, str | None]: + return { + "node_name": node.node_name, + "node_path": node.node_path, + "node_type": node.node_type, + } + + +def _enforce_report_policies( + report: dict[str, Any], + *, + fail_on_unsupported: bool = False, + missing_textures: str = "warn", +) -> None: + """Raise when report policy flags request hard failures.""" + if fail_on_unsupported and report.get("unsupported_nodes"): + raise RuntimeError(f"Unsupported Maya nodes were found: {json.dumps(report['unsupported_nodes'], sort_keys=True)}") + if missing_textures == "error" and report.get("missing_texture_paths"): + raise RuntimeError(f"Missing texture paths were found: {json.dumps(report['missing_texture_paths'], sort_keys=True)}") + + +def _extract_code(scene_path: Path, graph_json_path: Path) -> str: + """Return the Python script executed inside mayapy to extract material graphs.""" + return f""" +import json +from dataclasses import asdict +from pathlib import Path + +import maya.cmds as cmds +import maya.standalone + +from materials_processor.dcc.maya.adapters import MayaMaterialReader + +SCENE_PATH = {str(scene_path)!r} +GRAPH_JSON_PATH = {str(graph_json_path)!r} +PREFIX = {MAYA_GRAPH_EXPORT_PREFIX!r} +DEFAULT_SHADING_ENGINES = set({DEFAULT_SHADING_ENGINES!r}) + + +def iter_nodeinfos(nodes): + for node in nodes: + yield node + yield from iter_nodeinfos(node.children_list) + + +def node_summary(node): + return {{ + "node_name": node.node_name, + "node_path": node.node_path, + "node_type": node.node_type, + }} + + +maya.standalone.initialize(name="python") +try: + cmds.file(SCENE_PATH, open=True, force=True, prompt=False, ignoreVersion=True) + reader = MayaMaterialReader() + shading_engines = cmds.ls(type="shadingEngine") or [] + materials = [] + for shading_engine in shading_engines: + if shading_engine in DEFAULT_SHADING_ENGINES: + continue + sources = cmds.listConnections( + shading_engine + ".surfaceShader", + source=True, + destination=False, + plugs=True, + ) or [] + if sources: + materials.append(shading_engine) + + result = {{ + "scene": SCENE_PATH, + "material_count": len(shading_engines), + "node_material_count": len(materials), + "graphs": [], + "read_failures": [], + "unsupported_nodes": {{}}, + "missing_texture_paths": [], + }} + + for material in materials: + try: + graph = reader.read(material) + except Exception as exc: + result["read_failures"].append({{"material": material, "error": repr(exc)}}) + continue + + nodeinfos = list(iter_nodeinfos(graph.nodeinfo_list)) + unsupported = [node_summary(node) for node in nodeinfos if node.node_type is None] + if unsupported: + result["unsupported_nodes"][material] = unsupported + + for node in nodeinfos: + for parameter in node.parameters or []: + if parameter.generic_name != "filename" or not parameter.value: + continue + texture_path = str(parameter.value) + normalized = texture_path.replace("", "1001") + if "" not in texture_path and not Path(normalized).exists(): + result["missing_texture_paths"].append({{ + "material": material, + "path": texture_path, + }}) + + result["graphs"].append(asdict(graph)) + + Path(GRAPH_JSON_PATH).write_text(json.dumps(result, indent=2), encoding="utf-8") + summary = {{key: value for key, value in result.items() if key != "graphs"}} + summary["graph_count"] = len(result["graphs"]) + print(PREFIX + json.dumps(summary, sort_keys=True)) +finally: + try: + maya.standalone.uninitialize() + except Exception: + pass +""".strip() + + +def extract_maya_material_graphs( + scene_path: str | Path, + graph_json_path: str | Path, + *, + runtime: MayaRuntime | None = None, + package_src: str | Path | None = None, + timeout: int = 300, +) -> dict[str, Any]: + """Extract standardized Maya material graphs into a JSON file.""" + scene = Path(scene_path).expanduser().resolve() + if not scene.is_file(): + raise FileNotFoundError(f"Maya scene was not found: {scene}") + + graph_json = Path(graph_json_path).expanduser().resolve() + graph_json.parent.mkdir(parents=True, exist_ok=True) + package_src_path = Path(package_src).resolve() if package_src is not None else _default_package_src() + runtime = runtime or resolve_maya_runtime() + + completed = _run_mayapy( + runtime, + _extract_code(scene, graph_json), + package_src_path, + timeout=timeout, + ) + if completed.returncode != 0: + raise RuntimeError( + "Maya material graph extraction failed with exit code " + f"{completed.returncode}.\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + + for line in completed.stdout.splitlines(): + if line.startswith(MAYA_GRAPH_EXPORT_PREFIX): + return json.loads(line[len(MAYA_GRAPH_EXPORT_PREFIX):]) + + raise RuntimeError( + "Maya material graph extraction did not report a summary." + f"\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + + +def export_maya_scene_to_usd( + scene_path: str | Path, + out_dir: str | Path, + *, + targets: tuple[str, ...] = DEFAULT_EXPORT_TARGETS, + runtime: MayaRuntime | None = None, + package_src: str | Path | None = None, + timeout: int = 300, + missing_textures: str = "warn", + fail_on_unsupported: bool = False, + report_json: str | Path | None = None, + graph_json: str | Path | None = None, +) -> dict[str, Any]: + """Export Maya scene materials to USD MaterialX/OpenPBR files.""" + output_dir = Path(out_dir).expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + graph_json_path = Path(graph_json).expanduser().resolve() if graph_json else output_dir / "maya_material_graphs.json" + graph_json_path.parent.mkdir(parents=True, exist_ok=True) + + extract_maya_material_graphs( + scene_path, + graph_json_path, + runtime=runtime, + package_src=package_src, + timeout=timeout, + ) + graph_payload = json.loads(graph_json_path.read_text(encoding="utf-8")) + + report = build_usd_material_files(graph_payload, output_dir, source_label="maya_scene", targets=targets) + report["graph_json"] = str(graph_json_path) + + report_json_path = Path(report_json).expanduser().resolve() if report_json else output_dir / "export_report.json" + report_json_path.parent.mkdir(parents=True, exist_ok=True) + report["report_json"] = str(report_json_path) + report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8") + _enforce_report_policies( + report, + fail_on_unsupported=fail_on_unsupported, + missing_textures=missing_textures, + ) + return report + + +def inspect_maya_scene( + scene_path: str | Path, + *, + runtime: MayaRuntime | None = None, + package_src: str | Path | None = None, + timeout: int = 300, + graph_json: str | Path | None = None, + report_json: str | Path | None = None, + missing_textures: str = "warn", + fail_on_unsupported: bool = False, +) -> dict[str, Any]: + """Inspect a Maya scene's materials without writing USD files.""" + if graph_json: + graph_json_path = Path(graph_json).expanduser().resolve() + graph_json_path.parent.mkdir(parents=True, exist_ok=True) + cleanup_dir = None + else: + cleanup_dir = tempfile.TemporaryDirectory(prefix="materials_processor_maya_inspect_") + graph_json_path = Path(cleanup_dir.name) / "maya_material_graphs.json" + + try: + extract_maya_material_graphs( + scene_path, + graph_json_path, + runtime=runtime, + package_src=package_src, + timeout=timeout, + ) + graph_payload = json.loads(graph_json_path.read_text(encoding="utf-8")) + graphs = graph_payload.get("graphs") or [] + report = { + "scene": graph_payload.get("scene"), + "material_count": graph_payload.get("material_count", 0), + "node_material_count": graph_payload.get("node_material_count", 0), + "graph_count": len(graphs), + "read_failures": graph_payload.get("read_failures", []), + "unsupported_nodes": graph_payload.get("unsupported_nodes", {}), + "missing_texture_paths": graph_payload.get("missing_texture_paths", []), + } + if graph_json: + report["graph_json"] = str(graph_json_path) + if report_json: + report_json_path = Path(report_json).expanduser().resolve() + report_json_path.parent.mkdir(parents=True, exist_ok=True) + report["report_json"] = str(report_json_path) + report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8") + _enforce_report_policies( + report, + fail_on_unsupported=fail_on_unsupported, + missing_textures=missing_textures, + ) + return report + finally: + if cleanup_dir is not None: + cleanup_dir.cleanup() + + +def add_maya_runtime_arguments(parser: argparse.ArgumentParser) -> None: + """Add common Maya runtime options to an argument parser.""" + parser.add_argument("--maya-root", help="Explicit Maya install root.") + parser.add_argument("--maya-version", default="2024", help="Maya version to resolve. Default: 2024.") + parser.add_argument("--timeout", type=int, default=300, help="mayapy timeout in seconds.") + parser.add_argument( + "--package-src", + default=None, + help="Source directory to expose to mayapy. Defaults to this checkout's src directory.", + ) + + +def add_policy_arguments(parser: argparse.ArgumentParser) -> None: + """Add report policy options to an argument parser.""" + parser.add_argument( + "--missing-textures", + choices=MISSING_TEXTURE_POLICIES, + default="warn", + help="Whether missing textures should warn in the report or fail the command.", + ) + parser.add_argument( + "--fail-on-unsupported", + action="store_true", + help="Fail when unsupported Maya nodes are found.", + ) + + +def add_maya_export_parser(subparsers) -> argparse.ArgumentParser: + """Add the Maya ``export-usd`` subcommand to a subparser collection.""" + export_parser = subparsers.add_parser( + "export-usd", + help="Export node materials from a Maya scene to USD material files.", + ) + export_parser.add_argument("scene", help="Path to the .ma or .mb scene.") + export_parser.add_argument( + "--out-dir", + default=None, + help="Directory to write USD files and reports. Defaults to a temp directory.", + ) + export_parser.add_argument( + "--target", + choices=("materialx", "mtlx", "openpbr", "all"), + action="append", + default=None, + help="USD material target to export. Can be passed more than once. Default: all.", + ) + export_parser.add_argument("--report-json", default=None, help="Explicit path for the export report JSON.") + export_parser.add_argument("--graph-json", default=None, help="Explicit path for the extracted material graph JSON.") + add_maya_runtime_arguments(export_parser) + add_policy_arguments(export_parser) + return export_parser + + +def add_maya_inspect_parser(subparsers) -> argparse.ArgumentParser: + """Add the Maya ``inspect`` subcommand to a subparser collection.""" + inspect_parser = subparsers.add_parser( + "inspect", + help="Inspect node materials in a Maya scene without writing USD files.", + ) + inspect_parser.add_argument("scene", help="Path to the .ma or .mb scene.") + inspect_parser.add_argument("--report-json", default=None, help="Optional path for the inspection report JSON.") + inspect_parser.add_argument("--graph-json", default=None, help="Optional path for the extracted material graph JSON.") + add_maya_runtime_arguments(inspect_parser) + add_policy_arguments(inspect_parser) + return inspect_parser + + +def _runtime_from_args(args) -> MayaRuntime: + """Resolve Maya runtime from parsed arguments.""" + return resolve_maya_runtime(version=args.maya_version, root=args.maya_root) + + +def run_export_from_args(args) -> dict[str, Any]: + """Run Maya USD export from parsed CLI arguments.""" + out_dir = Path(args.out_dir) if args.out_dir else Path(tempfile.mkdtemp(prefix="materials_processor_maya_usd_")) + return export_maya_scene_to_usd( + args.scene, + out_dir, + targets=export_targets_from_args(args.target), + runtime=_runtime_from_args(args), + package_src=args.package_src, + timeout=args.timeout, + missing_textures=args.missing_textures, + fail_on_unsupported=args.fail_on_unsupported, + report_json=args.report_json, + graph_json=args.graph_json, + ) + + +def run_inspect_from_args(args) -> dict[str, Any]: + """Run Maya material inspection from parsed CLI arguments.""" + return inspect_maya_scene( + args.scene, + runtime=_runtime_from_args(args), + package_src=args.package_src, + timeout=args.timeout, + graph_json=args.graph_json, + report_json=args.report_json, + missing_textures=args.missing_textures, + fail_on_unsupported=args.fail_on_unsupported, + ) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="materials-processor-maya") + subparsers = parser.add_subparsers(dest="command", required=True) + + add_maya_export_parser(subparsers) + add_maya_inspect_parser(subparsers) + + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the Maya command line interface.""" + parser = _build_parser() + args = parser.parse_args(argv) + + try: + if args.command == "export-usd": + report = run_export_from_args(args) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + if args.command == "inspect": + report = run_inspect_from_args(args) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + parser.error(f"Unsupported command: {args.command}") + return 2 + except (FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/materials_processor/dcc/usd_cli.py b/src/materials_processor/dcc/usd_cli.py new file mode 100644 index 0000000..beb69f3 --- /dev/null +++ b/src/materials_processor/dcc/usd_cli.py @@ -0,0 +1,132 @@ +"""Shared CLI helpers for writing USD material files from DCC graph payloads.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from materials_processor.core.graph import MaterialGraph, NodeConnection, NodeInfo, NodeParameter, OutputConnection + +DEFAULT_EXPORT_TARGETS = ("mtlx", "openpbr") +TARGET_ALIASES = { + "materialx": "mtlx", + "mtlx": "mtlx", + "openpbr": "openpbr", +} +TARGET_FILE_LABELS = { + "mtlx": "materialx", + "openpbr": "openpbr", +} + + +def nodeinfo_from_dict(data: dict[str, Any]) -> NodeInfo: + """Rebuild a ``NodeInfo`` from JSON-compatible data.""" + return NodeInfo( + node_type=data.get("node_type"), + node_name=data["node_name"], + node_path=data["node_path"], + parameters=[NodeParameter(**param) for param in data.get("parameters") or []], + connection_info={ + key: NodeConnection.from_mapping(value) + for key, value in (data.get("connection_info") or {}).items() + }, + children_list=[nodeinfo_from_dict(child) for child in data.get("children_list") or []], + is_output_node=data.get("is_output_node", False), + output_type=data.get("output_type"), + position=data.get("position"), + ) + + +def material_graph_from_dict(data: dict[str, Any]) -> MaterialGraph: + """Rebuild a material graph from JSON-compatible data.""" + return MaterialGraph( + material_name=data["material_name"], + material_path=data.get("material_path"), + nodeinfo_list=[nodeinfo_from_dict(node) for node in data.get("nodeinfo_list") or []], + output_connections={ + key: OutputConnection.from_mapping(value) + for key, value in (data.get("output_connections") or {}).items() + }, + ) + + +def export_targets_from_args(values: list[str] | tuple[str, ...] | None) -> tuple[str, ...]: + """Normalize CLI target values to internal USD renderer target names.""" + targets = [] + for value in values or ["all"]: + if value == "all": + targets.extend(DEFAULT_EXPORT_TARGETS) + else: + targets.append(TARGET_ALIASES[value]) + return tuple(dict.fromkeys(targets)) + + +def build_usd_material_files( + graph_payload: dict[str, Any], + out_dir: str | Path, + *, + source_label: str, + targets: tuple[str, ...] = DEFAULT_EXPORT_TARGETS, +) -> dict[str, Any]: + """Build USD material files from extracted DCC material graph data.""" + from pxr import Sdf, Usd + + from materials_processor.usd.recreator import USDMaterialRecreator + + output_dir = Path(out_dir).expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + graphs = [material_graph_from_dict(graph) for graph in graph_payload.get("graphs") or []] + report = { + "scene": graph_payload.get("scene"), + "output_dir": str(output_dir), + "material_count": graph_payload.get("material_count", 0), + "node_material_count": graph_payload.get("node_material_count", 0), + "graph_count": len(graphs), + "read_failures": graph_payload.get("read_failures", []), + "unsupported_nodes": graph_payload.get("unsupported_nodes", {}), + "missing_texture_paths": graph_payload.get("missing_texture_paths", []), + "remapped_texture_paths": graph_payload.get("remapped_texture_paths", []), + "usd_files": {}, + } + + for target in export_targets_from_args(targets): + usd_path = output_dir / f"{source_label}_{TARGET_FILE_LABELS[target]}.usda" + stage = Usd.Stage.CreateNew(str(usd_path)) + stage.SetDefaultPrim(stage.DefinePrim(Sdf.Path("/materials"), "Scope")) + + for graph in graphs: + if not graph.nodeinfo_list or not graph.output_connections: + continue + USDMaterialRecreator( + stage=stage, + material_name=graph.material_name, + nodeinfo_list=graph.nodeinfo_list, + output_connections=graph.output_connections, + parent_scope_path="/materials", + target_renderer=target, + ).run() + + stage.GetRootLayer().Save() + opened_stage = Usd.Stage.Open(str(usd_path)) + if opened_stage is None: + raise RuntimeError(f"USD file was written but could not be reopened: {usd_path}") + + shader_ids = {} + materials = [] + for prim in opened_stage.Traverse(): + if prim.GetTypeName() == "Material": + materials.append(prim.GetPath().pathString) + attr = prim.GetAttribute("info:id") + if attr and attr.Get(): + shader_id = attr.Get() + shader_ids[shader_id] = shader_ids.get(shader_id, 0) + 1 + + report["usd_files"][target] = { + "path": str(usd_path), + "material_prim_count": len(materials), + "material_prims": materials, + "shader_ids": shader_ids, + } + + return report diff --git a/tests/test_cli.py b/tests/test_cli.py index 4fc6db1..87c302a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,8 +3,12 @@ from __future__ import annotations import json +from argparse import Namespace from pathlib import Path +import pytest + +import materials_processor from materials_processor import cli @@ -83,6 +87,95 @@ def test_top_level_cli_runtime_validate_maya_dispatches(monkeypatch, capsys): assert json.loads(capsys.readouterr().out) == {"dcc": "maya", "version": "2024"} +def test_top_level_cli_maya_export_dispatches(monkeypatch, tmp_path, capsys): + scene = tmp_path / "scene.ma" + scene.write_text("// fake maya scene", encoding="utf-8") + captured = {} + + def fake_export(args): + captured["args"] = args + return { + "scene": args.scene, + "usd_files": {"openpbr": {"path": "maya_scene_openpbr.usda"}}, + } + + monkeypatch.setattr(cli.maya_cli, "run_export_from_args", fake_export) + + exit_code = cli.main([ + "maya", + "export-usd", + str(scene), + "--target", + "openpbr", + "--out-dir", + str(tmp_path / "out"), + ]) + + assert exit_code == 0 + assert captured["args"].maya_command == "export-usd" + assert captured["args"].target == ["openpbr"] + assert json.loads(capsys.readouterr().out)["usd_files"]["openpbr"]["path"] == "maya_scene_openpbr.usda" + + +def test_top_level_cli_maya_inspect_dispatches(monkeypatch, tmp_path, capsys): + scene = tmp_path / "scene.ma" + scene.write_text("// fake maya scene", encoding="utf-8") + captured = {} + + def fake_inspect(args): + captured["args"] = args + return {"scene": args.scene, "graph_count": 1} + + monkeypatch.setattr(cli.maya_cli, "run_inspect_from_args", fake_inspect) + + exit_code = cli.main(["maya", "inspect", str(scene), "--missing-textures", "error"]) + + assert exit_code == 0 + assert captured["args"].maya_command == "inspect" + assert captured["args"].missing_textures == "error" + assert json.loads(capsys.readouterr().out)["graph_count"] == 1 + + +def test_top_level_cli_doctor_reports_runtime_warnings(monkeypatch, capsys): + monkeypatch.setattr(cli, "_doctor_blender", lambda args: {"dcc": "blender", "status": "missing"}) + monkeypatch.setattr(cli, "_doctor_maya", lambda args: {"dcc": "maya", "status": "found"}) + monkeypatch.setattr(cli, "_doctor_houdini", lambda args: {"dcc": "houdini", "status": "missing"}) + + exit_code = cli.main(["doctor"]) + + payload = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert payload["package"]["version"] == materials_processor.__version__ + assert payload["runtimes"] == [ + {"dcc": "blender", "status": "missing"}, + {"dcc": "maya", "status": "found"}, + {"dcc": "houdini", "status": "missing"}, + ] + + +def test_doctor_houdini_validation_keeps_executable_and_hfs_separate(monkeypatch, tmp_path): + hython = tmp_path / "hython.exe" + hython.write_text("fake", encoding="utf-8") + + monkeypatch.setattr(cli, "_validate_houdini_runtime", lambda path, timeout: {"version": "21.0.631", "hfs": "C:/HFS"}) + + assert cli._doctor_houdini(Namespace(hython=str(hython), validate=True, timeout=3)) == { + "dcc": "houdini", + "hfs": "C:/HFS", + "hython": str(hython.resolve()), + "status": "valid", + "version": "21.0.631", + } + + +def test_top_level_cli_version(capsys): + with pytest.raises(SystemExit) as exc: + cli.main(["--version"]) + + assert exc.value.code == 0 + assert f"materials-processor {materials_processor.__version__}" in capsys.readouterr().out + + def test_top_level_cli_reports_runtime_errors_without_traceback(monkeypatch, tmp_path, capsys): scene = tmp_path / "scene.blend" scene.write_text("fake blend", encoding="utf-8") @@ -104,4 +197,6 @@ def test_top_level_parser_exposes_expected_commands(): help_text = cli.build_parser().format_help() assert "blender" in help_text + assert "doctor" in help_text + assert "maya" in help_text assert "runtime" in help_text diff --git a/tests/test_maya_cli.py b/tests/test_maya_cli.py new file mode 100644 index 0000000..03299d8 --- /dev/null +++ b/tests/test_maya_cli.py @@ -0,0 +1,179 @@ +"""Tests for Maya command line export support.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pxr import Sdf, Usd + +from materials_processor.core.graph import MaterialGraph, NodeInfo, OutputConnection +from materials_processor.dcc.maya import cli + + +def _graph_payload(material_name="Maya Cli Material"): + graph = MaterialGraph( + material_name=material_name, + material_path=f"/maya/{material_name}", + nodeinfo_list=[ + NodeInfo( + node_type="GENERIC::standard_surface", + node_name="mayaSurface", + node_path=f"/maya/{material_name}/mayaSurface", + parameters=[], + connection_info={}, + children_list=[], + ) + ], + output_connections={ + "GENERIC::output_surface": OutputConnection( + node_name=f"{material_name}SG", + node_path=f"/maya/{material_name}/{material_name}SG", + connected_node_name="mayaSurface", + connected_node_path=f"/maya/{material_name}/mayaSurface", + connected_input_index=0, + connected_input_name="surfaceShader", + connected_output_name="surface", + ) + }, + ) + return { + "scene": "C:/scenes/example.ma", + "material_count": 1, + "node_material_count": 1, + "graphs": [ + { + "material_name": graph.material_name, + "material_path": graph.material_path, + "nodeinfo_list": [ + { + "node_type": graph.nodeinfo_list[0].node_type, + "node_name": graph.nodeinfo_list[0].node_name, + "node_path": graph.nodeinfo_list[0].node_path, + "parameters": [], + "connection_info": {}, + "children_list": [], + "is_output_node": False, + "output_type": None, + "position": None, + } + ], + "output_connections": { + key: value.to_dict() + for key, value in graph.output_connections.items() + }, + } + ], + "read_failures": [], + "unsupported_nodes": {}, + "missing_texture_paths": [], + } + + +def _texture_graph_payload(texture_path): + payload = _graph_payload("Maya Textured Material") + payload["graphs"][0]["nodeinfo_list"][0]["node_type"] = "GENERIC::image" + payload["graphs"][0]["nodeinfo_list"][0]["parameters"] = [ + { + "generic_name": "filename", + "generic_type": "string1", + "direction": "input", + "value": texture_path, + } + ] + payload["missing_texture_paths"] = [{"material": "Maya Textured Material", "path": texture_path}] + return payload + + +def test_export_maya_scene_to_usd_writes_graph_and_report(tmp_path, monkeypatch): + scene = tmp_path / "scene.ma" + scene.write_text("// fake maya scene", encoding="utf-8") + + def fake_extract(scene_path, graph_json_path, **kwargs): + Path(graph_json_path).write_text(json.dumps(_graph_payload()), encoding="utf-8") + return {"graph_count": 1} + + monkeypatch.setattr(cli, "extract_maya_material_graphs", fake_extract) + + report = cli.export_maya_scene_to_usd(scene, tmp_path / "export", targets=("mtlx",)) + + usd_path = Path(report["usd_files"]["mtlx"]["path"]) + assert usd_path.name == "maya_scene_materialx.usda" + assert usd_path.is_file() + assert Path(report["graph_json"]).is_file() + assert Path(report["report_json"]).is_file() + + stage = Usd.Stage.Open(str(usd_path)) + assert stage.GetPrimAtPath(Sdf.Path("/materials/Maya_Cli_Material")).IsValid() + + +def test_inspect_maya_scene_reports_without_writing_usd(tmp_path, monkeypatch): + scene = tmp_path / "scene.ma" + scene.write_text("// fake maya scene", encoding="utf-8") + report_json = tmp_path / "inspect_report.json" + + def fake_extract(scene_path, graph_json_path, **kwargs): + Path(graph_json_path).write_text(json.dumps(_graph_payload()), encoding="utf-8") + return {"graph_count": 1} + + monkeypatch.setattr(cli, "extract_maya_material_graphs", fake_extract) + + report = cli.inspect_maya_scene(scene, report_json=report_json) + + assert report["graph_count"] == 1 + assert report["report_json"] == str(report_json.resolve()) + assert report_json.is_file() + assert "usd_files" not in report + + +def test_inspect_maya_scene_writes_report_before_missing_texture_failure(tmp_path, monkeypatch): + scene = tmp_path / "scene.ma" + scene.write_text("// fake maya scene", encoding="utf-8") + report_json = tmp_path / "inspect_report.json" + + def fake_extract(scene_path, graph_json_path, **kwargs): + Path(graph_json_path).write_text(json.dumps(_texture_graph_payload("C:/missing/basecolor.png")), encoding="utf-8") + return {"graph_count": 1} + + monkeypatch.setattr(cli, "extract_maya_material_graphs", fake_extract) + + with pytest.raises(RuntimeError, match="Missing texture paths"): + cli.inspect_maya_scene(scene, report_json=report_json, missing_textures="error") + + assert report_json.is_file() + assert json.loads(report_json.read_text(encoding="utf-8"))["missing_texture_paths"] + + +def test_maya_cli_export_usd_dispatches_to_exporter(tmp_path, monkeypatch, capsys): + scene = tmp_path / "scene.ma" + scene.write_text("// fake maya scene", encoding="utf-8") + + monkeypatch.setattr(cli, "resolve_maya_runtime", lambda **kwargs: "runtime") + monkeypatch.setattr( + cli, + "export_maya_scene_to_usd", + lambda *args, **kwargs: {"scene": str(scene), "usd_files": {"mtlx": {"path": "maya_scene_materialx.usda"}}}, + ) + + exit_code = cli.main(["export-usd", str(scene), "--target", "materialx", "--out-dir", str(tmp_path / "out")]) + + assert exit_code == 0 + assert "maya_scene_materialx.usda" in capsys.readouterr().out + + +def test_maya_cli_reports_runtime_errors_without_traceback(tmp_path, monkeypatch, capsys): + scene = tmp_path / "scene.ma" + scene.write_text("// fake maya scene", encoding="utf-8") + + def fail(args): + raise RuntimeError("unsupported nodes") + + monkeypatch.setattr(cli, "run_inspect_from_args", fail) + + exit_code = cli.main(["inspect", str(scene)]) + captured = capsys.readouterr() + + assert exit_code == 1 + assert captured.err == "error: unsupported nodes\n" + assert "Traceback" not in captured.err diff --git a/tests/test_public_imports.py b/tests/test_public_imports.py index 2662e99..e1e7a95 100644 --- a/tests/test_public_imports.py +++ b/tests/test_public_imports.py @@ -12,6 +12,7 @@ def test_public_core_and_houdini_modules_import(): "materials_processor.core.conversion", "materials_processor.core.graph", "materials_processor.dcc", + "materials_processor.dcc.usd_cli", "materials_processor.dcc.blender", "materials_processor.dcc.blender.addon", "materials_processor.dcc.blender.adapters", @@ -24,6 +25,7 @@ def test_public_core_and_houdini_modules_import(): "materials_processor.dcc.houdini.traverser", "materials_processor.dcc.maya", "materials_processor.dcc.maya.adapters", + "materials_processor.dcc.maya.cli", "materials_processor.dcc.maya.recreator", "materials_processor.dcc.maya.runtime", "materials_processor.dcc.maya.traverser", diff --git a/uv.lock b/uv.lock index 682d627..887d3d6 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,6 @@ wheels = [ [[package]] name = "materials-processor" -version = "1.2.4" source = { editable = "." } [package.dev-dependencies]