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]