Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_dcc_maya_scenario-maya-expert-dc592af.zip · 90 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/maya/scenario-maya-expert
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git 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)

  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, mx_validate, 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: 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.

No comments yet.

Reviews (0)

No reviews yet.

Related