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
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/zbrush/scenario-zbrush-automation
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
git clone https://github.com/scenario-labs/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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_allrefuses to run without a versioned save and the recipe's project step passes one ({stem}_preproject.ztl), even withsave_ztloff. Decimation Master runs last, after every valuable output is on disk, so a hang there leaves apartialfile, 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 throughremote_call/zb_launch.run: modules are imported by path, thensys.path,sys.modules, stdout and the working directory are restored, also after an error. End a batch withzb_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_filenamefeeds 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.GoZby 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 diskrock.objandRock.OBJare 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). Thenzb_plugin_ops.probe()confirmsexists, 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
.GoZor 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
- 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. - 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. - Pilot one file, watched.
run_folder([f], out, params); opensheet.pngand the normal map; import the low and map in the target app (scenario-maya-pipeline-scripting or a Blender skill). GATE: fileok, step timings recorded, map orientation confirmed in the target app (flip V and green are [verify] until then). - Run the batch. Bridge mode by default;
mode="script"(oneZBrush -scriptprocess per file) once live_a05 has shown how ZBrush exits. Drop aSTOPfile in the output folder to end between files; rerun to resume (sha1 and recipe hash). GATE:report.jsonwritten after every file. - Recover. For each
failedorpartial: readsteps[].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), andbatch.blocked. Fix params or paths, rerun: finished files are skipped. GATE: every non-ok file has a cause in the report. - 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_meshwarnings); DynaMeshremeshed; 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_opsgate(volume fromget_polymesh3d_volume, watertight fromis_polymesh3d_solid, projection spikes) feedscheck_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,abortedinreport.json;report.csvfor 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 withzb_batch.step), inventories before GoZ or export, path discovery. Receives their procedure and params; deliversreport.json,report.csv,sheet.pngand per-file outputs. - From scenario-maya-pipeline-scripting or a Blender skill: OBJ or
.GoZfiles plus a manifest (names, units, which SubTool each updates). Names are checked withzb_goz.check_namesbefore 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" andmerge_undois "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)andshow_actions(0)(SDK GUI reference) throughzb_ops.sequence,frozen,quiet, orfor_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.pygo 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 -9at 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(-scriptentry),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.
Reviews (0)
No reviews yet.
No comments yet.