scenario-maya-expert
Use when doing any Autodesk Maya task through Python or a bridge, headless or live: maya.cmds or OpenMaya 2.0 scripts, mayapy or maya.standalone batch jobs, a commandPort or MCP connection into a running Maya, "Maya 2027", validating or exporting scenes, or a maya.cmds script tha
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/maya/scenario-maya-expert
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
git clone https://github.com/scenario-labs/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Maya expert (router and agent protocol)
Expert Maya work is a loop, not a script: block big to small, and at every stage measure, render, look and fix before adding detail. This skill is the protocol every Maya task follows, the shared toolkit the other scenario-maya-* skills call, and the map to them. Target: Maya 2027 on macOS Apple Silicon, driven through Python. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
Status (2026-09-24): Maya 2027 is not installed yet. Every Maya call in scripts/ and references/ is not yet run in Maya. The pure-Python parts and the Maya-layer logic ran offline against fakes and pass. First action once Maya is installed: tests/code/maya-expert/run_all.sh, then read archive/tests/maya-expert/<stamp>/results/test_00_probe.json and correct anything marked [verify] here.
1. Execution channels
| Channel | Use for | Rules |
|---|---|---|
Headless mayapy through scripts/mx_run.py |
audits, validation, exports, Arnold review renders, batch work, anything that needs no viewport | python3 mx_run.py [--scene f.ma] [--plugins mtoa,fbx,abc,usd] job.py -- args. One child process per scene: a crash or hang costs one job. The last stdout line is MX_RESULT {json}; exit 0 ok, 1 job error, 4 timeout. Children get MAYA_DISABLE_ADP=1 and Maya's stderr at warning (--maya-log all to debug). Script nodes off. Never overwrite the source: --save-as a new version. |
Live GUI Maya through scripts/mx_bridge.py |
playblasts, viewport captures, interactive tools, the user's open scene | Emmanuel starts mx_bridge_server.start() once in the Script Editor. Then Bridge().run(code): any length, multi-statement; every record carries result, stdout, stderr, maya_messages (Maya's warnings and errors) and the traceback, even on a timeout. Read them every call. One call = one undo chunk. Check cmds.file(q=True, modified=True) before replacing a scene. Never open modal UI. stop() when done. |
Do not launch a GUI Maya yourself [added]: it takes a license seat and cannot be driven without the bridge. One Arnold render at a time per machine [added].
mayapy: /Applications/Autodesk/maya2027/Maya.app/Contents/bin/mayapy (Maya 2027 Help). mx_run.find_mayapy() checks $MX_MAYAPY, $MAYA_LOCATION/bin/mayapy (on macOS MAYA_LOCATION ends in Maya.app/Contents), then the newest /Applications/Autodesk/maya20*/. Log cmds.about(version=True) at session start: Smart Bevel, MtoA and USD for Maya differ across 2027.x.
Why files for the bridge: a commandPort defaults to MEL, a 4096-character buffer (longer commands close the connection, longer results become an error) and returns the result of one statement only (Maya 2027 Help, commandPort; Python in Maya). mx_bridge sends one short line and exchanges code and results as files (GG_MayaMCP pre-installs a handler; Palmer uses two round trips). Security: the port is opened as 127.0.0.1:<port> [verify with lsof] and has no authentication; Maya's help warns an INET port runs any command, including system(), as the user. Personal machine only, close it after the session.
Capability matrix (from the docs; the probe test confirms or corrects each row):
| Action | Headless mayapy | GUI via bridge | Basis |
|---|---|---|---|
| cmds / OpenMaya edits, file IO, references, FBX, Alembic, USD export | yes | yes | Maya 2027 Help, Python in Maya |
Arnold stills (mx_review) |
yes, CPU; watermark without an Arnold batch license | yes | Arnold batch docs |
Render -r hw2 viewport-style frames |
documented, unverified on macOS | n/a | command-line render doc |
playblast, modelEditor, viewport grabs |
no (needs a model panel) [verify] | yes, mx_review.playblast |
scripting digest |
| Tool contexts: Quad Draw, Multi-Cut, sculpt, Paint Skin Weights, Skin Tools, 3D paint, XGen brushes | no | interactive only (no stroke API): script the underlying commands | Maya 2027 Help |
scriptJob |
no | yes, not during playback | Maya 2027 Help, scriptJobs |
maya.utils.executeInMainThreadWithResult |
no, "not available in batch mode" | yes | Maya 2027 Help, threading |
maya.utils.executeDeferred |
only when idle events run [verify] | yes | same |
| MMessage callbacks | [verify] | yes | scripting digest |
| commandPort | absent per Theodore 2014 [verify] | yes | MCP bridges note |
PySide6 windows, workspaceControl |
no | yes | Qt6 migration docs |
| Flow Retopology (cloud) | no: sign-in and network [added] | yes | version deltas |
confirmDialog, input(), pdb, file dialogs |
never | never: they block Maya's main thread and the bridge | Maya 2027 Help, standard input |
2. The expert loop (every domain)
- Brief in numbers. Purpose (engine, film, still, animation), budgets (triangles, texture size and texel density, frame range and fps), style, references, deadline. When the brief is silent, take the domain skill's documented default and write it down.
- Stages from the domain skill, big to small. Never start with detail.
- Build one stage with small, idempotent scripts (get-or-create by name, tag what you create, selection-free).
- Gate the stage with measurable checks (
mx_audit.verdict,mx_validate, the domain's numbers) AND a render you open with the image reader (mx_review.reviewsheet headless, or a playblast in the GUI). Experts turn the model constantly; the contact sheet is the agent's orbit. Judge it with the domain'sreferences/critique.md. - Fix before advancing. A problem found at blockout costs one script; found after texturing it costs the stage.
- Deliver with evidence: the saved version, audit and validation JSON, the sheet or playblast, and a list of what was not verified.
Never report a model, rig, shot or render as done without having looked at a render of it. Numbers first, then eyes: Fragapane's standard for a matching tool is the inverse of the expected matrix times the computed one equals identity, not "looks right" (Cult of Rig, [01:16:03]); dcc-mcp's showcase proves claims with deltas of 0.0 and color drift of at most 1/255.
3. Persona pipelines
| Brief | Skill chain | Deliverable |
|---|---|---|
| Hard-surface prop or game asset | scenario-maya-modeling, scenario-maya-retopology-uv, scenario-maya-lookdev, scenario-maya-pipeline-scripting (FBX) | low poly, UVs, PBR set, FBX re-imported and compared |
| Finish an AI-generated mesh | scenario-3d, mx_audit on the raw mesh, scenario-maya-retopology-uv, scenario-maya-lookdev, scenario-maya-pipeline-scripting |
clean, UV'd asset at a stated budget, baked from the generated high |
| Character for keyframe animation | scenario-maya-modeling, scenario-maya-retopology-uv, scenario-maya-rigging, scenario-maya-deformation, scenario-maya-animation (range-of-motion test) | referenced rig file, deformation playblast |
| Game character | the same chain with the game options of scenario-maya-rigging and scenario-maya-animation, then scenario-maya-pipeline-scripting | FBX skeletal mesh and clips for Unreal or Unity |
| Animated shot | scenario-maya-animation (fixes in scenario-maya-rigging), then scenario-maya-lighting-rendering | approved playblast, then renders |
| Look dev or hero still | scenario-maya-lookdev, scenario-maya-lighting-rendering | turntable, lit render, AOVs |
| FX shot | scenario-maya-fx (nCloth, Bifrost, MASH), then scenario-maya-lighting-rendering | versioned caches, render |
| Groomed character | scenario-maya-groom, scenario-maya-lookdev (hair shader), scenario-maya-lighting-rendering | groom, caches, render |
| Pipeline or batch tool | scenario-maya-pipeline-scripting, built on mx_run and mx_validate |
tool and JSON report |
Handoff contract: every handoff carries a new saved version (never the source overwritten), an mx_validate report for the receiving profile with no fail, mx_audit numbers for its meshes, a sheet or playblast the sender looked at, and the list of what was not verified. Units stay cm and Y-up (Maya 2027 Help; Unreal's unit is 1 cm).
| From, to | Gate in code | Look at |
|---|---|---|
| modeling to retopology-uv or rigging | validate(profile="model") clean, no locked normals on meshes that will deform; 0 n-gons on subdivided or deforming meshes; frozen, no history (FlippedNormals); characters symmetric on X, standing on the grid at the origin, Y-up facing +Z (Quick Rig requires it) |
silhouette and clay sheet |
| retopology-uv to lookdev or rigging | every face mapped, UVs in 0-1 or clean UDIM tiles, texel density stated in px/cm, overlaps only where stacked on purpose | wire and normals sheet |
| rigging or deformation to animation | rig referenced, never imported; profile="rig" clean; Skin Tools layers deleted before handoff (Maya 2027 Help: Delete Skin Layers keeps the weights) |
range-of-motion playblast |
| animation to lighting | profile="shot", rules={"fps": ...} clean; references loaded; caches versioned (Alembic is Ogawa only) |
final playblast |
| lookdev to lighting | file_paths pass; color space set per file node |
turntable |
| fx or groom to lighting | caches versioned with frame range; Nucleus and Bifrost assume 1 unit = 1 m (Maya 2027 Help) | playblast of the cache |
4. Shared toolkit (scripts/, not yet run in Maya)
Import: import sys; sys.path.insert(0, "<this skill>/scripts"). The bridge server adds this folder to Maya's sys.path, so Bridge().call("mx_audit", "audit", "crate_geo") works in the GUI too.
mx_run.py:find_mayapy(),run_subprocess(job, args, scene=, plugins=, timeout=, save_as=, maya_log=)(parent side, parsed dict),child_environment(),run(job, args)(inside mayapy).mx_bridge.py/mx_bridge_server.py:Bridge(port=7001).ping() / run(code) / call(module, fn, *a, reload=True) / fetch(id),format_record(rec); serverstart() / stop() / handle(id).mx_audit.py:audit(mesh, texture_size=2048, units="cm")returns counts, poles by valence, non-manifold, lamina, zero-area, borders and holes, winding, UV sets and bounds, UDIM tiles, overlapping and folded shells, texel density per shell, frozen transforms, pivot, history, naming, instances, symmetry on X, edge-length CV;verdict(report, profile="game"|"film"|"subd")listserror:/warn:/info:lines;audit_all(roots). Guards for all skills:mfn_mesh(shape)(None on an empty mesh),is_empty_mesh,split_history(bind pose and weight drivers are not history).mx_review.py:review(targets, out_dir, views, modes, resolution=1024, tile=512, focus=None): front, side, back, threequarter, top, low; clay, wire, silhouette, normals, opt-inchecker(checker_repeats=) and customshader_modes=;subdiv=; one labeled sheet plus tiles and each view's camera (r["cameras"], review.json,pixel_ray), the user's settings restored. Skills use these options, never_Session.turntable(targets, out_dir, frames=24);playblast(path, start, end, display={...})(GUI only).mx_validate.py:validate(profile="model"|"rig"|"shot", roots=None, rules=None, fix=()): naming, namespaces, history, frozen transforms, non-manifold, lamina, n-gons, UVs, locked normals, smoothing, render stats, layers, sets, unused and unknown nodes, plug-ins, references, script nodes, units and up axis, frame rate, missing files;SAFE_FIXES; roots held by UUID across fixes (roots_after).
5. Rules for Maya code (what generalist code gets wrong)
- Selection-free and explicit: pass node names to every command; never
selectthen operate. Capture creation returns (polyCubereturns[transform, history],#names expand) and use them, not the name you asked for (Autodesk intro series). - Long names or UUIDs for anything that can share a short name;
or []afterls,listRelatives,listConnections[added, probed]. - Undo is your job: wrap GUI edits in an undo chunk with
try/finally. OpenMaya 2.0 writes never reach the undo queue, and deleting with OM2 a node cmds created corrupts undo (Fragapane, [01:17:49]). OM2 for heavy reads and matrices, cmds for edits a user may undo. - Units: OpenMaya works in centimeters and radians; cmds returns UI units and degrees [added, probed in
test_00_probe]. - Never touch nodes you did not create; tag your own (namespace, attribute or set) and clean up by tag. Save stage versions, never over the source.
- Main thread only: cmds raises off the main thread (Maya 2027 Help).
6. Maya 2027 traps that break remembered code
- PySide6 only; ship no PySide2 fallback (it crashed 2027.0 when PySide2 was installed).
dockControlis gone:workspaceControl. - New scenes use OpenPBR Surface; its parameters do not port from Standard Surface (emission in nits, thin film in micrometers, SSS radius roles swapped).
addDoubleLinear,multiplyDoubleLinear,pointMatrixMultbecameaddDL,multiplyDL,pointMatrixMultDL(2026); plain names likepowernow create unitless nodes.- Removed flags:
getAttr(noEvaluation=),attributeQuery(computeModifies=); all keys iscutKey(time=(None, None)). - Arnold on macOS: CPU only, no OptiX; OIDN imager in new scenes; Maya IPR does not work with MtoA 5+ (Arnold RenderView); batch renders abort on license failure unless
ARNOLD_FORCE_ABORT_ON_LICENSE_FAIL=0. - Color: OCIO v2 and ACEScg; the default input space is
sRGB Encoded Rec.709 (sRGB)since 2026.2; query names, never hardcode them. - Skin Tools layers block the classic weight tools (Mirror, Copy, Hammer, Paste, Normalize, Set Max Influences) until deleted.
maya -renderis obsolete:Render -r <renderer>; Render Setup and legacy render layers are exclusive per session.mayaUSDExportdefaults:frameRange[1,1], catmullClark scheme,map1renamedst.MFnMeshraises on an empty mesh (usemx_audit.mfn_mesh);MItGeometryneeds group -1 for all vertices. PyMEL is not bundled; the Python version is undocumented: probe it.
References
references/maya-2027-traps.md: the full list of what breaks agent code, old name to 2027 replacement, with sources. Load before writing code from memory or following a pre-2025 tutorial.references/cmds-reliability.md: idioms and tested-pending procedures for reliable maya.cmds and OpenMaya 2.0 (return types, evaluated vs original data, OM2 patterns, undo, idempotent get-or-create, batch hygiene). Load before any non-trivial script.references/sources.md: every source behind this skill and the toolkit, with what each is best for.- Related skills outside this set: scenario-3d (generate meshes to finish in Maya), scenario-zbrush-expert and scenario-blender-expert (sister teams with the same protocol).
Files (skills)
-
references
-
cmds-reliability.md 18.5 KB
# Reliable maya.cmds and OpenMaya 2.0 for agents (Maya 2027) The idioms that keep generated Maya code correct, re-runnable and safe in someone's scene. Sources: Raffaele Fragapane (Cult of Rig, OpenMaya 2 stream, Dt5ODNLUuSM), the Autodesk Python scripting and plug-in series (2012 to 2013), the Maya 2027 Help (Python in Maya, API 2.0, devkit What's New), the public Maya MCP bridges (GG_MayaMCP, chadrik, Palmer, dcc-mcp) and Steve Theodore's posts; details in `sources.md`. **All code below is not yet run in Maya**; items marked [verify] are answered by `tests/code/maya-expert/test_00_probe.py`, [added] marks this toolkit's own rules. ## 1. Checklist (compact) 1. `import maya.cmds as cmds`; never `from maya.cmds import *` (shadows built-ins such as `help`) (Maya 2027 Help, Using Python). 2. Every command gets explicit node arguments; no `select` then operate, no `ls(sl=True)` in batch code (Autodesk series; [added] for agents). 3. Capture creation returns and use them: `polyCube` returns `[transform, historyNode]`, `name="crate#"` expands `#` to a unique number, a clashing name is renamed (Autodesk series, eXFGeZZbMzQ [00:02:49]). 4. Long names (`long=True`, `fullPath=True`) or UUIDs (`cmds.ls(n, uuid=True)`) wherever two nodes can share a short name [added]. 5. `or []` after `ls`, `listRelatives`, `listConnections`, `listHistory`, `polyInfo` [added: several return None on no match; probe records which]. 6. Compound `getAttr` returns `[(x, y, z)]`; take `[0]` (Autodesk series, ZmCYXzsCMO0 [00:01:23]). 7. Fixed arguments cannot be tuple-packed: `cmds.move(x, y, z, node)` or `cmds.move(*vec, node)` (Maya 2027 Help, Current limitations). 8. Flags that are Python keywords lose that name (`is` short flag: use `internalSet`; `break` long flag: use `b`); time ranges are tuples; units are strings (`"10pal"`) (same). 9. `objExists(node + ".attr")` or `attributeQuery(..., exists=True)` before `addAttr` (Autodesk series, ZmCYXzsCMO0 [00:03:59]). 10. Get-or-create by name, tag what you create, and clean up by tag, never by a wildcard that can hit user nodes (P2) [added]. 11. Never modify or delete nodes you did not create unless the task is to fix them, and then report every change (mx_validate's fix log) [added]. 12. GUI edits inside an undo chunk closed in `finally`; one agent step = one chunk (Maya 2027 Help, undoInfo: misused chunks "can leave the undo queue in a bad state"). 13. OpenMaya 2.0 writes are invisible to undo; in a GUI session write with cmds, or with OM2 only inside an `MPxCommand` owning an `MDGModifier` (Fragapane [01:17:49]; Autodesk plug-in series, BZyXe3MhEyI). 14. Never create a node with cmds and delete it with OM2 in one undoable operation: undo then tries to delete a node that is gone (Fragapane [01:17:49] to [01:18:56]). 15. Check types without exceptions: `obj.hasFn(om.MFn.kDagNode)`, `MFnDependencyNode.hasAttribute()` before `findPlug`; use the narrowest function set, it validates input for free (Fragapane [00:44:06] to [00:47:26]). 16. Use named rotation-order constants; `MTransformationMatrix` orders are off by one against `MEulerRotation`; `MEulerRotation` takes radians (Fragapane [00:59:59] to [01:01:04]). 17. OpenMaya works in internal units (centimeters, radians); cmds reads and writes UI units (the user may be in meters or degrees) [added, probed]. 18. `MFnMesh` raises on an empty mesh (devkit 2022.1), and `polyEvaluate` returns a message string, not 0, when it counts nothing, so `if not polyEvaluate(...)` is not a guard: use `mx_audit.mfn_mesh(shape)` (None on an empty mesh) or `mx_audit.is_empty_mesh(shape)` [added]. 19. Only the main thread talks to Maya: cmds raises elsewhere, OpenMaya has "unforeseen side effects" (Maya 2027 Help, Python and threading). 20. Over a bridge, never call `input()`, `pdb`, `confirmDialog`, `promptDialog`, `fileDialog2`: they open modal UI and block Maya (Maya 2027 Help, standard input; dcc-mcp suppresses dialogs for the same reason). 21. Before `file(new=True)` or `file(open=True, force=True)`: `cmds.file(q=True, modified=True)` is False or a copy was saved (GG_MayaMCP refuses otherwise). 22. Open untrusted scenes with `executeScriptNodes=False` [added: script nodes run arbitrary code on open; verify the flag]; save to a new path, never over the source. 23. Pass callables, not strings, to UI callbacks; accept `*args`; `functools.partial` instead of lambdas in loops (Theodore, callbacks cheat sheet). 24. Log `cmds.about(version=True)`, `cmds.about(apiVersion=True)`, `sys.version`, `cmds.about(batch=True)` with every report (devkit notes, [added]). 25. Prefer node networks (2024 math nodes, 2025 matrix nodes) to expressions for arithmetic: expressions with `getAttr` hide dependencies from the Evaluation Manager (Using Parallel Maya 2027). Keep expressions for formulas with no node equivalent. ## 2. cmds or OpenMaya 2.0 | Work | Use | Why | | ----------------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | One-off queries, `objExists`, `ls`, `listConnections` | cmds | Fragapane: cmds queries are often faster than API loops, sometimes faster than equivalent C++ [01:18:56] | | Edits in a live session the user may undo | cmds inside an undo chunk | OM2 does nothing for undo [01:17:49] | | Thousands of points or components, topology walks, matrices | OM2 (`MFnMesh.getPoints`, `getVertices`, `MItMeshEdge`, `MDagPath.inclusiveMatrix`) | one call instead of thousands; "long to write, fast to execute" [00:30:41] | | Callbacks | OM2 `MMessage` classes, cheap bodies, ids stored and removed | Fragapane: design the callback as a small graph first [00:08:18] | | Batch jobs that never undo | OM2 writes are fine | nothing to undo headless [added] | | Undoable OM2 operations in the GUI | an API 2.0 `MPxCommand` with an `MDGModifier` (P9) | the Autodesk undo model: `doIt` once, `redoIt`, `undoIt` | ## 3. Evaluated vs original data - A deformed mesh has two shapes: the visible one (deformed result) and an intermediate `...ShapeOrig` (`intermediateObject` 1) that feeds the deformer chain. `listRelatives(x, shapes=True, noIntermediate=True)` returns the visible shape; `MFnMesh` on it reads deformed points; read the Orig shape for rest positions [added, verify]. - Maya's graph is pull-based: querying a dirty plug (`getAttr`, `xform(q=True, ws=True)`, `MFnMesh.getPoints`) evaluates it; no refresh call is needed after `setAttr` [added]. To read another frame without moving time: `cmds.getAttr(plug, time=t)`; to step, `cmds.currentTime(t, update=True)` [verify how Cached Playback interacts with scripted stepping]. - `getAttr(".translate")` is local; world values come from `xform(q=True, ws=True, ...)` (UI units) or `MDagPath.inclusiveMatrix()` (cm). - History: `listHistory(shape, pruneDagObjects=True)` lists upstream nodes; deformers are the ones whose `nodeType(n, inherited=True)` contains `geometryFilter`. Delete construction history with `delete(n, constructionHistory=True)`; on skinned meshes use `bakePartialHistory(n, prePostDeformers=True)` (Delete Non-Deformer History) [verify flag]. - Instances share one shape with several DAG paths: `MDagPath.isInstanced()`, `listRelatives(shape, allParents=True)` (API 2.0 help, scene graph). - Referenced nodes: `referenceQuery(n, isNodeReferenced=True)`; edits to them live in the parent's reference node and replay by name and path, so never rename or re-parent inside a referenced file (Maya 2027 Help, reference edits). ## 4. Procedures (not yet run in Maya; test scripts under `tests/code/maya-expert/`) **P1. Session probe** ```python import sys, platform import maya.cmds as cmds def session_info(): return {"maya": cmds.about(version=True), "api": cmds.about(apiVersion=True), "python": sys.version.split()[0], "machine": platform.machine(), "batch": cmds.about(batch=True), "scene": cmds.file(q=True, sceneName=True), "modified": cmds.file(q=True, modified=True), "units": (cmds.currentUnit(q=True, linear=True), cmds.currentUnit(q=True, angle=True), cmds.currentUnit(q=True, time=True), cmds.upAxis(q=True, axis=True))} ``` `mx_run.session_info()` is the headless version. **P2. Idempotent get-or-create, tagged, and cleanup by tag** [added] ```python TAG = "mxAgent" def get_or_create(node_type, name, parent=None): """Long name of our node `name`, created if missing. Refuses a user node of that name.""" if cmds.objExists(name): n = cmds.ls(name, long=True)[0] if cmds.attributeQuery(TAG, node=n, exists=True): return n raise RuntimeError("%s exists and is not ours: choose another name" % name) n = cmds.createNode(node_type, name=name, parent=parent) if parent else cmds.createNode(node_type, name=name) cmds.addAttr(n, longName=TAG, attributeType="bool", defaultValue=True) return cmds.ls(n, long=True)[0] def our_nodes(): return [n for n in cmds.ls(long=True) or [] if cmds.attributeQuery(TAG, node=n, exists=True)] ``` `createNode` of a shape type without a parent also creates a transform; tag the transform too. Alternatives: a namespace for temporary setups (mx_review creates everything in `mxReview` and removes the namespace with its content) or an object set. **P3. Undo chunk** ```python from contextlib import contextmanager @contextmanager def undo_chunk(name="agent"): cmds.undoInfo(openChunk=True, chunkName=name) try: yield finally: cmds.undoInfo(closeChunk=True) # always close ``` The bridge server wraps every call in one chunk already (`mx:<job id>`). **P4. Undo round trip as the acceptance test** (Autodesk plug-in series, BZyXe3MhEyI [00:11:47]) ```python def undo_round_trip(fn, *a, **kw): before = sorted(cmds.ls(long=True)) with undo_chunk("probe"): fn(*a, **kw) after = sorted(cmds.ls(long=True)) cmds.undo() undone = sorted(cmds.ls(long=True)) == before cmds.redo() redone = sorted(cmds.ls(long=True)) == after return undone and redone ``` In mayapy, check `cmds.undoInfo(q=True, state=True)` first [verify undo is on headless]. **P5. OpenMaya 2.0 reads** (Fragapane; API 2.0 help) ```python import maya.api.OpenMaya as om def dag(name): sl = om.MSelectionList(); sl.add(name); return sl.getDagPath(0) def mobject(name): sl = om.MSelectionList(); sl.add(name); return sl.getDependNode(0) def world_points(mesh): # one call, centimeters; [] on an empty mesh import mx_audit # scenario-maya-expert/scripts on sys.path fn = mx_audit.mfn_mesh(mesh) # MFnMesh raises on an empty mesh (2022.1) return fn.getPoints(om.MSpace.kWorld) if fn else [] def world_matrix(name): # [added] instance-aware, shortest route return dag(name).inclusiveMatrix() def world_matrix_from_plug(obj): # Fragapane's plug route; MDataHandle.asMatrix() returns garbage if not obj.hasFn(om.MFn.kDagNode): return None p = om.MFnDagNode(obj).findPlug("worldMatrix", False).elementByLogicalIndex(0) return om.MFnMatrixData(p.asMObject()).matrix() def matrices_match(expected, computed, tol=1e-5): # the rigorous check [01:16:03] return (expected.inverse() * computed).isEquivalent(om.MMatrix(), tol) # [verify signature] ``` **P6. Unit-safe values** [added] ```python UI_TO_CM = {"mm": 0.1, "cm": 1.0, "m": 100.0, "in": 2.54, "ft": 30.48, "yd": 91.44} def ui_to_cm(v): return v * UI_TO_CM[cmds.currentUnit(q=True, linear=True)] ``` Never change the scene's units to make a script work; convert. Nucleus and Bifrost assume 1 unit = 1 m whatever the working unit (Maya 2027 Help). **P7. Selection-free attribute work** (Autodesk series, improved) ```python if not cmds.attributeQuery("expansion", node=ctrl, exists=True): cmds.addAttr(ctrl, longName="expansion", attributeType="double", minValue=0, maxValue=100, defaultValue=100, keyable=True) cmds.addAttr(node, longName="fkBufferMsg", attributeType="message") # metadata link cmds.connectAttr(buffer + ".message", node + ".fkBufferMsg") # (Fragapane [00:14:56]) cmds.keyTangent(obj, attribute="rotateY", time=(s, e), inTangentType="linear", outTangentType="linear") cmds.cutKey(obj, attribute="rotateY", time=(None, None)) # all keys since 2022 ``` Constraint weight attribute names: query `weightAliasList=True`, never hardcode `W0` [verify flag]. **P8. Headless job for mx_run** ```python # job_export.py run: python3 mx_run.py --scene in.ma --plugins fbx job_export.py -- /abs/out/asset.fbx import maya.cmds as cmds import maya.mel as mel def main(argv): out = argv[0] roots = cmds.ls("|*_GRP", long=True) or [] # explicit roots, no selection state kept mel.eval("FBXPushSettings") try: mel.eval("FBXResetExport") mel.eval("FBXExportSmoothingGroups -v true") cmds.select(roots, replace=True) # FBXExport -s needs a selection mel.eval('FBXExport -f "%s" -s' % out) finally: mel.eval("FBXPopSettings") return {"fbx": out, "roots": roots} ``` The full Unreal preset lives in scenario-maya-pipeline-scripting. Settings are global state: reset, set every option, log them with `-q` (Maya 2027 Help, FBX commands). **P9. Undoable OpenMaya command** (Autodesk plug-in series translated to API 2.0) ```python import maya.api.OpenMaya as om def maya_useNewAPI(): pass class AgentMakeGroup(om.MPxCommand): kName = "agentMakeGroup" def __init__(self): om.MPxCommand.__init__(self) self.mod = om.MDagModifier() def isUndoable(self): return True def doIt(self, args): # runs once: set up, then redoIt node = self.mod.createNode("transform") self.mod.renameNode(node, "agentGrp#") # [verify] '#' expansion self.redoIt() def redoIt(self): self.mod.doIt() def undoIt(self): self.mod.undoIt() def initializePlugin(obj): om.MFnPlugin(obj).registerCommand(AgentMakeGroup.kName, AgentMakeGroup) def uninitializePlugin(obj): om.MFnPlugin(obj).deregisterCommand(AgentMakeGroup.kName) ``` MDGModifier deletion: call `doIt()` before `deleteNode()` and again right after (API 2.0 help). Node IDs for internal plug-ins: `0x00000` to `0x7ffff`. **P10. Plug-in reload** (Autodesk plug-in series, v1d8fCtIROI [00:06:49]) ```python def reload_plugin(path, name): cmds.flushUndo() # no instance of the old command left on the undo stack if cmds.pluginInfo(name, q=True, loaded=True): cmds.unloadPlugin(name) cmds.loadPlugin(path) ``` **P11. Scene callbacks with a registry** (Fragapane's design discipline; API names [verify]) ```python _CALLBACKS = [] def watch_attr(node, attr, fn): obj = mobject(node) def cb(msg, plug, other, client): if msg & om.MNodeMessage.kAttributeSet and plug.partialName(useLongNames=True) == attr: fn(plug) _CALLBACKS.append(om.MNodeMessage.addAttributeChangedCallback(obj, cb)) def clear_callbacks(): for cid in _CALLBACKS: om.MMessage.removeCallback(cid) del _CALLBACKS[:] ``` Run the body as a plain function first; callbacks are not saved in the scene (Fragapane [00:43:30], [00:01:10]). ## 5. Lint before running generated code Refuse or rewrite: `from maya.cmds import *`; `import pymel`; `PySide2`, `shiboken2`, `QtWidgets.QAction`, `dockControl`; Python 2 syntax; `OpenMayaMPx` or `MScriptUtil` in API 2.0 code; `noEvaluation`, `computeModifies`, `time=()`; `addDoubleLinear`, `multiplyDoubleLinear`, `pointMatrixMult`; `ls(sl=True)` in batch code; `input(`, `pdb`, `confirmDialog`, `promptDialog`, `fileDialog2` over a bridge (mx_bridge lints the last group automatically); string UI callbacks; `file(new=True, force=True)` without a modified check (scripting digest, quality gates). ## 6. Runtime gates - Version probe logged; required plug-ins loaded (`pluginInfo(p, q=True, loaded=True)`). - Undo round trip passes for every scene-changing tool (P4). - Matrix gate `matrices_match` for any snapping, matching or rigging math. - Each OM2 helper returns cleanly (None or a neutral value) on a wrong-type node (Fragapane [00:45:44]). - Batch: child exit code 0, `MX_RESULT` parsed, uninitialize reached; one child per scene. - Bridge: port on loopback only (`lsof -nP -iTCP:7001 -sTCP:LISTEN`), every reply parsed, timeout set, port closed at the end. ## 7. GUI habit, agent substitute | Human habit | Agent substitute | | --------------------------------------- | --------------------------------------------------------------------------------- | | Learn the MEL from Script Editor echo | `cmds.help("<command>")` and the CommandsPython reference; never guess a flag | | Orbit the viewport | `mx_review.review` contact sheet (headless) or `mx_review.playblast` (GUI) | | Plug-in Manager | `loadPlugin`, `pluginInfo(q=True, loaded=True)`, `mx_run --plugins` | | Connection Editor, Add Attribute window | `connectAttr`, `addAttr` (message, matrix and array types the window cannot make) | | Mesh > Cleanup (select matching) | `polyInfo(nonManifoldEdges=, nonManifoldVertices=, laminaFaces=)` plus `mx_audit` | | Outliner with DAG Objects Only off | `ls()` with types; `mx_validate` unused, unknown, sets, layers checks | | Ctrl+Z | `cmds.undo()`; one bridge call is one chunk | | Render View IPR | not with MtoA 5+; `mx_review` stills headless, Arnold RenderView in the GUI | -
maya-2027-traps.md 23.6 KB
# Maya 2027 traps for agent code What breaks code written from memory or from 2016 to 2024 tutorials, with the 2027 replacement. Condensed from the project's `sources/maya-version-deltas.md` (every row there is cited to a saved Autodesk or Arnold page; the source keys below are that file's keys, for example DK = devkit What's New 2022 to 2027, WN27 = What's New in Maya 2027). Nothing here has been run in Maya yet: rows marked [verify] are open questions the probe test (`tests/code/maya-expert/test_00_probe.py`) answers first. **Read first:** `cmds.about(version=True)` at session start. Smart Bevel output, MtoA (5.6.0 / 5.6.1.1 / 5.6.2) and USD for Maya (0.35 / 0.36 / 0.37) change between 2027.0, 2027.1 and 2027.2 (WN27). ## 1. Python, Qt, API | Do not use | Use in 2027 | Since | Source | | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -------- | | Python 2 syntax (`print x`, `raw_input`, `long`, `thread`), Maya's own 2027 help samples included | Python 3; the shipped version is not documented: `mayapy -c "import sys; print(sys.version)"` [verify] | Python 2 removed 2023 | DK, PY | | PySide2, shiboken2, `pyside2-uic`, and `try: PySide6 except ImportError: PySide2` fallbacks | PySide6, shiboken6, `pyside6-uic`; import PySide6 only (a PySide2 fallback crashed 2027.0 when PySide2 was on the system, fixed 2027.1) | 2025 | QT, WN27 | | `wrapInstance(long(ptr), ...)` | `wrapInstance(int(ptr), ...)` | 2025 | QT | | `QtWidgets.QAction`, `QActionGroup`, `QShortcut`, `QFileSystemModel`; `QRegExp`; int arithmetic on Qt enums | `QtGui.*`; `QRegularExpression`; enum members (true Python enums) | 2025 | QT | | `mayaSharedGLWidget` | `QApplication.instance().property("mayaSharedQOpenGLContext")` | 2025 | QT | | `dockControl` | `workspaceControl` with a `uiScript` importable at every startup, or `MayaQWidgetDockableMixin.show(dockable=True)` | not stated | QT | | PyMEL in shipped code | `maya.cmds` + `maya.api.OpenMaya`; PyMEL is third party, unsupported by Autodesk, pip-installed | not stated | PY | | API 1.0 idioms (`MScriptUtil`, `OpenMayaMPx.asMPxPtr`, output arguments, `setWritable()`) | API 2.0: return values, properties (`fn.writable = True`), creator returns the instance, `maya_useNewAPI()` in plug-ins; never pass a 1.0 object to 2.0 | not stated | API2 | | `MFn.kAttribute2Long`, `kAttribute3Long` | `kAttribute2Int`, `kAttribute3Int` | 2022.1 | DK | | `MItGeometry(handle, 0, ...)` for all vertices | group id `-1` (0 now yields nothing) | 2022 | DK | | `MFnMesh(dag)` on an empty mesh | `mx_audit.mfn_mesh(shape)` (None when empty): it raises since 2022.1, and `polyEvaluate` returns a string, not 0, on nothing to count | 2022.1 | DK | | Workarounds for `MFnAnimCurve.evaluate()` / `addKey()` treating MTime as ticks | remove them: fixed in 2024 | 2024 | DK | | `==` between array `MPlug`s | `MPlug.isExactlyEqual()` | 2024 | DK | | Names as dict keys for nodes | `om.MObjectHandle` (hashable since 2024) or `cmds.ls(n, uuid=True)` | 2024 | DK | | `evalDeferred` from Python per older pages | `maya.utils.executeDeferred()`; `cmds.evalDeferred` also has a Python page [verify which works] | contradiction in docs | PY | | `executeInMainThreadWithResult`, `scriptJob` in mayapy | not available in batch: call Maya directly on the main thread; `scriptJob` needs the GUI | not stated | PY | ## 2. Commands and flags | Do not use | Use in 2027 | Since | Source | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------- | ------ | | `getAttr(noEvaluation=True)`, `attributeQuery(computeModifies=True)`, `dbtrace(level=)` | removed; drop the flag | 2026 | DK | | `cutKey(time=())` for all keys | `cutKey(time=(None, None))` | 2022 | DK | | `about(ltVersion=True)` | `about(creativeVersion=True)` | 2023.2 | DK | | `greasePencilCtx` and friends | `bluePencil*` commands | 2023 | DK | | `performSkinCluster.mel` | `maya.internal.nodes.skincluster.cmd_create.Command().executeWithDefaults()` | 2022 | DK | | `skinCluster(ignoreHierarchy=)`; `ignoreBindPose` | `bindMethod`; ignored | not stated | SKIN | | `maya -render` | `Render -r <renderer>`; without `-r` the scene's renderer | not stated | PY | | `Render -pre` / `-post` | `-preRender` / `-postRender` | obsolete | PY | | `dgdebug`, `dgInfo` | `dbpeek` | deprecated | PAR26 | | `dopeSheetEditor(showTicks=)` | no effect since 2025 | 2025 | DK | | New since 2026 you may use: `xform(parentSpace=)`, `loadPlugin(pluginPath=)`, `playblast(exposure=, gamma=, partialSave=)`, `confirmDialog(detailedMessage=)`, `-dumpInfo` on deformer commands; 2027: `polySmartBevel`; updated `playblast`, `loadPlugin`, `workspaceControl`, `confirmDialog` (flags not listed) [verify with `cmds.help`] | | 2026, 2027 | DK | commandPort (2027 reference, PY): default `sourceType` is MEL; default `bufferSize` 4096 characters, longer commands close the connection and longer results are replaced by an error; Python returns the result of one statement only; `name=":7001"` is INET "on the local host" [verify the bind with lsof], a name without a colon is a UNIX socket under /tmp, a name starting with `/` a full socket path; no authentication. Third-party bridges report a Maya 2024 echoOutput bug, a security dialog on first connection, and no commandPort under maya.standalone (Theodore 2014) (MCP) [verify all three]. ## 3. Nodes (rigging and utilities) | Do not use | Use in 2027 | Since | Source | | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------- | | `addDoubleLinear`, `multiplyDoubleLinear`, `pointMatrixMult` | `addDL`, `multiplyDL`, `pointMatrixMultDL`; old scenes open with warnings | 2026 | WN26 | | Assuming `createNode("power")`, `"absolute"` etc. carry units | they now create unitless nodes; check `getAttr(n + ".input", type=True)` [verify] | 2026 | WN26 | | `axisXFromMatrix`, `axisYFromMatrix`, `axisZFromMatrix` | `axisFromMatrix` | 2025 | DK | | Offset group transforms above controls | `offsetParentMatrix` (with `inheritsTransform` off for world-space matrices); `parentMatrix` node (2025), `blendMatrix`, `aimMatrix`, `pickMatrix`, `uvPin`, `proximityPin` | 2020 to 2025 | MTX, WN25 | | `blendMatrix` as a weighted average | it is an ordered blend; `rotationFromMatrix` assumes XYZ | not stated | MTX | | FBIK | HumanIK (FBIK forces Serial evaluation, being deprecated) | not stated | PAR27 | | `ik2Bsolver`, `ikMCsolver`, `ikSpringSolver` without loading them | load them first; `ikHandle` lists only RP, SC and spline as valid [verify] | not stated | SKEL | | `joint(e=True, oj=...)` on a joint with rotations or no child | silently ignored: zero rotations first; negative primary axes (`xyzNeg`) fixed in 2027.1 | 2027.1 | SKEL, WN27 | | Legacy particles, fluids, rigid bodies in rigs meant to run in parallel | Nucleus, Bullet, Bifrost (legacy dynamics force DG) | not stated | PAR26 | | Expressions that call `getAttr` | node networks: expressions hide dependencies from the Evaluation Manager (parallel by default since 2016) | 2016 | PAR27 | ## 4. Modeling, UVs, retopology - **Smart Bevel** `polySmartBevel` (2027): Width is in world units, not a fraction; Depth is a profile (1 flat chamfer, 0 to 1 rounded, negative concave); Filter defaults to Selected Edges; 2027.1 fixed spikes and arc welding, 2027.2 is faster. The old bevel is now "legacy bevel" (POLY, WN27). - **Smart Extrude** (2025) is Edit Mesh > Smart Extrude; the old shift-drag is "Shift Extrude" (WN25). - **Booleans:** `polyBoolean` node (2023), 3ds Max engine (2025.3), Volume mode (2026); select meshes, not their group (DK, WN25, WN26, POLY). - **UVs:** Unfold3D is the default for Unfold, Optimize and Layout and refuses non-manifold meshes; "Relax" is Optimize > Legacy; the UV Texture Editor is the UV Editor + UV Toolkit; Texel Density needs Map Size set first (UV). - **Retopologize** (`polyRetopo`): delete history after it or it re-runs; Keep Original renames the input to `<name>_original` (RETO). Reduce (`polyReduce`) is decimation, not retopology (FUND). - **Quad Draw** is interactive only (RETO). **Flow Retopology** is a cloud service needing sign-in and network: not for headless agents [added]. - **Smooth Mesh Preview** is display only (OpenSubdiv Catmull-Clark); crease sharpness 0 to 10, above 5 rarely needed (POLY, OSD). ## 5. Deformation - **Skin Tools** (2027, built on ngSkinTools): layers exist only after Initialize Skin Layers; once they exist, classic Paint Skin Weights, Set Max Influences, Hammer, Paste, Move Weights, Import Weight Maps, Mirror, Substitute Geometry, Normalize and Copy Skin Weights are blocked; Smooth Skin Weights errors with several layers; Delete Skin Layers keeps the evaluated weights and is the handoff step; no Skin Tools Python API is in the saved docs (SKIN, WN27) [verify what `skinPercent` and `MFnSkinCluster.setWeights` do with layers present]. - `skinCluster(multi=True)` for a second cluster (2024); `bm=3` needs `geomBind`; editing `mi` or `dr` in edit mode discards painted weights; Rigid Bind is gone (SKIN, DK). - `copySkinWeights`: pass `mirrorMode` and `surfaceAssociation` explicitly (docs disagree on the default) (SKIN). - ML Deformer (2025.2+) downloads PyTorch into `<MAYA_APP_DIR>/mlDeformer/...`; it sits above the deformers it learns (WN25, BLS). ## 6. Animation - Default tangent is Auto Span (Legacy) per the Tangents menu page (one line still says Clamped) [verify with `keyTangent(q=True, g=True, itt=True)`]; Lock/Free Tangent Weight is now Tangent Length; Butterworth is Smooth Filter (Butterworth) (GE). - Cached Playback is on by default; Trax, XGen, motion blur, classic particles and cloth disable it (CACHE). Time Editor replaces Trax and does not mix with animation layers (TE). - Ghost Selected is the Ghost Editor; Motion Trail Editor since 2025 (GHOST, WN25). - The Camera Sequencer is the Sequencer in 2027 (OTIO in 2027.1) (SEQ, WN27). - HumanIK: bake from the HumanIK window, not Bake Simulation (HIK). - Playblast: WebM since 2026; exposure and gamma follow the viewport toolbar (WN25, WN26). Playblast needs a model panel: GUI only [verify]. ## 7. Look dev, lighting, color (Arnold for Maya 5.6.x, Arnold 7.5.x) - **OpenPBR Surface** (`openPBRSurface`) is the default shader in new scenes (2026). Not 1:1 with Standard Surface: emission Luminance in nits scaled by `nits_per_unit`; thin film in micrometers (Standard Surface: nanometers); SSS Radius is scalar and Radius Scale the RGB multiplier (swapped); Sheen is Fuzz; dispersion needs Dispersion Scale above 0; metal Base Color is F0 and Specular Color the F82 tint (OPBR). Standard Surface still works and is what Substance presets target (SS). - **Arnold GPU and OptiX are not available on macOS**: CPU plus OIDN (runs on Apple GPUs since MtoA 5.4.5); OIDN imager added to new scenes (WN25, WN27, LGT). - **Batch licensing:** command-line Arnold renders carry a watermark without batch licenses; since Arnold 7.3 batch renders abort on license failure; `ARNOLD_FORCE_ABORT_ON_LICENSE_FAIL=0` watermarks instead (WN25, LGT). Arnold 7.5 uses license version ARNOL_2027 (WN27). - **Maya IPR does not work with MtoA 5 or newer**: Arnold RenderView (WN27). - **Sampling:** Global Light Sampling replaces per-light samples; per-light `sampling_mode`; most pixel filters are legacy (Gaussian 2.0 or Blackman-Harris); MtoA IPR defaults Camera AA 3, Diffuse 2, Specular 2, Transmission 2, SSS 2 (SMP, WN27). - **AOVs:** `direct_diffuse` is `diffuse_direct`; AOV shaders are Legacy, use Custom AOVs; Cryptomatte `object_split_instances` off by default (AOV, WN27). - **Bloom:** simple mode and `bloom_radius` deprecated (WN27). Mesh lights via Arnold > Light > Mesh light, not `translator = mesh_light` (LGT). - **Color management:** OCIO v2 Maya-default config, ACEScg rendering space, ACES sRGB view; synColor gone (2026); ACES2065-1 no longer a rendering space (2026.2); default input role `sRGB Encoded Rec.709 (sRGB)` (2026.2); linear sRGB is spelled two ways in the help: query names with `colorManagementPrefs`, never hardcode; rules tag .tx, .hdr and .exr as Raw, so a linear-sRGB HDRI is read as ACEScg; rules set only new file nodes (Reapply Rules); no gammaCorrect nodes; Apply Output Transform to Renderer for preview PNGs only, off for EXR (CM, WN26, BRJ-CM). - Render Setup and legacy render layers are exclusive per session; Render Setup nodes are dropped on import or reference (CM). ## 8. FX and grooming - Legacy Bifrost Fluids containers and BOSS: rebuild with Bifrost Graph compounds (`basic_liquid_graph`, `basic_aero_graph`, `basic_combustion_graph`, MPM compounds); Bifrost is 3.0 in 2027 and always installed since 2026.2; Nucleus and Bifrost assume 1 unit = 1 m (Space Scale 0.01 for cm scenes) (BSIM, WN26, NCL, PIPE). Some Bifrost node names are inconsistent across pages: read them in the graph [verify]. - MASH: Noise, Trig, Mute are Signal and Strength; default geometry type Repro (MASH). - nCloth Bend Solver default is High Quality (NCL). - XGen: legacy descriptions are not compatible with Interactive Groom tools; hair shader is Standard Hair (`scattering_mode` in 5.6.0); load MtoA before XGen; Card primitives unsupported in MtoA (XGIG, HAIR). ## 9. Pipeline - **USD for Maya 0.35 to 0.37:** `mayaUSDExport` defaults to `frameRange` [1,1], `defaultMeshScheme` catmullClark (suppresses normals: use `none` for game meshes), `exportSkels`/`exportSkin` none, `map1` renamed `st` unless `preserveUVSetNames`; `-parentScope` is `-rootPrim`; pass `convertMaterialsTo` explicitly (MaterialX is the default target on new installs since 0.34 while the command readme says UsdPreviewSurface); `mayaUSDImport` ignores animation unless `readAnimData=True` (USDCMD, WN26). - **FBX:** set the file version explicitly (the options page still names 2019.2 as default; Unreal uses 2020.2); Smoothing Groups, never `FBXExportHardEdges`; tangents only on all-triangle meshes; the split-take accumulator persists until `-clear`; wrap exports in `FBXPushSettings` / `FBXPopSettings` (FBXUE, PIPE). - **Alembic:** Ogawa only (DK). **References:** never rename or restructure a child file in use (edits replay by name and path) (PIPE). **Units:** working units reset to cm in each new file (PIPE). ## 10. macOS specifics - mayapy: `/Applications/Autodesk/maya2027/Maya.app/Contents/bin/mayapy`; `MAYA_LOCATION` is `.../Maya.app/Contents` while `internalVar(mayaInstallDir=True)` has no `Contents` (PY, DK). - pip without admin rights: `mayapy -m pip install <pkg> --target ~/Library/Preferences/Autodesk/maya/2027/scripts/site-packages` (the folder exists after Maya's first start) (PY). - `maya -log` and `Render -log` are Windows only: redirect stdout (PY). Batch renders can hang on localhost resolution: fix `/etc/hosts` (PY). Batch output is set by `MAYA_BATCH_STDOUT_LOGGING_LEVEL` (default none) and `MAYA_BATCH_STDERR_LOGGING_LEVEL` (default all), analytics by `MAYA_DISABLE_ADP=1`; mx_run sets all three for its children (DK 2022). - Hotkeys: Maya's docs map Ctrl to Control and Alt to Option, not Command (FUND). Plug-ins are `.bundle`; path lists use `:` (QT). CgFX, synColor and the stereoCamera plug-in are unavailable on Apple Silicon (DK). - GPU Override is documented as OpenCL with AMD and NVIDIA thresholds only; behavior on Apple GPUs is undocumented [verify] (PAR27). -
sources.md 11.3 KB
# Sources behind scenario-maya-expert Per-source expert notes are in the project's `notes/` (never edited to fit this skill); saved documentation pages in `sources/docs/`; the version map in `sources/maya-version-deltas.md`. Credentials are as stated in the notes (the manifest verified some; "unverified" is kept where the note says so). ## Experts and talks | Source | Who | Best for | Best timestamps | | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cult of Rig, "Intro to OpenMaya2, designing a callback", https://www.youtube.com/watch?v=Dt5ODNLUuSM (2017) | Raffaele Fragapane, former supervisor at Animal Logic (animation pipeline, LEGO Movie rigging tech) | cmds vs OM2 judgment, the OM2 undo trap, world matrix extraction, type checks without exceptions, callback design, matrix identity check | [00:08:18] callback as a graph; [00:27:21] to [00:42:21] world matrix route; [00:44:06] hasFn/hasAttribute; [00:59:59] rotation-order constants; [01:16:03] identity check; [01:17:49] undo trap; [01:20:37] cmds only for queries | | FlippedNormals, "Ultimate Guide to Clean Scenes", https://www.youtube.com/watch?v=ToWRH4IXF7A (2018) | Henning Sanden and Morten Jaeger, former senior film character artists (London VFX) | the modeling handoff checklist behind `mx_validate` profile "model" and `mx_audit` rules: history, frozen transforms, n-gons by hand, lamina and non-manifold, UVs in 0-1, unique names, smoothing off, render stats | [00:08:31] rebuild via OBJ; [00:11:50] never Cleanup n-gons; [00:18:16] unique names; [00:20:47] two-sided lighting hides flips; description checklist | | RISE VFX USD pipeline, https://www.youtube.com/watch?v=8UIW-g1_heg (FMX 2023) | Oliver Markowski (Head of 3D), Kolja Hübschmann (pipeline) | layer ownership, value clips, pinning, validation before publish | see `notes/pipeline/` digest | | New Tales from the Borderlands, https://www.youtube.com/watch?v=lfWkJrkhd2U (GDC 2023) | Marion Guignolle, Senior Technical Animator, Gearbox Québec | generated names and paths, publish diffs, gates at the point of action | [00:22:11] approved-shot warnings; [00:24:23] publish diff | | Maya Performance Webinar Part 2, https://www.youtube.com/watch?v=rhtERzmAoUM (2019) | Steven Roselle, Autodesk Maya product manager | profile before optimizing; evaluation-bound vs draw-bound | [00:04:14] Profiler ranking | ## Autodesk training series (official, presenters not named) - "Introduction to Python Scripting in Maya" parts 1, 3, 5: https://www.youtube.com/watch?v=eXFGeZZbMzQ, https://www.youtube.com/watch?v=ijMyo-6T8Ow, https://www.youtube.com/watch?v=ZmCYXzsCMO0 (2013). Return shapes, `#` names, re-runnable scripts, linear tangents for loops, `objExists` before `addAttr`. Python 2 on screen: translate. - "Introduction to Maya Python Plug-ins" 1 to 3: https://www.youtube.com/watch?v=Wf03mSCdh-Q, https://www.youtube.com/watch?v=BZyXe3MhEyI, https://www.youtube.com/watch?v=v1d8fCtIROI (2012 to 2013). Undoable commands (`isUndoable`, `doIt` once, `redoIt`, `undoIt`, modifier as a queue), the undo round-trip test [00:11:47], `flushUndo` before unloading [00:06:49]. API 1.0 on screen: translated to 2.0 in `cmds-reliability.md`. ## Official documentation (Maya 2027 Help and Developer Help, retrieved 2026-09-24) - Python in Maya (mayapy, maya.standalone, pip, threading, scriptJob, MEL vs Python), commandPort, undoInfo, evalDeferred, command-line start and rendering: https://help.autodesk.com/cloudhelp/2027/ENU/Maya-Scripting/files/GUID-C0F27A50-3DD6-454C-A4D1-9E3C44B3C990.htm. Ground truth for the execution channels and the capability matrix. - Maya Python API 2.0 (MObject and function sets, MDGModifier, plug-ins, undo, threading): https://help.autodesk.com/cloudhelp/2027/ENU/Maya-DEVHELP/files/Maya_DEVHELP_Maya_Python_API_html.html - Devkit What's New 2022 to 2027 (removed flags, renamed nodes, API behavior changes): https://help.autodesk.com/cloudhelp/2027/ENU/Maya-DEVHELP/files/Whats-New-Whats-Changed/2027-Whats-New-in-API.html - Qt6 / PySide6 migration, workspace controls, modules: https://help.autodesk.com/cloudhelp/2027/ENU/Maya-DEVHELP/files/Whats-New-Whats-Changed/2025-Whats-New-in-API/Qt6Migration.html - Managing scenes: projects, referencing, Alembic, GPU cache, FBX MEL commands, scale: https://help.autodesk.com/cloudhelp/2027/ENU/Maya-ManagingScenes/files/GUID-9CE78B5A-7E9F-45E6-AB6D-66795E5656F4.htm - Fundamentals (DAG vs DG, history, freeze, Optimize Scene Size): https://help.autodesk.com/cloudhelp/2027/ENU/Maya-Basics/files/GUID-9C82DAE2-8F19-4F63-BA1A-C28E95DDBE58.htm - UV Toolkit, Unfold3D, texel density, UDIM tiles (optimized game vs technical mapping): saved as `sources/docs/modeling__maya2027-help-uv-toolkit-unfold-layout.md`. - Color management and Render Setup (output transform for renders and playblasts): `sources/docs/lookdev-lighting__maya-2027-color-management-render-setup.md`. - What's New in Maya 2025, 2026, 2027 (point-release changes, known issues, Arnold license abort): `sources/docs/scripting-pipeline__whats-new-maya-202{5,6,7}.md`. - Arnold for Maya user guide: batch rendering and watermark, kick, lights, cameras, GPU FAQ (no Arnold GPU on macOS); AOVs, OIDN and denoising; sampling defaults: `sources/docs/lookdev-lighting__arnold-*.md`. - USD for Maya 2027 and the maya-usd command readme (dev branch): `sources/docs/scripting-pipeline__usd-for-maya-2027.md`, https://raw.githubusercontent.com/Autodesk/maya-usd/dev/lib/mayaUsd/commands/Readme.md - Using Parallel Maya 2026 and 2027 (evaluation modes, expressions and dependencies, GPU Override): `sources/docs/rigging__using-parallel-maya-2027.md`. ## Community references - Pixar OpenSubdiv modeling tips (valence, triangles and n-gons "used sparingly", crease sharpness 0 to 10): `sources/docs/modeling__pixar-opensubdiv-modeling-tips.md`. Used by `mx_audit` profile "subd". - Polycount wiki (poles away from deforming areas, UV shells off the 0-1 border unless tiling, triangulate before baking): `sources/docs/modeling__polycount-wiki-topology-polycount-normals-uvs.md`. - Epic Games, FBX Static Mesh Pipeline (pivot at origin, FBX 2020.2, collision naming): https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-static-mesh-pipeline-in-unreal-engine ## Maya bridges (design references for mx_bridge) - GG_MayaMCP (Gimbal Goats, unverified credential): https://github.com/GimbalGoats/GG_MayaMCP. Typed tools, raw exec off by default, a handler that execs and returns JSON, unsaved-changes guard, 2024 echoOutput workaround. - chadrik/maya-mcp-server (Chad Dombrova, Scanline VFX, PyMEL maintainer): raw Python with stdout and stderr streaming; "the agent must see Maya's output". - PatrickPalmer/MayaMCP (unverified): two round trips for results through the default MEL port; namespace-prefixed helpers. - dcc-mcp-maya: main-thread affinity, per-tool timeouts, dialog suppression, proof by numbers (matrix deltas 0.0, color drift at most 1/255). - Steve Theodore, theodox.github.io (2014, unverified credential): no commandPort under maya.standalone, `MAYA_SKIP_USERSETUP_PY`, modules, UI callback scoping. Re-test on 2027. ## Toolkit decisions not taken from a source ([added]) Result files instead of socket replies for the bridge; the exchange directory `/tmp/mx_bridge_<uid>` at mode 0700; hard `os._exit` after `uninitialize`; Maya's stderr at `warning` in mayapy children; Maya messages captured by kind (warning, error, stack trace) with MCommandMessage and deduplicated against stderr; the deformer-input rule of `split_history` (a node whose every output ends on a deformer's non-geometry input); roots held by UUID across validation fixes; the checker default of 16 repeats per UV tile; silhouette computed from alpha so a license watermark never reaches it; duplicates in a temporary namespace for review renders; verdict thresholds `td_ratio_max=2.0`, `symmetry_min_pct=99.0`, zero tolerances relative to the bounding box diagonal (1e-6), overlap grid of 256 cells per UV unit. Each is documented where it is used and can be overridden. ## Revision history - 2026-09-24, refactor pass 1 (blind grade M8 and `tests/OPEN_ISSUES.md`): `mx_run` children get `MAYA_DISABLE_ADP=1` and the batch logging levels (devkit What's New 2022), `maya_log=` / `--maya-log`; `mx_bridge` records always carry `stdout`, `stderr` and `maya_messages`, with partial output on a timeout (chadrik, maya-mcp-server); `mx_audit.mfn_mesh`, `is_empty_mesh` and `split_history` (MFnMesh raises on an empty mesh, devkit 2022.1; bind pose not history); `mx_validate` roots held by UUID across fixes, `locked_normals` check and `unlock_normals` fix (Maya 2027 Help, FBX Troubleshooting); `mx_review` public options (`checker` mode, `shader_modes`, `subdiv`, saved cameras, `pixel_ray`, `view_camera`) replacing the private patches in scenario-maya-modeling and scenario-maya-retopology-uv. v1 kept in `skills/_versions/maya-expert v1 2026-09-24/`. Offline tests only, not verified in Maya.
-
-
scripts
-
mx_audit.py 42.6 KB
""" mx_audit: objective mesh checks an agent runs before calling a model done (Maya 2027). STATUS: not yet run in Maya (written 2026-09-24). extract() uses maya.cmds and OpenMaya 2.0 and is unverified; analyze() is pure Python and was run offline on synthetic meshes (tests/code/maya-expert/test_mx_audit_offline.py). import sys; sys.path.insert(0, "<skill>/scripts"); import mx_audit r = mx_audit.audit("crate_geo", texture_size=2048, units="cm") mx_audit.verdict(r, profile="game") # [] when clean, else "error: ...", "warn: ..." mx_audit.audit_all(roots=["asset_GRP"]) # every mesh under the roots Headless, through mx_run: mayapy mx_run.py --scene crate.ma mx_audit.py -- --mesh crate_geo --texture-size 2048 \ --profile game --json /abs/out/audit.json (--all instead of --mesh) Two layers, so the math is testable without Maya: extract(mesh) -> plain lists from OpenMaya 2.0 (world points in centimeters, the internal unit, whatever the UI unit is) plus cmds metadata analyze(data) -> the report (pure Python) Report keys (distances in `units`, densities in px per `units`): verts faces tris quads ngons tris_equivalent quads_pct valence_hist_interior poles_e3 poles_e5 poles_e6plus pole_samples non_manifold_edges non_manifold_verts lamina_faces (Maya's polyInfo definitions) edges_over_2_faces inconsistent_winding_edges border_edges border_loops holes zero_area_faces zero_length_edges loose_verts loose_edges edge_len_mean edge_len_min edge_len_max edge_len_cv symmetry_pct symmetry_axis center_offset bbox_min bbox_max dimensions uv_sets uv (for the audited set: uvs, faces_without_uvs, uvs_outside_01, shells, udim_tiles, looks_udim, shells_crossing_tiles, uv_overlap_cells, uv_overlap_pct, uv_overlapping_shell_pairs, uv_self_overlap_cells, uv_folded_faces, mirrored_shells, uv_zero_area_faces, texel_density {px_per_<units>, min, max, max_over_min, shells}) transform (frozen, values, offset_parent_matrix_identity, ancestors_not_frozen, pivot_ws, pivot_at_origin, pivot_at_base_center, pivot_at_bbox_center) history_nodes deformers naming (short, default_name, duplicate_short_name, namespace) (history_nodes excludes bind poses and deformer weight drivers: split_history) empty mesh: {object, shape, empty: True, verts, faces}; verdict() gives "error: empty mesh" Guards other code should reuse: is_empty_mesh(shape) and mfn_mesh(shape) (None instead of the MFnMesh exception on an empty mesh, 2022.1+), split_history(shape) (construction history vs deformers vs deformer inputs such as the skinCluster's bind pose). instanced smooth_preview locked_normal_verts hard_edges What the numbers are NOT: edge_len_cv is not a quality score (hand-planned density around eyes and joints raises it, uniform remeshes lower it); poles are fine in flat, non-deforming areas and bad in folds and on deforming rims (Polycount wiki, Poles). Thresholds in verdict() are marked with their source; [added] ones are this toolkit's own defaults and can be overridden. """ __version__ = "0.1" # Maya Expert Skills v0.1 (2026-09-24) import json import math import re import sys UNIT_PER_CM = {"mm": 10.0, "cm": 1.0, "m": 0.01, "km": 1e-5, "in": 1 / 2.54, "ft": 1 / 30.48, "yd": 1 / 91.44} UI_TO_CM = {"mm": 0.1, "cm": 1.0, "m": 100.0, "km": 1e5, "in": 2.54, "ft": 30.48, "yd": 91.44, "mi": 160934.4} DEFAULT_NAME_RE = re.compile( r"^(pCube|pSphere|pCylinder|pCone|pPlane|pTorus|pPrism|pPyramid|pPipe|pHelix|pSolid|pDisc|" r"pPlatonic|pGear|pSuperShape|polySurface|group|transform|null|mesh|nurbsSphere|nurbsCube|" r"nurbsCylinder|nurbsCone|nurbsPlane|nurbsTorus|nurbsCircle|curve|subdiv)\d*$") _AXES = {"x": 0, "y": 1, "z": 2, 0: 0, 1: 1, 2: 2} # =========================================================================== pure analysis class _UF(object): """Union-find over integer ids.""" def __init__(self, n=0): self.p = list(range(n)) def find(self, x): p = self.p while p[x] != x: p[x] = p[p[x]] x = p[x] return x def union(self, a, b): ra, rb = self.find(a), self.find(b) if ra != rb: self.p[ra] = rb def _symmetry(pts, axis, tol, max_queries=300000): if not pts: return 0.0 cell = max(tol * 2.0, 1e-9) grid = {} for i, p in enumerate(pts): key = (int(math.floor(p[0] / cell)), int(math.floor(p[1] / cell)), int(math.floor(p[2] / cell))) grid.setdefault(key, []).append(i) step = max(1, len(pts) // max_queries) tol2 = tol * tol hits = queries = 0 for i in range(0, len(pts), step): m = list(pts[i]) m[axis] = -m[axis] kx, ky, kz = (int(math.floor(m[0] / cell)), int(math.floor(m[1] / cell)), int(math.floor(m[2] / cell))) found = False for dx in (-1, 0, 1): for dy in (-1, 0, 1): for dz in (-1, 0, 1): for j in grid.get((kx + dx, ky + dy, kz + dz), ()): q = pts[j] if (q[0] - m[0]) ** 2 + (q[1] - m[1]) ** 2 + (q[2] - m[2]) ** 2 <= tol2: found = True break if found: break if found: break hits += found queries += 1 return 100.0 * hits / queries def _raster_triangle(p0, p1, p2, visit): """Call visit(ix, iy) for every unit cell whose center lies inside the triangle.""" area = (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p1[1] - p0[1]) * (p2[0] - p0[0]) if abs(area) < 1e-12: return sgn = 1.0 if area > 0 else -1.0 x0 = int(math.floor(min(p0[0], p1[0], p2[0]))) x1 = int(math.ceil(max(p0[0], p1[0], p2[0]))) y0 = int(math.floor(min(p0[1], p1[1], p2[1]))) y1 = int(math.ceil(max(p0[1], p1[1], p2[1]))) for iy in range(y0, y1): cy = iy + 0.5 for ix in range(x0, x1): cx = ix + 0.5 w0 = ((p2[0] - p1[0]) * (cy - p1[1]) - (p2[1] - p1[1]) * (cx - p1[0])) * sgn if w0 < 0: continue w1 = ((p0[0] - p2[0]) * (cy - p2[1]) - (p0[1] - p2[1]) * (cx - p2[0])) * sgn if w1 < 0: continue w2 = ((p1[0] - p0[0]) * (cy - p0[1]) - (p1[1] - p0[1]) * (cx - p0[0])) * sgn if w2 < 0: continue visit(ix, iy) def _uv_analysis(uvd, counts, areas, texture_size, k, overlap_grid, max_faces_overlap, max_listed): u, v = uvd.get("u") or [], uvd.get("v") or [] ucnt, uids = uvd.get("uv_counts") or [], uvd.get("uv_ids") or [] nf = len(counts) res = {"uvs": len(u)} if not u or not ucnt: res.update(faces_without_uvs=nf, shells=0) return res eps = 1e-5 res["uvs_outside_01"] = sum(1 for i in range(len(u)) if u[i] < -eps or u[i] > 1 + eps or v[i] < -eps or v[i] > 1 + eps) res["uv_bounds"] = [round(min(u), 5), round(min(v), 5), round(max(u), 5), round(max(v), 5)] uf = _UF(len(u)) face_ids = [None] * nf off = missing = 0 for fi in range(nf): c = ucnt[fi] if fi < len(ucnt) else 0 if c <= 0: missing += 1 continue ids = uids[off:off + c] off += c face_ids[fi] = ids for x in ids[1:]: uf.union(ids[0], x) res["faces_without_uvs"] = missing roots = {} face_shell = [None] * nf for fi, ids in enumerate(face_ids): if ids is None: continue r = uf.find(ids[0]) face_shell[fi] = roots.setdefault(r, len(roots)) ns = len(roots) res["shells"] = ns if "maya_shells" in uvd: res["maya_shell_count"] = uvd["maya_shells"] # per-shell accumulators a3 = [0.0] * ns auv = [0.0] * ns pos = [0.0] * ns neg = [0.0] * ns bnd = [[1e30, 1e30, -1e30, -1e30] for _ in range(ns)] face_sign = [0] * nf uv_zero = 0 tiles = {} bad_tiles = 0 for fi, ids in enumerate(face_ids): if ids is None: continue s = face_shell[fi] sa = 0.0 n = len(ids) cu = cv = 0.0 b = bnd[s] for j in range(n): ia, ib = ids[j], ids[(j + 1) % n] sa += u[ia] * v[ib] - u[ib] * v[ia] cu += u[ia] cv += v[ia] if u[ia] < b[0]: b[0] = u[ia] if v[ia] < b[1]: b[1] = v[ia] if u[ia] > b[2]: b[2] = u[ia] if v[ia] > b[3]: b[3] = v[ia] sa *= 0.5 if abs(sa) < 1e-12: uv_zero += 1 a3[s] += areas[fi] auv[s] += abs(sa) if sa >= 0: pos[s] += sa face_sign[fi] = 1 else: neg[s] -= sa face_sign[fi] = -1 cu, cv = cu / n, cv / n tu, tv = int(math.floor(cu)), int(math.floor(cv)) if 0 <= tu <= 9 and tv >= 0: t = 1001 + tu + 10 * tv tiles[t] = tiles.get(t, 0) + 1 else: bad_tiles += 1 res["uv_zero_area_faces"] = uv_zero res["udim_tiles"] = sorted(tiles) res["faces_outside_udim_range"] = bad_tiles crossing = 0 for b in bnd: if b[0] > b[2]: continue if (math.floor(b[0] + eps) != math.floor(b[2] - eps)) or (math.floor(b[1] + eps) != math.floor(b[3] - eps)): crossing += 1 res["shells_crossing_tiles"] = crossing res["looks_udim"] = bool(tiles) and sorted(tiles) != [1001] and crossing == 0 and bad_tiles == 0 # orientation: folded faces inside a shell, mirrored shells relative to the majority dom = [1 if pos[s] >= neg[s] else -1 for s in range(ns)] total_pos = sum(pos[s] for s in range(ns) if dom[s] > 0) total_neg = sum(neg[s] for s in range(ns) if dom[s] < 0) glob = 1 if total_pos >= total_neg else -1 res["mirrored_shells"] = sum(1 for s in range(ns) if dom[s] != glob) res["uv_folded_faces"] = sum(1 for fi in range(nf) if face_shell[fi] is not None and face_sign[fi] != dom[face_shell[fi]] and face_sign[fi] != 0) # texel density tot3, totuv = sum(a3), sum(auv) td = {"texture_size": texture_size, "units": "px/%s" % _unit_name(k)} if tot3 > 0 and totuv > 0: td["px_per_unit"] = round(texture_size * math.sqrt(totuv / tot3) / k, 4) shells = [] for s in range(ns): if a3[s] > 0 and auv[s] > 0: shells.append({"shell": s, "px_per_unit": round(texture_size * math.sqrt(auv[s] / a3[s]) / k, 4), "area_3d": round(a3[s] * k * k, 6), "uv_area": round(auv[s], 8)}) sig = [x for x in shells if x["area_3d"] >= 0.001 * tot3 * k * k] # [added] ignore slivers vals = [x["px_per_unit"] for x in sig] or [x["px_per_unit"] for x in shells] if vals: td["min"], td["max"] = min(vals), max(vals) td["max_over_min"] = round(max(vals) / min(vals), 4) if min(vals) > 0 else None w = [x["area_3d"] for x in (sig or shells)] mean = sum(a * b for a, b in zip(vals, w)) / sum(w) var = sum(wi * (x - mean) ** 2 for x, wi in zip(vals, w)) / sum(w) td["area_weighted_cv"] = round(math.sqrt(var) / mean, 4) if mean else None shells.sort(key=lambda x: x["px_per_unit"]) td["lowest_shells"] = shells[:max_listed] td["highest_shells"] = shells[-max_listed:][::-1] res["texel_density"] = td # overlap by rasterization on a grid of overlap_grid cells per UV unit if nf > max_faces_overlap: res["uv_overlap_cells"] = None res["uv_overlap_note"] = "skipped: more than %d faces" % max_faces_overlap return res G = float(overlap_grid) owner = {} # cell -> (first shell, first face) extra = {} # cell -> set of shells, only for cells covered by 2+ shells overlap = set() selfo = set() pairs = set() for fi, ids in enumerate(face_ids): if ids is None: continue s = face_shell[fi] p = [(u[i] * G, v[i] * G) for i in ids] def visit(ix, iy, s=s, fi=fi): key = (ix, iy) prev = owner.get(key) if prev is None: owner[key] = (s, fi) return shells = extra.get(key) if shells is None: if prev[0] == s: if prev[1] != fi: selfo.add(key) # same shell, another face: a fold return shells = extra[key] = {prev[0]} if s not in shells: for o in shells: pairs.add((min(o, s), max(o, s))) shells.add(s) overlap.add(key) for j in range(1, len(p) - 1): _raster_triangle(p[0], p[j], p[j + 1], visit) res["uv_overlap_grid"] = overlap_grid res["uv_covered_cells"] = len(owner) res["uv_overlap_cells"] = len(overlap) res["uv_overlap_pct"] = round(100.0 * len(overlap) / len(owner), 3) if owner else 0.0 res["uv_overlapping_shell_pairs"] = len(pairs) res["uv_self_overlap_cells"] = len(selfo) return res def _unit_name(k): for name, val in UNIT_PER_CM.items(): if abs(val - k) < 1e-12: return name return "unit" def analyze(data, texture_size=2048, units="cm", uv_set=None, symmetry_axis="x", symmetry_tol=None, max_listed=25, uv_checks=True, overlap_grid=256, max_faces_overlap=300000): """Pure-Python analysis of extract()'s data. Points must be in centimeters.""" k = UNIT_PER_CM[units] pts = data["points"] counts = data["counts"] conn = data["connects"] nv, nf = len(pts), len(counts) r = {"object": data.get("name"), "shape": data.get("shape"), "units": units, "verts": nv, "faces": nf} tris = quads = 0 ngons = [] for i, c in enumerate(counts): if c == 3: tris += 1 elif c == 4: quads += 1 elif c > 4: ngons.append(i) r.update(tris=tris, quads=quads, ngons=len(ngons), tris_equivalent=sum(c - 2 for c in counts), quads_pct=round(100.0 * quads / nf, 2) if nf else 0.0, ngon_faces_sample=ngons[:max_listed], all_triangles=(nf > 0 and tris == nf)) if nv: lo = [min(p[i] for p in pts) for i in range(3)] hi = [max(p[i] for p in pts) for i in range(3)] else: lo = hi = [0.0, 0.0, 0.0] diag = math.sqrt(sum((hi[i] - lo[i]) ** 2 for i in range(3))) or 1.0 r["bbox_min"] = [round(x * k, 6) for x in lo] r["bbox_max"] = [round(x * k, 6) for x in hi] r["dimensions"] = [round((hi[i] - lo[i]) * k, 6) for i in range(3)] # ---- edges from face corners cnt, dsum = {}, {} degenerate = 0 N = max(nv, 1) off = 0 for c in counts: f = conn[off:off + c] off += c for j in range(c): a, b = f[j], f[(j + 1) % c] if a == b: degenerate += 1 continue if a < b: key, d = a * N + b, 1 else: key, d = b * N + a, -1 cnt[key] = cnt.get(key, 0) + 1 dsum[key] = dsum.get(key, 0) + d val = [0] * nv bnd = bytearray(nv) border = [] over2 = incons = zero_len = 0 lengths = [] eps_len = diag * 1e-6 # [added] relative tolerance for key, n in cnt.items(): a, b = divmod(key, N) val[a] += 1 val[b] += 1 L = math.dist(pts[a], pts[b]) lengths.append(L) if L < eps_len: zero_len += 1 if n == 1: border.append((a, b)) bnd[a] = bnd[b] = 1 elif n > 2: over2 += 1 elif dsum[key] != 0: incons += 1 r.update(edges=len(cnt), edges_over_2_faces=over2, inconsistent_winding_edges=incons, border_edges=len(border), zero_length_edges=zero_len, degenerate_face_corners=degenerate) if data.get("num_edges_api") is not None: r["loose_edges"] = max(0, int(data["num_edges_api"]) - len(cnt)) uf = _UF(nv) for a, b in border: uf.union(a, b) loops = {} for a, b in border: root = uf.find(a) loops[root] = loops.get(root, 0) + 1 r["border_loops"] = sorted(loops.values(), reverse=True)[:max_listed] r["holes"] = len(loops) hist = {} loose_v = 0 e5, e3 = [], [] for i in range(nv): n = val[i] if n == 0: loose_v += 1 continue if bnd[i]: continue hist[n] = hist.get(n, 0) + 1 if n >= 5 and len(e5) < max_listed: e5.append(i) elif n == 3 and len(e3) < max_listed: e3.append(i) r["loose_verts"] = loose_v r["valence_hist_interior"] = {str(key): hist[key] for key in sorted(hist)} r["poles_e3"] = hist.get(3, 0) r["poles_e5"] = hist.get(5, 0) r["poles_e6plus"] = sum(c for n, c in hist.items() if n >= 6) r["pole_samples"] = {"e5plus": [[round(x * k, 5) for x in pts[i]] + [val[i]] for i in e5], "e3": [[round(x * k, 5) for x in pts[i]] for i in e3]} if lengths: mean = sum(lengths) / len(lengths) sd = math.sqrt(sum((x - mean) ** 2 for x in lengths) / len(lengths)) r.update(edge_len_mean=round(mean * k, 6), edge_len_min=round(min(lengths) * k, 6), edge_len_max=round(max(lengths) * k, 6), edge_len_cv=round(sd / mean, 4) if mean else 0.0) # ---- face areas (vector area: exact for planar polygons, convex or not) areas = [0.0] * nf off = 0 zero_area = 0 eps_area = eps_len * eps_len for i, c in enumerate(counts): f = conn[off:off + c] off += c p0 = pts[f[0]] ax = ay = az = 0.0 for j in range(1, c - 1): p1, p2 = pts[f[j]], pts[f[j + 1]] ux, uy, uz = p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2] vx, vy, vz = p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2] ax += uy * vz - uz * vy ay += uz * vx - ux * vz az += ux * vy - uy * vx areas[i] = 0.5 * math.sqrt(ax * ax + ay * ay + az * az) if areas[i] < eps_area: zero_area += 1 r["zero_area_faces"] = zero_area r["surface_area"] = round(sum(areas) * k * k, 6) # ---- symmetry (world space, mirror across the axis plane through the origin) ax_i = _AXES[symmetry_axis] tol = symmetry_tol / k if symmetry_tol else max(hi[i] - lo[i] for i in range(3)) * 1e-3 or 1e-6 r["symmetry_axis"] = "xyz"[ax_i] r["symmetry_tol"] = round(tol * k, 8) r["symmetry_pct"] = round(_symmetry(pts, ax_i, tol), 2) r["center_offset"] = round((lo[ax_i] + hi[ax_i]) / 2.0 * k, 6) # ---- UVs sets = data.get("uv_sets") or {} r["uv_sets"] = list(sets) use = uv_set or data.get("current_uv_set") or (list(sets)[0] if sets else None) r["uv_set"] = use if uv_checks and use in sets: r["uv"] = _uv_analysis(sets[use], counts, areas, texture_size, k, overlap_grid, max_faces_overlap, max_listed) else: r["uv"] = {"uvs": 0, "faces_without_uvs": nf, "shells": 0} r["uv_other_sets"] = {name: {"uvs": len(d.get("u") or []), "faces_without_uvs": sum(1 for c in (d.get("uv_counts") or []) if c <= 0) if d.get("uv_counts") else nf} for name, d in sets.items() if name != use} # ---- metadata from extract() is copied through untouched for key in ("non_manifold_edges", "non_manifold_verts", "lamina_faces", "non_manifold_sample", "lamina_sample", "transform", "history_nodes", "deformers", "naming", "instanced", "smooth_preview", "locked_normal_verts", "hard_edges", "linear_unit", "up_axis", "arnold_subdiv"): if key in data: r[key] = data[key] return r # =========================================================================== Maya layer def _num(fn, name, *args): attr = getattr(fn, name) return attr(*args) if callable(attr) else attr def _count(cmds, node, **flag): """polyEvaluate as an int. polyEvaluate returns a message string, not 0, when it has nothing to count ("Nothing counted : no polygonal object is selected.") [verify the exact 2027 behaviour on an empty mesh], so anything that is not an int counts as 0.""" try: v = cmds.polyEvaluate(node, **flag) except Exception: return 0 if isinstance(v, bool): return 0 return v if isinstance(v, int) else 0 def mesh_counts(shape): """{"verts", "faces"} of a mesh shape as ints (0 when polyEvaluate counts nothing).""" import maya.cmds as cmds return {"verts": _count(cmds, shape, vertex=True), "faces": _count(cmds, shape, face=True)} def is_empty_mesh(shape): """True for a mesh with no vertices or no faces. Call before om.MFnMesh: API 2.0 MFnMesh raises on an empty mesh since 2022.1 (devkit What's New), which would turn one empty leftover shape into a crash of the whole audit or batch job.""" c = mesh_counts(shape) return c["verts"] == 0 or c["faces"] == 0 def mfn_mesh(shape): """om.MFnMesh for a mesh shape, or None when the mesh is empty (guarded twice: counts first, then the constructor's RuntimeError). The one sanctioned way for toolkit and skill code to wrap a mesh it did not build itself.""" import maya.api.OpenMaya as om if is_empty_mesh(shape): return None sl = om.MSelectionList() sl.add(shape) try: return om.MFnMesh(sl.getDagPath(0)) except RuntimeError: # [verify] the exception type OM2 raises here return None # Nodes upstream of a mesh that are never construction history. dagPose: the bind pose # that every skinCluster's bindPose attribute reads (open issue 2026-09-24: it was counted # as history, so every skinned mesh looked dirty). The others are component bookkeeping. DEFORMER_SUPPORT_TYPES = ("groupId", "groupParts", "tweak", "objectSet", "shadingEngine", "dagPose") _GEOMETRY_INPUTS = ("input", "originalGeometry") # geometryFilter plugs carrying the mesh _BOOKKEEPING_TYPES = ("nodeGraphEditorInfo", "hyperLayout", "hyperView", "container", "dagContainer", "objectSet", "shapeEditorManager") def _drives_deformer_only(cmds, plug, deformers, drivers): node, _, attr = plug.partition(".") node = node.rsplit("|", 1)[-1] if node in deformers: return re.split(r"[\[.]", attr, 1)[0] not in _GEOMETRY_INPUTS if node in drivers: return True try: return cmds.nodeType(node) in _BOOKKEEPING_TYPES except Exception: return False def split_history(shape): """Upstream nodes of a mesh in three groups: history construction history, what Delete History removes: [(node, type)] deformers geometryFilter nodes (skinCluster, blendShape, cluster...) deformer_inputs nodes that only feed deformers' non-geometry inputs: the bind pose, weight drivers (animation curves, unit conversions, expressions) A node counts as a deformer input when every one of its outgoing connections ends on a deformer attribute other than input/originalGeometry, on another deformer input, or on editor bookkeeping (iterated to a fixed point) [added rule].""" import maya.cmds as cmds hist = cmds.listHistory(shape, pruneDagObjects=True) or [] deformers, cands, support = [], [], [] for h in hist: t = cmds.nodeType(h) if t == "mesh": continue if "geometryFilter" in (cmds.nodeType(h, inherited=True) or []): deformers.append(h) elif t in DEFORMER_SUPPORT_TYPES: if t == "dagPose": support.append(h) else: cands.append((h, t)) drivers = set(support) if deformers and cands: dset = set(d.rsplit("|", 1)[-1] for d in deformers) changed = True while changed: changed = False for h, _t in cands: if h in drivers: continue dests = cmds.listConnections(h, source=False, destination=True, plugs=True) or [] if dests and all(_drives_deformer_only(cmds, p, dset, drivers) for p in dests): drivers.add(h) changed = True return {"history": [(h, t) for h, t in cands if h not in drivers], "deformers": deformers, "deformer_inputs": sorted(drivers)} def resolve_mesh(name): """Return (shape, transform) long names for a transform or mesh shape name.""" import maya.cmds as cmds found = cmds.ls(name, long=True) or [] if not found: raise ValueError("no node named %s" % name) if len(found) > 1 and len({f.rsplit("|", 1)[-1] for f in found}) > 1: raise ValueError("ambiguous name %s: %s (use a long name)" % (name, found)) node = found[0] # several paths to one instanced node: take the first if cmds.nodeType(node) == "mesh": return node, (cmds.listRelatives(node, parent=True, fullPath=True) or [node])[0] shapes = cmds.listRelatives(node, shapes=True, noIntermediate=True, fullPath=True, type="mesh") or [] if not shapes: raise ValueError("%s has no (non-intermediate) mesh shape" % node) return shapes[0], node def _is_identity(m, tol=1e-6): ident = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] return all(abs(a - b) <= tol for a, b in zip(m, ident)) def _frozen(cmds, node, tol=1e-5): t = cmds.getAttr(node + ".translate")[0] r = cmds.getAttr(node + ".rotate")[0] s = cmds.getAttr(node + ".scale")[0] sh = cmds.getAttr(node + ".shear")[0] if cmds.attributeQuery("shear", node=node, exists=True) else (0, 0, 0) opm_ok = True if cmds.attributeQuery("offsetParentMatrix", node=node, exists=True): opm_ok = _is_identity(cmds.getAttr(node + ".offsetParentMatrix")) ok = (all(abs(x) <= tol for x in tuple(t) + tuple(r) + tuple(sh)) and all(abs(x - 1) <= tol for x in s) and opm_ok) return ok, {"t": [round(x, 6) for x in t], "r": [round(x, 6) for x in r], "s": [round(x, 6) for x in s], "shear": [round(x, 6) for x in sh], "offset_parent_matrix_identity": opm_ok} def transform_info(xform, bbox_cm=None): import maya.cmds as cmds ok, vals = _frozen(cmds, xform) anc = [] p = cmds.listRelatives(xform, parent=True, fullPath=True) while p: pok, _ = _frozen(cmds, p[0]) if not pok: anc.append(p[0]) p = cmds.listRelatives(p[0], parent=True, fullPath=True) unit = cmds.currentUnit(q=True, linear=True) f = UI_TO_CM.get(unit, 1.0) piv = [x * f for x in cmds.xform(xform, q=True, ws=True, rotatePivot=True)] info = {"frozen": ok, "values": vals, "ancestors_not_frozen": anc, "pivot_ws_cm": [round(x, 6) for x in piv]} if bbox_cm: lo, hi = bbox_cm tol = max(1e-4, 1e-3 * math.sqrt(sum((hi[i] - lo[i]) ** 2 for i in range(3)))) base = [(lo[0] + hi[0]) / 2, lo[1], (lo[2] + hi[2]) / 2] if cmds.upAxis(q=True, axis=True) == "z": base = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, lo[2]] center = [(lo[i] + hi[i]) / 2 for i in range(3)] info["pivot_at_origin"] = all(abs(x) <= tol for x in piv) info["pivot_at_base_center"] = all(abs(piv[i] - base[i]) <= tol for i in range(3)) info["pivot_at_bbox_center"] = all(abs(piv[i] - center[i]) <= tol for i in range(3)) return info def history_info(shape): """(["node (type)" construction history], [deformers]); bind poses and deformer weight drivers are not history (split_history).""" s = split_history(shape) return ["%s (%s)" % (h, t) for h, t in s["history"]], s["deformers"] def naming_info(xform): import maya.cmds as cmds short = xform.rsplit("|", 1)[-1] base = short.rsplit(":", 1)[-1] dups = [n for n in (cmds.ls(type="transform") or []) if n.rsplit("|", 1)[-1] == short] return {"short": short, "default_name": bool(DEFAULT_NAME_RE.match(base)), "duplicate_short_name": len(dups) > 1, "namespace": ":" in short} def _poly_info(cmds, shape, max_listed, **flag): res = cmds.polyInfo(shape, **flag) or [] try: flat = cmds.ls(res, flatten=True) or res except Exception: flat = res return len(flat), [str(x).strip() for x in flat[:max_listed]] def extract(mesh, max_listed=25, hard_edge_limit=500000): """Read a mesh with OpenMaya 2.0 (world space, centimeters) plus cmds metadata.""" import maya.cmds as cmds import maya.api.OpenMaya as om shape, xform = resolve_mesh(mesh) data = {"name": xform, "shape": shape} fn = mfn_mesh(shape) # None for an empty mesh: MFnMesh raises on one since 2022.1 if fn is None: data.update(points=[], counts=[], connects=[], empty=True, counted=mesh_counts(shape)) return data sl = om.MSelectionList() sl.add(shape) dag = sl.getDagPath(0) data["points"] = [(p.x, p.y, p.z) for p in fn.getPoints(om.MSpace.kWorld)] counts, connects = fn.getVertices() data["counts"], data["connects"] = list(counts), list(connects) data["num_edges_api"] = int(_num(fn, "numEdges")) sets = {} for s in fn.getUVSetNames(): u, v = fn.getUVs(s) c, ids = fn.getAssignedUVs(s) d = {"u": list(u), "v": list(v), "uv_counts": list(c), "uv_ids": list(ids)} try: n, _ = fn.getUvShellsIds(s) # [verify] OM2 name; cross-check only d["maya_shells"] = int(n) except Exception: pass sets[s] = d data["uv_sets"] = sets try: data["current_uv_set"] = fn.currentUVSetName() except Exception: data["current_uv_set"] = None n, sample = _poly_info(cmds, shape, max_listed, nonManifoldEdges=True) data["non_manifold_edges"], data["non_manifold_sample"] = n, sample data["non_manifold_verts"] = _poly_info(cmds, shape, max_listed, nonManifoldVertices=True)[0] n, sample = _poly_info(cmds, shape, max_listed, laminaFaces=True) data["lamina_faces"], data["lamina_sample"] = n, sample pts = data["points"] lo = [min(p[i] for p in pts) for i in range(3)] hi = [max(p[i] for p in pts) for i in range(3)] data["transform"] = transform_info(xform, (lo, hi)) data["history_nodes"], data["deformers"] = history_info(shape) data["naming"] = naming_info(xform) data["instanced"] = len(cmds.listRelatives(shape, allParents=True, fullPath=True) or []) > 1 data["smooth_preview"] = cmds.getAttr(shape + ".displaySmoothMesh") if cmds.attributeQuery( "displaySmoothMesh", node=shape, exists=True) else None if cmds.attributeQuery("aiSubdivType", node=shape, exists=True): data["arnold_subdiv"] = {"type": cmds.getAttr(shape + ".aiSubdivType"), "iterations": cmds.getAttr(shape + ".aiSubdivIterations")} try: fr = cmds.polyNormalPerVertex(shape + ".vtx[*]", q=True, freezeNormal=True) or [] data["locked_normal_verts"] = sum(1 for x in fr if x) except Exception: data["locked_normal_verts"] = None if data["num_edges_api"] <= hard_edge_limit: it = om.MItMeshEdge(dag) hard = 0 while not it.isDone(): sm = it.isSmooth if callable(sm): sm = sm() if not sm: hard += 1 it.next() data["hard_edges"] = hard data["linear_unit"] = cmds.currentUnit(q=True, linear=True) data["up_axis"] = cmds.upAxis(q=True, axis=True) return data def audit(mesh, texture_size=2048, units="cm", uv_set=None, symmetry_axis="x", symmetry_tol=None, max_listed=25, uv_checks=True, overlap_grid=256, max_faces_overlap=300000): """Full report for one mesh (transform or shape name).""" data = extract(mesh, max_listed=max_listed) if data.get("empty"): c = data.get("counted") or {} return {"object": data["name"], "shape": data["shape"], "empty": True, "verts": c.get("verts", 0), "faces": c.get("faces", 0)} return analyze(data, texture_size=texture_size, units=units, uv_set=uv_set, symmetry_axis=symmetry_axis, symmetry_tol=symmetry_tol, max_listed=max_listed, uv_checks=uv_checks, overlap_grid=overlap_grid, max_faces_overlap=max_faces_overlap) def list_meshes(roots=None): """Transforms of every non-intermediate mesh in the scene or under `roots`.""" import maya.cmds as cmds if roots: shapes = [] for r in roots: shapes += cmds.listRelatives(r, allDescendents=True, type="mesh", fullPath=True) or [] if cmds.nodeType(r) == "mesh": shapes.append(cmds.ls(r, long=True)[0]) else: shapes = cmds.ls(type="mesh", long=True) or [] out = [] for s in shapes: if cmds.getAttr(s + ".intermediateObject"): continue p = (cmds.listRelatives(s, parent=True, fullPath=True) or [None])[0] if p and p not in out: out.append(p) return out def audit_all(roots=None, **kw): reports = [] for m in list_meshes(roots): try: reports.append(audit(m, **kw)) except Exception as exc: reports.append({"object": m, "error": "%s: %s" % (type(exc).__name__, exc)}) return reports # =========================================================================== verdict # severity per profile: None = not reported, "info", "warn", "error" PROFILES = { # game film subd "ngons": ("error", "error", "error"), # FlippedNormals clean-scene checklist; OSD "tris": (None, None, "warn"), # Pixar OSD: triangles "used sparingly" "poles_e6plus": ("info", "warn", "warn"), # Pixar OSD: high valence costs, artifacts "history": ("error", "error", "warn"), # FlippedNormals: delete history before handoff "not_frozen": ("error", "error", "warn"), # FlippedNormals; Unreal pipeline (pipeline digest) "pivot": ("warn", "info", None), # Unreal: pivot at origin (pipeline digest) "uv_missing": ("error", "error", "warn"), "uv_outside": ("warn", "error", "warn"), # FlippedNormals: no UVs outside 0-1; Polycount: unless tiling "uv_crossing": ("error", "error", "warn"), # a shell across UDIM tiles "uv_overlap": ("warn", "error", "warn"), # Maya UV help: stacked shells are normal in optimized game UVs "uv_self_overlap": ("error", "error", "warn"), "uv_zero_area": ("warn", "warn", "info"), "td_ratio": (None, "warn", None), # Maya UV help: technical mapping keeps one texel density "smooth_preview": ("warn", "warn", "info"), # FlippedNormals: save with smoothing off "instanced": ("warn", "info", "info"), # FBX: instances get the original's material "not_triangulated": ("info", None, None), # FBX: tangents need an all-triangle mesh "locked_normals": ("warn", "warn", "warn"), # FBX troubleshooting: locked normals ignore deformation "default_name": ("warn", "warn", "info"), "holes": ("info", "info", "info"), } _PI = {"game": 0, "film": 1, "subd": 2} def verdict(r, profile="game", symmetric=None, td_ratio_max=2.0, max_tris=None, symmetry_min_pct=99.0): """Problems as strings "error: ...", "warn: ...", "info: ..." (empty list = clean). symmetric=True adds the X-symmetry check (characters). td_ratio_max and symmetry_min_pct are [added] defaults. max_tris: triangle budget (tris_equivalent).""" if r.get("error"): return ["error: audit failed: %s" % r["error"]] if r.get("empty"): return ["error: empty mesh"] pi = _PI[profile] out = [] def add(rule, msg): sev = PROFILES[rule][pi] if rule in PROFILES else rule if sev: out.append("%s: %s" % (sev, msg)) # unambiguous defects: errors in every profile (FlippedNormals: lamina, non-manifold, # zero-length edges are "purely technical"; Unfold3D refuses non-manifold meshes) for key, msg in (("non_manifold_edges", "non-manifold edges (Maya polyInfo)"), ("non_manifold_verts", "non-manifold vertices (Maya polyInfo)"), ("edges_over_2_faces", "edges shared by 3+ faces"), ("lamina_faces", "lamina faces"), ("zero_area_faces", "zero-area faces"), ("zero_length_edges", "zero-length edges"), ("inconsistent_winding_edges", "edges between faces of opposite winding (flipped normals)")): if r.get(key): out.append("error: %d %s" % (r[key], msg)) for key, msg in (("loose_verts", "loose vertices"), ("loose_edges", "loose edges")): if r.get(key): out.append("warn: %d %s" % (r[key], msg)) if r.get("naming", {}).get("duplicate_short_name"): out.append("error: another transform has the short name %s (never two objects with the same name: " "FlippedNormals)" % r["naming"]["short"]) if r.get("ngons"): add("ngons", "%d n-gons (faces over 4 sides): fix by hand, not with Cleanup" % r["ngons"]) if r.get("tris"): add("tris", "%d triangles in a subdivision cage" % r["tris"]) if r.get("poles_e6plus"): add("poles_e6plus", "%d interior vertices with 6+ edges" % r["poles_e6plus"]) if max_tris and r.get("tris_equivalent", 0) > max_tris: out.append("error: %d triangles over the budget of %d" % (r["tris_equivalent"], max_tris)) if r.get("history_nodes"): add("history", "construction history: %s" % ", ".join(r["history_nodes"][:6])) tr = r.get("transform") or {} if tr and not tr.get("frozen", True): add("not_frozen", "transform not frozen %s" % tr.get("values")) if tr.get("ancestors_not_frozen"): add("not_frozen", "parent groups not frozen: %s" % ", ".join(tr["ancestors_not_frozen"][:4])) if tr and not (tr.get("pivot_at_origin") or tr.get("pivot_at_base_center")): add("pivot", "pivot neither at the origin nor at the base center: %s cm" % tr.get("pivot_ws_cm")) uv = r.get("uv") or {} if not uv.get("uvs"): add("uv_missing", "no UVs in set %s" % r.get("uv_set")) else: if uv.get("faces_without_uvs"): add("uv_missing", "%d faces without UVs" % uv["faces_without_uvs"]) if uv.get("uvs_outside_01") and not uv.get("looks_udim"): add("uv_outside", "%d UVs outside 0-1 (fine only if the shader tiles them)" % uv["uvs_outside_01"]) if uv.get("looks_udim") and profile == "game": out.append("warn: UVs use UDIM tiles %s; engines expect 0-1" % uv.get("udim_tiles")) if uv.get("shells_crossing_tiles") and (uv.get("looks_udim") or uv.get("uvs_outside_01")): add("uv_crossing", "%d shells cross a UV tile border" % uv["shells_crossing_tiles"]) if uv.get("uv_overlap_cells"): add("uv_overlap", "%d shell pairs overlap (%.2f%% of covered cells): unique bakes and lightmaps " "need unique UVs" % (uv.get("uv_overlapping_shell_pairs", 0), uv.get("uv_overlap_pct", 0))) so = uv.get("uv_self_overlap_cells") or 0 if so > max(4, 0.001 * (uv.get("uv_covered_cells") or 0)): # [added] tolerance add("uv_self_overlap", "%d UV cells covered twice inside one shell (folds)" % so) if uv.get("uv_folded_faces"): add("uv_self_overlap", "%d UV faces flipped against their shell" % uv["uv_folded_faces"]) if uv.get("uv_zero_area_faces"): add("uv_zero_area", "%d faces with zero UV area" % uv["uv_zero_area_faces"]) td = uv.get("texel_density") or {} if td_ratio_max and td.get("max_over_min") and td["max_over_min"] > td_ratio_max: add("td_ratio", "texel density varies %.2fx across shells (%.3g to %.3g %s)" % (td["max_over_min"], td["min"], td["max"], td.get("units"))) if r.get("smooth_preview"): add("smooth_preview", "Smooth Mesh Preview is on (press 1 before saving)") if r.get("instanced"): add("instanced", "shape is instanced") if not r.get("all_triangles"): add("not_triangulated", "not triangulated: FBX writes tangents and binormals only for all-triangle meshes") if r.get("locked_normal_verts"): add("locked_normals", "%d vertices with locked normals (they will not follow skinning)" % r["locked_normal_verts"]) if r.get("naming", {}).get("default_name"): add("default_name", "default name %s" % r["naming"]["short"]) if r.get("holes"): add("holes", "%d open borders %s (expected for eye holes or cut necks, else holes)" % (r["holes"], r.get("border_loops"))) if symmetric and r.get("symmetry_pct", 100) < symmetry_min_pct: out.append("warn: only %.1f%% of vertices mirror across %s=0 (center offset %s %s)" % (r["symmetry_pct"], r.get("symmetry_axis"), r.get("center_offset"), r.get("units"))) order = {"error": 0, "warn": 1, "info": 2} out.sort(key=lambda s: order.get(s.split(":", 1)[0], 3)) return out def passed(r, profile="game", **kw): return not any(p.startswith("error") for p in verdict(r, profile, **kw)) # =========================================================================== CLI (via mx_run) def main(argv): import argparse ap = argparse.ArgumentParser(prog="mx_audit") ap.add_argument("--mesh", default="", help="comma list of meshes") ap.add_argument("--all", action="store_true", help="every mesh in the scene (or under --roots)") ap.add_argument("--roots", default="", help="comma list of root groups for --all") ap.add_argument("--texture-size", type=int, default=2048) ap.add_argument("--units", default="cm", choices=sorted(UNIT_PER_CM)) ap.add_argument("--uv-set", default=None) ap.add_argument("--profile", default="game", choices=sorted(_PI)) ap.add_argument("--symmetric", action="store_true") ap.add_argument("--max-tris", type=int, default=None) ap.add_argument("--json", default=None) a = ap.parse_args(argv) kw = dict(texture_size=a.texture_size, units=a.units, uv_set=a.uv_set) if a.all or not a.mesh: reports = audit_all([x for x in a.roots.split(",") if x] or None, **kw) else: reports = [audit(m, **kw) for m in a.mesh.split(",") if m] out = {"profile": a.profile, "reports": reports, "problems": {r.get("object"): verdict(r, a.profile, symmetric=a.symmetric, max_tris=a.max_tris) for r in reports}} if a.json: with open(a.json, "w") as f: json.dump(out, f, indent=1) return out if __name__ == "__main__": import maya.standalone maya.standalone.initialize(name="python") try: print(json.dumps(main(sys.argv[1:]), indent=1)) finally: maya.standalone.uninitialize() -
mx_bridge.py 15 KB
""" mx_bridge: client for mx_bridge_server (which runs inside a live GUI Maya). Runs in any Python 3 outside Maya (the agent's shell), no dependencies. STATUS: not yet run against Maya (written 2026-09-24). The protocol logic is exercised offline by tests/code/maya-expert/test_mx_bridge_offline.py with a fake command port. import sys; sys.path.insert(0, "<skill>/scripts"); import mx_bridge b = mx_bridge.Bridge() # 127.0.0.1:7001, /tmp/mx_bridge_<uid> b.ping() # full round trip, returns Maya version + scene r = b.run(''' cube = cmds.polyCube(name="agentCube#")[0] # multi-statement code, any length print("made", cube) result = {"cube": cube, "faces": cmds.polyEvaluate(cube, face=True)} ''') r["ok"], r["result"], r["stdout"], r["stderr"], r["maya_messages"], r.get("traceback") print(mx_bridge.format_record(r)) # the same, as text an agent reads at a glance b.call("mx_review", "playblast", path="/abs/out/shot", start=1, end=48) # module function b.call("mx_audit", "audit", "agentCube1", reload=True) # reload edited module Every record returned by run(), call(), ping() and wait() has the keys ok, result, stdout, stderr and maya_messages (Maya's warnings, errors and stack traces from commands), also when the call was refused, could not connect or timed out; on a timeout, stdout holds what the job printed so far (read from <id>.out) and partial_output is True. Read them after every call: a Maya warning with ok=True is still a finding. Shell: python3 mx_bridge.py ping | info | run FILE.py | exec "CODE" [--port N] [--timeout S] [--text] Guards (from the MCP bridge designs in notes/scripting): code that opens modal UI or waits for stdin is refused (it blocks Maya's main thread); code that replaces the scene with force=True is refused unless allow_scene_replace=True (check cmds.file(q=True, modified=True) first, as GG_MayaMCP does). One undo chunk per call, so the user can undo an agent step with one Ctrl+Z. """ __version__ = "0.1" # Maya Expert Skills v0.1 (2026-09-24) import json import os import re import socket import sys import time import uuid DEFAULT_PORT = 7001 _MODAL_PATTERNS = [ (r"\binput\s*\(", "input() opens a modal dialog in GUI Maya"), (r"\braw_input\s*\(", "raw_input is Python 2 and modal"), (r"\bpdb\b|\bbreakpoint\s*\(", "pdb opens input dialogs in GUI Maya"), (r"\bconfirmDialog\b", "confirmDialog waits for a click"), (r"\bpromptDialog\b", "promptDialog waits for typing"), (r"\blayoutDialog\b", "layoutDialog is modal"), (r"\bfileDialog2?\b", "file dialogs wait for a click"), (r"\bshowWindow\b.*modal|\bmodal\s*=\s*True", "modal windows block the main thread"), ] # Long or short flags (new/n, open/o, force/f), in cmds.file, its MEL form # inside mel.eval (the server's namespace provides mel), or PyMEL's # newFile/openFile. _TRUE = r"\s*=\s*(?:True|1)\b" _SCENE_REPLACE = re.compile( r"\bfile\s*\((?=[^)]*\b(?:new|n|open|o)" + _TRUE + r")(?=[^)]*\b(?:force|f)" + _TRUE + r")" r"|\bfile\b(?=[^;\n]*?\s-(?:force|f)\b)(?=[^;\n]*?\s-(?:new|n|open|o)\b)" r"|\b(?:newFile|openFile)\s*\((?=[^)]*\b(?:force|f)" + _TRUE + r")", re.S, ) class BridgeError(RuntimeError): pass OUTPUT_KEYS = {"result": None, "stdout": "", "stderr": "", "maya_messages": []} def complete(rec, partial_text=None): """Give a record every output key (stdout, stderr, maya_messages, result, ok), so callers never branch on missing keys. partial_text: output a timed-out job wrote so far.""" rec = dict(rec or {}) rec.setdefault("ok", False) for k, v in OUTPUT_KEYS.items(): if rec.get(k) is None: rec[k] = list(v) if isinstance(v, list) else v if partial_text: rec["stdout"] = partial_text rec["partial_output"] = True return rec def format_record(rec, tail=60): """Readable text of a bridge record: status, result, the last `tail` lines of stdout and stderr, Maya's warnings and errors, and the traceback.""" rec = complete(rec) lines = ["ok: %s%s" % (rec["ok"], " (partial output, job still running)" if rec.get("partial_output") else "")] if rec.get("error"): lines.append("error: %s" % rec["error"]) if rec.get("result") is not None: lines.append("result: %s" % json.dumps(rec["result"], default=str)[:2000]) for key in ("stdout", "stderr"): text = rec[key].rstrip("\n") if text: body = text.splitlines() cut = " [%d earlier lines cut]" % (len(body) - tail) if len(body) > tail else "" lines.append("%s:%s" % (key, cut)) lines += [" " + l for l in body[-tail:]] if rec["maya_messages"]: lines.append("maya messages:") lines += [" %s: %s" % (m.get("type"), m.get("text")) for m in rec["maya_messages"][:tail]] if rec.get("traceback"): lines.append("traceback:") lines += [" " + l for l in rec["traceback"].rstrip().splitlines()[-tail:]] return "\n".join(lines) def default_root(): env = os.environ.get("MX_BRIDGE_DIR") if env: return env uid = str(os.getuid()) if hasattr(os, "getuid") else os.environ.get("USERNAME", "user") base = "/tmp" if os.name != "nt" else os.environ.get("TEMP", ".") return os.path.join(base, "mx_bridge_%s" % uid) def lint(code, allow_modal=False, allow_scene_replace=False): """Return a list of reasons this code must not run over the bridge.""" problems = [] if not allow_modal: for pat, why in _MODAL_PATTERNS: if re.search(pat, code): problems.append(why) if not allow_scene_replace and _SCENE_REPLACE.search(code): problems.append("replaces the user's scene with force=True: check " "cmds.file(q=True, modified=True) first, or pass allow_scene_replace=True") return problems class Bridge(object): def __init__(self, port=DEFAULT_PORT, host="127.0.0.1", root=None, unix_socket=None, timeout=300.0, start_timeout=30.0, connect_timeout=5.0): self.port, self.host, self.unix_socket = port, host, unix_socket self.root = root or default_root() self.timeout, self.start_timeout, self.connect_timeout = timeout, start_timeout, connect_timeout # ----------------------------------------------------------------- socket layer def _connect(self): if self.unix_socket: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(self.connect_timeout) s.connect(self.unix_socket) return s return socket.create_connection((self.host, self.port), timeout=self.connect_timeout) def send_line(self, line, read_reply=True, reply_timeout=5.0): """Send one statement to the command port; return Maya's reply text or None. The reply framing is not documented [verify]: read until newline, NUL, close or timeout, and strip.""" if len(line) > 3900: raise BridgeError("line too long for the default 4096-character port buffer") try: s = self._connect() except OSError as exc: raise BridgeError("cannot connect to Maya on %s: %s (is mx_bridge_server.start() " "running in the GUI session?)" % (self.unix_socket or "%s:%s" % (self.host, self.port), exc)) try: s.sendall((line + "\n").encode("utf-8")) if not read_reply: return None s.settimeout(reply_timeout) buf = b"" try: while True: chunk = s.recv(65536) if not chunk: break buf += chunk if b"\n" in chunk or b"\x00" in chunk: break except socket.timeout: pass return buf.replace(b"\x00", b"").decode("utf-8", "replace").strip() finally: s.close() # ----------------------------------------------------------------- file layer def _paths(self, job_id): base = os.path.join(self.root, job_id) return base + ".py", base + ".json", base + ".running", base + ".opts.json" def partial_output(self, job_id): """What a running (or timed-out) job has printed so far, from <id>.out, or ''.""" try: with open(os.path.join(self.root, job_id + ".out"), encoding="utf-8", errors="replace") as f: return f.read() except OSError: return "" def write_job(self, code, undo=True, name=None): """Write a job file and return its id (used by run(); tests call it directly).""" if not os.path.isdir(self.root): os.makedirs(self.root, mode=0o700, exist_ok=True) job_id = "%s_%s" % (time.strftime("%Y%m%d_%H%M%S"), uuid.uuid4().hex[:8]) if name: job_id += "_" + re.sub(r"[^A-Za-z0-9_\-]", "", name)[:24] code_path, _, _, opts_path = self._paths(job_id) tmp = code_path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: f.write(code) os.replace(tmp, code_path) with open(opts_path, "w") as f: json.dump({"undo": bool(undo)}, f) return job_id def fetch(self, job_id): """Return the result record of a job, or None if not written yet.""" _, out_path, _, _ = self._paths(job_id) if not os.path.isfile(out_path): return None for _ in range(5): try: with open(out_path) as f: return json.load(f) except ValueError: time.sleep(0.05) return None def wait(self, job_id, timeout=None, reply=None): timeout = self.timeout if timeout is None else timeout _, _, running_path, _ = self._paths(job_id) t0 = time.time() delay = 0.02 while True: rec = self.fetch(job_id) if rec is not None: rec["elapsed"] = round(time.time() - t0, 3) rec["reply"] = reply return complete(rec) elapsed = time.time() - t0 if not os.path.exists(running_path) and elapsed > self.start_timeout: return complete({"id": job_id, "ok": False, "reply": reply, "error": "Maya did not start the job within %.0fs: it is busy, a modal " "dialog is open, or the reply (%r) shows the entry point is " "missing (restart mx_bridge_server)" % (self.start_timeout, reply)}) if elapsed > timeout: return complete({"id": job_id, "ok": False, "reply": reply, "running": True, "error": "timeout after %.0fs; the job may still be running on Maya's " "main thread; fetch(%r) later" % (timeout, job_id)}, partial_text=self.partial_output(job_id)) time.sleep(delay) delay = min(delay * 1.5, 0.25) # ----------------------------------------------------------------- public API def run(self, code, timeout=None, undo=True, allow_modal=False, allow_scene_replace=False, name=None, deferred=True): """Run Python in the GUI session; `result` in the code becomes rec["result"].""" problems = lint(code, allow_modal, allow_scene_replace) if problems: return complete({"ok": False, "error": "refused by lint: " + "; ".join(problems)}) job_id = self.write_job(code, undo=undo, name=name) line = '_mx_bridge_run("%s", deferred=%s)' % (job_id, "True" if deferred else "False") try: reply = self.send_line(line, reply_timeout=5.0 if deferred else (timeout or self.timeout)) except BridgeError as exc: return complete({"id": job_id, "ok": False, "error": str(exc)}) return self.wait(job_id, timeout=timeout, reply=reply) def run_file(self, path, **kw): with open(path, encoding="utf-8") as f: return self.run(f.read(), name=os.path.splitext(os.path.basename(path))[0], **kw) def call(self, module, function, *args, **kwargs): """Import `module` inside Maya (the server puts the skill scripts on sys.path) and return function(*args, **kwargs). reload=True re-imports an edited module.""" reload_mod = kwargs.pop("reload", False) timeout = kwargs.pop("timeout", None) undo = kwargs.pop("undo", True) payload = json.dumps({"args": args, "kwargs": kwargs}) code = ("import importlib, json\n" "import {m} as _m\n" "{r}" "_p = json.loads({p!r})\n" "result = _m.{f}(*_p['args'], **_p['kwargs'])\n").format( m=module, f=function, p=payload, r="_m = importlib.reload(_m)\n" if reload_mod else "") return self.run(code, timeout=timeout, undo=undo, name="%s.%s" % (module, function)) def ping(self, timeout=30.0): """Full round trip through the file protocol.""" code = ("import sys\n" "result = {'maya': cmds.about(version=True), 'batch': cmds.about(batch=True),\n" " 'scene': cmds.file(q=True, sceneName=True),\n" " 'modified': cmds.file(q=True, modified=True),\n" " 'python': sys.version.split()[0]}\n") return self.run(code, timeout=timeout, undo=False, name="ping") def info(self): """Server info through the port reply only (no files).""" text = self.send_line("_mx_bridge_info()") try: return json.loads(text.strip("'\"")) except (ValueError, AttributeError): return {"raw_reply": text} _DEFAULT = {} def default_bridge(**kw): key = tuple(sorted(kw.items())) if key not in _DEFAULT: _DEFAULT[key] = Bridge(**kw) return _DEFAULT[key] def run(code, **kw): return default_bridge().run(code, **kw) def ping(**kw): return default_bridge(**kw).ping() def _cli(argv): port, timeout, rest, as_text = DEFAULT_PORT, 300.0, [], False i = 0 while i < len(argv): if argv[i] == "--port": port = int(argv[i + 1]); i += 2 elif argv[i] == "--timeout": timeout = float(argv[i + 1]); i += 2 elif argv[i] == "--text": as_text = True; i += 1 else: rest.append(argv[i]); i += 1 if not rest: print(__doc__) return 2 b = Bridge(port=port, timeout=timeout) cmd = rest[0] if cmd == "ping": rec = b.ping() elif cmd == "info": rec = b.info() rec.setdefault("ok", True) elif cmd == "run" and len(rest) > 1: rec = b.run_file(rest[1]) elif cmd == "exec" and len(rest) > 1: rec = b.run(rest[1]) else: print(__doc__) return 2 print(format_record(rec) if as_text else json.dumps(rec, indent=1)) return 0 if rec.get("ok") else 1 if __name__ == "__main__": sys.exit(_cli(sys.argv[1:])) -
mx_bridge_server.py 14.5 KB
""" mx_bridge_server: runs INSIDE a live GUI Maya session. Opens a loopback-only Python commandPort and executes agent code exchanged through files, on Maya's main thread, inside one undo chunk per call. The client is mx_bridge.py (runs outside Maya). STATUS: not yet run in Maya (written 2026-09-24, Maya 2027 not installed yet). Install, pick one: a) paste once in the Script Editor, Python tab (Ctrl+Enter): import sys; sys.path.insert(0, "<skill>/scripts") import mx_bridge_server; mx_bridge_server.start() b) userSetup.py in ~/Library/Preferences/Autodesk/maya/2027/scripts/ (runs before the UI exists, so defer the start until Maya is idle): import maya.utils def _mx_bridge_boot(): import sys; sys.path.insert(0, "<skill>/scripts") import mx_bridge_server; mx_bridge_server.start() maya.utils.executeDeferred(_mx_bridge_boot) Stop when done: mx_bridge_server.stop() Protocol (why files): the commandPort buffer defaults to 4096 characters (longer commands close the connection, longer results are replaced by an error) and Python sent to Maya returns the result of a single statement only (Maya 2027 Help: commandPort command; Python in Maya, current limitations). So: 1. the client writes <root>/<id>.py (UTF-8) and optionally <id>.opts.json 2. the client sends ONE short line: _mx_bridge_run("<id>") 3. that call queues handle(<id>) with maya.utils.executeDeferred and returns at once; handle() writes <id>.running, execs the code in a persistent namespace (cmds, mel, om pre-imported) inside the undo chunk "mx:<id>", captures stdout/stderr, reads the variable `result`, and writes <id>.json atomically 4. the client polls <id>.json (timeout, error path with traceback) What comes back (every record, success or not): ok, result, stdout, stderr, maya_messages (Maya's own warnings, errors and stack traces from commands, e.g. cmds.warning or a MEL error, caught with OpenMaya MCommandMessage.addCommandOutputCallback [verify callback arguments on 2027]), error, traceback, seconds, scene, scene_modified. While a job runs, <id>.out receives stdout and stderr as they are written, so a client that times out can still read what the job printed. An agent that cannot read Maya's output is blind (chadrik, maya-mcp-server README). Exchange root: $MX_BRIDGE_DIR, else /tmp/mx_bridge_<uid> (same path for Maya and the agent, whatever TMPDIR each process has), mode 0700, refused if owned by another user. Security stance (read before starting it): - The port binds 127.0.0.1 (name "127.0.0.1:<port>") [verify with lsof -nP -iTCP:<port> -sTCP:LISTEN that it is not listening on *]. - There is NO authentication. Maya's own help: INET command ports need no user identification and run every command, including system(), with the Maya user's permissions. Any local process can send Python to it. Use only on a personal machine, never forward the port, and stop() it when the session ends. - start(unix_socket="/Users/<me>/.mx_bridge.sock") uses a UNIX socket instead, which file permissions can restrict to the user [verify the permissions Maya sets]. - Agent code must never open modal UI (confirmDialog, promptDialog, fileDialog2, input(), pdb): it blocks Maya's main thread and the bridge. The client refuses it. """ __version__ = "0.1" # Maya Expert Skills v0.1 (2026-09-24) import builtins import contextlib import functools import io import json import linecache import os import re import sys import threading import time import traceback DEFAULT_PORT = 7001 SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) _ID_RE = re.compile(r"^[A-Za-z0-9_\-]{1,80}$") _STATE = {"name": None, "root": None, "ns": None, "started": None, "jobs": 0} MAX_CAPTURE = 200000 # characters of stdout/stderr kept per job MAX_MESSAGES = 500 # Maya warnings/errors kept per job CAPTURED_KINDS = ("warning", "error", "stacktrace") # prints already come through stdout def default_root(): env = os.environ.get("MX_BRIDGE_DIR") if env: return env uid = str(os.getuid()) if hasattr(os, "getuid") else os.environ.get("USERNAME", "user") base = "/tmp" if os.name != "nt" else os.environ.get("TEMP", ".") return os.path.join(base, "mx_bridge_%s" % uid) def prepare_root(root): os.makedirs(root, mode=0o700, exist_ok=True) if hasattr(os, "getuid") and os.stat(root).st_uid != os.getuid(): raise RuntimeError("bridge dir %s is owned by another user; refusing" % root) try: os.chmod(root, 0o700) except OSError: pass return root def _write_json_atomic(path, data): tmp = path + ".tmp" with open(tmp, "w") as f: json.dump(data, f, default=_jsonable_default) os.replace(tmp, path) def _jsonable_default(obj): try: return list(obj) # MMatrix, MVector, MPoint, OpenMaya arrays, sets, tuples except TypeError: return repr(obj) def _jsonable(value): try: json.dumps(value) return value except (TypeError, ValueError): return json.loads(json.dumps(value, default=_jsonable_default)) class _Tee(io.TextIOBase): """Capture a stream and still echo it to Maya's Script Editor, so the user sees what the agent does in their session. `live` (an open text file) also gets every write at once, for clients that time out before the record exists.""" def __init__(self, original, live=None): super().__init__() self.original = original self.buf = io.StringIO() self.live = live def write(self, s): self.buf.write(s) if self.live is not None: try: self.live.write(s) self.live.flush() except Exception: self.live = None if self.original is not None: try: self.original.write(s) except Exception: pass return len(s) def flush(self): if self.original is not None: try: self.original.flush() except Exception: pass def _message_kinds(om): kinds = {} for attr in ("kHistory", "kDisplay", "kInfo", "kWarning", "kError", "kResult", "kStackTrace"): val = getattr(om.MCommandMessage, attr, None) if val is not None: kinds[val] = attr[1:].lower() return kinds def start_message_capture(sink, kinds=CAPTURED_KINDS, limit=MAX_MESSAGES): """Append Maya's command output of the given kinds to `sink` as {"type", "text"} and return the callback id (None when OpenMaya or the callback is unavailable, e.g. in a fake or very old session). Remove it with stop_message_capture(id).""" try: import maya.api.OpenMaya as om names = _message_kinds(om) except Exception: return None def _cb(*args): # (message, messageType, clientData) [verify order] if len(sink) >= limit: return kind = names.get(args[1], str(args[1])) if len(args) > 1 else "unknown" if kind in kinds: sink.append({"type": kind, "text": str(args[0]).rstrip()}) try: return om.MCommandMessage.addCommandOutputCallback(_cb) except Exception: return None def stop_message_capture(cb_id): if cb_id is None: return try: import maya.api.OpenMaya as om om.MMessage.removeCallback(cb_id) except Exception: pass def _namespace(): if _STATE["ns"] is None: ns = {"__name__": "__mx_agent__", "__builtins__": builtins} try: import maya.cmds as cmds import maya.mel as mel import maya.api.OpenMaya as om ns.update(cmds=cmds, mel=mel, om=om) except ImportError: pass _STATE["ns"] = ns return _STATE["ns"] def reset_namespace(): """Forget every name the agent defined in earlier calls.""" _STATE["ns"] = None def handle(job_id, root=None, undo=None, echo=True): """Execute <root>/<job_id>.py and write <root>/<job_id>.json. Must run on the main thread; callable directly (tests use it under mayapy without a commandPort). Returns the record written.""" if not _ID_RE.match(job_id or ""): raise ValueError("bad job id %r" % (job_id,)) if threading.current_thread() is not threading.main_thread(): import maya.utils return maya.utils.executeInMainThreadWithResult(handle, job_id, root, undo, echo) root = root or _STATE["root"] or default_root() code_path = os.path.join(root, job_id + ".py") out_path = os.path.join(root, job_id + ".json") opts = {} opts_path = os.path.join(root, job_id + ".opts.json") if os.path.isfile(opts_path): with open(opts_path) as f: opts = json.load(f) if undo is None: undo = bool(opts.get("undo", True)) with open(os.path.join(root, job_id + ".running"), "w") as f: f.write(str(time.time())) t0 = time.time() rec = {"id": job_id, "ok": False, "result": None, "stdout": "", "stderr": "", "maya_messages": []} try: with open(code_path, encoding="utf-8") as f: code = f.read() except OSError as exc: rec.update(error="cannot read job file: %s" % exc) _write_json_atomic(out_path, rec) return rec filename = "<mx:%s>" % job_id linecache.cache[filename] = (len(code), None, code.splitlines(True), filename) # tracebacks show source ns = _namespace() ns["result"] = None ns["MX_JOB_ID"] = job_id try: live = open(os.path.join(root, job_id + ".out"), "w", encoding="utf-8") except OSError: live = None out = _Tee(sys.stdout if echo else None, live) err = _Tee(sys.stderr if echo else None, live) messages = [] cb_id = start_message_capture(messages) cmds = ns.get("cmds") chunk = False try: if undo and cmds is not None: cmds.undoInfo(openChunk=True, chunkName="mx:%s" % job_id) chunk = True with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): exec(compile(code, filename, "exec"), ns) rec["ok"] = True rec["result"] = _jsonable(ns.get("result")) except BaseException as exc: # SystemExit included: never let agent code exit Maya rec["error"] = "%s: %s" % (type(exc).__name__, exc) rec["traceback"] = traceback.format_exc() finally: if chunk: try: cmds.undoInfo(closeChunk=True) # always close, an open chunk corrupts undo except Exception: pass stop_message_capture(cb_id) if live is not None: try: live.close() except Exception: pass rec["stdout"] = out.buf.getvalue()[-MAX_CAPTURE:] rec["stderr"] = err.buf.getvalue()[-MAX_CAPTURE:] # the stderr echo into the Script Editor can come back through the callback as an error: # keep only messages that are not already in the captured stderr rec["maya_messages"] = [m for m in messages if m["text"] and m["text"] not in rec["stderr"]] rec["seconds"] = round(time.time() - t0, 3) if cmds is not None: try: rec["scene"] = cmds.file(q=True, sceneName=True) rec["scene_modified"] = cmds.file(q=True, modified=True) except Exception: pass _write_json_atomic(out_path, rec) _STATE["jobs"] += 1 try: with open(os.path.join(root, "bridge.log"), "a") as f: f.write("%s\t%s\t%s\t%.3fs\t%d maya messages\n" % ( time.strftime("%Y-%m-%d %H:%M:%S"), job_id, "ok" if rec["ok"] else "ERROR", rec["seconds"], len(rec["maya_messages"]))) except OSError: pass return rec def _mx_bridge_run(job_id, deferred=True, root=None): """The one-line entry point the client sends. Returns a short status string.""" if not _ID_RE.match(job_id or ""): return "error:bad-id" if deferred: import maya.utils maya.utils.executeDeferred(functools.partial(handle, job_id, root)) return "queued:%s" % job_id rec = handle(job_id, root) return ("done:" if rec.get("ok") else "error:") + job_id def _mx_bridge_ping(): return "pong" def _mx_bridge_info(): return json.dumps(info()) def info(): d = {"name": _STATE["name"], "root": _STATE["root"], "started": _STATE["started"], "jobs": _STATE["jobs"], "pid": os.getpid(), "scripts": SCRIPTS_DIR} try: import maya.cmds as cmds d["maya_version"] = cmds.about(version=True) d["batch"] = cmds.about(batch=True) d["scene"] = cmds.file(q=True, sceneName=True) except Exception: pass return d def _install_entry_points(): import __main__ for fn in (_mx_bridge_run, _mx_bridge_ping, _mx_bridge_info): setattr(builtins, fn.__name__, fn) # resolves whatever namespace the port uses setattr(__main__, fn.__name__, fn) def start(port=DEFAULT_PORT, root=None, host="127.0.0.1", unix_socket=None, buffer_size=16384, echo_output=False): """Open the command port and install the entry points. Returns info().""" import maya.cmds as cmds root = prepare_root(root or default_root()) if SCRIPTS_DIR not in sys.path: sys.path.insert(0, SCRIPTS_DIR) # so jobs can `import mx_review` etc. name = unix_socket or "%s:%d" % (host, port) open_ports = cmds.commandPort(q=True, listPorts=True) or [] if name in open_ports: cmds.commandPort(name=name, close=True) # stale port from an earlier start cmds.commandPort(name=name, sourceType="python", noreturn=False, echoOutput=echo_output, bufferSize=buffer_size) _install_entry_points() _STATE.update(name=name, root=root, started=time.strftime("%Y-%m-%d %H:%M:%S")) d = info() _write_json_atomic(os.path.join(root, "server.json"), d) print("mx_bridge_server: listening on %s, exchange dir %s (no auth: stop() when done)" % (name, root)) return d def stop(): """Close the command port opened by start().""" import maya.cmds as cmds name = _STATE.get("name") if name and name in (cmds.commandPort(q=True, listPorts=True) or []): cmds.commandPort(name=name, close=True) if _STATE.get("root"): d = info() d["stopped"] = time.strftime("%Y-%m-%d %H:%M:%S") _write_json_atomic(os.path.join(_STATE["root"], "server.json"), d) _STATE["name"] = None print("mx_bridge_server: stopped") def status(): import maya.cmds as cmds d = info() d["open_ports"] = cmds.commandPort(q=True, listPorts=True) or [] return d -
mx_review.py 62.3 KB
""" mx_review: the renders an agent looks at instead of orbiting a viewport (Maya 2027, Arnold). STATUS: not yet run in Maya (written 2026-09-24). The Arnold and cmds calls are unverified; the image code (PNG read/write, labels, contact sheet) and the camera math are pure Python and were run offline (tests/code/maya-expert/test_mx_review_offline.py). Experts judge a model by turning it: silhouette first, then big forms, then edge flow. review() renders those passes from fixed cameras into ONE labelled contact sheet (rows = modes, columns = views) that the agent opens with its image reader, plus every tile at full resolution for close inspection. import sys; sys.path.insert(0, "<skill>/scripts"); import mx_review r = mx_review.review(["crate_geo"], "/abs/out/review") # all defaults r["sheet"], r["tiles"]["clay"]["front"], r["notes"] mx_review.review(["head_geo"], out, views=("front", "side"), modes=("clay", "wire"), focus=((0, 160, 8), 6)) # close-up: frame a sphere (center cm, radius cm) mx_review.turntable(["crate_geo"], "/abs/out/turn", frames=24) # frames + strip sheet mx_review.playblast("/abs/out/shot", start=1, end=48) # GUI session only # public options for skills that need more than the four modes (no private internals): mx_review.review(["body_geo"], out, modes=("checker",), checker_repeats=32) # UV checker mx_review.review(["cage_geo"], out, modes=("metal", "wire"), subdiv=2, shader_modes={"metal": {"build": make_metal, "lit": True, "dome_color": stripes_plug, "key_intensity": 0.6}}) r["cameras"]["front"] # every view's camera (also in review.json): eye, fwd, right, # up, perspective, ortho_width, tan_half, near, far, resolution mx_review.view_camera(["head_geo"], "front") # the same camera without rendering mx_review.pixel_ray(r["cameras"]["front"], px, py) # tile pixel -> world ray (UI units) Headless, through mx_run: mayapy mx_run.py --plugins mtoa --scene crate.ma mx_review.py -- --targets crate_geo \ --out /abs/out/review [--views front,side] [--modes clay,wire] [--res 1024] [--tile 512] Modes clay grey aiStandardSurface lit by a grey sky dome plus a key light placed over the camera's shoulder for every view: reads forms and planes wire aiWireframe (polygon edges) over an unlit facing-ratio fill: reads topology silhouette black on white, computed from the alpha of an unlit render (so an Arnold watermark never touches it): reads shape and gesture only normals aiUtility normal colors, unlit: reveals flipped faces and shading breaks checker the clay setup with a checker texture on the UVs (checker_repeats per 0-1 tile, place2dTexture repeatUV): even squares = even texel density, stretched squares = distortion (opt-in, not in DEFAULT_MODES) custom shader_modes={"name": spec}: spec["build"](session) returns a shader node the caller made (name it session.name("x") so it is cleaned up); optional "lit" (default True: dome and key on), "dome_color" (a plug, or callable(session) -> plug, connected to the dome color for that mode only), "key_intensity" (for that mode only). A plain callable is a build function. Other options: subdiv=N renders the temporary duplicates with Arnold catclark subdivision (N iterations, Smooth Mesh Preview off on the duplicates only); render_prefix, sheet_name, json_name (None: no JSON written) rename the outputs. Views (Y-up; mapped for Z-up scenes): front (+Z), side (+X), back (-Z), left (-X), top (+Y), threequarter (perspective 50 mm), low (three-quarter from below, catches under-planes and jaw lines). Never touches the user's setup: everything it creates lives in the namespace mxReview and is deleted afterwards; the targets are rendered as temporary duplicates; every render setting, camera renderable flag, render layer flag, light and geometry visibility it changes is recorded and restored, and so are the selection, the current namespace and the scene's modified flag. In a GUI session all of it is one undo chunk. Arnold on macOS renders on CPU only (no Arnold GPU, no OptiX); the OIDN imager is added when available. Samples follow the documented MtoA defaults (Camera AA 3, Diffuse 2, Specular 2). Batch renders without an Arnold batch licence carry a watermark; since Arnold 7.3 they ABORT on licence failure unless ARNOLD_FORCE_ABORT_ON_LICENSE_FAIL=0, which this module sets (What's New in Maya 2025, Arnold for Maya 5.4.0). """ __version__ = "0.1" # Maya Expert Skills v0.1 (2026-09-24) import glob import json import math import os import shutil import struct import subprocess import sys import time import zlib os.environ.setdefault("ARNOLD_FORCE_ABORT_ON_LICENSE_FAIL", "0") NS = "mxReview" VIEW_DIRS = { "front": (0.0, 0.0, 1.0), "back": (0.0, 0.0, -1.0), "side": (1.0, 0.0, 0.0), "left": (-1.0, 0.0, 0.0), "top": (0.0, 1.0, 0.0), "threequarter": (0.7, 0.35, 1.0), "low": (0.8, -0.45, 1.0), } PERSPECTIVE = {"threequarter", "low"} DEFAULT_VIEWS = ("front", "side", "back", "threequarter", "top", "low") DEFAULT_MODES = ("clay", "wire", "silhouette", "normals") BUILTIN_MODES = DEFAULT_MODES + ("checker",) LIT_MODES = ("clay", "checker") CHECKER_REPEATS = 16 # [added] place2dTexture repeatUV per 0-1 tile [verify squares per repeat] CHECKER_COLORS = ((0.9, 0.9, 0.9), (0.1, 0.1, 0.1)) DOME_COLOR = (0.45, 0.45, 0.45) KEY_INTENSITY = 2.0 UI_TO_CM = {"mm": 0.1, "cm": 1.0, "m": 100.0, "km": 1e5, "in": 2.54, "ft": 30.48, "yd": 91.44, "mi": 160934.4} APERTURE_IN = 1.417 # square film back for square renders FOCAL_MM = 50.0 # =========================================================================== vector math def _sub(a, b): return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) def _add(a, b): return (a[0] + b[0], a[1] + b[1], a[2] + b[2]) def _mul(a, s): return (a[0] * s, a[1] * s, a[2] * s) def _dot(a, b): return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] def _cross(a, b): return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]) def _norm(a): n = math.sqrt(_dot(a, a)) or 1.0 return (a[0] / n, a[1] / n, a[2] / n) def map_dir(d, up_axis="y"): """Y-up view direction to the scene's up axis (Z-up: x, -z, y).""" return d if up_axis == "y" else (d[0], -d[2], d[1]) def basis(direction, up_axis="y"): """Camera basis for a camera placed along `direction` from the target, looking back. Returns (right, up, forward) unit vectors; forward points from camera to target.""" fwd = _norm(_mul(direction, -1.0)) world_up = (0.0, 1.0, 0.0) if up_axis == "y" else (0.0, 0.0, 1.0) if abs(_dot(fwd, world_up)) > 0.999: # top/bottom: screen-up is -Z (Y-up) as in Maya's top view world_up = (0.0, 0.0, -1.0) if up_axis == "y" else (0.0, 1.0, 0.0) if _dot(fwd, (0.0, 1.0, 0.0) if up_axis == "y" else (0.0, 0.0, 1.0)) > 0: world_up = _mul(world_up, -1.0) right = _norm(_cross(fwd, world_up)) up = _cross(right, fwd) return right, up, fwd def euler_xyz_from_axes(x_axis, y_axis, z_axis): """Maya rotate order xyz (row vectors, R = Rx*Ry*Rz) from the node's world axes. Returns degrees. Maya's rotateX matrix is [1 0 0; 0 c s; 0 -s c] (row-major).""" r00, r01, r02 = x_axis r10, r11, r12 = y_axis r20, r21, r22 = z_axis sy = max(-1.0, min(1.0, -r02)) ry = math.asin(sy) if abs(math.cos(ry)) > 1e-6: rx = math.atan2(r12, r22) rz = math.atan2(r01, r00) else: # gimbal: put everything in X rz = 0.0 rx = math.atan2(r10 * sy, r11) return (math.degrees(rx), math.degrees(ry), math.degrees(rz)) def matrix_from_euler_xyz(rx, ry, rz): """Inverse of euler_xyz_from_axes (degrees in), rows = world axes of the node.""" cx, sx = math.cos(math.radians(rx)), math.sin(math.radians(rx)) cy, sy = math.cos(math.radians(ry)), math.sin(math.radians(ry)) cz, sz = math.cos(math.radians(rz)), math.sin(math.radians(rz)) return ((cy * cz, cy * sz, -sy), (sx * sy * cz - cx * sz, sx * sy * sz + cx * cz, sx * cy), (cx * sy * cz + sx * sz, cx * sy * sz - sx * cz, cx * cy)) def aim_rotation(direction, up_axis="y"): """Euler XYZ (degrees) that points a Maya camera or light (which look down -Z) along -direction, with screen-up following the scene's up axis.""" right, up, fwd = basis(direction, up_axis) return euler_xyz_from_axes(right, up, _mul(fwd, -1.0)) def camera_record(center, direction, fr, up_axis="y", resolution=None, view=None): """The camera review() places for one view, as plain data (UI units): eye, center, direction (target to camera), fwd (camera to target), right, up, rotate_xyz_deg, perspective, ortho_width, focal_mm, aperture_in, tan_half, near, far, distance, resolution. Enough to turn a tile pixel into a ray (pixel_ray) without Maya. Pure.""" d = _norm(direction) right, up, fwd = basis(d, up_axis) persp = fr["ortho_width"] is None return {"view": view, "center": list(center), "eye": list(_add(center, _mul(d, fr["distance"]))), "direction": list(d), "fwd": list(fwd), "right": list(right), "up": list(up), "rotate_xyz_deg": list(aim_rotation(d, up_axis)), "perspective": persp, "ortho_width": fr["ortho_width"], "focal_mm": FOCAL_MM, "aperture_in": APERTURE_IN, "tan_half": (APERTURE_IN * 25.4 / 2.0) / FOCAL_MM, "near": fr["near"], "far": fr["far"], "distance": fr["distance"], "resolution": resolution, "up_axis": up_axis, "units": "ui"} def pixel_ray(camera, px, py, resolution=None): """(origin, direction) of the ray through pixel (px, py) of a square render of `resolution` pixels (default: the camera's own), top-left origin, pixel centres at +0.5. `camera`: a camera_record (review()["cameras"][view], review.json, or view_camera). Pure.""" res = float(resolution or camera.get("resolution") or 0) if res <= 0: raise ValueError("pixel_ray needs the render resolution") nx = (px + 0.5) / res * 2.0 - 1.0 ny = 1.0 - (py + 0.5) / res * 2.0 right, up, fwd, eye = (tuple(camera[k]) for k in ("right", "up", "fwd", "eye")) if camera["perspective"]: t = camera["tan_half"] return eye, _norm(_add(fwd, _add(_mul(right, nx * t), _mul(up, ny * t)))) half = camera["ortho_width"] / 2.0 return _add(eye, _add(_mul(right, nx * half), _mul(up, ny * half))), fwd def frame_points(points, focus=None): """(center, points to frame) the way review() frames the targets: the bounding-box centre of the points, or a focus sphere ((cx, cy, cz), radius) in the same units. Pure.""" if focus: c = tuple(float(x) for x in focus[0]) rad = float(focus[1]) return c, [_add(c, _mul(a, rad)) for a in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))] lo = [min(p[i] for p in points) for i in range(3)] hi = [max(p[i] for p in points) for i in range(3)] return tuple((lo[i] + hi[i]) / 2.0 for i in range(3)), points def view_cameras(points, views, up_axis="y", margin=1.1, focus=None, resolution=None): """{view: camera_record} for points already in UI units (review() uses exactly this). Pure.""" center, pts = frame_points(points, focus) out = {} for view in views: d = map_dir(VIEW_DIRS[view], up_axis) fr = frame(pts, center, d, view in PERSPECTIVE, margin, up_axis) out[view] = camera_record(center, d, fr, up_axis, resolution, view) return out def frame(points, center, direction, perspective, margin=1.1, up_axis="y"): """Camera distance and ortho width (same units as points) that fit every point.""" right, up, fwd = basis(direction, up_axis) d = _norm(direction) if perspective: tan_half = (APERTURE_IN * 25.4 / 2.0) / FOCAL_MM / margin need = 1e-3 for p in points: rel = _sub(p, center) toward = _dot(rel, d) need = max(need, abs(_dot(rel, right)) / tan_half + toward, abs(_dot(rel, up)) / tan_half + toward) depth = max(abs(_dot(_sub(p, center), d)) for p in points) if points else 1.0 return {"distance": need, "ortho_width": None, "near": max(need - depth * 1.5, need * 0.01), "far": need + depth * 3 + 1.0} half = max(max(abs(_dot(_sub(p, center), right)), abs(_dot(_sub(p, center), up))) for p in points) depth = max(abs(_dot(_sub(p, center), d)) for p in points) dist = depth * 3.0 + max(half, 1e-3) * 2.0 return {"distance": dist, "ortho_width": 2.0 * max(half, 1e-4) * margin, "near": max(dist - depth * 2.0, dist * 0.001), "far": dist + depth * 2.0 + 1.0} # =========================================================================== image utilities def write_png(path, width, height, data, channels=3): """Write 8-bit RGB (channels=3) or RGBA (4) rows, top row first. Pure Python (zlib).""" stride = width * channels raw = bytearray() for y in range(height): raw.append(0) raw += data[y * stride:(y + 1) * stride] def chunk(tag, body): return struct.pack(">I", len(body)) + tag + body + struct.pack(">I", zlib.crc32(tag + body) & 0xFFFFFFFF) ctype = 2 if channels == 3 else 6 png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, ctype, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b"")) with open(path, "wb") as f: f.write(png) return path def png_size(path): with open(path, "rb") as f: head = f.read(24) if head[:8] != b"\x89PNG\r\n\x1a\n": raise ValueError("not a PNG: %s" % path) return struct.unpack(">II", head[16:24]) def _paeth(a, b, c): p = a + b - c pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) if pa <= pb and pa <= pc: return a return b if pb <= pc else c def read_png_pure(path): """Decode a non-interlaced 8/16-bit gray, RGB or RGBA PNG to (w, h, RGBA bytes). Slow (pure Python) but dependency-free: the last-resort reader.""" with open(path, "rb") as f: blob = f.read() if blob[:8] != b"\x89PNG\r\n\x1a\n": raise ValueError("not a PNG") pos, idat = 8, bytearray() w = h = depth = ctype = None while pos < len(blob): n = struct.unpack(">I", blob[pos:pos + 4])[0] tag = blob[pos + 4:pos + 8] body = blob[pos + 8:pos + 8 + n] pos += 12 + n if tag == b"IHDR": w, h, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", body) if interlace: raise ValueError("interlaced PNG not supported") elif tag == b"IDAT": idat += body elif tag == b"IEND": break chans = {0: 1, 2: 3, 4: 2, 6: 4}.get(ctype) if chans is None or depth not in (8, 16): raise ValueError("unsupported PNG type %s depth %s" % (ctype, depth)) bpp = chans * depth // 8 stride = w * bpp raw = zlib.decompress(bytes(idat)) out = bytearray(h * stride) prev = bytearray(stride) p = 0 for y in range(h): ft = raw[p] line = bytearray(raw[p + 1:p + 1 + stride]) p += 1 + stride if ft == 1: for i in range(bpp, stride): line[i] = (line[i] + line[i - bpp]) & 255 elif ft == 2: for i in range(stride): line[i] = (line[i] + prev[i]) & 255 elif ft == 3: for i in range(stride): left = line[i - bpp] if i >= bpp else 0 line[i] = (line[i] + ((left + prev[i]) >> 1)) & 255 elif ft == 4: for i in range(stride): left = line[i - bpp] if i >= bpp else 0 ul = prev[i - bpp] if i >= bpp else 0 line[i] = (line[i] + _paeth(left, prev[i], ul)) & 255 out[y * stride:(y + 1) * stride] = line prev = line if depth == 16: out = out[0::2] n = w * h rgba = bytearray(n * 4) if chans == 4: rgba[:] = out elif chans == 3: rgba[0::4], rgba[1::4], rgba[2::4] = out[0::3], out[1::3], out[2::3] rgba[3::4] = b"\xff" * n elif chans == 2: rgba[0::4] = rgba[1::4] = rgba[2::4] = out[0::2] rgba[3::4] = out[1::2] else: rgba[0::4] = rgba[1::4] = rgba[2::4] = out rgba[3::4] = b"\xff" * n return w, h, bytes(rgba) def _read_pil(path): from PIL import Image im = Image.open(path).convert("RGBA") return im.size[0], im.size[1], im.tobytes() _MIMAGE_LAYOUT = {} def _mimage_bytes(path): import ctypes import maya.api.OpenMaya as om img = om.MImage() img.readFromFile(path) w, h = img.getSize() px = img.pixels() n = w * h * 4 data = bytes(px)[:n] if isinstance(px, (bytes, bytearray, memoryview)) else ctypes.string_at(int(px), n) return w, h, data def _mimage_layout(tmp_dir): """Self-calibrate MImage row order and channel order on a 1x2 PNG written here.""" if "bottom_up" in _MIMAGE_LAYOUT: return _MIMAGE_LAYOUT probe = os.path.join(tmp_dir, "_mx_mimage_probe.png") write_png(probe, 1, 2, bytes([255, 0, 0, 0, 255, 0]), channels=3) # top red, bottom green _, _, d = _mimage_bytes(probe) first = tuple(d[:3]) _MIMAGE_LAYOUT["bottom_up"] = first == (0, 255, 0) _MIMAGE_LAYOUT["bgra"] = first == (0, 0, 255) return _MIMAGE_LAYOUT def _read_mimage(path): w, h, d = _mimage_bytes(path) lay = _mimage_layout(os.path.dirname(path)) d = bytearray(d) if lay.get("bgra"): d[0::4], d[2::4] = d[2::4], d[0::4] if lay.get("bottom_up"): stride = w * 4 d = b"".join(bytes(d[y * stride:(y + 1) * stride]) for y in range(h - 1, -1, -1)) return w, h, bytes(d) def _read_imconvert(path): exe = None for cand in (os.path.join(os.path.dirname(sys.executable), "imconvert"), shutil.which("imconvert")): if cand and os.path.isfile(cand): exe = cand break if not exe: raise RuntimeError("imconvert not found") w, h = png_size(path) raw = path + ".rgba" subprocess.check_call([exe, path, "-depth", "8", "rgba:" + raw]) with open(raw, "rb") as f: d = f.read() if len(d) != w * h * 4: raise RuntimeError("imconvert returned %d bytes for %dx%d" % (len(d), w, h)) return w, h, d READERS = [("pil", _read_pil), ("mimage", _read_mimage), ("imconvert", _read_imconvert), ("pure", read_png_pure)] _READER_USED = {} def read_rgba(path): """(w, h, RGBA bytes, top row first) with the first backend that works: PIL, OpenMaya MImage, Maya's imconvert, then a pure-Python decoder.""" errors = [] order = READERS if _READER_USED.get("name"): order = sorted(READERS, key=lambda x: x[0] != _READER_USED["name"]) for name, fn in order: try: w, h, d = fn(path) _READER_USED["name"] = name return w, h, d except Exception as exc: errors.append("%s: %s" % (name, exc)) raise RuntimeError("cannot read %s (%s)" % (path, "; ".join(errors))) def rgba_to_rgb(d): n = len(d) // 4 out = bytearray(n * 3) out[0::3], out[1::3], out[2::3] = d[0::4], d[1::4], d[2::4] return bytes(out) def alpha_silhouette(d): """Black shape on white from the alpha channel of RGBA bytes.""" inv = bytes(range(255, -1, -1)) g = bytes(d[3::4]).translate(inv) out = bytearray(len(g) * 3) out[0::3] = out[1::3] = out[2::3] = g return bytes(out) def srgb_lut(): """Linear to sRGB-encoded 8-bit lookup (used only when no output transform applied).""" t = [] for i in range(256): c = i / 255.0 s = 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055 t.append(int(round(max(0.0, min(1.0, s)) * 255))) return bytes(t) def downsample(w, h, rgb, tw, th): """Nearest-neighbour resize of RGB bytes (exact integer factors use slicing).""" if (w, h) == (tw, th): return rgb out = bytearray(tw * th * 3) if w % tw == 0 and h % th == 0 and w // tw == h // th: f = w // tw stride = w * 3 for y in range(th): row = rgb[(y * f) * stride:(y * f + 1) * stride] o = y * tw * 3 seg = bytearray(tw * 3) seg[0::3], seg[1::3], seg[2::3] = row[0::3 * f], row[1::3 * f], row[2::3 * f] out[o:o + tw * 3] = seg return bytes(out) xs = [min(w - 1, int((x + 0.5) * w / tw)) * 3 for x in range(tw)] for y in range(th): sy = min(h - 1, int((y + 0.5) * h / th)) * w * 3 o = y * tw * 3 for x, sx in enumerate(xs): out[o + x * 3:o + x * 3 + 3] = rgb[sy + sx:sy + sx + 3] return bytes(out) # 5x7 bitmap font, one int per row, bit 4 = leftmost column FONT = { "A": (14, 17, 17, 31, 17, 17, 17), "B": (30, 17, 17, 30, 17, 17, 30), "C": (14, 17, 16, 16, 16, 17, 14), "D": (30, 17, 17, 17, 17, 17, 30), "E": (31, 16, 16, 30, 16, 16, 31), "F": (31, 16, 16, 30, 16, 16, 16), "G": (14, 17, 16, 23, 17, 17, 15), "H": (17, 17, 17, 31, 17, 17, 17), "I": (14, 4, 4, 4, 4, 4, 14), "J": (7, 2, 2, 2, 2, 18, 12), "K": (17, 18, 20, 24, 20, 18, 17), "L": (16, 16, 16, 16, 16, 16, 31), "M": (17, 27, 21, 21, 17, 17, 17), "N": (17, 17, 25, 21, 19, 17, 17), "O": (14, 17, 17, 17, 17, 17, 14), "P": (30, 17, 17, 30, 16, 16, 16), "Q": (14, 17, 17, 17, 21, 18, 13), "R": (30, 17, 17, 30, 20, 18, 17), "S": (15, 16, 16, 14, 1, 1, 30), "T": (31, 4, 4, 4, 4, 4, 4), "U": (17, 17, 17, 17, 17, 17, 14), "V": (17, 17, 17, 17, 17, 10, 4), "W": (17, 17, 17, 21, 21, 21, 10), "X": (17, 17, 10, 4, 10, 17, 17), "Y": (17, 17, 17, 10, 4, 4, 4), "Z": (31, 1, 2, 4, 8, 16, 31), "0": (14, 17, 19, 21, 25, 17, 14), "1": (4, 12, 4, 4, 4, 4, 14), "2": (14, 17, 1, 2, 4, 8, 31), "3": (31, 2, 4, 2, 1, 17, 14), "4": (2, 6, 10, 18, 31, 2, 2), "5": (31, 16, 30, 1, 1, 17, 14), "6": (6, 8, 16, 30, 17, 17, 14), "7": (31, 1, 2, 4, 8, 8, 8), "8": (14, 17, 17, 14, 17, 17, 14), "9": (14, 17, 17, 15, 1, 2, 12), " ": (0, 0, 0, 0, 0, 0, 0), "/": (1, 1, 2, 4, 8, 16, 16), "-": (0, 0, 0, 31, 0, 0, 0), "_": (0, 0, 0, 0, 0, 0, 31), ".": (0, 0, 0, 0, 0, 12, 12), ":": (0, 12, 12, 0, 12, 12, 0), "(": (2, 4, 8, 8, 8, 4, 2), ")": (8, 4, 2, 2, 2, 4, 8), "%": (24, 25, 2, 4, 8, 19, 3), "+": (0, 4, 4, 31, 4, 4, 0), "=": (0, 0, 31, 0, 31, 0, 0), "?": (14, 17, 1, 2, 4, 0, 4), } def draw_text(buf, width, x, y, text, scale=2, color=(0, 0, 0)): """Draw uppercase text into an RGB bytearray of the given width.""" px = bytes(color) cx = x for ch in text.upper(): rows = FONT.get(ch, FONT["?"]) for ry, bits in enumerate(rows): for rx in range(5): if bits & (16 >> rx): for sy in range(scale): yy = y + ry * scale + sy o = (yy * width + cx + rx * scale) * 3 for sx in range(scale): buf[o + sx * 3:o + sx * 3 + 3] = px cx += 6 * scale return cx def text_width(text, scale=2): return len(text) * 6 * scale def contact_sheet(cells, cols, tile_w, tile_h, out_path, label_scale=2, bg=(235, 235, 235), title=None): """cells: list (row-major) of (label, rgb_bytes or None) at tile size. Writes a PNG.""" rows = int(math.ceil(len(cells) / float(cols))) lh = 7 * label_scale + 6 th = 7 * label_scale + 10 if title else 0 W, H = cols * tile_w, th + rows * (tile_h + lh) buf = bytearray(bytes(bg) * (W * H)) if title: draw_text(buf, W, 4, 4, title[: max(1, W // (6 * label_scale) - 1)], label_scale, (0, 0, 0)) for i, (label, rgb) in enumerate(cells): r, c = divmod(i, cols) x0, y0 = c * tile_w, th + r * (tile_h + lh) maxch = max(1, tile_w // (6 * label_scale) - 1) draw_text(buf, W, x0 + 3, y0 + 3, label[:maxch], label_scale, (20, 20, 20)) if rgb is None: continue for y in range(tile_h): o = ((y0 + lh + y) * W + x0) * 3 buf[o:o + tile_w * 3] = rgb[y * tile_w * 3:(y + 1) * tile_w * 3] write_png(out_path, W, H, buf, channels=3) return out_path, (W, H) # =========================================================================== Maya helpers def _cmds(): import maya.cmds as cmds return cmds class _Restorer(object): """Records every change to user nodes and undoes them in reverse order.""" def __init__(self): self.ops = [] self.missing = [] self.errors = [] def set(self, plug, value, kind=None): cmds = _cmds() node, attr = plug.split(".", 1) if not cmds.objExists(node) or not cmds.attributeQuery(attr.split("[")[0], node=node, exists=True): self.missing.append(plug) return False if cmds.getAttr(plug, lock=True): self.errors.append("locked: " + plug) return False if cmds.listConnections(plug, source=True, destination=False, plugs=True): self.errors.append("connected: " + plug) return False kind = kind or cmds.getAttr(plug, type=True) old = cmds.getAttr(plug) self.ops.append(("set", plug, old, kind)) self._apply(plug, value, kind) return True @staticmethod def _apply(plug, value, kind): cmds = _cmds() if kind == "string": cmds.setAttr(plug, value or "", type="string") elif isinstance(value, (list, tuple)): vals = value[0] if value and isinstance(value[0], (list, tuple)) else value if kind in ("double3", "float3", "double2", "float2", "long2", "long3", "short2", "short3"): cmds.setAttr(plug, *vals, type=kind) else: cmds.setAttr(plug, *vals) else: cmds.setAttr(plug, value) def disconnect_inputs(self, plug): cmds = _cmds() node, attr = plug.split(".", 1) if not cmds.objExists(node) or not cmds.attributeQuery(attr, node=node, exists=True): return for src in cmds.listConnections(plug, source=True, destination=False, plugs=True) or []: cmds.disconnectAttr(src, plug) self.ops.append(("connect", src, plug, None)) def restore(self): cmds = _cmds() for op in reversed(self.ops): try: if op[0] == "set": self._apply(op[1], op[2], op[3]) elif op[0] == "connect": cmds.connectAttr(op[1], op[2], force=True) except Exception as exc: self.errors.append("restore %s: %s" % (op[1], exc)) self.ops = [] def _pick_type(cmds, *names): have = set(cmds.allNodeTypes() or []) for n in names: if n in have: return n return None def _set_enum(cmds, plug, label): node, attr = plug.split(".", 1) if not cmds.attributeQuery(attr, node=node, exists=True): return False try: names = (cmds.attributeQuery(attr, node=node, listEnum=True) or [""])[0].split(":") except Exception: names = [] idx = None for i, n in enumerate(names): if n.split("=")[0].lower() == label.lower(): idx = int(n.split("=")[1]) if "=" in n else i break if idx is None: return False cmds.setAttr(plug, idx) return True def _light_shape_and_xform(cmds, node): if cmds.nodeType(node) == "transform": return (cmds.listRelatives(node, shapes=True, fullPath=True) or [node])[0], node return node, (cmds.listRelatives(node, parent=True, fullPath=True) or [node])[0] def _ensure_arnold(cmds): if not cmds.pluginInfo("mtoa", q=True, loaded=True): cmds.loadPlugin("mtoa", quiet=True) created = [] try: import mtoa.core before = set(cmds.ls(["defaultArnoldRenderOptions", "defaultArnoldDriver", "defaultArnoldFilter"]) or []) mtoa.core.createOptions() # [verify] creates the options nodes created = sorted(set(cmds.ls(["defaultArnoldRenderOptions", "defaultArnoldDriver", "defaultArnoldFilter"]) or []) - before) except Exception: pass if not cmds.objExists("defaultArnoldRenderOptions"): raise RuntimeError("Arnold options node missing after loading mtoa") return created def _geometry_to_hide(cmds): shapes = set(cmds.ls(geometry=True, long=True, noIntermediate=True) or []) for t in ("aiStandIn", "aiVolume", "gpuCache", "mayaUsdProxyShape", "instancer", "xgmSplineDescription", "aiMeshLight"): if _pick_type(cmds, t): shapes.update(cmds.ls(type=t, long=True) or []) return sorted(s for s in shapes if not s.split("|")[-1].startswith(NS + ":")) def _lights_to_hide(cmds): lights = set(cmds.ls(lights=True, long=True) or []) for t in ("aiSkyDomeLight", "aiAreaLight", "aiMeshLight", "aiPhotometricLight", "aiLightPortal", "aiPhysicalSky"): if _pick_type(cmds, t): lights.update(cmds.ls(type=t, long=True) or []) return sorted(s for s in lights if not s.split("|")[-1].startswith(NS + ":")) def _target_points_ui(cmds, targets, limit=20000): """World-space sample points (UI units) of the targets' meshes; bbox corners for others.""" import maya.api.OpenMaya as om f = 1.0 / UI_TO_CM.get(cmds.currentUnit(q=True, linear=True), 1.0) pts = [] shapes = [] for t in targets: if cmds.nodeType(t) == "mesh": shapes.append(t) shapes += cmds.listRelatives(t, allDescendents=True, type="mesh", fullPath=True) or [] shapes = [s for s in dict.fromkeys(shapes) if not cmds.getAttr(s + ".intermediateObject")] per = max(1, limit // max(1, len(shapes))) for s in shapes: if not cmds.polyEvaluate(s, vertex=True): continue sl = om.MSelectionList() sl.add(s) arr = om.MFnMesh(sl.getDagPath(0)).getPoints(om.MSpace.kWorld) step = max(1, len(arr) // per) pts += [(arr[i].x * f, arr[i].y * f, arr[i].z * f) for i in range(0, len(arr), step)] if not pts: bb = cmds.exactWorldBoundingBox(targets) pts = [(x, y, z) for x in (bb[0], bb[3]) for y in (bb[1], bb[4]) for z in (bb[2], bb[5])] return pts def _deg(cmds, angles): if cmds.currentUnit(q=True, angle=True) == "rad": return tuple(math.radians(a) for a in angles) return angles def _place(cmds, xform, position, direction, up_axis): rot = _deg(cmds, aim_rotation(direction, up_axis)) cmds.setAttr(xform + ".rotateOrder", 0) cmds.setAttr(xform + ".translate", *position, type="double3") cmds.setAttr(xform + ".rotate", *rot, type="double3") class _Session(object): """Temporary review setup: created nodes in namespace mxReview, restorer for the rest. Private: skills use review()'s options (modes, checker_repeats, shader_modes, subdiv). scenario-maya-groom subclasses it; keep the positional signature and method names stable.""" def __init__(self, targets, out_dir, resolution, samples, denoise, log, subdiv=None, checker=None, shader_modes=None): cmds = _cmds() self.cmds = cmds self.subdiv = subdiv self.checker = checker # None, or {"repeats": float, "colors": (c1, c2)} self.shader_modes = _normalize_modes(shader_modes) self.custom = {} self.out_dir = out_dir self.res = resolution self.notes = [] self.log = log self.rest = _Restorer() self.prev_ns = cmds.namespaceInfo(currentNamespace=True, absoluteName=True) self.sel = cmds.ls(selection=True, long=True) or [] self.modified = cmds.file(q=True, modified=True) self.batch = cmds.about(batch=True) self.up = cmds.upAxis(q=True, axis=True) self.chunk = False self.samples = samples self.denoise = denoise self.targets = targets self.cm_prefs = [] self.gamma = False self.dups = [] def __enter__(self): cmds = self.cmds if not self.batch: cmds.undoInfo(openChunk=True, chunkName="mxReview") self.chunk = True try: if cmds.namespace(exists=":" + NS): # left over by a crashed earlier run cmds.namespace(removeNamespace=":" + NS, deleteNamespaceContent=True) created = _ensure_arnold(cmds) if created: self.notes.append("created missing Arnold option nodes %s (left in the scene)" % created) cmds.namespace(add=NS) with self.in_ns(): self._build() self._build_options() self._configure() except BaseException: self.__exit__(*sys.exc_info()) # __exit__ does not run when __enter__ raises raise return self def in_ns(self): cmds, prev = self.cmds, self.prev_ns or ":" class _Ctx(object): def __enter__(self_): cmds.namespace(set=":" + NS) def __exit__(self_, *a): cmds.namespace(set=prev) return False return _Ctx() # ---------------------------------------------------------------- build def _build(self): cmds = self.cmds self.grp = cmds.createNode("transform", name=NS + ":review_GRP") dups = [] for t in self.targets: d = cmds.duplicate(t, returnRootsOnly=True, name=NS + ":dup_" + t.split("|")[-1].split(":")[-1])[0] dups.append(cmds.parent(d, self.grp)[0]) # keeps the world transform self.dups = [cmds.ls(d, long=True)[0] for d in dups] for d in self.dups: # a duplicate of a hidden original stays hidden: show it for n in [d] + (cmds.listRelatives(d, allDescendents=True, fullPath=True) or []): if cmds.attributeQuery("visibility", node=n, exists=True) and not cmds.getAttr(n + ".visibility"): try: cmds.setAttr(n + ".visibility", 1) except Exception: pass # shaders self.sg = {} std = _pick_type(cmds, "aiStandardSurface", "standardSurface", "lambert") clay = cmds.shadingNode(std, asShader=True, name=NS + ":clay") if std in ("aiStandardSurface", "standardSurface"): cmds.setAttr(clay + ".base", 1.0) cmds.setAttr(clay + ".baseColor", 0.5, 0.5, 0.5, type="double3") cmds.setAttr(clay + ".specular", 0.3) cmds.setAttr(clay + ".specularRoughness", 0.6) else: cmds.setAttr(clay + ".color", 0.5, 0.5, 0.5, type="double3") self.sg["clay"] = self._sg(clay, "clay") util_fill = cmds.shadingNode("aiUtility", asShader=True, name=NS + ":fill") _set_enum(cmds, util_fill + ".shadeMode", "ndoteye") cmds.setAttr(util_fill + ".color", 0.8, 0.8, 0.8, type="double3") wire = cmds.shadingNode("aiWireframe", asShader=True, name=NS + ":wire") cmds.connectAttr(util_fill + ".outColor", wire + ".fillColor", force=True) cmds.setAttr(wire + ".lineColor", 0.02, 0.02, 0.02, type="double3") cmds.setAttr(wire + ".lineWidth", max(1.0, self.res / 1024.0)) _set_enum(cmds, wire + ".edgeType", "polygons") if cmds.attributeQuery("rasterSpace", node=wire, exists=True): cmds.setAttr(wire + ".rasterSpace", 1) self.sg["wire"] = self._sg(wire, "wire") nrm = cmds.shadingNode("aiUtility", asShader=True, name=NS + ":normals") if not _set_enum(cmds, nrm + ".colorMode", "n"): _set_enum(cmds, nrm + ".colorMode", "ns") _set_enum(cmds, nrm + ".shadeMode", "flat") self.sg["normals"] = self._sg(nrm, "normals") # lights and camera dome = cmds.shadingNode("aiSkyDomeLight", asLight=True, name=NS + ":dome") self.dome_shape, self.dome = _light_shape_and_xform(cmds, dome) cmds.setAttr(self.dome_shape + ".color", 0.45, 0.45, 0.45, type="double3") cmds.setAttr(self.dome_shape + ".intensity", 1.0) key = cmds.shadingNode("directionalLight", asLight=True, name=NS + ":key") self.key_shape, self.key = _light_shape_and_xform(cmds, key) cmds.setAttr(self.key_shape + ".intensity", 2.0) if cmds.attributeQuery("aiAngle", node=self.key_shape, exists=True): cmds.setAttr(self.key_shape + ".aiAngle", 3.0) cam = cmds.camera(name=NS + ":cam") self.cam, self.cam_shape = cmds.ls(cam[0], long=True)[0], cmds.ls(cam[1], long=True)[0] cmds.setAttr(self.cam_shape + ".horizontalFilmAperture", APERTURE_IN) cmds.setAttr(self.cam_shape + ".verticalFilmAperture", APERTURE_IN) cmds.setAttr(self.cam_shape + ".focalLength", FOCAL_MM) self.imager = None if self.denoise: t = _pick_type(self.cmds, "aiImagerDenoiserOidn") if t and not cmds.ls(type=t): # new scenes may already have one self.imager = cmds.createNode(t, name=NS + ":oidn") def _sg(self, shader, name): cmds = self.cmds sg = cmds.sets(renderable=True, noSurfaceShader=True, empty=True, name=NS + ":%sSG" % name) cmds.connectAttr(shader + ".outColor", sg + ".surfaceShader", force=True) return sg def name(self, suffix): """A node name inside the review namespace (deleted with it at the end).""" return "%s:%s" % (NS, suffix) def _build_options(self): """The optional parts: Arnold subdivision on the duplicates, the checker shader, the callers' custom shaders. Runs after _build() (subclasses that replace _build and set no option get nothing here).""" cmds = self.cmds if self.subdiv: for d in self.dups: for s in cmds.listRelatives(d, allDescendents=True, type="mesh", fullPath=True) or []: if cmds.getAttr(s + ".intermediateObject"): continue if cmds.attributeQuery("displaySmoothMesh", node=s, exists=True): cmds.setAttr(s + ".displaySmoothMesh", 0) if cmds.attributeQuery("aiSubdivType", node=s, exists=True): if not _set_enum(cmds, s + ".aiSubdivType", "catclark"): cmds.setAttr(s + ".aiSubdivType", 1) # [verify] 1 = catclark cmds.setAttr(s + ".aiSubdivIterations", int(self.subdiv)) else: self.notes.append("subdiv: %s has no aiSubdivType (mtoa not loaded?)" % s) if self.checker is not None: std = _pick_type(cmds, "aiStandardSurface", "standardSurface", "lambert") shader = cmds.shadingNode(std, asShader=True, name=self.name("checkerShader")) tex = cmds.shadingNode("checker", asTexture=True, name=self.name("checker")) p2d = cmds.shadingNode("place2dTexture", asUtility=True, name=self.name("checkerP2d")) cmds.connectAttr(p2d + ".outUV", tex + ".uvCoord", force=True) cmds.connectAttr(p2d + ".outUvFilterSize", tex + ".uvFilterSize", force=True) reps = float(self.checker.get("repeats") or CHECKER_REPEATS) cmds.setAttr(p2d + ".repeatU", reps) cmds.setAttr(p2d + ".repeatV", reps) c1, c2 = self.checker.get("colors") or CHECKER_COLORS cmds.setAttr(tex + ".color1", *c1, type="double3") cmds.setAttr(tex + ".color2", *c2, type="double3") if std in ("aiStandardSurface", "standardSurface"): cmds.setAttr(shader + ".base", 1.0) cmds.setAttr(shader + ".specular", 0.3) cmds.setAttr(shader + ".specularRoughness", 0.6) cmds.connectAttr(tex + ".outColor", shader + ".baseColor", force=True) else: cmds.connectAttr(tex + ".outColor", shader + ".color", force=True) self.sg["checker"] = self._sg(shader, "checker") self.checker_nodes = {"shader": shader, "texture": tex, "place2d": p2d, "repeats": reps} for mode, spec in self.shader_modes.items(): shader = spec["build"](self) if isinstance(shader, (list, tuple)): shader = shader[0] if not shader or not cmds.objExists(shader): raise RuntimeError("shader_modes[%r] build returned %r, not a shader node" % (mode, shader)) self.sg[mode] = self._sg(shader, mode) self.custom[mode] = dict(spec, shader=shader) # ---------------------------------------------------------------- configure (restorable) def _configure(self): cmds, R = self.cmds, self.rest R.set("defaultRenderGlobals.currentRenderer", "arnold", "string") R.set("defaultRenderGlobals.animation", 0) R.set("defaultRenderGlobals.imageFormat", 32) # png R.set("defaultRenderGlobals.imfPluginKey", "png", "string") R.set("defaultRenderGlobals.imageFilePrefix", os.path.join(self.out_dir, "_raw", "mx"), "string") R.set("defaultResolution.width", self.res) R.set("defaultResolution.height", self.res) R.set("defaultResolution.deviceAspectRatio", 1.0) R.set("defaultResolution.pixelAspect", 1.0) R.set("defaultArnoldDriver.ai_translator", "png", "string") s = dict(AASamples=3, GIDiffuseSamples=2, GISpecularSamples=2, GITransmissionSamples=2, GISssSamples=2, GIVolumeSamples=0, GIDiffuseDepth=1, GISpecularDepth=1) s.update(self.samples or {}) for attr, val in s.items(): R.set("defaultArnoldRenderOptions." + attr, val) for attr, val in (("enableAdaptiveSampling", 0), ("aovMode", 0), ("motion_blur_enable", 0), ("renderDevice", 0), ("abortOnLicenseFail", 0), ("log_to_file", 1), ("log_verbosity", 1)): R.set("defaultArnoldRenderOptions." + attr, val) R.set("defaultArnoldRenderOptions.log_filename", os.path.join(self.out_dir, "arnold.log"), "string") for plug in ("defaultArnoldRenderOptions.background", "defaultArnoldRenderOptions.atmosphere"): R.disconnect_inputs(plug) if R.missing: self.notes.append("attributes not found (check names on this MtoA): %s" % sorted(set(R.missing))) # only our camera and the master layer render for c in cmds.ls(type="camera", long=True) or []: if c != self.cam_shape: R.set(c + ".renderable", 0) cmds.setAttr(self.cam_shape + ".renderable", 1) # ours: no restore needed for lay in cmds.ls(type="renderLayer") or []: if cmds.referenceQuery(lay, isNodeReferenced=True): continue R.set(lay + ".renderable", 1 if lay == "defaultRenderLayer" else 0) # hide the user's geometry and lights (the duplicates render instead) mine = set(self.dups) | set(cmds.listRelatives(self.dups, allDescendents=True, fullPath=True) or []) for s in _geometry_to_hide(cmds): if s not in mine and cmds.getAttr(s + ".visibility"): R.set(s + ".visibility", 0) for l in _lights_to_hide(cmds): if l not in (self.dome_shape, self.key_shape) and cmds.getAttr(l + ".visibility"): R.set(l + ".visibility", 0) # imager if self.imager: for holder in ("defaultArnoldDriver", "defaultArnoldRenderOptions"): if cmds.attributeQuery("imagers", node=holder, exists=True): idx = len(cmds.getAttr(holder + ".imagers", multiIndices=True) or []) try: cmds.connectAttr(self.imager + ".message", "%s.imagers[%d]" % (holder, idx), force=True) self.notes.append("OIDN imager connected to %s.imagers[%d]" % (holder, idx)) except Exception as exc: self.notes.append("OIDN imager not connected: %s" % exc) break else: self.notes.append("no imagers attribute found; rendering without OIDN") # output transform so 8-bit PNGs are display-referred self.gamma = False try: old_on = cmds.colorManagementPrefs(q=True, outputTarget="renderer", outputTransformEnabled=True) old_view = cmds.colorManagementPrefs(q=True, outputTarget="renderer", outputUseViewTransform=True) self.cm_prefs = [(old_on, old_view)] cmds.colorManagementPrefs(e=True, outputTarget="renderer", outputTransformEnabled=True) cmds.colorManagementPrefs(e=True, outputTarget="renderer", outputUseViewTransform=True) except Exception as exc: self.cm_prefs = [] self.gamma = True self.notes.append("output transform not set (%s); applying an sRGB curve to the PNGs" % exc) # ---------------------------------------------------------------- per render def assign(self, mode): with self.in_ns(): # groupId nodes made by the assignment land in mxReview too self.cmds.sets(self.dups, e=True, forceElement=self.sg["wire" if mode == "silhouette" else mode]) spec = self.custom.get(mode) lit = bool(spec.get("lit", True)) if spec else mode in LIT_MODES self.cmds.setAttr(self.dome_shape + ".visibility", 1 if lit else 0) self.cmds.setAttr(self.key_shape + ".visibility", 1 if lit else 0) if self.custom: self._mode_lights(spec) def _mode_lights(self, spec): """Dome colour input and key intensity for one mode; back to the defaults otherwise.""" cmds = self.cmds plug = spec.get("dome_color") if spec else None if callable(plug): plug = plug(self) dome_color = self.dome_shape + ".color" current = cmds.listConnections(dome_color, source=True, destination=False, plugs=True) or [] if plug: cmds.connectAttr(plug, dome_color, force=True) else: for src in current: cmds.disconnectAttr(src, dome_color) if current: cmds.setAttr(dome_color, *DOME_COLOR, type="double3") key = spec.get("key_intensity") if spec else None cmds.setAttr(self.key_shape + ".intensity", float(key) if key is not None else KEY_INTENSITY) def aim(self, center, direction, fr): cmds = self.cmds d = _norm(direction) pos = _add(center, _mul(d, fr["distance"])) _place(cmds, self.cam, pos, d, self.up) ortho = fr["ortho_width"] is not None cmds.setAttr(self.cam_shape + ".orthographic", 1 if ortho else 0) if ortho: cmds.setAttr(self.cam_shape + ".orthographicWidth", fr["ortho_width"]) cmds.setAttr(self.cam_shape + ".nearClipPlane", fr["near"]) cmds.setAttr(self.cam_shape + ".farClipPlane", fr["far"]) right, up, fwd = basis(d, self.up) key_dir = _norm(_add(_add(_mul(fwd, -0.6), _mul(up, 0.7)), _mul(right, -0.4))) # [added] over the left shoulder _place(cmds, self.key, center, key_dir, self.up) def render(self, name): cmds = self.cmds import maya.mel as mel prefix = os.path.join(self.out_dir, "_raw", name) os.makedirs(os.path.dirname(prefix), exist_ok=True) cmds.setAttr("defaultRenderGlobals.imageFilePrefix", prefix, type="string") t0 = time.time() mel.eval("arnoldRender -b;") # [verify] batch render to file found = [p for p in glob.glob(prefix + "*.png") + glob.glob(os.path.join( cmds.workspace(q=True, rootDirectory=True), "images", "**", name + "*.png"), recursive=True) if os.path.getmtime(p) >= t0 - 1] if not found: raise RuntimeError("Arnold wrote no PNG for %s (see %s/arnold.log)" % (name, self.out_dir)) found.sort(key=os.path.getmtime) dst = os.path.join(self.out_dir, name + ".png") shutil.copyfile(found[-1], dst) self.log.append({"render": name, "seconds": round(time.time() - t0, 2)}) return dst def __exit__(self, *exc): cmds = self.cmds try: self.rest.restore() for old_on, old_view in self.cm_prefs: try: cmds.colorManagementPrefs(e=True, outputTarget="renderer", outputTransformEnabled=old_on) cmds.colorManagementPrefs(e=True, outputTarget="renderer", outputUseViewTransform=old_view) except Exception as e: self.notes.append("could not restore output transform prefs: %s" % e) if cmds.namespace(exists=":" + NS): cmds.namespace(removeNamespace=":" + NS, deleteNamespaceContent=True) cmds.namespace(set=self.prev_ns or ":") keep = [s for s in self.sel if cmds.objExists(s)] if keep: cmds.select(keep, replace=True) else: cmds.select(clear=True) cmds.file(modified=self.modified) finally: if self.chunk: cmds.undoInfo(closeChunk=True) if self.rest.errors: self.notes.append("restore issues: %s" % self.rest.errors) return False def _normalize_modes(shader_modes): """{name: {"build": callable, "lit", "dome_color", "key_intensity"}} from the caller's shader_modes (a callable alone is a build function). Pure.""" out = {} for name, spec in (shader_modes or {}).items(): if name in BUILTIN_MODES: raise ValueError("shader_modes name %r collides with a built-in mode" % name) if callable(spec): spec = {"build": spec} if not isinstance(spec, dict) or not callable(spec.get("build")): raise ValueError("shader_modes[%r] needs a 'build' callable" % name) unknown = set(spec) - {"build", "lit", "dome_color", "key_intensity"} if unknown: raise ValueError("shader_modes[%r]: unknown keys %s" % (name, sorted(unknown))) out[name] = dict(spec) return out # =========================================================================== public helpers def resolve_targets(targets): """Long names of the targets (a shape is replaced by its transform), as review() uses them.""" return _resolve_targets(_cmds(), targets if isinstance(targets, (list, tuple)) else [targets]) def target_points(targets, limit=20000): """World-space sample points in UI units of the targets' meshes, as review() frames them (empty meshes skipped; bbox corners when there is no mesh).""" cmds = _cmds() return _target_points_ui(cmds, resolve_targets(targets), limit) def set_enum(plug, label): """Set an enum attribute by its label (case-insensitive). False when the attribute or the label does not exist on this build.""" return _set_enum(_cmds(), plug, label) def pick_type(*names): """The first node type of `names` this session knows (plug-in types may be missing).""" return _pick_type(_cmds(), *names) def to_ui_angles(angles): """Degrees to the scene's angle unit (radians when the UI unit is rad).""" return _deg(_cmds(), tuple(angles)) def view_camera(targets, view, margin=1.1, focus=None, resolution=None): """The camera review() would use for one view, without rendering (Maya session needed for the target points). focus: ((x, y, z), radius) in centimeters, as in review().""" cmds = _cmds() tg = resolve_targets(targets) pts = _target_points_ui(cmds, tg) f_ui = 1.0 / UI_TO_CM.get(cmds.currentUnit(q=True, linear=True), 1.0) foc = (tuple(x * f_ui for x in focus[0]), float(focus[1]) * f_ui) if focus else None return view_cameras(pts, [view], cmds.upAxis(q=True, axis=True), margin, foc, resolution)[view] def _resolve_targets(cmds, targets): """Long names of the targets; a shape name is replaced by its transform.""" out = [] for t in targets: found = cmds.ls(t, long=True) or [] if not found: raise ValueError("no node named %s" % t) n = found[0] if cmds.nodeType(n) != "transform" and "shape" in (cmds.nodeType(n, inherited=True) or []): n = (cmds.listRelatives(n, parent=True, fullPath=True) or [n])[0] if n not in out: out.append(n) return out def _tile_rgb(path, mode, tile, gamma, view_alpha_src=None): w, h, d = read_rgba(path) if mode == "silhouette": rgb = alpha_silhouette(d) else: rgb = rgba_to_rgb(d) if gamma: rgb = rgb.translate(srgb_lut()) return downsample(w, h, rgb, tile, tile), (w, h) def review(targets, out_dir, views=DEFAULT_VIEWS, modes=DEFAULT_MODES, resolution=1024, tile=512, margin=1.1, focus=None, samples=None, denoise=True, title=None, checker_repeats=None, checker_colors=None, shader_modes=None, subdiv=None, render_prefix="", sheet_name="review_sheet.png", json_name="review.json"): """Render modes x views of the targets and write one contact sheet. Returns a dict: sheet, sheet_size, tiles {mode: {view: png}}, cameras {view: camera_record}, seconds, renders, notes, reader. modes may include "checker" and the keys of shader_modes (see the module docstring); the cameras are also saved in review.json.""" cmds = _cmds() t0 = time.time() out_dir = os.path.abspath(out_dir) os.makedirs(out_dir, exist_ok=True) targets = _resolve_targets(cmds, targets if isinstance(targets, (list, tuple)) else [targets]) custom = _normalize_modes(shader_modes) unknown = [m for m in modes if m not in BUILTIN_MODES and m not in custom] if unknown: raise ValueError("unknown modes %s (built-in %s, custom %s)" % (unknown, BUILTIN_MODES, sorted(custom))) checker = None if "checker" in modes: checker = {"repeats": checker_repeats or CHECKER_REPEATS, "colors": checker_colors or CHECKER_COLORS} log = [] tiles = {m: {} for m in modes} raw = {} up = cmds.upAxis(q=True, axis=True) pts = _target_points_ui(cmds, targets) f_ui = 1.0 / UI_TO_CM.get(cmds.currentUnit(q=True, linear=True), 1.0) foc = (tuple(x * f_ui for x in focus[0]), float(focus[1]) * f_ui) if focus else None center, pts = frame_points(pts, foc) cameras = view_cameras(pts, views, up, margin, None, resolution) # pts already framed render_modes = [m for m in modes if m != "silhouette"] if "silhouette" in modes and not any(m in render_modes for m in ("wire", "normals")): render_modes.append("normals") # silhouette needs an unlit render's alpha with _Session(targets, out_dir, resolution, samples, denoise, log, subdiv=subdiv, checker=checker, shader_modes={m: s for m, s in custom.items() if m in modes}) as S: for mode in render_modes: S.assign(mode) for view in views: d = map_dir(VIEW_DIRS[view], up) fr = frame(pts, center, d, view in PERSPECTIVE, margin, up) S.aim(center, d, fr) raw[(mode, view)] = S.render("%s%s_%s" % (render_prefix, mode, view)) notes = S.notes gamma = S.gamma cells = [] for mode in modes: for view in views: src = raw.get((mode, view)) if mode == "silhouette": src = raw.get(("wire", view)) or raw.get(("normals", view)) if not src: cells.append(("%s / %s (missing)" % (mode, view), None)) continue rgb, _ = _tile_rgb(src, mode, tile, gamma) if mode == "silhouette": p = os.path.join(out_dir, "%ssilhouette_%s.png" % (render_prefix, view)) w, h, d = read_rgba(src) write_png(p, w, h, alpha_silhouette(d)) src = p tiles[mode][view] = src cells.append(("%s / %s" % (mode, view), rgb)) sheet, size = contact_sheet(cells, len(views), tile, tile, os.path.join(out_dir, sheet_name), title=title) rec = {"sheet": sheet, "sheet_size": size, "tiles": tiles, "cameras": cameras, "renders": log, "notes": notes, "reader": _READER_USED.get("name"), "resolution": resolution, "tile": tile, "seconds": round(time.time() - t0, 2), "targets": targets, "modes": list(modes), "subdiv": subdiv, "checker_repeats": (checker or {}).get("repeats")} if json_name: with open(os.path.join(out_dir, json_name), "w") as f: json.dump(rec, f, indent=1) return rec def turntable(targets, out_dir, frames=24, mode="clay", resolution=512, elevation=15.0, margin=1.15, columns=8, video=True, fps=24): """Orbit a perspective camera around the up axis and render `frames` stills; writes a strip sheet and, if ffmpeg is on PATH, an mp4 [added].""" cmds = _cmds() out_dir = os.path.abspath(out_dir) os.makedirs(out_dir, exist_ok=True) targets = _resolve_targets(cmds, targets if isinstance(targets, (list, tuple)) else [targets]) up = cmds.upAxis(q=True, axis=True) pts = _target_points_ui(cmds, targets) lo = [min(p[i] for p in pts) for i in range(3)] hi = [max(p[i] for p in pts) for i in range(3)] center = tuple((lo[i] + hi[i]) / 2.0 for i in range(3)) log, paths = [], [] el = math.radians(elevation) dists = [] dirs = [] for i in range(frames): a = 2 * math.pi * i / frames d = map_dir((math.sin(a) * math.cos(el), math.sin(el), math.cos(a) * math.cos(el)), up) dirs.append(d) dists.append(frame(pts, center, d, True, margin, up)) far = max(dists, key=lambda x: x["distance"]) # constant distance: no breathing with _Session(targets, out_dir, resolution, None, True, log) as S: S.assign("wire" if mode == "silhouette" else mode) for i, d in enumerate(dirs): S.aim(center, d, far) paths.append(S.render("turn_%s_%04d" % (mode, i + 1))) notes, gamma = S.notes, S.gamma tile = min(resolution, 256) cells = [("F%d" % (i + 1), _tile_rgb(p, mode, tile, gamma)[0]) for i, p in enumerate(paths)] sheet, size = contact_sheet(cells, min(columns, frames), tile, tile, os.path.join(out_dir, "turntable_sheet.png")) rec = {"frames": paths, "sheet": sheet, "notes": notes, "renders": log} ff = shutil.which("ffmpeg") if video and ff: pattern = os.path.join(out_dir, "turn_%s_%%04d.png" % mode) mp4 = os.path.join(out_dir, "turntable_%s.mp4" % mode) try: subprocess.check_call([ff, "-y", "-loglevel", "error", "-framerate", str(fps), "-i", pattern, "-pix_fmt", "yuv420p", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", mp4]) rec["video"] = mp4 except Exception as exc: rec["notes"].append("ffmpeg failed: %s" % exc) return rec # =========================================================================== GUI playb -
mx_run.py 21.3 KB
""" mx_run: run one Python job inside headless Maya (mayapy + maya.standalone) and print one machine-readable result line. The shared entry point for every scenario-maya-* skill. Shell: mayapy mx_run.py [options] job.py [-- job args...] python3 mx_run.py [options] job.py [-- job args...] # finds mayapy and re-runs itself python3 mx_run.py --find # print the mayapy path, exit 5 if none Options: --scene PATH open this scene first (script nodes NOT executed unless --script-nodes) --plugins LIST comma list: mtoa, fbx, abc, usd, obj, gpucache, bifrost, mash, xgen, game, or any exact plug-in name --require-plugins fail if a requested plug-in does not load --script-nodes execute the scene's script nodes on open (off: they can run any code) --save-as PATH save to PATH after a successful job (refuses the source path) --usersetup let userSetup.py run (skipped by default so runs are reproducible) --timeout SEC parent mode only: kill the child after SEC seconds (exit code 4) --json PATH also write the result JSON to PATH --soft-exit return normally instead of os._exit() after uninitialize --maya-log LEVEL what Maya prints to stderr in batch: all, info, result, warning (the default here), error, none. Use "all" when a job fails silently. Child environment (set by the parent before mayapy starts, so Maya sees it at start-up): MAYA_DISABLE_ADP=1 analytics off in batch (Maya 2027 Help, Start Maya from the command line; devkit What's New 2022) MAYA_BATCH_STDOUT_LOGGING_LEVEL=none the documented default, written explicitly so a shell setting cannot flood the MX_RESULT channel MAYA_BATCH_STDERR_LOGGING_LEVEL=warning Maya's warnings and errors reach log_tail without the info and result chatter (documented default: all) MAYA_SKIP_USERSETUP_PY=1, ARNOLD_FORCE_ABORT_ON_LICENSE_FAIL=0, PYTHONUNBUFFERED=1 A variable already set in the caller's environment wins; env= and maya_log= win over both. [verify] that "warning" also passes errors, and that Python print() is unaffected by the stdout level (devkit 2022 lists the values, not their order). record["maya"]["batch_env"] shows what the child actually had. Job protocol: The job file runs with runpy under the name "__mx_job__" (its `if __name__ == "__main__"` block does not fire). If it defines main(argv), main(job_args) is called and its return value is the result; otherwise the job's global `result` is. The global MX_ARGS holds the job args. The LAST stdout line is always: MX_RESULT {"ok": ..., "result": ..., ...} Library: import mx_run mx_run.find_mayapy() # path or None mx_run.run_subprocess("job.py", ["--x", "1"], scene=..., plugins=["fbx"], timeout=600, maya_log="all") # parent side: one mayapy child per call (crash isolation), returns the parsed dict mx_run.run("job.py", args) # inside mayapy: returns the dict, # keeps Maya initialized (call mx_run.shutdown() at the very end of your process) Exit codes: 0 ok, 1 job raised, 2 usage error, 3 Maya failed to initialize, 4 timeout, 5 mayapy not found. """ __version__ = "0.1" # Maya Expert Skills v0.1 (2026-09-24) import glob import json import os import platform import re import runpy import subprocess import sys import threading import time import traceback RESULT_TAG = "MX_RESULT " PLUGIN_ALIASES = { "mtoa": ["mtoa"], "arnold": ["mtoa"], "fbx": ["fbxmaya"], "abc": ["AbcExport", "AbcImport"], "alembic": ["AbcExport", "AbcImport"], "usd": ["mayaUsdPlugin"], "obj": ["objExport"], "gpucache": ["gpuCache"], "bifrost": ["bifrostGraph"], "mash": ["MASH"], "xgen": ["xgenToolkit"], "game": ["gameFbxExporter"], } # Environment for reproducible batch runs. MAYA_DISABLE_ADP: analytics off in batch # (Maya 2027 Help, Start Maya from the command line). MAYA_SKIP_USERSETUP_PY: skip startup # scripts (Theodore 2014, re-verify on 2027). ARNOLD_FORCE_ABORT_ON_LICENSE_FAIL=0: since # Arnold 7.3 batch renders ABORT on a licence failure by default; 0 renders with a # watermark instead (What's New in Maya 2025, Arnold for Maya 5.4.0). # MAYA_BATCH_*_LOGGING_LEVEL: what scripts, commands and API calls print in batch, values # all, info, result, warning, error, none; defaults stdout none, stderr all (devkit What's # New 2022, Output stream variables). stderr lowered to warning [added]. BATCH_ENV = { "MAYA_DISABLE_ADP": "1", "MAYA_SKIP_USERSETUP_PY": "1", "ARNOLD_FORCE_ABORT_ON_LICENSE_FAIL": "0", "PYTHONUNBUFFERED": "1", "MAYA_BATCH_STDOUT_LOGGING_LEVEL": "none", "MAYA_BATCH_STDERR_LOGGING_LEVEL": "warning", } MAYA_LOG_LEVELS = ("all", "info", "result", "warning", "error", "none") STDERR_LEVEL_VAR = "MAYA_BATCH_STDERR_LOGGING_LEVEL" def child_environment(base=None, usersetup=False, maya_log=None, env=None): """The environment a mayapy child gets: `base` (default os.environ), BATCH_ENV for every key the base does not set, then maya_log (stderr level), then `env`. Pure; tested offline.""" if maya_log is not None and maya_log not in MAYA_LOG_LEVELS: raise ValueError("maya_log must be one of %s" % (MAYA_LOG_LEVELS,)) out = dict(os.environ if base is None else base) for k, v in BATCH_ENV.items(): if k == "MAYA_SKIP_USERSETUP_PY" and usersetup: continue out.setdefault(k, v) if maya_log is not None: out[STDERR_LEVEL_VAR] = maya_log out.update(env or {}) return out _STATE = {"initialized_here": False} # --------------------------------------------------------------------------- discovery def _version_key(path): m = re.search(r"[Mm]aya(\d{4})(?:\.(\d+))?", path) return (int(m.group(1)), int(m.group(2) or 0)) if m else (0, 0) def find_mayapy(version=None): """Return the mayapy path or None. Order: $MX_MAYAPY, $MAYA_LOCATION/bin/mayapy (on macOS MAYA_LOCATION is .../Maya.app/Contents), then the newest /Applications/Autodesk/maya20*/ install (Linux /usr/autodesk/maya20*/, Windows C:/Program Files/Autodesk/Maya20*/). `version` ("2027") restricts the glob search to that release.""" cands = [] if os.environ.get("MX_MAYAPY"): cands.append(os.environ["MX_MAYAPY"]) loc = os.environ.get("MAYA_LOCATION") if loc: cands += [os.path.join(loc, "bin", "mayapy"), os.path.join(loc, "Contents", "bin", "mayapy"), os.path.join(loc, "bin", "mayapy.exe")] found = [] for pat in ("/Applications/Autodesk/maya20*/Maya.app/Contents/bin/mayapy", "/usr/autodesk/maya20*/bin/mayapy", "C:/Program Files/Autodesk/Maya20*/bin/mayapy.exe"): found += glob.glob(pat) if version: found = [p for p in found if str(version) in p] found.sort(key=_version_key, reverse=True) for p in cands + found: if p and os.path.isfile(p) and os.access(p, os.X_OK): return p return None def _in_mayapy(): try: import maya.standalone # noqa: F401 (only importable in Maya's interpreter) return True except ImportError: return False # --------------------------------------------------------------------------- parent side def parse_result(text): """Return the dict of the last MX_RESULT line in `text`, or None.""" for line in reversed(text.splitlines()): if line.startswith(RESULT_TAG): try: return json.loads(line[len(RESULT_TAG):]) except ValueError: return None return None def run_subprocess(job, args=(), scene=None, plugins=(), timeout=None, save_as=None, require_plugins=False, script_nodes=False, usersetup=False, mayapy=None, env=None, echo=False, log_path=None, maya_log=None): """Run `job` in a fresh mayapy child and return the parsed result dict. One child per call gives crash isolation, fresh memory and a hard timeout: a scene that hangs or segfaults costs one job, not the batch. The dict always has keys ok, exit_code, seconds, log_tail; plus whatever the child reported (result, error...). The child environment comes from child_environment() (analytics off, logging levels); maya_log sets Maya's stderr level ("all" to debug a silent failure).""" mayapy = mayapy or find_mayapy() if not mayapy: return {"ok": False, "exit_code": 5, "error": "mayapy not found (set MX_MAYAPY or MAYA_LOCATION)"} cmd = [mayapy, os.path.abspath(__file__)] if scene: cmd += ["--scene", os.path.abspath(scene)] if plugins: cmd += ["--plugins", ",".join(plugins)] if require_plugins: cmd.append("--require-plugins") if script_nodes: cmd.append("--script-nodes") if save_as: cmd += ["--save-as", os.path.abspath(save_as)] if usersetup: cmd.append("--usersetup") cmd.append(os.path.abspath(job) if os.path.isfile(job) else job) if args: cmd += ["--"] + [str(a) for a in args] child_env = child_environment(usersetup=usersetup, maya_log=maya_log, env=env) t0 = time.time() lines = [] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=child_env, text=True, bufsize=1, errors="replace") def _pump(): for line in proc.stdout: lines.append(line) if echo: sys.stdout.write(line) sys.stdout.flush() reader = threading.Thread(target=_pump, daemon=True) reader.start() timed_out = False try: proc.wait(timeout=timeout) except subprocess.TimeoutExpired: timed_out = True proc.kill() proc.wait() reader.join(timeout=5) text = "".join(lines) if log_path: os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True) with open(log_path, "w") as f: f.write(text) res = parse_result(text) or {} res.setdefault("ok", False) res["exit_code"] = 4 if timed_out else proc.returncode if timed_out: res["ok"] = False res["error"] = "timeout after %ss" % timeout elif proc.returncode != 0 and res.get("ok"): # the job reported success but the process died afterwards (teardown crash) res["teardown_warning"] = "child exited with %s after reporting ok" % proc.returncode if not res.get("ok") and "error" not in res: res["error"] = "child exited with %s and no MX_RESULT line" % proc.returncode res["seconds_wall"] = round(time.time() - t0, 2) res["log_tail"] = text.splitlines()[-40:] res["cmd"] = cmd return res # --------------------------------------------------------------------------- child side def _maya_ready(): try: import maya.cmds as cmds return bool(cmds.about(version=True)) except Exception: return False def ensure_maya(): """Initialize maya.standalone unless Maya is already up (GUI, or initialized before). Returns True when this call initialized it. initialize() raises inside a GUI session, hence the check (Maya 2027 Help, Initializing and uninitializing in Python).""" if _maya_ready(): return False import maya.standalone try: maya.standalone.initialize(name="python") except TypeError: # [verify] the name kwarg on 2027 maya.standalone.initialize() _STATE["initialized_here"] = True return True def shutdown(): """Uninitialize maya.standalone if this module initialized it.""" if _STATE.get("initialized_here"): _STATE["initialized_here"] = False try: import maya.standalone maya.standalone.uninitialize() except Exception: pass def session_info(): import maya.cmds as cmds info = {"python": sys.version.split()[0], "machine": platform.machine(), "mayapy": sys.executable} for key, flag in (("maya_version", "version"), ("api_version", "apiVersion"), ("cut", "cutIdentifier"), ("batch", "batch"), ("os", "operatingSystem")): try: info[key] = cmds.about(**{flag: True}) except Exception as exc: info[key] = "error: %s" % exc info["batch_env"] = {k: os.environ.get(k) for k in BATCH_ENV} return info def load_plugins(names, required=False): """Load plug-ins by alias or exact name. Returns {name: {"loaded", "version"|"error"}}.""" import maya.cmds as cmds report = {} for alias in names or (): alias = alias.strip() if not alias: continue for name in PLUGIN_ALIASES.get(alias.lower(), [alias]): if name == "mtoa": os.environ.setdefault("ARNOLD_FORCE_ABORT_ON_LICENSE_FAIL", "0") entry = {} try: if not cmds.pluginInfo(name, q=True, loaded=True): cmds.loadPlugin(name, quiet=True) entry["loaded"] = bool(cmds.pluginInfo(name, q=True, loaded=True)) entry["version"] = cmds.pluginInfo(name, q=True, version=True) except Exception as exc: entry = {"loaded": False, "error": "%s: %s" % (type(exc).__name__, exc)} report[name] = entry if required and not entry.get("loaded"): raise RuntimeError("required plug-in %s did not load: %s" % (name, entry.get("error"))) return report def open_scene(path, script_nodes=False): """Open a scene without prompts and, by default, without running its script nodes.""" import maya.cmds as cmds if not os.path.isfile(path): raise IOError("scene not found: %s" % path) try: cmds.file(path, open=True, force=True, prompt=False, ignoreVersion=True, executeScriptNodes=script_nodes) except TypeError: # [verify] flag names on 2027; fall back to the minimal call cmds.file(path, open=True, force=True) return cmds.file(q=True, sceneName=True) def save_scene(path, source=None, overwrite=False): """Save the current scene as `path` (.ma or .mb). Never overwrites the opened source.""" import maya.cmds as cmds path = os.path.abspath(path) if source and os.path.abspath(source) == path and not overwrite: raise RuntimeError("refusing to overwrite the source scene %s; save a new version" % path) os.makedirs(os.path.dirname(path), exist_ok=True) kind = "mayaBinary" if path.lower().endswith(".mb") else "mayaAscii" cmds.file(rename=path) cmds.file(save=True, type=kind, force=True) return path def _json_default(obj): try: return list(obj) # OpenMaya arrays, MMatrix, MVector, tuples, sets except TypeError: return repr(obj) def emit(record, json_path=None): text = json.dumps(record, default=_json_default) sys.stdout.flush() sys.stdout.write("\n" + RESULT_TAG + text + "\n") sys.stdout.flush() if json_path: os.makedirs(os.path.dirname(os.path.abspath(json_path)), exist_ok=True) with open(json_path, "w") as f: json.dump(record, f, indent=1, default=_json_default) def _exec_job(job, args): args = list(args or []) if os.path.isfile(job): job = os.path.abspath(job) job_dir = os.path.dirname(job) old_argv, old_path = sys.argv[:], sys.path[:] sys.argv = [job] + args if job_dir not in sys.path: sys.path.insert(0, job_dir) here = os.path.dirname(os.path.abspath(__file__)) if here not in sys.path: sys.path.insert(0, here) try: ns = runpy.run_path(job, init_globals={"MX_ARGS": args}, run_name="__mx_job__") if callable(ns.get("main")): return ns["main"](args) return ns.get("result") finally: sys.argv, sys.path[:] = old_argv, old_path if ":" in job: # "package.module:function" import importlib mod_name, fn_name = job.split(":", 1) return getattr(importlib.import_module(mod_name), fn_name)(args) raise IOError("job not found: %s" % job) def run(job, args=None, scene=None, plugins=(), require_plugins=False, script_nodes=False, save_as=None, json_path=None, new_scene=True, print_result=True): """Inside mayapy: initialize Maya if needed, load plug-ins, open the scene, run the job, print the MX_RESULT line and return the record. Maya stays initialized (see shutdown).""" t0 = time.time() rec = {"ok": False, "job": job, "args": list(args or [])} try: ensure_maya() except Exception as exc: rec.update(error="maya.standalone.initialize failed: %s: %s" % (type(exc).__name__, exc), traceback=traceback.format_exc(), init_failed=True) if print_result: emit(rec, json_path) return rec try: import maya.cmds as cmds rec["maya"] = session_info() rec["plugins"] = load_plugins(plugins, required=require_plugins) if scene: rec["scene"] = open_scene(scene, script_nodes=script_nodes) elif new_scene: cmds.file(new=True, force=True) rec["result"] = _exec_job(job, args) rec["ok"] = True if save_as: rec["saved"] = save_scene(save_as, source=scene) except SystemExit as exc: rec["ok"] = exc.code in (0, None) if not rec["ok"]: rec["error"] = "job called sys.exit(%r)" % (exc.code,) except BaseException as exc: rec["ok"] = False # also when the job passed but the save failed rec["error"] = "%s: %s" % (type(exc).__name__, exc) rec["traceback"] = traceback.format_exc() rec["seconds"] = round(time.time() - t0, 3) if print_result: emit(rec, json_path) return rec # --------------------------------------------------------------------------- CLI def _parse_cli(argv): opts = {"plugins": [], "scene": None, "require_plugins": False, "script_nodes": False, "save_as": None, "usersetup": False, "timeout": None, "json": None, "soft_exit": False, "find": False, "job": None, "args": [], "maya_log": None} i = 0 while i < len(argv): a = argv[i] if a == "--": opts["args"] = argv[i + 1:] break if opts["job"] is not None: opts["args"].append(a) # args after the job without "--" elif a == "--find": opts["find"] = True elif a in ("--scene", "--plugins", "--save-as", "--timeout", "--json", "--maya-log"): if i + 1 >= len(argv): raise ValueError("%s needs a value" % a) v = argv[i + 1] i += 1 if a == "--plugins": opts["plugins"] += [p for p in v.split(",") if p] elif a == "--timeout": opts["timeout"] = float(v) elif a == "--maya-log": if v not in MAYA_LOG_LEVELS: raise ValueError("--maya-log must be one of %s" % (MAYA_LOG_LEVELS,)) opts["maya_log"] = v else: opts[a[2:].replace("-", "_")] = v elif a in ("--require-plugins", "--script-nodes", "--usersetup", "--soft-exit"): opts[a[2:].replace("-", "_")] = True elif a.startswith("--"): raise ValueError("unknown option %s" % a) else: opts["job"] = a i += 1 return opts def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) try: o = _parse_cli(argv) except ValueError as exc: sys.stderr.write("mx_run: %s\n%s" % (exc, __doc__)) return 2 if o["find"]: p = find_mayapy() print(p or "") return 0 if p else 5 if not o["job"]: sys.stderr.write(__doc__) return 2 if not _in_mayapy(): res = run_subprocess(o["job"], o["args"], scene=o["scene"], plugins=o["plugins"], timeout=o["timeout"], save_as=o["save_as"], require_plugins=o["require_plugins"], script_nodes=o["script_nodes"], usersetup=o["usersetup"], echo=True, maya_log=o["maya_log"]) if res.get("exit_code") in (4, 5): emit(res, o["json"]) # the child could not report: report for it elif o["json"]: with open(o["json"], "w") as f: json.dump(res, f, indent=1, default=_json_default) return res.get("exit_code", 1) # started as `mayapy mx_run.py ...`: Maya is not initialized yet, so the variables still # reach it; setting them this late inside an already running Maya would not [verify] os.environ.update(child_environment(usersetup=o["usersetup"], maya_log=o["maya_log"])) rec = run(o["job"], o["args"], scene=o["scene"], plugins=o["plugins"], require_plugins=o["require_plugins"], script_nodes=o["script_nodes"], save_as=o["save_as"], json_path=o["json"]) code = 0 if rec.get("ok") else (3 if rec.get("init_failed") else 1) shutdown() if not o["soft_exit"]: # [added] hard exit keeps the exit code even if Maya's own teardown misbehaves sys.stdout.flush() sys.stderr.flush() os._exit(code) return code if __name__ == "__main__": sys.exit(main()) -
mx_validate.py 41.1 KB
""" mx_validate: the pre-handoff scene validator a pipeline TD runs (Maya 2027). STATUS: not yet run in Maya (written 2026-09-24). import sys; sys.path.insert(0, "<skill>/scripts"); import mx_validate rep = mx_validate.validate(profile="model") # read-only rep["ok"], rep["summary"], [c for c in rep["checks"] if c["status"] == "fail"] rep = mx_validate.validate(profile="model", roots=["crate_GRP"], fix=mx_validate.SAFE_FIXES) # fix, then re-check rep = mx_validate.validate(profile="shot", rules={"fps": 24}) Headless, through mx_run (never overwrite the source: fixes go to --save-as): mayapy mx_run.py --scene in.ma --save-as out/in_fixed.ma mx_validate.py -- \ --profile model --fix history,smooth_preview --json out/validate.json Profiles (what the receiving department needs): model modeling to retopology, rigging or look dev: no history, frozen transforms, clean manifold geometry, UVs, clean Outliner (the FlippedNormals clean-scene checklist, ToWRH4IXF7A) rig rig to animation: deformer history allowed, scene hygiene, references state shot animation or lighting shots: references loaded and resolvable, frame rate, units, paths; geometry rules skipped (assets are checked at their own handoff) Checks (id): scene, units, frame_rate, naming_default, naming_duplicates, naming_pattern, namespaces, history, transforms, geometry (non-manifold, lamina, n-gons, empty meshes), uvs, locked_normals, smooth_preview, render_stats, display_layers, selection_sets, unused_nodes, unknown (unknown nodes and plug-ins), plugins, references, script_nodes, file_paths, cameras, deep_audit (mx_audit per mesh, opt-in with deep=True) Each check: {id, status pass|warn|fail|skip|info, message, count, items, fix} history ignores bind poses (dagPose) and deformer weight drivers (mx_audit.split_history). locked_normals: imported, non-deformed geometry arrives with locked normals that do not follow skinning or blend shapes (Maya 2027 Help, FBX Troubleshooting); deliberate custom normals on a static mesh are the exception, so it warns and never fixes on its own. Fixes (only when asked; each in its own undo chunk, then every check runs again): SAFE_FIXES: history (meshes without deformers), smooth_preview, unknown_dead (unknown nodes and plug-in requirements of dead plug-ins, default mental ray "Mayatomr") optional: history_deformed (bakePartialHistory prePostDeformers: Delete Non-Deformer History), freeze (only unreferenced, unlocked, unconnected transforms with no skinned mesh, joint or instance below), namespaces (merge non-reference namespaces into root when no name clash), render_stats, unused_shading (Hypershade Delete Unused Nodes), empty_groups, unknown_all (deletes data of plug-ins missing on this machine), unlock_normals (Mesh Display > Unlock Normals on unreferenced meshes; shading then follows the edges' hard/soft flags, so compare a normals sheet before and after) Never fixed automatically: non-manifold, lamina and n-gons (FlippedNormals: fix n-gons by hand, Cleanup's automatic result is unpredictable), units, frame rate, references, naming, script nodes. Roots survive the fixes: validate() and apply_fixes() hold each root by UUID and find it again after every fix, so a namespace merge that renames "chr:body_GRP" to "body_GRP" keeps the scope (open issue 2026-09-24; it raised "root does not exist" before). The report's roots_after lists the names after the fixes; roots_lost lists roots that no longer exist (an empty_groups fix can delete one). """ __version__ = "0.1" # Maya Expert Skills v0.1 (2026-09-24) import glob import json import os import re import sys import time DEFAULT_RULES = { "linear": "cm", # keep Maya in centimeters (Maya 2027 Help, working in different scales) "angle": "deg", "up_axis": "y", "fps": None, # e.g. 24 or 30; None = report only "uv_sets": None, # e.g. ["map1"] or ["map1", "lightmap"] "name_patterns": {}, # e.g. {"mesh": r".+_geo$", "group": r".+_GRP$", "joint": r".+_jnt$"} "freeze_exclude": [], # regexes of transforms allowed to keep values (eye groups: FlippedNormals) "dead_plugins": ["Mayatomr"], # [added] mental ray: not shipped since Maya 2018 "allowed_plugins": None, # list of plug-ins the receiving side has; None = report only "project_root": None, # defaults to the current workspace root "max_items": 50, "deep_profile": None, # mx_audit profile for deep=True: game, film or subd (default: film, shot: game) } SEV = { # profile -> check -> severity when the check finds something (None = skip) "model": {"units": "fail", "frame_rate": "warn", "naming_default": "warn", "naming_duplicates": "fail", "naming_pattern": "warn", "namespaces": "warn", "history": "fail", "transforms": "fail", "geometry": "fail", "uvs": "fail", "locked_normals": "warn", "smooth_preview": "warn", "render_stats": "warn", "display_layers": "warn", "selection_sets": "warn", "unused_nodes": "warn", "unknown": "fail", "plugins": "warn", "references": "warn", "script_nodes": "warn", "file_paths": "fail", "cameras": "warn"}, "rig": {"units": "fail", "frame_rate": "warn", "naming_default": "warn", "naming_duplicates": "fail", "naming_pattern": "warn", "namespaces": "warn", "history": "fail", "transforms": "fail", "geometry": "warn", "uvs": "warn", "locked_normals": "warn", "smooth_preview": "warn", "render_stats": None, "display_layers": None, "selection_sets": None, "unused_nodes": "warn", "unknown": "fail", "plugins": "warn", "references": "warn", "script_nodes": "warn", "file_paths": "fail", "cameras": "warn"}, "shot": {"units": "fail", "frame_rate": "fail", "naming_default": None, "naming_duplicates": "warn", "naming_pattern": None, "namespaces": None, "history": None, "transforms": None, "geometry": None, "uvs": None, "locked_normals": None, "smooth_preview": None, "render_stats": None, "display_layers": None, "selection_sets": None, "unused_nodes": "warn", "unknown": "fail", "plugins": "warn", "references": "fail", "script_nodes": "warn", "file_paths": "fail", "cameras": None}, } TIME_UNITS = {"game": 15.0, "film": 24.0, "pal": 25.0, "ntsc": 30.0, "show": 48.0, "palf": 50.0, "ntscf": 60.0, "sec": 1.0, "millisec": 1000.0} DEFAULT_NAME_RE = re.compile( r"^(pCube|pSphere|pCylinder|pCone|pPlane|pTorus|pPrism|pPyramid|pPipe|pHelix|pSolid|pDisc|pPlatonic|" r"pGear|pSuperShape|polySurface|group|transform|null|nurbsSphere|nurbsCube|nurbsCylinder|nurbsCone|" r"nurbsPlane|nurbsTorus|nurbsCircle|curve|locator|joint)\d*$") RENDER_STATS = {"castsShadows": 1, "receiveShadows": 1, "motionBlur": 1, "primaryVisibility": 1, "smoothShading": 1, "visibleInReflections": 1, "visibleInRefractions": 1, "doubleSided": 1, "opposite": 0} PATH_ATTRS = [("file", "fileTextureName"), ("aiImage", "filename"), ("aiStandIn", "dso"), ("aiVolume", "filename"), ("gpuCache", "cacheFileName"), ("AlembicNode", "abc_File"), ("imagePlane", "imageName"), ("audio", "filename"), ("mayaUsdProxyShape", "filePath"), ("aiPhotometricLight", "aiFilename"), ("psdFileTex", "fileTextureName"), ("movie", "fileTextureName"), ("cacheFile", "cachePath")] DEFAULT_SHADERS = {"lambert1", "particleCloud1", "shaderGlow1", "standardSurface1", "openPBRSurface1"} DEFAULT_SETS = {"defaultLightSet", "defaultObjectSet", "initialShadingGroup", "initialParticleSE", "initialMaterialInfo"} STANDARD_SCRIPT_NODES = {"uiConfigurationScriptNode", "sceneConfigurationScriptNode"} # [added] names used by the self-replicating Maya scene "virus" that writes userSetup files KNOWN_BAD_SCRIPT_NODES = {"vaccine_gene", "breed_gene"} SUSPICIOUS_SCRIPT = ("userSetup", "base64", "exec(", "urllib", "socket", "os.system", "subprocess") SAFE_FIXES = ("history", "smooth_preview", "unknown_dead") def _cmds(): import maya.cmds as cmds return cmds def _audit(): here = os.path.dirname(os.path.abspath(__file__)) if here not in sys.path: sys.path.insert(0, here) import mx_audit return mx_audit def _leaf(n): return n.rsplit("|", 1)[-1] # --------------------------------------------------------------------------- roots by UUID def root_handles(roots): """[(name, uuid)] for the roots, so they can be found again after a fix renames them (namespace merge). Raises for a root that does not exist. None for roots=None.""" if not roots: return None cmds = _cmds() out = [] for r in roots: if not cmds.objExists(r): raise ValueError("root %s does not exist" % r) u = cmds.ls(r, uuid=True) or [] out.append((r, u[0] if u else None)) return out def live_roots(handles): """(current long names, [original names no longer found]) for root_handles().""" if handles is None: return None, [] cmds = _cmds() now, lost = [], [] for name, uid in handles: hits = (cmds.ls(uid, long=True) or []) if uid else [] # ls resolves UUIDs [verify] if not hits and cmds.objExists(name): hits = cmds.ls(name, long=True) or [] if hits: if hits[0] not in now: now.append(hits[0]) else: lost.append(name) return now, lost def _check(cid, sev, items, message, fix=None, info=None, max_items=50): items = list(items or []) if sev is None: status = "skip" elif items: status = sev else: status = "pass" c = {"id": cid, "status": status, "count": len(items), "items": items[:max_items], "message": message} if fix and items and sev: c["fix"] = fix if info is not None: c["info"] = info return c # --------------------------------------------------------------------------- scope def _scope(roots): cmds = _cmds() if roots: xf = [] for r in roots: if not cmds.objExists(r): raise ValueError("root %s does not exist" % r) xf += cmds.ls(r, long=True, type="transform") or [] xf += cmds.listRelatives(r, allDescendents=True, type="transform", fullPath=True) or [] else: xf = cmds.ls(type="transform", long=True) or [] startup = set() for c in cmds.ls(type="camera", long=True) or []: if cmds.camera(c, q=True, startupCamera=True): startup.update(cmds.listRelatives(c, parent=True, fullPath=True) or []) xf = [x for x in dict.fromkeys(xf) if x not in startup] meshes = [] for x in xf: for s in cmds.listRelatives(x, shapes=True, type="mesh", fullPath=True, noIntermediate=True) or []: meshes.append(s) return xf, meshes def _is_ref(cmds, n): try: return cmds.referenceQuery(n, isNodeReferenced=True) except Exception: return False def _mesh_history(cmds, shape): """(construction history nodes, deformers). A skinned mesh's bind pose and the drivers of deformer weights are not history (mx_audit.split_history).""" s = _audit().split_history(shape) return [h for h, _t in s["history"]], s["deformers"] def _xform_values(cmds, x): t = cmds.getAttr(x + ".translate")[0] r = cmds.getAttr(x + ".rotate")[0] s = cmds.getAttr(x + ".scale")[0] sh = cmds.getAttr(x + ".shear")[0] opm = cmds.getAttr(x + ".offsetParentMatrix") if cmds.attributeQuery( "offsetParentMatrix", node=x, exists=True) else [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] ident = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] ok = (all(abs(v) < 1e-5 for v in tuple(t) + tuple(r) + tuple(sh)) and all(abs(v - 1) < 1e-5 for v in s) and all(abs(a - b) < 1e-6 for a, b in zip(opm, ident))) return ok, {"t": list(t), "r": list(r), "s": list(s), "shear": list(sh), "offsetParentMatrix_identity": all(abs(a - b) < 1e-6 for a, b in zip(opm, ident))} def fps_of(unit): """Frames per second of a Maya time unit ("film" 24, "ntsc" 30, "game" 15, "60fps"...), None when unknown. Pure.""" if unit in TIME_UNITS: return TIME_UNITS[unit] m = re.match(r"^([\d.]+)fps$", unit or "") return float(m.group(1)) if m else None _fps = fps_of def _expand(path, root): cmds = _cmds() if not path: return path p = os.path.expandvars(os.path.expanduser(path)) if not os.path.isabs(p): try: p = cmds.workspace(expandName=p) except Exception: p = os.path.join(root or "", p) return p def locked_normal_count(shape): """Number of locked (frozen) normal entries on a mesh, 0 on an empty mesh or when the query fails. Same query as mx_audit's locked_normal_verts [verify on 2027 whether polyNormalPerVertex(q=True, freezeNormal=True) returns one flag per vertex or per vertex-face; either way 0 means none locked].""" cmds = _cmds() if _audit().is_empty_mesh(shape): return 0 try: flags = cmds.polyNormalPerVertex(shape + ".vtx[*]", q=True, freezeNormal=True) or [] except Exception: return 0 return sum(1 for x in flags if x) def _exists_pattern(path): if os.path.exists(path): return True pat = re.sub(r"<[^>]+>", "*", path) pat = re.sub(r"#+", "*", pat) pat = re.sub(r"_MAPID_|\$F\d*", "*", pat) return pat != path and bool(glob.glob(pat)) # --------------------------------------------------------------------------- checks def run_checks(profile="model", roots=None, rules=None, deep=False): cmds = _cmds() R = dict(DEFAULT_RULES) R.update(rules or {}) S = SEV[profile] mx = R["max_items"] xf, meshes = _scope(roots) checks = [] add = checks.append add({"id": "scene", "status": "info", "count": 0, "items": [], "message": "scene info", "info": {"scene": cmds.file(q=True, sceneName=True), "modified": cmds.file(q=True, modified=True), "maya": cmds.about(version=True), "transforms_in_scope": len(xf), "meshes_in_scope": len(meshes), "nodes": len(cmds.ls() or [])}}) # units and frame rate lin, ang, up = (cmds.currentUnit(q=True, linear=True), cmds.currentUnit(q=True, angle=True), cmds.upAxis(q=True, axis=True)) bad = [] if R["linear"] and lin != R["linear"]: bad.append("linear unit %s (expected %s)" % (lin, R["linear"])) if R["angle"] and ang != R["angle"]: bad.append("angle unit %s (expected %s)" % (ang, R["angle"])) if R["up_axis"] and up != R["up_axis"]: bad.append("up axis %s (expected %s)" % (up, R["up_axis"])) add(_check("units", S["units"], bad, "working units and up axis (never change them silently)", info={"linear": lin, "angle": ang, "up": up})) tu = cmds.currentUnit(q=True, time=True) fps = _fps(tu) bad = [] if R["fps"] and (fps is None or abs(fps - float(R["fps"])) > 1e-3): bad.append("time unit %s = %s fps (expected %s)" % (tu, fps, R["fps"])) add(_check("frame_rate", S["frame_rate"] if R["fps"] else "info", bad, "scene frame rate", info={"time_unit": tu, "fps": fps, "range": [cmds.playbackOptions(q=True, minTime=True), cmds.playbackOptions(q=True, maxTime=True)]})) # naming add(_check("naming_default", S["naming_default"], [x for x in xf if DEFAULT_NAME_RE.match(_leaf(x).rsplit(":", 1)[-1]) and not _is_ref(cmds, x)], "default names (pCube1, group3...) in published nodes", max_items=mx)) short = {} for x in cmds.ls(type="transform", long=True) or []: short.setdefault(_leaf(x), []).append(x) dups = [x for x in xf if len(short.get(_leaf(x), [])) > 1] add(_check("naming_duplicates", S["naming_duplicates"], dups, "two nodes share a short name (FlippedNormals: never in production; breaks rigs and name-based " "texture assignment)", max_items=mx)) pats = R["name_patterns"] or {} badn = [] if pats: for x in xf: kind = ("mesh" if cmds.listRelatives(x, shapes=True, type="mesh") else "joint" if cmds.nodeType(x) == "joint" else "group" if cmds.nodeType(x) == "transform" and not cmds.listRelatives(x, shapes=True) else None) if kind in pats and not re.match(pats[kind], _leaf(x).rsplit(":", 1)[-1]): badn.append("%s (%s: %s)" % (x, kind, pats[kind])) add(_check("naming_pattern", S["naming_pattern"] if pats else None, badn, "studio naming patterns", max_items=mx)) # namespaces ref_ns = set() for ref in cmds.ls(type="reference") or []: if ref in ("sharedReferenceNode", "_UNKNOWN_REF_NODE_"): continue try: ref_ns.add(cmds.referenceQuery(ref, namespace=True).lstrip(":")) except Exception: pass all_ns = [n.lstrip(":") for n in (cmds.namespaceInfo(":", listOnlyNamespaces=True, recurse=True, absoluteName=True) or [])] loose_ns = [n for n in all_ns if n not in ("UI", "shared") and n.split(":")[0] not in ref_ns] add(_check("namespaces", S["namespaces"], loose_ns, "namespaces not owned by a reference (import leftovers)", fix="namespaces", max_items=mx)) # history and transforms hist_items = [] for m in meshes: if _is_ref(cmds, m): continue other, deformers = _mesh_history(cmds, m) if other: hist_items.append({"mesh": m, "history": other[:8], "deformers": deformers[:8]}) add(_check("history", S["history"], hist_items, "non-deformer construction history on meshes (FlippedNormals: delete history before handoff)", fix="history (no deformers) / history_deformed", max_items=mx)) excl = [re.compile(p) for p in R["freeze_exclude"]] tr_items = [] if S["transforms"]: for x in xf: if cmds.nodeType(x) != "transform" or _is_ref(cmds, x) or any(e.search(_leaf(x)) for e in excl): continue has_geo = cmds.listRelatives(x, shapes=True, type="mesh") or cmds.listRelatives( x, allDescendents=True, type="mesh") if not has_geo: continue ok, vals = _xform_values(cmds, x) if not ok: tr_items.append({"node": x, "values": vals}) add(_check("transforms", S["transforms"], tr_items, "transforms of meshes and their groups not frozen (or offsetParentMatrix not identity)", fix="freeze", max_items=mx)) # geometry geo_items = [] uv_items = [] if S["geometry"] or S["uvs"]: A = _audit() for m in meshes: fn = A.mfn_mesh(m) # None for an empty mesh (MFnMesh raises on one since 2022.1) if fn is None: geo_items.append({"mesh": m, "problem": "empty mesh", "counted": A.mesh_counts(m)}) continue probs = {} for flag, name in (("nonManifoldEdges", "non_manifold_edges"), ("nonManifoldVertices", "non_manifold_verts"), ("laminaFaces", "lamina_faces")): res = cmds.polyInfo(m, **{flag: True}) or [] try: res = cmds.ls(res, flatten=True) or res except Exception: pass if res: probs[name] = len(res) counts, _ = fn.getVertices() ng = sum(1 for c in counts if c > 4) if ng: probs["ngons"] = ng if probs: geo_items.append(dict(mesh=m, **probs)) sets = cmds.polyUVSet(m, q=True, allUVSets=True) or [] up_ = {} if not sets: up_["problem"] = "no UV set" else: cur = fn.currentUVSetName() ucounts, _ = fn.getAssignedUVs(cur) missing = sum(1 for c in ucounts if c == 0) if missing: up_["faces_without_uvs"] = missing if R["uv_sets"] and list(sets) != list(R["uv_sets"]): up_["uv_sets"] = sets if up_: uv_items.append(dict(mesh=m, **up_)) add(_check("geometry", S["geometry"], geo_items, "non-manifold, lamina, n-gons, empty meshes (fix by hand: Mesh > Cleanup is fine for lamina and " "non-manifold, never for n-gons, per FlippedNormals)", max_items=mx)) add(_check("uvs", S["uvs"], uv_items, "UV sets present, every face mapped, expected set names", max_items=mx)) ln_items = [] if S["locked_normals"]: for m in meshes: if _is_ref(cmds, m): continue n = locked_normal_count(m) if n: ln_items.append({"mesh": m, "locked_entries": n, "deformed": bool(_mesh_history(cmds, m)[1])}) add(_check("locked_normals", S["locked_normals"], ln_items, "locked normals do not follow skinning or blend shapes (typical after FBX or OBJ import): unlock " "before rigging, keep only deliberate custom normals on static meshes (Maya 2027 Help, FBX " "Troubleshooting)", fix="unlock_normals", max_items=mx)) # display and render state sp = [m for m in meshes if cmds.attributeQuery("displaySmoothMesh", node=m, exists=True) and cmds.getAttr(m + ".displaySmoothMesh")] add(_check("smooth_preview", S["smooth_preview"], sp, "Smooth Mesh Preview on (FlippedNormals: save with smoothing off, press 1)", fix="smooth_preview", max_items=mx)) rs = [] if S["render_stats"]: for m in meshes: for a, v in RENDER_STATS.items(): if cmds.attributeQuery(a, node=m, exists=True) and cmds.getAttr(m + "." + a) != v: rs.append("%s.%s=%s" % (m, a, cmds.getAttr(m + "." + a))) add(_check("render_stats", S["render_stats"], rs, "render stats not at defaults (FlippedNormals: a stray unchecked box costs days)", fix="render_stats", max_items=mx)) dl = [l for l in (cmds.ls(type="displayLayer") or []) if l != "defaultLayer" and not _is_ref(cmds, l)] add(_check("display_layers", S["display_layers"], dl, "display layers (FlippedNormals: delete before handoff)", max_items=mx)) ss = [] for s in cmds.ls(type="objectSet") or []: if cmds.nodeType(s) != "objectSet" or s in DEFAULT_SETS or _is_ref(cmds, s): continue if cmds.listConnections(s + ".usedBy", source=True, destination=False) if cmds.attributeQuery( "usedBy", node=s, exists=True) else False: continue # deformer membership set ss.append(s) add(_check("selection_sets", S["selection_sets"], ss, "selection sets (FlippedNormals: delete before handoff)", max_items=mx)) # unused nodes unused = [] for se in cmds.ls(type="shadingEngine") or []: if se in DEFAULT_SETS or _is_ref(cmds, se): continue if not cmds.sets(se, q=True): unused.append("empty shading group %s" % se) for mat in cmds.ls(materials=True) or []: if mat in DEFAULT_SHADERS or _is_ref(cmds, mat): continue if not cmds.listConnections(mat, type="shadingEngine"): unused.append("unassigned material %s" % mat) for x in xf: if cmds.nodeType(x) == "transform" and not cmds.listRelatives(x, children=True) and not _is_ref(cmds, x): unused.append("empty group %s" % x) add(_check("unused_nodes", S["unused_nodes"], unused, "unused shading nodes and empty groups", fix="unused_shading / empty_groups", max_items=mx)) # unknown nodes and plug-ins unk = [] for t in ("unknown", "unknownDag", "unknownTransform"): for n in cmds.ls(type=t) or []: plug = None try: plug = cmds.unknownNode(n, q=True, plugin=True) except Exception: pass unk.append({"node": n, "type": t, "plugin": plug}) try: unk_plugins = cmds.unknownPlugin(q=True, list=True) or [] except Exception: unk_plugins = [] items = unk + [{"plugin_requirement": p} for p in unk_plugins] add(_check("unknown", S["unknown"], items, "unknown nodes and missing plug-in requirements (data from plug-ins this Maya lacks)", fix="unknown_dead / unknown_all", max_items=mx, info={"dead_plugins": R["dead_plugins"]})) try: in_use = cmds.pluginInfo(q=True, pluginsInUse=True) or [] except Exception: in_use = [] names = in_use[0::2] if in_use and len(in_use) % 2 == 0 and not isinstance(in_use[0], (list, tuple)) else in_use bad = [p for p in names if R["allowed_plugins"] is not None and p not in R["allowed_plugins"]] add(_check("plugins", S["plugins"] if R["allowed_plugins"] is not None else "info", bad, "plug-ins the scene requires", info={"in_use": in_use})) # references ref_items = [] for ref in cmds.ls(type="reference") or []: if ref in ("sharedReferenceNode", "_UNKNOWN_REF_NODE_"): continue d = {"ref": ref} try: d["file"] = cmds.referenceQuery(ref, filename=True, withoutCopyNumber=True) d["loaded"] = cmds.referenceQuery(ref, isLoaded=True) d["exists"] = os.path.isfile(_expand(d["file"], R["project_root"])) try: d["failed_edits"] = len(cmds.referenceQuery(ref, editStrings=True, failedEdits=True, successfulEdits=False) or []) except Exception: d["failed_edits"] = None except Exception as exc: d["error"] = str(exc) problem = (not d.get("loaded") or not d.get("exists") or d.get("failed_edits") or d.get("error")) if problem or profile == "model": ref_items.append(d) fp = cmds.ls(type="fosterParent") or [] ref_items += [{"fosterParent": f} for f in fp] add(_check("references", S["references"], ref_items, "references unloaded, missing, with failed edits, or present in an asset file; foster parents", max_items=mx)) # script nodes sn = [] for n in cmds.ls(type="script") or []: body = "" for a in ("before", "after"): try: body += cmds.getAttr(n + "." + a) or "" except Exception: pass sus = [s for s in SUSPICIOUS_SCRIPT if s in body] bad = n.split(":")[-1] in KNOWN_BAD_SCRIPT_NODES if n in STANDARD_SCRIPT_NODES and not sus: continue sn.append({"node": n, "known_malicious_name": bad, "suspicious": sus, "chars": len(body), "scriptType": cmds.getAttr(n + ".scriptType")}) sev = S["script_nodes"] if any(i["known_malicious_name"] for i in sn): sev = "fail" add(_check("script_nodes", sev, sn, "script nodes run code when the scene opens (open untrusted files with executeScriptNodes=False)", max_items=mx)) # file paths root = R["project_root"] or cmds.workspace(q=True, rootDirectory=True) have = set(cmds.allNodeTypes() or []) paths = [] for t, a in PATH_ATTRS: if t not in have: continue for n in cmds.ls(type=t) or []: if not cmds.attributeQuery(a, node=n, exists=True): continue v = cmds.getAttr(n + "." + a) if not v: continue full = _expand(v, root) ok = _exists_pattern(full) outside = os.path.isabs(v) and root and not os.path.abspath(v).startswith(os.path.abspath(root)) if not ok or (outside and profile != "shot"): paths.append({"node": n, "attr": a, "path": v, "resolved": full, "exists": ok, "absolute_outside_project": bool(outside)}) missing = [p for p in paths if not p["exists"]] add(_check("file_paths", S["file_paths"] if missing else ("warn" if paths else S["file_paths"]), paths, "missing files (textures, caches, image planes, USD); absolute paths outside the project", max_items=mx, info={"project_root": root})) # cameras cams = [] for c in cmds.ls(type="camera", long=True) or []: if not cmds.camera(c, q=True, startupCamera=True) and not _is_ref(cmds, c): cams.append(c) add(_check("cameras", S["cameras"], cams, "non-default cameras in an asset file travel into every scene " "that imports it", max_items=mx)) if deep: try: here = os.path.dirname(os.path.abspath(__file__)) if here not in sys.path: sys.path.insert(0, here) import mx_audit prof = R.get("deep_profile") or {"model": "film", "rig": "film", "shot": "game"}[profile] deep_items = [] for m in dict.fromkeys(cmds.listRelatives(s, parent=True, fullPath=True)[0] for s in meshes): r = mx_audit.audit(m) errs = [p for p in mx_audit.verdict(r, prof) if p.startswith("error")] if errs: deep_items.append({"mesh": m, "problems": errs}) add(_check("deep_audit", "fail", deep_items, "mx_audit verdict errors per mesh (%s profile)" % prof, max_items=mx)) except Exception as exc: add({"id": "deep_audit", "status": "warn", "count": 0, "items": [], "message": "deep audit failed: %s" % exc}) return checks def _summary(checks): s = {} for c in checks: s[c["status"]] = s.get(c["status"], 0) + 1 return s # --------------------------------------------------------------------------- fixes def _chunk(cmds, name): class _C(object): def __enter__(self): cmds.undoInfo(openChunk=True, chunkName=name) def __exit__(self, *a): cmds.undoInfo(closeChunk=True) return False return _C() def _safe_to_freeze(cmds, x): if _is_ref(cmds, x) or cmds.nodeType(x) != "transform": return False, "referenced or not a plain transform" for a in ("translate", "rotate", "scale", "shear") + tuple(c + ax for c in "trs" for ax in "xyz"): plug = "%s.%s" % (x, a) if not cmds.attributeQuery(a, node=x, exists=True): continue if cmds.getAttr(plug, lock=True): return False, "locked %s" % a if cmds.listConnections(plug, source=True, destination=False): return False, "%s is driven (animation or constraint)" % a below = [x] + (cmds.listRelatives(x, allDescendents=True, fullPath=True) or []) for n in below: t = cmds.nodeType(n) if t == "joint": return False, "joint below" if t == "mesh": if len(cmds.listRelatives(n, allParents=True) or []) > 1: return False, "instanced shape below" if cmds.ls(cmds.listHistory(n) or [], type="skinCluster"): return False, "skinned mesh below (unbind, freeze, rebind)" return True, "" def apply_fixes(fixes, profile="model", roots=None, rules=None, handles=None): """Apply fix ids in order, each in its own undo chunk. Roots are held by UUID (root_handles) and found again after every fix, so a rename by one fix does not lose the scope of the next. Returns [{fix, done, skipped}].""" cmds = _cmds() import maya.mel as mel R = dict(DEFAULT_RULES) R.update(rules or {}) if handles is None: handles = root_handles(roots) live, lost = live_roots(handles) xf, meshes = _scope(live) log = [] for fx in fixes: done, skipped = [], [] if handles is not None and not live: log.append({"fix": fx, "done": [], "skipped": ["no root left in scope (lost: %s)" % lost]}) continue try: with _chunk(cmds, "mx_validate:%s" % fx): if fx in ("history", "history_deformed"): for m in meshes: if _is_ref(cmds, m): continue other, deformers = _mesh_history(cmds, m) if not other: continue x = cmds.listRelatives(m, parent=True, fullPath=True)[0] if not deformers: cmds.delete(x, constructionHistory=True) done.append(x) elif fx == "history_deformed": cmds.bakePartialHistory(x, prePostDeformers=True) done.append(x) else: skipped.append("%s has deformers (use history_deformed)" % x) elif fx == "freeze": excl = [re.compile(p) for p in R["freeze_exclude"]] # top-down: freezing a group bakes its children too; children already # frozen that way are skipped by the _xform_values test below tops = sorted((x for x in xf if cmds.nodeType(x) == "transform"), key=lambda n: n.count("|")) for x in tops: if any(e.search(_leaf(x)) for e in excl): continue ok_vals, _ = _xform_values(cmds, x) if ok_vals or not cmds.objExists(x): continue ok, why = _safe_to_freeze(cmds, x) if not ok: skipped.append("%s: %s" % (x, why)) continue cmds.makeIdentity(x, apply=True, translate=True, rotate=True, scale=True, normal=0, preserveNormals=True) done.append(x) elif fx == "unlock_normals": for m in meshes: if _is_ref(cmds, m) or not locked_normal_count(m): continue cmds.polyNormalPerVertex(m, unFreezeNormal=True) # [verify] flag on 2027 done.append(m) elif fx == "smooth_preview": for m in meshes: if cmds.attributeQuery("displaySmoothMesh", node=m, exists=True) and \ cmds.getAttr(m + ".displaySmoothMesh") and not cmds.getAttr(m + ".displaySmoothMesh", lock=True): cmds.setAttr(m + ".displaySmoothMesh", 0) done.append(m) elif fx == "render_stats": for m in meshes: for a, v in RENDER_STATS.items(): p = m + "." + a if cmds.attributeQuery(a, node=m, exists=True) and cmds.getAttr(p) != v and \ not cmds.getAttr(p, lock=True) and not cmds.listConnections(p, s=True, d=False): cmds.setAttr(p, v) done.append(p) elif fx == "namespaces": ref_ns = set() for ref in cmds.ls(type="reference") or []: try: ref_ns.add(cmds.referenceQuery(ref, namespace=True).lstrip(":")) except Exception: pass all_ns = [n.lstrip(":") for n in (cmds.namespaceInfo(":", listOnlyNamespaces=True, recurse=True, absoluteName=True) or [])] for ns in sorted(all_ns, key=lambda n: -n.count(":")): # deepest first if ns in ("UI", "shared") or ns.split(":")[0] in ref_ns: continue members = cmds.namespaceInfo(":" + ns, listOnlyDependencyNodes=True, absoluteName=True) or [] clash = [n for n in members if cmds.objExists(":" + n.rsplit(":", 1)[-1])] if clash: skipped.append("%s: name clash for %s" % (ns, clash[:3])) continue cmds.namespace(removeNamespace=":" + ns, mergeNamespaceWithRoot=True) done.append(ns) elif fx in ("unknown_dead", "unknown_all"): dead = set(R["dead_plugins"]) for t in ("unknown", "unknownDag", "unknownTransform"): for n in cmds.ls(type=t) or []: plug = None try: plug = cmds.unknownNode(n, q=True, plugin=True) except Exception: pass if fx == "unknown_all" or plug in dead: if cmds.objExists(n): cmds.lockNode(n, lock=False) cmds.delete(n) done.append(n) else: skipped.append("%s (plug-in %s not in dead_plugins)" % (n, plug)) for p in cmds.unknownPlugin(q=True, list=True) or []: if fx == "unknown_all" or p in dead: try: cmds.unknownPlugin(p, remove=True) done.append("plugin requirement " + p) except Exception as exc: skipped.append("%s: %s" % (p, exc)) elif fx == "unused_shading": before = len(cmds.ls() or []) mel.eval("MLdeleteUnused;") # Hypershade > Delete Unused Nodes [verify] done.append("%d nodes removed" % (before - len(cmds.ls() or []))) elif fx == "empty_groups": for x in sorted(xf, key=lambda n: -n.count("|")): if cmds.objExists(x) and cmds.nodeType(x) == "transform" and not _is_ref(cmds, x) \ and not cmds.listRelatives(x, children=True): cmds.delete(x) done.append(x) else: skipped.append("unknown fix id") except Exception as exc: skipped.append("failed: %s: %s" % (type(exc).__name__, exc)) log.append({"fix": fx, "done": done, "skipped": skipped}) live, lost = live_roots(handles) # a fix may have renamed or removed a root xf, meshes = _scope(live) if (handles is None or live) else ([], []) return log def validate(profile="model", roots=None, rules=None, fix=(), deep=False): """Run every check; with fix=[...] apply those fixes and check again. Returns {ok, profile, summary, checks, fixes, before_summary, roots, roots_after, roots_lost, seconds}. Roots are held by UUID across the fixes.""" t0 = time.time() if profile not in SEV: raise ValueError("profile must be one of %s" % sorted(SEV)) handles = root_handles(roots) before = run_checks(profile, roots, rules, deep) rep = {"profile": profile, "roots": roots, "rules": dict(DEFAULT_RULES, **(rules or {}))} if fix: rep["before_summary"] = _summary(before) rep["fixes"] = apply_fixes(list(fix), profile, roots, rules, handles=handles) live, lost = live_roots(handles) rep["roots_after"] = live if lost: rep["roots_lost"] = lost if handles is not None and not live: checks = [{"id": "roots", "status": "fail", "count": len(lost), "items": lost, "message": "no root exists after the fixes: nothing left to validate"}] else: checks = run_checks(profile, live, rules, deep) else: rep["roots_after"] = live_roots(handles)[0] checks = before rep["checks"] = checks rep["summary"] = _summary(checks) rep["ok"] = not any(c["status"] == "fail" for c in checks) rep["seconds"] = round(time.time() - t0, 3) return rep def main(argv): import argparse ap = argparse.ArgumentParser(prog="mx_validate") ap.add_argument("--profile", default="model", choices=sorted(SEV)) ap.add_argument("--roots", default="") ap.add_argument("--fix", default="", help="comma list, or 'safe'") ap.add_argument("--rules", default="", help="JSON dict overriding DEFAULT_RULES") ap.add_argument("--deep", action="store_true") ap.add_argument("--json", default=None) a = ap.parse_args(argv) fixes = SAFE_FIXES if a.fix == "safe" else tuple(f for f in a.fix.split(",") if f) rep = validate(a.profile, [r for r in a.roots.split(",") if r] or None, json.loads(a.rules) if a.rules else None, fixes, a.deep) if a.json: with open(a.json, "w") as f: json.dump(rep, f, indent=1, default=str) return rep if __name__ == "__main__": import maya.standalone maya.standalone.initialize(name="python") try: print(json.dumps(main(sys.argv[1:]), indent=1, default=str)) finally: maya.standalone.uninitialize()
-
-
SKILL.md 21.6 KB
--- name: scenario-maya-expert description: 'Use when doing any Autodesk Maya task through Python or a bridge, headless or live: maya.cmds or OpenMaya 2.0 scripts, mayapy or maya.standalone batch jobs, a commandPort or MCP connection into a running Maya, "Maya 2027", validating or exporting scenes, or a maya.cmds script that fails after the 2025 to 2027 changes (PySide6, OpenPBR, renamed math nodes, removed flags). Also when a Maya model, rig, shot or render must be judged like a professional before it is called done, or a brief must be routed to the right Maya specialist.' license: MIT --- # Maya expert (router and agent protocol) Expert Maya work is a loop, not a script: block big to small, and at every stage measure, render, look and fix before adding detail. This skill is the protocol every Maya task follows, the shared toolkit the other scenario-maya-* skills call, and the map to them. Target: Maya 2027 on macOS Apple Silicon, driven through Python. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap. **Status (2026-09-24):** Maya 2027 is not installed yet. Every Maya call in [`scripts/`](scripts/) and `references/` is **not yet run in Maya**. The pure-Python parts and the Maya-layer logic ran offline against fakes and pass. First action once Maya is installed: `tests/code/maya-expert/run_all.sh`, then read `archive/tests/maya-expert/<stamp>/results/test_00_probe.json` and correct anything marked [verify] here. ## 1. Execution channels | Channel | Use for | Rules | | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Headless `mayapy` through [`scripts/mx_run.py`](scripts/mx_run.py) | audits, validation, exports, Arnold review renders, batch work, anything that needs no viewport | `python3 mx_run.py [--scene f.ma] [--plugins mtoa,fbx,abc,usd] job.py -- args`. One child process per scene: a crash or hang costs one job. The last stdout line is `MX_RESULT {json}`; exit 0 ok, 1 job error, 4 timeout. Children get `MAYA_DISABLE_ADP=1` and Maya's stderr at `warning` (`--maya-log all` to debug). Script nodes off. Never overwrite the source: `--save-as` a new version. | | Live GUI Maya through [`scripts/mx_bridge.py`](scripts/mx_bridge.py) | playblasts, viewport captures, interactive tools, the user's open scene | Emmanuel starts [`mx_bridge_server.start()`](scripts/mx_bridge_server.py) once in the Script Editor. Then `Bridge().run(code)`: any length, multi-statement; every record carries `result`, `stdout`, `stderr`, `maya_messages` (Maya's warnings and errors) and the traceback, even on a timeout. Read them every call. One call = one undo chunk. Check `cmds.file(q=True, modified=True)` before replacing a scene. Never open modal UI. `stop()` when done. | Do not launch a GUI Maya yourself [added]: it takes a license seat and cannot be driven without the bridge. One Arnold render at a time per machine [added]. mayapy: `/Applications/Autodesk/maya2027/Maya.app/Contents/bin/mayapy` (Maya 2027 Help). `mx_run.find_mayapy()` checks `$MX_MAYAPY`, `$MAYA_LOCATION/bin/mayapy` (on macOS `MAYA_LOCATION` ends in `Maya.app/Contents`), then the newest `/Applications/Autodesk/maya20*/`. Log `cmds.about(version=True)` at session start: Smart Bevel, MtoA and USD for Maya differ across 2027.x. **Why files for the bridge:** a commandPort defaults to MEL, a 4096-character buffer (longer commands close the connection, longer results become an error) and returns the result of one statement only (Maya 2027 Help, commandPort; Python in Maya). `mx_bridge` sends one short line and exchanges code and results as files (GG_MayaMCP pre-installs a handler; Palmer uses two round trips). Security: the port is opened as `127.0.0.1:<port>` [verify with lsof] and has no authentication; Maya's help warns an INET port runs any command, including `system()`, as the user. Personal machine only, close it after the session. **Capability matrix** (from the docs; the probe test confirms or corrects each row): | Action | Headless mayapy | GUI via bridge | Basis | | --------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------ | | cmds / OpenMaya edits, file IO, references, FBX, Alembic, USD export | yes | yes | Maya 2027 Help, Python in Maya | | Arnold stills ([`mx_review`](scripts/mx_review.py)) | yes, CPU; watermark without an Arnold batch license | yes | Arnold batch docs | | `Render -r hw2` viewport-style frames | documented, unverified on macOS | n/a | command-line render doc | | `playblast`, `modelEditor`, viewport grabs | no (needs a model panel) [verify] | yes, `mx_review.playblast` | scripting digest | | Tool contexts: Quad Draw, Multi-Cut, sculpt, Paint Skin Weights, Skin Tools, 3D paint, XGen brushes | no | interactive only (no stroke API): script the underlying commands | Maya 2027 Help | | `scriptJob` | no | yes, not during playback | Maya 2027 Help, scriptJobs | | `maya.utils.executeInMainThreadWithResult` | no, "not available in batch mode" | yes | Maya 2027 Help, threading | | `maya.utils.executeDeferred` | only when idle events run [verify] | yes | same | | MMessage callbacks | [verify] | yes | scripting digest | | commandPort | absent per Theodore 2014 [verify] | yes | MCP bridges note | | PySide6 windows, `workspaceControl` | no | yes | Qt6 migration docs | | Flow Retopology (cloud) | no: sign-in and network [added] | yes | version deltas | | `confirmDialog`, `input()`, `pdb`, file dialogs | never | never: they block Maya's main thread and the bridge | Maya 2027 Help, standard input | ## 2. The expert loop (every domain) 1. **Brief in numbers.** Purpose (engine, film, still, animation), budgets (triangles, texture size and texel density, frame range and fps), style, references, deadline. When the brief is silent, take the domain skill's documented default and write it down. 2. **Stages** from the domain skill, big to small. Never start with detail. 3. **Build one stage** with small, idempotent scripts (get-or-create by name, tag what you create, selection-free). 4. **Gate the stage** with measurable checks ([`mx_audit.verdict`](scripts/mx_audit.py), [`mx_validate`](scripts/mx_validate.py), the domain's numbers) AND a render you open with the image reader (`mx_review.review` sheet headless, or a playblast in the GUI). Experts turn the model constantly; the contact sheet is the agent's orbit. Judge it with the domain's `references/critique.md`. 5. **Fix before advancing.** A problem found at blockout costs one script; found after texturing it costs the stage. 6. **Deliver with evidence:** the saved version, audit and validation JSON, the sheet or playblast, and a list of what was not verified. Never report a model, rig, shot or render as done without having looked at a render of it. Numbers first, then eyes: Fragapane's standard for a matching tool is the inverse of the expected matrix times the computed one equals identity, not "looks right" (Cult of Rig, [01:16:03]); dcc-mcp's showcase proves claims with deltas of 0.0 and color drift of at most 1/255. ## 3. Persona pipelines | Brief | Skill chain | Deliverable | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | Hard-surface prop or game asset | scenario-maya-modeling, scenario-maya-retopology-uv, scenario-maya-lookdev, scenario-maya-pipeline-scripting (FBX) | low poly, UVs, PBR set, FBX re-imported and compared | | Finish an AI-generated mesh | scenario-3d, `mx_audit` on the raw mesh, scenario-maya-retopology-uv, scenario-maya-lookdev, scenario-maya-pipeline-scripting | clean, UV'd asset at a stated budget, baked from the generated high | | Character for keyframe animation | scenario-maya-modeling, scenario-maya-retopology-uv, scenario-maya-rigging, scenario-maya-deformation, scenario-maya-animation (range-of-motion test) | referenced rig file, deformation playblast | | Game character | the same chain with the game options of scenario-maya-rigging and scenario-maya-animation, then scenario-maya-pipeline-scripting | FBX skeletal mesh and clips for Unreal or Unity | | Animated shot | scenario-maya-animation (fixes in scenario-maya-rigging), then scenario-maya-lighting-rendering | approved playblast, then renders | | Look dev or hero still | scenario-maya-lookdev, scenario-maya-lighting-rendering | turntable, lit render, AOVs | | FX shot | scenario-maya-fx (nCloth, Bifrost, MASH), then scenario-maya-lighting-rendering | versioned caches, render | | Groomed character | scenario-maya-groom, scenario-maya-lookdev (hair shader), scenario-maya-lighting-rendering | groom, caches, render | | Pipeline or batch tool | scenario-maya-pipeline-scripting, built on `mx_run` and `mx_validate` | tool and JSON report | **Handoff contract:** every handoff carries a new saved version (never the source overwritten), an `mx_validate` report for the receiving profile with no `fail`, `mx_audit` numbers for its meshes, a sheet or playblast the sender looked at, and the list of what was not verified. Units stay cm and Y-up (Maya 2027 Help; Unreal's unit is 1 cm). | From, to | Gate in code | Look at | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | | modeling to retopology-uv or rigging | `validate(profile="model")` clean, no locked normals on meshes that will deform; 0 n-gons on subdivided or deforming meshes; frozen, no history (FlippedNormals); characters symmetric on X, standing on the grid at the origin, Y-up facing +Z (Quick Rig requires it) | silhouette and clay sheet | | retopology-uv to lookdev or rigging | every face mapped, UVs in 0-1 or clean UDIM tiles, texel density stated in px/cm, overlaps only where stacked on purpose | wire and normals sheet | | rigging or deformation to animation | rig referenced, never imported; `profile="rig"` clean; Skin Tools layers deleted before handoff (Maya 2027 Help: Delete Skin Layers keeps the weights) | range-of-motion playblast | | animation to lighting | `profile="shot", rules={"fps": ...}` clean; references loaded; caches versioned (Alembic is Ogawa only) | final playblast | | lookdev to lighting | `file_paths` pass; color space set per file node | turntable | | fx or groom to lighting | caches versioned with frame range; Nucleus and Bifrost assume 1 unit = 1 m (Maya 2027 Help) | playblast of the cache | ## 4. Shared toolkit (`scripts/`, not yet run in Maya) Import: `import sys; sys.path.insert(0, "<this skill>/scripts")`. The bridge server adds this folder to Maya's `sys.path`, so `Bridge().call("mx_audit", "audit", "crate_geo")` works in the GUI too. - `mx_run.py`: `find_mayapy()`, `run_subprocess(job, args, scene=, plugins=, timeout=, save_as=, maya_log=)` (parent side, parsed dict), `child_environment()`, `run(job, args)` (inside mayapy). - `mx_bridge.py` / `mx_bridge_server.py`: `Bridge(port=7001).ping() / run(code) / call(module, fn, *a, reload=True) / fetch(id)`, `format_record(rec)`; server `start() / stop() / handle(id)`. - `mx_audit.py`: `audit(mesh, texture_size=2048, units="cm")` returns counts, poles by valence, non-manifold, lamina, zero-area, borders and holes, winding, UV sets and bounds, UDIM tiles, overlapping and folded shells, texel density per shell, frozen transforms, pivot, history, naming, instances, symmetry on X, edge-length CV; `verdict(report, profile="game"|"film"|"subd")` lists `error:` / `warn:` / `info:` lines; `audit_all(roots)`. Guards for all skills: `mfn_mesh(shape)` (None on an empty mesh), `is_empty_mesh`, `split_history` (bind pose and weight drivers are not history). - `mx_review.py`: `review(targets, out_dir, views, modes, resolution=1024, tile=512, focus=None)`: front, side, back, threequarter, top, low; clay, wire, silhouette, normals, opt-in `checker` (`checker_repeats=`) and custom `shader_modes=`; `subdiv=`; one labeled sheet plus tiles and each view's camera (`r["cameras"]`, review.json, `pixel_ray`), the user's settings restored. Skills use these options, never `_Session`. `turntable(targets, out_dir, frames=24)`; `playblast(path, start, end, display={...})` (GUI only). - `mx_validate.py`: `validate(profile="model"|"rig"|"shot", roots=None, rules=None, fix=())`: naming, namespaces, history, frozen transforms, non-manifold, lamina, n-gons, UVs, locked normals, smoothing, render stats, layers, sets, unused and unknown nodes, plug-ins, references, script nodes, units and up axis, frame rate, missing files; `SAFE_FIXES`; roots held by UUID across fixes (`roots_after`). ## 5. Rules for Maya code (what generalist code gets wrong) - **Selection-free and explicit:** pass node names to every command; never `select` then operate. Capture creation returns (`polyCube` returns `[transform, history]`, `#` names expand) and use them, not the name you asked for (Autodesk intro series). - **Long names or UUIDs** for anything that can share a short name; `or []` after `ls`, `listRelatives`, `listConnections` [added, probed]. - **Undo is your job:** wrap GUI edits in an undo chunk with `try/finally`. OpenMaya 2.0 writes never reach the undo queue, and deleting with OM2 a node cmds created corrupts undo (Fragapane, [01:17:49]). OM2 for heavy reads and matrices, cmds for edits a user may undo. - **Units:** OpenMaya works in centimeters and radians; cmds returns UI units and degrees [added, probed in `test_00_probe`]. - **Never touch nodes you did not create**; tag your own (namespace, attribute or set) and clean up by tag. Save stage versions, never over the source. - **Main thread only:** cmds raises off the main thread (Maya 2027 Help). ## 6. Maya 2027 traps that break remembered code - PySide6 only; ship no PySide2 fallback (it crashed 2027.0 when PySide2 was installed). `dockControl` is gone: `workspaceControl`. - New scenes use OpenPBR Surface; its parameters do not port from Standard Surface (emission in nits, thin film in micrometers, SSS radius roles swapped). - `addDoubleLinear`, `multiplyDoubleLinear`, `pointMatrixMult` became `addDL`, `multiplyDL`, `pointMatrixMultDL` (2026); plain names like `power` now create unitless nodes. - Removed flags: `getAttr(noEvaluation=)`, `attributeQuery(computeModifies=)`; all keys is `cutKey(time=(None, None))`. - Arnold on macOS: CPU only, no OptiX; OIDN imager in new scenes; Maya IPR does not work with MtoA 5+ (Arnold RenderView); batch renders abort on license failure unless `ARNOLD_FORCE_ABORT_ON_LICENSE_FAIL=0`. - Color: OCIO v2 and ACEScg; the default input space is `sRGB Encoded Rec.709 (sRGB)` since 2026.2; query names, never hardcode them. - Skin Tools layers block the classic weight tools (Mirror, Copy, Hammer, Paste, Normalize, Set Max Influences) until deleted. - `maya -render` is obsolete: `Render -r <renderer>`; Render Setup and legacy render layers are exclusive per session. - `mayaUSDExport` defaults: `frameRange` [1,1], catmullClark scheme, `map1` renamed `st`. - `MFnMesh` raises on an empty mesh (use `mx_audit.mfn_mesh`); `MItGeometry` needs group -1 for all vertices. PyMEL is not bundled; the Python version is undocumented: probe it. ## References - [`references/maya-2027-traps.md`](references/maya-2027-traps.md): the full list of what breaks agent code, old name to 2027 replacement, with sources. Load before writing code from memory or following a pre-2025 tutorial. - [`references/cmds-reliability.md`](references/cmds-reliability.md): idioms and tested-pending procedures for reliable maya.cmds and OpenMaya 2.0 (return types, evaluated vs original data, OM2 patterns, undo, idempotent get-or-create, batch hygiene). Load before any non-trivial script. - [`references/sources.md`](references/sources.md): every source behind this skill and the toolkit, with what each is best for. - Related skills outside this set: scenario-3d (generate meshes to finish in Maya), scenario-zbrush-expert and scenario-blender-expert (sister teams with the same protocol).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.