Claude Skill

scenario-zbrush-automation

Use when automating ZBrush 2026: batch processing a folder of OBJ or ZTL files, a ZBrush Python script or ZScript, recording a macro to learn a palette path, the ZBrush -script command line, scripting UV Master, Decimation Master or Multi Map Exporter, exporting every SubTool, Go

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_zbrush_scenario-zbrush-automation-dc592af.zip · 71 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/zbrush/scenario-zbrush-automation
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

ZBrush automation (tools and pipeline TD)

An expert ZBrush TD automates with contracts and proof: every file is its own tool, every step is a separate call with a time limit, every output is read back from disk, and a hung plugin costs one file, never the batch. ZBrush has no timeout, no undo control from Python and dialogs that code cannot answer, so the safety lives on the agent side: the runner, the checkpoints, the report. 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.

REQUIRED BACKGROUND: scenario-zbrush-expert (bridge, zb_launch, zb_ops, zb_audit, review loop, 2026 traps).

Status (2026-09-24, refactor pass 2): every ZBrush call here is not yet run in ZBrush (no session assigned; the README's startup stall is still open). Offline: 56 tests pass (tests/code/zbrush-automation/run_offline.py), including a GoZ reader and writer matching ZBrush's own ZResources/DXFStar.GoZ byte for byte. Live suite: tests/code/zbrush-automation/run_live.sh.

Stance (the expert delta)

  • One file, one tool, one step per call. "Only one zscript/plugin can be active at a time, so if your zscript calls someting like UV Mapper control passes to UV Mapper and no more commands will run" (marcus_civis, ZBrushCentral 2014); pressing Decimation Master's Pre-process ended ijacobs' script (2013) and forced cgside into a two-run helper (2022, [00:06:56]). So the batch is an agent-side loop (zb_batch.run_folder) that sends each step as its own bridge call with its own timeout, pings after a timeout, and restarts ZBrush after a hang. One long in-ZBrush script dies with its first stuck plugin. [added design]
  • Plugins last, checkpoint first. cgside saves before ProjectAll "since it might crash the program" [00:02:19]. zb_ops.project_all refuses to run without a versioned save and the recipe's project step passes one ({stem}_preproject.ztl), even with save_ztl off. Decimation Master runs last, after every valuable output is on disk, so a hang there leaves a partial file, not a lost one. [added]
  • Leave the interpreter as found. ZBrush has one Python for the session and the script folder is not on sys.path (Maxon Style Guide). Send code only through remote_call/zb_launch.run: modules are imported by path, then sys.path, sys.modules, stdout and the working directory are restored, also after an error. End a batch with zb_launch.hygiene() (empty lists).
  • Dialogs are the automation killer; budget them before running. A studio dropped ZBrush from its farm over Note dialogs (danvas 2017); "no way to detect whether they succeed or error" (BigRoyNL 2024); "if the export plugin, trigger a popup you are screwed" (m_adam, Maxon 2025). set_next_filename feeds built-in buttons only: FBX ExportImport shows options "You cannot escape" (ferdinand, Maxon 2025) and USD Format ignores the preset (usd-portal). Unattended exports are OBJ or .GoZ by path; has_next_filename() still true after a press means a dialog is probably waiting [added].
  • Names are identity. GoZ needs unique names "including between all the loaded Tools" (GoZ docs); Decimation Master caches and UV Master control maps are keyed by the tool name, so a new tool with an old name inherits stale data (plugin docs). Stage each input under a unique, sanitized name before import (stage_input); on this Mac's case-insensitive disk rock.obj and Rock.OBJ are one file [added].
  • Learn paths from ZBrush, never guess. Recordings show exact paths and hidden preconditions (Maxon ZScript manual; MadPony DnLlDJxfxHs [00:02:16]); Ctrl+hover shows a path. For an agent the cheapest oracle is ZBrush's own Activity log, which writes every press and set with its canonical path (zb_batch.ActivityWatch; proven by the 05:20 log). Then zb_plugin_ops.probe() confirms exists, range and title.
  • Record once, apply to all. Pavlovich replays one recording on every visible SubTool with ZRepeat It [00:37:30]; ZRepeat It is not shipped in 2026.2.1, the Python equivalent is for_each_subtool.
  • Files, not live links, between apps. GoZ sends the lowest subdivision level (GoZ docs; FlippedNormals [00:04:02]), updates by name, and raises a reprojection prompt when topology changed. An agent hands over .GoZ or OBJ files plus a manifest and lets the other app's agent import them.

Establish first

Input Changes Default when silent
Inputs: count, format, faces per file timeouts, DynaMesh resolution, restart cadence OBJ, audited first with zb_audit
Outputs: low count, map size and space, preview count recipe params brief's numbers; else DEFAULT_PARAMS [added]
Parts per file, meaningful polygroups ZRemesher grouping cgside: Auto Groups then ZRemesher Keep Groups on the copy, so the low follows the parts (MyhwQvkcnwI [00:08:38]); zr_auto_groups=False when the input's groups matter
Target app: Maya, Blender, Unreal, Unity flip V, green channel, axes, format Maya: OBJ + tangent PNG flipped V, OpenGL green [added]
Whose ZBrush restart rights a ZBrush the batch did not start is never restarted or quit
Watched or unattended pilot size, screenshot use pilot of one file, watched
Shared config allowed (GoZ folder, Macro palette) GoZ push, key-answer macros no: ask first

Workflow

  1. Session and probe. zb_launch.start() or reuse a running bridge; zb_plugin_ops.probe() on every path the recipe uses; memory(). GATE: ping returns 2026.2, every recipe path exists, plugins present.
  2. Plan dry. zb_batch.plan(files, out, params) resolves every file's steps, paths and timeouts without ZBrush. GATE: outputs outside the install (read-only since 2026.1), unique stems, no FBX or USD step.
  3. Pilot one file, watched. run_folder([f], out, params); open sheet.png and the normal map; import the low and map in the target app (scenario-maya-pipeline-scripting or a Blender skill). GATE: file ok, step timings recorded, map orientation confirmed in the target app (flip V and green are [verify] until then).
  4. Run the batch. Bridge mode by default; mode="script" (one ZBrush -script process per file) once live_a05 has shown how ZBrush exits. Drop a STOP file in the output folder to end between files; rerun to resume (sha1 and recipe hash). GATE: report.json written after every file.
  5. Recover. For each failed or partial: read steps[].outcome (error, timeout_recovered, hang), the hang evidence (screenshot when the screen is unlocked; ZBrush Notes are drawn in the canvas, only the screenshot shows them), and batch.blocked. Fix params or paths, rerun: finished files are skipped. GATE: every non-ok file has a cause in the report.
  6. Review and deliver. Open the contact sheet; check low_audit, preview_audit, normal_stats; write the handoff manifest. GATE: numbers pass, sheet looked at, report and manifest delivered.

Numbers

Value Relative to Source
UV Master: 100k to 150k faces max; seconds at 3k, up to 5 min at 150k faces of the SubTool unwrapped UV Master doc
Divide multiplies faces by 4; levels = smallest n with low x 4^n >= source faces, top capped at 8M projection stack Pablo Logic Part 7; cap [added]
Decimation % of decimation is kept percent of points; 100 none, 0.01 max points; about 2 triangles per point, so % = 100 x target_faces / 2 / points DM doc; ijacobs 2013; Euler [added]
DM quality: 40 to 100 % looks the same, 2 % still good, 1 % degrades original model DM doc
ZRemesher target in thousands, approximate (5 gave 4,963) target polygons Pablo Logic Part 7
Project All Dist 0.1, set explicitly same shape, new topology Drust nxMYYsyJt3o [00:02:19]; toolkit default
create_normal_map(..., local_coordinates=True) = tangent; default False = world map space SDK stub
Macro file name 8+ characters, in a subfolder; new folder needs a restart <Asset Dir>/ZStartup/Macros/ Maxon interfaces doc
Step timeouts 30 to 1,800 s, raised to 4 x slowest observed, 10 x base max; restart every 10 files per step, per session [added]
Tangent map sanity: blue >= 128 on 90 % of pixels final PNG [added]
Maya GoZ flips Y and Z both ways and flips normal maps vertically GoZ_Info.txt local file 2026-09-24
Blender FBX import from ZBrush: scale 1000 FBX FlippedNormals [00:11:21]

Quality gates

  • Per step (code): import counts equal the file's (import_mesh warnings); DynaMesh remeshed; ZRemesher ratio to target 0.5 to 1.5 [added]; UV bbox inside 0..1; Divide ratio 4 per level; every output file exists and is non-empty. Each zb_ops gate (volume from get_polymesh3d_volume, watertight from is_polymesh3d_solid, projection spikes) feeds check_step: a collapsed or exploded DynaMesh or a spike fails the file.
  • Per file (code): low OBJ passes zb_audit.verdict(..., "game") (UVs, no n-gons); low bbox equals the input bbox within 2 % (an OBJ import sets Export Scale: Outgang [00:24:03]) [added tolerance]; normal map at the asked size, not uniform, tangent-like; preview faces near target.
  • Per batch: counts, restarts, blocked, aborted in report.json; report.csv for humans.
  • Visual: sheet.png (one canvas snapshot per file, MatCap Gray): melted thin parts from a low DynaMesh, projection spikes, missing parts. The normal map applied in the target app, lit from the side: seams, upside-down or inverted detail.

Common mistakes

Mistake What it looks like Fix
One in-ZBrush loop over all files first stuck plugin freezes everything, no report run_folder: one call per step, report per file
Pressing a plugin with no timeout bridge silent for minutes plugin steps are separate calls; wait_recovery, then restart
Importing onto the active tool the active SubTool is replaced import_mesh: PolyMesh3D star first (usd-portal)
Same-named inputs or tools stale Decimation cache, GoZ updates the wrong SubTool unique_stems + stage_input
FBX or USD in an unattended run options window, batch stuck OBJ or .GoZ by path
Decimating the master sculpt gone, no undo from Python checkpoint ZTL, decimate last or on a duplicate
Normal map from two SubTools no detail in the map the map compares levels of one SubTool (AskZBrush n0qqpwvn-jA [00:01:14])
World-space map by default rainbow-colored map, wrong shading in engines local_coordinates=True
Restarting the user's ZBrush unsaved work lost BridgeSession.owned; abort instead
Trusting set() slider unchanged, no error exists() then read back (zb_ops.set_checked)

Handoffs

  • Serves every persona: batch versions of their procedures (for_each_subtool, recipes built with zb_batch.step), inventories before GoZ or export, path discovery. Receives their procedure and params; delivers report.json, report.csv, sheet.png and per-file outputs.
  • From scenario-maya-pipeline-scripting or a Blender skill: OBJ or .GoZ files plus a manifest (names, units, which SubTool each updates). Names are checked with zb_goz.check_names before import.
  • To scenario-maya-pipeline-scripting, scenario-zbrush-retopology-export, scenario-blender-uv-baking: low OBJ with UVs, tangent normal PNG (flip V and green stated), decimated preview, audit numbers, Export Scale and axis notes.
  • To a human or computer-use agent: anything behind a dialog (FBX options, SubTool Rename and New Folder prompts, End Macro save dialog, GoZ app chooser, reprojection prompt).

ZBrush 2026 notes

  • Python (2026.0) runs next to ZScript; press_key "Does not work" and merge_undo is "Not implemented" (2026.1 stub): key-held presses and one-undo sequences need a ZScript macro pressed from Python [verify].
  • Long deterministic sequences: freeze(fn) and show_actions(0) (SDK GUI reference) through zb_ops.sequence, frozen, quiet, or for_each_subtool(..., freeze=True); never around a plugin press or a canvas export [added].
  • Since 2026.1 the install is read-only: macros, plugins and Python/init.py go in the Asset Directory, here ~/Library/Preferences/Maxon/ZBrush_03C27D49/.
  • ZRepeat It, Turntabler and ZFileUtils are not shipped in 2026.2.1 (listing); no rename API exists (Maxon forum 2026-09).
  • GoZ still reports "version 1.01 - April, 11th 2011"; Maya's GoZ config on this Mac points at a Maya 2017 that is not installed (zb_goz.app_info).
  • Relaunches stalled after a kill -9 at 05:29 (README, cause unknown): a restart after a hang may stall too. The runner then aborts with a resumable report.

References

  • references/procedures.md: full code for each procedure (session, probe, import, per-file recipe, folder batch, -script, plugins, maps, SubTool loops, names, GoZ, Maya and Blender round trips, macros, file-drop, reports, interpreter hygiene). Load before writing any batch.
  • references/expert-notes.md: the sources' principles with timestamps, and the choices where they disagree. Load when a design decision is not covered here.
  • references/critique.md: the rubric for judging a batch, a script or a round trip. Load before reporting.
  • references/gui-paths.md: the same operations by palette and hotkey, for a human or computer-use agent. Load when a step needs the GUI.
  • references/sources.md: every source with credentials and URLs.
  • scripts/zb_batch.py (agent side: runner, sessions, reports, -script, Activity oracle, ZScript generators), scripts/zb_plugin_ops.py (inside ZBrush: per-file steps and plugins), scripts/zb_batch_job.py (-script entry), scripts/zb_goz.py (GoZ files and folder, any Python).
Files (skills)
  • references
    • critique.md 8.6 KB
      # scenario-zbrush-automation critique rubric
      
      What a pipeline TD checks before calling a batch, a script or a round trip done. Score each line pass, fix or fail; any fail blocks delivery. Sources are in expert-notes.md; `[added]` lines are this skill's own standards.
      
      ## A. Design of the job
      
      | Check         | Pass when                                                                  | Why                                                                      |
      | ------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
      | Isolation     | each file is its own tool, each step its own call with a timeout           | one stuck plugin must not take the batch (marcus_civis; ijacobs; cgside) |
      | Order         | checkpoint ZTL before destructive stages; the riskiest plugin last         | ProjectAll "might crash" (cgside [00:02:19]); no undo control (SDK stub) |
      | Dialog budget | every step's modal risk listed and avoided or handed to a human            | popups are the automation killer (TVeyes; danvas; m_adam)                |
      | Names         | inputs staged under unique sanitized names; `check_names` clean before GoZ | GoZ and plugin caches key on names (GoZ docs; DM and UV Master docs)     |
      | Paths         | every item path confirmed by `exists` or the Activity log; none guessed    | `set()` on a wrong path is silent (v03)                                  |
      | Ownership     | the job never restarts or quits a ZBrush it did not start                  | the user's unsaved work [added]                                          |
      | Install       | nothing written into the install; absolute paths only                      | read-only since 2026.1; cwd is the app folder                            |
      | Dry run       | `plan()` reviewed before the first file                                    | cheap, catches wrong paths and params [added]                            |
      
      ## B. Evidence per file
      
      | Check        | Pass when                                                                                                                                                                                                                                                         |
      | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Import       | points and faces equal the file's; the tool carries the staged name                                                                                                                                                                                               |
      | Remesh       | DynaMesh changed the mesh; ZRemesher within 0.5 to 1.5 of target [added]; each `gate` has no problem (volume ratio in band on a closed mesh, still watertight)                                                                                                    |
      | Low topology | with several pieces, Auto Groups then Keep Groups: one group per piece, the low does not bridge pieces (cgside)                                                                                                                                                   |
      | UVs          | UV bbox inside 0..1; the low OBJ has `vt` lines                                                                                                                                                                                                                   |
      | Projection   | each Divide x4; top level at least the source's faces (else detail is lost); a `_preproject_vNNN.ztl` saved before the first Project All; morph target on every pass, a layer on the last; Dist 0.1; gate: points kept, nothing outside the visible bbox (spikes) |
      | Maps         | file at the asked size; not uniform; tangent-like (blue >= 128 on 90 %) [added]; flips stated                                                                                                                                                                     |
      | Preview      | faces near the target; triangles; silhouette kept on the sheet                                                                                                                                                                                                    |
      | Scale        | low bbox equals the input bbox within 2 % [added]; Export Scale recorded                                                                                                                                                                                          |
      | Files        | every output exists, is non-empty and is newer than the job start                                                                                                                                                                                                 |
      
      ## C. The batch
      
      | Check    | Pass when                                                                                     |
      | -------- | --------------------------------------------------------------------------------------------- |
      | Report   | `report.json` written after every file; every non-ok file has a step, an outcome and an error |
      | Recovery | hangs have evidence (screenshot or diagnose); blocked capabilities listed; fallbacks named    |
      | Resume   | a rerun skips finished files (sha1 and recipe hash) and redoes only failed ones               |
      | Look     | `sheet.png` opened and judged: no melted parts, no spikes, nothing missing                    |
      | Honesty  | the delivery says which steps are unverified in ZBrush and which checks are only numeric      |
      
      ## D. Round trips
      
      | Check           | Pass when                                                                               |
      | --------------- | --------------------------------------------------------------------------------------- |
      | Identity        | the right SubTool updated (names unique across loaded tools)                            |
      | Level           | the level sent is the one intended (GoZ sends the lowest)                               |
      | Axes and scale  | an asymmetric feature lands on the same side in both apps; size in real units matches   |
      | Maps            | orientation checked in the target app with side light; green channel matches the engine |
      | Topology change | handled by import as a new SubTool plus Project All, not by a blind reprojection prompt |
      
      ## E. Scripts and macros handed to others
      
      | Check              | Pass when                                                                                                                                                                          |
      | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Shared interpreter | no module left on `sys.path` or in `sys.modules`; no change to `sys`, `math`, `random`, no `os.chdir` (SDK Style Guide); `zb_launch.hygiene()` returns empty lists after the batch |
      | Long sequences     | deterministic runs grouped under `freeze` and `show_actions(0)` (`zb_ops.sequence`); no plugin press or canvas export inside                                                       |
      | User state         | material, brush, Draw values, active SubTool restored (shipped macro house style)                                                                                                  |
      | Blocking calls     | no `message_*`, `ask_*`, `show_note(duration 0)`, no `time.sleep` inside ZBrush                                                                                                    |
      | Macro install      | subfolder, 8+ character name, restart noted, user consent recorded                                                                                                                 |
      | Docs               | every function states its evidence tag and its live test                                                                                                                           |
      
      ## Red flags that fail a delivery at once
      
      - A report that says "done" without a looked-at sheet or without per-file numbers.
      - A batch that pressed FBX ExportImport or USD Format unattended.
      - A normal map exported in world space for an engine.
      - Outputs written over earlier outputs without a `.bak` or a new version.
      - A "verified" label on code that did not run in ZBrush.
      - A Project All without a checkpoint ZTL saved just before it, or projection spikes smoothed instead of morphed back.
      
    • expert-notes.md 22.7 KB
      # scenario-zbrush-automation expert notes
      
      The depth behind SKILL.md: what each source says, where, and how it changes an agent's batch. Notes live in `notes/scripting/`, `notes/versions/`, `notes/maps-export/`, `notes/retopology-uv/`, `notes/pipeline/`; version facts in `sources/zbrush-version-deltas.md`. `[added]` marks this skill's own inference.
      
      ## 1. Control: one active script, plugins that keep it
      
      - **marcus_civis** (ZBrushCentral, long-time ZScript and plugin author), 2014-02-17: "only one zscript/plugin can be active at a time, so if your zscript calls someting like UV Mapper control passes to UV Mapper and no more commands will run in your zscript." Same author, 2013-06-20 and 2019-04-22: a script handed to ZBrush on the command line only runs without a button when wrapped in `[If,1, ...]`, and should guard itself with a memory block so it runs once.
      - **ijacobs** (ZBrushCentral, 2013-02-21): "as soon as my code clicks the Pre-Process All button in Decimation Master, my script exits because Decimation Master is a plugin that calls it's own scripts." He pre-processed by hand, then ran a per-SubTool decimate loop (target 75,000 points per SubTool for print: `% = 75000 / points * 100`).
      - **cgside** (3D environment artist, "ZSCRIPT in Zbrush | Automate tasks", 2022): "we can't really run other plugins like the decimation master within our script by default" [00:06:56]; a free helper plugin with a two-run routine got around it [00:08:38]. Decimation target: points / 10, then / 1000 because the plugin field is in thousands [00:08:04].
        - His remesh recipe [00:01:43]-[00:02:51], scripted at [00:08:38]-[00:09:42]: save with a suffix, Merge Visible, fill white, duplicate and decimate; then Polygroups > Auto Groups (one group per separate piece), duplicate again, ZRemesher with Keep Groups, Divide 4 times, move to the top, save, hide the decimated SubTool, set the projection settings, ProjectAll. Keep Groups on Auto Groups is what makes the low follow each piece of a merged tile instead of bridging them. The toolkit runs it as `make_low(auto_groups=True)` (on the copy [added]) and it is on in `DEFAULT_PARAMS`.
        - "save it before projectile [ProjectAll] since it might crash the program this way we have some sort of backup" [00:02:19]: `zb_ops.project_all` refuses to run without a checkpoint, and `divide_project` saves once before its first pass.
      - **MadPony** (plugin author, "What is ZScript", 2019): "one Z script will cancel out the other Z script" [00:00:35]; loading a recorded script removed the macro's button [00:05:52]. "Improving the Nudge Axis Plugin" [00:03:27]: a plugin's variables were wiped after another macro ran.
      - **Maxon interfaces doc**: "Only one zscript (or plugin or macro) can be active at a time"; Python differs: "a single, persistent session".
      - Agent consequence [added]: in Python the question is whether `zbc.press()` on a ZScript plugin returns to the caller at all (untested in 2026: scenario-zbrush-expert live_08, live_09, this skill's live_a02, live_a03). Until then every plugin press is a separate bridge call with a timeout, and the batch runner, not ZBrush, owns the loop.
      
      ## 2. Dialogs, notes and popups
      
      - **TVeyes** (ZBrushCentral, 2015-04-11): "There is no reliable solution to using an external library to dismiss the note window based messages ZBrush displays." Merge Down's confirmation cannot be answered by a script because Space or Enter triggers the first Note button, which is Cancel (TVeyes, dargelos).
      - **danvas** (2017-03-10): a studio dropped ZBrush from its render farm because Note dialogs when splitting polygroups could not be controlled.
      - **BigRoyNL** (2024-03-07): "I can run zscripts from external, but have no way to detect whether they succeed or error"; plugin exports "seem to stop the current zscript execution".
      - **m_adam** (Maxon SDK team, 2025-02-25): background-thread export from another program is likely impossible, and "if the export plugin, trigger a popup you are screwed".
      - **ferdinand** (Maxon SDK team, 2025-03-11) on FBX: "You cannot escape any GUI associated with such call." **pablo31** (2026-02-04): `set_next_filename` before `Zplugin:FBX ExportImport:Import` still opened the file dialog; `Tool:SubTool:New Folder` opens a name prompt.
      - **usd-portal** (baborub, tested on 2026.2): the USD Format plugin "opens its own file dialog and ignores FileNameSetNext".
      - **Maxon shipped macros** (2026.2.1): confirmations are answered by holding a key during the press: `[IKeyPress,'2',[IPress,Tool:SubTool:Del All]]` (Delete All), `[IKeyPress,'1',[IPress,"Brush:Create:Create InsertMesh"]]` (Create Instance Subtool).
      - **pocacola** (Maxon forum, 2026-04-21): Python `press_key` with modifier chords does not work; pressing a macro button that contains the `IKeyPress` worked.
      - **SDK stub** (2026.1): `press_key` "Does not work, hidden for now"; `merge_undo` "Not implemented"; `message_*`, `ask_*` and `show_note(duration 0)` block until a click.
      - Agent consequence: the dialog budget of a recipe is decided before the run (table in section 9); `has_next_filename()` after a file press is the cheap detector [added]; a modal Note is visible only in a screenshot, because ZBrush draws Notes inside its canvas, not as OS windows [added: System Events lists OS windows only].
      
      ## 3. Files without dialogs
      
      - **Command reference** (FileNameSetNext): "Pre-sets the file name that will be used in the next Save/Load action"; `FileNameHasNext` tests whether it is still pending. Python: `set_next_filename`, `has_next_filename`, `get_last_used_filename` (returns a full path despite its name, SDK system doc).
      - **MadPony** ("Tools and Folders", 2019): `FileNameSetNext` before `Tool:Save As` names the saved tool [00:02:51]; without it the tool takes its first SubTool's name [00:03:48].
      - **Maxon example ex_mod_subtool_export** (Javier Edo, 2025): loop SubTools, skip unless status bit 0x01, name from `get_active_tool_path().rsplit('/')[-1]`, `set_next_filename`, `press("Tool:Export")`, `sys.exit(not exported)`.
      - **usd-portal**: folder-aware visibility (eye 0x1 and folder 0x2); `.GoZ` by path "reliably carries UVs + polypaint"; OBJ is the reliable import; import onto the PolyMesh3D star, never SimpleBrush; Duplicate is "not found" outside Edit mode; textures need unique file names or ZBrush adds "(1)".
      - **Paul Gaboury** (Maxon, #AskZBrush echGh0tZEts, 2022): OBJ cannot keep every SubTool with its name in one file [00:00:34]; FBX can, through its options dialog [00:01:06]; check by re-importing onto the PolyMesh3D star [00:01:42].
      - **Maxon SDK environment doc**: the working directory is the app folder; since 2026.1 ZBrush does not write into its install (interfaces doc "Important Information for Users"): absolute paths outside the install only.
      
      ## 4. Launch channels
      
      | Channel                                                                                              | Source                                               | Good for                                              | Weak points                                                      |
      | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------- |
      | Project bridge (socket plus main-thread serve loop)                                                  | README (proven 04:25, 05:20)                         | interactive batches with return values and tracebacks | a hung main thread silences it; startup stall since 05:29 (open) |
      | `ZBrush -script job.py args scene.ZPR`                                                               | Maxon SDK quickstart; ex_mod_subtool_export          | one process per file, isolation by process            | quit-on-exit and `-batch` undocumented; launch cost per file     |
      | File-drop ZScript (`open -a ZBrush.app job.txt`)                                                     | usd-portal, GoB, gozbruh                             | stock install without the bridge                      | one way, no errors, ends the active ZScript, VarSave sandbox     |
      | GoZ folder (`GoZ_ObjectList.txt` + `GoZBrushFromApp.app`)                                            | GoZ SDK; GoB                                         | pushing meshes in from another app                    | not a command channel; names and identifiers must match          |
      | Startup Python (`init.py` on PYTHONPATH, `*.py` on ZBRUSH_PLUGIN_PATH, `<Asset Dir>/Python/init.py`) | SDK environment doc; davide (Maxon forum 2026-04-15) | installing helpers permanently                        | changes the user's ZBrush                                        |
      
      ## 5. Learning paths
      
      - **Maxon ZScript manual**: "do the thing you're interested in while recording a zscript of your actions... open this in a text editor and examine it"; "To find the button path for any item on the interface, place the cursor over the item and hold down Ctrl."
      - **MadPony** ("What is ZScript" [00:04:40]-[00:05:17]): the Ctrl+hover path equals the recorded `IPress` line.
      - **Pavlovich** (Workshop 45, 2018): recorded ZRepeat It scripts are editable text ("you can go through here and you can edit it" [01:02:07]); Ctrl+Alt+click any button to give it a hotkey [01:04:24].
      - **Maxon SDK quickstart**: Python Scripting > New Macro and End Macro record Python; End Macro opens a save dialog; loading the file places a button instead of running it.
      - **SDK API overview**: item paths ignore case and spaces; picker items (brushes, tools, materials) resolve by name without opening the picker.
      - **Project Activity log** (README, 05:20 log): ZBrush writes every `IPress`/`ISet` with its canonical path, including plugin sliders at startup (`ISet, "Zplugin:3D Print Hub:X(mm)", 25.4`); the agent reads new lines after a human click [added use as an oracle].
      
      ## 6. Per-SubTool repetition and names
      
      - **Pavlovich** (Workshop 45): ZRepeat It "you can record ... and then name it and then you can tell it I want to apply what I just did to this single visible sub tool or all visible sub tools or all sub tools" [00:37:30]; demo DynaMesh 64, Blur 0 [00:42:53]. Not shipped in 2026.2.1: a Python loop replaces it.
      - **MadPony** ("Selecting SubTools" [00:01:03]-[00:03:10]): names repeat and "a duplicate will always give you the same ID"; work by index, re-read after any reorder; the ItemInfo title ends with a period (strip 2 characters, usd-portal).
      - **MadPony** ("Renaming SubTools" [00:00:34]; "Moving SubTools" [00:01:06]): `ToolSetPath` renames only the top SubTool; move it up while MoveUp is enabled, rename, move back.
      - **davide** (Maxon forum 2026-09-10): SubTool and folder management "are high on the list" for a future API; no date.
      - **GoZ docs** ("Restrictions"): unique Tool and SubTool names "including between all the loaded Tools", no spaces or non-alphanumeric characters; PolyMesh3D only.
      
      ## 7. Plugins
      
      - **Decimation Master doc**: options, then Pre-process (progressive meshes from 100 to 0 % on disk), then Decimate; the result reflects the model at pre-process time; unique SubTool names required; a new tool with a cached name reuses stale caches; Keep UVs +50 % memory; quality 40 to 100 % near identical, 2 % still good, 1 % degrades; killing the plugin process leaves ZBrush alive but the calling ZScript keeps focus, and ESC stops it (Troubleshooting). Up to 100M polygons since 2021.6.3 (version deltas).
      - **Ian Robinson** (Maxon, #AskZBrush bVX9utHc_ZI, 2022): Use and Keep Polypaint "by default this is turned off" [00:01:17]; presets pre-process and decimate in one click [00:02:15].
      - **Joseph Drust** (Pixologic, #AskZBrush mRfA_WDvvrg, 2018): polygroups are lost; Unweld Groups Border [00:02:35], Freeze Borders and a preset [00:03:14], Auto Groups [00:03:50], Weld Points [00:04:30], divide to check [00:05:04].
      - **UV Master doc**: a UV creator, not an editor; 100k to 150k faces maximum, up to 5 minutes at 150k; Work on Clone whenever there are levels or polypaint (clone is a new CL_ tool); Copy UVs, Paste UVs between same-topology meshes; polygroups speed it up and reduce cuts; Attract from Ambient Occlusion is the stroke-free seam hint; control maps are bound to the tool name. UV Master with Polygroups fixed in 2026.2.0 (release notes).
      - **Multi Map Exporter doc**: Create All Maps opens a save dialog; aborting with ESC can lose UVs, back up first; Mid 0 and Scale 1 for 32-bit displacement, 0.5 and Get Scale's lower value for 16-bit; Flip V flips all maps; UDIM naming options.
      - **Joseph Drust** (#AskZBrush n0qqpwvn-jA, 2017): a ZBrush map compares the selected level with the highest level of the same SubTool, "you can't have a subtool that is your low resolution version and then a subtool that is your high resolution version" [00:01:14]; 2zDAtaQqwh8 (2020): baking from a high level keeps only micro detail [00:02:53].
      - **SDK stub**: `create_normal_map(..., local_coordinates=False)` is world space by default; tangent needs True.
      
      ## 8. GoZ, Maya and Blender
      
      - **GoZ docs**: GoZ, All, Visible, R; ZBrush sends the lowest subdivision level; maps travel only if loaded in the Tool's map slots; topology changes raise a reprojection prompt that "can be time consuming" and "may alter a little bit the quality"; Preferences > GoZ > Import as SubTool.
      - **GoZ SDK** ("not really a true SDK"): the shared folder `/Users/Shared/Pixologic`; object identifier = full path without extension; `GoZ_ObjectList.txt`; `GoZBrushFromApp` opens listed objects in the running ZBrush; `GoZ_Info.txt` flip flags per app.
      - **Local files** (2026-09-24): Maya's `GoZ_Info.txt` sets EXPORT/IMPORT_FLIP_Y and _Z and NORMAL_MAP_FLIP_VERT to TRUE; Maya's `GoZ_Config.txt` points at `/Applications/Autodesk/maya2017` (no Maya installed); the Maya side loads the `gozMaya` plug-in and opens command port 5555; `ZResources/DXFStar.GoZ` confirms the binary layout (header, name, flags 5001, points 10001, faces 20001, material 2, 16-byte end).
      - **FlippedNormals** (Morten Jaeger, Henning Sanden, 2020): GoB is "by far the fastest way"; GoZ exports "from the lowest subdivision level" [00:04:02]; delete lower levels before sending if polypaint resolution matters [00:06:01]; pack and unpack textures out of the public folder [00:08:15]; FBX into Blender at scale 1000 [00:11:21].
      - **usd-portal**: GoZ UVs match USD, but an imported Texture Map needs a V flip; OBJ vertex colors are decoded sRGB to linear, so pre-encode.
      - **Laura Gallagher** (Outgang, ex Lead Character Artist at Eidos Montreal): ZBrush has only units [00:04:27]; keep XYZ Size near 2 [00:28:23]; Export Scale converts to real units and an OBJ import sets it [00:24:03]; offsets are in internal units [00:33:40]; Unify is unsafe on characters with layers and morph targets [00:27:16].
      
      ## 9. Dialog budget for batches
      
      | Step                                              | Modal risk                          | Plan                                                                       |
      | ------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------- |
      | Import, Export, Save As, Texture and Alpha export | file dialog without a preset        | `set_next_filename`, then `has_next_filename()` false and the file on disk |
      | DynaMesh or ZRemesher on a SubTool with levels    | note [verify]                       | zb_ops refuses; ZRemesher a duplicate                                      |
      | UV Master, Decimation Master, MME                 | control handover, save dialog (MME) | own call, timeout, fallback, last in the recipe                            |
      | FBX ExportImport, USD Format                      | options or file dialog              | not in unattended recipes                                                  |
      | SubTool Rename, New Folder                        | text prompt                         | name at creation, top-SubTool trick                                        |
      | Del All, Delete, Merge Down                       | confirmation note                   | avoid; key-held macro with consent                                         |
      | GoZ first press, GoZ return with new topology     | app chooser, reprojection prompt    | `.GoZ` by path; import as a new SubTool and Project All                    |
      | Quit with unsaved work                            | save prompt                         | `zb_launch.stop` answers No when unlocked, else SIGTERM                    |
      
      ## 10. ZScript to Python (for reading macros and forum answers)
      
      | ZScript                                                       | Python (`zbrush.commands`)                              | Trap                                                                                                                        |
      | ------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
      | `[IPress,p]` `[IUnPress,p]` `[IToggle,p]`                     | `press`, `un_press`, `toggle`                           | `un_press` cannot release radio groups                                                                                      |
      | `[ISet,p,v]` `[IGet,p]` `[IModSet,p,bits]`                    | `set`, `get`, `set_mod`                                 | `set` on a missing path is silent (v03)                                                                                     |
      | `[IExists,p]` `[IsEnabled,p]` `[IGetTitle,p]` `[IGetInfo,p]`  | `exists`, `is_enabled`, `get_title`, `get_info`         | `get_status` is enabled or grayed, not on or off                                                                            |
      | `[FileNameSetNext,f]` `[FileNameHasNext]`                     | `set_next_filename`, `has_next_filename`                | plugins may ignore it                                                                                                       |
      | `[Mesh3DGet,0,,,var]`                                         | `query_mesh3d(0)[0]`                                    | returns a list; ZScript returned a status                                                                                   |
      | `[SubToolSelect,i]` `[SubToolGetStatus,i]` `[ToolSetPath,,n]` | `select_subtool`, `get_subtool_status`, `set_tool_path` | -1 on error; top SubTool only for the rename                                                                                |
      | `[IFreeze,...]` `[IShowActions,0]`                            | `freeze(fn)`, `show_actions(0)`                         | progress bars still update; toolkit: `zb_ops.frozen`, `quiet`, `sequence` (no plugin press or canvas export inside [added]) |
      | `[IKeyPress,k,...]` `[MergeUndo]`                             | `press_key`, `merge_undo`                               | broken and not implemented: use a macro                                                                                     |
      | `[VarSave]` `[MVarDef]` `[Loop]` `[If]` `[Sleep]`             | files, globals, `for`, `if`; no `Sleep`                 | memory blocks are case-sensitive, strings capped at 255, math left to right                                                 |
      
      ## 11. Where the sources disagree, and the deciding condition
      
      | Choice            | Option A                                              | Option B                                                                     | Decide by                                                                                            |
      | ----------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
      | Batch loop        | one script inside ZBrush (Maxon example, cgside)      | agent-side loop, one call per step [added]                                   | any plugin press, any file count above a handful, any unattended run: B                              |
      | Launch            | bridge (README)                                       | `-script` per file (SDK quickstart)                                          | need return values and screenshots: bridge; need isolation after live_a05 proves the exit: `-script` |
      | Rename            | top-SubTool trick (MadPony)                           | rename files before import (wtz2025, Maxon forum)                            | names known before import: rename files; inside an existing tool: the trick                          |
      | UVs               | UV Master (seams in cavities, polygroups, AO attract) | native Create (Unwrap) (2023+, in the SDK docs)                              | organic, readable islands: UV Master; hard surface with creased seams, or UV Master hangs: native    |
      | Decimated preview | Decimation Master (quality at 2 %)                    | ZRemesher low as preview, or decimate the OBJ outside ZBrush [added]         | DM returns control: DM; it hangs: B                                                                  |
      | Exchange          | GoZ buttons (one click, GoZ docs)                     | `.GoZ` or OBJ by path (usd-portal)                                           | human at the desk: buttons; agent: files                                                             |
      | Known UI state    | `zbc.config(2026)` (SDK quickstart)                   | explicit sets and read-backs (Maxon config doc warns it overwrites settings) | a dedicated agent profile: config; the user's ZBrush: explicit sets                                  |
      
      ## 12. Projection safety and the shared interpreter (added 2026-09-24 from the Z7 grade)
      
      - **FlippedNormals** (Henning Sanden, Morten Jaeger, ex-MPC and ex-Framestore, Zp07GW3rND0, 2018): at the highest level, Store Morph Target, then a New Layer, then Project All; "It's important that you do all of this on the highest level" [00:02:13]. Repair busted areas with the Morph brush, not smoothing: "you're never gonna be able to smooth these kind of things out properly" [00:06:36]. Local fix: mask, invert, project again [00:09:27].
      - **Joseph Drust** (Pixologic, #AskZBrush nxMYYsyJt3o): Dist 0.1; the small default causes "90 percent" of Project All artifacts [00:02:19]. Set explicitly by `zb_ops.project_all`.
      - **Maxon SDK Style Guide and examples**: one persistent interpreter; the script folder is not on `sys.path`; insert a folder only for the import, then remove it and pop the modules (ex_mod_curve_lightning); `random.seed = 42` breaks `random.seed` until restart. `freeze(fn)` for heavy sequences ("Frozen will be most of the UI, but things like progress bars will still work"), `show_actions(0)` before scripted presses (not needed for `set`).
      - **Maxon SDK modeling reference**: `get_polymesh3d_volume()` and `is_polymesh3d_solid()` are one call each; the toolkit gates every destructive op on them (python_sdk digest delta 22: catch a collapsed or exploded remesh). Bands are [added].
      
    • gui-paths.md 12.3 KB
      # scenario-zbrush-automation GUI paths (for a human or a computer-use agent)
      
      The same operations as procedures.md, by palette, button and hotkey, ZBrush 2026.2.1 on macOS. Item paths in code are the same labels joined by `:`. Labels marked [verify] are from docs or older builds; confirm with Ctrl+hover (the path shows at the bottom of the popup, Maxon ZScript manual) or the Activity log.
      
      ## Scripting and macros
      
      | Operation               | GUI                                                                                            | Notes                                                                  |
      | ----------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
      | Run a Python file       | ZScript > Python Scripting > Load (file dialog), Reload                                        | the agent uses `zbrush.utils.run_path` or the bridge instead           |
      | Show Python output      | ZScript > Script Window Mode > Python Output                                                   | output appears when the script returns                                 |
      | Record Python           | ZScript > Python Scripting > New Macro, act, End Macro (save dialog)                           | Load places a console button; click it to run                          |
      | Record ZScript          | Macro > New Macro, act, Macro > End Macro                                                      | save in `<Asset Dir>/ZStartup/Macros/<Subfolder>/`, name 8+ characters |
      | Reload macros           | Macro > Reload All Macros                                                                      | a new subfolder needs a restart                                        |
      | ZScript window          | H toggles it; ZScript > Load, Reload, Record, EndRec                                           | loading a script removes the previous one's buttons                    |
      | Read a path             | hover an item, hold Ctrl                                                                       | same string as the recorded `IPress` line                              |
      | Hotkey any button       | Ctrl+Alt+click the button, press the key (Pavlovich [01:04:24]); Preferences > Hotkeys > Store | never assign or send Cmd+W (quits ZBrush on macOS)                     |
      | Abort a macro or script | Esc                                                                                            | Decimation Master doc: Esc stops a plugin that keeps focus             |
      | Asset Directory         | Preferences > Asset Directory > Open Directory, Change Location (restart), Reset to Default    | here `~/Library/Preferences/Maxon/ZBrush_03C27D49/`                    |
      | Known UI state          | Preferences > Init ZBrush                                                                      | resets the interface: save first                                       |
      | Store configuration     | Preferences > Config > Store Config (Shift+Cmd+I on macOS)                                     | changes the user's startup UI                                          |
      
      ## Files
      
      | Operation                    | GUI                                                      | Notes                                                                                                        |
      | ---------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
      | New tool from a file         | Tool > PolyMesh3D (star), then Tool > Import             | Import on another tool replaces its active SubTool                                                           |
      | Export the active SubTool    | Tool > Export (extension picks OBJ, GoZ, others)         | options Qud, Tri, Txr, Grp, Mrg, Smooth Normals, Scale, X/Y/Z Offset at the bottom of the Export sub-palette |
      | Import and export axis flips | Preferences > ImportExport (iFlip, eFlip, NormalMapFlip) | global since 2021.7                                                                                          |
      | Save the tool                | Tool > Save As (.ZTL)                                    | Ctrl+S saves a project, not the tool                                                                         |
      | Save the project             | File > Save As (.ZPR)                                    | opening a ZPR removes every loaded tool                                                                      |
      | Export a texture             | Texture > Export                                         | after Tool > Normal Map > Clone NM or Tool > Texture Map > Clone Txtr                                        |
      | Export an alpha              | Alpha > Export                                           | after Tool > Displacement Map > Clone Disp [verify]                                                          |
      
      ## SubTools
      
      | Operation             | GUI                                                                                                                         |
      | --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
      | Duplicate             | Tool > SubTool > Duplicate (Edit mode on)                                                                                   |
      | Move up or down       | Tool > SubTool > arrow buttons (MoveUp, MoveDown); Shift+MoveUp jumps to the top (pocacola, Maxon forum)                    |
      | Rename                | Tool > SubTool > Rename (text prompt)                                                                                       |
      | Folder from selection | Ctrl+F with SubTools selected (2026.1), asks for a name                                                                     |
      | Visibility            | eye icon per SubTool and per folder                                                                                         |
      | Project detail        | Tool > SubTool > Project All (Dist, Mean, PA Blur)                                                                          |
      | Export every SubTool  | Zplugin > SubTool Master > Export (visible) [verify]; Decimation Master > Export All SubTools (one OBJ, groups per SubTool) |
      
      ## Plugins
      
      | Plugin             | GUI path                                                                                                                                                                                  | Main controls                                                                                                                                                                                                                                                      |
      | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
      | UV Master          | Zplugin > UV Master                                                                                                                                                                       | Unwrap, Unwrap All, Symmetry, Polygroups, Use Existing UV Seams, Enable Control Painting (Protect, Attract, Erase, Density), Attract From Ambient Occlusion, Check Seams, Flatten, UnFlatten, Work On Clone, Copy UVs, Paste UVs, Clear Control Maps               |
      | Native unwrap      | Tool > UV Map > Create (Unwrap)                                                                                                                                                           | Unwrap, Auto Seams, Crease Edges, Crease Seams, Symmetry; UV Map Size                                                                                                                                                                                              |
      | Decimation Master  | Zplugin > Decimation Master                                                                                                                                                               | Freeze Borders, Keep UVs, Keep & Use PolyPainting, Pre-process Current, Pre-process All, % of decimation (or a target in thousands of points or polygons [verify labels]), Decimate Current, Decimate All, presets, Utilities > Delete caches, Export All SubTools |
      | Multi Map Exporter | Zplugin > Multi Map Exporter                                                                                                                                                              | map switches (Displacement, Vector Displacement, Normal, Texture From Polypaint, Ambient Occlusion, Cavity, Export Mesh), SubTools, Merge Maps, Map Size, Map Border, Flip V, Export Options, Create All Maps (save dialog)                                        |
      | Maps per SubTool   | Tool > Normal Map (Tangent, Adaptive, SmoothUV, SwitchRG, FlipR/G/B, Create NormalMap, Clone NM); Tool > Displacement Map (Create DispMap, Mid, 32Bit, Clone Disp, Create And Export Map) | the selected level is the base, the top level the detail                                                                                                                                                                                                           |
      | FBX                | Zplugin > FBX ExportImport (Selected, Visible, All; version; Tris; axis preset, "If in doubt then MayaYUp")                                                                               | options window: human step                                                                                                                                                                                                                                         |
      | Scale              | Zplugin > Scale Master (Set Scene Scale dialog, ZBrush Scale Unify, Export to Unit Scale)                                                                                                 | dialogs; prefer Tool > Export > Scale                                                                                                                                                                                                                              |
      | Substance Painter  | Texture > Substance Bridge > Send to Painter (2026.2)                                                                                                                                     | starts Painter, a new project each send                                                                                                                                                                                                                            |
      
      ## GoZ
      
      | Operation                   | GUI                                                                                                  |
      | --------------------------- | ---------------------------------------------------------------------------------------------------- |
      | Send                        | Tool palette top: GoZ (active), All, Visible; R resets the target app                                |
      | New objects as SubTools     | Preferences > GoZ > Import as SubTool                                                                |
      | Reinstall or repath targets | Preferences > GoZ > Update all Paths, Path to <App>, Force reinstall                                 |
      | Maya side                   | GoZBrush shelf (from `userSetup.mel` sourcing `GoZScript.mel`), `gozMaya` plug-in, command port 5555 |
      | Blender side                | GoB add-on (Blender extension): GoB Export and GoB Import in the top bar; Import keeps listening     |
      
      ## Human hand-off list (what the agent cannot click)
      
      FBX export and import options; SubTool Rename and New Folder prompts; End Macro save dialog; GoZ app chooser on the first press; GoZ reprojection prompt; Delete and Merge Down confirmations without a key macro; Scale Master and 3D Print Hub size dialogs; Change Location of the Asset Directory. For each, the agent screenshots first (when the screen is unlocked), names the button, and states what the click will do.
      
    • procedures.md 23.7 KB
      # scenario-zbrush-automation procedures (full code)
      
      Every procedure below is **not yet run in ZBrush**. The pure-Python parts ran offline (56 tests, `tests/code/zbrush-automation/run_offline.py`, results in `offline_results.json`); the ZBrush-side parts ran only against a fake `zbrush.commands`. The live check that will prove each one is named `live_aNN` (`tests/code/zbrush-automation/run_live.sh`). When a live test passes, replace "not yet run in ZBrush" with "verified on ZBrush 2026.2.1" and the log path.
      
      Conventions:
      
      ```python
      import sys
      sys.path.insert(0, "<project>/skills/scenario-zbrush-automation/scripts")   # also puts scenario-zbrush-expert/scripts on sys.path
      import zb_batch, zb_goz
      import zb_launch            # lead toolkit, found through zb_batch
      # ZBrush-side call, main thread, JSON-able args. Modules are imported by path; afterwards sys.path,
      # sys.modules, stdout and the working directory are as they were, also after an error (P19):
      zb_batch.remote_call("zb_plugin_ops", "memory", timeout=30)
      zb_batch.remote_run("result = zbc.get_tool_count()", modules=())
      ```
      
      `zb_ops` functions (scenario-zbrush-expert) are called the same way: `zb_batch.remote_call("zb_ops", "dynamesh", 256)`.
      
      ## P1. Session, health, probe
      
      Not yet run in ZBrush. Test: `live_a01_import_roundtrip.py`, `live_a06_activity_oracle.py`.
      
      ```python
      s = zb_batch.BridgeSession()           # owned only if this call spawns ZBrush
      s.ensure()                             # zb_launch.start() or reuse a live bridge; BatchAbort if ZBrush runs without it
      print(s.version, s.owned)              # 2026.2 expected
      paths = ["Tool:Import", "Tool:SubTool:Duplicate", "Zplugin:UV Master:Unwrap",
               "Zplugin:Decimation Master:Decimate Current", "Tool:Normal Map:Clone NM", "Texture:Export"]
      probe = zb_batch.remote_call("zb_plugin_ops", "probe", paths)
      missing = [p for p, r in probe.items() if not r["exists"]]
      mem = zb_batch.remote_call("zb_plugin_ops", "memory")    # runtime, used, virtual, free (units [verify])
      ```
      
      Gate: `missing == []` for the recipe's paths. A miss is a wrong label, Edit mode off, not a PolyMesh3D, or a missing plugin; case and spaces do not matter (SDK Item Paths).
      
      ## P2. Learn a path: Activity log, probe, recording
      
      Not yet run in ZBrush (the log format is proven by the 05:20 session log; the parser ran offline). Test: `live_a06_activity_oracle.py`.
      
      ```python
      w = zb_batch.ActivityWatch()     # newest <Asset Dir>/Logs/Activity/Activity <date>.txt
      w.mark()
      # a human or a computer-use agent now clicks the button once (or the agent presses something)
      for e in w.entries():
          print(e["verb"], e["path"], e.get("value"), e.get("canonical"))
      # e.g. IPress Tool:Geometry:DynaMesh; ISet Tool:Geometry:Resolution 128.0
      ```
      
      - The log writes the canonical form and drops group names (`Tool:Geometry:Resolution` for what was sent as `Tool:Geometry:DynaMesh:Resolution`); both forms resolve. Strokes are logged only as `Click in Canvas`.
      - Python macro recording (human step because End Macro opens a save dialog): ZScript > Python Scripting > New Macro, act, End Macro, save; read the `.py`. Loading it with Load puts a button in the console instead of running it (SDK quickstart).
      - ZScript recording: Macro > New Macro / End Macro writes `[IPress,...]` lines; port them with the table in expert-notes.md ("ZScript to Python").
      
      ## P3. Import a file as a new tool, and check it
      
      Not yet run in ZBrush. Test: `live_a01_import_roundtrip.py`.
      
      ```python
      stem = zb_batch.unique_stems([src])[0]
      staged = zb_batch.stage_input(src, "/abs/out/_staged", stem)      # copy, unique clean name
      import zb_audit
      a = zb_audit.audit(staged)                                        # counts the import must match
      imp = zb_batch.remote_call("zb_plugin_ops", "import_mesh", staged,
                                 expect_points=a["points"], expect_faces=a["faces"], timeout=180)
      assert not imp["warnings"], imp["warnings"]       # welded or split points show up here
      print(imp["tool"], imp["export"])                  # tool = stem; Export Scale set by the import
      ```
      
      Inside ZBrush (`zb_plugin_ops.import_mesh`): press `Tool:PolyMesh3D` (never SimpleBrush), `set_next_filename(path)`, `Tool:Import`, refuse if the preset is still pending, draw with `canvas_click(10, 10, 10, 20)` and set Edit only when Edit is off, `Transform:Fit`. Import onto an existing tool replaces its active SubTool (usd-portal).
      
      ## P4. One file through the default recipe, step by step
      
      Not yet run in ZBrush. Test: `live_a03_batch_three.py` (through the runner), `live_a02_maps_mme.py` (map chain).
      
      What `run_folder` does per file, written out so a single file can be driven by hand:
      
      ```python
      T = zb_batch.TIMEOUTS
      call = zb_batch.remote_call
      out = f"/abs/out/{stem}"
      call("zb_plugin_ops", "import_mesh", staged, timeout=T["import"])
      call("zb_ops", "dynamesh", 256, timeout=T["dynamesh"])                      # zb_ops refuses levels
      call("zb_ops", "save_ztl", f"{out}/{stem}_dyn.ztl", timeout=T["save"])      # _v001, never overwrites
      low = call("zb_plugin_ops", "make_low", 5, auto_groups=True, keep_groups=True,  # cgside recipe
                 timeout=T["make_low"])["ctx"]                                  # {"source": 0, "low": 1}
      try:
          call("zb_plugin_ops", "uv_master", low["low"], timeout=T["uv"])        # plugin: own call
      except zb_launch.ZBTimeout:
          ...                                                                     # P7: recover, then uv_native
      call("zb_plugin_ops", "export_subtool", low["low"], f"{out}/{stem}_low.obj", timeout=T["export"])
      pj = call("zb_plugin_ops", "divide_project", low["low"], low["source"],
                checkpoint=f"{out}/{stem}_preproject.ztl", timeout=T["project"])   # saved before Project All
      for g in zb_batch.step_gates(pj):                     # points kept, no spike, volume, watertight
          assert not g["problems"], (g["problems"], pj.get("repair"))
      call("zb_ops", "save_ztl", f"{out}/{stem}_proj.ztl", timeout=T["save"])
      call("zb_plugin_ops", "normal_map", low["low"], f"{out}/{stem}_normal_raw.png", size=2048,
           timeout=T["normal_map"])
      call("zb_review", "snapshot", f"{out}/{stem}_view.png", matcap="MatCap Gray", timeout=T["snapshot"])
      call("zb_plugin_ops", "decimated_preview", low["source"], f"{out}/{stem}_preview.obj",
           target_faces=20000, timeout=T["preview"])                              # LAST
      zb_batch.fix_normal_map(f"{out}/{stem}_normal_raw.png", f"{out}/{stem}_normal.png", flip_v=True)
      ```
      
      Custom recipes are data: `zb_batch.step(name, module, func, args, kwargs, timeout, critical, plugin, fallback)`, with `"$p.x"` for params, `"$c.x"` for per-file context (stem, out, staged, source, low) and `"{out}/{stem}..."` templates.
      
      ```python
      recipe = [zb_batch.step("import", "zb_plugin_ops", "import_mesh", ["$c.staged"]),
                zb_batch.step("polish", "zb_ops", "polish", [10], critical=False),
                zb_batch.step("export", "zb_plugin_ops", "export_subtool", [0, "{out}/{stem}_polished.obj"])]
      print(zb_batch.plan(files, "/abs/out", {}, recipe=recipe))        # dry run first
      ```
      
      ## P5. A folder, with recovery, resume and a report
      
      Not yet run in ZBrush. Runner logic ran offline with fake sessions (ok, plugin hang with fallback, recovered timeout, repeated hang abort, not-owned session, non-critical hang, STOP file, script mode). Test: `live_a03_batch_three.py`, `live_a04_hang_drill.py`.
      
      ```python
      files = zb_batch.discover("/abs/in", ("*.obj",))                  # case-insensitive
      params = {"dyn_res": 256, "zr_target_k": 5, "map_size": 2048, "preview_target_faces": 20000,
                "map_flip_v": True, "map_flip_green": False}             # True for Unreal (DirectX)
      print(zb_batch.plan(files, "/abs/out", params)["files"][0])        # review one file's steps
      rep = zb_batch.run_folder(files, "/abs/out", params, restart_every=10, grace_s=60)
      print(rep["batch"]["counts"], rep["batch"]["restarts"], rep["batch"]["blocked"], rep["batch"]["aborted"])
      for stem, r in rep["files"].items():
          if r["status"] != "ok":
              bad = [s for s in r["steps"] if not s.get("ok")]
              print(stem, r["status"], [(s["step"], s.get("outcome"), s.get("error")) for s in bad], r["problems"])
      ```
      
      Recovery rules, in the order the runner applies them:
      
      1. Remote error (Python exception in ZBrush): the step fails; a critical step ends the file.
      2. Timeout, then ZBrush answers within `grace_s`: outcome `timeout_recovered`; the late operation still ran, so the file is failed and the next file starts from a new tool.
      3. Timeout and no answer: outcome `hang`; evidence saved (diagnose, screenshot when unlocked, OS windows); ZBrush restarted with `zb_launch.stop(force=True)` then `start()` (owned sessions only); a plugin step or a step with a fallback is marked blocked; the file is retried once with the fallback.
      4. The same step hangs on two files and has no fallback: `BatchAbort`. Restart refused (not owned) or failed (startup stall): `BatchAbort`. The report stays resumable.
      5. `STOP` file in the output folder: the batch ends between files. Rerunning skips files whose sha1 and recipe hash match an `ok` record.
         Outputs: `report.json` (after every file), `report.csv`, `manifest.json` (handoff), `sheet.png` (one snapshot per file). Look at the sheet before reporting.
      
      ## P6. `-script` mode: one ZBrush process per file
      
      Not yet run in ZBrush (launcher and job loop ran offline with a fake process). Test: `live_a05_script_mode.py`.
      
      ```python
      rep = zb_batch.run_folder(files, "/abs/out", params, mode="script")   # ZBrush must not be running
      print(rep["files"][stem]["process"])     # state finished|exited|timeout|startup_stall, ended self|asked_to_quit|terminated
      ```
      
      The command (scene LAST, Maxon quickstart; script args start at `sys.argv.index("-script") + 2`):
      
      ```
      "/Applications/Maxon ZBrush 2026/ZBrush.app/Contents/MacOS/ZBrush" \
          -script <skills>/scenario-zbrush-automation/scripts/zb_batch_job.py --job /abs/out/<stem>/job.json
      ```
      
      `zb_batch_job.py` imports the toolkit by path (then removes it from `sys.path` and `sys.modules`), runs the recipe, writes `job_result.json` after every step, and calls `sys.exit(0|1|2)`. Whether ZBrush quits on `sys.exit` is undocumented: the launcher waits `quit_grace` seconds, then quits it through `zb_launch.stop`. A second `-script` launch while ZBrush runs may hand the script to the running instance (single instance) [verify]: `run_script_job` refuses to launch then.
      
      ## P7. Pressing a plugin without losing the session
      
      Not yet run in ZBrush. Test: `live_a02_maps_mme.py` (MME), `live_a03_batch_three.py` (UV Master, Decimation Master), scenario-zbrush-expert `live_08`, `live_09` (raw presses).
      
      ```python
      def plugin_step(module, func, *args, timeout=300, grace=60, **kw):
          try:
              return {"ok": True, "value": zb_batch.remote_call(module, func, *args, timeout=timeout, **kw)}
          except zb_launch.ZBTimeout:
              s = zb_batch.BridgeSession()
              if s.wait_recovery(grace):
                  return {"ok": False, "outcome": "timeout_recovered"}      # state uncertain: redo from a checkpoint
              ev = zb_batch.evidence("/abs/out/evidence", func)              # look at ev["screenshot"]
              return {"ok": False, "outcome": "hang", "evidence": ev}        # then restart (owned) or ask a human
      ```
      
      | Plugin step                                    | Guard before                                                    | Fallback after a hang                                                            |
      | ---------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- |
      | `uv_master(index)`                             | no levels, faces <= 150k, unique name                           | `uv_native(index)` (Tool > UV Map > Create (Unwrap), 2023+)                      |
      | `decimated_preview(index, path, target_faces)` | checkpoint saved, unique names, pre-process after the last edit | none in ZBrush: skip (file `partial`) or decimate the OBJ outside ZBrush [added] |
      | `mme_create_all(base, maps)`                   | back up first (ESC may lose UVs, MME doc)                       | `normal_map` / `displacement_map` per SubTool                                    |
      | FBX ExportImport, USD Format                   | never unattended                                                | OBJ or `.GoZ` by path                                                            |
      
      ## P8. Maps: normal and displacement
      
      Not yet run in ZBrush. Test: `live_a02_maps_mme.py`.
      
      ```python
      nm = zb_batch.remote_call("zb_plugin_ops", "normal_map", low, "/abs/out/rock_normal_raw.png",
                                size=2048, tangent=True, timeout=600)      # lowest level vs top
      micro = zb_batch.remote_call("zb_plugin_ops", "normal_map", low, "/abs/out/rock_micro_raw.png",
                                   size=2048, level=top_level - 1)         # micro detail only
      fx = zb_batch.fix_normal_map(nm["path"], "/abs/out/rock_normal.png", flip_v=True, flip_green=False)
      st = zb_batch.image_stats(fx["path"])      # size, mean, std, blue_ge_128, uniform
      dm = zb_batch.remote_call("zb_plugin_ops", "displacement_map", low, "/abs/out/rock_disp.tif", size=2048)
      ```
      
      Inside ZBrush: the SubTool must have UVs and levels (the map compares the current level with the highest of the same SubTool); `create_normal_map(w, h, smooth, 0, border, 1000000, True)` (last argument True = tangent); `Tool:Normal Map:Clone NM`; `set_next_filename` + `Texture:Export`. Displacement: `create_displacement_map(...)`, `Tool:Displacement Map:Clone Disp`, `Alpha:Export` [verify both]. Flip V and green are applied on the agent side because they can be checked there; confirm the orientation once in the target app.
      
      ## P9. Decimated preview and Decimation Master options
      
      Not yet run in ZBrush. Test: `live_a03_batch_three.py`.
      
      ```python
      pct = zb_plugin_ops.decimation_percent(points, target_faces=20000)    # = 100 * 10000 / points
      zb_batch.remote_call("zb_plugin_ops", "decimated_preview", source, "/abs/out/rock_preview.obj",
                           target_faces=20000, keep_uvs=None, timeout=1500)
      ```
      
      - The decimation reflects the model at pre-process time: any edit, mask or level change needs a new pre-process (DM doc); `zb_ops.decimate` always pre-processes.
      - Keep polypaint: turn on Keep & Use PolyPainting before pre-processing (off by default, AskZBrush bVX9utHc_ZI [00:01:17]). Keep UVs costs 50 % more memory.
      - Keep polygroups (AskZBrush mRfA_WDvvrg): `Tool:Geometry:Unweld Group Border` [verify label], Freeze Borders on, decimate, `Tool:Polygroups:Auto Groups`, `Tool:Geometry:Weld Points`; divide once on a copy to check for seams.
      - Delete caches between long batches: `zb_plugin_ops.delete_dm_caches()` [verify path and confirmation].
      
      ## P10. The same operation on every SubTool (ZRepeat It)
      
      Not yet run in ZBrush. Test: offline only (`test_zb_plugin_ops.py`); live via `live_a08_names.py` indirectly.
      
      ```python
      rows = zb_batch.remote_call("zb_plugin_ops", "for_each_subtool", "dynamesh", [64],
                                  {"blur": 0}, True, timeout=1800)       # visible SubTools, active restored
      for r in rows:
          print(r["index"], r["faces_before"], r["faces_after"], r["error"])
      ```
      
      Pavlovich's ZRepeat It demo was DynaMesh 64, Blur 0 on every visible SubTool [00:42:53]. Any `zb_ops` or `zb_plugin_ops` function name works; one bridge call runs the whole loop, so give it a timeout for all SubTools, and prefer one call per SubTool when a plugin is involved. The loop runs under `zb_ops.quiet()` (show_actions 0); `freeze=True` (the sixth argument) also wraps it in `zbc.freeze` for deterministic ops, and is refused for plugin steps.
      
      ## P11. Inventory, unique names, rename
      
      Not yet run in ZBrush. Test: `live_a08_names.py`.
      
      ```python
      inv = zb_batch.remote_call("zb_plugin_ops", "inventory")
      print(inv["totals"], inv["duplicate_names"], inv["non_alphanumeric"])
      for s in inv["subtools"]:
          print(s["index"], s["name"], s["points"], s["sdiv_max"], s["uv"], s["visible"], s["folder_name"])
      issues = zb_goz.check_names([s["name"] for s in inv["subtools"]])
      r = zb_batch.remote_call("zb_plugin_ops", "rename_subtool", 2, "Belt_L")   # r["ok"] must be True
      ```
      
      Rename moves the SubTool to the top (MoveUp while enabled), calls `set_tool_path` (renames the top SubTool only), moves it back, and reads the name back. There is no rename API and Tool > SubTool > Rename opens a text prompt. Folders can change the number of presses [verify]; when in doubt, name parts at creation or rename files before import.
      
      ## P12. Export every SubTool with its name
      
      Not yet run in ZBrush. Test: offline (`test_zb_plugin_ops.py`); live pending (add to `live_a08_names.py` once names are proven).
      
      ```python
      r = zb_batch.remote_call("zb_plugin_ops", "export_all", "/abs/out/parts", ".obj", True)   # or ".GoZ"
      for f in r["files"]:
          print(f["index"], f["name"], f["path"], f["bytes"])
      ```
      
      Visible means eye bit 0x1 and, inside a folder, the folder's bit 0x2 (usd-portal; Maxon's own example checks only 0x1). OBJ cannot keep several SubTools with names in one file (Gaboury), FBX can but needs its dialog.
      
      ## P13. GoZ files and the GoZ folder
      
      Reader and writer ran offline (byte-identical rewrite of ZBrush's `DXFStar.GoZ`); push not yet run. Test: `test_zb_goz.py`, `live_a01_import_roundtrip.py` (export and axes), `live_a07_goz_push.py` (push, needs `ALLOW_SHARED=1`).
      
      ```python
      g = zb_goz.read_goz("/abs/out/rock_low.GoZ")          # name, counts, points, faces, uvs, groups, polypaint, mask, maps
      assert g["counts"]["points"] == inv["subtools"][1]["points"]
      zb_goz.write_goz("/abs/in/prop.GoZ", "prop", points, faces, uvs=uvs, polygroups=groups)
      print(zb_goz.app_info("Maya"))                          # flip flags; PATH may be stale
      plan = zb_goz.push_to_zbrush(["/Users/Shared/Pixologic/GoZProjects/Default/prop.GoZ"],
                                   as_subtool=None, dry_run=True)     # dry first; real push writes shared files (backed up)
      ```
      
      Out of ZBrush: export `.GoZ` by path (P12 or `export_subtool`), which skips the GoZ app chooser. Into ZBrush from another program: write the `.GoZ`, list its extension-less path in `GoZBrush/GoZ_ObjectList.txt`, run `GoZBrushFromApp.app`; keep the identifier stable or ZBrush makes a new SubTool instead of updating (GoZ SDK).
      
      ## P14. Round trip with Maya (scenario-maya-pipeline-scripting on the other side)
      
      Not yet run (Maya is not installed on this Mac). Test: none yet; the ZBrush half is P12 and P13.
      
      1. ZBrush: `inventory`, fix names (`check_names`, P11); `export_all(dir, ".obj")` for the lowest level (GoZ semantics) or the level the brief asks for; write `manifest.json` (`zb_batch.write_manifest`) with Export Scale, axes (OBJ Y up) and map conventions.
      2. Maya: scenario-maya-pipeline-scripting imports the OBJs (or `.GoZ` through its `gozMaya` plugin), edits, exports OBJ with the same names and vertex order.
      3. ZBrush: select the matching SubTool (`locate_subtool_by_name`), go to level 1, import the OBJ into it (same topology keeps the levels [verify]); for changed topology import as a new SubTool and `project_all` from the old one instead of answering GoZ's reprojection prompt.
         Maya's GoZ flips Y and Z on the way in and out and flips normal maps vertically (`GoZ_Info.txt`); OBJ exports from ZBrush are Y up already (scenario-zbrush-expert v03 analysis).
      
      ## P15. Round trip with Blender
      
      Not yet run. Test: none yet.
      
      - Files: OBJ both ways (Blender's OBJ importer converts Y up to Z up [added]); or `.GoZ` read and written by `zb_goz` inside Blender's Python (pure Python, no GoB needed).
      - GoB (JoseConseco/GoB, community add-on, installed as a Blender extension): ZBrush GoZ button with Blender as target, polypaint as a color attribute, textures in the public Pixologic folder until pack and unpack (FlippedNormals). GoB creates `GoZApps/Blender` on its first export.
      - FBX from ZBrush into Blender: scale 1000 (FlippedNormals [00:11:21]); needs the FBX dialog, so not unattended.
      
      ## P16. Answer a confirmation with a key-held macro
      
      Not yet run in ZBrush. Test: offline text and install rules (`test_zb_batch.py`); live pending (needs a restart after install and the user's consent).
      
      ```python
      txt = zb_batch.keypress_macro_text("2", "Tool:SubTool:Del All")   # the shipped Delete All pattern
      r = zb_batch.install_macro(txt, "DelAllYes")                      # <Asset Dir>/ZStartup/Macros/AgentHelpers/, restart needed
      # after a restart:
      zb_batch.remote_call("zb_plugin_ops", "press_macro", "DelAllYes")  # tries Macro:AgentHelpers:DelAllYes, then Macro:Macros:...
      ```
      
      Screenshot once to learn which key answers which Note button; Space or Enter triggers the first Note button, which is Cancel for Merge Down (TVeyes 2015).
      
      ## P17. File-drop ZScript into a ZBrush without the bridge
      
      Not yet run in ZBrush. Test: offline text (`test_zb_batch.py`).
      
      ```python
      body = '    [FileNameSetNext,"/abs/out/active.obj"][IPress,"Tool:Export"]'
      open("/abs/jobs/export.txt", "w").write(zb_batch.filedrop_job_text(body, "/abs/jobs/export_done.zvr"))
      print(zb_batch.run_filedrop("/abs/jobs/export.txt", "/abs/jobs/export_done.zvr", timeout=180))
      ```
      
      `open -a ZBrush.app job.txt` hands the script to the running single instance (GoB's macOS pattern); it ends any active ZScript, steals focus, and reports nothing but the files it writes. VarSave with a bare name lands in ZBrush's temp sandbox in 2026 (usd-portal): always an absolute path, and the runner also searches the temp folder.
      
      ## P18. Reports other personas read
      
      Not yet run in ZBrush (built offline from fake runs). Test: `test_zb_batch.py`.
      
      - `report.json`: `batch` (params, recipe hash, counts, restarts, blocked, aborted, session log, csv, manifest, sheet) and `files[stem]` (input, sha1, status, steps with outcome, timeout and seconds, outputs, problems, warnings, input, low and preview audits, normal map stats, hang evidence, attempts).
      - `report.csv`: one row per file for humans.
      - `manifest.json`: what the next app imports and under which conventions.
      - `sheet.png`: canvas snapshots labeled stem, status and low faces (zb_review.contact_sheet).
      - Scene report without a batch: `inventory()` (SubTools, counts, levels, UVs, visibility, duplicate and non-alphanumeric names).
      
      ## P19. Keep ZBrush's interpreter clean; one call for a long deterministic sequence
      
      Not yet run in ZBrush (the generated code runs offline in-process: `tests/code/zbrush-expert/test_zb_launch.py`, class `Hygiene`; `zb_ops` sequence tests in `test_zb_ops.py`). Test: scenario-zbrush-expert `live_06_ops.py` (last section).
      
      ```python
      import zb_launch
      # 1. hygiene: nothing of the toolkit stays in ZBrush between calls (Maxon Style Guide)
      print(zb_launch.hygiene())                 # {"modules": [], "path": []}; else zb_launch.purge()
      r = zb_launch.run("result = zb_ops.stats()", modules=("zb_ops",), full=True)
      print(r.get("hygiene"))                    # {"reloaded": [...]} when a stale copy was set aside
      # 2. many deterministic steps, one bridge call, UI frozen and press feedback off
      steps = [["divide", [1]], ["polish", [5]], ["stats"]]
      res = zb_launch.call("zb_ops", "sequence", steps, timeout=900)
      bad = [x for x in res if not x["ok"]]      # stop_on_error: the first failure ends the list
      ```
      
      - Why: ZBrush runs ONE Python interpreter for the session; the script's folder is not on `sys.path`, so `import helper` fails, and what a script leaves in `sys.path` or `sys.modules` stays until restart (SDK quickstart "Virtual Machine"; Style Guide; ex_mod_curve_lightning pops its modules after import). `zb_launch.run` and `zb_batch.remote_call` import by path, restore `sys.path`, `sys.modules`, stdout, stderr and the working directory, and pop every toolkit module; the bridge server runs the same restore after an error.
      - Never send raw code that does `sys.path.append(...)` and imports toolkit files; never assign to `sys`, `math` or `random`; never `os.chdir`.
      - `sequence` refuses plugin presses (UV Master, Decimation Master, Multi Map Exporter), canvas exports and strokes: those need a live UI and their own call with a short timeout (P7). After a frozen block the canvas is redrawn (`update(redraw_ui=True)`) before any snapshot.
      
    • sources.md 10.5 KB
      # Sources of scenario-zbrush-automation
      
      Each line: source, who, credential, where, what it is best for, best timestamps. Notes live in `notes/scripting/` (digests `_digest_zscript_goz_automation.md`, `_digest_python_sdk.md`), `notes/versions/`, and for the plugins `notes/maps-export/`, `notes/retopology-uv/`, `notes/pipeline/`. Version facts: `sources/zbrush-version-deltas.md`.
      
      ## Revisions
      
      - 2026-09-24 v1: initial build (snapshot `skills/_versions/zbrush-automation v1 2026-09-24/`).
      - 2026-09-24 v2, refactor from the blind grade Z7 ("missing from both" and the gaps of both answers): cgside's Auto Groups then ZRemesher Keep Groups recipe (MyhwQvkcnwI 00:01:43 to 00:02:51, 00:08:38 to 00:09:42) as `make_low(auto_groups=True)` and on in `DEFAULT_PARAMS`; the save before ProjectAll (00:02:19) enforced by the lead's safe `zb_ops.project_all` and passed by the recipe (`{stem}_preproject.ztl`); morph target, layer and Dist 0.1 per pass (FlippedNormals Zp07GW3rND0, Drust nxMYYsyJt3o); zb_ops gates (volume, watertightness, spikes) read by `check_step`; interpreter hygiene through `zb_launch.build_code` (SDK Style Guide) and P19; `freeze` and `show_actions(0)` guidance, `for_each_subtool(freeze=True)`, SubTool loops under `quiet()` (SDK GUI reference). Offline tests only (56 pass); not yet run in ZBrush (live_a02 updated).
      
      ## Evidence produced on this Mac (highest weight)
      
      - `/Applications/Maxon ZBrush 2026/ZResources/DXFStar.GoZ`, a GoZ file written by ZBrush 2026.2.1: parsed and rewritten byte for byte by `zb_goz` (`tests/code/zbrush-automation/test_zb_goz.py`). Best for: the GoZ binary layout.
      - `/Users/Shared/Pixologic/` (GoZ 1.01 folder, 2026-09-24 listing): `GoZApps/Maya/GoZ_Info.txt` (Y and Z flips, normal map V flip), `GoZ_Config.txt` (stale Maya 2017 path), `GoZScript.mel` (command port 5555, `gozMaya` plug-in), `GoZBrush/GoZ_Config.txt`, `GoZ_ProjectPath.txt`. Best for: round-trip conventions and what the protocol files look like.
      - `/Applications/Maxon ZBrush 2026/ZData/ExportTemplates/GoZ Complete Binary.GoZ`: the block order ZBrush writes (header, mesh, flags, subdiv, points, faces, UVs, MRGB, mask, groups, creases, map paths, end).
      - ZBrush Activity logs, `~/Library/Preferences/Maxon/ZBrush_03C27D49/Logs/Activity/Activity 2026-09-24-05-20-14.txt` and later. Best for: canonical item paths (the path oracle) and the startup stall.
      - Project README, "Agent bridge into ZBrush" and the open startup stall (2026-09-24). Best for: what is proven, the kill -9 and relaunch history.
      - scenario-zbrush-expert tests `tests/code/zbrush-expert/v01..v03_*` and the offline OBJ analysis. Best for: dialog-free exports, OBJ axes (Y up, +Z toward the front camera).
      - This skill's offline tests (`tests/code/zbrush-automation/offline_results.json`). Best for: what the runner, recipe, reports and GoZ helpers do, against fakes.
      
      ## Maxon official
      
      - ZBrush Python SDK 2026.1 manuals (Maxon SDK team): quickstart, environment, libraries, https://developers.maxon.net/docs/zbrush/py/2026_1_0/manuals/index.html; local `/Applications/Maxon ZBrush 2026/Documentation/python-api`. Best for: `-script` (scene last), startup plugins, macro recording, shared interpreter.
      - SDK API references (GUI, modeling, system) and the stub `api/zbrush/commands.py` (dated 2025-10-28): https://developers.maxon.net/docs/zbrush/py/2026_1_0/api/zbr_cmds_gui.html, .../zbr_cmds_modeling.html, .../zbr_cmds_system.html. Best for: `set_next_filename`, `has_next_filename`, `create_normal_map(local_coordinates)`, blocking calls, `press_key` and `merge_undo` status.
      - SDK examples (Ferdinand Hoppe, Javier Edo, Jan Wesbuer): `examples/modeling/ex_mod_subtool_export.py` (command-line batch export), `ex_sys_timeline_camera.py`, `ZData/Python/init.py`, https://developers.maxon.net/docs/zbrush/py/2026_1_0/examples/index.html. Best for: the `-script` job template.
      - Migrating ZScript and `ZData/Python/zscript_command_mapping.json`, https://developers.maxon.net/docs/zbrush/py/2026_1_0/manuals/migrating_zscript.html. Best for: reading any macro as Python.
      - Maxon developer forum, ZBrush SDK category (ferdinand, m_adam, i_mazlov, davide; users pablo31, pocacola, wtz2025), https://developers.maxon.net/forum/category/55/zbrush-sdk. Best for: FBX dialog (2025-03-11), background export (2025-02-25), `press_key` failures and the macro workaround (2026-04-21), startup `init.py` in the Asset Directory (2026-04-15), no rename API (2026-09-10).
      - ZScript command reference, https://help.maxon.net/zbr/en-us/Content/html/user-guide/customizing-zbrush/zscripting/command-reference/command-reference.html. Best for: FileNameSetNext, IKeyPress, SubTool status bits, Mesh3DGet.
      - ZScript interfaces, plugins, macros, user content, https://help.maxon.net/zbr/en-us/Content/html/user-guide/customizing-zbrush/zscripting/interfaces/zplugin-interface/zplugin-interface.html. Best for: one active ZScript, macro folder and 8-character rule, read-only install since 2026.1.
      - ZScripting manual, https://help.maxon.net/zbr/en-us/Content/html/user-guide/customizing-zbrush/zscripting/technical/technical.html. Best for: learning paths from recordings, Ctrl+hover, memory blocks.
      - Shipped 2026.2.1 macros, `/Applications/Maxon ZBrush 2026/ZData/Macros/` (authors not named). Best for: `[IKeyPress,'2',...]` answers, `PopUp:` items, save and restore of user state.
      - GoZ documentation, https://help.maxon.net/zbr/en-us/Content/html/user-guide/zbrush-other-programs/goz/goz.html; GoZ SDK PDF, https://developers.maxon.net/docs/zbrush/goz_sdk.pdf. Best for: lowest level, unique names, folder protocol, `GoZBrushFromApp`.
      - Plugin documentation (local copies): `sources/docs/topology-export__decimation-master.md`, `topology-export__uv-master-uv-map.md`, `topology-export__multi-map-exporter-maps.md`, `topology-export__export-goz-fbx-scale.md`. Best for: plugin options, limits (UV Master 150k), cache and naming pitfalls, MME save dialog and bit depths, FBX presets.
      - Release notes and What's New (`sources/docs/automation__zbrush-release-notes-2022-2026-maxon-kb.md`, `automation__zbrush-whats-new-2021-2026-help.md`). Best for: 2026.0 Python, 2026.1 Asset Directory, 2026.2.0 UV Master polygroups fix and Substance Bridge.
      
      ## Experts and practitioners
      
      - marcus_civis (ZBrushCentral, long-time ZScript and plugin author, credential unverified), threads 2009 to 2019, https://www.zbrushcentral.com/c/zscripting-help/14. Best for: one active script, `[If,1,...]` launch scripts, startup macros. Posts 2013-06-20, 2014-02-17, 2019-04-22.
      - ijacobs, TVeyes, dargelos, danvas, BigRoyNL (ZBrushCentral users, 2013 to 2024). Best for: the plugin handover (ijacobs 2013-02-21), Note dialogs (TVeyes 2015-04-11, danvas 2017-03-10), no success signal (BigRoyNL 2024-03-07).
      - cgside (3D environment artist), "ZSCRIPT in Zbrush | Automate tasks" (2022), https://www.youtube.com/watch?v=MyhwQvkcnwI. Best for: a production remesh-and-project batch, save before ProjectAll [00:02:19], the Decimation Master limit [00:06:56], target math [00:08:04].
      - Michael Pavlovich (Director of Character, Weapon and Vehicle Art at Certain Affinity; Maxon ZBrushLIVE host), Workshop 45 (2018), https://www.youtube.com/watch?v=9Acq70iPaVI. Best for: ZRepeat It [00:37:30], DynaMesh 64 demo [00:42:53], editing recordings [01:02:07], Ctrl+Alt+click hotkeys [01:04:24].
      - MadPonyInteractive (ZBrush plugin author, credential unverified), ZScript Fundamentals (2019): What is ZScript https://www.youtube.com/watch?v=DnLlDJxfxHs [00:00:35], [00:02:16]; IGet/ISet https://www.youtube.com/watch?v=vnaNrBzT1UQ; Memory Variables https://www.youtube.com/watch?v=_YuSnHxJtsI; VarSave https://www.youtube.com/watch?v=_I5i60PNvj4; NoteBar https://www.youtube.com/watch?v=1L77Sssism0; Note Interface https://www.youtube.com/watch?v=EEEyBHE5UrE; Selecting SubTools https://www.youtube.com/watch?v=DNCJ_2vH9iU [00:01:35]; Moving SubTools https://www.youtube.com/watch?v=9-5AkwkZqAo; Renaming SubTools https://www.youtube.com/watch?v=w7RxSx1G1hQ [00:00:34]; Tools and Folders https://www.youtube.com/watch?v=Fns6YoDE1j4 [00:02:51]; Loop https://www.youtube.com/watch?v=Iwg5i4pWAdE; Routines https://www.youtube.com/watch?v=bBNrtIsHfiE; IButton https://www.youtube.com/watch?v=cfWdG_UPx90; Macros and Plugins https://www.youtube.com/watch?v=_1wAuQ3IUf4; Nudge Axis https://www.youtube.com/watch?v=a9YEqjF0bkU and https://www.youtube.com/watch?v=xv-DDf4EckE [00:03:27]. Best for: ZScript semantics, names and IDs, dialog-free saves.
      - Paul Gaboury (Pixologic and Maxon ZBrush staff), #AskZBrush export all SubTools (2022), https://www.youtube.com/watch?v=echGh0tZEts [00:00:34], [00:01:06]. Best for: why FBX needs a dialog and OBJ loses names.
      - Joseph Drust (Pixologic), #AskZBrush: decimation keeping polygroups https://www.youtube.com/watch?v=mRfA_WDvvrg [00:02:35]-[00:05:04]; normal maps https://www.youtube.com/watch?v=n0qqpwvn-jA [00:01:14]; small-detail bakes https://www.youtube.com/watch?v=2zDAtaQqwh8 [00:02:53]. Best for: plugin recipes and map level rules.
      - Ian Robinson (senior ZBrush instructor, Maxon): #AskZBrush Keep Polypaint https://www.youtube.com/watch?v=bVX9utHc_ZI [00:01:17]; Asset Directory https://www.youtube.com/watch?v=w7lI2RCK2aI [00:02:12]; What's New 2026.1 with Rodolfo Silva https://www.youtube.com/watch?v=rbikNPggIeo [00:12:41]. Best for: 2026 folders and plugin load order.
      - Maxon, Fall Release 2025 (2026.0), https://www.youtube.com/watch?v=DdKubRp7C4A [00:01:08]. Best for: dating Python support.
      - FlippedNormals (Morten Jaeger, Henning Sanden, character artists and educators), ZBrush to Blender with GoB (2020), https://www.youtube.com/watch?v=2-pFspCbykk [00:04:02], [00:06:01], [00:08:15], [00:11:21]. Best for: GoB round trips, lowest level, FBX scale 1000.
      - Laura Gallagher (Outgang; former Lead Character Artist, Eidos Montreal and Guerrilla Games), units and scale (2021), https://www.youtube.com/watch?v=EXjfH_X2hkM [00:24:03], [00:27:16], [00:28:23], [00:33:40]. Best for: Export Scale and offsets in round trips.
      - Community bridges on GitHub: baborub/usd-portal (tested on 2026.2), JoseConseco/GoB, LumaPictures/gozbruh, nlapinski/ZBrush-Command-Port, newsbubbles/zbrush-mcp (unverified), https://github.com/baborub/usd-portal and others (note `automation__community-bridges-remote-control`). Best for: file-drop control, `.GoZ` by path, folder-aware visibility, the GoZ block tags, VarSave sandbox.
      
      ## Sister skills used
      
      - scenario-zbrush-expert (this project): zb_launch, zb_ops, zb_audit, zb_review, zb_stroke; the bridge protocol and 2026 traps.
      - scenario-maya-pipeline-scripting (Maya Expert Skills project): the batch-runner shape (one bad file must not kill a batch) and the Maya side of round trips.
      
  • scripts
    • zb_batch.py 52.6 KB
      #!/usr/bin/env python3
      """
      zb_batch: run a recipe over a folder of meshes in ZBrush 2026, one file at a time, with a
      timeout per step, restart of ZBrush after a hang, resume, and a JSON + CSV + contact-sheet
      report. Agent side (system python3); ZBrush-side work is zb_ops (scenario-zbrush-expert) and
      zb_plugin_ops (this skill).
      
          import sys; sys.path.insert(0, "<skills/scenario-zbrush-automation/scripts>")
          import zb_batch
          files = zb_batch.discover("/abs/in", ("*.obj",))
          print(zb_batch.plan(files, "/abs/out", {"dyn_res": 256, "zr_target_k": 5}))   # dry run
          rep = zb_batch.run_folder(files, "/abs/out", {"dyn_res": 256, "zr_target_k": 5})
          # open /abs/out/sheet.png, read /abs/out/report.json
      
          python3 zb_batch.py plan|run --in /abs/in --out /abs/out [--params p.json] [--mode script]
      
      Two execution modes:
        bridge  one ZBrush with the agent bridge (zb_launch.start), each step a separate
                main-thread call with its own timeout. A timeout does not cancel the call; the
                runner pings for `grace_s`, and if ZBrush stays unresponsive (plugin that keeps
                control, modal note, dialog) it saves evidence, restarts ZBrush with zb_launch
                and goes on with the next file. A plugin step that hung is marked blocked and its
                fallback is used from then on (UV Master -> native Unwrap). It never kills a
                ZBrush it did not start.
        script  one ZBrush process per file: ZBrush -script zb_batch_job.py --job job.json
                (Maxon quickstart). Isolation by process; the job writes its result after each
                step; the runner kills the process on timeout. Quit-on-exit [verify live_a05].
      
      Status: the runner, recipe, resume, reports, GoZ helpers and ZScript generators run offline
      against fakes (tests/code/zbrush-automation). NOT YET RUN IN ZBRUSH as a batch.
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import csv
      import glob
      import hashlib
      import json
      import os
      import re
      import shutil
      import subprocess
      import sys
      import time
      import unicodedata
      
      HERE = os.path.dirname(os.path.abspath(__file__))
      LEAD = os.path.abspath(os.path.join(HERE, "..", "..", "scenario-zbrush-expert", "scripts"))
      for _d in (HERE, LEAD):
          if _d not in sys.path:
              sys.path.insert(0, _d)
      import zb_launch  # noqa: E402  (lead toolkit: start, ping, stop, send, diagnose)
      import zb_goz  # noqa: E402,F401  (re-exported for callers: zb_batch.zb_goz.read_goz)
      from zb_batch_job import resolve_refs, write_json_atomic  # noqa: E402
      
      JOB_PY = os.path.join(HERE, "zb_batch_job.py")
      LEAD_MODULES = {"zb_ops", "zb_stroke", "zb_review", "zb_audit"}
      OWN_MODULES = {"zb_plugin_ops", "zb_batch_job", "zb_goz"}
      
      
      class BatchAbort(RuntimeError):
          """Stop the batch; the report stays resumable."""
      
      
      # --------------------------------------------------------------------------------------------
      # Files and names
      # --------------------------------------------------------------------------------------------
      
      def discover(folder, patterns=("*.obj",), recursive=False):
          """Sorted absolute paths whose names match the patterns, case-insensitively (rock.OBJ
          and rock.obj both match *.obj)."""
          import fnmatch
          folder = os.path.abspath(folder)
          pats = [p.lower() for p in patterns]
          out = []
          for root, dirs, files in os.walk(folder):
              dirs[:] = sorted(d for d in dirs if not d.startswith((".", "_")))
              out += [os.path.join(root, f) for f in files
                      if any(fnmatch.fnmatch(f.lower(), p) for p in pats)]
              if not recursive:
                  break
          return sorted(out)
      
      
      def sanitize_name(s, max_len=40):
          """ASCII letters, digits and underscores, not starting with a digit: safe as a ZBrush
          tool name, a GoZ name (no spaces or symbols, GoZ docs) and a Decimation Master cache
          key [added]."""
          s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii")
          s = re.sub(r"[^A-Za-z0-9_]+", "_", s).strip("_") or "mesh"
          if s[0].isdigit():
              s = "m_" + s
          return s[:max_len]
      
      
      def unique_stems(paths, max_len=40):
          """One unique sanitized stem per path, in order (case-insensitive: rock, Rock -> rock,
          Rock_2). Imported tools take the file name, and GoZ plus Decimation Master key on it."""
          seen, out = {}, []
          for p in paths:
              base = sanitize_name(os.path.splitext(os.path.basename(p))[0], max_len)
              stem, n = base, 1
              while stem.lower() in seen:
                  n += 1
                  stem = f"{base[:max_len - len(str(n)) - 1]}_{n}"
              seen[stem.lower()] = p
              out.append(stem)
          return out
      
      
      def file_sha1(path, block=1 << 20):
          h = hashlib.sha1()
          with open(path, "rb") as fh:
              while True:
                  b = fh.read(block)
                  if not b:
                      break
                  h.update(b)
          return h.hexdigest()
      
      
      def stage_input(src, stage_dir, stem):
          """Copy (never move) the input to <stage>/<stem><ext> so the ZBrush tool gets a unique,
          clean name. Reuses an identical earlier copy."""
          ext = os.path.splitext(src)[1].lower()
          os.makedirs(stage_dir, exist_ok=True)
          dst = os.path.join(stage_dir, stem + ext)
          if not (os.path.exists(dst) and os.path.getsize(dst) == os.path.getsize(src)
                  and file_sha1(dst) == file_sha1(src)):
              shutil.copy2(src, dst)
          return dst
      
      
      def read_json(path, default=None):
          try:
              with open(path) as fh:
                  return json.load(fh)
          except (OSError, ValueError):
              return default
      
      
      # --------------------------------------------------------------------------------------------
      # Remote calls (both skills' script folders on sys.path only while importing)
      # --------------------------------------------------------------------------------------------
      
      def build_remote_code(code, modules=()):
          """Source sent to ZBrush: the lead's hygiene prelude (zb_launch.build_code) importing the
          named toolkit modules by path from scenario-zbrush-automation and scenario-zbrush-expert. After the call
          sys.path, sys.modules, stdout and the working directory are as they were and every
          toolkit module is popped, also when the call raised (the bridge server runs the same
          restore hook): Maxon Style Guide, shared interpreter."""
          for m in modules:
              if not m.isidentifier() or m not in LEAD_MODULES | OWN_MODULES:
                  raise ValueError(f"unknown toolkit module {m!r}")
          return zb_launch.build_code(code, tuple(modules), dirs=[HERE, LEAD])
      
      
      def remote_run(code, modules=(), port=zb_launch.DEFAULT_PORT, timeout=120):
          reply = zb_launch.send(build_remote_code(code, tuple(modules)), "main", port, timeout)
          return zb_launch.decode_reply(reply)
      
      
      def remote_call(module, func, *args, port=zb_launch.DEFAULT_PORT, timeout=120, **kwargs):
          """module.func(*args, **kwargs) inside ZBrush on the main thread; JSON-able args."""
          code = (f"result = {module}.{func}(*_zb_json.loads({json.dumps(list(args))!r}), "
                  f"**_zb_json.loads({json.dumps(kwargs)!r}))")
          mods = [module] + (["zb_ops"] if module != "zb_ops" else [])
          return remote_run(code, mods, port, timeout)
      
      
      # --------------------------------------------------------------------------------------------
      # Session: one ZBrush with the bridge, restarted after a hang
      # --------------------------------------------------------------------------------------------
      
      def screen_locked():
          return zb_launch.screen_locked()
      
      
      def evidence(out_dir, tag):
          """What the agent looks at after a hang: zb_launch.diagnose(), a screenshot when the
          screen is unlocked (screencapture -x works here; under the lock it shows only the lock
          screen, README), and ZBrush's OS windows via System Events (ZBrush Notes are drawn
          inside the canvas and are NOT OS windows: only the screenshot shows them)."""
          os.makedirs(out_dir, exist_ok=True)
          ev = {"diagnose": zb_launch.diagnose(), "screen_locked": screen_locked()}
          if ev["screen_locked"] is False:
              shot = os.path.join(out_dir, f"hang_{tag}_{time.strftime('%H%M%S')}.png")
              r = subprocess.run(["screencapture", "-x", shot], capture_output=True, timeout=30)
              ev["screenshot"] = shot if r.returncode == 0 and os.path.exists(shot) else None
              ev["windows"] = os_windows()
          return ev
      
      
      def os_windows():
          """Names of ZBrush's OS-level windows and their buttons (file dialogs, save prompts).
          Needs Accessibility (allowed on this Mac, README) and an unlocked session."""
          script = ('tell application "System Events"\n if exists process "ZBrush" then\n'
                    '  tell process "ZBrush"\n   set out to ""\n   repeat with w in windows\n'
                    '    set out to out & (name of w as text) & " | "\n    try\n'
                    '     repeat with b in buttons of w\n'
                    '      set out to out & "[" & (name of b as text) & "] "\n     end repeat\n'
                    '    end try\n    set out to out & linefeed\n   end repeat\n   return out\n'
                    '  end tell\n end if\nend tell\nreturn ""')
          try:
              r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=20)
              return [ln for ln in (r.stdout or "").splitlines() if ln.strip()]
          except (OSError, subprocess.TimeoutExpired):
              return None
      
      
      class BridgeSession:
          """One ZBrush with the bridge. owned=True only when this session spawned it: a ZBrush
          that was already running (the user's session, maybe with unsaved work) is never
          restarted or quit by the batch."""
      
          def __init__(self, port=zb_launch.DEFAULT_PORT, start_timeout=180, max_restarts=3,
                       hang_abort=2):
              self.port = port
              self.start_timeout = start_timeout
              self.max_restarts = max_restarts
              self.hang_abort = hang_abort
              self.owned = False
              self.restarts = 0
              self.files_since_restart = 0
              self.blocked = {}          # step name -> file stem where it hung
              self.hangs = {}            # step name -> count
              self.version = None
              self.log = []
      
          def call(self, module, func, args, kwargs, timeout):
              return remote_call(module, func, *args, port=self.port, timeout=timeout, **kwargs)
      
          def alive(self, timeout=5):
              try:
                  self.version = zb_launch.ping(self.port, timeout)["version"]
                  return True
              except zb_launch.ZBError:
                  return False
      
          def ensure(self):
              if zb_launch.port_open(self.port) and self.alive(10):
                  return {"status": "alive", "owned": self.owned}
              if zb_launch.is_running():
                  raise BatchAbort("ZBrush is running without the bridge on this port: save and quit "
                                   "it, then rerun (ZBrush is single-instance)")
              try:
                  r = zb_launch.start(self.port, timeout=self.start_timeout)
              except zb_launch.ZBError as e:
                  raise BatchAbort(f"ZBrush did not start with the bridge: {e}")
              self.owned = r.get("status") == "started" or self.owned
              self.version = r.get("version")
              self.files_since_restart = 0
              self.log.append({"t": time.time(), "event": "start", "result": r})
              return r
      
          def wait_recovery(self, grace_s=60, every=5):
              """After a timeout: does the main thread come back (the call merely ran long)?"""
              end = time.time() + grace_s
              while time.time() < end:
                  if self.alive(every):
                      return True
                  time.sleep(1)
              return False
      
          def restart(self, reason):
              if not self.owned:
                  raise BatchAbort(f"{reason}; ZBrush was not started by this batch, so it is left "
                                   "running for a human to inspect")
              if self.restarts >= self.max_restarts:
                  raise BatchAbort(f"{reason}; {self.restarts} restarts already (max {self.max_restarts})")
              self.restarts += 1
              stop = zb_launch.stop(save_to=None, port=self.port, force=True)
              self.log.append({"t": time.time(), "event": "stop", "reason": reason, "result": stop})
              if stop.get("running_after"):
                  raise BatchAbort(f"{reason}; ZBrush did not quit: {stop}")
              self.owned = False
              r = self.ensure()
              self.owned = True
              return {"reason": reason, "stop": stop, "start": r}
      
          def close(self):
              if self.owned and zb_launch.is_running():
                  return zb_launch.stop(save_to=None, port=self.port, force=True)
              return None
      
      
      # --------------------------------------------------------------------------------------------
      # Recipe (Z7 by default): data, so bridge and -script modes run the same steps
      # --------------------------------------------------------------------------------------------
      
      DEFAULT_PARAMS = {
          "dyn_res": 256, "dyn_blur": None,                       # the brief decides; [added] defaults
          # cgside (MyhwQvkcnwI 00:08:38 to 00:09:42): Auto Groups, then ZRemesher Keep Groups, so
          # the low follows the parts. Set zr_auto_groups False when the input's groups matter.
          "zr_target_k": 5.0, "zr_adaptive": None, "zr_keep_groups": True, "zr_auto_groups": True,
          "uv_polygroups": None, "uv_symmetry": None,
          "divide_max": 6, "divide_max_faces": 8_000_000,
          "normal_map": True, "map_size": 2048, "map_flip_v": True, "map_flip_green": False,
          "preview_target_faces": 20000,                           # None: no decimated preview
          "snapshot": True, "save_ztl": True, "export_goz": False,
      }
      
      # Base timeouts in seconds per step [added]; the runner raises them to 4 x the slowest
      # observed run, up to 10 x base.
      TIMEOUTS = {"import": 180, "dynamesh": 300, "save": 120, "make_low": 900, "uv": 600,
                  "export": 120, "project": 1800, "normal_map": 600, "snapshot": 120,
                  "preview": 1500, "memory": 30}
      
      
      def step(name, module, func, args=(), kwargs=None, timeout=None, critical=True, plugin=False,
               fallback=None, kind=None):
          return {"name": name, "module": module, "func": func, "args": list(args),
                  "kwargs": dict(kwargs or {}), "timeout": timeout or TIMEOUTS.get(kind or name, 300),
                  "critical": critical, "plugin": plugin, "fallback": fallback}
      
      
      def default_recipe(params):
          """Import -> DynaMesh -> checkpoint -> Duplicate + Auto Groups + ZRemesher Keep Groups
          (cgside) -> UV Master (fallback: native Unwrap) -> low OBJ -> Divide + Project All (a
          versioned save right before the first Project All, which can crash: cgside; morph
          target, layer, Dist 0.1 through zb_ops.project_all) -> checkpoint -> tangent normal map
          -> canvas snapshot -> Decimation Master preview LAST (plugin that may keep control, after
          every valuable output is on disk). The pre-projection save is made even when save_ztl is
          False: Project All never runs without one."""
          p = params
          r = [step("memory", "zb_plugin_ops", "memory", critical=False),
               step("import", "zb_plugin_ops", "import_mesh", ["$c.staged"],
                    {"expect_points": "$c.input_points", "expect_faces": "$c.input_faces"}),
               step("dynamesh", "zb_ops", "dynamesh", ["$p.dyn_res"], {"blur": "$p.dyn_blur"})]
          if p.get("save_ztl"):
              r.append(step("save_dyn", "zb_ops", "save_ztl", ["{out}/{stem}_dyn.ztl"], kind="save"))
          r.append(step("make_low", "zb_plugin_ops", "make_low", ["$p.zr_target_k"],
                        {"adaptive": "$p.zr_adaptive", "keep_groups": "$p.zr_keep_groups",
                         "auto_groups": "$p.zr_auto_groups"}))
          native = step("uv_native", "zb_plugin_ops", "uv_native", ["$c.low"],
                        {"symmetry": "$p.uv_symmetry"}, kind="uv")
          r.append(step("uv", "zb_plugin_ops", "uv_master", ["$c.low"],
                        {"polygroups": "$p.uv_polygroups", "symmetry": "$p.uv_symmetry"},
                        plugin=True, fallback=native))
          r.append(step("export_low", "zb_plugin_ops", "export_subtool", ["$c.low", "{out}/{stem}_low.obj"],
                        kind="export"))
          if p.get("export_goz"):
              r.append(step("export_goz", "zb_plugin_ops", "export_subtool",
                            ["$c.low", "{out}/{stem}_low.GoZ"], kind="export", critical=False))
          if p.get("normal_map"):
              r.append(step("project", "zb_plugin_ops", "divide_project", ["$c.low", "$c.source"],
                            {"max_levels": "$p.divide_max", "max_faces": "$p.divide_max_faces",
                             "checkpoint": "{out}/{stem}_preproject.ztl"}))
              if p.get("save_ztl"):
                  r.append(step("save_proj", "zb_ops", "save_ztl", ["{out}/{stem}_proj.ztl"], kind="save"))
              r.append(step("normal_map", "zb_plugin_ops", "normal_map",
                            ["$c.low", "{out}/{stem}_normal_raw.png"], {"size": "$p.map_size"}))
          if p.get("snapshot"):
              r.append(step("snapshot", "zb_review", "snapshot", ["{out}/{stem}_view.png"],
                            {"matcap": "MatCap Gray"}, critical=False))
          if p.get("preview_target_faces"):
              r.append(step("preview", "zb_plugin_ops", "decimated_preview",
                            ["$c.source", "{out}/{stem}_preview.obj"],
                            {"target_faces": "$p.preview_target_faces"}, critical=False, plugin=True))
          return r
      
      
      def recipe_hash(recipe, params):
          blob = json.dumps({"recipe": recipe, "params": params}, sort_keys=True, default=repr)
          return hashlib.sha1(blob.encode()).hexdigest()[:12]
      
      
      # --------------------------------------------------------------------------------------------
      # Agent-side checks and post-processing
      # --------------------------------------------------------------------------------------------
      
      def _exists(path):
          return bool(path) and os.path.exists(path) and os.path.getsize(path) > 0
      
      
      def step_gates(value):
          """The zb_ops gate dicts in a step result: its own "gate" (dynamesh, project_all,
          divide_project's merged gate) and those of nested op results (make_low's zremesher,
          decimated_preview's decimate)."""
          v = value if isinstance(value, dict) else {}
          out = [v["gate"]] if isinstance(v.get("gate"), dict) else []
          for key in ("zremesher", "decimate", "dynamesh", "project"):
              sub = v.get(key)
              if isinstance(sub, dict) and isinstance(sub.get("gate"), dict):
                  out.append(sub["gate"])
          return out
      
      
      def check_step(name, value, params):
          """Problems (the file is not done) and warnings (look at it) for one step result. The
          zb_ops gates (volume and watertightness, projection spikes) feed both lists."""
          probs, warns = [], []
          v = value if isinstance(value, dict) else {}
          for g in step_gates(v):
              probs += list(g.get("problems", []))
              warns += list(g.get("warnings", []))
          if v.get("repair") and (probs or warns):
              warns.append(f"{name}: {v['repair']}")
          if name == "import":
              warns += v.get("warnings", [])
              if not (v.get("stats") or {}).get("faces"):
                  probs.append("imported tool has no faces")
          elif name == "dynamesh" and not v.get("remeshed"):
              probs.append("DynaMesh did not change the mesh")
          elif name == "make_low":
              zr = v.get("zremesher") or {}
              rt = zr.get("ratio_to_target")
              if rt is None:
                  probs.append("ZRemesher returned no face count")
              elif not 0.5 <= rt <= 1.5:
                  warns.append(f"ZRemesher gave {zr.get('faces_after')} faces for target "
                               f"{zr.get('target')} (ratio {rt:.2f}); Adapt off hits counts closer")
          elif name in ("uv", "uv_native"):
              bb = v.get("uv_bbox")
              if not bb:
                  probs.append("no UVs after unwrap")
              elif min(bb) < -1e-4 or max(bb) > 1 + 1e-4:
                  warns.append(f"UV bbox {bb} outside 0..1")
          elif name == "project":
              for i, lv in enumerate(v.get("per_level", [])):
                  if lv.get("ratio") and not 3.5 <= lv["ratio"] <= 4.5:
                      warns.append(f"level {i + 1}: Divide ratio {lv['ratio']:.2f}, expected 4")
              if v.get("top_faces") and v.get("source_faces") and v["top_faces"] < 0.5 * v["source_faces"]:
                  warns.append(f"top level {v['top_faces']} faces < half the source "
                               f"{v['source_faces']}: detail will be lost (raise divide_max)")
          elif name == "preview":
              d = (v.get("decimate") or {})
              if d.get("faces_after") and params.get("preview_target_faces"):
                  r = d["faces_after"] / params["preview_target_faces"]
                  if not 0.5 <= r <= 2.0:
                      warns.append(f"preview has {d['faces_after']} faces for target "
                                   f"{params['preview_target_faces']}")
          for key in ("path", "file"):
              if isinstance(v.get(key), str) and v.get(key).lower().endswith((".obj", ".png", ".ztl", ".goz")):
                  if not _exists(v[key]):
                      probs.append(f"{name}: {v[key]} missing or empty")
          ex = v.get("export")
          if isinstance(ex, dict) and ex.get("path") and not _exists(ex["path"]):
              probs.append(f"{name}: {ex['path']} missing or empty")
          return probs, warns
      
      
      def png_size(path):
          with open(path, "rb") as fh:
              head = fh.read(24)
          if head[:8] != b"\x89PNG\r\n\x1a\n":
              raise ValueError(f"not a PNG: {path}")
          import struct
          return list(struct.unpack(">II", head[16:24]))
      
      
      def image_stats(path):
          """Mean and spread per channel, and the share of pixels with blue >= 128 (a tangent
          normal map points mostly out of the surface: blue high) [added thresholds]."""
          from PIL import Image
          import numpy as np
          with Image.open(path) as im:
              a = np.asarray(im.convert("RGB")).astype(np.float32)
          mean = a.reshape(-1, 3).mean(axis=0)
          std = a.reshape(-1, 3).std(axis=0)
          return {"size": [int(a.shape[1]), int(a.shape[0])], "mean": [round(float(x), 2) for x in mean],
                  "std": [round(float(x), 2) for x in std],
                  "blue_ge_128": round(float((a[:, :, 2] >= 128).mean()), 4),
                  "uniform": bool(std.max() < 1.0)}
      
      
      def fix_normal_map(raw, out, flip_v=True, flip_green=False):
          """Agent-side orientation fix, where it can be checked: flip V (ZBrush maps are upside
          down for other apps: MME's Flip V switch, NORMAL_MAP_FLIP_VERT in Maya's GoZ_Info
          [verify on the first file]) and invert green for DirectX engines such as Unreal
          [added]. The raw file stays."""
          from PIL import Image, ImageOps
          with Image.open(raw) as im:
              im = im.convert("RGB")
              if flip_v:
                  im = ImageOps.flip(im)
              if flip_green:
                  r, g, b = im.split()
                  im = Image.merge("RGB", (r, ImageOps.invert(g), b))
              os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
              im.save(out)
          return {"raw": raw, "path": out, "flip_v": flip_v, "flip_green": flip_green}
      
      
      def _audit(path, profile, **kw):
          try:
              import zb_audit
          except ImportError as e:            # numpy missing
              return {"error": f"zb_audit unavailable: {e}"}
          rep = zb_audit.audit(path)
          keep = ("points", "faces", "triangles", "quads", "ngons", "quads_pct", "tris_equiv",
                  "boundary_loops", "non_manifold_edges", "shells", "size", "center", "has_uvs",
                  "uv_bbox", "poles_6plus", "volume")
          out = {k: rep.get(k) for k in keep}
          out["boundary_loops"] = len(rep.get("boundary_loops") or [])
          out["verdict"] = zb_audit.verdict(rep, profile, **kw)
          return out
      
      
      def post_process(rec, params):
          """Audits and map fixes on the agent side, after the ZBrush steps."""
          outs = rec["outputs"]
          probs, warns = rec["problems"], rec["warnings"]
          inp = rec.get("input_audit") or {}
          if "export_low" in outs:
              a = _audit(outs["export_low"], "game", expected_holes=inp.get("boundary_loops", 0))
              rec["low_audit"] = a
              if "error" not in a:
                  if not a.get("has_uvs"):
                      probs.append("low OBJ has no UVs")
                  if inp.get("size") and a.get("size"):
                      d = max(abs(x - y) / max(abs(x), 1e-9) for x, y in zip(inp["size"], a["size"]))
                      if d > 0.02:
                          warns.append(f"low bbox size {a['size']} differs from input {inp['size']} by "
                                       f"{d:.1%}: Export Scale or offsets changed [added 2% tolerance]")
                  warns += [f"low: {w}" for w in a["verdict"]["warnings"]]
          if "preview" in outs:
              a = _audit(outs["preview"], "sculpt")
              rec["preview_audit"] = a
          if "normal_map" in outs and params.get("normal_map"):
              raw = outs["normal_map"]
              fin = raw.replace("_normal_raw.png", "_normal.png")
              try:
                  rec["normal_fix"] = fix_normal_map(raw, fin, params.get("map_flip_v", True),
                                                     params.get("map_flip_green", False))
                  st = image_stats(fin)
                  rec["normal_stats"] = st
                  outs["normal_final"] = fin
                  if st["uniform"]:
                      probs.append("normal map is one flat colour: nothing was baked")
                  elif st["blue_ge_128"] < 0.9:
                      warns.append(f"only {st['blue_ge_128']:.0%} of normal-map pixels have blue >= 128: "
                                   "world-space map or wrong channels? [added threshold]")
                  want = params.get("map_size")
                  if want and st["size"] != [want, want]:
                      probs.append(f"normal map is {st['size']}, expected {want}")
              except Exception as e:  # PIL missing, unreadable file
                  warns.append(f"normal map post-process failed: {e}")
      
      
      # --------------------------------------------------------------------------------------------
      # Per-file processing
      # --------------------------------------------------------------------------------------------
      
      def _file_ctx(src, stem, out_dir, stage_dir, audit):
          fout = os.path.join(out_dir, stem)
          os.makedirs(fout, exist_ok=True)
          staged = stage_input(src, stage_dir, stem)
          ctx = {"stem": stem, "input": src, "staged": staged, "out": fout,
                 "input_points": None, "input_faces": None}
          inp = None
          if audit and staged.lower().endswith(".obj"):
              inp = _audit(staged, "sculpt")
              if "error" not in inp:
                  ctx["input_points"], ctx["input_faces"] = inp["points"], inp["faces"]
          return ctx, inp
      
      
      def _new_record(src, stem, sha, rhash, inp):
          return {"stem": stem, "input": src, "sha1": sha, "recipe": rhash, "status": "running",
                  "started": time.strftime("%Y-%m-%dT%H:%M:%S"), "steps": [], "outputs": {},
                  "problems": [], "warnings": [], "input_audit": inp, "attempt": 1}
      
      
      def _collect_output(rec, name, value):
          if not isinstance(value, dict):
              return
          for key in ("path", "file"):
              p = value.get(key)
              if isinstance(p, str) and os.path.isabs(p) and p.lower().endswith(
                      (".obj", ".png", ".ztl", ".goz", ".tif", ".exr")):
                  rec["outputs"][name] = p
          ex = value.get("export")
          if isinstance(ex, dict) and ex.get("path"):
              rec["outputs"][name] = ex["path"]
          for sub in ("views",):                       # zb_review.snapshot with a view
              if isinstance(value.get(sub), list) and value[sub] and value[sub][0].get("path"):
                  rec["outputs"][name] = value[sub][0]["path"]
      
      
      def _finish(rec, crit_failed, params):
          """failed: a critical step failed or a check found a problem; partial: only non-critical
          steps failed or were skipped; ok otherwise. Warnings never change the status."""
          if not crit_failed:
              post_process(rec, params)
          if crit_failed or rec["problems"]:
              rec["status"] = "failed"
          elif any(not s.get("ok") for s in rec["steps"]):
              rec["status"] = "partial"
          else:
              rec["status"] = "ok"
          rec["ended"] = time.strftime("%Y-%m-%dT%H:%M:%S")
          return rec
      
      
      def process_file_bridge(session, src, stem, recipe, params, out_dir, stage_dir, rhash,
                              observed, grace_s=60, audit=True):
          """One file, one step per bridge call. Returns (record, hung_step_or_None)."""
          ctx, inp = _file_ctx(src, stem, out_dir, stage_dir, audit)
          rec = _new_record(src, stem, file_sha1(src), rhash, inp)
          hung, crit_failed = None, False
          for st in recipe:
              use = st
              if st["name"] in session.blocked:
                  if st.get("fallback"):
                      use = st["fallback"]
                  elif not st["critical"]:
                      rec["steps"].append({"step": st["name"], "ok": False, "skipped": True,
                                           "error": f"blocked after a hang on {session.blocked[st['name']]}"})
                      continue
              base = use["timeout"]
              tmo = int(max(base, min(4 * observed.get(use["name"], 0), 10 * base)))
              r = {"step": use["name"], "ok": False, "seconds": None, "timeout": tmo, "value": None,
                   "error": None, "outcome": None}
              t = time.time()
              try:
                  args = resolve_refs(use["args"], ctx, params)
                  kwargs = resolve_refs(use["kwargs"], ctx, params)
                  r["value"] = session.call(use["module"], use["func"], args, kwargs, tmo)
                  r["ok"], r["outcome"] = True, "ok"
              except zb_launch.ZBRemoteError as e:
                  r["error"], r["outcome"] = str(e), "error"
                  r["remote_trace"] = (e.traceback or "")[-1500:]
              except zb_launch.ZBTimeout as e:
                  r["error"] = str(e)[:500]
                  if session.wait_recovery(grace_s):
                      r["outcome"] = "timeout_recovered"      # it ran long; state is uncertain
                  else:
                      r["outcome"] = "hang"
                      hung = use["name"]
              except zb_launch.ZBError as e:                  # bridge gone (crash, quit)
                  r["error"], r["outcome"] = str(e)[:500], "bridge_down"
                  hung = use["name"]
              except (KeyError, ValueError) as e:             # recipe reference problem
                  r["error"], r["outcome"] = f"recipe: {e!r}", "error"
              r["seconds"] = round(time.time() - t, 3)
              if r["ok"]:
                  observed[use["name"]] = max(observed.get(use["name"], 0), r["seconds"])
                  if isinstance(r["value"], dict) and isinstance(r["value"].get("ctx"), dict):
                      ctx.update(r["value"]["ctx"])
                  _collect_output(rec, st["name"], r["value"])
                  p, w = check_step(use["name"], r["value"], params)
                  rec["problems"] += p
                  rec["warnings"] += w
              rec["steps"].append(r)
              if hung:
                  rec["hang"] = {"step": hung, "evidence": evidence(ctx["out"], hung)}
                  crit_failed = crit_failed or st["critical"]
                  break
              if not r["ok"] and st["critical"]:
                  crit_failed = True
                  break
          rec["ctx"] = ctx
          return _finish(rec, crit_failed, params), hung
      
      
      def process_file_script(src, stem, recipe, params, out_dir, stage_dir, rhash, audit=True,
                              timeout=None, runner=None):
          """One file in its own ZBrush process (-script). runner is run_script_job (injectable)."""
          ctx, inp = _file_ctx(src, stem, out_dir, stage_dir, audit)
          rec = _new_record(src, stem, file_sha1(src), rhash, inp)
          res_path = os.path.join(ctx["out"], "job_result.json")
          job_path = os.path.join(ctx["out"], "job.json")
          write_json_atomic(job_path, {"recipe": recipe, "params": params, "ctx": ctx, "result": res_path})
          tmo = timeout or sum(s["timeout"] for s in recipe) + 180
          run = (runner or run_script_job)(job_path, res_path, timeout=tmo, log_dir=ctx["out"])
          rec["process"] = {k: run.get(k) for k in ("state", "exit_code", "seconds", "ended", "cmd")}
          res = run.get("result") or {}
          by_name = {s["name"]: s for s in recipe}
          for s in res.get("steps", []):
              s = dict(s, outcome="ok" if s.get("ok") else "error")
              rec["steps"].append(s)
              if s.get("ok"):
                  _collect_output(rec, s["step"], s.get("value"))
                  p, w = check_step(s["step"], s.get("value"), params)
                  rec["problems"] += p
                  rec["warnings"] += w
          done = res.get("steps", [])
          crit_failed = any(not s.get("ok") and by_name.get(s.get("step"), {}).get("critical", True)
                            for s in done)
          hung = None
          if run.get("state") in ("timeout", "startup_stall"):
              hung = recipe[len(done)]["name"] if len(done) < len(recipe) else "exit"
              rec["hang"] = {"step": hung, "state": run["state"]}
              crit_failed = crit_failed or by_name.get(hung, {}).get("critical", True)
          elif not res.get("finished"):
              rec["problems"].append(f"ZBrush process {run.get('state')} before the job finished "
                                     f"(exit code {run.get('exit_code')})")
              crit_failed = True
          rec["ctx"] = res.get("ctx", ctx)
          return _finish(rec, crit_failed, params), hung
      
      
      # --------------------------------------------------------------------------------------------
      # The folder runner
      # --------------------------------------------------------------------------------------------
      
      def plan(inputs, out_dir, params=None, recipe=None):
          """Dry run: every file's resolved steps, without ZBrush."""
          params = {**DEFAULT_PARAMS, **(params or {})}
          recipe = recipe or default_recipe(params)
          out_dir = os.path.abspath(out_dir)
          rows = []
          for src, stem in zip(inputs, unique_stems(inputs)):
              ctx = {"stem": stem, "input": src, "out": os.path.join(out_dir, stem),
                     "staged": os.path.join(out_dir, "_staged", stem + os.path.splitext(src)[1].lower()),
                     "input_points": None, "input_faces": None, "source": "<source>", "low": "<low>"}
              rows.append({"stem": stem, "input": src, "steps": [
                  {"name": s["name"], "call": f"{s['module']}.{s['func']}",
                   "args": resolve_refs(s["args"], ctx, params),
                   "kwargs": resolve_refs(s["kwargs"], ctx, params), "timeout": s["timeout"],
                   "critical": s["critical"], "plugin": s["plugin"],
                   "fallback": s["fallback"]["func"] if s.get("fallback") else None}
                  for s in recipe]})
          return {"params": params, "recipe_hash": recipe_hash(recipe, params), "files": rows}
      
      
      def write_csv(report, path):
          cols = ["stem", "status", "failed_step", "seconds", "input_faces", "low_faces", "low_quads_pct",
                  "uv", "top_faces", "normal_map", "preview_faces", "problems", "warnings"]
          with open(path, "w", newline="") as fh:
              w = csv.writer(fh)
              w.writerow(cols)
              for stem, r in sorted(report["files"].items()):
                  steps = {s["step"]: s for s in r.get("steps", [])}
                  bad = next((s["step"] for s in r.get("steps", []) if not s.get("ok")), "")
                  proj = (steps.get("project") or {}).get("value") or {}
                  la, pa = r.get("low_audit") or {}, r.get("preview_audit") or {}
                  w.writerow([stem, r.get("status"), bad,
                              round(sum(s.get("seconds") or 0 for s in r.get("steps", [])), 1),
                              (r.get("input_audit") or {}).get("faces"), la.get("faces"),
                              la.get("quads_pct"), la.get("uv_bbox"), proj.get("top_faces"),
                              r.get("outputs", {}).get("normal_final"), pa.get("faces"),
                              len(r.get("problems", [])), len(r.get("warnings", []))])
          return path
      
      
      def write_manifest(report, path, target="maya"):
          """Handoff manifest for the next app's agent: per ok or partial file, the outputs, counts,
          Export Scale and the conventions used (map flips, axes, units). The receiving agent
          imports from this, not from guesses."""
          p = report["batch"].get("params", {})
          files = []
          for stem, r in sorted(report["files"].items()):
              if r.get("status") not in ("ok", "partial"):
                  continue
              imp = next((s.get("value") for s in r.get("steps", []) if s.get("step") == "import"
                          and s.get("ok")), None) or {}
              files.append({"name": stem, "source": r.get("input"), "status": r.get("status"),
                            "outputs": r.get("outputs", {}), "low_faces": (r.get("low_audit") or {}).get("faces"),
                            "low_size": (r.get("low_audit") or {}).get("size"),
                            "export_settings": imp.get("export"), "warnings": r.get("warnings", [])})
          man = {"target": target, "from": "ZBrush " + str(report["batch"].get("zbrush")),
                 "conventions": {"obj_axes": "Y up, +Z toward the front view camera (v03 OBJ analysis)",
                                 "units": "ZBrush units x Export Scale; an OBJ import sets Export Scale "
                                          "to the file's size (Outgang)",
                                 "normal_map": {"space": "tangent", "flip_v": p.get("map_flip_v"),
                                                "green": "DirectX (-Y)" if p.get("map_flip_green")
                                                else "OpenGL (+Y)"},
                                 "preview": "Decimation Master triangles"},
                 "files": files}
          write_json_atomic(path, man)
          return path
      
      
      def write_sheet(report, path):
          """Contact sheet of every file's canvas snapshot, labelled stem | status | low faces
          (zb_review.contact_sheet). The agent opens it: numbers alone do not show a melted
          DynaMesh or projection spikes."""
          shots, labels = [], []
          for stem, r in sorted(report["files"].items()):
              p = r.get("outputs", {}).get("snapshot")
              if p and os.path.exists(p):
                  shots.append(p)
                  labels.append(f"{stem} | {r.get('status')} | low {(r.get('low_audit') or {}).get('faces')}")
          if not shots:
              return None
          import zb_review
          return zb_review.contact_sheet(shots, labels, path, title="batch " + report["batch"]["started"])
      
      
      def summarize(report):
          files = report["files"].values()
          by = {}
          for r in files:
              by[r.get("status")] = by.get(r.get("status"), 0) + 1
          report["batch"]["counts"] = by
          report["batch"]["problems"] = sum(len(r.get("problems", [])) for r in files)
          return by
      
      
      def run_folder(inputs, out_dir, params=None, recipe=None, mode="bridge", session=None,
                     resume=True, restart_every=10, retry_fallback=True, grace_s=60, audit=True,
                     sheet=True, quit_at_end=True, stop_file="STOP", script_runner=None):
          """Run the recipe on every input. Writes <out>/report.json after every file (atomic),
          then report.csv and sheet.png. Resume skips files whose sha1 and recipe hash match an
          ok record. A file named STOP in <out> ends the batch between files."""
          params = {**DEFAULT_PARAMS, **(params or {})}
          recipe = recipe or default_recipe(params)
          rhash = recipe_hash(recipe, params)
          out_dir = os.path.abspath(out_dir)
          stage_dir = os.path.join(out_dir, "_staged")
          os.makedirs(out_dir, exist_ok=True)
          rp = os.path.join(out_dir, "report.json")
          report = read_json(rp) if resume else None
          if not report or "files" not in report:
              report = {"batch": {}, "files": {}}
          report["batch"].update(started=time.strftime("%Y-%m-%dT%H:%M:%S"), mode=mode,
                                 recipe_hash=rhash, params=params, out=out_dir, inputs=len(inputs),
                                 aborted=None, restarts=0, blocked={}, notes=[])
          stems = unique_stems(inputs)
          observed = {}
          if mode == "bridge":
              session = session or BridgeSession()
          try:
              if mode == "bridge":
                  session.ensure()
                  report["batch"]["zbrush"] = session.version
              for src, stem in zip(inputs, stems):
                  if stop_file and os.path.exists(os.path.join(out_dir, stop_file)):
                      report["batch"]["notes"].append(f"{stop_file} file found before {stem}")
                      break
                  prev = report["files"].get(stem)
                  if resume and prev and prev.get("status") == "ok" and prev.get("recipe") == rhash \
                          and prev.get("sha1") == file_sha1(src):
                      continue
                  if mode == "bridge":
                      if session.owned and session.files_since_restart >= restart_every:
                          session.restart("scheduled restart (tool list and memory growth)")
                      rec, hung = process_file_bridge(session, src, stem, recipe, params, out_dir,
                                                      stage_dir, rhash, observed, grace_s, audit)
                      session.files_since_restart += 1
                      if hung:
                          session.hangs[hung] = session.hangs.get(hung, 0) + 1
                          by_name = {s["name"]: s for s in recipe}
                          if by_name.get(hung, {}).get("plugin") or by_name.get(hung, {}).get("fallback"):
                              session.blocked[hung] = stem
                          report["files"][stem] = rec
                          write_json_atomic(rp, report)
                          if session.hangs[hung] >= session.hang_abort and hung not in session.blocked:
                              raise BatchAbort(f"step {hung} hung on {session.hangs[hung]} files")
                          session.restart(f"hang in {hung} on {stem}")
                          if retry_fallback and by_name.get(hung, {}).get("fallback"):
                              rec2, _ = process_file_bridge(session, src, stem, recipe, params, out_dir,
                                                            stage_dir, rhash, observed, grace_s, audit)
                              rec2["attempt"] = 2
                              rec2["first_attempt"] = {"hang": rec.get("hang"), "steps": rec["steps"]}
                              rec = rec2
                              session.files_since_restart += 1
                  else:
                      rec, hung = process_file_script(src, stem, recipe, params, out_dir, stage_dir,
                                                      rhash, audit, runner=script_runner)
                      if hung and rec.get("hang", {}).get("state") == "startup_stall":
                          report["files"][stem] = rec
                          write_json_atomic(rp, report)
                          raise BatchAbort(f"ZBrush -script launch stalled on {stem}")
                  report["files"][stem] = rec
                  write_json_atomic(rp, report)
          except BatchAbort as e:
              report["batch"]["aborted"] = str(e)
          finally:
              if mode == "bridge" and session is not None:
                  report["batch"]["restarts"] = session.restarts
                  report["batch"]["blocked"] = dict(session.blocked)
                  report["batch"]["session_log"] = session.log[-20:]
                  if quit_at_end and not report["batch"]["aborted"]:
                      try:
                          report["batch"]["quit"] = session.close()
                      except Exception as e:  # never lose the report over a quit problem
                          report["batch"]["quit_error"] = repr(e)
              report["batch"]["ended"] = time.strftime("%Y-%m-%dT%H:%M:%S")
              summarize(report)
              write_json_atomic(rp, report)
              try:
                  report["batch"]["csv"] = write_csv(report, os.path.join(out_dir, "report.csv"))
                  report["batch"]["manifest"] = write_manifest(report, os.path.join(out_dir, "manifest.json"))
                  if sheet:
                      report["batch"]["sheet"] = write_sheet(report, os.path.join(out_dir, "sheet.png"))
              except Exception as e:
                  report["batch"]["report_error"] = repr(e)
              write_json_atomic(rp, report)
          return report
      
      
      # --------------------------------------------------------------------------------------------
      # -script mode
      # --------------------------------------------------------------------------------------------
      
      def script_command(job_path, scene=None, zbrush_bin=None, job_py=JOB_PY):
          """ZBrush -script <job_py> --job <job.json> [scene]: scene LAST (Maxon quickstart)."""
          cmd = [zbrush_bin or zb_launch.ZBRUSH_BIN, "-script", job_py, "--job", os.path.abspath(job_path)]
          if scene:
              cmd.append(os.path.abspath(scene))
          return cmd
      
      
      def run_script_job(job_path, result_path, timeout=1800, scene=None, startup_timeout=180,
                         quit_grace=20, log_dir=None, popen=None, is_running=None, quit_fn=None,
                         poll=1.0):
          """Spawn one ZBrush for one job and watch its result file. States: finished (the job
          wrote finished=True), exited (process ended first), timeout, startup_stall (no result
          file within startup_timeout: the 05:30 kind of stall, README). A process still alive
          after `finished` + quit_grace is asked to quit (zb_launch.stop, which also answers the
          save prompt when the screen is unlocked); on timeout it is terminated."""
          is_running = is_running or zb_launch.is_running
          if is_running():
              raise BatchAbort("ZBrush is already running: -script needs a fresh process (single "
                               "instance; a second launch may hand the script to the running one "
                               "[verify])")
          popen = popen or subprocess.Popen
          env = os.environ.copy()
          env.pop("ZB_BRIDGE_AUTOSERVE", None)
          cmd = script_command(job_path, scene)
          log_path = os.path.join(log_dir or os.path.dirname(os.path.abspath(result_path)), "zbrush_script.log")
          t0 = time.time()
          with open(log_path, "a") as log:
              log.write(f"\n--- {time.strftime('%Y-%m-%d %H:%M:%S')} {cmd}\n")
              log.flush()
              proc = popen(cmd, stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
                           start_new_session=True, env=env)
              while True:
                  res = read_json(result_path)
                  if res and res.get("finished"):
                      state = "finished"
                      break
                  if proc.poll() is not None:
                      state = "exited"
                      break
                  el = time.time() - t0
                  if res is None and el > startup_timeout:
                      state = "startup_stall"
                      break
                  if el > timeout:
                      state = "timeout"
                      break
                  time.sleep(poll)
              ended = "self" if proc.poll() is not None else None
              if ended is None and state == "finished":
                  end = time.time() + quit_grace
                  while time.time() < end and proc.poll() is None:
                      time.sleep(poll)
                  if proc.poll() is None:
                      (quit_fn or (lambda: zb_launch.stop(save_to=None, force=True)))()
                      ended = "asked_to_quit"
                  else:
                      ended = "self_after_finish"
              if ended is None:
                  proc.terminate()
                  try:
                      proc.wait(15)
                      ended = "terminated"
                  except subprocess.TimeoutExpired:
                      proc.kill()
                      ended = "killed"
          return {"state": state, "exit_code": proc.poll(), "result": read_json(result_path),
                  "seconds": round(time.time() - t0, 1), "ended": ended, "cmd": cmd, "log": log_path}
      
      
      # --------------------------------------------------------------------------------------------
      # Learning paths: the Activity log oracle
      # --------------------------------------------------------------------------------------------
      
      _ACT = re.compile(r'^(IPress|IUnPress|ISet|IToggle|IModSet|IKeyPress|IClick),\s*"([^"]+)"(?:,\s*(.*))?$')
      
      
      def canonical(path):
          """Item paths ignore case and spaces (SDK Item Paths): compare them this way."""
          return re.sub(r"\s+", "", path).lower()
      
      
      def parse_activity(text):
          """ZBrush's Activity log (<Asset Directory>/Logs/Activity/Activity <date>.txt) writes one
          line per press or set with the canonical path, e.g. 'IPress, "Tool:Geometry:DynaMesh"'
          (the 05:20 session log). Strokes are not logged ('Click in Canvas' only)."""
          rows = []
          for line in text.splitlines():
              m = _ACT.match(line.strip())
              if m:
                  val = m.group(3)
                  try:
                      val = float(val) if val is not None else None
                  except ValueError:
                      pass
                  rows.append({"verb": m.group(1), "path": m.group(2), "value": val,
                               "canonical": canonical(m.group(2))})
              elif line.strip():
                  rows.append({"verb": "other", "text": line.strip()})
          return rows
      
      
      class ActivityWatch:
          """mark() before a human, a computer-use agent or a macro presses buttons; entries()
          afterwards returns what was pressed, with canonical paths. The path oracle that needs no
          macro recording and no save dialog."""
      
          def __init__(self, path=None):
              logs = zb_launch.activity_logs()
              self.path = path or (logs[-1] if logs else None)
              self.offset = 0
      
          def mark(self):
              self.offset = os.path.getsize(self.path) if self.path and os.path.exists(self.path) else 0
              return self.offset
      
          def entries(self):
              if not self.path or not os.path.exists(self.path):
                  return []
              with open(self.path, errors="ignore") as fh:
                  fh.seek(self.offset)
                  text = fh.read()
                  self.offset = fh.tell()
              return parse_activity(text)
      
      
      # --------------------------------------------------------------------------------------------
      # ZScript helpers: key-answer macros and the file-drop channel
      # --------------------------------------------------------------------------------------------
      
      def keypress_macro_text(key, press_path, info="agent helper: press with a key held"):
          """A macro that presses an item while holding a key, the way Maxon's shipped Delete All
          macro answers its confirmation: [IKeyPress,'2',[IPress,Tool:SubTool:Del All]]. Python's
          press_key is 'Does not work' (2026.1 stub), so Python presses this macro instead
          (pocacola, Maxon forum 2026-04) [verify]."""
          if '"' in press_path or "'" in key:
              raise ValueError("quotes are not allowed in the key or the path")
          return (f'// generated by zb_batch.keypress_macro_text\n[IButton,???,"{info}",\n'
                  f'  [IShowActions,0]\n  [IKeyPress,\'{key}\',[IPress,"{press_path}"]]\n]\n')
      
      
      def install_macro(text, name, folder="AgentHelpers", asset_dir=None):
          """Write <Asset Directory>/ZStartup/Macros/<folder>/<name>.txt. Rules (interfaces doc):
          subfolder required, file name of 8+ characters, a new folder appears after a restart.
          Never overwrites. This adds a visible Macro palette entry: ask the user first."""
          if len(name) < 8:
              raise ValueError("macro file names need 8 or more characters or ZBrush skips them")
          if not asset_dir:
              dirs = glob.glob(os.path.expanduser("~/Library/Preferences/Maxon/ZBrush_*"))
              if not dirs:
                  raise FileNotFoundError("no ZBrush Asset Directory found")
              asset_dir = dirs[0]
          d = os.path.join(asset_dir, "ZStartup", "Macros", folder)
          os.makedirs(d, exist_ok=True)
          p = os.path.join(d, name + ".txt")
          if os.path.exists(p):
              raise FileExistsError(f"{p} exists: pick a new name (never overwrite)")
          with open(p, "w") as fh:
              fh.write(text)
          return {"path": p, "restart_needed": True, "press": f"Macro:{folder}:{name} [verify]"}
      
      
      def filedrop_job_text(body, signal_path):
          """A ZScript that runs on load (wrapped in [If,1,...], marcus_civis) and signals the end
          with VarSave to an absolute path (usd-portal's completion signal; ZBrush 2026 redirects
          bare names into a temp sandbox). For a ZBrush without the bridge."""
          if not os.path.isabs(signal_path):
              raise ValueError("signal path must be absolute")
          return (f'[VarDef,agentDone,0]\n[If,1,\n  [IFreeze,\n{body}\n  ]\n'
                  f'  [VarSet,agentDone,1]\n  [VarSave,agentDone,"{signal_path}"]\n]\n')
      
      
      def run_filedrop(job_txt, signal_path, timeout=180, poll=0.5):
          """Hand a ZScript to the running ZBrush with open -a (GoB's macOS pattern) and wait for
          its signal [verify on 2026.2.1]. It ends any active ZScript and steals focus; no return
          value or error beyond the files it writes."""
          app = zb_launch.ZBRUSH_APP
          subprocess.run(["open", "-a", app, os.path.abspath(job_txt)], capture_output=True, timeout=30)
          t0 = time.time()
          base = os.path.basename(signal_path)
          while time.time() - t0 < timeout:
              if os.path.exists(signal_path):
                  return {"done": True, "signal": signal_path, "seconds": round(time.time() - t0, 1)}
              hits = glob.glob(f"/private/var/folders/*/*/T/ZBrushData2026*/**/{base}", recursive=True)
              if hits:
                  return {"done": True, "signal": hits[0], "sandboxed": True,
                          "seconds": round(time.time() - t0, 1)}
              time.sleep(poll)
          return {"done": False, "seconds": timeout}
      
      
      # --------------------------------------------------------------------------------------------
      # CLI
      # --------------------------------------------------------------------------------------------
      
      def _cli(argv=None):
          import argparse
          ap = argparse.ArgumentParser(description="Batch ZBrush jobs over a folder")
          ap.add_argument("cmd", choices=("plan", "run", "activity"))
          ap.add_argument("--in", dest="src")
          ap.add_argument("--out")
          ap.add_argument("--params")
          ap.add_argument("--pattern", default="*.obj")
          ap.add_argument("--mode", default="bridge", choices=("bridge", "script"))
          ap.add_argument("--no-resume", action="store_true")
          ns = ap.parse_args(argv)
          if ns.cmd == "activity":
              w = ActivityWatch()
              print(json.dumps({"file": w.path, "entries": w.entries()[-40:]}, indent=1))
              return 0
          params = read_json(ns.params, {}) if ns.params else {}
          files = discover(ns.src, (ns.pattern,))
          if ns.cmd == "plan":
              print(json.dumps(plan(files, ns.out, params), indent=1))
              return 0
          rep = run_folder(files, ns.out, params, mode=ns.mode, resume=not ns.no_resume)
          print(json.dumps({"counts": rep["batch"].get("counts"), "aborted": rep["batch"].get("aborted"),
                            "report": os.path.join(os.path.abspath(ns.out), "report.json")}, indent=1))
          return 0 if not rep["batch"].get("aborted") else 3
      
      
      if __name__ == "__main__":
          sys.exit(_cli())
      
    • zb_batch_job.py 5.9 KB
      """
      zb_batch_job: one batch recipe for one file inside ZBrush, launched with ZBrush's -script
      mode (one ZBrush process per file, no bridge). Also holds resolve_refs(), the recipe
      argument resolver shared with the agent-side runner (zb_batch), so both modes run the same
      recipe data.
      
      Launch (Maxon SDK quickstart, ex_mod_subtool_export.py; the scene file, if any, is the LAST
      argument):
          "/Applications/Maxon ZBrush 2026/ZBrush.app/Contents/MacOS/ZBrush" \
              -script /abs/skills/scenario-zbrush-automation/scripts/zb_batch_job.py --job /abs/job.json
      
      job.json = {"recipe": [step, ...], "params": {...}, "ctx": {...}, "result": "/abs/x.json"}.
      A step is {"name", "module", "func", "args", "kwargs", "critical", ...}. The job writes the
      result file after every step (atomic replace), so a hang still leaves the finished steps on
      disk, then calls sys.exit(0 ok / 1 failed / 2 bad job). Whether ZBrush quits on sys.exit in
      -script mode is not documented [verify live_a05]: zb_batch.run_script_job quits it if not.
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import json
      import os
      import sys
      import time
      import traceback
      
      HERE = os.path.dirname(os.path.abspath(__file__))
      LEAD = os.path.abspath(os.path.join(HERE, "..", "..", "scenario-zbrush-expert", "scripts"))
      SCENE_EXT = (".zpr", ".ztl", ".zbr")
      
      
      def parse_argv(argv):
          """Script arguments start at sys.argv.index('-script') + 2 (Maxon example); a trailing
          scene file (.ZPR/.ZTL) is ZBrush's own argument and is split off."""
          if "-script" in argv:
              i = argv.index("-script")
              script = argv[i + 1] if i + 1 < len(argv) else None
              rest = list(argv[i + 2:])
          else:
              script, rest = None, list(argv[1:])
          scene = rest.pop() if rest and rest[-1].lower().endswith(SCENE_EXT) else None
          job = None
          if "--job" in rest:
              j = rest.index("--job")
              job = rest[j + 1] if j + 1 < len(rest) else None
          return {"script": script, "job": job, "scene": scene, "args": rest}
      
      
      def _lookup(root, dotted):
          cur = root
          for part in dotted.split("."):
              if isinstance(cur, dict) and part in cur:
                  cur = cur[part]
              else:
                  raise KeyError(dotted)
          return cur
      
      
      def resolve_refs(obj, ctx, params):
          """'$p.name' -> params, '$c.name' -> ctx (dots walk dicts); other strings are formatted
          with {**params, **ctx} when they contain '{'. Lists and dicts are walked."""
          if isinstance(obj, str):
              if obj.startswith("$p."):
                  return _lookup(params, obj[3:])
              if obj.startswith("$c."):
                  return _lookup(ctx, obj[3:])
              if "{" in obj:
                  return obj.format(**{**params, **ctx})
              return obj
          if isinstance(obj, list):
              return [resolve_refs(x, ctx, params) for x in obj]
          if isinstance(obj, dict):
              return {k: resolve_refs(v, ctx, params) for k, v in obj.items()}
          return obj
      
      
      def write_json_atomic(path, data):
          os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
          tmp = path + ".tmp"
          with open(tmp, "w") as fh:
              json.dump(data, fh, indent=1, default=repr)
          os.replace(tmp, path)
      
      
      def _import_toolkit(names):
          """Import toolkit modules by path, then take the folders off sys.path and the modules
          out of sys.modules (Maxon Style Guide: shared interpreter)."""
          import importlib
          before = set(sys.modules)
          dirs = [HERE, LEAD]
          for d in dirs:
              sys.path.insert(0, d)
          try:
              mods = {n: importlib.import_module(n) for n in names}
          finally:
              for d in dirs:
                  while d in sys.path:
                      sys.path.remove(d)
              for k in set(sys.modules) - before:
                  if k.startswith("zb_"):
                      sys.modules.pop(k, None)
          return mods
      
      
      def run_job(job, mods):
          recipe, params = job["recipe"], job.get("params", {})
          ctx = dict(job.get("ctx", {}))
          res_path = job["result"]
          result = {"status": "running", "started": time.strftime("%Y-%m-%dT%H:%M:%S"),
                    "steps": [], "ctx": ctx, "finished": False}
          write_json_atomic(res_path, result)
          failed = False
          for step in recipe:
              rec = {"step": step["name"], "ok": False, "seconds": None, "value": None, "error": None}
              t = time.time()
              try:
                  fn = getattr(mods[step["module"]], step["func"])
                  args = resolve_refs(step.get("args", []), ctx, params)
                  kwargs = resolve_refs(step.get("kwargs", {}), ctx, params)
                  rec["value"] = fn(*args, **kwargs)
                  rec["ok"] = True
                  if isinstance(rec["value"], dict) and isinstance(rec["value"].get("ctx"), dict):
                      ctx.update(rec["value"]["ctx"])
              except Exception as e:
                  rec["error"] = f"{type(e).__name__}: {e}"
                  rec["trace"] = traceback.format_exc()[-2000:]
              rec["seconds"] = round(time.time() - t, 3)
              result["steps"].append(rec)
              result["ctx"] = ctx
              write_json_atomic(res_path, result)
              if not rec["ok"] and step.get("critical", True):
                  failed = True
                  break
          result["status"] = "failed" if failed else "ok"
          result["finished"] = True
          result["ended"] = time.strftime("%Y-%m-%dT%H:%M:%S")
          write_json_atomic(res_path, result)
          return result
      
      
      def main(argv=None):
          a = parse_argv(sys.argv if argv is None else argv)
          if not a["job"] or not os.path.exists(a["job"]):
              print(f"zb_batch_job: no job file ({a})")
              return 2
          with open(a["job"]) as fh:
              job = json.load(fh)
          names = sorted({s["module"] for s in job["recipe"]} | {"zb_ops"})
          try:
              mods = _import_toolkit(names)
          except Exception:
              write_json_atomic(job["result"], {"status": "failed", "finished": True,
                                                "error": traceback.format_exc()[-2000:]})
              return 2
          r = run_job(job, mods)
          return 0 if r["status"] == "ok" else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • zb_goz.py 18.4 KB
      """
      zb_goz: read and write GoZ binary files, and drive the GoZ folder protocol, in pure Python.
      
      No ZBrush, numpy or Blender needed: the same file runs in system python3, in Blender's or
      Maya's Python, and inside ZBrush. That is the point: a GoZ file becomes plain data an agent
      can check (counts, names, UVs, polygroups) or write, without GoB or the GoZ buttons.
      
          import zb_goz
          info = zb_goz.read_goz("/abs/out/body.GoZ")          # dict: name, points, faces, uvs...
          zb_goz.write_goz("/abs/in/rock.GoZ", "rock", points, faces, polygroups=groups)
          zb_goz.check_names(["Body", "Body", "left eye"])     # GoZ naming problems
      
      Layout (little endian), confirmed by parsing the ZBrush-written file
      /Applications/Maxon ZBrush 2026/ZResources/DXFStar.GoZ (2026.2.1) and by rewriting it byte
      for byte (tests/code/zbrush-automation/test_zb_goz.py):
        header  32 bytes  b"GoZb 1.0 ZBrush GoZ Binary" + 6 dots
        block   tag u32, size u32 (whole block, header included), count u32, modifier u32, payload
        end     a 16-byte block of zeros (tag 0)
      Tags seen in DXFStar.GoZ: 1 object name ("GoZMesh_<name>", NUL padded to 4 bytes), 5001
      flags (4 zero bytes), 10001 points (3 x f32), 20001 faces (4 x u32, 0xFFFFFFFF as the 4th
      index of a triangle), 2 material name ("GoZMat_<name>"). Tags known only from the GoB
      add-on (JoseConseco/GoB, via the project note automation__community-bridges-remote-control)
      [verify against a ZBrush export with UVs, polypaint, mask and groups]: 25001 UVs (4 x (u, v)
      f32 per face), 35001 polypaint (B, G, R, 0 bytes per point), 30002 mask (u16 per point,
      stored as (1 - mask) * 65535), 40001 polygroups (u16 per face, 65504 = no group), 45001
      diffuse map path, 50001 normal map path, 55001 displacement map path.
      
      Axes [inferred, verify]: Maya's GoZ_Info.txt flips Y and Z on both import and export
      (/Users/Shared/Pixologic/GoZApps/Maya/GoZ_Info.txt), GoB's Blender GoZ_Info flips nothing
      and applies its own matrix. So a .GoZ holds ZBrush's own axes, not OBJ axes: convert with
      goz_to_obj_axes() before comparing with an OBJ export of the same SubTool.
      
      Folder protocol (GoZ SDK "GoZBrushFromApp"; not yet run on this Mac): write the .GoZ files,
      list their extension-less paths in GoZBrush/GoZ_ObjectList.txt, optionally set
      IMPORT_AS_SUBTOOL in GoZBrush/GoZ_Config.txt, then run GoZBrush/GoZBrushFromApp.app, which
      opens every listed object in the running ZBrush (or launches it). push_to_zbrush() does this
      and backs up every shared file it changes.
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import os
      import re
      import shutil
      import struct
      import subprocess
      import time
      
      HEADER = b"GoZb 1.0 ZBrush GoZ Binary" + b"." * 6
      PIXOLOGIC = "/Users/Shared/Pixologic"
      NO_GROUP = 65504
      TRI = 0xFFFFFFFF
      
      TAG_OBJECT, TAG_MATERIAL, TAG_FLAGS = 1, 2, 5001
      TAG_POINTS, TAG_FACES, TAG_UVS = 10001, 20001, 25001
      TAG_MASK, TAG_POLYPAINT, TAG_GROUPS = 30002, 35001, 40001
      TAG_DIFFUSE, TAG_NORMAL, TAG_DISPLACEMENT = 45001, 50001, 55001
      TAG_NAMES = {0: "end", TAG_OBJECT: "object_name", TAG_MATERIAL: "material_name",
                   TAG_FLAGS: "flags", TAG_POINTS: "points", TAG_FACES: "faces", TAG_UVS: "uvs",
                   TAG_MASK: "mask", TAG_POLYPAINT: "polypaint", TAG_GROUPS: "polygroups",
                   TAG_DIFFUSE: "diffuse_map", TAG_NORMAL: "normal_map",
                   TAG_DISPLACEMENT: "displacement_map"}
      STRING_TAGS = (TAG_OBJECT, TAG_MATERIAL, TAG_DIFFUSE, TAG_NORMAL, TAG_DISPLACEMENT)
      
      
      class GoZError(ValueError):
          pass
      
      
      # --------------------------------------------------------------------------------------------
      # Binary format
      # --------------------------------------------------------------------------------------------
      
      def _cstr(payload):
          return payload.split(b"\0", 1)[0].decode("utf-8", "replace")
      
      
      def read_goz(path, data=True):
          """Parse a .GoZ file. data=False skips the arrays (counts only, fast on big files).
      
          Returns {"path", "header", "name", "material", "blocks": [{tag, kind, offset, size,
          count, modifier}], "points", "faces", "uvs", "polypaint", "mask", "polygroups", "maps",
          "counts": {"points", "faces", "triangles", "quads"}, "unknown_tags"}. Faces are lists of
          3 or 4 point indices; uvs are per face lists of 4 (u, v) pairs as stored."""
          with open(path, "rb") as fh:
              buf = fh.read()
          if not buf.startswith(b"GoZb"):
              raise GoZError(f"{path}: not a GoZ file (header {buf[:8]!r})")
          out = {"path": path, "header": buf[:32].decode("ascii", "replace"), "name": None,
                 "material": None, "blocks": [], "points": None, "faces": None, "uvs": None,
                 "polypaint": None, "mask": None, "polygroups": None, "maps": {},
                 "unknown_tags": []}
          off = 32
          ended = False
          while off + 16 <= len(buf):
              tag, size, count, mod = struct.unpack_from("<IIII", buf, off)
              kind = TAG_NAMES.get(tag, "unknown")
              out["blocks"].append({"tag": tag, "kind": kind, "offset": off, "size": size,
                                    "count": count, "modifier": mod})
              if tag == 0:
                  ended = True
                  break
              if size < 16 or off + size > len(buf):
                  raise GoZError(f"{path}: block tag {tag} at {off} has size {size} (file {len(buf)})")
              body = buf[off + 16:off + size]
              if tag in STRING_TAGS:
                  s = _cstr(body)
                  if tag == TAG_OBJECT:
                      out["name"] = s[len("GoZMesh_"):] if s.startswith("GoZMesh_") else s
                      out["raw_name"] = s
                  elif tag == TAG_MATERIAL:
                      out["material"] = s[len("GoZMat_"):] if s.startswith("GoZMat_") else s
                  else:
                      out["maps"][kind] = s
              elif not data:
                  pass
              elif tag == TAG_POINTS:
                  v = struct.unpack_from(f"<{3 * count}f", body)
                  out["points"] = [tuple(v[i:i + 3]) for i in range(0, 3 * count, 3)]
              elif tag == TAG_FACES:
                  v = struct.unpack_from(f"<{4 * count}I", body)
                  out["faces"] = [list(v[i:i + 3]) if v[i + 3] == TRI else list(v[i:i + 4])
                                  for i in range(0, 4 * count, 4)]
              elif tag == TAG_UVS:
                  v = struct.unpack_from(f"<{8 * count}f", body)
                  out["uvs"] = [[(v[i + 2 * k], v[i + 2 * k + 1]) for k in range(4)]
                                for i in range(0, 8 * count, 8)]
              elif tag == TAG_POLYPAINT:
                  out["polypaint"] = [(body[i + 2], body[i + 1], body[i])      # stored B, G, R, 0
                                      for i in range(0, 4 * count, 4)]
              elif tag == TAG_MASK:
                  v = struct.unpack_from(f"<{count}H", body)
                  out["mask"] = [round(1.0 - x / 65535.0, 6) for x in v]
              elif tag == TAG_GROUPS:
                  out["polygroups"] = list(struct.unpack_from(f"<{count}H", body))
              elif tag == TAG_FLAGS:
                  out["flags"] = body
              else:
                  out["unknown_tags"].append(tag)
              off += size
          if not ended:
              raise GoZError(f"{path}: no end block (truncated file?)")
          pts = next((b["count"] for b in out["blocks"] if b["tag"] == TAG_POINTS), 0)
          fcs = next((b["count"] for b in out["blocks"] if b["tag"] == TAG_FACES), 0)
          counts = {"points": pts, "faces": fcs}
          if out["faces"] is not None:
              counts["triangles"] = sum(1 for f in out["faces"] if len(f) == 3)
              counts["quads"] = fcs - counts["triangles"]
          out["counts"] = counts
          return out
      
      
      def goz_info(path):
          """Counts, names and block list only (no arrays)."""
          r = read_goz(path, data=False)
          return {k: r[k] for k in ("path", "header", "name", "material", "counts", "maps",
                                    "unknown_tags")} | {"tags": [b["tag"] for b in r["blocks"]]}
      
      
      def _string_block(tag, text):
          raw = text.encode("utf-8") + b"\0"
          raw += b"\0" * (-len(raw) % 4)                     # DXFStar.GoZ: NUL padded to 4 bytes
          return struct.pack("<IIII", tag, 16 + len(raw), 1, 0) + raw
      
      
      def _array_block(tag, count, payload):
          return struct.pack("<IIII", tag, 16 + len(payload), count, 0) + payload
      
      
      def write_goz(path, name, points, faces, uvs=None, polygroups=None, polypaint=None, mask=None,
                    material=None, flags=b"\0\0\0\0", maps=None):
          """Write a .GoZ in the layout of DXFStar.GoZ. points: [(x, y, z)] in ZBrush axes
          (see obj_to_goz_axes); faces: lists of 3 or 4 zero-based indices; uvs: per face 3 or 4
          (u, v) pairs, stored unchanged [verify orientation]; polygroups: one int per face
          (NO_GROUP = none); polypaint: (r, g, b) 0..255 per point; mask: 0..1 per point
          (1 = masked); maps: {"diffuse_map"|"normal_map"|"displacement_map": path}.
          material defaults to name, like ZBrush's own GoZMat_<name>. Returns {path, bytes}."""
          npts, nf = len(points), len(faces)
          chunks = [HEADER, _string_block(TAG_OBJECT, "GoZMesh_" + name),
                    _array_block(TAG_FLAGS, 1, flags)]
          chunks.append(_array_block(TAG_POINTS, npts,
                                     b"".join(struct.pack("<3f", *map(float, p)) for p in points)))
          fbytes = []
          for f in faces:
              if len(f) == 3:
                  fbytes.append(struct.pack("<4I", f[0], f[1], f[2], TRI))
              elif len(f) == 4:
                  fbytes.append(struct.pack("<4I", *f))
              else:
                  raise GoZError(f"face with {len(f)} corners: GoZ holds triangles and quads only")
              if max(f) >= npts or min(f) < 0:
                  raise GoZError(f"face {f} indexes outside 0..{npts - 1}")
          chunks.append(_array_block(TAG_FACES, nf, b"".join(fbytes)))
          if uvs is not None:
              if len(uvs) != nf:
                  raise GoZError(f"{len(uvs)} UV entries for {nf} faces")
              ub = []
              for fu in uvs:
                  pairs = list(fu) + [(0.0, 0.0)] * (4 - len(fu))
                  ub.append(b"".join(struct.pack("<2f", float(u), float(v)) for u, v in pairs[:4]))
              chunks.append(_array_block(TAG_UVS, nf, b"".join(ub)))
          if polypaint is not None:
              if len(polypaint) != npts:
                  raise GoZError(f"{len(polypaint)} colours for {npts} points")
              chunks.append(_array_block(TAG_POLYPAINT, npts, b"".join(
                  bytes((int(b) & 255, int(g) & 255, int(r) & 255, 0)) for r, g, b in polypaint)))
          if mask is not None:
              if len(mask) != npts:
                  raise GoZError(f"{len(mask)} mask values for {npts} points")
              chunks.append(_array_block(TAG_MASK, npts, b"".join(
                  struct.pack("<H", int(round((1.0 - min(max(float(m), 0.0), 1.0)) * 65535)))
                  for m in mask)))
          if polygroups is not None:
              if len(polygroups) != nf:
                  raise GoZError(f"{len(polygroups)} polygroups for {nf} faces")
              chunks.append(_array_block(TAG_GROUPS, nf, b"".join(
                  struct.pack("<H", int(g) & 0xFFFF) for g in polygroups)))
          tag_of = {"diffuse_map": TAG_DIFFUSE, "normal_map": TAG_NORMAL,
                    "displacement_map": TAG_DISPLACEMENT}
          for kind, p in (maps or {}).items():
              chunks.append(_string_block(tag_of[kind], p))
          chunks.append(_string_block(TAG_MATERIAL, "GoZMat_" + (material or name)))
          chunks.append(b"\0" * 16)
          data = b"".join(chunks)
          os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
          tmp = path + ".tmp"
          with open(tmp, "wb") as fh:
              fh.write(data)
          os.replace(tmp, path)
          return {"path": path, "bytes": len(data)}
      
      
      def goz_to_obj_axes(points):
          """GoZ (ZBrush) axes to OBJ-export axes: flip Y and Z [inferred from Maya's GoZ_Info
          EXPORT_FLIP_Y/Z, verify with one SubTool exported both ways]."""
          return [(x, -y, -z) for x, y, z in points]
      
      
      obj_to_goz_axes = goz_to_obj_axes            # the flip is its own inverse
      
      
      def bbox(points):
          if not points:
              return None
          xs, ys, zs = zip(*points)
          return [min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)]
      
      
      # --------------------------------------------------------------------------------------------
      # Names
      # --------------------------------------------------------------------------------------------
      
      _BAD = re.compile(r"[^A-Za-z0-9_]")
      
      
      def check_names(names):
          """GoZ needs unique Tool and SubTool names across every loaded Tool, without spaces or
          non-alphanumeric characters (GoZ docs "Restrictions"). Underscore is accepted here because
          ZBrush's own names use it (PM3D_Sphere3D, Polymesh3D_1) [added]. Returns a list of
          {"name", "problem"}."""
          issues, seen = [], {}
          for n in names:
              key = n.lower()
              if key in seen:
                  issues.append({"name": n, "problem": f"duplicate of {seen[key]!r}"})
              else:
                  seen[key] = n
              if not n:
                  issues.append({"name": n, "problem": "empty"})
              elif _BAD.search(n):
                  issues.append({"name": n, "problem": "characters other than A-Z, a-z, 0-9, _"})
              elif n[0].isdigit():
                  issues.append({"name": n, "problem": "starts with a digit [added caution]"})
          return issues
      
      
      # --------------------------------------------------------------------------------------------
      # Folder protocol
      # --------------------------------------------------------------------------------------------
      
      def read_pff(path):
          """GoZ preference files: 'KEY = value' per line (GoZ SDK appendix)."""
          out = {}
          with open(path, errors="ignore") as fh:
              for line in fh:
                  if "=" in line:
                      k, v = line.split("=", 1)
                      out[k.strip()] = v.strip().strip('"')
          return out
      
      
      def set_pff_value(path, key, value, backup_dir=None):
          """Set KEY = value in a PFF file, keeping a timestamped copy first (never overwrite
          without a copy). Returns {"path", "backup", "old", "new"}."""
          with open(path, errors="ignore") as fh:
              lines = fh.read().splitlines()
          stamp = time.strftime("%Y%m%d-%H%M%S")
          bdir = backup_dir or os.path.dirname(path)
          os.makedirs(bdir, exist_ok=True)
          backup = os.path.join(bdir, os.path.basename(path) + f".{stamp}.bak")
          shutil.copy2(path, backup)
          old, done = None, False
          for i, line in enumerate(lines):
              if "=" in line and line.split("=", 1)[0].strip() == key:
                  old = line.split("=", 1)[1].strip()
                  lines[i] = f"{key}\t=\t{value}"
                  done = True
          if not done:
              lines.append(f"{key}\t=\t{value}")
          with open(path, "w") as fh:
              fh.write("\n".join(lines) + "\n")
          return {"path": path, "backup": backup, "old": old, "new": value}
      
      
      def project_dir(pixologic=PIXOLOGIC):
          p = os.path.join(pixologic, "GoZBrush", "GoZ_ProjectPath.txt")
          try:
              with open(p) as fh:
                  d = fh.read().strip()
              return d or os.path.join(pixologic, "GoZProjects", "Default")
          except OSError:
              return os.path.join(pixologic, "GoZProjects", "Default")
      
      
      def identifier(goz_path):
          """GoZ object identifier: the full path without the extension (GoZ SDK
          "GoZ_ObjectList.txt")."""
          return os.path.splitext(os.path.abspath(goz_path))[0]
      
      
      def write_object_list(goz_paths, pixologic=PIXOLOGIC, backup_dir=None):
          """GoZBrush/GoZ_ObjectList.txt: one identifier per line (an empty list is one empty
          line). The previous list is copied first."""
          p = os.path.join(pixologic, "GoZBrush", "GoZ_ObjectList.txt")
          backup = None
          if os.path.exists(p):
              bdir = backup_dir or os.path.dirname(p)
              os.makedirs(bdir, exist_ok=True)
              backup = os.path.join(bdir, "GoZ_ObjectList.txt." + time.strftime("%Y%m%d-%H%M%S") + ".bak")
              shutil.copy2(p, backup)
          ids = [identifier(g) for g in goz_paths]
          with open(p, "w") as fh:
              fh.write("\n".join(ids) + "\n")
          return {"path": p, "identifiers": ids, "backup": backup}
      
      
      def app_info(app="Maya", pixologic=PIXOLOGIC):
          """Flip flags and commands of a GoZ target app (GoZApps/<app>/GoZ_Info.txt, GoZ SDK
          IV.1) plus its configured PATH, which can be stale: on this Mac Maya's GoZ_Config.txt
          points at /Applications/Autodesk/maya2017 although no Maya is installed (2026-09-24)."""
          d = os.path.join(pixologic, "GoZApps", app)
          out = {"app": app, "dir": d, "exists": os.path.isdir(d)}
          if out["exists"]:
              for f in ("GoZ_Info.txt", "GoZ_Config.txt"):
                  p = os.path.join(d, f)
                  if os.path.exists(p):
                      out[f] = read_pff(p)
              cfg_path = out.get("GoZ_Config.txt", {}).get("PATH")
              out["path_exists"] = bool(cfg_path) and os.path.exists(cfg_path)
          return out
      
      
      def push_to_zbrush(goz_paths, as_subtool=None, pixologic=PIXOLOGIC, backup_dir=None,
                         dry_run=False):
          """Open .GoZ files in the running ZBrush through GoZBrushFromApp (GoZ SDK III.8)
          [verify end to end]. as_subtool None leaves Preferences > GoZ > Import as SubTool as it
          is; True or False rewrites IMPORT_AS_SUBTOOL in GoZBrush/GoZ_Config.txt after a backup
          (a shared, user-visible setting: restore it with restore_pff). ZBrush comes to the front.
          The identifier must stay the same for a mesh already linked to ZBrush, or ZBrush makes a
          new SubTool instead of updating it (GoZ SDK IV.6)."""
          for g in goz_paths:
              read_goz(g, data=False)                       # refuse to hand ZBrush a broken file
          app = os.path.join(pixologic, "GoZBrush", "GoZBrushFromApp.app")
          plan = {"object_list": [identifier(g) for g in goz_paths], "launcher": app,
                  "as_subtool": as_subtool, "dry_run": dry_run}
          if dry_run:
              return plan
          if not os.path.exists(app):
              raise GoZError(f"{app} missing: run ZBrush once so GoZ installs itself")
          if as_subtool is not None:
              plan["config"] = set_pff_value(os.path.join(pixologic, "GoZBrush", "GoZ_Config.txt"),
                                             "IMPORT_AS_SUBTOOL", "TRUE" if as_subtool else "FALSE",
                                             backup_dir)
          plan["list"] = write_object_list(goz_paths, pixologic, backup_dir)
          r = subprocess.run(["open", "-a", app], capture_output=True, text=True, timeout=60)
          plan["open_rc"], plan["open_err"] = r.returncode, r.stderr.strip()
          return plan
      
      
      def restore_pff(backup, path):
          """Put a backed-up GoZ preference file back (copy, the backup stays)."""
          shutil.copy2(backup, path)
          return {"restored": path, "from": backup}
      
      
      def _cli(argv=None):
          import argparse
          import json
          ap = argparse.ArgumentParser(description="Inspect GoZ files and the GoZ folder")
          ap.add_argument("cmd", choices=("info", "dump", "app"))
          ap.add_argument("target")
          ns = ap.parse_args(argv)
          if ns.cmd == "info":
              res = goz_info(ns.target)
          elif ns.cmd == "dump":
              r = read_goz(ns.target)
              res = {k: v for k, v in r.items() if k != "flags"}
          else:
              res = app_info(ns.target)
          print(json.dumps(res, indent=1, default=repr))
          return 0
      
      
      if __name__ == "__main__":
          import sys
          sys.exit(_cli())
      
    • zb_plugin_ops.py 29.6 KB
      """
      zb_plugin_ops: the per-file and per-SubTool operations of batch jobs, plugin automation
      (UV Master, Decimation Master, Multi Map Exporter), map export, GoZ by path, SubTool
      inventory and naming. Runs INSIDE ZBrush 2026 (CPython 3.11.9).
      
      It builds on scenario-zbrush-expert's zb_ops (checked presses, stats, exports, DynaMesh, ZRemesher,
      Divide, Project All, UV unwrap, decimate) and never re-implements them; the ZBrush handle is
      zb_ops.zbc, so tests inject one fake for both modules. Call it from the agent side with
      zb_batch.remote_call("zb_plugin_ops", "<func>", ...), which puts both skills' script folders
      on sys.path only while importing (Maxon's shared-interpreter rule).
      
      Status: NOT YET RUN IN ZBRUSH. Offline tests run the logic against a fake zbc
      (tests/code/zbrush-automation/test_zb_plugin_ops.py); live checks are the live_a* files.
      Path evidence tags as in zb_ops: [bridge] proven on 2026.2.1, [doc] Maxon docs, [macro]
      shipped 2026 macro, [cb] community bridge tested on 2026.2 (usd-portal), [verify] unknown.
      
      Refactor 2026-09-24 (offline only): make_low(auto_groups=True) is cgside's Auto Groups then
      ZRemesher Keep Groups; divide_project needs a checkpoint and goes through the lead's safe
      zb_ops.project_all (versioned save first, morph target each pass, a layer on the last pass,
      Dist 0.1, gates); SubTool loops run under zb_ops.quiet() (show_actions 0).
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import os
      import time
      
      import zb_ops
      
      PATHS = {
          "polymesh_star": ["Tool:PolyMesh3D"],                              # [cb] usd-portal
          "import": ["Tool:Import"],                                         # [bridge] exists
          "duplicate": ["Tool:SubTool:Duplicate"],                           # [bridge] needs Edit
          "move_up": ["Tool:SubTool:MoveUp", "Tool:SubTool:Move Up"],        # [macro] MadPony
          "move_down": ["Tool:SubTool:MoveDown", "Tool:SubTool:Move Down"],  # [macro]
          "subtool_title": ["Tool:SubTool:ItemInfo"],                        # [cb] IGetTitle, minus ". "
          "export_scale": ["Tool:Export:Scale"],                             # [doc] Scale Master page
          "export_x_offset": ["Tool:Export:X Offset", "Tool:Export:XOffset"],  # [verify]
          "export_y_offset": ["Tool:Export:Y Offset", "Tool:Export:YOffset"],  # [verify]
          "export_z_offset": ["Tool:Export:Z Offset", "Tool:Export:ZOffset"],  # [verify]
          "uv_map_size": ["Tool:UV Map:UV Map Size"],                        # [doc] reference [verify]
          "nm_clone": ["Tool:Normal Map:Clone NM"],                          # [doc] reference [verify]
          "nm_create": ["Tool:Normal Map:Create NormalMap"],                 # [doc] reference [verify]
          "nm_tangent": ["Tool:Normal Map:Tangent"],                         # [doc] reference [verify]
          "disp_clone": ["Tool:Displacement Map:Clone Disp"],                # [doc] reference [verify]
          "texture_export": ["Texture:Export"],                              # [cb] usd-portal
          "alpha_export": ["Alpha:Export"],                                  # [verify]
          "texture_flip_v": ["Texture:Flip V", "Texture:FlipV"],             # [verify]
          "dm_delete_caches": ["Zplugin:Decimation Master:Delete caches",
                               "Zplugin:Decimation Master:Utilities:Delete caches"],  # [doc] label [verify]
          "mme_create": ["Zplugin:Multi Map Exporter:Create All Maps"],      # [bridge] exists
          "mme_normal": ["Zplugin:Multi Map Exporter:Normal Map"],           # [verify]
          "mme_disp": ["Zplugin:Multi Map Exporter:Displacement Map"],       # [verify]
          "mme_texture": ["Zplugin:Multi Map Exporter:Texture From Polypaint"],  # [verify]
          "mme_ao": ["Zplugin:Multi Map Exporter:Ambient Occlusion"],        # [verify]
          "mme_cavity": ["Zplugin:Multi Map Exporter:Cavity"],               # [verify]
          "mme_flip_v": ["Zplugin:Multi Map Exporter:Flip V"],               # [verify]
          "mme_map_size": ["Zplugin:Multi Map Exporter:Map Size"],           # [verify]
      }
      
      UV_MASTER_MAX_FACES = 150_000        # UV Master doc: 100k to 150k recommended maximum
      
      
      class PluginOpError(zb_ops.ZBOpError):
          pass
      
      
      def _z():
          return zb_ops._z()
      
      
      def _res(key, required=True):
          return zb_ops.resolve(PATHS.get(key, key) if isinstance(key, str) else key, required)
      
      
      # --------------------------------------------------------------------------------------------
      # Pure helpers (also used on the agent side and in offline tests)
      # --------------------------------------------------------------------------------------------
      
      def divide_levels(low_faces, source_faces, max_levels=6, max_faces=8_000_000):
          """Smallest n with low_faces * 4**n >= source_faces, so the projected stack can hold the
          source detail (Divide multiplies faces by 4: Pablo, Logic Part 7), clamped to max_levels
          and to max_faces at the top level [added]."""
          if not low_faces or low_faces <= 0:
              raise ValueError("low_faces must be > 0")
          n = 0
          while low_faces * 4 ** n < source_faces and n < max_levels:
              n += 1
          while n > 0 and low_faces * 4 ** n > max_faces:
              n -= 1
          return n
      
      
      def decimation_percent(points, target_faces=None, target_points=None, lo=0.01, hi=100.0):
          """Decimation Master '% of decimation' to reach a triangle count. The percentage applies
          to points (ijacobs' 2013 script: 75000 / points * 100; cgside: points / 10 in
          thousands), and a closed triangle mesh has about two faces per point (Euler) [added], so
          target_points = target_faces / 2. Clamped to the slider range 0.01 to 100 (DM doc)."""
          if target_points is None:
              if target_faces is None:
                  raise ValueError("give target_faces or target_points")
              target_points = target_faces / 2.0
          pct = 100.0 * float(target_points) / float(points)
          return round(min(max(pct, lo), hi), 3)
      
      
      def clean_title(title):
          """SubTool titles end with '. ' (usd-portal drops the last 2 characters; MadPony 00:00:32)."""
          return (title or "").rstrip().rstrip(".").strip()
      
      
      # --------------------------------------------------------------------------------------------
      # Session hygiene and probes
      # --------------------------------------------------------------------------------------------
      
      def memory():
          """zbrush_info 2 runtime s, 3 physical memory used, 4 virtual, 5 free [doc]; units are
          not documented [verify], log them per file to see growth across a batch."""
          z = _z()
          return {"runtime_s": z.zbrush_info(2), "mem_used": z.zbrush_info(3),
                  "virtual": z.zbrush_info(4), "free": z.zbrush_info(5)}
      
      
      def probe(paths):
          """Path discovery: exists, enabled, value, range, title and popup info per item path.
          Paths ignore case and spaces (SDK Item Paths); a miss means a wrong label or a missing
          precondition (Edit off, not a PolyMesh3D, plugin absent)."""
          z = _z()
          out = {}
          for p in paths:
              r = {"exists": bool(z.exists(p))}
              if r["exists"]:
                  for k, fn in (("enabled", z.is_enabled), ("value", z.get), ("min", z.get_min),
                                ("max", z.get_max), ("title", z.get_title), ("info", z.get_info)):
                      try:
                          r[k] = fn(p)
                      except Exception as e:  # a button has no range, a slider no title...
                          r[k] = f"error: {e!r}"[:120]
              out[p] = r
          return out
      
      
      def press_guarded(path, file=None, overwrite=False, check_enabled=True):
          """exists + enabled, set_next_filename when a file is involved, press, then the preset
          check: has_next_filename() still True after the press means the button did not consume
          it (a plugin with its own dialog, e.g. FBX ExportImport or USD Format)."""
          z = _z()
          p = _res([path])
          if check_enabled and not z.is_enabled(p):
              raise PluginOpError(f"{p} is disabled in the current state")
          t0 = time.time()
          if file:
              file = os.path.abspath(file)
              os.makedirs(os.path.dirname(file), exist_ok=True)
              if os.path.exists(file) and not overwrite:
                  raise PluginOpError(f"{file} exists (pass overwrite=True; a .bak is kept)")
              if os.path.exists(file):
                  os.replace(file, file + time.strftime(".%Y%m%d-%H%M%S.bak"))
              z.set_next_filename(file)
          z.press(p)
          z.update(redraw_ui=True)
          out = {"path": p, "seconds": round(time.time() - t0, 3)}
          if file:
              pending = bool(z.has_next_filename())
              out.update(file=file, preset_pending=pending, wrote=os.path.exists(file),
                         bytes=os.path.getsize(file) if os.path.exists(file) else 0)
              if pending:
                  z.set_next_filename()          # clear it so the next dialog is not pre-filled
                  out["warning"] = "preset not consumed: a dialog or plugin ignored it"
          return out
      
      
      def press_macro(name, folder="AgentHelpers"):
          """Press a ZScript macro button (for IKeyPress or MergeUndo tricks Python lacks). Macros
          load from <Asset Directory>/ZStartup/Macros/<folder>/<name>.txt, name of 8+ characters,
          new folder needs a restart (interfaces doc). Path form [verify]."""
          z = _z()
          cands = [f"Macro:{folder}:{name}", f"Macro:Macros:{folder}:{name}", f"Macro:{name}"]
          for p in cands:
              if z.exists(p):
                  z.press(p)
                  z.update(redraw_ui=True)
                  return {"pressed": p}
          raise PluginOpError(f"macro not found as any of {cands}: installed, 8+ chars, restarted?")
      
      
      # --------------------------------------------------------------------------------------------
      # Per-file steps
      # --------------------------------------------------------------------------------------------
      
      def _tool_name():
          return _z().get_active_tool_path().rsplit("/", 1)[-1]
      
      
      def export_settings():
          """Export Scale and offsets: an OBJ import sets them, so exports return to the input
          size (Outgang [00:24:03], [00:31:27]); Scale 0 means never set."""
          z = _z()
          out = {}
          for k in ("export_scale", "export_x_offset", "export_y_offset", "export_z_offset"):
              p = _res(k, required=False)
              out[k] = z.get(p) if p else None
          return out
      
      
      def import_mesh(path, expect_points=None, expect_faces=None, frame=True):
          """OBJ (or GoZ) as a NEW tool: press the PolyMesh3D star, preset the file, Tool:Import,
          then draw and enter Edit when Edit is off (usd-portal _ZS_SEND, tested on 2026.2: never
          SimpleBrush, and Import onto an existing tool REPLACES its active SubTool). The tool
          takes the file name, so stage inputs under unique names first (zb_batch.stage_input)."""
          z = _z()
          path = os.path.abspath(path)
          if not os.path.exists(path):
              raise PluginOpError(f"input missing: {path}")
          tools_before = z.get_tool_count()
          z.press(_res("polymesh_star"))
          z.set_next_filename(path)
          z.press(_res("import"))
          z.update(redraw_ui=True)
          pending = bool(z.has_next_filename())
          if pending:
              z.set_next_filename()
              raise PluginOpError(f"Tool:Import did not consume the preset for {path}: dialog?")
          drew = False
          if z.get("Transform:Edit") < 0.5:
              z.canvas_click(10, 10, 10, 20)            # usd-portal: only when Edit is off
              z.set("Transform:Edit", 1)
              drew = True
          zb_ops.ensure_edit()
          if frame:
              fp = zb_ops.resolve("fit", required=False)
              if fp:
                  z.press(fp)
          st = zb_ops.stats()
          out = {"tool": _tool_name(), "tools_before": tools_before, "tools_after": z.get_tool_count(),
                 "drew": drew, "stats": st, "export": export_settings(), "warnings": []}
          if expect_points is not None and st.get("points") != expect_points:
              out["warnings"].append(f"points {st.get('points')} != file {expect_points} (welded or split?)")
          if expect_faces is not None and st.get("faces") != expect_faces:
              out["warnings"].append(f"faces {st.get('faces')} != file {expect_faces}")
          return out
      
      
      def make_low(target_k=5.0, adaptive=None, keep_groups=None, adaptive_size=None, mode=None,
                   auto_groups=False):
          """Keep the active SubTool as the projection source: Duplicate it (Edit mode needed,
          usd-portal) and ZRemesher the copy (zb_ops.zremesher, target in thousands). Returns
          ctx {"source", "low"} indices for later steps.
          auto_groups=True is cgside's recipe (MyhwQvkcnwI 00:01:43 to 00:02:51, 00:08:38 to
          00:09:42): Polygroups > Auto Groups (one group per separate piece), then ZRemesher with
          Keep Groups, so the new topology follows the parts instead of bridging them. Done on the
          copy [added], so the source keeps its own groups. It replaces the copy's polygroups:
          leave it off when the input's groups carry meaning (material or UV splits). With Adapt
          on, groups make ZRemesher overshoot the target (scenario-zbrush-retopology-export)."""
          z = _z()
          zb_ops.ensure_edit()
          src = z.get_active_subtool_index()
          n0 = z.get_subtool_count()
          z.press(_res("duplicate"))
          z.update(redraw_ui=True)
          if z.get_subtool_count() != n0 + 1:
              raise PluginOpError(f"Duplicate did not add a SubTool ({n0} -> {z.get_subtool_count()})")
          low = z.get_active_subtool_index()
          if low == src:                                  # duplicate not auto-selected [verify]
              low = src + 1
              z.select_subtool(low)
          groups = None
          if auto_groups:
              groups = zb_ops.polygroups("auto")
              if keep_groups is None:
                  keep_groups = True
          zr = zb_ops.zremesher(target_k, adaptive=adaptive, keep_groups=keep_groups,
                                adaptive_size=adaptive_size, mode=mode)
          return {"ctx": {"source": src, "low": low}, "zremesher": zr, "auto_groups": groups,
                  "keep_groups": keep_groups}
      
      
      def _select(index):
          z = _z()
          if index is not None and z.select_subtool(int(index)) == -1:
              raise PluginOpError(f"no SubTool {index}")
      
      
      def uv_master(index=None, polygroups=None, symmetry=None, max_faces=UV_MASTER_MAX_FACES):
          """UV Master Unwrap on one SubTool at its lowest level. Guards: no subdivision levels
          (unwrap the base, UV Master doc) and faces under the 150k recommendation (the doc: up to
          5 minutes at 150k). A ZScript that pressed UV Master lost control for good
          (marcus_civis 2014); from Python [verify live_a03]: run this as its own bridge call with
          a short timeout. No Work On Clone here: a batch low has no levels and no polypaint."""
          _select(index)
          st = zb_ops.stats()
          if st.get("sdiv_max", 1) > 1:
              raise PluginOpError("SubTool has subdivision levels: unwrap the base mesh before Divide")
          if st.get("faces", 0) > max_faces:
              raise PluginOpError(f"{st['faces']} faces > {max_faces}: UV Master is slow and unstable "
                                  "above 150k (doc); unwrap a ZRemeshed low instead")
          r = zb_ops.uv_unwrap("uvmaster", auto_seams=None, symmetry=symmetry, polygroups=polygroups)
          if not r.get("uv_bbox"):
              raise PluginOpError("UV Master returned but the SubTool has no UVs")
          return r
      
      
      def uv_native(index=None, auto_seams=True, symmetry=None):
          """Fallback without the plugin: Tool > UV Map > Create (Unwrap) (2023+, [doc])."""
          _select(index)
          r = zb_ops.uv_unwrap("native", auto_seams=auto_seams, symmetry=symmetry)
          if not r.get("uv_bbox"):
              raise PluginOpError("native Unwrap returned but the SubTool has no UVs")
          return r
      
      
      def export_subtool(index, path, overwrite=True):
          """One SubTool to OBJ or .GoZ by path (the extension picks the format; .GoZ by path is
          usd-portal's reliable carrier of UVs and polypaint). zb_ops.export_obj does the preset,
          the press and the on-disk check."""
          _select(index)
          return zb_ops.export_obj(path, overwrite=overwrite)
      
      
      def divide_project(low, source, levels=None, max_levels=6, max_faces=8_000_000,
                         dist=zb_ops.PROJECT_DIST, pa_blur=None, checkpoint=None, store_mt=True,
                         layer="last"):
          """Divide the low and Project All from the source after each Divide (cgside's recipe;
          Maxon: only source and target visible). levels None picks divide_levels(). Returns faces
          per level; each level should be 4x the previous.
          Every pass goes through the lead's safe zb_ops.project_all: `checkpoint` (an absolute
          .ztl path, required) is saved versioned before the FIRST Project All (cgside saves
          before ProjectAll "since it might crash", 00:02:19; the later divides replay from it);
          Dist is set explicitly (0.1 unless given); a morph target is stored at each new top
          level (the previous one dies with the Divide's point-count change); layer "last" adds
          one New Layer on the final pass only, the top level where FlippedNormals set up both
          nets (Zp07GW3rND0 00:02:13); True adds one per pass [verify Divide with a recording
          layer, live_a02]. The gates of each pass are merged into "gate"."""
          z = _z()
          _select(source)
          src_faces = zb_ops.stats().get("faces")
          _select(low)
          low_faces = zb_ops.stats().get("faces")
          n = levels if levels is not None else divide_levels(low_faces, src_faces, max_levels, max_faces)
          vis = [s for s in zb_ops.subtools() if s["visible"]]
          if len(vis) != 2:
              raise PluginOpError(f"{len(vis)} visible SubTools: Project All wants only source and "
                                  "target visible (hide the others first)")
          if n and checkpoint is None:
              raise PluginOpError("divide_project needs checkpoint='/abs/name.ztl' (versioned save "
                                  "before the first Project All) or False right after a save")
          per_level = []
          gate = {"ok": True, "problems": [], "warnings": []}
          with zb_ops.quiet():
              for i in range(int(n)):
                  _select(low)
                  d = zb_ops.divide(1)
                  lay = (i == int(n) - 1) if layer == "last" else bool(layer)
                  p = zb_ops.project_all(dist=dist, pa_blur=pa_blur,
                                         checkpoint=checkpoint if i == 0 else False,
                                         store_mt=store_mt, layer=lay, exclusive=True)
                  g = p.get("gate") or {}
                  gate["ok"] = gate["ok"] and g.get("ok", True)
                  gate["problems"] += [f"level {i + 2}: {m}" for m in g.get("problems", [])]
                  gate["warnings"] += [f"level {i + 2}: {m}" for m in g.get("warnings", [])]
                  per_level.append({"faces": d["faces_after"], "ratio": d["ratio"],
                                    "volume_after": p["volume_after"], "gate": g,
                                    "checkpoint": (p.get("checkpoint") or {}).get("path"),
                                    "morph_target": p["morph_target"].get("stored"),
                                    "layer": p["layer"].get("created")})
                  z.update(redraw_ui=True)
          out = {"levels": n, "source_faces": src_faces, "low_faces": low_faces,
                 "per_level": per_level, "top_faces": per_level[-1]["faces"] if per_level else low_faces,
                 "checkpoint": per_level[0]["checkpoint"] if per_level else None, "gate": gate}
          if gate["problems"] or gate["warnings"]:
              out["repair"] = zb_ops.REPAIR
          return out
      
      
      def _lowest_level():
          """SDiv to 1 by the slider, else Lower Res presses (capped)."""
          z = _z()
          p = zb_ops.resolve("sdiv", required=False)
          if p:
              z.set(p, 1)
              z.update(redraw_ui=True)
              if z.get(p) <= 1.01:
                  return 1
          lr = zb_ops.resolve("lower_res", required=False)
          for _ in range(12):
              if not lr or not z.is_enabled(lr):
                  break
              z.press(lr)
          return z.get(p) if p else None
      
      
      def normal_map(index, path, size=2048, tangent=True, smooth=True, border=8, level=None,
                     overwrite=True):
          """Tangent normal map of the SubTool's levels (lowest vs highest of the SAME SubTool:
          AskZBrush n0qqpwvn-jA [00:01:14]) with the SDK call create_normal_map (local_coordinates
          True = tangent; the default False is world space), then Tool > Normal Map > Clone NM and
          Texture > Export by preset [verify both paths]. The PNG is exported as ZBrush makes it:
          zb_batch.fix_normal_map() flips V (and green for DirectX engines) on the agent side, where
          the result can be checked. level None bakes from the lowest level (forms and detail);
          level = top - 1 keeps only micro detail (AskZBrush 2zDAtaQqwh8 [00:02:53])."""
          z = _z()
          _select(index)
          st = zb_ops.stats()
          if st.get("uv_bbox") is None:
              raise PluginOpError("no UVs: unwrap before creating maps")
          if st.get("sdiv_max", 1) < 2:
              raise PluginOpError("no subdivision levels: Divide and project first (the map compares "
                                  "the current level with the highest)")
          if level is None:
              level = _lowest_level()
          else:
              zb_ops.set_checked("sdiv", int(level), tol=0.5)
          t0 = time.time()
          z.create_normal_map(int(size), int(size), bool(smooth), 0, int(border), 1000000, bool(tangent))
          z.update(redraw_ui=True)
          z.press(_res("nm_clone"))
          z.update(redraw_ui=True)
          res = zb_ops._file_op(PATHS["texture_export"], path, overwrite)   # preset + press + disk check
          res.update(level=level, size=[int(size), int(size)], tangent=bool(tangent),
                     create_s=round(time.time() - t0, 2))
          return res
      
      
      def displacement_map(index, path, size=2048, smooth=True, border=8, overwrite=True):
          """Displacement map with the SDK call create_displacement_map (UVs and the lowest level
          needed: SDK stub), then Tool > Displacement Map > Clone Disp (to the Alpha palette) and
          Alpha > Export by preset [verify both paths and the bit depth written]. For 32-bit EXR or
          UDIMs use Multi Map Exporter instead (Mid 0, Scale 1 for 32-bit: MME doc)."""
          z = _z()
          _select(index)
          st = zb_ops.stats()
          if st.get("uv_bbox") is None or st.get("sdiv_max", 1) < 2:
              raise PluginOpError("displacement needs UVs and subdivision levels")
          level = _lowest_level()
          z.create_displacement_map(int(size), int(size), bool(smooth), 0, int(border), 1000000, True)
          z.update(redraw_ui=True)
          z.press(_res("disp_clone"))
          res = zb_ops._file_op(PATHS["alpha_export"], path, overwrite)
          res.update(level=level, size=[int(size), int(size)])
          return res
      
      
      def export_all(out_dir, ext=".obj", visible_only=True, prefix_index=True, restore=True):
          """Every (visible, folder aware) SubTool to its own file, named from the SubTool name
          (Maxon ex_mod_subtool_export; usd-portal's folder-aware test). OBJ cannot hold several
          SubTools with names in one file (Gaboury echGh0tZEts [00:00:34]); one file per SubTool
          keeps the names without the FBX dialog. Returns the file list."""
          z = _z()
          keep = z.get_active_subtool_index()
          out = []
          try:
              with zb_ops.quiet():
                  for s in zb_ops.subtools():
                      if visible_only and not s["visible"]:
                          continue
                      nm = subtool_name(s["index"])["name"] or f"subtool_{s['index']:02d}"
                      safe = "".join(c if c.isalnum() or c == "_" else "_" for c in nm)
                      base = f"{s['index']:03d}_{safe}" if prefix_index else safe
                      r = zb_ops.export_obj(os.path.join(out_dir, base + ext), overwrite=True)
                      out.append({"index": s["index"], "name": nm, **r})
          finally:
              if restore:
                  z.select_subtool(keep)
          return {"count": len(out), "files": out}
      
      
      def decimated_preview(index, path, target_faces=20000, percent=None, keep_uvs=None,
                            overwrite=True):
          """Decimation Master on the SubTool, then OBJ export. Run it LAST in a file's recipe,
          after the ZTL checkpoint: it replaces the SubTool's mesh (no undo control from Python)
          and it is the plugin press most likely to keep control (ijacobs 2013, cgside 2022).
          The cache is keyed by the tool name (DM doc: same-named tools reuse stale caches), so
          unique staged names matter. Needs unique SubTool names (DM doc)."""
          _select(index)
          st = zb_ops.stats()
          pct = percent if percent is not None else decimation_percent(st["points"], target_faces)
          d = zb_ops.decimate(pct, preprocess=True, keep_uvs=keep_uvs)
          ex = zb_ops.export_obj(path, overwrite=overwrite)
          return {"percent": pct, "decimate": d, "export": ex}
      
      
      def delete_dm_caches():
          """Decimation Master Utilities > Delete caches (cannot be undone; DM doc). Use between
          long batches to free disk [verify path and whether it asks for confirmation]."""
          return press_guarded(PATHS["dm_delete_caches"][0], check_enabled=False)
      
      
      def mme_create_all(base_path, maps=("normal",), size=None, flip_v=None):
          """Multi Map Exporter: set the map switches, then Create All Maps, which opens a save
          dialog (MME doc) that set_next_filename may or may not feed [verify live_a02]. Returns
          press_guarded's report: preset_pending True means the dialog is waiting."""
          z = _z()
          keys = {"normal": "mme_normal", "displacement": "mme_disp", "texture": "mme_texture",
                  "ao": "mme_ao", "cavity": "mme_cavity"}
          for name, key in keys.items():
              p = _res(key, required=False)
              if p:
                  z.set(p, 1 if name in maps else 0)
          if size is not None:
              zb_ops.set_checked(PATHS["mme_map_size"], size, tol=0.5)
          if flip_v is not None:
              zb_ops.set_checked(PATHS["mme_flip_v"], 1 if flip_v else 0, tol=0.5)
          return press_guarded(PATHS["mme_create"][0], file=base_path, overwrite=True,
                               check_enabled=False)
      
      
      # --------------------------------------------------------------------------------------------
      # SubTool reports and naming
      # --------------------------------------------------------------------------------------------
      
      def subtool_name(index=None):
          """Name candidates: get_active_tool_path() tail (Maxon export example says it includes
          the SubTool name [verify]) and Tool:SubTool:ItemInfo title minus '. ' (usd-portal)."""
          z = _z()
          _select(index)
          path_name = _tool_name()
          title = None
          p = _res("subtool_title", required=False)
          if p:
              try:
                  title = clean_title(z.get_title(p))
              except Exception:
                  title = None
          return {"from_path": path_name, "from_title": title, "name": title or path_name}
      
      
      def inventory(restore=True):
          """Per SubTool: index, name, points, faces, levels, UVs, visibility (folder aware, via
          zb_ops.subtools), folder, status bits. The report other personas read before GoZ,
          Decimation Master or export. Selecting each SubTool changes the active one: restored."""
          z = _z()
          keep = z.get_active_subtool_index()
          rows = []
          try:
              with zb_ops.quiet():
                  for s in zb_ops.subtools():
                      nm = subtool_name(s["index"])
                      st = zb_ops.stats()
                      rows.append({**s, "name": nm["name"], "name_path": nm["from_path"],
                                   "points": st.get("points"), "faces": st.get("faces"),
                                   "sdiv_max": st.get("sdiv_max"), "uv": st.get("uv_bbox") is not None,
                                   "volume": st.get("volume"), "solid": st.get("solid")})
          finally:
              if restore:
                  z.select_subtool(keep)
          names = [r["name"] for r in rows]
          dup = sorted({n for n in names if names.count(n) > 1})
          bad = [n for n in names if not n.replace("_", "").isalnum()]
          return {"tool": _tool_name(), "count": len(rows), "subtools": rows,
                  "totals": {"points": sum(r["points"] or 0 for r in rows),
                             "faces": sum(r["faces"] or 0 for r in rows)},
                  "duplicate_names": dup, "non_alphanumeric": bad}
      
      
      def rename_subtool(index, new_name, cap=500):
          """Rename without the text prompt: move the SubTool to the top (MoveUp while enabled),
          set_tool_path (renames the top SubTool only: MadPony w7RxSx1G1hQ, Maxon forum 2026),
          move it back. Folders change the press count [verify]; read the name back."""
          z = _z()
          _select(index)
          up = _res("move_up")
          moves = 0
          while z.is_enabled(up) and moves < cap:
              z.press(up)
              moves += 1
          if z.get_active_subtool_index() != 0:
              raise PluginOpError(f"SubTool did not reach the top after {moves} MoveUp presses")
          rc = z.set_tool_path(z.get_active_tool_index(), new_name)
          z.update(redraw_ui=True)
          down = _res("move_down")
          for _ in range(moves):
              z.press(down)
          now = z.get_active_subtool_index()
          nm = subtool_name(now)
          return {"index_before": index, "index_after": now, "moves": moves, "rc": rc,
                  "name": nm["name"], "ok": nm["name"] == new_name and now == index}
      
      
      def for_each_subtool(func, args=(), kwargs=None, visible_only=True, restore=True, freeze=False):
          """ZRepeat It equivalent (Pavlovich Workshop 45 [00:37:30]): run a zb_ops or
          zb_plugin_ops function on each (visible) SubTool, with faces before and after. The loop
          runs under zb_ops.quiet() (show_actions 0); freeze=True also wraps it in zbc.freeze
          (zb_ops.frozen), for deterministic ops only: never with a plugin press or a canvas
          export inside [added]."""
          z = _z()
          fn = getattr(zb_ops, func, None) or globals().get(func)
          if not callable(fn):
              raise PluginOpError(f"no function {func} in zb_ops or zb_plugin_ops")
          if freeze and func in ("uv_master", "decimated_preview", "mme_create_all", "delete_dm_caches",
                                 "decimate", "export_canvas"):
              raise PluginOpError(f"{func} presses a plugin or needs the canvas: not inside freeze")
          keep = z.get_active_subtool_index()
      
          def _loop():
              out = []
              with zb_ops.quiet():
                  for s in zb_ops.subtools():
                      if visible_only and not s["visible"]:
                          continue
                      z.select_subtool(s["index"])
                      f0 = zb_ops.stats().get("faces")
                      try:
                          r, err = fn(*args, **(kwargs or {})), None
                      except Exception as e:
                          r, err = None, f"{type(e).__name__}: {e}"
                      out.append({"index": s["index"], "faces_before": f0,
                                  "faces_after": zb_ops.stats().get("faces"), "result": r, "error": err})
              return out
      
          try:
              return zb_ops.frozen(_loop) if freeze else _loop()
          finally:
              if restore:
                  z.select_subtool(keep)
      
  • SKILL.md 18.4 KB
    ---
    name: scenario-zbrush-automation
    description: "Use when automating ZBrush 2026: batch processing a folder of OBJ or ZTL files, a ZBrush Python script or ZScript, recording a macro to learn a palette path, the ZBrush -script command line, scripting UV Master, Decimation Master or Multi Map Exporter, exporting every SubTool, GoZ or GoB round trips with Maya or Blender, renaming SubTools, or batch reports. Also when a plugin press never returns, a dialog stalls a batch, one file kills the whole run, or GoZ updates the wrong SubTool."
    license: MIT
    ---
    
    # ZBrush automation (tools and pipeline TD)
    
    An expert ZBrush TD automates with contracts and proof: every file is its own tool, every step is a separate call with a time limit, every output is read back from disk, and a hung plugin costs one file, never the batch. ZBrush has no timeout, no undo control from Python and dialogs that code cannot answer, so the safety lives on the agent side: the runner, the checkpoints, the report. 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.
    
    **REQUIRED BACKGROUND:** scenario-zbrush-expert (bridge, zb_launch, zb_ops, zb_audit, review loop, 2026 traps).
    
    **Status (2026-09-24, refactor pass 2):** every ZBrush call here is **not yet run in ZBrush** (no session assigned; the README's startup stall is still open). Offline: 56 tests pass (`tests/code/zbrush-automation/run_offline.py`), including a GoZ reader and writer matching ZBrush's own `ZResources/DXFStar.GoZ` byte for byte. Live suite: `tests/code/zbrush-automation/run_live.sh`.
    
    ## Stance (the expert delta)
    
    - **One file, one tool, one step per call.** "Only one zscript/plugin can be active at a time, so if your zscript calls someting like UV Mapper control passes to UV Mapper and no more commands will run" (marcus_civis, ZBrushCentral 2014); pressing Decimation Master's Pre-process ended ijacobs' script (2013) and forced cgside into a two-run helper (2022, [00:06:56]). So the batch is an agent-side loop (`zb_batch.run_folder`) that sends each step as its own bridge call with its own timeout, pings after a timeout, and restarts ZBrush after a hang. One long in-ZBrush script dies with its first stuck plugin. [added design]
    - **Plugins last, checkpoint first.** cgside saves before ProjectAll "since it might crash the program" [00:02:19]. `zb_ops.project_all` refuses to run without a versioned save and the recipe's project step passes one (`{stem}_preproject.ztl`), even with `save_ztl` off. Decimation Master runs last, after every valuable output is on disk, so a hang there leaves a `partial` file, not a lost one. [added]
    - **Leave the interpreter as found.** ZBrush has one Python for the session and the script folder is not on `sys.path` (Maxon Style Guide). Send code only through `remote_call`/`zb_launch.run`: modules are imported by path, then `sys.path`, `sys.modules`, stdout and the working directory are restored, also after an error. End a batch with `zb_launch.hygiene()` (empty lists).
    - **Dialogs are the automation killer; budget them before running.** A studio dropped ZBrush from its farm over Note dialogs (danvas 2017); "no way to detect whether they succeed or error" (BigRoyNL 2024); "if the export plugin, trigger a popup you are screwed" (m_adam, Maxon 2025). `set_next_filename` feeds built-in buttons only: FBX ExportImport shows options "You cannot escape" (ferdinand, Maxon 2025) and USD Format ignores the preset (usd-portal). Unattended exports are OBJ or `.GoZ` by path; `has_next_filename()` still true after a press means a dialog is probably waiting [added].
    - **Names are identity.** GoZ needs unique names "including between all the loaded Tools" (GoZ docs); Decimation Master caches and UV Master control maps are keyed by the tool name, so a new tool with an old name inherits stale data (plugin docs). Stage each input under a unique, sanitized name before import (`stage_input`); on this Mac's case-insensitive disk `rock.obj` and `Rock.OBJ` are one file [added].
    - **Learn paths from ZBrush, never guess.** Recordings show exact paths and hidden preconditions (Maxon ZScript manual; MadPony DnLlDJxfxHs [00:02:16]); Ctrl+hover shows a path. For an agent the cheapest oracle is ZBrush's own Activity log, which writes every press and set with its canonical path (`zb_batch.ActivityWatch`; proven by the 05:20 log). Then `zb_plugin_ops.probe()` confirms `exists`, range and title.
    - **Record once, apply to all.** Pavlovich replays one recording on every visible SubTool with ZRepeat It [00:37:30]; ZRepeat It is not shipped in 2026.2.1, the Python equivalent is `for_each_subtool`.
    - **Files, not live links, between apps.** GoZ sends the lowest subdivision level (GoZ docs; FlippedNormals [00:04:02]), updates by name, and raises a reprojection prompt when topology changed. An agent hands over `.GoZ` or OBJ files plus a manifest and lets the other app's agent import them.
    
    ## Establish first
    
    | Input                                                 | Changes                                        | Default when silent                                                                                                                                                      |
    | ----------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
    | Inputs: count, format, faces per file                 | timeouts, DynaMesh resolution, restart cadence | OBJ, audited first with zb_audit                                                                                                                                         |
    | Outputs: low count, map size and space, preview count | recipe params                                  | brief's numbers; else `DEFAULT_PARAMS` [added]                                                                                                                           |
    | Parts per file, meaningful polygroups                 | ZRemesher grouping                             | cgside: Auto Groups then ZRemesher Keep Groups on the copy, so the low follows the parts (MyhwQvkcnwI [00:08:38]); `zr_auto_groups=False` when the input's groups matter |
    | Target app: Maya, Blender, Unreal, Unity              | flip V, green channel, axes, format            | Maya: OBJ + tangent PNG flipped V, OpenGL green [added]                                                                                                                  |
    | Whose ZBrush                                          | restart rights                                 | a ZBrush the batch did not start is never restarted or quit                                                                                                              |
    | Watched or unattended                                 | pilot size, screenshot use                     | pilot of one file, watched                                                                                                                                               |
    | Shared config allowed (GoZ folder, Macro palette)     | GoZ push, key-answer macros                    | no: ask first                                                                                                                                                            |
    
    ## Workflow
    
    1. **Session and probe.** `zb_launch.start()` or reuse a running bridge; `zb_plugin_ops.probe()` on every path the recipe uses; `memory()`. GATE: ping returns 2026.2, every recipe path exists, plugins present.
    2. **Plan dry.** `zb_batch.plan(files, out, params)` resolves every file's steps, paths and timeouts without ZBrush. GATE: outputs outside the install (read-only since 2026.1), unique stems, no FBX or USD step.
    3. **Pilot one file, watched.** `run_folder([f], out, params)`; open `sheet.png` and the normal map; import the low and map in the target app (scenario-maya-pipeline-scripting or a Blender skill). GATE: file `ok`, step timings recorded, map orientation confirmed in the target app (flip V and green are [verify] until then).
    4. **Run the batch.** Bridge mode by default; `mode="script"` (one `ZBrush -script` process per file) once live_a05 has shown how ZBrush exits. Drop a `STOP` file in the output folder to end between files; rerun to resume (sha1 and recipe hash). GATE: `report.json` written after every file.
    5. **Recover.** For each `failed` or `partial`: read `steps[].outcome` (`error`, `timeout_recovered`, `hang`), the hang evidence (screenshot when the screen is unlocked; ZBrush Notes are drawn in the canvas, only the screenshot shows them), and `batch.blocked`. Fix params or paths, rerun: finished files are skipped. GATE: every non-ok file has a cause in the report.
    6. **Review and deliver.** Open the contact sheet; check `low_audit`, `preview_audit`, `normal_stats`; write the handoff manifest. GATE: numbers pass, sheet looked at, report and manifest delivered.
    
    ## Numbers
    
    | Value                                                                                              | Relative to                                                                 | Source                                        |
    | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------- |
    | UV Master: 100k to 150k faces max; seconds at 3k, up to 5 min at 150k                              | faces of the SubTool unwrapped                                              | UV Master doc                                 |
    | Divide multiplies faces by 4; levels = smallest n with low x 4^n >= source faces, top capped at 8M | projection stack                                                            | Pablo Logic Part 7; cap [added]               |
    | Decimation `% of decimation` is kept percent of points; 100 none, 0.01 max                         | points; about 2 triangles per point, so % = 100 x target_faces / 2 / points | DM doc; ijacobs 2013; Euler [added]           |
    | DM quality: 40 to 100 % looks the same, 2 % still good, 1 % degrades                               | original model                                                              | DM doc                                        |
    | ZRemesher target in thousands, approximate (5 gave 4,963)                                          | target polygons                                                             | Pablo Logic Part 7                            |
    | Project All Dist 0.1, set explicitly                                                               | same shape, new topology                                                    | Drust nxMYYsyJt3o [00:02:19]; toolkit default |
    | `create_normal_map(..., local_coordinates=True)` = tangent; default False = world                  | map space                                                                   | SDK stub                                      |
    | Macro file name 8+ characters, in a subfolder; new folder needs a restart                          | `<Asset Dir>/ZStartup/Macros/`                                              | Maxon interfaces doc                          |
    | Step timeouts 30 to 1,800 s, raised to 4 x slowest observed, 10 x base max; restart every 10 files | per step, per session                                                       | [added]                                       |
    | Tangent map sanity: blue >= 128 on 90 % of pixels                                                  | final PNG                                                                   | [added]                                       |
    | Maya GoZ flips Y and Z both ways and flips normal maps vertically                                  | GoZ_Info.txt                                                                | local file 2026-09-24                         |
    | Blender FBX import from ZBrush: scale 1000                                                         | FBX                                                                         | FlippedNormals [00:11:21]                     |
    
    ## Quality gates
    
    - **Per step (code):** import counts equal the file's (`import_mesh` warnings); DynaMesh `remeshed`; ZRemesher ratio to target 0.5 to 1.5 [added]; UV bbox inside 0..1; Divide ratio 4 per level; every output file exists and is non-empty. Each zb_ops `gate` (volume from `get_polymesh3d_volume`, watertight from `is_polymesh3d_solid`, projection spikes) feeds `check_step`: a collapsed or exploded DynaMesh or a spike fails the file.
    - **Per file (code):** low OBJ passes `zb_audit.verdict(..., "game")` (UVs, no n-gons); low bbox equals the input bbox within 2 % (an OBJ import sets Export Scale: Outgang [00:24:03]) [added tolerance]; normal map at the asked size, not uniform, tangent-like; preview faces near target.
    - **Per batch:** `counts`, `restarts`, `blocked`, `aborted` in `report.json`; `report.csv` for humans.
    - **Visual:** `sheet.png` (one canvas snapshot per file, MatCap Gray): melted thin parts from a low DynaMesh, projection spikes, missing parts. The normal map applied in the target app, lit from the side: seams, upside-down or inverted detail.
    
    ## Common mistakes
    
    | Mistake                           | What it looks like                                    | Fix                                                                       |
    | --------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
    | One in-ZBrush loop over all files | first stuck plugin freezes everything, no report      | `run_folder`: one call per step, report per file                          |
    | Pressing a plugin with no timeout | bridge silent for minutes                             | plugin steps are separate calls; `wait_recovery`, then restart            |
    | Importing onto the active tool    | the active SubTool is replaced                        | `import_mesh`: PolyMesh3D star first (usd-portal)                         |
    | Same-named inputs or tools        | stale Decimation cache, GoZ updates the wrong SubTool | `unique_stems` + `stage_input`                                            |
    | FBX or USD in an unattended run   | options window, batch stuck                           | OBJ or `.GoZ` by path                                                     |
    | Decimating the master             | sculpt gone, no undo from Python                      | checkpoint ZTL, decimate last or on a duplicate                           |
    | Normal map from two SubTools      | no detail in the map                                  | the map compares levels of one SubTool (AskZBrush n0qqpwvn-jA [00:01:14]) |
    | World-space map by default        | rainbow-colored map, wrong shading in engines         | `local_coordinates=True`                                                  |
    | Restarting the user's ZBrush      | unsaved work lost                                     | `BridgeSession.owned`; abort instead                                      |
    | Trusting `set()`                  | slider unchanged, no error                            | `exists()` then read back (zb_ops.set_checked)                            |
    
    ## Handoffs
    
    - **Serves every persona:** batch versions of their procedures (`for_each_subtool`, recipes built with `zb_batch.step`), inventories before GoZ or export, path discovery. Receives their procedure and params; delivers `report.json`, `report.csv`, `sheet.png` and per-file outputs.
    - **From scenario-maya-pipeline-scripting or a Blender skill:** OBJ or `.GoZ` files plus a manifest (names, units, which SubTool each updates). Names are checked with `zb_goz.check_names` before import.
    - **To scenario-maya-pipeline-scripting, scenario-zbrush-retopology-export, scenario-blender-uv-baking:** low OBJ with UVs, tangent normal PNG (flip V and green stated), decimated preview, audit numbers, Export Scale and axis notes.
    - **To a human or computer-use agent:** anything behind a dialog (FBX options, SubTool Rename and New Folder prompts, End Macro save dialog, GoZ app chooser, reprojection prompt).
    
    ## ZBrush 2026 notes
    
    - Python (2026.0) runs next to ZScript; `press_key` "Does not work" and `merge_undo` is "Not implemented" (2026.1 stub): key-held presses and one-undo sequences need a ZScript macro pressed from Python [verify].
    - Long deterministic sequences: `freeze(fn)` and `show_actions(0)` (SDK GUI reference) through `zb_ops.sequence`, `frozen`, `quiet`, or `for_each_subtool(..., freeze=True)`; never around a plugin press or a canvas export [added].
    - Since 2026.1 the install is read-only: macros, plugins and `Python/init.py` go in the Asset Directory, here `~/Library/Preferences/Maxon/ZBrush_03C27D49/`.
    - ZRepeat It, Turntabler and ZFileUtils are not shipped in 2026.2.1 (listing); no rename API exists (Maxon forum 2026-09).
    - GoZ still reports "version 1.01 - April, 11th 2011"; Maya's GoZ config on this Mac points at a Maya 2017 that is not installed (`zb_goz.app_info`).
    - Relaunches stalled after a `kill -9` at 05:29 (README, cause unknown): a restart after a hang may stall too. The runner then aborts with a resumable report.
    
    ## References
    
    - [`references/procedures.md`](references/procedures.md): full code for each procedure (session, probe, import, per-file recipe, folder batch, `-script`, plugins, maps, SubTool loops, names, GoZ, Maya and Blender round trips, macros, file-drop, reports, interpreter hygiene). Load before writing any batch.
    - [`references/expert-notes.md`](references/expert-notes.md): the sources' principles with timestamps, and the choices where they disagree. Load when a design decision is not covered here.
    - [`references/critique.md`](references/critique.md): the rubric for judging a batch, a script or a round trip. Load before reporting.
    - [`references/gui-paths.md`](references/gui-paths.md): the same operations by palette and hotkey, for a human or computer-use agent. Load when a step needs the GUI.
    - [`references/sources.md`](references/sources.md): every source with credentials and URLs.
    - [`scripts/zb_batch.py`](scripts/zb_batch.py) (agent side: runner, sessions, reports, `-script`, Activity oracle, ZScript generators), [`scripts/zb_plugin_ops.py`](scripts/zb_plugin_ops.py) (inside ZBrush: per-file steps and plugins), [`scripts/zb_batch_job.py`](scripts/zb_batch_job.py) (`-script` entry), [`scripts/zb_goz.py`](scripts/zb_goz.py) (GoZ files and folder, any Python).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related