Claude Skill

scenario-zbrush-expert

Use when an agent drives Maxon ZBrush 2026 for any task (sculpt in ZBrush, DynaMesh, ZRemesher, UVs, export, polypaint, posing, 3D print, clean up an AI mesh in ZBrush), when writing ZBrush Python (zbrush.commands) or ZScript, when the ZBrush bridge is not answering, a palette pa

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

Full trust report

Download scenario-labs-skills-skills_dcc_zbrush_scenario-zbrush-expert-dc592af.zip · 69 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/zbrush/scenario-zbrush-expert
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git git clone https://github.com/scenario-labs/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

ZBrush expert (router and agent protocol)

An agent does expert ZBrush work as a loop: deterministic palette operations, synthesized strokes and canvas renders from fixed views, gated at every stage. One long script is not the method. This skill is the protocol every ZBrush task follows, the shared toolkit in scripts/, and the map to the team skills. Target: ZBrush 2026.2.1 on macOS, driven through the project's Python bridge. 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.

1. Execution channel

Channel Use for Rules
Bridge into a running ZBrush (scripts/zb_launch.py, over the client scripts/zb_bridge.py) all interactive work: palette operations, strokes, renders, measurements zb_launch.start() spawns ZBrush with ZBRUSH_PLUGIN_PATH=scripts/zb_plugins and ZB_BRIDGE_AUTOSERVE=1. It waits for "plugin loaded", the port and a ping, then quits the ZHomePage helper. Work with zb_launch.run(code) or zb_launch.call("zb_ops", "dynamesh", 256). End with zb_launch.stop(save_to=".../x.ztl").
-script batch launch unattended jobs on saved files (export every SubTool, reports) ZBrush.app/Contents/MacOS/ZBrush -script /abs/job.py args /abs/scene.ZPR: the scene is the LAST argument, script args start at sys.argv.index("-script") + 2, report through files and sys.exit(code) (Maxon quickstart). Whether ZBrush quits on exit [verify].

Rules for the bridge:

  • Main thread only. Every zbc call runs in main mode, which is the default of run and call. The embedded VM keeps the GIL on ZBrush's main thread, so a socket thread alone starves. The proven design is a serve loop on the main thread that calls update(redraw_ui=True) between polls (README).
  • One driver. Only one agent drives ZBrush at a time. ZBrush is single-instance: start() refuses a second instance and never kills a ZBrush it did not spawn.
  • Short, measured calls. Each call returns numbers (faces, volume, file bytes). Long operations such as ZRemesher, Decimation or Divide into millions get minute-scale timeouts. A timeout does not cancel queued code: it still runs when the main thread frees. Ping and read state before sending anything else.
  • No blocking UI. Never call message_*, ask_* or show_note(..., 0). For visible progress use set_notebar_text(text, progress).
  • One shared interpreter. ZBrush keeps one embedded Python for the whole session: what a call leaves behind stays until restart. run and call leave sys.path, sys.modules, stdout and the working directory as found and pop every toolkit module, also after an error (Maxon Style Guide; the server runs the restore hook). Never add a folder to sys.path for good, never import toolkit code outside zb_launch.run. zb_launch.hygiene() must return empty lists; purge() removes a leak.
  • Long sequences. Group deterministic steps into one call: zb_ops.sequence([["divide", [2]], ["polish", [10]]]) runs them inside freeze with show_actions(0); quiet() and frozen(fn) are the parts (SDK GUI reference). Never freeze a plugin press or a canvas export; call update(redraw_ui=True) before a snapshot.
  • Locked screen. Document:Export still writes the canvas PNG (v03); screencapture shows only the lock screen. At 05:30 on 2026-09-24 a launch stalled after the Home Page press, while the 05:20 launch under the same lock worked. start() detects the stall, terminates the process it spawned and reports a diagnosis (details in zb_launch.py).
  • Check zbrush_info(0) = 2026.2 before trusting anything below.

2. Capability matrix (ZBrush 2026.2.1, 2026-09-24)

Action Status Substitute
Press, set and read palette items by path (DynaMesh, Divide, masks, exports) yes, proven (v01, v03) always exists() first: set() on a wrong path is silent
Synthesized strokes Stroke("(ZObjStrokeV02n<N>=H<x>V<y>...)") + canvas_stroke yes, proven (v02: volume 4.189 to 4.193)
Replaying a recorded stroke with an offset yes (v01: volume 4.184 to 4.189); rotation and scale [doc]
canvas_click drags to sculpt no (v01: no change) canvas_stroke
Dialog-free canvas PNG and OBJ export yes (v03: 0.04 s, 0.08 s)
ZTL save by path path exists, dialog-free [verify]
Aiming strokes at model points front view measured on the v03 OBJ; other views [verify live_04] zb_stroke.fit_convention
Pen pressure in strings unknown [verify live_02] Draw:Z Intensity and Draw Size per stroke
Smoothing by stroke (Shift) no: a Smooth brush only becomes the Shift brush Tool:Deformation:Polish, hPolish brush
ZModeler polygon clicks, Gizmo drags, pivot placement no Deformation sliders, Geometry X/Y/Z Position and XYZ Size, DynaMesh with Booleans, ZRemesher; else hand to a human or computer-use agent
Text prompts (SubTool Rename, New Folder) no name parts at creation; set_tool_path renames the top SubTool only
FBX export options dialog no ("You cannot escape any GUI", Maxon forum) OBJ or .GoZ by path
press_key no ("Does not work" in the stub) ZScript macro with IKeyPress [verify]; System Events from outside
merge_undo, undo control no ("Not implemented") versioned ZTL saves, Tool:Morph Target:StoreMT
Mask, layer, polygroup data no API (Maxon forum 2026) buttons plus visual checks; OBJ export for geometry
UV Master and Decimation Master presses paths exist; return of control [verify live_08, live_09] native Tool:UV Map:Create (Unwrap):Unwrap

3. The expert loop in ZBrush

  1. Brief in numbers. Purpose (film, game, print, concept), polycount per stage, print height in mm, style, references. When the brief is silent, pick the domain skill's documented default and write it down.
  2. Plan big to small. Block with primitives, ZSpheres or low DynaMesh; then primary, secondary and tertiary forms. Topology, UVs and export come last (Pablo Munoz Gomez's order in Understanding ZBrush's Logic).
  3. Build one stage with deterministic operations first; they are exact and cheap. Use strokes only where the form needs them. Before any stroke: Edit mode on, symmetry state chosen on purpose, brush and Draw values set and read back, view fixed with set_transform. Record the volume before and after as evidence that the stroke sculpted.
  4. Gate the stage.
    • Numbers: zb_ops.stats(), plus zb_audit.audit() on an OBJ export. Every destructive wrapper (DynaMesh, ZRemesher, Divide, Decimate, Polish, Project All) returns a gate: volume ratio from get_polymesh3d_volume() and watertightness from is_polymesh3d_solid(). A collapsed or exploded remesh is a problem, a lost watertight state a warning; zb_ops.require_gate(r) raises. Bands are [added] start values.
    • Review sheet: zb_review.review(out_dir) renders front, right, back, three-quarter and top with a neutral MatCap (MatCap Gray) and tiles one labeled sheet (the triples beyond front are [verify live_03]; an upside-down or wrong-side tile is visible on the sheet). Open it with the image reader and judge it with the domain skill's critique. Experts orbit constantly; the sheet is the agent's orbit.
  5. Fix before detail. A proportion error found at blockout costs one call; found after projection it costs the stage.
  6. Checkpoint before anything destructive. Save a versioned ZTL before DynaMesh, ZRemesher, Project All, Booleans, merges and Decimation. There is no undo control. zb_ops.project_all(checkpoint="/abs/x.ztl") refuses to run without one, since Project All "might crash the program" (cgside). It then stores a morph target and a New Layer at the top level (FlippedNormals), sets Dist 0.1 (Drust) and flags spikes (a vertex outside the visible bbox). Repair with morph_repair(points) (Morph brush back to the stored target) or the layer, never by smoothing (FlippedNormals).
  7. Deliver with evidence: the final sheet, audit numbers, file paths, and what was not verified.

Never report a model as done without having looked at a canvas render of its final state.

4. File safety

  • Turn Edit mode on before any canvas stroke (zb_ops.ensure_edit()). With Edit off, a drag stamps another pixol copy of the tool (Pablo, Logic Part 1).
  • Save versioned .ZTL files: zb_ops.save_ztl(path) picks the next free _vNNN and never overwrites. Keep at least three increments; AutoSave alone is not enough (Maxon, Saving Your Work). QuickSave is crash recovery, not versioning.
  • Never open a project over unsaved tools. Opening a .ZPR deletes every loaded tool (Pavlovich 005). Save each changed tool first. Ctrl+S saves a project, not the model.
  • Name SubTools at creation: renaming later needs a text prompt. On save, the top SubTool takes the file name (Pavlovich 030).
  • Write only outside the install, which is read-only for ZBrush since 2026.1, and always use absolute paths: relative ones land in the app folder. Exports keep a .bak of any file they replace.
  • Keep originals. Duplicate before ZRemesher or Decimation (the sculpt stays the projection source). Merge Visible creates a new tool. Store a morph target before a risky stroke pass.
  • Do not touch the user's configuration (Store Config, hotkeys, zbc.config(), zbc.reset()) without consent.

5. Persona pipelines

Brief Skill chain Delivers
Realistic character or creature scenario-zbrush-sculpting, scenario-zbrush-character-creature, scenario-zbrush-retopology-export, scenario-zbrush-paint-render ZTL stack, maps, renders
Stylized character or collectible scenario-zbrush-sculpting, scenario-zbrush-stylized, then scenario-zbrush-pose-print or scenario-zbrush-paint-render sculpt, print parts or renders
Hard-surface prop or weapon scenario-zbrush-hard-surface, scenario-zbrush-retopology-export high poly, game low poly, bakes
Retopology and export of a sculpt scenario-zbrush-retopology-export clean low poly, UVs, maps, OBJ or GoZ
AI-generated mesh cleanup audit with zb_audit, scenario-zbrush-retopology-export (repair, remesh, project), scenario-zbrush-sculpting (forms) clean sculpt worth detailing
Posing and 3D print scenario-zbrush-pose-print posed, hollowed, keyed parts in mm (STL or 3MF)
Polypaint, render, presentation scenario-zbrush-paint-render BPR or Redshift renders, turntables
Batch jobs, GoZ round trips, reports scenario-zbrush-automation scripts, -script jobs, reports

Handoffs between team skills are a versioned ZTL plus its stats() and last review sheet. Handoffs out of ZBrush are OBJ or GoZ plus maps and an audit report. Sister teams: scenario-maya-expert (rigging, animation, Arnold lookdev), scenario-blender-expert (retopology, lookdev, rendering, GoB round trips), scenario-3d (AI mesh generation to finish here).

6. Shared toolkit (scripts/)

  • zb_launch.py (agent side): start, ping, stop(save_to), is_running, status, screen_locked, diagnose, run(code, modules), call(module, func, *args), hygiene, purge. CLI: python3 zb_launch.py start|ping|status|stop|run.
  • zb_ops.py (inside ZBrush): checked wrappers new_sphere, dynamesh, divide, del_levels, zremesher, project_all (safe: checkpoint, nets, Dist, gate), top_level, store_morph_target, new_layer, set_layer_intensity, morph_repair, polish, deform, mask, polygroups, visibility, uv_unwrap, decimate, export_obj, export_canvas, save_ztl, select_brush, find_brush, set_draw, set_material, set_symmetry, sculpt_stroke, stats, subtools, gate, require_gate, quiet, frozen, sequence. PATHS lists every item path with its evidence tag.
  • zb_stroke.py (pure Python, both sides): encode/decode V02 strings, line, polyline, arc, zigzag, dab, resample, clip_to_canvas, Camera(transform, pivot).project(p), frame_transform, visible_mask, front_most_vertex, fit_convention, VIEWS.
  • zb_review.py: review(out_dir, views, matcap) (agent side), capture_views and snapshot(path) (inside ZBrush), contact_sheet, silhouette_bbox.
  • zb_audit.py (agent side, numpy): audit(obj) gives counts, poles, holes, non-manifold, winding, shells, symmetry, bbox, area, volume and edge lengths; verdict(report, "sculpt"|"game"|"print", ...); displaced(a, b).

Offline tests pass (python3 -m unittest discover -s tests/code/zbrush-expert -p "test_*.py"). The ZBrush-side functions have not yet run in ZBrush as functions; the calls under them that v01 to v03 proved are listed in references/sdk-cookbook.md. tests/code/zbrush-expert/run_live.sh runs the live checks in one command.

# not yet run in ZBrush as a sequence
import sys; sys.path.insert(0, "<this skill>/scripts")
import zb_launch as zl, zb_review, zb_audit
zl.start()
zl.call("zb_ops", "new_sphere", 128)
zl.call("zb_ops", "sculpt_stroke", [[500, 380], [620, 380]], brush="ClayBuildup", size=40,
        z_intensity=25)
sheet = zb_review.review("/abs/out/stage1")["sheet"]          # look at it
obj = zl.call("zb_ops", "export_obj", "/abs/out/stage1.obj")
print(zb_audit.verdict(zb_audit.audit(obj["path"]), "sculpt"))
zl.call("zb_ops", "save_ztl", "/abs/out/head.ztl")            # head_v001.ztl
zl.stop()

7. Top 2026 traps

  • set() on a missing path does nothing and raises nothing. Transform:Frame and Brush:Dam_Standard do not exist; use Transform:Fit and zb_ops.select_brush("Dam_Standard"). PolyFrame is Transform:Pf (commands.xml id Transform: Pf) and perspective is Draw:Perspective; Transform:PolyF and Transform:Persp are not ids.
  • query_mesh3d() returns lists: int(zbc.query_mesh3d(1)[0]).
  • A ZScript that pressed Decimation Master or a UV plugin never got control back (ZBrushCentral 2013 and 2014, cgside 2022). Test each plugin press once, with a short timeout and a ping after.
  • DynaMesh on a SubTool with levels is expected to raise a modal note [verify]; the wrappers refuse instead. Never Divide a DynaMesh to detail; ZRemesher it first (Pablo, Logic Part 7).
  • The ZRemesher target is in thousands and approximate: read the face count back.
  • ZHomePage covers the canvas after launch. Cmd+W quits ZBrush on macOS: never send it.
  • Item paths ignore case and spaces (SDK docs). The Activity log (Asset Directory Logs/Activity) records the canonical path of each press and set, which makes it a free path oracle (strokes are not logged).

References

  • references/sdk-cookbook.md: calls and paths by task with the v01 to v03 evidence and the dialog budget. Load before writing any bridge code.
  • references/zbrush-2026-traps.md: automation and modeling traps with fixes. Load when a call does nothing, hangs, or a tutorial step does not match 2026.
  • references/sources.md: every source, with credentials and URLs.
Files (skills)
  • references
    • sdk-cookbook.md 24.1 KB
      # ZBrush 2026.2.1 SDK cookbook for an agent
      
      Calls and item paths by task, with the evidence for each. `zbc` = `zbrush.commands`, run on ZBrush's main thread through the bridge (`zb_launch.run` / `zb_launch.call`). Toolkit functions are named after the arrow.
      
      Tags:
      
      - `[v01]`, `[v02]`, `[v03]`: ran through the bridge on 2026-09-24 (`tests/code/zbrush-expert/v0N_*`, results in `v0N_result.json`).
      - `[log]`: the path as ZBrush itself wrote it in `~/Library/Preferences/Maxon/ZBrush_03C27D49/Logs/Activity/Activity 2026-09-24-05-20-14.txt`.
      - `[obj]`: measured offline on the v03 OBJ export (`tests/code/zbrush-expert/fixtures/v03_sphere.obj`).
      - `[doc]`: Maxon SDK 2026.1 docs, examples or stub. `[macro]`: a ZScript macro shipped in `ZData/Macros` (2026.2.1).
      - `[cmdxml]`: an id in `ZData/ZLang/zcommands/commands.xml` of the install (2026-06-01); spaces and case are ignored in paths.
      - `[verify]`: not confirmed. The live check that will settle it is named as `live_NN`.
      
      ## 0. Session and channel
      
      | Task                            | Call                                                                                                                                                                                                                                            | Evidence                                                                       |
      | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
      | Launch with the bridge          | `ZBRUSH_PLUGIN_PATH=<scripts/zb_plugins> ZB_BRIDGE_AUTOSERVE=1 ".../ZBrush.app/Contents/MacOS/ZBrush"` → `zb_launch.start()`                                                                                                                    | README (proven 04:25 and 05:20); `start()` itself [verify]                     |
      | Health                          | `zbc.zbrush_info(0)` = 2026.2 → `zb_launch.ping()`                                                                                                                                                                                              | [v01] README                                                                   |
      | Run code                        | `zb_bridge.py --main "result = ..."` → `zb_launch.run(code, modules=(...))`                                                                                                                                                                     | [v01..v03]                                                                     |
      | Stop the serve loop             | send `stop` in main mode                                                                                                                                                                                                                        | README                                                                         |
      | Home Page overlay               | `osascript -e 'tell application "ZHomePage" to quit'` → `zb_launch.quit_homepage()`                                                                                                                                                             | README                                                                         |
      | Save dialog on quit             | System Events clicks "No"                                                                                                                                                                                                                       | README (unlocked session)                                                      |
      | Batch without the bridge        | `ZBrush -script /abs/job.py args /abs/scene.ZPR` (scene LAST), args at `sys.argv[sys.argv.index("-script") + 2:]`, `sys.exit(code)`                                                                                                             | [doc] getting-started, ex_mod_subtool_export; quit on exit [verify]            |
      | Python output visible in ZBrush | `zbc.set("ZScript:Script Window Mode:Python Output", True)`                                                                                                                                                                                     | [doc]                                                                          |
      | Learn a path                    | read the Activity log after a press: it writes the canonical path                                                                                                                                                                               | [log]                                                                          |
      | Shared-interpreter hygiene      | every `run`/`call`: snapshot, import by path, `_zb_restore()` puts `sys.path`, `sys.modules`, stdout, stderr, cwd back and pops toolkit modules; the server calls the hook after an error → `zb_launch.hygiene()` (both lists empty), `purge()` | [doc] Style Guide, ex_mod_curve_lightning; offline tests only [verify live_06] |
      | Many steps in one call          | `zbc.freeze(fn)` (progress bars still work), `zbc.show_actions(0)` before presses (not needed for `set`) → `zb_ops.sequence(steps)`, `quiet()`, `frozen(fn)`                                                                                    | [doc] SDK GUI reference; [verify live_06]                                      |
      
      Hygiene rules the prelude enforces (Maxon Style Guide): the script folder is NOT on `sys.path` (so `import my_helper` fails in ZBrush), a folder goes on `sys.path` only for the import, helper modules are popped from `sys.modules` after use, nothing touches `sys`, `math` or `random` (`random.seed = 42` breaks `random.seed` until restart), no `os.chdir`. A stale copy of a requested module found in `sys.modules` is set aside for the call, so edited toolkit code runs from disk; `run(..., full=True)` reports it as `hygiene`. Freeze rules [added]: no plugin press (UV Master, Decimation Master, Multi Map Exporter) and no canvas export inside `freeze`; `freeze()` returns None and its behavior when the payload raises is undocumented, so `frozen()` catches inside and raises after.
      
      The Activity log canonicalizes group names away: the agent sent `Tool:Geometry:DynaMesh:Resolution` and `Tool:Geometry:DynaMesh:DynaMesh`; ZBrush logged `ISet "Tool:Geometry:Resolution"` and `IPress "Tool:Geometry:DynaMesh"` [log]. Both forms resolve; keep the grouped form in code (it names one item unambiguously).
      
      ## 1. Tools and the canvas
      
      ```python
      zbc.press("Tool:Sphere3D")                      # [v01]
      zbc.press("Tool:Make PolyMesh3D")               # [v01] [log]; the copy is PM3D_Sphere3D [v01]
      w, h = zbc.get("Document:Width"), zbc.get("Document:Height")   # [v01] 1120 x 840
      zbc.canvas_click(w*0.5, h*0.5, w*0.5, h*0.85)   # draws the tool (Edit off) [v01] [log "Click in Canvas"]
      zbc.set("Transform:Edit", 1)                    # [v01]; logged as IPress "Transform: Edit" [log]
      ```
      
      → `zb_ops.new_sphere(resolution=128)` (same sequence, plus Layer:Clear [macro] before drawing).
      
      - Edit off + canvas drag stamps another pixol copy (Pablo, Logic Part 1). → `zb_ops.ensure_edit()` before every stroke.
      - Pressing a Tool while Edit is on swaps the edited tool [verify live_01].
      - Start new tools from `Tool:PolyMesh3D`, never SimpleBrush: switching to a 2.5D brush drops the edited tool (usd-portal) [verify].
      
      ## 2. View (the "camera" is the tool transform)
      
      ```python
      t = zbc.get_transform()      # 9 floats: pos x y z (canvas px), scale x y z, rot x y z (deg) [doc]
      zbc.set_transform(*t)        # restore [doc]; round trip [verify live_01]
      zbc.set_transform(x_rotate=0, y_rotate=90, z_rotate=0)       # keywords [doc]
      ```
      
      - Framing: `scale = min(w, h) / longest_bbox_side * 0.75`, position `(w/2, h/2, 0)` (Maxon turntable example) → `zb_stroke.frame_transform(bbox, w, h, rot)`. Whether the pivot is the bbox center and k = 1 [verify live_03].
      - `Transform:Frame` does not exist [v03]. Candidates: `Transform:Fit` [macro], `Transform:Fit Mesh To View` [doc] [verify live_03].
      - Canonical triples: front `(0,0,0)` (default after drawing; used by the shipped Create Instance Subtool macro), top `(0,90,90)` [doc set_transform example], back `(0,180,180)` [doc turntable key]. Right `(0,90,0)`, left, three-quarter [verify live_03]. MadPony: X flips at 90 and Y beyond ±90 needs Z = 180 (2019 build); set absolute views, never add degrees.
      - Keep x, y, z scale equal and positive (MadPony: stretched or flipped model otherwise).
      - Canvas zoom and pan (`get_canvas_zoom`) are the document view, not the camera [doc].
      
      ## 3. Strokes
      
      ```python
      st = zbc.Stroke("(ZObjStrokeV02n11=H1CCV1FE...)")   # decimal count, upper-case hex canvas px [v02]
      zbc.canvas_stroke(st)                                 # sculpts in Edit mode: volume 4.189 -> 4.193 [v02]
      s = zbc.get_last_stroke()                             # [v01] returned the tool-drawing click
      zbc.canvas_stroke(s, v_offset=60)                     # replay with offset: 4.184 -> 4.189 [v01]
      ```
      
      → `zb_stroke.encode(points)` (byte-identical to the v02 string), `zb_stroke.play(points)`, `zb_ops.sculpt_stroke(points, brush=..., size=..., z_intensity=...)`.
      
      - Canvas pixels = pixels of the `Document:Export` PNG, origin top left, y down [v02, v03 canvas].
      - `canvas_click` drags in Edit mode did not sculpt [v01]: never use them to sculpt.
      - Replay transforms: `canvas_stroke(st, None, rotation_deg, h_scale, v_scale, h_offset, v_offset)`; origin = stroke start, canvas space [doc]; rotation on a synthesized stroke [verify live_02].
      - Parse oracle: `zbc.get_stroke_info(st, 0)` count, `(st, 1, i)` x, `(st, 2, i)` y, `(st, 3, i)` pressure [doc]. Use it to probe the format without touching the mesh (live_02).
      - Lower-case `h`/`v` = value/256 (decoded from the Maxon V03 example, `zb_stroke.decode`) [verify live_02]. Pressure token unknown [verify live_02]; substitute Draw:Z Intensity and Draw:Draw Size per stroke.
      - Model point to canvas pixel: `zb_stroke.Camera(zbc.get_transform(), pivot).project(p)`. Front view in OBJ space: `x_px = pos_x + k*s*x`, `y_px = pos_y - k*s*y`, +Z toward the camera; k*s was 207 px per unit on the v01 sphere [obj]. Other rotations: `zb_stroke.fit_convention` over dab samples (live_04), installed as `scripts/camera_convention.json`.
      - `pixol_pick(0, x, y)` color, `(5, ...)` material, `(6..8, ...)` normal; depth (1) is broken; empty canvas reads about 0x303030 [doc] [verify live_03, live_04].
      - Symmetry OFF before a stroke aimed at one side (`zb_ops.set_symmetry(False)`), or the mirrored copy lands too.
      
      ## 4. DynaMesh, subdivision, ZRemesher
      
      ```python
      zbc.set("Tool:Geometry:DynaMesh:Resolution", 128)   # [v01] [log]
      zbc.press("Tool:Geometry:DynaMesh:DynaMesh")        # [v01] [log]: sphere 128 -> 43,480 points, 44,020 faces [v03]
      zbc.press("Tool:Geometry:Divide")                   # exists [v03]; x4 faces [doc] [verify live_06]
      zbc.set("Tool:Geometry:Smt", 1)                     # exists [v03]
      zbc.get("Tool:Geometry:SDiv"), zbc.get_max("Tool:Geometry:SDiv")   # 1.0 on a fresh mesh [v03]
      zbc.set("Tool:Geometry:ZRemesher:Target Polygons Count", 5)        # exists [v03]; thousands (Pablo: 5 -> 4,963)
      zbc.press("Tool:Geometry:ZRemesher:ZRemesher")      # exists [v03]; run [verify live_07]
      ```
      
      → `zb_ops.dynamesh(res, blur, project, groups)`, `divide(n, smt)`, `del_levels()`, `zremesher(target_k, adaptive, adaptive_size, keep_groups, mode)`.
      
      - Re-DynaMesh an active DynaMesh: the wrapper switches the button off then on (the human gesture is Ctrl+drag on empty canvas) [verify live_06].
      - DynaMesh or ZRemesher on a SubTool with levels: the wrapper refuses (ZBrush would raise a modal note) [added].
      - ZRemesher options Half, Same, Double, Adapt, AdaptiveSize, KeepGroups, KeepCreases, Detect Edges: both `Tool:Geometry:ZRemesher:<label>` and `Tool:Geometry:<label>` are tried [verify live_07].
      - Mirror And Weld `Tool:Geometry:Mirror And Weld`, Del Lower/Higher, X/Y/Z Position, XYZ Size [macro] [doc].
      
      ## 5. SubTools and projection
      
      | Task                     | Call                                                                                                                                               | Evidence                                              |
      | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
      | Count, select, active    | `get_subtool_count()`, `select_subtool(i)` (0 ok, -1 error), `get_active_subtool_index()`                                                          | [v03] count; rest [doc]                               |
      | Find by name / stable id | `locate_subtool_by_name(n)` (first match), `get_subtool_id()` + `locate_subtool(id)` (duplicates share ids)                                        | [doc] MadPony                                         |
      | Visibility, boolean bits | `get_subtool_status(i)`: eye 0x1, folder eye 0x2, add 0x10, sub 0x20, clip 0x40, start 0x80                                                        | [doc]; set semantics [verify live_06]                 |
      | Folder-aware visible     | eye bit and, in a folder, folder bit 0x2 → `zb_ops.subtools()`                                                                                     | usd-portal [verify]                                   |
      | Duplicate, Append        | `Tool:SubTool:Duplicate` (needs Edit), `Tool:SubTool:Append` then `PopUp:<Tool>`                                                                   | [v03] exists; PopUp [macro]                           |
      | Project All              | `Tool:SubTool:Project All` with only source and target visible → `zb_ops.project_all(checkpoint=...)`                                              | [v03] exists; run [verify live_06]                    |
      | Project settings         | `Tool:SubTool:Dist`, `Mean`, `PA Blur` (commands.xml; the grouped `Tool:SubTool:Project:Dist` form is tried first)                                 | [cmdxml]                                              |
      | Morph target             | `Tool:Morph Target:StoreMT`, `Switch`, `DelMT`, `Morph` slider → `store_morph_target()` (DelMT first when StoreMT is grayed)                       | [cmdxml] [macro]; grayed-state model [verify live_06] |
      | Layers                   | `Tool:Layers:New` (top level only, records at once), `Tool:Layers:Intensity` (no effect while recording) → `new_layer()`, `set_layer_intensity(v)` | [cmdxml] [macro]; REC toggle path unknown             |
      | Rename without a prompt  | none. `set_tool_path` renames the top SubTool only; name parts at creation                                                                         | forum 2026, MadPony                                   |
      
      Safe Project All, the order `zb_ops.project_all` runs (not yet run in ZBrush):
      
      1. `checkpoint`: versioned ZTL save first; Project All "might crash the program" (cgside MyhwQvkcnwI 00:02:19). None raises; False only right after a save.
      2. `top_level()`, then `store_morph_target()` and `new_layer()` there: "It's important that you do all of this on the highest level" (FlippedNormals Zp07GW3rND0 00:02:13, 00:02:47). One morph target per SubTool, destroyed by any point-count change, so it is stored after the last Divide.
      3. Back to `level` (default: where the SubTool was) for a staged projection from the lowest level up (00:03:17).
      4. Dist set explicitly, 0.1 (Drust nxMYYsyJt3o 00:02:19: the small default causes "90 percent" of artifacts); PA Blur lowered for a clean source (FlippedNormals).
      5. Project All, then the gate: point count kept, target bbox (visible, mode 0) inside the bbox of all visible SubTools before (mode 2) within 2 percent [added], volume ratio 0.5 to 2 [added], watertightness kept.
      6. Repair: `morph_repair(points)` (Morph brush strokes at the top level), `set_layer_intensity(v)`, or mask the busted area, invert and project again (00:09:27). Smoothing does not fix rogue vertices (00:04:27, 00:06:36). A target inside the source: masked Inflate and reproject, or ProjectionShell + Inner + Dist 1 (scenario-zbrush-retopology-export).
      
      ## 6. Masks, polygroups, deformation
      
      - `Tool:Masking:Clear`, `Inverse` [v03]; `BlurMask`, `SharpenMask`, `Go To Unmasked Center` [macro] → `zb_ops.mask(op)`. Mask data cannot be read [forum].
      - `Tool:Polygroups:Auto Groups` [v03]; `Group Visible` [macro] → `zb_ops.polygroups(op)`. `Tool:Visibility:HidePt/ShowPt/Grow/Shrink` [macro] → `zb_ops.visibility(op)`.
      - `Tool:Deformation:Polish`, `Inflate` exist [v03]; each `set` applies once (Enhance Details macro sets Polish 100) → `zb_ops.polish(v)`, `deform(name, v, axes)`; axis bits x=1, y=2, z=4 with `set_mod` [doc].
      - Morph target checkpoint: `Tool:Morph Target:StoreMT`, `Switch` [macro].
      
      ## 7. UVs and maps
      
      - Native: `Tool:UV Map:Create (Unwrap):Unwrap` [v03] exists, Auto Seams [doc] → `zb_ops.uv_unwrap("native")` [verify live_08].
      - UV Master: `Zplugin:UV Master:Unwrap` [v03] exists; a ZScript plugin press, control return [verify live_08].
      - Check: `query_mesh3d(3)` UV bbox, `(4)` first tile, `(5, id)` next tile [doc]; OBJ `vt` lines (`zb_audit`).
      - Maps by direct call: `create_normal_map(w, h, smooth, sub_poly, border, uv_tile, local_coordinates)`, `create_displacement_map(...)` (UVs and level 1 needed) [doc]. Multi Map Exporter `Zplugin:Multi Map Exporter:Create All Maps` exists [v03].
      
      ## 8. Decimation
      
      `Zplugin:Decimation Master:Decimate Current` exists [v03]; `% of decimation`, `Pre-process Current` [verify live_09] → `zb_ops.decimate(percent)`. A ZScript that pressed Decimation Master lost control (ijacobs 2013, cgside 2022); Python [verify live_09].
      
      ## 9. Files
      
      ```python
      zbc.set_next_filename("/abs/canvas.png"); zbc.press("Document:Export")   # 0.04 s, no dialog [v03]
      zbc.set_next_filename("/abs/mesh.obj");   zbc.press("Tool:Export")       # 0.08 s, no dialog [v03]
      zbc.set_next_filename("/abs/head_v003.ztl"); zbc.press("Tool:Save As")   # exists [v03]; dialog-free [verify live_06]
      ```
      
      → `zb_ops.export_canvas`, `export_obj`, `save_ztl(path, version=True)` (never overwrites, picks `_vNNN`). Every wrapper checks the file on disk and `has_next_filename()` (a preset still pending means a dialog probably opened) [added].
      
      - OBJ export = active SubTool, tool units, no UVs when there are none; header `#Auto scale 1 / offset 0`; counts and volume equal the bridge values [obj].
      - FBX ExportImport shows an options window that script cannot escape (Maxon forum 2025); USD Format ignores the preset (usd-portal). Use OBJ or `.GoZ` by path.
      - Relative paths resolve in the ZBrush app folder: always absolute [doc].
      
      ## 10. Measuring (gates)
      
      - `query_mesh3d(0)` points, `(1)` faces: lists, e.g. `[43480.0]` [v03]; `(2, mode)` bbox (0 visible current, 1 full current, 2 visible all, 3 full all) [doc].
      - `get_polymesh3d_volume()` [v01], `get_polymesh3d_area()`, `is_polymesh3d_solid()` [doc] → `zb_ops.stats()`.
      - Gates → `zb_ops.gate(op, before, after)`, returned as `result["gate"]` by `dynamesh`, `zremesher`, `divide`, `del_levels`, `decimate`, `polish`/`deform` and `project_all`. Volume ratio bands in `GATE_BANDS` ([added] start values: DynaMesh 0.5 to 2 and a problem outside, since that is a collapsed or exploded remesh; ZRemesher 0.8 to 1.25; Divide, Decimate 0.9 to 1.1; Polish 0.98 to 1.02 after the sculpting digest's 2 percent start value). Volume is judged only on a mesh that was watertight; a result volume at or below 0 is always a problem; losing watertightness is a warning (python_sdk digest delta 22). `require_gate(r)` raises.
      - Per vertex or per edge: export and run `zb_audit.audit(obj)` (poles, holes, non-manifold, symmetry, winding, scale).
      
      ## 11. Brushes, Draw, material
      
      - Found: `Brush:Move`, `Brush:Standard`, `Brush:hPolish`, `Brush:ClayBuildup` [v03]. Not found: `Brush:Dam_Standard` [v03]; the file is `ZData/BrushPresets/DamStandard.ZBP` and the UI label is `Dam_Standard` → `zb_ops.select_brush("Dam_Standard")` tries every spelling, then loads the file with `Brush:Load Brush` [verify live_05].
      - `Draw:Draw Size` 64, `Draw:Z Intensity` 20, `Draw:Zadd` 1 read on a fresh session [v01] → `zb_ops.set_draw()`.
      - Smooth brushes only become the Shift brush and cannot be held from Python (BR doc; press_key broken) [verify live_05]. Smooth with `Tool:Deformation:Polish` or the hPolish brush.
      - Neutral MatCap: `Material:MatCap Gray` (file `ZData/Materials/MatCap/MatCap Gray.ZMT`) [verify live_03]; current: `get_title("Material:Current Material")` [doc manual].
      - Lazy Mouse `Stroke:Lazy Mouse:LazyMouse`, symmetry `Transform:Activate Symmetry` [v03].
      - PolyFrame `Transform:Pf` (commands.xml id `Transform: Pf`; `Transform:PolyF` is the spoken label, not an id), perspective `Draw:Perspective` [cmdxml] → `zb_ops.PATHS["polyframe"]`, `["persp"]` [verify live_03].
      
      ## 12. Dialog budget: what blocks, and the substitute
      
      | Raises a modal                                                  | Substitute                                                                                            |
      | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
      | File dialogs of Import/Export/Save without a preset             | `set_next_filename(abs)` first [v03]                                                                  |
      | FBX options, USD Format                                         | OBJ or GoZ by path                                                                                    |
      | SubTool Rename, New Folder (text prompt)                        | name at creation; `set_tool_path` for the top SubTool; rename outside ZBrush                          |
      | DynaMesh / ZRemesher on levels                                  | `del_levels()` on a duplicate first                                                                   |
      | Del All, Delete, Merge Down confirmations                       | avoid; shipped macros answer with `[IKeyPress,'2',...]`, a ZScript macro pressed from Python [verify] |
      | `message_*`, `ask_*`, `show_note(duration 0)`                   | never call; `set_notebar_text(text, progress)`                                                        |
      | Quit with unsaved work                                          | `zb_launch.stop(save_to=...)`: save, then System Events "No"                                          |
      | `press_key` ("Does not work"), `merge_undo` ("Not implemented") | versioned ZTL saves and morph targets as checkpoints                                                  |
      
      ## 13. Evidence table (2026-09-24, ZBrush 2026.2.1 build 53205)
      
      | Test          | Result                                                                                                                                                                                                           |
      | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | v01           | sphere DynaMesh 128, volume 4.184; canvas_click drag in Edit mode: no change; replayed last stroke with v_offset 60: 4.189; `query_mesh3d(0)` returns a list; Draw Size 64, Z Intensity 20, Zadd 1               |
      | v02           | synthesized V02 stroke of 11 points at y 510 to 518: volume 4.189 → 4.193                                                                                                                                        |
      | v03           | canvas PNG 2,827,877 bytes in 0.04 s; OBJ 2,665,861 bytes in 0.08 s; `set` on a missing path silent; 33 of 35 paths exist (not `Transform:Frame`, `Brush:Dam_Standard`); 43,480 points, 44,020 faces, SDiv max 1 |
      | obj (offline) | the v03 OBJ has 43,480 points, 44,020 faces (42,936 quads), volume 4.193083 (= bridge 4.193), closed, one shell; stroke bumps at OBJ y about -0.46, z about +0.86: front view maps +Y up, +Z toward the camera   |
      | log           | Activity log of the 05:20 session: canonical paths as in section 0                                                                                                                                               |
      
    • sources.md 10.3 KB
      # Sources of scenario-zbrush-expert
      
      Each line: source, who, credential, where, what it is best for. Notes live in `notes/scripting/` and `notes/fundamentals/` of the project; the digests are `_digest_python_sdk.md`, `_digest_zscript_goz_automation.md` and `_digest_fundamentals_logic_files.md`.
      
      ## Revisions
      
      - 2026-09-24 v1: initial build (snapshot `skills/_versions/zbrush-expert v1 2026-09-24/`).
      - 2026-09-24 v2, refactor from the blind grade Z7 (all of it), the "missing from both" lists of Z1, Z2 and Z6, and `tests/OPEN_ISSUES.md` (toolkit): `zb_ops.project_all` safe by default (versioned checkpoint first, cgside MyhwQvkcnwI 00:02:19; Store MT and a New Layer at the top level, FlippedNormals Zp07GW3rND0 00:02:13, 00:02:47; Dist set to 0.1, Drust nxMYYsyJt3o 00:02:19; spike, point and volume gate; `morph_repair` instead of smoothing, 00:04:27, 00:06:36) with `top_level`, `store_morph_target`, `new_layer`, `set_layer_intensity`; volume and watertightness gates (`get_polymesh3d_volume`, `is_polymesh3d_solid`, SDK modeling reference) on DynaMesh, ZRemesher, Divide, Del Levels, Decimate, Polish and Project All; `quiet`, `frozen`, `sequence` for `show_actions(0)` and `freeze` (SDK GUI reference); PolyFrame path `Transform:Pf` and perspective `Draw:Perspective` from the installed `commands.xml`; shared-interpreter hygiene in `zb_launch.run` (exact `sys.path` restore, toolkit modules popped also after an error through a server hook, stdout and working directory restored, stale modules set aside; `hygiene()`, `purge()`), after the SDK Style Guide and ex_mod_curve_lightning. Offline tests only (65 pass); nothing of it has run in ZBrush (live_03, live_06 updated).
      - Installed `ZData/ZLang/zcommands/commands.xml` (ZBrush 2026.2.1, dated 2026-06-01, 2,357 ids). Best for: the real item ids (`Transform: Pf`, `Draw:Perspective`, `Tool:SubTool:Dist`, `Tool:Layers:New`, `Tool:Morph Target:DelMT`).
      
      ## Evidence produced by this project (highest weight: it ran on this install)
      
      - Bridge tests `tests/code/zbrush-expert/v01_setup_and_stroke.py`, `v02_synth_stroke.py`, `v03_export_and_paths.py` with `v0N_result.json` and `v03_canvas.png` (2026-09-24 05:20 to 05:21, ZBrush 2026.2.1 build 53205). Best for: what is proven (strokes, exports, paths).
      - Project README, section "Agent bridge into ZBrush" (2026-09-24). Best for: the launch line, the main-thread design, side channels.
      - ZBrush Activity logs `~/Library/Preferences/Maxon/ZBrush_03C27D49/Logs/Activity/Activity 2026-09-24-05-20-14.txt` and `-05-30-32.txt`. Best for: canonical item paths; the 05:30 startup stall.
      - `tests/code/zbrush-expert/fixtures/v03_sphere.obj` (the v03 export) analyzed offline. Best for: OBJ axes against canvas pixels, counts and volume against the bridge.
      - `sources/zbrush-version-deltas.md` (project, 2026-09-24). Best for: every 2026 version fact and the verify list.
      
      ## Maxon official (SDK and docs)
      
      - ZBrush Python SDK 2026.1 manuals: quickstart, environment, libraries (Maxon SDK team), https://developers.maxon.net/docs/zbrush/py/2026_1_0/manuals/index.html, local copy `/Applications/Maxon ZBrush 2026/Documentation/python-api`. Best for: entry points (`-script`, startup plugins), interpreter limits.
      - API overview, item paths, modifiers, camera transform (Maxon SDK team), https://developers.maxon.net/docs/zbrush/py/2026_1_0/manuals/api_overview.html. Best for: path rules (case and spaces ignored, picker items by name), what the API cannot see.
      - GUI API reference (Maxon SDK team), https://developers.maxon.net/docs/zbrush/py/2026_1_0/api/zbr_cmds_gui.html. Best for: `set`/`press`/`get`, blocking calls, `canvas_click`, `update`, `freeze`.
      - Modeling API reference (Maxon SDK team), https://developers.maxon.net/docs/zbrush/py/2026_1_0/api/zbr_cmds_modeling.html. Best for: `query_mesh3d`, strokes, `get_transform`, `pixol_pick`, SubTool status bits, ZSpheres, curves, maps.
      - System API reference plus the stub (Maxon SDK team), https://developers.maxon.net/docs/zbrush/py/2026_1_0/api/zbr_cmds_system.html and `Documentation/python-api/api/zbrush/commands.py` (145 functions, dated 2025-10-28). Best for: `set_next_filename`, `zbrush_info`, the not-implemented `merge_undo` and broken `press_key`.
      - Official examples (Ferdinand Hoppe, Javier Edo, Jan Wesbuer), https://developers.maxon.net/docs/zbrush/py/2026_1_0/examples/index.html and `examples/fragments/*.py`. Best for: hello world, SubTool array, batch export, turntable framing rule, the V03 stroke string, `pixol_pick` background 0x303030.
      - Migrating ZScript and the command map (Maxon SDK team), https://developers.maxon.net/docs/zbrush/py/2026_1_0/manuals/migrating_zscript.html and `ZData/Python/zscript_command_mapping.json`. Best for: reading any ZScript macro as Python.
      - Maxon developer forum, ZBrush SDK category (ferdinand, m_adam, i_mazlov, davide of the Maxon SDK team, and users), https://developers.maxon.net/forum/category/55/zbrush-sdk. Best for: current gaps (no SubTool rename, no mask data, FBX dialog, `press_key` failures, asset-directory `init.py`).
      - ZScript command reference (Maxon help, originally Pixologic), https://help.maxon.net/zbr/en-us/Content/html/user-guide/customizing-zbrush/zscripting/command-reference/command-reference.html. Best for: argument orders, status bits, `FileNameSetNext`.
      - ZScript interfaces, plugins, ZFileUtils, macros, user content (Maxon help), 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 at a time, macro folder rules, 2026.1 read-only install.
      - ZScripting manual (Maxon help), 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 path display.
      - Shipped 2026.2.1 macros, `/Applications/Maxon ZBrush 2026/ZData/Macros/` (Maxon, authors not named). Best for: known-good 2026 paths, `PopUp:<item>`, `IKeyPress` dialog answers, a recorded V02 stroke string.
      - GoZ documentation (Maxon help), https://help.maxon.net/zbr/en-us/Content/html/user-guide/zbrush-other-programs/goz/goz.html, and GoZ SDK PDF, https://developers.maxon.net/docs/zbrush/goz_sdk.pdf. Best for: round-trip rules (lowest level, unique names), the `/Users/Shared/Pixologic` folder protocol.
      - Maxon docs on canvas, ZTools, saving, SubTools, folders, LightBox, hotkeys and user content (`sources/docs/fundamentals-sculpting__basic-concepts-files-subtools.md`, `...__custom-ui-hotkeys-preferences.md`). Best for: file safety (.ZTL vs .ZPR vs .ZBR, QuickSave, versioning advice).
      
      ## Experts and community
      
      - Pablo Munoz Gomez, 3D concept artist and educator, ZBrushGuides founder, Maxon ZBrushLIVE host, "Understanding ZBrush's Logic" Parts 1 to 8 (2025-11 to 2025-12): https://www.youtube.com/watch?v=NckHjyO6GNk (Part 1, canvas and Edit mode [00:12:20]), https://www.youtube.com/watch?v=RABWfzWNw0c (Part 2), https://www.youtube.com/watch?v=gitoJ7B8FmY (Part 3, parts and Booleans), https://www.youtube.com/watch?v=mVp_jNfEI3I (Part 4), https://www.youtube.com/watch?v=FrqUnna1jns (Part 5, DynaMesh resolution relative to size [00:13:40]), https://www.youtube.com/watch?v=9a9S1OcYHx4 (Part 6, brushes), https://www.youtube.com/watch?v=rArw79xEpvE (Part 7, never Divide a DynaMesh [00:09:44], ZRemesher 5 gave 4,963 [00:03:16]), https://www.youtube.com/watch?v=VRisbJQAaZw (Part 8, reprojection density [00:17:24]). Best for: the production order and the fundamentals of the expert loop.
      - Michael Pavlovich, Director of Character, Weapon and Vehicle Art at Certain Affinity, Maxon ZBrushLIVE host: Intro 005 ZTool, ZProject, QuickSave https://www.youtube.com/watch?v=rEIoFEEgS80 (opening a ZPR deletes tools [00:09:55]); 026 Splitting https://www.youtube.com/watch?v=TjGlVX4w1nM; 030 SubTool options and folders https://www.youtube.com/watch?v=bBHqeaKNOzg (top SubTool takes the file name [00:00:30]); Workshop 45 macros and ZRepeat It https://www.youtube.com/watch?v=9Acq70iPaVI (record once, apply to every SubTool [00:37:30]). Best for: file safety and per-SubTool automation habits.
      - Joseph Drust, Pixologic, #AskZBrush Morph Target https://www.youtube.com/watch?v=B_wKwXwjcZs. Best for: morph targets as checkpoints.
      - Paul Gaboury, Maxon ZBrush staff, #AskZBrush export all SubTools as FBX https://www.youtube.com/watch?v=echGh0tZEts. Best for: why FBX needs a dialog and OBJ loses names.
      - MadPonyInteractive, ZBrush plugin author, ZScript Fundamentals (2019): What is ZScript https://www.youtube.com/watch?v=DnLlDJxfxHs, IButton https://www.youtube.com/watch?v=cfWdG_UPx90, IGet/ISet https://www.youtube.com/watch?v=vnaNrBzT1UQ, Loop https://www.youtube.com/watch?v=Iwg5i4pWAdE, Memory Variables https://www.youtube.com/watch?v=_YuSnHxJtsI, Routines https://www.youtube.com/watch?v=bBNrtIsHfiE, 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, Moving SubTools https://www.youtube.com/watch?v=9-5AkwkZqAo, Renaming SubTools https://www.youtube.com/watch?v=w7RxSx1G1hQ, Tools and Folders https://www.youtube.com/watch?v=Fns6YoDE1j4, Macros and Plugins https://www.youtube.com/watch?v=_1wAuQ3IUf4, Nudge Axis plugin https://www.youtube.com/watch?v=a9YEqjF0bkU and https://www.youtube.com/watch?v=xv-DDf4EckE (Euler flips [00:15:26], uniform scale [00:47:40]). Best for: ZScript semantics and view-transform behavior.
      - cgside, 3D environment artist, ZScript batch automation https://www.youtube.com/watch?v=MyhwQvkcnwI (2022). Best for: the remesh-and-project batch recipe and the Decimation Master hand-off limit.
      - FlippedNormals (Morten Jaeger, Henning Sanden), ZBrush to Blender with GoB https://www.youtube.com/watch?v=2-pFspCbykk (2020). Best for: GoZ lowest-level rule, FBX scale 1000 into Blender.
      - ZBrushCentral scripting threads (marcus_civis and others, 2007 to 2026), https://www.zbrushcentral.com/c/zscripting-help/14. Best for: command-line ZScript, one-active-script rule, popups as the automation killer.
      - Community bridges on GitHub: baborub/usd-portal (tested on 2026.2), JoseConseco/GoB, LumaPictures/gozbruh, nlapinski/ZBrush-Command-Port, newsbubbles/zbrush-mcp (unverified). Best for: file-drop control, GoZ by path, Duplicate-needs-Edit, why a thread-only server starves.
      
      ## Sister skills used for shape
      
      - scenario-blender-expert (Blender Expert Skills project): the router-and-protocol shape and the review-sheet loop.
      
    • zbrush-2026-traps.md 22.2 KB
      # ZBrush 2026.2.1 traps for an agent
      
      What breaks remembered ZBrush knowledge or agent code on this install, with the fix. Sources: project README (bridge findings 2026-09-24), `sources/zbrush-version-deltas.md` (version facts), the scripting and fundamentals digests, v01..v03 tests. Load when code "does nothing", a call hangs, or a tutorial step does not match the 2026 UI.
      
      ## Automation traps
      
      | Trap                                     | Symptom                                                                                                                                         | Fix                                                                                                                                                                                                                                                 | Source                                  |
      | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
      | `set()` on a wrong path is silent        | nothing changes, no error                                                                                                                       | `exists()` first; read back with `get()` (`zb_ops.set_checked`)                                                                                                                                                                                     | v03                                     |
      | Socket thread without main-thread Python | first bridge test got no reply                                                                                                                  | serve loop on the main thread with `update(redraw_ui=True)` between polls (`ZB_BRIDGE_AUTOSERVE=1`)                                                                                                                                                 | README                                  |
      | `press_key`                              | "Does not work, hidden for now" (stub); modifier chords fail                                                                                    | no key simulation; ZScript macro with `[IKeyPress,...]` pressed from Python [verify], or System Events from outside                                                                                                                                 | stub; Maxon forum 2026-04               |
      | `merge_undo`                             | "Not implemented" (stub)                                                                                                                        | checkpoints: versioned ZTL (`zb_ops.save_ztl`), `Tool:Morph Target:StoreMT`                                                                                                                                                                         | stub                                    |
      | `canvas_click` to sculpt                 | drag in Edit mode leaves the mesh unchanged                                                                                                     | `canvas_stroke(Stroke(...))` with a V02 string                                                                                                                                                                                                      | v01                                     |
      | Canvas drag with Edit off                | a second pixol copy of the tool on the canvas                                                                                                   | `zb_ops.ensure_edit()` before any stroke                                                                                                                                                                                                            | Pablo Logic Part 1; ZTools docs         |
      | `query_mesh3d` returns lists             | `int(zbc.query_mesh3d(0))` raises                                                                                                               | `int(zbc.query_mesh3d(0)[0])`                                                                                                                                                                                                                       | v01                                     |
      | `get_status` is enabled/grayed           | switch logic inverted (Maxon's own freeze example)                                                                                              | read switches with `get()`                                                                                                                                                                                                                          | SDK GUI doc                             |
      | Group names in paths                     | `Tool:Geometry:DynaMesh:Resolution` vs `Tool:Geometry:Resolution`                                                                               | both resolve; ZBrush logs the short form                                                                                                                                                                                                            | Activity log 05:20                      |
      | Names that are not labels                | `Transform:Frame`, `Brush:Dam_Standard` not found                                                                                               | `Transform:Fit` [macro] / `Fit Mesh To View` [doc]; `zb_ops.select_brush` tries every spelling                                                                                                                                                      | v03                                     |
      | ZScript plugin presses                   | a ZScript that pressed Decimation Master or a UV plugin never got control back                                                                  | test once per plugin through the bridge (live_08, live_09); short timeouts, ping after                                                                                                                                                              | ZBrushCentral 2013/2014; cgside 2022    |
      | A bridge timeout does not cancel         | the queued code still runs when the main thread frees                                                                                           | after a timeout, ping and read state before sending anything else                                                                                                                                                                                   | bridge design [added]                   |
      | Modal notes and dialogs                  | the call never returns; UI waits for a click                                                                                                    | see the dialog budget in sdk-cookbook.md section 12                                                                                                                                                                                                 | forum, ZBrushCentral                    |
      | FBX export                               | "FBX Export Options" window cannot be escaped from script                                                                                       | OBJ or `.GoZ` via `Tool:Export`                                                                                                                                                                                                                     | Maxon forum 2025-03                     |
      | Rename and New Folder                    | text prompt the API cannot fill; no rename API in 2026                                                                                          | name parts at creation (build each part in a tool of that name); `set_tool_path` renames the top SubTool only                                                                                                                                       | forum 2026-09; MadPony                  |
      | Saving renames                           | the top SubTool takes the file name; the tool is renamed                                                                                        | keep a placeholder top SubTool or accept it; read `get_active_tool_path()` after saving                                                                                                                                                             | Pavlovich 030                           |
      | Opening a .ZPR                           | every loaded tool is deleted                                                                                                                    | save all changed tools first; merge with Tool > Load Tools From Project [verify path]                                                                                                                                                               | Pavlovich 005; docs                     |
      | Ctrl+S                                   | saves a project, not the model                                                                                                                  | `Tool:Save As` for the .ZTL                                                                                                                                                                                                                         | Pavlovich 005                           |
      | Shared interpreter                       | module state persists; `random.seed = 42` breaks `random.seed` until restart; `import my_helper` fails (the script folder is not on `sys.path`) | only through `zb_launch.run`/`call`: import by path, restore `sys.path`, `sys.modules`, stdout, cwd, pop toolkit modules, also after an error (server hook); `zb_launch.hygiene()` empty, `purge()` for a leak; never touch `sys`, `math`, `random` | SDK style guide; ex_mod_curve_lightning |
      | Stale toolkit module                     | edited toolkit code seems ignored                                                                                                               | a stale copy in `sys.modules` from a raw import: `run` sets it aside for requested modules (`hygiene` in `full=True`); `purge()`                                                                                                                    | [added]                                 |
      | Slow, flickering sequences               | every press animates                                                                                                                            | `zb_ops.sequence(steps)` / `frozen(fn)` / `quiet()`; nothing plugin-driven and no canvas export inside `freeze`                                                                                                                                     | SDK GUI reference                       |
      | Project All crash                        | ZBrush dies mid-projection                                                                                                                      | `zb_ops.project_all(checkpoint=...)`: versioned save first, refuses without                                                                                                                                                                         | cgside MyhwQvkcnwI 00:02:19             |
      | PolyFrame, perspective paths             | `Transform:PolyF`, `Transform:Persp` not found                                                                                                  | `Transform:Pf` (id `Transform: Pf`), `Draw:Perspective`                                                                                                                                                                                             | commands.xml 2026.2.1                   |
      | Working directory                        | relative paths land in the root-owned app folder                                                                                                | absolute paths only                                                                                                                                                                                                                                 | SDK getting-started                     |
      | Install is read-only (2026.1)            | writing into `ZData`, `ZStartup` or `ZScripts` does not take; DefaultZScript hook did not fire                                                  | user files in `~/Library/Preferences/Maxon/ZBrush_03C27D49/` (this Mac)                                                                                                                                                                             | version deltas 2.1; README              |
      | Python output                            | nothing in the ZBrush console                                                                                                                   | `ZScript:Script Window Mode:Python Output` on; the bridge returns `out` anyway                                                                                                                                                                      | SDK getting-started                     |
      | Home Page                                | a separate ZHomePage process covers the canvas                                                                                                  | `zb_launch.quit_homepage()`                                                                                                                                                                                                                         | README                                  |
      | Locked screen                            | `screencapture` shows only the lock screen                                                                                                      | `Document:Export` PNG is the review image; it works under the lock                                                                                                                                                                                  | README, v03                             |
      | Startup stall at 05:30                   | Activity log stops at `IPress "Zplugin:Misc Utilities: Home Page"`, bridge log empty, no port                                                   | `zb_launch.start()` detects it and terminates the stalled instance; the 05:20 launch under the same lock worked, cause unknown                                                                                                                      | Activity logs 05:20, 05:30              |
      | macOS Cmd+W                              | quits ZBrush since 2023.0.1                                                                                                                     | never send Cmd+W through System Events; Control+W assigns a polygroup                                                                                                                                                                               | version deltas 3.13                     |
      | pixol depth                              | `pixol_pick(1, ...)` "is broken"                                                                                                                | use color (0), material (5), normal (6..8)                                                                                                                                                                                                          | stub                                    |
      
      ## Modeling traps (2026 behavior)
      
      | Trap                                    | Symptom                                                            | Fix                                                                                                                | Source                                                          |
      | --------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- |
      | Divide a DynaMesh to detail             | 390k to 1.5M to 6M triangles and star poles                        | ZRemesher first, then Divide and Project                                                                           | Pablo Logic Part 7                                              |
      | Projection without nets                 | spikes, rogue vertices, lost detail with no way back               | Store MT and a New Layer at the top level, Dist 0.1, then Project All (`zb_ops.project_all`)                       | FlippedNormals Zp07GW3rND0 00:02:13; Drust nxMYYsyJt3o 00:02:19 |
      | Smoothing projection spikes             | rogue vertices survive, forms go soft                              | Morph brush back to the stored target (`zb_ops.morph_repair`), layer intensity, or masked local reprojection       | FlippedNormals 00:04:27, 00:06:36, 00:09:27                     |
      | Morph target lost                       | Switch grayed after a Divide or remesh                             | any point-count change destroys it: store after the last Divide                                                    | Maxon Morph Targets doc                                         |
      | Collapsed or exploded remesh            | DynaMesh volume near 0 or several times larger                     | `result["gate"]` (volume from `get_polymesh3d_volume`, watertight from `is_polymesh3d_solid`) before the next step | python_sdk digest delta 22 [added bands]                        |
      | DynaMesh on levels                      | expected modal note asking to delete levels [verify]               | `del_levels()` on a duplicate; `zb_ops.dynamesh` refuses                                                           | python_sdk digest [added]                                       |
      | DynaMesh fuses intersecting parts       | two parts become one                                               | separate polygroups plus Groups on                                                                                 | Pablo Logic Part 5                                              |
      | DynaMesh resolution is relative to size | same value, different detail on different objects                  | pick it from the bbox and the smallest feature                                                                     | Pablo Logic Part 5                                              |
      | DynaMesh deletes UVs                    | UVs gone after remesh                                              | UV after the last DynaMesh                                                                                         | DYN doc                                                         |
      | ZRemesher target                        | the slider is in thousands and approximate (5 gave 4,963)          | read faces back; Adapt off to hit a count                                                                          | Pablo Logic Part 7; ZRemesher doc                               |
      | ZRemesher Legacy (2018)                 | missing in the user guide                                          | default algorithm, Retry to compare                                                                                | version deltas table 4                                          |
      | Smooth brushes                          | picking one only sets the Shift brush; the agent cannot hold Shift | `Tool:Deformation:Polish`, hPolish brush [verify live_05]                                                          | BR doc                                                          |
      | Symmetry and aimed strokes              | the mirrored stroke lands on the other side                        | symmetry off for one-sided strokes                                                                                 | [added]                                                         |
      | Local Symmetry                          | follows the Gizmo center since 2023                                | L.Sym with Dynamic off for the classic behavior                                                                    | version deltas 3.8                                              |
      | Chisel default stroke                   | changed in 2025.1.0 and again in 2025.1.2                          | set the stroke type explicitly [verify path]                                                                       | version deltas 3.2                                              |
      | View Euler angles                       | X flips at 90; Y past ±90 needs Z = 180 (2019 build)               | absolute canonical triples, uniform scale                                                                          | MadPony a9YEqjF0bkU, xv-DDf4EckE                                |
      | Duplicate outside Edit                  | `Tool:SubTool:Duplicate` "not found"                               | Edit mode on                                                                                                       | usd-portal                                                      |
      | GoZ names                               | wrong SubTool updated when names repeat                            | unique alphanumeric names across loaded tools                                                                      | GoZ docs                                                        |
      | GoZ sends the lowest level              | polypaint resolution drops                                         | delete lower levels first when polypaint matters                                                                   | GoZ docs; FlippedNormals                                        |
      
      ## Version clues when reading a tutorial
      
      Python Scripting sub-palette: 2026.0 or later. Asset Directory and LightBox Maxon/User tabs: 2026.1. Retopo brush with IMM tabs: 2026.1. Redshift: 2023+. SubTool folders: 2019+. Pixologic branding: 2022 or earlier. Full list: `sources/zbrush-version-deltas.md`.
      
  • scripts
    • zb_plugins
      • zb_bridge_server.py 4.3 KB
        """ZBrush agent bridge (server side). Loaded by ZBrush at startup when this folder is on
        ZBRUSH_PLUGIN_PATH. Listens on 127.0.0.1:$ZB_BRIDGE_PORT (default 7788) for one JSON
        line {"code": "...", "mode": "thread"|"main"} and answers one JSON line
        {"ok": bool, "out": str, "result": repr, "error": str}.
        
        mode "thread": code runs on the bridge thread (to be proven safe for zbrush.commands).
        mode "main": code is queued; it runs when the "ZScript:Agent Bridge:Serve" loop, running
        on ZBrush's main thread, picks it up (the loop keeps the UI alive with commands.update()).
        Experimental, 2026-09-24.
        """
        import json
        import os
        import queue
        import socket
        import threading
        import time
        import traceback
        
        from zbrush import commands as zbc
        
        PORT = int(os.environ.get("ZB_BRIDGE_PORT", "7788"))
        READ_TIMEOUT = 30.0  # seconds to receive one request line
        LOG = os.path.expanduser("~/Library/Logs/zb_bridge.log")
        MAIN_Q: "queue.Queue" = queue.Queue()
        PALETTE = "ZScript:Agent Bridge"
        
        
        def log(msg):
            with open(LOG, "a") as f:
                f.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
        
        
        def run_code(code):
            """Each call gets a fresh namespace. Code built by zb_launch defines _zb_restore(), which
            puts sys.path, sys.modules, stdout and the working directory back as they were; its
            epilogue calls it, and this finally calls it again when the code raised (idempotent)."""
            out = []
            ns = {"zbc": zbc, "print": lambda *a, **k: out.append(" ".join(str(x) for x in a))}
            try:
                exec(compile(code, "<agent>", "exec"), ns)
                return {"ok": True, "out": "\n".join(out), "result": repr(ns.get("result"))}
            except Exception:
                return {"ok": False, "out": "\n".join(out), "error": traceback.format_exc()}
            finally:
                hook = ns.get("_zb_restore")
                if callable(hook):
                    try:
                        hook()
                    except Exception:
                        log("hygiene hook error " + traceback.format_exc())
        
        
        def handle(conn):
            with conn:
                # One accept loop serves every caller, so a client that stalls mid-request
                # must not hold it: the read times out, the code itself runs untimed.
                conn.settimeout(READ_TIMEOUT)
                data = b""
                while not data.endswith(b"\n"):
                    chunk = conn.recv(1 << 16)
                    if not chunk:
                        break
                    data += chunk
                conn.settimeout(None)
                req = json.loads(data.decode() or "{}")
                if req.get("mode") == "main":
                    box = queue.Queue(maxsize=1)
                    MAIN_Q.put((req.get("code", ""), box))
                    try:
                        res = box.get(timeout=float(req.get("timeout", 600)))
                    except queue.Empty:
                        res = {"ok": False, "error": "timeout: is the main-thread Serve loop running?"}
                else:
                    res = run_code(req.get("code", ""))
                conn.sendall((json.dumps(res) + "\n").encode())
        
        
        def server():
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            s.bind(("127.0.0.1", PORT))
            s.listen(4)
            log(f"bridge listening on {PORT}")
            while True:
                conn, _ = s.accept()
                try:
                    handle(conn)
                except Exception:
                    log("handler error " + traceback.format_exc())
        
        
        def serve_main(sender=None):
            """Main-thread loop: run queued code between UI updates until 'stop' is queued."""
            log("main-thread serve loop started")
            while True:
                try:
                    code, box = MAIN_Q.get(timeout=0.05)
                except queue.Empty:
                    zbc.update(redraw_ui=True)
                    continue
                if code.strip() == "stop":
                    box.put({"ok": True, "out": "loop stopped"})
                    break
                box.put(run_code(code))
            log("main-thread serve loop stopped")
        
        
        if __name__ == "__main__" or True:
            try:
                zbc.add_subpalette(PALETTE, 0)
                zbc.add_button(f"{PALETTE}:Serve", "Run queued agent code on the main thread", serve_main)
            except Exception:
                log("palette error " + traceback.format_exc())
            threading.Thread(target=server, daemon=True).start()
            log("plugin loaded")
            if os.environ.get("ZB_BRIDGE_AUTOSERVE") == "1":
                # The embedded VM keeps the GIL on the main thread between scripts, so the socket
                # thread only runs while main-thread Python is active: serve from here.
                serve_main()
        
    • zb_audit.py 17.4 KB
      """
      zb_audit: objective checks on a mesh exported from ZBrush (OBJ), run on the agent side.
      
      The ZBrush SDK only reports counts, bounding boxes, area, volume and watertightness
      (query_mesh3d, get_polymesh3d_*, is_polymesh3d_solid); anything per vertex or per edge needs
      an export (Maxon: "export the tool and parse it", SDK api-overview). Export with
      zb_ops.export_obj(path) (dialog-free, proven in v03), then:
      
          import zb_audit
          rep = zb_audit.audit("/abs/body.obj")                # dict of numbers
          zb_audit.verdict(rep, profile="game", budget_tris=20000)
          python3 zb_audit.py body.obj --profile print --target-height-mm 300 --json out.json
      
      Needs numpy (system python3), not available inside ZBrush.
      
      Report keys
        points, faces, quads, triangles, ngons, quads_pct, tris_equiv (fan triangulation count)
        valence_hist (interior vertices), poles_e3, poles_e5, poles_6plus (interior only)
        non_manifold_edges (edges with 3+ faces), inconsistent_edges (two faces run the edge in the
        same direction: flipped winding), boundary_edges, boundary_loops (sizes), shells,
        loose_verts (unreferenced), zero_area_faces
        bbox_min, bbox_max, size, center, diag, area, volume (signed; meaningful when closed)
        edge_len_mean, edge_len_min, edge_len_max, edge_len_cv (std / mean)
        symmetry_x_pct (vertices with a mirror partner across x = sym_center within sym_tol*diag)
        has_uvs, uv_bbox
        unit_hint: "zbrush_default_2" when the longest side is about 2 (Scale Master: "ZBrush
        Scale Unify returns the Tool to XYZ Size 2", EXP doc)
      
      Numbers in verdict() are attributed in PROFILE_NOTES; anything marked [added] is this
      toolkit's own default, meant to be overridden by the brief.
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import argparse
      import json
      import sys
      
      import numpy as np
      
      
      class Mesh:
          """verts (N,3) float64, flat (M,) int64 corner vertex indices, sizes (F,) corner counts,
          starts (F,) offsets into flat; uvs (T,2) and flat_uv (M,) or None."""
      
          def __init__(self, verts, flat, sizes, uvs=None, flat_uv=None, path=None):
              self.verts = np.asarray(verts, dtype=np.float64).reshape(-1, 3)
              self.flat = np.asarray(flat, dtype=np.int64)
              self.sizes = np.asarray(sizes, dtype=np.int64)
              self.starts = np.concatenate([[0], np.cumsum(self.sizes)[:-1]]) if len(self.sizes) \
                  else np.zeros(0, dtype=np.int64)
              self.uvs = None if uvs is None or len(uvs) == 0 else np.asarray(uvs, dtype=np.float64)
              self.flat_uv = None if flat_uv is None else np.asarray(flat_uv, dtype=np.int64)
              self.path = path
      
          @classmethod
          def from_faces(cls, verts, faces):
              flat = [i for f in faces for i in f]
              return cls(verts, flat, [len(f) for f in faces])
      
          def faces(self):
              return [self.flat[s:s + n].tolist() for s, n in zip(self.starts, self.sizes)]
      
      
      def load_obj(path):
          """Minimal OBJ reader: v, vt, f (v, v/vt, v//vn, v/vt/vn, negative indices)."""
          verts, uvs, flat, flat_uv, sizes = [], [], [], [], []
          has_vt = False
          with open(path, "r", errors="ignore") as fh:
              for line in fh:
                  if line.startswith("v "):
                      parts = line.split()
                      verts.append((float(parts[1]), float(parts[2]), float(parts[3])))
                  elif line.startswith("vt "):
                      parts = line.split()
                      uvs.append((float(parts[1]), float(parts[2])))
                  elif line.startswith("f "):
                      toks = line.split()[1:]
                      nv = len(verts)
                      for t in toks:
                          a = t.split("/")
                          vi = int(a[0])
                          flat.append(vi - 1 if vi > 0 else nv + vi)
                          if len(a) > 1 and a[1]:
                              ti = int(a[1])
                              flat_uv.append(ti - 1 if ti > 0 else len(uvs) + ti)
                              has_vt = True
                          else:
                              flat_uv.append(-1)
                      sizes.append(len(toks))
          return Mesh(verts, flat, sizes, uvs if has_vt else None, flat_uv if has_vt else None, path)
      
      
      def _as_mesh(m):
          return load_obj(m) if isinstance(m, str) else m
      
      
      def _edges(mesh):
          """Directed corner edges (a -> b) following each face's winding."""
          flat, sizes, starts = mesh.flat, mesh.sizes, mesh.starts
          nxt = np.roll(flat, -1)
          ends = starts + sizes - 1
          nxt[ends] = flat[starts]
          return flat, nxt
      
      
      def _shells(n, a, b):
          """Connected components over vertices (hook and compress label propagation)."""
          lab = np.arange(n, dtype=np.int64)
          if len(a) == 0:
              return lab
          while True:
              m = np.minimum(lab[a], lab[b])
              old = lab.copy()
              np.minimum.at(lab, a, m)
              np.minimum.at(lab, b, m)
              while True:
                  j = lab[lab]
                  if np.array_equal(j, lab):
                      break
                  lab = j
              if np.array_equal(lab, old):
                  return lab
      
      
      def _boundary_loops(ba, bb, n):
          """Group boundary edges into connected components (independent of winding, so flipped
          faces do not split a hole); returns the edge count of each component, largest first.
          On a clean mesh each component is one hole."""
          lab = _shells(n, ba, bb)
          comp = lab[ba]
          _, counts = np.unique(comp, return_counts=True)
          return sorted((int(c) for c in counts), reverse=True)
      
      
      def _symmetry(verts, diag, tol_rel, center):
          """Share of vertices whose mirror across x = center has a vertex within tol."""
          n = len(verts)
          if n == 0:
              return 100.0
          tol = max(tol_rel * diag, 1e-12)
          mir = verts.copy()
          mir[:, 0] = 2 * center - mir[:, 0]
          base = np.floor(np.minimum(verts.min(axis=0), mir.min(axis=0)) / tol).astype(np.int64) - 1
          q = np.floor(verts / tol).astype(np.int64) - base
          qm = np.floor(mir / tol).astype(np.int64) - base
          dims = np.maximum(q.max(axis=0), qm.max(axis=0)) + 2
          stride = (dims[1] * dims[2], dims[2], 1)
      
          def pack(c):
              return c[:, 0] * stride[0] + c[:, 1] * stride[1] + c[:, 2] * stride[2]
      
          key = pack(q)
          order = np.argsort(key, kind="stable")
          skey = key[order]
          found = np.zeros(n, dtype=bool)
          for off in ((dx, dy, dz) for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1)):
              k = pack(qm + np.array(off))          # cells stay >= 0 thanks to the -1 margin
              pos = np.clip(np.searchsorted(skey, k), 0, n - 1)
              hit = skey[pos] == k
              d = np.linalg.norm(verts[order[pos]] - mir, axis=1)
              found |= hit & (d <= tol)
          # one representative vertex per cell is tested: exact when tol is below vertex spacing
          return float(found.mean() * 100.0)
      
      
      def audit(mesh, sym_tol=1e-3, sym_center=0.0):
          """Measure a mesh (Mesh or OBJ path). sym_tol is relative to the bbox diagonal;
          sym_center is the mirror plane x value (ZBrush symmetry mirrors across local X = 0
          [added]; pass "bbox" to use the bbox centre)."""
          m = _as_mesh(mesh)
          v, sizes = m.verts, m.sizes
          nv, nf = len(v), len(sizes)
          rep = {"path": m.path, "points": int(nv), "faces": int(nf)}
          rep["triangles"] = int((sizes == 3).sum())
          rep["quads"] = int((sizes == 4).sum())
          rep["ngons"] = int((sizes > 4).sum())
          rep["degenerate_faces"] = int((sizes < 3).sum())
          rep["quads_pct"] = round(100.0 * rep["quads"] / nf, 3) if nf else 0.0
          rep["tris_equiv"] = int(np.clip(sizes - 2, 0, None).sum())
      
          lo, hi = (v.min(axis=0), v.max(axis=0)) if nv else (np.zeros(3), np.zeros(3))
          size = hi - lo
          diag = float(np.linalg.norm(size))
          rep.update(bbox_min=lo.round(6).tolist(), bbox_max=hi.round(6).tolist(),
                     size=size.round(6).tolist(), center=((lo + hi) / 2).round(6).tolist(),
                     diag=round(diag, 6))
          rep["unit_hint"] = "zbrush_default_2" if nv and abs(float(size.max()) - 2.0) < 0.05 else None
      
          a, b = _edges(m)
          n = max(nv, 1)
          und = np.minimum(a, b) * n + np.maximum(a, b)
          ukeys, inv, counts = np.unique(und, return_inverse=True, return_counts=True)
          rep["edges"] = int(len(ukeys))
          rep["boundary_edges"] = int((counts == 1).sum())
          rep["non_manifold_edges"] = int((counts > 2).sum())
          dkeys, dcounts = np.unique(a * n + b, return_counts=True)
          rep["inconsistent_edges"] = int((dcounts > 1).sum())
      
          bmask = counts[inv] == 1
          rep["boundary_loops"] = _boundary_loops(a[bmask], b[bmask], nv) if bmask.any() else []
      
          eu, ev = ukeys // n, ukeys % n
          val = np.bincount(np.concatenate([eu, ev]), minlength=nv) if nv else np.zeros(0, int)
          boundary_v = np.zeros(nv, dtype=bool)
          if bmask.any():
              boundary_v[a[bmask]] = True
              boundary_v[b[bmask]] = True
          used = np.zeros(nv, dtype=bool)
          used[m.flat] = True
          interior = used & ~boundary_v
          iv = val[interior]
          hist = np.bincount(iv) if len(iv) else np.zeros(0, int)
          rep["valence_hist"] = {int(k): int(c) for k, c in enumerate(hist) if c}
          rep["poles_e3"] = int((iv == 3).sum())
          rep["poles_e5"] = int((iv == 5).sum())
          rep["poles_6plus"] = int((iv >= 6).sum())
          rep["loose_verts"] = int((~used).sum())
      
          lab = _shells(nv, eu, ev)
          rep["shells"] = int(len(np.unique(lab[used]))) if used.any() else 0
      
          # fan triangulation for area and volume
          if nf:
              ntri = np.clip(sizes - 2, 0, None)
              rep_face = np.repeat(np.arange(nf), ntri)
              tri_start = np.concatenate([[0], np.cumsum(ntri)[:-1]])
              local = np.arange(len(rep_face)) - np.repeat(tri_start, ntri) + 1
              st = m.starts[rep_face]
              p0 = v[m.flat[st]]
              p1 = v[m.flat[st + local]]
              p2 = v[m.flat[st + local + 1]]
              cr = np.cross(p1 - p0, p2 - p0)
              tri_area = 0.5 * np.linalg.norm(cr, axis=1)
              rep["area"] = round(float(tri_area.sum()), 6)
              rep["volume"] = round(float((p0 * np.cross(p1, p2)).sum() / 6.0), 6)
              face_area = np.bincount(rep_face, weights=tri_area, minlength=nf)
              rep["zero_area_faces"] = int((face_area <= 1e-12 * max(diag, 1e-9) ** 2).sum())
          else:
              rep.update(area=0.0, volume=0.0, zero_area_faces=0)
      
          el = np.linalg.norm(v[eu] - v[ev], axis=1) if len(eu) else np.zeros(1)
          mean = float(el.mean())
          rep.update(edge_len_mean=round(mean, 6), edge_len_min=round(float(el.min()), 6),
                     edge_len_max=round(float(el.max()), 6),
                     edge_len_cv=round(float(el.std() / mean), 4) if mean > 0 else 0.0)
      
          c = float((lo[0] + hi[0]) / 2) if sym_center == "bbox" else float(sym_center)
          rep["symmetry_center_x"] = round(c, 6)
          rep["symmetry_x_pct"] = round(_symmetry(v, diag, sym_tol, c), 3)
      
          rep["has_uvs"] = m.uvs is not None and m.flat_uv is not None and bool((m.flat_uv >= 0).any())
          if rep["has_uvs"]:
              uv = m.uvs[m.flat_uv[m.flat_uv >= 0]]
              rep["uv_bbox"] = [round(float(uv[:, 0].min()), 6), round(float(uv[:, 1].min()), 6),
                                round(float(uv[:, 0].max()), 6), round(float(uv[:, 1].max()), 6)]
          else:
              rep["uv_bbox"] = None
          return rep
      
      
      PROFILE_NOTES = {
          "watertight": "print parts must be closed: no boundary loops, no non-manifold edges "
                        "(3D Print Hub doc; SDK is_polymesh3d_solid)",
          "print_faces": "about 1M polygons per mesh is plenty for print (Gaboury, W9P5 01:11:40)",
          "print_height": "bbox height within 0.5 percent of the target in mm [added, print digest]",
          "game_ngons": "triangulate the game mesh the way the engine does; n-gons triangulate "
                        "unpredictably (Polycount doc via retopology digest) [added wording]",
          "game_uvs": "maps need UVs (Multi Map Exporter, create_normal_map doc)",
          "poles6": "valence 6+ interior vertices pinch under subdivision [added]",
          "shells": "DynaMesh fuses intersecting parts; Split To Parts separates shells "
                    "(fundamentals digest)",
          "winding": "faces running a shared edge in the same direction = flipped winding [added]",
      }
      
      
      def verdict(report, profile="sculpt", budget_tris=None, max_faces=None, expected_holes=0,
                  expected_shells=1, symmetric=False, min_symmetry_pct=99.0, require_uvs=None,
                  target_height_mm=None, height_axis=1, height_tol_pct=0.5):
          """Problems (must fix) and warnings (look at it) for a profile: sculpt, game, print."""
          r = report
          problems, warnings = [], []
          if r.get("non_manifold_edges"):
              problems.append(f"{r['non_manifold_edges']} non-manifold edges ({PROFILE_NOTES['watertight']})")
          if r.get("inconsistent_edges"):
              problems.append(f"{r['inconsistent_edges']} edges with flipped winding ({PROFILE_NOTES['winding']})")
          if r.get("degenerate_faces"):
              problems.append(f"{r['degenerate_faces']} faces with fewer than 3 corners")
          if r.get("zero_area_faces"):
              (problems if profile == "print" else warnings).append(f"{r['zero_area_faces']} zero-area faces")
          if r.get("loose_verts"):
              warnings.append(f"{r['loose_verts']} unreferenced vertices")
          holes = len(r.get("boundary_loops") or [])
          if profile == "print":
              if holes:
                  problems.append(f"{holes} holes (boundary loops {r['boundary_loops'][:5]}): not watertight")
              if r.get("volume", 0) <= 0:
                  problems.append(f"volume {r.get('volume')} <= 0: open or inside out")
              lim = max_faces or 1_000_000
              if r["faces"] > lim:
                  warnings.append(f"{r['faces']} faces > {lim} ({PROFILE_NOTES['print_faces']})")
              if target_height_mm:
                  h = r["size"][height_axis]
                  err = abs(h - target_height_mm) / target_height_mm * 100
                  if err > height_tol_pct:
                      problems.append(f"height {h:.3f} vs target {target_height_mm} mm ({err:.2f} %; "
                                      f"{PROFILE_NOTES['print_height']})")
              if r.get("shells", 1) > expected_shells:
                  warnings.append(f"{r['shells']} shells: one part per shell unless intended")
          else:
              if holes > expected_holes:
                  warnings.append(f"{holes} boundary loops (expected {expected_holes})")
              if r.get("shells", 1) > expected_shells:
                  warnings.append(f"{r['shells']} shells, expected {expected_shells} ({PROFILE_NOTES['shells']})")
          if profile == "game":
              if r.get("ngons"):
                  problems.append(f"{r['ngons']} n-gons ({PROFILE_NOTES['game_ngons']})")
              if budget_tris is not None and r["tris_equiv"] > budget_tris:
                  problems.append(f"{r['tris_equiv']} triangles > budget {budget_tris}")
              if (require_uvs is None or require_uvs) and not r.get("has_uvs"):
                  problems.append(f"no UVs ({PROFILE_NOTES['game_uvs']})")
              if r.get("poles_6plus"):
                  warnings.append(f"{r['poles_6plus']} vertices with 6+ edges ({PROFILE_NOTES['poles6']})")
          elif profile == "sculpt":
              if r.get("ngons"):
                  warnings.append(f"{r['ngons']} n-gons: Divide and ZRemesher expect quads/tris [added]")
              if require_uvs and not r.get("has_uvs"):
                  problems.append("no UVs")
          if symmetric and r.get("symmetry_x_pct", 100) < min_symmetry_pct:
              warnings.append(f"symmetry {r['symmetry_x_pct']} % < {min_symmetry_pct} % [added threshold]")
          return {"profile": profile, "ok": not problems, "problems": problems, "warnings": warnings}
      
      
      def displaced(mesh_a, mesh_b, rel_tol=1e-4):
          """Vertices that moved between two exports of the same topology (for example before and
          after one scripted stroke). Returns {'count', 'indices', 'centroid_before',
          'centroid_after', 'max_disp'}; raises if the vertex counts differ (topology changed)."""
          a, b = _as_mesh(mesh_a), _as_mesh(mesh_b)
          if len(a.verts) != len(b.verts):
              raise ValueError(f"vertex count changed {len(a.verts)} -> {len(b.verts)}")
          diag = float(np.linalg.norm(a.verts.max(axis=0) - a.verts.min(axis=0))) or 1.0
          d = np.linalg.norm(b.verts - a.verts, axis=1)
          idx = np.nonzero(d > rel_tol * diag)[0]
          out = {"count": int(len(idx)), "indices": idx.tolist()[:50000],
                 "max_disp": round(float(d.max()), 6) if len(d) else 0.0}
          if len(idx):
              w = d[idx][:, None]                    # weight by displacement: the stroke centre
              out["centroid_before"] = ((a.verts[idx] * w).sum(axis=0) / w.sum()).round(6).tolist()
              out["centroid_after"] = ((b.verts[idx] * w).sum(axis=0) / w.sum()).round(6).tolist()
              out["peak_before"] = a.verts[idx[int(np.argmax(w))]].round(6).tolist()
          return out
      
      
      def compare(before, after):
          """Numeric deltas between two audit reports (face ratio, volume change, bbox change)."""
          out = {"faces_ratio": round(after["faces"] / before["faces"], 4) if before["faces"] else None}
          if before.get("volume"):
              out["volume_change_pct"] = round((after["volume"] - before["volume"]) / abs(before["volume"]) * 100, 3)
          out["size_change"] = [round(b - a, 6) for a, b in zip(before["size"], after["size"])]
          return out
      
      
      def _cli(argv=None):
          ap = argparse.ArgumentParser(description="Audit an OBJ exported from ZBrush")
          ap.add_argument("obj")
          ap.add_argument("--profile", default="sculpt", choices=("sculpt", "game", "print"))
          ap.add_argument("--budget-tris", type=int)
          ap.add_argument("--max-faces", type=int)
          ap.add_argument("--target-height-mm", type=float)
          ap.add_argument("--symmetric", action="store_true")
          ap.add_argument("--json")
          ns = ap.parse_args(argv)
          rep = audit(ns.obj)
          rep["verdict"] = verdict(rep, ns.profile, budget_tris=ns.budget_tris, max_faces=ns.max_faces,
                                   target_height_mm=ns.target_height_mm, symmetric=ns.symmetric)
          text = json.dumps(rep, indent=1)
          if ns.json:
              with open(ns.json, "w") as fh:
                  fh.write(text)
          print(text)
          return 0 if rep["verdict"]["ok"] else 1
      
      
      if __name__ == "__main__":
          sys.exit(_cli())
      
    • zb_bridge.py 1.2 KB
      #!/usr/bin/env python3
      """ZBrush agent bridge (client side). Sends Python code to a running ZBrush that loaded
      zb_plugins/zb_bridge_server.py, prints the JSON reply.
      
      Usage: python3 zb_bridge.py [--main] [--port 7788] "code"   or   ... -f script.py
      In the code, `zbc` is zbrush.commands; assign `result = ...` to return a value.
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import json
      import socket
      import sys
      
      
      def send(code, mode="thread", port=7788, timeout=600):
          with socket.create_connection(("127.0.0.1", port), timeout=timeout + 5) as s:
              s.sendall((json.dumps({"code": code, "mode": mode, "timeout": timeout}) + "\n").encode())
              data = b""
              while not data.endswith(b"\n"):
                  chunk = s.recv(1 << 16)
                  if not chunk:
                      break
                  data += chunk
          return json.loads(data.decode())
      
      
      if __name__ == "__main__":
          args = sys.argv[1:]
          mode = "main" if "--main" in args else "thread"
          args = [a for a in args if a != "--main"]
          port = 7788
          if "--port" in args:
              i = args.index("--port"); port = int(args[i + 1]); args = args[:i] + args[i + 2:]
          code = open(args[1]).read() if args[0] == "-f" else args[0]
          print(json.dumps(send(code, mode, port), indent=1))
      
    • zb_launch.py 23.5 KB
      #!/usr/bin/env python3
      """
      zb_launch: start, ping and stop ZBrush 2026 with the agent bridge, and run toolkit code in it.
      
      Runs on the agent side (system python3), never inside ZBrush.
      
          import sys; sys.path.insert(0, "<skills/scenario-zbrush-expert/scripts>")
          import zb_launch as zl
          zl.start()                                    # spawn ZBrush with the bridge, wait for ping
          zl.run("result = zbc.get_active_tool_path()") # any code, main thread, `result` returned
          zl.call("zb_ops", "dynamesh", 256)            # a toolkit function, JSON result
          zl.hygiene()                                  # {"modules": [], "path": []} between calls
          zl.stop(save_to="/abs/out/head.ztl")          # versioned save, stop loop, quit
      
      Shared-interpreter hygiene (every run/call): sys.path, sys.modules, stdout, stderr and the
      working directory are left as found, toolkit modules are popped after use, and a stale copy of
      a requested module is set aside so the call runs the file on disk (Maxon Style Guide). Not yet
      run in ZBrush; offline tests exec the generated code in-process and check the state after.
      
          python3 zb_launch.py start|ping|status|stop [--save /abs/x.ztl]|run -c "result = 1"
      
      Proven (2026-09-24, README "Agent bridge"): the launch line (ZBRUSH_PLUGIN_PATH plus
      ZB_BRIDGE_AUTOSERVE=1 on the ZBrush binary), main-mode calls, `stop` ending the serve loop,
      quitting ZHomePage with AppleScript, System Events answering "No" in the save dialog.
      Not yet run in ZBrush as a module: start(), stop(), call(), the stall detector.
      
      Startup stall seen at 05:30 on 2026-09-24. The screen had been locked since 04:29:09
      (CGSSessionScreenLockedTime). The 05:20 launch ran under that lock and worked: v01 to v03
      passed, and its screencapture shows only the lock-screen wallpaper. The 05:30 launch, under
      the same lock, stalled: the ZBrush Activity log (Asset Directory/Logs/Activity/Activity
      2026-09-24-05-30-32.txt) ends with the last default startup line, IPress "Zplugin:Misc
      Utilities: Home Page", at 05:30:36; ~/Library/Logs/zb_bridge.log stayed empty (the plugin
      never wrote "plugin loaded"); port 7788 never answered. So the lock alone does not explain
      it, and the cause is unknown [verify]. start() therefore watches the startup stages. If
      "plugin loaded" does not appear within `stall_timeout`, it raises ZBStall with a diagnosis
      and, by default, terminates the process it spawned, because a hung ZBrush instance blocks
      the next launch (ZBrush is single-instance).
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import ast
      import ctypes
      import ctypes.util
      import glob
      import json
      import os
      import signal
      import socket
      import subprocess
      import sys
      import time
      
      HERE = os.path.dirname(os.path.abspath(__file__))
      if HERE not in sys.path:
          sys.path.insert(0, HERE)
      import zb_bridge  # noqa: E402  (proven client, same folder)
      
      ZBRUSH_APP = "/Applications/Maxon ZBrush 2026/ZBrush.app"
      ZBRUSH_BIN = ZBRUSH_APP + "/Contents/MacOS/ZBrush"
      BUNDLE_ID = "net.maxon.zbrush"
      PLUGIN_DIR = os.path.join(HERE, "zb_plugins")
      LOGS = os.path.expanduser("~/Library/Logs")
      BRIDGE_LOG = os.path.join(LOGS, "zb_bridge.log")
      STDOUT_LOG = os.path.join(LOGS, "zbrush_stdout.log")
      ASSET_GLOB = os.path.expanduser("~/Library/Preferences/Maxon/ZBrush_*")
      DEFAULT_PORT = 7788
      
      
      class ZBError(RuntimeError):
          pass
      
      
      class ZBTimeout(ZBError):
          pass
      
      
      class ZBStall(ZBError):
          pass
      
      
      class ZBRemoteError(ZBError):
          def __init__(self, msg, traceback_text="", out=""):
              super().__init__(msg)
              self.traceback = traceback_text
              self.out = out
      
      
      # --------------------------------------------------------------------------------------------
      # Machine state
      # --------------------------------------------------------------------------------------------
      
      def screen_locked():
          """True / False from CGSessionCopyCurrentDictionary (CGSSessionScreenIsLocked), None if
          unknown. Verified on this Mac 2026-09-24 (returned True while locked)."""
          try:
              cg = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreGraphics"))
              cf = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))
              cg.CGSessionCopyCurrentDictionary.restype = ctypes.c_void_p
              cf.CFDictionaryGetValue.restype = ctypes.c_void_p
              cf.CFDictionaryGetValue.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
              cf.CFStringCreateWithCString.restype = ctypes.c_void_p
              cf.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32]
              cf.CFBooleanGetValue.argtypes = [ctypes.c_void_p]
              cf.CFBooleanGetValue.restype = ctypes.c_bool
              d = cg.CGSessionCopyCurrentDictionary()
              if not d:
                  return None
              key = cf.CFStringCreateWithCString(None, b"CGSSessionScreenIsLocked", 0x08000100)
              v = cf.CFDictionaryGetValue(d, key)
              return bool(cf.CFBooleanGetValue(v)) if v else False
          except Exception:
              return None
      
      
      def pids(pattern="ZBrush.app/Contents/MacOS/ZBrush"):
          r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, text=True)
          return [int(x) for x in r.stdout.split() if x.strip().isdigit() and int(x) != os.getpid()]
      
      
      def is_running():
          """True when a ZBrush process exists (with or without the bridge)."""
          return bool(pids())
      
      
      def homepage_running():
          return bool(pids("ZHomePage.app/Contents/MacOS/ZHomePage"))
      
      
      def port_open(port=DEFAULT_PORT, timeout=0.5):
          try:
              with socket.create_connection(("127.0.0.1", port), timeout=timeout):
                  return True
          except OSError:
              return False
      
      
      def _tail(path, n=8):
          try:
              with open(path, "r", errors="ignore") as fh:
                  return [ln.rstrip("\n") for ln in fh.readlines()[-n:]]
          except OSError:
              return None
      
      
      def activity_logs():
          files = []
          for d in glob.glob(ASSET_GLOB):
              files += glob.glob(os.path.join(d, "Logs", "Activity", "Activity *.txt"))
          return sorted(files, key=os.path.getmtime)
      
      
      def diagnose(port=DEFAULT_PORT):
          """Everything useful when the bridge does not answer."""
          act = activity_logs()
          return {
              "zbrush_pids": pids(), "port_open": port_open(port), "screen_locked": screen_locked(),
              "homepage_running": homepage_running(), "bridge_log_tail": _tail(BRIDGE_LOG),
              "stdout_tail": _tail(STDOUT_LOG), "activity_file": act[-1] if act else None,
              "activity_tail": _tail(act[-1], 6) if act else None,
          }
      
      
      # --------------------------------------------------------------------------------------------
      # Talking to the bridge
      # --------------------------------------------------------------------------------------------
      
      def send(code, mode="main", port=DEFAULT_PORT, timeout=120):
          """Raw bridge call (zb_bridge.send) with readable errors."""
          try:
              reply = zb_bridge.send(code, mode, port, timeout)
          except ConnectionRefusedError:
              raise ZBError(f"bridge not answering on port {port}: start ZBrush with zb_launch.start() "
                            f"({json.dumps(diagnose(port))[:400]})")
          except socket.timeout:
              raise ZBTimeout(f"no reply within {timeout + 5} s. The main thread is busy (long "
                              "operation or a modal dialog). The queued code still runs when the "
                              "main thread frees: ping before sending anything else")
          if not reply.get("ok") and "timeout: is the main-thread Serve loop running" in reply.get("error", ""):
              raise ZBTimeout(f"bridge queued the code but the main thread did not pick it up within "
                              f"{timeout} s (serve loop stopped, modal dialog, or long operation). "
                              "The code may still run later")
          return reply
      
      
      SKILLS_ROOT = os.path.dirname(os.path.dirname(HERE))
      
      
      def toolkit_dirs():
          """This skill's scripts folder and every other scenario-zbrush-* skill's scripts folder: the
          modules the bridge prelude owns (imports, pops, reports). Plugin subfolders such as
          zb_plugins (the bridge server) are not included."""
          found = sorted(glob.glob(os.path.join(SKILLS_ROOT, "scenario-zbrush-*", "scripts")))
          return list(dict.fromkeys(os.path.abspath(d) for d in [HERE] + found))
      
      
      # Shared-interpreter hygiene (Maxon Style Guide: leave sys.path, sys.modules, stdout and the
      # working directory as found; pop helper modules after use, ex_mod_curve_lightning). ZBrush
      # has ONE embedded interpreter for the whole session, so anything a call leaves behind stays
      # until restart. The prelude snapshots the state, _zb_restore() puts it back and pops every
      # toolkit module the call imported (also lazy imports inside functions). The epilogue calls
      # it; the bridge server calls it again after an error (idempotent). Names _zb_sys, _zb_json
      # and _zb_il stay bound: other skills' preludes use them.
      _HYGIENE = r'''import sys as _zb_sys, json as _zb_json, importlib as _zb_il, os as _zb_os
      _zb_own = set(__OWN__)
      def _zb_owned(_k, _m, _own=_zb_own, _os=_zb_os):
          _f = getattr(_m, "__file__", None) or ""
          return bool(_f) and _os.path.dirname(_os.path.abspath(_f)) in _own
      _zb_state = {"path": list(_zb_sys.path), "modules": set(_zb_sys.modules),
                   "cwd": _zb_os.getcwd(), "stdout": _zb_sys.stdout, "stderr": _zb_sys.stderr,
                   "evicted": {}, "done": None}
      def _zb_restore(_st=_zb_state, _sys=_zb_sys, _os=_zb_os, _owned=_zb_owned):
          if _st["done"] is not None:
              return _st["done"]
          _sys.path[:] = _st["path"]
          for _k in [k for k in list(_sys.modules) if k not in _st["modules"]]:
              _m = _sys.modules.get(_k)
              if (_k.startswith("zb_") and not _k.startswith("zb_bridge_server")) or _owned(_k, _m):
                  _sys.modules.pop(_k, None)
          _sys.modules.update(_st["evicted"])
          if _sys.stdout is not _st["stdout"]:
              _sys.stdout = _st["stdout"]
          if _sys.stderr is not _st["stderr"]:
              _sys.stderr = _st["stderr"]
          try:
              if _os.getcwd() != _st["cwd"]:
                  _os.chdir(_st["cwd"])
          except OSError:
              pass
          _st["done"] = {"reloaded": sorted(_st["evicted"])} if _st["evicted"] else {}
          return _st["done"]
      '''
      
      
      def _prelude(modules, dirs=None):
          """Hygiene definitions, then (when modules are named) the import block: a stale copy of
          a named module already in sys.modules is set aside so the call runs the file on disk
          (restored afterwards), the folders go on sys.path only for the import, and the modules
          the import added are popped again before `code` runs (as before 2026-09-24)."""
          dirs = [os.path.abspath(d) for d in (dirs or [HERE])]
          lines = [_HYGIENE.replace("__OWN__", repr(sorted(set(toolkit_dirs()) | set(dirs)))).rstrip()]
          if modules:
              lines += [f"for _zb_k in {list(modules)!r}:",
                        "    if _zb_k in _zb_sys.modules:",
                        "        _zb_state['evicted'][_zb_k] = _zb_sys.modules.pop(_zb_k)",
                        f"_zb_dirs = {dirs!r}", "_zb_before = set(_zb_sys.modules)",
                        "_zb_sys.path[0:0] = _zb_dirs", "try:"]
              lines += [f"    {m} = _zb_il.import_module({m!r})" for m in modules]
              lines += ["finally:", "    _zb_sys.path[:] = _zb_state['path']",
                        "    for _zb_k in set(_zb_sys.modules) - _zb_before:",
                        "        if _zb_k.startswith('zb_') or _zb_owned(_zb_k, _zb_sys.modules.get(_zb_k)):",
                        "            _zb_sys.modules.pop(_zb_k, None)"]
          return "\n".join(lines) + "\n"
      
      
      _EPILOGUE = ("\n_zb_hyg = _zb_restore()\n"
                   "result = _zb_json.dumps(dict({'value': globals().get('result')}, "
                   "**({'hygiene': _zb_hyg} if _zb_hyg else {})), default=repr)\n")
      
      
      def build_code(code, modules=(), dirs=None):
          """The exact source sent to ZBrush: the hygiene prelude, toolkit modules imported by path
          (from `dirs`, default this skill's scripts folder) without leaving them on sys.path or in
          sys.modules, then `code`, then _zb_restore() and the `result` value JSON-encoded."""
          for m in modules:
              if not m.isidentifier():
                  raise ValueError(f"bad module name {m!r}")
          return _prelude(tuple(modules), dirs) + code + _EPILOGUE
      
      
      def _decode(reply):
          if not reply.get("ok"):
              err = reply.get("error", "")
              last = err.strip().splitlines()[-1] if err.strip() else "remote error"
              raise ZBRemoteError(last, err, reply.get("out", ""))
          raw = reply.get("result")
          try:
              obj = json.loads(ast.literal_eval(raw))
          except (ValueError, SyntaxError, TypeError):
              return raw, None
          if isinstance(obj, dict) and "value" in obj:
              return obj["value"], obj.get("hygiene") or None
          return raw, None
      
      
      def decode_reply(reply):
          return _decode(reply)[0]
      
      
      def run(code, modules=(), port=DEFAULT_PORT, timeout=120, full=False, dirs=None):
          """Run `code` on ZBrush's main thread; it sees `zbc` and the named toolkit modules and
          returns whatever it assigns to `result` (JSON-able, else repr). full=True returns
          {"value", "out"} plus "hygiene" when a stale toolkit module had to be set aside."""
          reply = send(build_code(code, modules, dirs), "main", port, timeout)
          value, hyg = _decode(reply)
          if not full:
              return value
          res = {"value": value, "out": reply.get("out", "")}
          if hyg:
              res["hygiene"] = hyg
          return res
      
      
      _PROBE = r'''import sys as _s, os as _o
      _d = set(__DIRS__)
      def _own(m):
          f = getattr(m, "__file__", None) or ""
          return bool(f) and _o.path.dirname(_o.path.abspath(f)) in _d
      result = {"modules": sorted(k for k, m in list(_s.modules.items()) if _own(m)),
                "path": [p for p in _s.path if _o.path.abspath(p) in _d]}
      '''
      
      
      def hygiene(port=DEFAULT_PORT, timeout=15):
          """What ZBrush's shared interpreter holds of the toolkit right now: toolkit modules in
          sys.modules and toolkit folders on sys.path. Both lists must be empty between calls;
          anything listed leaked from code sent outside zb_launch.run (fix: purge())."""
          return run(_PROBE.replace("__DIRS__", repr(toolkit_dirs())), port=port, timeout=timeout)
      
      
      def purge(port=DEFAULT_PORT, timeout=15):
          """Remove leaked toolkit modules and folders from ZBrush's interpreter. Sent raw (no
          prelude), because the prelude would restore the leak it snapshotted."""
          code = (_PROBE.replace("__DIRS__", repr(toolkit_dirs())) +
                  "for _k in result['modules']:\n    _s.modules.pop(_k, None)\n"
                  "_s.path[:] = [p for p in _s.path if _o.path.abspath(p) not in _d]\n")
          reply = send(code, "main", port, timeout)
          if not reply.get("ok"):
              raise ZBRemoteError("purge failed", reply.get("error", ""), reply.get("out", ""))
          try:
              return {"removed": ast.literal_eval(reply.get("result"))}
          except (ValueError, SyntaxError):
              return {"removed": reply.get("result")}
      
      
      def call(module, func, *args, port=DEFAULT_PORT, timeout=120, **kwargs):
          """module.func(*args, **kwargs) inside ZBrush; args must be JSON-able."""
          code = (f"result = {module}.{func}(*_zb_json.loads({json.dumps(list(args))!r}), "
                  f"**_zb_json.loads({json.dumps(kwargs)!r}))")
          return run(code, (module,), port, timeout)
      
      
      def ping(port=DEFAULT_PORT, timeout=10):
          t = time.time()
          v = run("result = zbc.zbrush_info(0)", port=port, timeout=timeout)
          return {"ok": True, "version": v, "latency_s": round(time.time() - t, 3)}
      
      
      # --------------------------------------------------------------------------------------------
      # Start and stop
      # --------------------------------------------------------------------------------------------
      
      def build_env(port=DEFAULT_PORT, plugin_dir=PLUGIN_DIR, extra_env=None):
          env = os.environ.copy()
          prev = env.get("ZBRUSH_PLUGIN_PATH")
          env["ZBRUSH_PLUGIN_PATH"] = plugin_dir if not prev else plugin_dir + os.pathsep + prev
          env["ZB_BRIDGE_AUTOSERVE"] = "1"
          env["ZB_BRIDGE_PORT"] = str(port)
          env.update(extra_env or {})
          return env
      
      
      def quit_homepage():
          """The Home Page runs as a separate ZHomePage process over the canvas (README)."""
          if not homepage_running():
              return False
          subprocess.run(["osascript", "-e", 'tell application "ZHomePage" to quit'],
                         capture_output=True, timeout=15)
          return True
      
      
      def _bridge_log_size():
          try:
              return os.path.getsize(BRIDGE_LOG)
          except OSError:
              return 0
      
      
      def _bridge_log_since(offset):
          try:
              with open(BRIDGE_LOG, "r", errors="ignore") as fh:
                  fh.seek(offset)
                  return fh.read()
          except OSError:
              return ""
      
      
      def start(port=DEFAULT_PORT, plugin_dir=PLUGIN_DIR, timeout=120, stall_timeout=60,
                kill_on_stall=True, quit_home=True, extra_env=None):
          """Spawn ZBrush with the bridge, wait for "plugin loaded", the port and a ping, then quit
          the ZHomePage helper. Returns a dict. Refuses to start a second instance: if ZBrush is
          already running it pings it and returns, or raises when it runs without the bridge."""
          t0 = time.time()
          if is_running():
              if port_open(port):
                  p = ping(port)
                  return {"status": "already running", "pids": pids(), **p}
              raise ZBError("ZBrush is running without the bridge on this port. ZBrush is "
                            "single-instance: quit it (save first) before zb_launch.start()")
          if not os.path.exists(ZBRUSH_BIN):
              raise ZBError(f"ZBrush binary not found: {ZBRUSH_BIN}")
          locked = screen_locked()
          offset = _bridge_log_size()
          os.makedirs(LOGS, exist_ok=True)
          out = open(STDOUT_LOG, "a")
          out.write(f"\n--- zb_launch.start {time.strftime('%Y-%m-%d %H:%M:%S')} locked={locked}\n")
          out.flush()
          proc = subprocess.Popen([ZBRUSH_BIN], env=build_env(port, plugin_dir, extra_env),
                                  stdout=out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
                                  start_new_session=True)
          stages = {"spawned": 0.0}
          try:
              while True:
                  el = time.time() - t0
                  if proc.poll() is not None:
                      raise ZBError(f"ZBrush exited with code {proc.returncode} during startup "
                                    f"({json.dumps(diagnose(port))[:600]})")
                  if "loaded" not in stages and "plugin loaded" in _bridge_log_since(offset):
                      stages["loaded"] = round(el, 1)
                  if "loaded" in stages and port_open(port):
                      try:
                          p = ping(port, timeout=10)
                          stages["ping"] = round(time.time() - t0, 1)
                          break
                      except ZBError:
                          pass
                  if "loaded" not in stages and el > stall_timeout:
                      raise ZBStall(f"no 'plugin loaded' in {BRIDGE_LOG} after {stall_timeout} s: "
                                    "startup stalled (see the 05:30 case in this module's docstring)")
                  if el > timeout:
                      raise ZBTimeout(f"bridge not answering after {timeout} s")
                  time.sleep(1.0)
          except ZBError as e:
              diag = diagnose(port)
              if kill_on_stall and proc.poll() is None:
                  proc.terminate()
                  try:
                      proc.wait(15)
                  except subprocess.TimeoutExpired:
                      proc.kill()
                  diag["terminated_spawned_pid"] = proc.pid
              e.args = (f"{e.args[0]} | stages {stages} | diagnosis {json.dumps(diag)}",)
              raise
          homepage = False
          if quit_home:
              for _ in range(10):  # the helper can appear a little after the ping
                  if quit_homepage():
                      homepage = True
                      break
                  time.sleep(0.5)
          return {"status": "started", "pid": proc.pid, "port": port, "version": p["version"],
                  "screen_locked": locked, "stages_s": stages, "homepage_quit": homepage,
                  "seconds": round(time.time() - t0, 1)}
      
      
      _DIALOG_NO = '''
      tell application "System Events"
        if exists process "ZBrush" then
          tell process "ZBrush"
            repeat with w in windows
              try
                if exists button "No" of w then
                  click button "No" of w
                  return "clicked No"
                end if
              end try
            end repeat
          end tell
        end if
      end tell
      return "no dialog found"
      '''
      
      
      def _wait_exit(seconds):
          end = time.time() + seconds
          while time.time() < end:
              if not is_running():
                  return True
              time.sleep(0.5)
          return not is_running()
      
      
      def stop(save_to=None, port=DEFAULT_PORT, timeout=60, force=True):
          """Save (optional), stop the serve loop, quit ZBrush.
      
          save_to: a .ZTL path; saved with zb_ops.save_ztl (versioned: never overwrites). If the
          save fails, ZBrush is left running (no work is thrown away) and the error is returned.
          Quit: AppleScript quit, then System Events clicks "No" in the save-changes dialog when
          the screen is unlocked (UI scripting needs an unlocked session [added]); when locked or
          still running, SIGTERM, then SIGKILL if force (only after the save succeeded)."""
          rep = {"was_running": is_running(), "screen_locked": screen_locked()}
          if not rep["was_running"]:
              return rep
          if save_to:
              try:
                  rep["saved"] = call("zb_ops", "save_ztl", save_to, port=port, timeout=timeout)
              except ZBError as e:
                  rep["save_error"] = str(e)
                  rep["action"] = "left running because the save failed"
                  return rep
          try:
              rep["loop"] = send("stop", "main", port, timeout=15).get("out")
          except ZBError as e:
              rep["loop_error"] = str(e)
          subprocess.run(["osascript", "-e", "ignoring application responses", "-e",
                          f'tell application id "{BUNDLE_ID}" to quit', "-e", "end ignoring"],
                         capture_output=True, timeout=15)
          rep["quit_sent"] = True
          if _wait_exit(5):
              rep["exit"] = "clean"
          elif not rep["screen_locked"]:
              r = subprocess.run(["osascript", "-e", _DIALOG_NO], capture_output=True, text=True,
                                 timeout=20)
              rep["dialog"] = (r.stdout or r.stderr).strip()
              if _wait_exit(15):
                  rep["exit"] = "clean after dialog"
          if is_running():
              for pid in pids():
                  os.kill(pid, signal.SIGTERM)
              rep["sigterm"] = True
              if not _wait_exit(15) and force:
                  for pid in pids():
                      os.kill(pid, signal.SIGKILL)
                  rep["sigkill"] = True
                  _wait_exit(5)
              rep.setdefault("exit", "terminated")
          if homepage_running():
              quit_homepage()
          rep["running_after"] = is_running()
          return rep
      
      
      def status(port=DEFAULT_PORT):
          d = diagnose(port)
          if d["port_open"]:
              try:
                  d["ping"] = ping(port)
              except ZBError as e:
                  d["ping_error"] = str(e)
          return d
      
      
      def _cli(argv=None):
          import argparse
          ap = argparse.ArgumentParser(description="ZBrush bridge session control")
          ap.add_argument("cmd", choices=("start", "ping", "status", "stop", "run"))
          ap.add_argument("--port", type=int, default=DEFAULT_PORT)
          ap.add_argument("--timeout", type=int, default=120)
          ap.add_argument("--save")
          ap.add_argument("-c", "--code")
          ap.add_argument("--json")
          ns = ap.parse_args(argv)
          try:
              if ns.cmd == "start":
                  res = start(ns.port, timeout=ns.timeout)
              elif ns.cmd == "ping":
                  res = ping(ns.port)
              elif ns.cmd == "status":
                  res = status(ns.port)
              elif ns.cmd == "stop":
                  res = stop(ns.save, ns.port)
              else:
                  res = run(ns.code, port=ns.port, timeout=ns.timeout, full=True)
              ok = True
          except ZBError as e:
              res, ok = {"error": type(e).__name__, "message": str(e)}, False
          text = json.dumps(res, indent=1, default=repr)
          if ns.json:
              os.makedirs(os.path.dirname(os.path.abspath(ns.json)), exist_ok=True)
              with open(ns.json, "w") as fh:
                  fh.write(text)
          print(text)
          return 0 if ok else 1
      
      
      if __name__ == "__main__":
          sys.exit(_cli())
      
    • zb_ops.py 50.1 KB
      """
      zb_ops: thin, checked wrappers for the deterministic ZBrush operations experts use.
      
      Runs INSIDE ZBrush 2026 (CPython 3.11.9). From the agent side call it through the bridge:
          zb_launch.call("zb_ops", "dynamesh", 256)
          zb_launch.run("result = [zb_ops.divide(2), zb_ops.stats()]", modules=("zb_ops",))
      
      Every wrapper resolves its item path from candidates with exists(), because set() on a
      missing path is silent (proven v03). It reads values back and returns before/after numbers
      so the caller can gate on them. Paths are tagged in PATHS:
        [bridge] exists() or used through the bridge on 2026.2.1 (v01-v03)
        [log]    canonical form written by ZBrush in Logs/Activity (2026-09-24 05:20 session)
        [doc]    printed in Maxon's SDK docs or examples;  [macro] used by a shipped 2026 macro
        [verify] not confirmed; the wrapper tries it and fails loudly if absent
        [cmdxml] id listed in ZData/ZLang/zcommands/commands.xml of 2026.2.1 (spaces and case are
                 ignored in paths, so the id "Transform: Pf" is the path "Transform:Pf")
      Only export_obj, export_canvas and the DynaMesh, Divide, brush and Draw paths have run
      through the bridge. Every function here is "not yet run in ZBrush" as a function; see
      tests/code/zbrush-expert/live_* for the checks that will prove them.
      
      Safety defaults (refactor 2026-09-24, offline-tested only):
        project_all()  versioned ZTL first (it can crash: cgside), Store MT and a New Layer at the
                       top level, Dist set explicitly to 0.1 (Drust), gates after; repair with
                       morph_repair() or the layer, never by smoothing (FlippedNormals)
        gates          dynamesh, zremesher, divide, del_levels, decimate, deform/polish and
                       project_all return a "gate" dict built from get_polymesh3d_volume() and
                       is_polymesh3d_solid() (collapsed or exploded remesh, lost watertightness)
        long sequences quiet() (show_actions 0, restored), frozen(fn) (zbc.freeze), sequence()
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import contextlib
      import difflib
      import glob
      import os
      import re
      import time
      
      import zb_stroke  # pure Python sibling; imported at load time (the bridge prelude puts this
                        # folder on sys.path only while importing)
      
      try:
          from zbrush import commands as zbc
      except ImportError:  # agent side or offline tests: inject a fake with zb_ops.zbc = Fake()
          zbc = None
      
      INSTALL = "/Applications/Maxon ZBrush 2026"
      
      
      class ZBOpError(RuntimeError):
          pass
      
      
      PATHS = {
          # mode, tools, canvas
          "edit": ["Transform:Edit"],                                   # [bridge]
          "sphere3d": ["Tool:Sphere3D"],                                # [bridge]
          "make_polymesh": ["Tool:Make PolyMesh3D"],                    # [bridge]
          "polymesh_star": ["Tool:PolyMesh3D"],                         # [verify] usd-portal
          "layer_clear": ["Layer:Clear"],                               # [macro]
          "fit": ["Transform:Fit", "Transform:Fit Mesh To View"],       # [macro] [doc]; Transform:Frame absent [bridge]
          "persp": ["Draw:Perspective", "Draw:Persp", "Transform:Persp"],  # [cmdxml] Draw:Perspective
          "polyframe": ["Transform:Pf", "Transform:PolyF"],             # [cmdxml] id "Transform: Pf"; PolyF is the label people say
          "symmetry": ["Transform:Activate Symmetry"],                  # [bridge]
          "sym_x": ["Transform:>X<"],                                   # [macro]
          "sym_y": ["Transform:>Y<"],                                   # [verify]
          "sym_z": ["Transform:>Z<"],                                   # [verify]
          "lsym": ["Transform:LSym"],                                   # [macro]
          "python_output": ["ZScript:Script Window Mode:Python Output"],  # [doc]
          # DynaMesh. The Activity log writes Tool:Geometry:Resolution and Tool:Geometry:DynaMesh
          # for what the agent sent as Tool:Geometry:DynaMesh:Resolution / :DynaMesh.
          "dyn_res": ["Tool:Geometry:DynaMesh:Resolution", "Tool:Geometry:Resolution"],  # [bridge] [log]
          "dyn_button": ["Tool:Geometry:DynaMesh:DynaMesh", "Tool:Geometry:DynaMesh"],    # [bridge] [log]
          "dyn_blur": ["Tool:Geometry:DynaMesh:Blur", "Tool:Geometry:Blur"],              # [verify]
          "dyn_project": ["Tool:Geometry:DynaMesh:Project", "Tool:Geometry:Project"],     # [verify]
          "dyn_groups": ["Tool:Geometry:DynaMesh:Groups", "Tool:Geometry:Groups"],        # [verify]
          # subdivision
          "divide": ["Tool:Geometry:Divide"],                           # [bridge] [doc]
          "smt": ["Tool:Geometry:Smt"],                                 # [bridge]
          "suv": ["Tool:Geometry:Suv"],                                 # [verify]
          "sdiv": ["Tool:Geometry:SDiv"],                               # [bridge]
          "lower_res": ["Tool:Geometry:Lower Res"],                     # [doc]
          "higher_res": ["Tool:Geometry:Higher Res"],                   # [verify]
          "del_lower": ["Tool:Geometry:Del Lower"],                     # [macro]
          "del_higher": ["Tool:Geometry:Del Higher"],                   # [macro]
          "mirror_weld": ["Tool:Geometry:Mirror And Weld"],             # [macro]
          "x_pos": ["Tool:Geometry:X Position"],                        # [doc]
          "y_pos": ["Tool:Geometry:Y Position"],                        # [doc]
          "z_pos": ["Tool:Geometry:Z Position"],                        # [doc]
          "xyz_size": ["Tool:Geometry:XYZ Size"],                       # [macro]
          # ZRemesher
          "zr_button": ["Tool:Geometry:ZRemesher:ZRemesher", "Tool:Geometry:ZRemesher"],   # [bridge]
          "zr_target": ["Tool:Geometry:ZRemesher:Target Polygons Count",
                        "Tool:Geometry:Target Polygons Count"],         # [bridge]
          "zr_half": ["Tool:Geometry:ZRemesher:Half", "Tool:Geometry:Half"],               # [verify]
          "zr_same": ["Tool:Geometry:ZRemesher:Same", "Tool:Geometry:Same"],               # [verify]
          "zr_double": ["Tool:Geometry:ZRemesher:Double", "Tool:Geometry:Double"],         # [verify]
          "zr_adapt": ["Tool:Geometry:ZRemesher:Adapt", "Tool:Geometry:Adapt"],            # [verify]
          "zr_adaptive_size": ["Tool:Geometry:ZRemesher:AdaptiveSize", "Tool:Geometry:AdaptiveSize"],  # [verify]
          "zr_keep_groups": ["Tool:Geometry:ZRemesher:KeepGroups", "Tool:Geometry:KeepGroups"],        # [verify]
          "zr_keep_creases": ["Tool:Geometry:ZRemesher:KeepCreases", "Tool:Geometry:KeepCreases"],     # [verify]
          "zr_detect_edges": ["Tool:Geometry:ZRemesher:Detect Edges", "Tool:Geometry:Detect Edges"],   # [verify]
          # SubTools and projection
          "duplicate": ["Tool:SubTool:Duplicate"],                      # [bridge] needs Edit mode
          "append": ["Tool:SubTool:Append"],                            # [bridge]
          "insert": ["Tool:SubTool:Insert"],                            # [macro] then PopUp:<Tool>
          "project_all": ["Tool:SubTool:Project All", "Tool:SubTool:Project:Project All"],  # [bridge] [cmdxml ProjectAll]
          "project_dist": ["Tool:SubTool:Project:Dist", "Tool:SubTool:Dist"],              # [cmdxml] second form
          "project_mean": ["Tool:SubTool:Project:Mean", "Tool:SubTool:Mean"],              # [cmdxml] second form
          "project_pa_blur": ["Tool:SubTool:Project:PA Blur", "Tool:SubTool:PA Blur"],     # [cmdxml] second form
          # deformation (each set applies once, like the shipped Enhance Details macro)
          "polish": ["Tool:Deformation:Polish"],                        # [bridge] [macro]
          "inflate": ["Tool:Deformation:Inflate"],                      # [bridge]
          "unify": ["Tool:Deformation:Unify"],                          # [doc]
          # masking, polygroups, visibility
          "mask_clear": ["Tool:Masking:Clear"],                         # [bridge]
          "mask_inverse": ["Tool:Masking:Inverse"],                     # [bridge]
          "mask_blur": ["Tool:Masking:BlurMask"],                       # [macro]
          "mask_sharpen": ["Tool:Masking:SharpenMask"],                 # [macro]
          "mask_grow": ["Tool:Masking:GrowMask"],                       # [verify]
          "mask_shrink": ["Tool:Masking:ShrinkMask"],                   # [verify]
          "mask_all": ["Tool:Masking:MaskAll"],                         # [verify]
          "mask_center": ["Tool:Masking:Go To Unmasked Center"],        # [macro]
          "pg_auto": ["Tool:Polygroups:Auto Groups"],                   # [bridge]
          "pg_visible": ["Tool:Polygroups:Group Visible"],              # [macro]
          "pg_masked": ["Tool:Polygroups:Group Masked"],                # [verify]
          "pg_normals": ["Tool:Polygroups:GroupsByNormals"],            # [verify]
          "vis_hide": ["Tool:Visibility:HidePt"],                       # [macro]
          "vis_show": ["Tool:Visibility:ShowPt"],                       # [macro]
          "vis_grow": ["Tool:Visibility:Grow"],                         # [macro]
          "vis_shrink": ["Tool:Visibility:Shrink"],                     # [macro]
          "store_mt": ["Tool:Morph Target:StoreMT"],                    # [macro] [cmdxml]
          "switch_mt": ["Tool:Morph Target:Switch"],                    # [macro] [cmdxml]
          "del_mt": ["Tool:Morph Target:DelMT"],                        # [cmdxml]
          "morph_slider": ["Tool:Morph Target:Morph"],                  # [cmdxml]
          "layer_new": ["Tool:Layers:New"],                             # [macro] [cmdxml]
          "layer_intensity": ["Tool:Layers:Intensity"],                 # [cmdxml]
          # UVs
          "uv_unwrap": ["Tool:UV Map:Create (Unwrap):Unwrap"],          # [bridge] [doc]
          "uv_auto_seams": ["Tool:UV Map:Create (Unwrap):Auto Seams"],  # [doc]
          "uv_symmetry": ["Tool:UV Map:Create (Unwrap):Symmetry"],      # [verify]
          "uvm_unwrap": ["Zplugin:UV Master:Unwrap"],                   # [bridge]
          "uvm_symmetry": ["Zplugin:UV Master:Symmetry"],               # [verify]
          "uvm_polygroups": ["Zplugin:UV Master:Polygroups"],           # [verify]
          "uvm_clone": ["Zplugin:UV Master:Work On Clone"],             # [verify]
          # Decimation Master (a ZScript plugin: whether a press returns to Python is [verify])
          "dm_percent": ["Zplugin:Decimation Master:% of decimation"],  # [verify] ZBrushCentral 2013
          "dm_preprocess": ["Zplugin:Decimation Master:Pre-process Current"],  # [verify]
          "dm_decimate": ["Zplugin:Decimation Master:Decimate Current"],       # [bridge]
          "dm_keep_uvs": ["Zplugin:Decimation Master:Keep UVs"],               # [verify]
          # files (set_next_filename first)
          "export": ["Tool:Export"],                                    # [bridge] dialog-free
          "import": ["Tool:Import"],                                    # [bridge]
          "tool_save_as": ["Tool:Save As"],                             # [bridge] exists; dialog-free [verify]
          "file_save_as": ["File:Save As"],                             # [bridge] exists
          "doc_export": ["Document:Export"],                            # [bridge] dialog-free
          "bpr": ["Render:BPR"],                                        # [bridge] exists
          "load_brush": ["Brush:Load Brush"],                           # [doc] path example
          # draw and state readers
          "draw_size": ["Draw:Draw Size"],                              # [bridge]
          "z_intensity": ["Draw:Z Intensity"],                          # [bridge]
          "focal": ["Draw:Focal Shift"],                                # [bridge]
          "zadd": ["Draw:Zadd"],                                        # [bridge] get worked
          "zsub": ["Draw:Zsub"],                                        # [verify]
          "mrgb": ["Draw:Mrgb"],                                        # [doc]
          "rgb": ["Draw:Rgb"],                                          # [verify]
          "lazymouse": ["Stroke:Lazy Mouse:LazyMouse"],                 # [bridge]
          "fill_object": ["Color:FillObject"],                          # [doc]
          "current_brush": ["Brush:Current Brush"],                     # [verify]
          "current_material": ["Material:Current Material"],            # [doc ZScript manual]
          "current_tool": ["Tool:Current Tool"],                        # [doc ZScript manual]
      }
      
      
      def _z():
          if zbc is None:
              raise ZBOpError("zbrush.commands not available: run inside ZBrush (zb_launch.call)")
          return zbc
      
      
      def resolve(key, required=True):
          """First existing item path among the candidates for `key` (or a list of paths)."""
          cands = PATHS.get(key, [key]) if isinstance(key, str) else list(key)
          z = _z()
          for p in cands:
              if z.exists(p):
                  return p
          if required:
              raise ZBOpError(f"none of {cands} exists: label changed in this build, wrong mode "
                              "(Edit off, not a PolyMesh3D), or plugin missing")
          return None
      
      
      def get(key):
          return _z().get(resolve(key))
      
      
      def set_checked(key, value, tol=1e-3):
          """set() then read back; raises when the value did not take (clamped, disabled, wrong
          path type). tol None skips the comparison."""
          p = resolve(key)
          z = _z()
          z.set(p, value)
          got = z.get(p)
          if tol is not None and abs(float(got) - float(value)) > tol:
              raise ZBOpError(f"{p}: set {value}, reads {got} (clamped or disabled?)")
          return got
      
      
      def press(key, check_enabled=True):
          p = resolve(key)
          z = _z()
          if check_enabled and not z.is_enabled(p):
              raise ZBOpError(f"{p} is greyed out in the current state (pass check_enabled=False "
                              "to press anyway)")
          z.press(p)
          return p
      
      
      def stats(bbox_mode=1):
          """Cheap numbers for gates: counts, bbox, area, volume, watertight, levels, UVs."""
          z = _z()
          s = {"tool": z.get_active_tool_path(), "subtools": z.get_subtool_count(),
               "active": z.get_active_subtool_index(), "edit": z.get("Transform:Edit")}
          try:
              s["points"] = int(z.query_mesh3d(0)[0])
              s["faces"] = int(z.query_mesh3d(1)[0])
              s["bbox"] = [round(float(v), 6) for v in z.query_mesh3d(2, bbox_mode)]
              s["area"] = round(float(z.get_polymesh3d_area()), 6)
              s["volume"] = round(float(z.get_polymesh3d_volume()), 6)
              s["solid"] = bool(z.is_polymesh3d_solid())
          except Exception as e:  # "Almost all queries can fail" (Maxon query_mesh3d example)
              s["mesh_error"] = repr(e)
          try:
              s["uv_bbox"] = [round(float(v), 6) for v in z.query_mesh3d(3)]
          except Exception:
              s["uv_bbox"] = None
          p = resolve("sdiv", required=False)
          if p:
              s["sdiv"] = z.get(p)
              s["sdiv_max"] = z.get_max(p)
          return s
      
      
      # --------------------------------------------------------------------------------------------
      # Gates: volume and watertightness after each destructive op
      # --------------------------------------------------------------------------------------------
      
      # Volume ratio after/before per op, and which checks apply. All bands are [added] start values
      # to calibrate on live runs (the sculpting digest starts volume drift at 2 percent, also
      # [added] there). The SDK gives both numbers in one call each: get_polymesh3d_volume() catches
      # a collapsed or exploded remesh, is_polymesh3d_solid() is the cheap watertight check
      # (python_sdk digest delta 22). A gate never raises: callers read gate["problems"] (the step
      # is not done) and gate["warnings"] (look at it), or call require_gate(result).
      GATE_BANDS = {
          "dynamesh": {"volume": (0.5, 2.0), "volume_problem": True, "solid_after": True},
          "zremesher": {"volume": (0.8, 1.25), "keep_solid": True},
          "divide": {"volume": (0.9, 1.1), "keep_solid": True},
          "del_levels": {"volume": (0.9, 1.1), "keep_solid": True},
          "decimate": {"volume": (0.9, 1.1), "keep_solid": True},
          "polish": {"volume": (0.98, 1.02), "keep_solid": True},
          "deform": {"keep_solid": True},
          "project_all": {"volume": (0.5, 2.0), "keep_solid": True, "keep_points": True},
      }
      BBOX_MARGIN = 0.02          # spike test of project_all, fraction of the largest side [added]
      
      
      def gate(op, before, after, bands=None):
          """Compare two stats() dicts for `op`. Volume is judged only when the mesh was closed and
          had a positive volume before (an open mesh has no meaningful volume); a result volume at
          or below 0 on a closed result is always a problem (collapsed or inverted)."""
          b = dict(GATE_BANDS.get(op, {}), **(bands or {}))
          v0, v1 = before.get("volume"), after.get("volume")
          s0, s1 = before.get("solid"), after.get("solid")
          probs, warns = [], []
          ratio = round(v1 / v0, 5) if v0 and v1 is not None else None
          if s1 is True and v1 is not None and v1 <= 0:
              probs.append(f"{op}: volume {v1} after on a closed mesh (collapsed or inverted)")
          elif s0 is not False and v0 and v0 > 0 and v1 is not None and "volume" in b:
              lo, hi = b["volume"]
              if not lo <= ratio <= hi:
                  msg = (f"{op}: volume ratio {ratio} outside {lo} to {hi} (collapsed, exploded or "
                         "melted parts: look at a render before going on)")
                  (probs if b.get("volume_problem") else warns).append(msg)
          if b.get("solid_after") and s1 is False:
              warns.append(f"{op}: result is not watertight (is_polymesh3d_solid False)")
          if b.get("keep_solid") and s0 is True and s1 is False:
              warns.append(f"{op}: the mesh was watertight before and is not any more")
          if b.get("keep_points") and before.get("points") is not None \
                  and after.get("points") != before.get("points"):
              probs.append(f"{op}: point count changed {before.get('points')} -> {after.get('points')}")
          return {"ok": not probs, "volume_ratio": ratio, "solid_before": s0, "solid_after": s1,
                  "problems": probs, "warnings": warns}
      
      
      def require_gate(result):
          """Raise ZBOpError when an op's gate found a problem; returns the result otherwise."""
          g = (result or {}).get("gate") or {}
          if g and not g.get("ok", True):
              raise ZBOpError("; ".join(g.get("problems", [])) or "gate failed")
          return result
      
      
      # --------------------------------------------------------------------------------------------
      # Long sequences: show_actions(0) and freeze (SDK GUI reference, python_sdk digest delta 27)
      # --------------------------------------------------------------------------------------------
      
      _QUIET = {"depth": 0}
      
      
      @contextlib.contextmanager
      def quiet():
          """show_actions(0) around a run of press/toggle calls (pure set() calls do not need it:
          SDK show_actions), back to 1 on exit, also after an error; nesting is counted. The state
          lives in this module, which the bridge prelude re-imports and pops on every call, so it
          cannot leak between calls."""
          fn = getattr(_z(), "show_actions", None)
          _QUIET["depth"] += 1
          if fn and _QUIET["depth"] == 1:
              fn(0)
          try:
              yield
          finally:
              _QUIET["depth"] -= 1
              if fn and _QUIET["depth"] == 0:
                  fn(1)
      
      
      def frozen(fn, *args, fade_time=0.0, **kwargs):
          """fn(*args, **kwargs) inside zbc.freeze: no UI redraw, progress bars still work (SDK
          freeze). freeze() returns None and does not document what happens when the payload
          raises, so the payload catches and the error is raised again after freeze returns
          [added]. Then update(redraw_ui=True), so a following canvas export sees the result.
          Never export the canvas or press a ZScript plugin (UV Master, Decimation Master, Multi
          Map Exporter) inside a frozen block [added]."""
          z = _z()
          fz = getattr(z, "freeze", None)
          if fz is None:
              return fn(*args, **kwargs)
          box = {}
      
          def _payload():
              try:
                  box["value"] = fn(*args, **kwargs)
              except BaseException as e:  # re-raised below, outside ZBrush's freeze
                  box["error"] = e
      
          fz(_payload, fade_time)
          z.update(redraw_ui=True)
          if "error" in box:
              raise box["error"]
          return box.get("value")
      
      
      _NOT_IN_SEQUENCE = {"sequence", "frozen", "quiet", "decimate", "export_canvas", "morph_repair",
                          "sculpt_stroke"}
      
      
      def sequence(steps, freeze=True, show=False, stop_on_error=True):
          """Several zb_ops calls in ONE bridge call, e.g. [["divide", [2]], ["polish", [10]],
          ["export_obj", ["/abs/a.obj"], {"overwrite": True}]]. freeze=True runs them inside
          zbc.freeze (Maxon's own freeze example wraps Divide, Unwrap, Lower Res and a map);
          show=False hides press feedback (show_actions 0). Refused inside: plugin presses
          (decimate, uv_unwrap with "uvmaster"), canvas exports and strokes, which need a live UI
          [added]. Returns [{"step", "ok", "value" or "error"}]."""
          plan = []
          for st in steps:
              name = st[0]
              args = list(st[1]) if len(st) > 1 and st[1] is not None else []
              kw = dict(st[2]) if len(st) > 2 and st[2] is not None else {}
              fn = globals().get(name)
              words = [str(a).lower() for a in list(args) + list(kw.values())]
              plugin = any(w == "uvmaster" or "zplugin" in w or w.startswith(("uvm_", "dm_"))
                           for w in words)
              if name.startswith("_") or not callable(fn) or name in _NOT_IN_SEQUENCE or plugin:
                  raise ValueError(f"{name!r} cannot run in a sequence (unknown, private, plugin, "
                                   "stroke or canvas export)")
              plan.append((name, fn, args, kw))
      
          def _run():
              out = []
              for name, fn, args, kw in plan:
                  try:
                      out.append({"step": name, "ok": True, "value": fn(*args, **kw)})
                  except Exception as e:
                      out.append({"step": name, "ok": False, "error": f"{type(e).__name__}: {e}"})
                      if stop_on_error:
                          break
              return out
      
          def _body():
              if show:
                  return _run()
              with quiet():
                  return _run()
      
          return frozen(_body) if freeze else _body()
      
      
      def ensure_edit():
          """Edit mode must be on before any canvas stroke or click: with Edit off a drag stamps
          another pixol copy of the tool (Pablo, Logic Part 1; ZTools docs)."""
          z = _z()
          if z.get("Transform:Edit") >= 0.5:
              return True
          if z.is_enabled("Transform:Edit"):
              z.set("Transform:Edit", 1)
              if z.get("Transform:Edit") >= 0.5:
                  return True
          raise ZBOpError("no editable tool on the canvas: draw one (new_sphere) before strokes")
      
      
      def new_sphere(resolution=128, drag_frac=0.35):
          """Sphere3D -> Make PolyMesh3D -> draw (only when Edit is off) -> Edit -> DynaMesh.
          Same sequence as v01 [bridge]. Pressing a Tool while Edit is on swaps the edited tool
          [verify]."""
          z = _z()
          press("sphere3d", check_enabled=False)
          press("make_polymesh", check_enabled=False)
          if z.get("Transform:Edit") < 0.5:
              lc = resolve("layer_clear", required=False)
              if lc:
                  z.press(lc)
              w, h = z.get("Document:Width"), z.get("Document:Height")
              z.canvas_click(w * 0.5, h * 0.5, w * 0.5, h * (0.5 + drag_frac))
              z.set("Transform:Edit", 1)
          ensure_edit()
          out = {"created": z.get_active_tool_path()}
          if resolution:
              out["dynamesh"] = dynamesh(resolution)
          out["stats"] = stats()
          return out
      
      
      def dynamesh(resolution, blur=None, project=None, groups=None, allow_levels=False):
          """(Re)DynaMesh the active SubTool at `resolution`. Refuses a SubTool with subdivision
          levels (ZBrush would ask to delete them in a modal note). When DynaMesh is already on,
          it is switched off then on so the new resolution applies (the human gesture is Ctrl+drag
          on empty canvas, which the API cannot do) [verify toggle semantics, live_06]."""
          z = _z()
          before = stats()
          if before.get("sdiv_max", 1) > 1 and not allow_levels:
              raise ZBOpError(f"SubTool has {before['sdiv_max']:.0f} subdivision levels: delete them "
                              "first (del_levels) so DynaMesh raises no dialog")
          set_checked("dyn_res", resolution, tol=0.5)
          for key, val in (("dyn_blur", blur), ("dyn_project", project), ("dyn_groups", groups)):
              if val is not None:
                  set_checked(key, float(val), tol=0.5)
          btn = resolve("dyn_button")
          was_on = z.get(btn) >= 0.5
          if was_on:
              z.set(btn, 0)
              z.update(redraw_ui=True)
          z.press(btn)
          z.update(redraw_ui=True)
          after = stats()
          return {"resolution": resolution, "was_on": was_on, "state_after": z.get(btn),
                  "faces_before": before.get("faces"), "faces_after": after.get("faces"),
                  "volume_before": before.get("volume"), "volume_after": after.get("volume"),
                  "remeshed": (not was_on) or before.get("faces") != after.get("faces"),
                  "gate": gate("dynamesh", before, after)}
      
      
      def del_levels():
          """Delete lower and higher subdivision levels (keeps the current level's shape)."""
          z = _z()
          before = stats()
          out = []
          with quiet():
              for key in ("del_lower", "del_higher"):
                  p = resolve(key, required=False)
                  if p and z.is_enabled(p):
                      z.press(p)
                      out.append(p)
          after = stats()
          return {"pressed": out, "stats": after, "gate": gate("del_levels", before, after)}
      
      
      def divide(n=1, smt=None, suv=None):
          """Divide n times; each level should multiply faces by 4 [doc; Pablo Logic Part 7]. The
          presses run under quiet() (show_actions 0)."""
          z = _z()
          if smt is not None:
              set_checked("smt", 1 if smt else 0, tol=0.5)
          if suv is not None:
              set_checked("suv", 1 if suv else 0, tol=0.5)
          before = stats()
          with quiet():
              for _ in range(int(n)):
                  press("divide")
                  z.update(redraw_ui=True)
          after = stats()
          ratio = after["faces"] / before["faces"] if before.get("faces") else None
          return {"n": n, "faces_before": before.get("faces"), "faces_after": after.get("faces"),
                  "ratio": ratio, "expected_ratio": 4 ** int(n), "sdiv_max": after.get("sdiv_max"),
                  "gate": gate("divide", before, after)}
      
      
      def zremesher(target_k=5.0, adaptive=None, adaptive_size=None, keep_groups=None, mode=None,
                    keep_creases=None, detect_edges=None):
          """ZRemesher to `target_k` thousand polygons (the slider is in thousands: Pablo, 5 gave
          4,963). mode: None, "half", "same" or "double". adaptive False hits the count more
          closely (doc: Adapt on by default for quality). Needs a SubTool without levels
          [added][verify]. Slow: call through the bridge with a long timeout."""
          z = _z()
          before = stats()
          if before.get("sdiv_max", 1) > 1:
              raise ZBOpError("ZRemesher on a SubTool with subdivision levels: duplicate and "
                              "del_levels first, keep the sculpt as the projection source")
          set_checked("zr_target", target_k, tol=0.01)
          for key, val in (("zr_adapt", adaptive), ("zr_keep_groups", keep_groups),
                           ("zr_keep_creases", keep_creases), ("zr_detect_edges", detect_edges)):
              if val is not None:
                  set_checked(key, 1 if val else 0, tol=0.5)
          if adaptive_size is not None:
              set_checked("zr_adaptive_size", adaptive_size, tol=0.5)
          if mode is not None:
              if mode not in ("half", "same", "double"):
                  raise ValueError("mode must be half, same or double")
              for m in ("half", "same", "double"):
                  set_checked("zr_" + m, 1 if m == mode else 0, tol=0.5)
          t = time.time()
          press("zr_button")
          z.update(redraw_ui=True)
          after = stats()
          tgt = target_k * 1000.0
          return {"target": tgt, "faces_before": before.get("faces"), "faces_after": after.get("faces"),
                  "ratio_to_target": after["faces"] / tgt if after.get("faces") else None,
                  "seconds": round(time.time() - t, 2), "volume_before": before.get("volume"),
                  "volume_after": after.get("volume"), "gate": gate("zremesher", before, after)}
      
      
      def subtools():
          """Index, status bits and folder-aware visibility per SubTool (eye 0x1 and, inside a
          folder, the folder eye 0x2: usd-portal ActiveSubToolVisible) [verify folder index]."""
          z = _z()
          out = []
          n = z.get_subtool_count()
          for i in range(n):
              st = int(z.get_subtool_status(i))
              f = int(z.get_subtool_folder_index(i))
              fvis = f < 0 or bool(int(z.get_subtool_status(f)) & 0x2)
              out.append({"index": i, "status": st, "folder": f,
                          "folder_name": z.get_subtool_folder_name(i) if f >= 0 else "",
                          "visible": bool(st & 0x1) and fvis})
          return out
      
      
      PROJECT_DIST = 0.1   # the working value; the small default causes "90 percent" of Project All
                           # artifacts (Drust nxMYYsyJt3o 00:02:19, R2MzFqWMaWY 00:02:48; topology digest
                           # consensus 6). Very different shapes: ProjectionShell + Inner + Dist 1
                           # (Gaboury pQbPtH0p5Bg 00:03:02), see scenario-zbrush-retopology-export.
      
      REPAIR = ("Repair without smoothing (FlippedNormals Zp07GW3rND0 00:04:27, 00:06:36: smoothing "
                "does not fix busted areas or rogue vertices): zb_ops.morph_repair(points) paints the "
                "area back to the stored morph target with the Morph brush; zb_ops.set_layer_intensity"
                "(v) dials the whole projection once its layer no longer records [verify]; or mask the "
                "busted region, invert and project again (local reprojection, 00:09:27). A target "
                "inside the source: masked Inflate and reproject, or ProjectionShell + Inner + Dist 1. "
                "Worst case: reopen the checkpoint ZTL.")
      
      
      def top_level():
          """SDiv slider to its maximum, where new layers must be made (Maxon 3D Layers doc) and
          where FlippedNormals store the morph target before projecting (Zp07GW3rND0 00:02:13).
          Returns {"level_before", "top"} so staged work can go back down."""
          z = _z()
          p = resolve("sdiv", required=False)
          if not p:
              return {"level_before": None, "top": None}
          cur, top = z.get(p), z.get_max(p)
          if top > cur + 0.5:
              set_checked("sdiv", top, tol=0.5)
          return {"level_before": cur, "top": top}
      
      
      def store_morph_target(replace=True):
          """Tool > Morph Target > StoreMT on the active SubTool at its current level. One morph
          target per SubTool, tied to the level where it was stored, destroyed by any point-count
          change (Maxon Morph Targets doc), so store it after the last Divide. When StoreMT is
          greyed out because a target exists, DelMT first (replace=True) [verify live_06: StoreMT
          greyed while a target exists, DelMT without a confirmation note]."""
          z = _z()
          p = resolve("store_mt", required=False)
          if not p:
              return {"stored": False, "replaced": False, "why": "StoreMT not found"}
          replaced = False
          if not z.is_enabled(p) and replace:
              d = resolve("del_mt", required=False)
              if d and z.is_enabled(d):
                  z.press(d)
                  z.update(redraw_ui=True)
                  replaced = True
          if not z.is_enabled(p):
              return {"stored": False, "replaced": replaced, "why": "StoreMT greyed out"}
          z.press(p)
          z.update(redraw_ui=True)
          return {"stored": True, "replaced": replaced, "path": p}
      
      
      def new_layer():
          """Tool > Layers > New at the current level, which must be the top level (3D Layers doc).
          The new layer records (REC on) until REC is clicked off at the top level; while it
          records, its Intensity reads as 1 whatever the slider says. Layers cannot be named from
          Python (Rename opens a prompt; no layer API: Maxon forum 2026)."""
          z = _z()
          p = resolve("layer_new", required=False)
          if not p or not z.is_enabled(p):
              return {"created": False, "why": "Tool:Layers:New not found or greyed out"}
          z.press(p)
          z.update(redraw_ui=True)
          return {"created": True, "path": p}
      
      
      def set_layer_intensity(value):
          """Tool > Layers > Intensity of the selected layer: 1 as recorded, 0 hides it, above 1
          exaggerates, negative inverts (3D Layers doc). No effect while the layer records, and the
          REC toggle has no confirmed path [verify live_06]; the human path is the layer's eye or
          REC icon."""
          return {"intensity": set_checked("layer_intensity", value, tol=0.01)}
      
      
      def _bbox(z, mode):
          try:
              return [round(float(v), 6) for v in z.query_mesh3d(2, mode)]
          except Exception:
              return None
      
      
      def _outside(bbox, box):
          """How far bbox reaches outside box, as a fraction of box's largest side (0 inside)."""
          if not bbox or not box:
              return None
          side = max(box[3] - box[0], box[4] - box[1], box[5] - box[2]) or 1.0
          over = max([box[k] - bbox[k] for k in range(3)] + [bbox[k + 3] - box[k + 3] for k in range(3)])
          return round(max(over, 0.0) / side, 5)
      
      
      def project_all(dist=PROJECT_DIST, mean=None, pa_blur=None, checkpoint=None, store_mt=True,
                      layer=True, level=None, exclusive=False):
          """Project All onto the active SubTool from the other visible SubTools, safe by default.
      
          1. checkpoint: a versioned ZTL save FIRST, because Project All "might crash the program"
             (cgside MyhwQvkcnwI 00:02:19) and Python has no undo. An absolute .ztl path (saved as
             _vNNN, never overwritten), or False only when the caller has just saved this state.
             None raises before anything changes.
          2. At the top level (top_level()): Store Morph Target, then a New Layer, the two safety
             nets FlippedNormals set up before every projection (Zp07GW3rND0 00:02:13, 00:02:47).
             The layer records the projection; the morph target is what the Morph brush paints
             back to. store_mt / layer False skip them (the result says so).
          3. Back to `level` (default: the level the SubTool was on) for staged projection: from
             the lowest level upward when the shapes differ a lot (FlippedNormals 00:03:17).
          4. Dist set explicitly, 0.1 by default (PROJECT_DIST; None also means 0.1); Mean and
             PA Blur only when given (PA Blur lowered for a clean source: FlippedNormals 00:02:47).
          5. Project All, then the gate: point count unchanged, target bbox inside the bbox of all
             visible SubTools before projecting (a vertex outside is a spike or a rogue vertex),
             volume ratio, watertightness kept. When the gate is not clean, "repair" says how.
          Only source and target should be visible (Maxon; Pavlovich n5 01:07:26): exclusive=True
          refuses more; by default more than two is a warning (colour from several pieces)."""
          z = _z()
          vis = [s for s in subtools() if s["visible"]]
          if len(vis) < 2:
              raise ZBOpError("Project All needs a visible source SubTool besides the target")
          if exclusive and len(vis) != 2:
              raise ZBOpError(f"{len(vis)} visible SubTools: only source and target may be visible")
          if checkpoint is None:
              raise ZBOpError("Project All can crash ZBrush (cgside) and there is no undo from Python: "
                              "pass checkpoint='/abs/name.ztl' (versioned save first) or "
                              "checkpoint=False when this state was just saved")
          out = {"visible_subtools": len(vis),
                 "warning": "more than 2 visible" if len(vis) > 2 else None}
          t0 = time.time()
          if checkpoint:
              out["checkpoint"] = save_ztl(checkpoint)
          else:
              out["checkpoint"] = {"path": None, "by": "caller"}
          lv = top_level()
          out["level_before"], out["top_level"] = lv["level_before"], lv["top"]
          out["morph_target"] = store_morph_target() if store_mt else {"stored": False, "why": "store_mt=False"}
          out["layer"] = new_layer() if layer else {"created": False, "why": "layer=False"}
          want = level if level is not None else lv["level_before"]
          sd = resolve("sdiv", required=False)
          if sd and want is not None and abs(z.get(sd) - want) > 0.5:
              set_checked("sdiv", want, tol=0.5)
          out["level"] = z.get(sd) if sd else None
          out["dist"] = set_checked("project_dist", PROJECT_DIST if dist is None else dist, tol=0.01)
          if mean is not None:
              out["mean"] = set_checked("project_mean", mean, tol=0.01)
          if pa_blur is not None:
              out["pa_blur"] = set_checked("project_pa_blur", pa_blur, tol=0.01)
          before = stats(bbox_mode=0)
          union = _bbox(z, 2)                      # visible SubTools, all of them [doc query_mesh3d]
          press("project_all")
          z.update(redraw_ui=True)
          after = stats(bbox_mode=0)
          g = gate("project_all", before, after)
          reach = _outside(after.get("bbox"), union)
          if reach is not None and reach > BBOX_MARGIN:
              g["problems"].append(f"project_all: target reaches {reach:.1%} outside the visible "
                                   "bbox: spikes or rogue vertices")
              g["ok"] = False
          missing = []
          if store_mt and not out["morph_target"].get("stored"):
              missing.append(f"morph target ({out['morph_target'].get('why')})")
          if layer and not out["layer"].get("created"):
              missing.append(f"layer ({out['layer'].get('why')})")
          if missing:
              g["warnings"].append("project_all: safety net missing: " + ", ".join(missing) +
                                   "; the checkpoint ZTL is the only way back")
          g["outside_fraction"] = reach
          out.update({"volume_before": before.get("volume"), "volume_after": after.get("volume"),
                      "area_before": before.get("area"), "area_after": after.get("area"),
                      "points_before": before.get("points"), "points_after": after.get("points"),
                      "bbox_visible_before": union, "bbox_after": after.get("bbox"), "gate": g,
                      "seconds": round(time.time() - t0, 2)})
          if g["problems"] or g["warnings"]:
              out["repair"] = REPAIR
          return out
      
      
      def morph_repair(points, size=None, z_intensity=None, repeats=1, **stroke_kw):
          """Paint a busted projection area back to the stored morph target with the Morph brush
          (FlippedNormals Zp07GW3rND0 00:04:27, 00:06:36), at the top level where project_all
          stored it. points: canvas pixels over the area (zb_stroke helpers; aim with the camera
          of the review view). Symmetry is the caller's choice (set_symmetry). Needs a morph
          target: Tool:Morph Target:Switch enabled [verify live_06]. Stroke-free alternatives:
          set_layer_intensity(), or mask the area, invert and project again."""
          z = _z()
          sw = resolve("switch_mt", required=False)
          if not sw or not z.is_enabled(sw):
              raise ZBOpError("no morph target on this SubTool: nothing to paint back to "
                              "(project_all stores one unless store_mt=False)")
          lv = top_level()
          r = sculpt_stroke(points, brush="Morph", size=size, z_intensity=z_intensity,
                            repeats=repeats, **stroke_kw)
          r["level"] = lv["top"]
          return r
      
      
      def deform(name, value, axes=None):
          """Tool:Deformation:<name> set to value (applies once). axes: bit mask x=1, y=2, z=4 in
          GUI order (SDK Modifiers partial); restored afterwards."""
          z = _z()
          p = resolve([f"Tool:Deformation:{name}"])
          old = None
          if axes is not None:
              old = z.get_mod(p)
              z.set_mod(p, axes)
          before = stats()
          z.set(p, value)
          z.update(redraw_ui=True)
          after = stats()
          if old is not None:
              z.set_mod(p, old)
          op = "polish" if name.replace(" ", "").lower() == "polish" else "deform"
          return {"path": p, "value": value, "volume_before": before.get("volume"),
                  "volume_after": after.get("volume"), "gate": gate(op, before, after)}
      
      
      def polish(value=10, axes=None):
          return deform("Polish", value, axes)
      
      
      _OPS = {
          "mask": {"clear": "mask_clear", "invert": "mask_inverse", "blur": "mask_blur",
                   "sharpen": "mask_sharpen", "grow": "mask_grow", "shrink": "mask_shrink",
                   "all": "mask_all", "center": "mask_center"},
          "polygroups": {"auto": "pg_auto", "visible": "pg_visible", "masked": "pg_masked",
                         "normals": "pg_normals"},
          "visibility": {"hide": "vis_hide", "show": "vis_show", "grow": "vis_grow",
                         "shrink": "vis_shrink"},
      }
      
      
      def _op(group, op):
          key = _OPS[group].get(op)
          if key is None:
              raise ValueError(f"{group} op must be one of {sorted(_OPS[group])}")
          p = press(key)
          _z().update(redraw_ui=True)
          return p
      
      
      def mask(op):
          """Masking button (mask data cannot be read back: no API, Maxon forum 2026)."""
          return _op("mask", op)
      
      
      def polygroups(op):
          return _op("polygroups", op)
      
      
      def visibility(op):
          return _op("visibility", op)
      
      
      def _uv_tiles(z, cap=64):
          try:
              t = int(z.query_mesh3d(4)[0])
          except Exception:
              return None
          tiles = []
          while len(tiles) < cap and t not in tiles and t >= 0:
              tiles.append(t)
              try:
                  t = int(z.query_mesh3d(5, t)[0])
              except Exception:
                  break
          return tiles
      
      
      def uv_unwrap(method="native", auto_seams=True, symmetry=None, polygroups=None,
                    work_on_clone=None):
          """UVs on the active SubTool. native: Tool > UV Map > Create (Unwrap), the only path in
          the SDK docs. uvmaster: Zplugin > UV Master (a ZScript plugin; whether control returns
          to Python is [verify], live_08). Run on the lowest level."""
          z = _z()
          before = stats()
          t = time.time()
          if method == "native":
              if auto_seams is not None:
                  set_checked("uv_auto_seams", 1 if auto_seams else 0, tol=0.5)
              if symmetry is not None:
                  set_checked("uv_symmetry", 1 if symmetry else 0, tol=0.5)
              press("uv_unwrap")
          elif method == "uvmaster":
              for key, val in (("uvm_symmetry", symmetry), ("uvm_polygroups", polygroups),
                               ("uvm_clone", work_on_clone)):
                  if val is not None:
                      set_checked(key, 1 if val else 0, tol=0.5)
              press("uvm_unwrap", check_enabled=False)
          else:
              raise ValueError("method must be native or uvmaster")
          z.update(redraw_ui=True)
          after = stats()
          return {"method": method, "seconds": round(time.time() - t, 2),
                  "uv_bbox_before": before.get("uv_bbox"), "uv_bbox": after.get("uv_bbox"),
                  "tiles": _uv_tiles(z), "faces": after.get("faces")}
      
      
      def decimate(percent, preprocess=True, keep_uvs=None):
          """Decimation Master on the active SubTool: keep `percent` of the polygons (% of
          decimation). Pre-process is the slow step. Decimation Master is a ZScript plugin; a
          ZScript that pressed it lost control (ijacobs 2013, cgside); Python is [verify]."""
          z = _z()
          before = stats()
          set_checked("dm_percent", percent, tol=0.51)
          if keep_uvs is not None:
              set_checked("dm_keep_uvs", 1 if keep_uvs else 0, tol=0.5)
          t = time.time()
          if preprocess:
              press("dm_preprocess", check_enabled=False)
          t_pre = time.time() - t
          press("dm_decimate", check_enabled=False)
          z.update(redraw_ui=True)
          after = stats()
          return {"percent": percent, "faces_before": before.get("faces"),
                  "faces_after": after.get("faces"),
                  "ratio": after["faces"] / before["faces"] if before.get("faces") else None,
                  "preprocess_s": round(t_pre, 2), "total_s": round(time.time() - t, 2),
                  "gate": gate("decimate", before, after)}
      
      
      def _fresh_file(path, t0):
          return os.path.exists(path) and os.path.getsize(path) > 0 and os.path.getmtime(path) >= t0 - 2
      
      
      def _prepare_target(path, overwrite):
          path = os.path.abspath(os.path.expanduser(path))
          os.makedirs(os.path.dirname(path), exist_ok=True)
          if os.path.exists(path):
              if not overwrite:
                  raise ZBOpError(f"{path} exists: pass overwrite=True (the old file is kept as .bak)")
              os.replace(path, path + time.strftime(".%Y%m%d-%H%M%S.bak"))
          return path
      
      
      def _file_op(key, path, overwrite):
          z = _z()
          path = _prepare_target(path, overwrite)
          t0 = time.time()
          z.set_next_filename(path)
          press(key, check_enabled=False)
          consumed = None
          try:
              consumed = not z.has_next_filename()
          except Exception:
              pass
          if not _fresh_file(path, t0):
              raise ZBOpError(f"{resolve(key)} wrote nothing at {path}; if the preset is still "
                              f"pending ({not consumed}) a dialog probably opened: screenshot it")
          return {"path": path, "bytes": os.path.getsize(path), "seconds": round(time.time() - t0, 3),
                  "preset_consumed": consumed}
      
      
      def export_obj(path, overwrite=False):
          """Active SubTool to OBJ without a dialog [bridge v03: 0.08 s]."""
          return _file_op("export", path, overwrite)
      
      
      def export_canvas(path, overwrite=False):
          """Document image to PNG without a dialog [bridge v03: 0.04 s; works with the screen
          locked]. This is the review image."""
          return _file_op("doc_export", path, overwrite)
      
      
      def next_version(path, digits=3):
          """head.ztl -> head_v001.ztl; head_v004.ztl -> first free _vNNN at or after 004."""
          root, ext = os.path.splitext(path)
          m = re.match(r"^(.*)_v(\d+)$", root)
          base, n = (m.group(1), int(m.group(2))) if m else (root, 1)
          while True:
              cand = f"{base}_v{n:0{digits}d}{ext}"
              if not os.path.exists(cand):
                  return cand
              n += 1
      
      
      def save_ztl(path, version=True):
          """Save the active tool as .ZTL with set_next_filename + Tool:Save As [verify dialog-free].
          version=True never overwrites: it picks the next free _vNNN name. Saving renames the
          tool, and the top SubTool takes the file name (Pavlovich 030)."""
          path = os.path.abspath(os.path.expanduser(path))
          if not path.lower().endswith(".ztl"):
              path += ".ztl"
          if version:
              path = next_version(path)
          res = _file_op("tool_save_as", path, overwrite=False)
          res["tool_after"] = _z().get_active_tool_path()
          return res
      
      
      # --------------------------------------------------------------------------------------------
      # Brushes
      # --------------------------------------------------------------------------------------------
      
      def _asset_dirs():
          return glob.glob(os.path.expanduser("~/Library/Preferences/Maxon/ZBrush_*"))
      
      
      def brush_roots():
          roots = [INSTALL + "/ZData/BrushPresets", INSTALL + "/Lightbox/Brushes"]
          for d in _asset_dirs():
              roots += [d + "/LightBox/Brushes", d + "/ZStartup/BrushPresets"]
          return [r for r in roots if os.path.isdir(r)]
      
      
      def list_brush_files(roots=None):
          """[{name, path}] for every .ZBP under the install and the user Asset Directory.
          ZData/BrushPresets is what loads at startup; Lightbox brushes load on demand."""
          out = []
          for r in roots or brush_roots():
              for dirpath, _, files in os.walk(r):
                  for f in sorted(files):
                      if f.lower().endswith(".zbp"):
                          out.append({"name": f[:-4], "path": os.path.join(dirpath, f)})
          return out
      
      
      def normalize_name(s):
          return re.sub(r"[^a-z0-9]", "", s.lower())
      
      
      # Palette labels that differ from the file stem or from what people type. Dam_Standard is
      # the UI label (UserInterfaceLayouts cfg) of DamStandard.ZBP; Brush:Dam_Standard was not
      # found by exists() in v03, so every spelling is tried.
      BRUSH_ALIASES = {
          "damstandard": ["DamStandard", "Dam_Standard", "Dam Standard"],
          "claybuildup": ["ClayBuildup", "Clay Buildup"],
          "movetopological": ["Move Topological", "MoveTopological"],
          "moveelastic": ["Move Elastic"],
          "inflate": ["Inflat"],
          "inflat": ["Inflat"],
          "smooth": ["Smooth"],
          "retopo": ["Retopo"],
      }
      
      
      def find_brush(name, limit=5, files=None):
          """Closest brush files for a name people use ("dam standard", "Clay Buildup")."""
          files = files if files is not None else list_brush_files()
          key = normalize_name(name)
          exact = [f for f in files if normalize_name(f["name"]) == key]
          if exact:
              return exact[:limit]
          alias = [normalize_name(a) for a in BRUSH_ALIASES.get(key, [])]
          by_alias = [f for f in files if normalize_name(f["name"]) in alias]
          if by_alias:
              return by_alias[:limit]
          names = {normalize_name(f["name"]): f for f in files}
          close = difflib.get_close_matches(key, list(names), n=limit, cutoff=0.6)
          return [names[c] for c in close]
      
      
      def current_brush_title():
          p = resolve("current_brush", required=False)
          return _z().get_title(p) if p else None
      
      
      def select_brush(name, load=True):
          """Select a brush by any common spelling; else load its .ZBP with Brush:Load Brush
          [verify dialog-free]. Note: picking a Smooth brush only changes the Shift-smooth brush;
          it cannot become the active sculpt brush (BR doc), and press_key cannot hold Shift."""
          z = _z()
          labels = list(BRUSH_ALIASES.get(normalize_name(name), []))
          labels += [f["name"] for f in find_brush(name)]
          labels += [name]
          tried = []
          for lab in dict.fromkeys(labels):
              p = "Brush:" + lab
              tried.append(p)
              if z.exists(p):
                  z.press(p)
                  return {"path": p, "title": current_brush_title(), "loaded": False, "tried": tried}
          if load:
              hits = find_brush(name, 1)
              if hits:
                  z.set_next_filename(hits[0]["path"])
                  press("load_brush", check_enabled=False)
                  return {"path": None, "file": hits[0]["path"], "title": current_brush_title(),
                          "loaded": True, "tried": tried}
          raise ZBOpError(f"brush {name!r} not found; tried {tried}")
      
      
      def set_draw(size=None, z_intensity=None, focal=None, zadd=None, zsub=None, rgb=None,
                   mrgb=None, lazymouse=None):
          """Draw palette values with read-back. Draw Size is in screen pixels; Dynamic brush
          size (Preferences:Draw:Dynamic Brush Scale) can change what that means [verify]."""
          out = {}
          for key, val in (("draw_size", size), ("z_intensity", z_intensity), ("focal", focal)):
              if val is not None:
                  out[key] = set_checked(key, val, tol=0.51)
          for key, val in (("zadd", zadd), ("zsub", zsub), ("rgb", rgb), ("mrgb", mrgb),
                           ("lazymouse", lazymouse)):
              if val is not None:
                  out[key] = set_checked(key, 1 if val else 0, tol=0.5)
          return out
      
      
      def set_material(name):
          """Press Material:<name>; returns the previous material title so it can be restored."""
          z = _z()
          cur = resolve("current_material", required=False)
          prev = z.get_title(cur).strip().rstrip(".").strip() if cur else None
          p = resolve([f"Material:{name}"])
          z.press(p)
          return {"previous": prev, "set": p}
      
      
      def set_symmetry(on=True, axis="x"):
          """Transform:Activate Symmetry plus the axis switch; read back. Turn it OFF before a
          stroke aimed at one side, or the mirrored copy lands on the other side."""
          out = {"symmetry": set_checked("symmetry", 1 if on else 0, tol=0.5)}
          if on and axis:
              out[axis] = set_checked("sym_" + axis, 1, tol=0.5)
          return out
      
      
      def sculpt_stroke(points, brush=None, size=None, z_intensity=None, zsub=False, repeats=1,
                        **stroke_kw):
          """Brush + Draw settings + one synthesized stroke (zb_stroke.encode, proven V02 format),
          with volume before and after as the evidence that it sculpted."""
          z = _z()
          ensure_edit()
          sel = select_brush(brush) if brush else None
          draw = set_draw(size=size, z_intensity=z_intensity, zadd=not zsub, zsub=zsub)
          v0 = z.get_polymesh3d_volume()
          ok = True
          st = z.Stroke(zb_stroke.encode(points, **stroke_kw))
          for _ in range(int(repeats)):
              ok = bool(z.canvas_stroke(st)) and ok
          z.update(redraw_ui=True)
          v1 = z.get_polymesh3d_volume()
          return {"ok": ok, "brush": sel, "draw": draw, "points": len(points),
                  "volume_before": round(float(v0), 6), "volume_after": round(float(v1), 6)}
      
    • zb_review.py 9.8 KB
      """
      zb_review: the agent's orbit. Canvas renders from fixed views with a neutral MatCap, tiled
      into one labelled contact sheet the agent opens with its image reader.
      
      Two halves:
        inside ZBrush (through zb_launch.call): capture_views(), snapshot()
        agent side (system python3 with PIL + numpy): review(), contact_sheet(), silhouette_bbox()
      
          import zb_review
          rep = zb_review.review("/abs/out/stage2")          # front, right, back, 3/4, top
          # open rep["sheet"]; rep["silhouettes"] flags clipped or empty views
      
      Proven: Document:Export writes the canvas PNG without a dialog, also with the screen locked
      (v03). Everything else here is not yet run in ZBrush: the view triples (zb_stroke.VIEWS,
      only "front" is the default view of a drawn tool), the math framing (Maxon's 0.75 rule),
      Transform:Fit, the MatCap path Material:MatCap Gray (file ZData/Materials/MatCap/
      MatCap Gray.ZMT exists), restoring the transform and material. live_03 checks all of it.
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import os
      
      import zb_ops
      import zb_stroke
      
      DEFAULT_VIEWS = ("front", "right", "back", "threequarter", "top")
      NEUTRAL_MATCAP = "MatCap Gray"
      
      
      # --------------------------------------------------------------------------------------------
      # Inside ZBrush
      # --------------------------------------------------------------------------------------------
      
      def _facing_axis(t, conv=None):
          """Which model axis (OBJ export space) faces the camera for transform t, under the
          current convention: a label to check the view names against [verify]."""
          cam = zb_stroke.Camera(t, (0, 0, 0), conv)
          best = max(((cam.facing(n), lab) for lab, n in (
              ("+X", (1, 0, 0)), ("-X", (-1, 0, 0)), ("+Y", (0, 1, 0)), ("-Y", (0, -1, 0)),
              ("+Z", (0, 0, 1)), ("-Z", (0, 0, -1)))))
          return best[1]
      
      
      def capture_views(out_dir, views=DEFAULT_VIEWS, matcap=NEUTRAL_MATCAP, frame="math", margin=0.75,
                        render=None, prefix="view", restore=True):
          """Set each view, export the canvas PNG, then restore the transform, MatCap and
          perspective. frame: "math" (same scale for every view: centre + Maxon's rule), "fit"
          (rotation then Transform:Fit), "keep" (rotation only). render: None or "bpr"."""
          z = zb_ops._z()
          zb_ops.ensure_edit()
          os.makedirs(out_dir, exist_ok=True)
          t0 = [float(v) for v in z.get_transform()]
          w, h = z.get("Document:Width"), z.get("Document:Height")
          bbox = [float(v) for v in z.query_mesh3d(2, 3)]  # full bbox of all SubTools
          mat = None
          if matcap:
              try:
                  mat = zb_ops.set_material(matcap)
              except zb_ops.ZBOpError as e:  # keep reviewing with the current material
                  mat = {"error": str(e)}
          persp = zb_ops.resolve("persp", required=False)
          persp_was = z.get(persp) if persp else 0
          if persp and persp_was:
              z.set(persp, 0)
          shots = []
          try:
              for i, v in enumerate(views):
                  rot = tuple(float(a) for a in (zb_stroke.VIEWS[v] if isinstance(v, str) else v))
                  label = v if isinstance(v, str) else "rot_%g_%g_%g" % rot
                  tset = None
                  if frame == "math":
                      tset = zb_stroke.frame_transform(bbox, w, h, rot, margin)
                      z.set_transform(*tset)
                  else:
                      z.set_transform(x_rotate=rot[0], y_rotate=rot[1], z_rotate=rot[2])
                      if frame == "fit":
                          fp = zb_ops.resolve("fit", required=False)
                          if fp:
                              z.press(fp)
                  z.update(redraw_ui=True)
                  if render == "bpr":
                      z.press(zb_ops.resolve("bpr"))
                  path = os.path.join(out_dir, f"{prefix}_{i:02d}_{label}.png")
                  info = zb_ops.export_canvas(path, overwrite=True)
                  tr = [float(a) for a in z.get_transform()]
                  shots.append({"view": label, "rotation": list(rot), "transform_set": tset,
                                "transform_read": tr, "facing_axis_h0": _facing_axis(tr), **info})
          finally:
              if restore:
                  z.set_transform(*t0)
                  if persp and persp_was:
                      z.set(persp, persp_was)
                  if mat and mat.get("previous"):
                      back = "Material:" + mat["previous"]
                      mat["restored"] = bool(z.exists(back))
                      if mat["restored"]:
                          z.press(back)
                  z.update(redraw_ui=True)
          return {"views": shots, "doc": [w, h], "bbox": bbox, "frame": frame,
                  "transform_before": t0, "transform_after": [float(a) for a in z.get_transform()],
                  "material": mat}
      
      
      def snapshot(path, view=None, matcap=None, render=None):
          """One PNG of the canvas. view None keeps the current view (no transform change)."""
          if view is None:
              z = zb_ops._z()
              prev = zb_ops.set_material(matcap) if matcap else None
              try:
                  if render == "bpr":
                      z.press(zb_ops.resolve("bpr"))
                  return zb_ops.export_canvas(path, overwrite=True)
              finally:
                  if prev and prev.get("previous") and z.exists("Material:" + prev["previous"]):
                      z.press("Material:" + prev["previous"])
          d, base = os.path.split(os.path.abspath(path))
          res = capture_views(d, [view], matcap or NEUTRAL_MATCAP, "math", render=render,
                              prefix=os.path.splitext(base)[0])
          return res
      
      
      # --------------------------------------------------------------------------------------------
      # Agent side
      # --------------------------------------------------------------------------------------------
      
      def silhouette_bbox(png_path, threshold=28, edge=4):
          """Model pixels against the canvas background. ZBrush's background is a vertical
          gradient (v03_canvas.png: black top, grey bottom), so each row's background is the
          median of its outer `edge` pixels. Returns {"bbox": [x0, y0, x1, y1], "fill",
          "touches_border", "size"} or None when nothing stands out."""
          import numpy as np
          from PIL import Image
          with Image.open(png_path) as src:
              im = np.asarray(src.convert("RGB")).astype(np.int16)
          hgt, wid, _ = im.shape
          ref = np.median(np.concatenate([im[:, :edge], im[:, -edge:]], axis=1), axis=1)
          diff = np.abs(im - ref[:, None, :]).max(axis=2)
          mask = diff > threshold
          mask[:, :edge] = False
          mask[:, -edge:] = False
          if not mask.any():
              return None
          ys, xs = np.nonzero(mask)
          x0, x1, y0, y1 = int(xs.min()), int(xs.max()), int(ys.min()), int(ys.max())
          return {"bbox": [x0, y0, x1, y1], "fill": round(float(mask.mean()), 4),
                  "touches_border": x0 <= edge or y0 <= 0 or x1 >= wid - 1 - edge or y1 >= hgt - 1,
                  "size": [wid, hgt]}
      
      
      def contact_sheet(paths, labels, out_path, cols=None, thumb_w=560, title=None,
                        bg=(24, 24, 24), fg=(235, 235, 235)):
          """Tile images left to right, top to bottom, with a label bar above each."""
          from PIL import Image, ImageDraw, ImageFont
          ims = []
          for p in paths:
              with Image.open(p) as im:
                  ims.append(im.convert("RGB"))
          if not ims:
              raise ValueError("no images")
          cols = cols or min(3, len(ims))
          rows = (len(ims) + cols - 1) // cols
          th = [int(im.height * thumb_w / im.width) for im in ims]
          cell_h = max(th) + 30
          top = 40 if title else 0
          sheet = Image.new("RGB", (cols * thumb_w + (cols + 1) * 8, top + rows * cell_h + (rows + 1) * 8), bg)
          draw = ImageDraw.Draw(sheet)
          try:
              font = ImageFont.load_default(size=18)
              tfont = ImageFont.load_default(size=22)
          except TypeError:  # Pillow < 10.1
              font = tfont = ImageFont.load_default()
          if title:
              draw.text((10, 8), title, fill=fg, font=tfont)
          for i, (im, lab) in enumerate(zip(ims, labels)):
              r, c = divmod(i, cols)
              x = 8 + c * (thumb_w + 8)
              y = top + 8 + r * (cell_h + 8)
              draw.text((x + 4, y + 4), str(lab), fill=fg, font=font)
              sheet.paste(im.resize((thumb_w, th[i])), (x, y + 30))
          os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
          sheet.save(out_path)
          return out_path
      
      
      def _label(shot, sil):
          s = f"{shot['view']} {tuple(int(a) for a in shot['rotation'])}"
          if sil is None:
              return s + " EMPTY"
          if sil["touches_border"]:
              s += " CLIPPED"
          return s
      
      
      def review(out_dir, views=DEFAULT_VIEWS, matcap=NEUTRAL_MATCAP, frame="auto", margin=0.75,
                 render=None, port=7788, sheet="review_sheet.png", title=None, timeout=240):
          """Capture the views through the bridge, check silhouettes, tile the contact sheet.
          frame "auto": math framing first, then Transform:Fit if a view is clipped or empty."""
          import zb_launch
          out_dir = os.path.abspath(out_dir)
          mode = "math" if frame == "auto" else frame
          res = zb_launch.call("zb_review", "capture_views", out_dir, list(views), matcap, mode,
                               margin, render, port=port, timeout=timeout)
          sils = [silhouette_bbox(s["path"]) for s in res["views"]]
          bad = sum(1 for s in sils if s is None or s["touches_border"])
          if frame == "auto" and bad:
              res2 = zb_launch.call("zb_review", "capture_views", out_dir, list(views), matcap, "fit",
                                    margin, render, "fit", port=port, timeout=timeout)
              sils2 = [silhouette_bbox(s["path"]) for s in res2["views"]]
              if sum(1 for s in sils2 if s is None or s["touches_border"]) < bad:
                  res, sils, mode = res2, sils2, "fit"
          labels = [_label(s, q) for s, q in zip(res["views"], sils)]
          path = contact_sheet([s["path"] for s in res["views"]], labels,
                               os.path.join(out_dir, sheet), title=title or f"{matcap} | frame {mode}")
          drift = max(abs(a - b) for a, b in zip(res["transform_before"], res["transform_after"]))
          return {"sheet": path, "frame_used": mode, "views": res["views"], "silhouettes": sils,
                  "transform_restored": drift < 1e-3, "transform_drift": drift,
                  "material": res.get("material")}
      
    • zb_stroke.py 21.3 KB
      """
      zb_stroke: build ZBrush stroke strings from canvas points, and map model points to canvas pixels.
      
      Pure Python (no numpy), so it imports both inside ZBrush 2026 (CPython 3.11.9) and on the
      agent side (system python3). Only `play()` needs `zbrush.commands`.
      
      What is proven (ZBrush 2026.2.1, tests/code/zbrush-expert/v01..v03):
        * `zbc.Stroke("(ZObjStrokeV02n<N>=H<x>V<y>...)")` with canvas pixels in upper-case hex,
          replayed with `zbc.canvas_stroke(stroke)`, sculpts in Edit mode (v02: volume 4.189 -> 4.193).
          `encode()` reproduces the v02 string byte for byte (offline test).
        * Canvas pixels are document pixels, origin top left, y down: the strokes land at the same
          pixels in the `Document:Export` PNG (v03_canvas.png).
        * Front view, in the space of the exported OBJ: canvas_x = pos_x + k*s*x, canvas_y = pos_y
          - k*s*y, and +Z faces the camera (strokes below the canvas centre landed at negative OBJ Y
          on the +Z hemisphere of fixtures/v03_sphere.obj; k*s was 207 px per unit there).
      
      What is NOT proven yet ([verify], see tests/code/zbrush-expert/live_02, live_04):
        * The decimal point count `n<N>` (the Maxon doc example has 27 points after `n27`, the v02
          string had 11 after `n11`; both consistent with decimal).
        * Lower-case `h`/`v` sub-pixel coordinates (value / 256), decoded from the Maxon doc example.
        * Any pressure token. `encode(..., pressure=...)` refuses unless PRESSURE_TOKEN is set after
          live_02 identifies one. Substitute: set Draw:Z Intensity / Draw:Draw Size per stroke.
        * The Euler rotation convention of get_transform/set_transform and the scale factor k.
          DEFAULT_CONVENTION is a hypothesis; `fit_convention()` measures the real one from dab
          samples (live_04) and `save_convention()` installs it as camera_convention.json.
      
      Canvas-space helpers: line, polyline, arc, zigzag, dab, resample, clip_to_canvas.
      Model-space helpers: Camera (project model points), frame_transform, visible_mask,
      front_most_vertex, fit_convention.
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import itertools
      import json
      import math
      import os
      import re
      
      __all__ = [
          "encode", "decode", "to_stroke", "play", "resample", "line", "polyline", "arc", "zigzag",
          "dab", "clip_to_canvas", "path_length", "Convention", "DEFAULT_CONVENTION",
          "load_convention", "save_convention", "rotation_matrix", "Camera", "frame_transform",
          "visible_mask", "front_most_vertex", "fit_convention", "VIEWS",
      ]
      
      HERE = os.path.dirname(os.path.abspath(__file__))
      CONVENTION_FILE = os.path.join(HERE, "camera_convention.json")
      
      STROKE_PREFIX = "ZObjStrokeV02"
      # Token that carries per-point pressure, once live_02 finds one (for example "P" with a hex
      # byte). None means: pressure is not encodable yet.
      PRESSURE_TOKEN = None
      
      
      # --------------------------------------------------------------------------------------------
      # Stroke strings
      # --------------------------------------------------------------------------------------------
      
      def _num(v, subpixel):
          """Canvas coordinate -> (letter case, hex digits). Integers use H/V, fractions h/v (x256)."""
          if v < 0:
              raise ValueError(f"negative canvas coordinate {v}: clip the path first (clip_to_canvas)")
          if subpixel and abs(v - round(v)) > 1e-6:
              return True, format(int(round(v * 256)), "X")
          return False, format(int(round(v)), "X")
      
      
      def encode(points, pressure=None, subpixel=False):
          """Stroke string in the V02 format proven in v02: '(ZObjStrokeV02n<N>=H<x>V<y>...)'.
      
          points: iterable of (x, y) canvas pixels (document space, origin top left, y down).
          pressure: optional list of 0..1 values; refused until PRESSURE_TOKEN is known [verify].
          subpixel: emit lower-case h/v with 1/256 fractions for non-integer points [verify].
          """
          pts = [(float(p[0]), float(p[1])) for p in points]
          if not pts:
              raise ValueError("empty stroke")
          if pressure is not None:
              if PRESSURE_TOKEN is None:
                  raise NotImplementedError(
                      "no pressure token is known for V02 strings [verify with live_02]; set "
                      "Draw:Z Intensity or Draw:Draw Size per stroke instead")
              if len(pressure) != len(pts):
                  raise ValueError("pressure list length differs from points")
          body = []
          for i, (x, y) in enumerate(pts):
              lx, hx = _num(x, subpixel)
              ly, hy = _num(y, subpixel)
              body.append(("h" if lx else "H") + hx + ("v" if ly else "V") + hy)
              if pressure is not None:
                  body.append(PRESSURE_TOKEN + format(max(0, min(255, int(round(pressure[i] * 255)))), "X"))
          return f"({STROKE_PREFIX}n{len(pts)}={''.join(body)})"
      
      
      _TOKEN = re.compile(r"([A-Za-z])(-?[0-9A-F]*)")
      
      
      def decode(s):
          """Parse a stroke string (V02 or V03) into {'version', 'count', 'header', 'points',
          'extras'}. Tolerates unknown tokens (the shipped macro string carries 'Y', 'K1', 'X').
          Lower-case h/v values are divided by 256 (decoded from the Maxon doc example)."""
          s = s.strip()
          m = re.match(r"^\(?ZObjStroke(V\d\d)n(\d+)(.*?)=(.*?)\)?$", s)
          if not m:
              raise ValueError("not a ZObjStroke string")
          version, count, header, body = m.group(1), int(m.group(2)), m.group(3), m.group(4)
          points, extras = [], {}
          cur_x = None
          for letter, val in _TOKEN.findall(body):
              if letter in "Hh":
                  cur_x = int(val, 16) / (256.0 if letter == "h" else 1.0)
              elif letter in "Vv":
                  if cur_x is None:
                      raise ValueError("V before H in stroke body")
                  points.append((cur_x, int(val, 16) / (256.0 if letter == "v" else 1.0)))
                  cur_x = None
              else:
                  extras.setdefault(len(points), []).append(letter + val)
          return {"version": version, "count": count, "header": header, "points": points,
                  "extras": extras}
      
      
      def to_stroke(points, zbc=None, **kw):
          """zbc.Stroke object for the points (inside ZBrush only)."""
          zbc = zbc or _zbc()
          return zbc.Stroke(encode(points, **kw))
      
      
      def play(points, zbc=None, rotation=None, h_scale=None, v_scale=None, h_offset=None,
               v_offset=None, **kw):
          """Draw the points as one stroke with zbc.canvas_stroke (inside ZBrush, Edit mode on).
          Returns canvas_stroke's bool. Proven for plain point lists (v02)."""
          zbc = zbc or _zbc()
          st = zbc.Stroke(encode(points, **kw))
          return zbc.canvas_stroke(st, None, rotation, h_scale, v_scale, h_offset, v_offset)
      
      
      def _zbc():
          from zbrush import commands as zbc  # only available inside ZBrush
          return zbc
      
      
      # --------------------------------------------------------------------------------------------
      # Canvas-space paths
      # --------------------------------------------------------------------------------------------
      
      def path_length(points):
          return sum(math.dist(points[i], points[i + 1]) for i in range(len(points) - 1))
      
      
      def resample(points, spacing_px, keep_corners=False):
          """Points evenly spaced along the polyline (keeps both ends). keep_corners=True samples
          each segment on its own (gaps <= spacing) so every input vertex stays in the path."""
          pts = [(float(x), float(y)) for x, y in points]
          if len(pts) < 2 or spacing_px <= 0:
              return pts
          if keep_corners:
              out = [pts[0]]
              for a, b in zip(pts, pts[1:]):
                  n = max(1, int(math.ceil(math.dist(a, b) / spacing_px - 1e-9)))
                  out += [(a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n)
                          for i in range(1, n + 1)]
              return out
          out = [pts[0]]
          carry = 0.0
          for a, b in zip(pts, pts[1:]):
              seg = math.dist(a, b)
              if seg == 0:
                  continue
              t = spacing_px - carry
              while t <= seg + 1e-9:
                  out.append((a[0] + (b[0] - a[0]) * t / seg, a[1] + (b[1] - a[1]) * t / seg))
                  t += spacing_px
              carry = seg - (t - spacing_px)
          if math.dist(out[-1], pts[-1]) > 1e-6:
              out.append(pts[-1])
          return out
      
      
      def line(p0, p1, spacing=4.0):
          return resample([p0, p1], spacing)
      
      
      def polyline(points, spacing=4.0, closed=False, keep_corners=True):
          pts = list(points)
          if closed and pts and pts[0] != pts[-1]:
              pts.append(pts[0])
          return resample(pts, spacing, keep_corners)
      
      
      def arc(center, radius, a0_deg, a1_deg, spacing=4.0):
          """Arc on the canvas; angles in degrees, 0 = +x (right), 90 = +y (down, canvas y)."""
          span = math.radians(a1_deg - a0_deg)
          n = max(2, int(abs(span) * radius / max(spacing, 1e-6)) + 1)
          return [(center[0] + radius * math.cos(math.radians(a0_deg) + span * i / (n - 1)),
                   center[1] + radius * math.sin(math.radians(a0_deg) + span * i / (n - 1)))
                  for i in range(n)]
      
      
      def zigzag(p0, p1, amplitude, wavelength, spacing=4.0):
          """Triangle wave from p0 to p1, amplitude in px perpendicular to the path."""
          length = math.dist(p0, p1)
          if length == 0:
              return [tuple(p0)]
          ux, uy = (p1[0] - p0[0]) / length, (p1[1] - p0[1]) / length
          nx, ny = -uy, ux
          corners = []
          half = wavelength / 2.0
          k = 0
          while k * half <= length + 1e-9:
              t = min(k * half, length)
              off = 0.0 if k == 0 or t >= length else (amplitude if k % 2 else -amplitude)
              corners.append((p0[0] + ux * t + nx * off, p0[1] + uy * t + ny * off))
              k += 1
          if math.dist(corners[-1], p1) > 1e-6:
              corners.append(tuple(p1))
          return resample(corners, spacing, keep_corners=True)
      
      
      def dab(p, repeats=2):
          """Single-point stamp: the same point repeated. The v01 recorded click-drag ended with a
          repeated point; whether 1, 2 or 3 repeats sculpt best is [verify] (live_02)."""
          return [(float(p[0]), float(p[1]))] * max(1, int(repeats))
      
      
      def clip_to_canvas(points, width, height, margin=0.0):
          """Drop points outside [margin, size - margin]; returns the kept points."""
          return [(x, y) for x, y in points
                  if margin <= x <= width - margin and margin <= y <= height - margin]
      
      
      # --------------------------------------------------------------------------------------------
      # Model space -> canvas pixels
      # --------------------------------------------------------------------------------------------
      
      class Convention:
          """How get_transform() values map model points (OBJ-export space) to canvas pixels.
      
          view = R(angles) . diag(axes) . (p - pivot)
          canvas_x = pos_x + k * s * fx * view_x ; canvas_y = pos_y + k * s * fy * view_y
          depth toward the camera = fz * view_z
          R = product of three axis rotations, one per transform angle: `perm` names the axis each
          of x_rotate, y_rotate, z_rotate turns about, `order` the product order (leftmost applied
          last), `signs` the sign of each angle.
          """
      
          FIELDS = ("perm", "order", "signs", "axes", "fx", "fy", "fz", "k", "use_pivot", "source")
      
          def __init__(self, perm="xyz", order="xyz", signs=(1, 1, 1), axes=(1, 1, 1), fx=1, fy=-1,
                       fz=1, k=1.0, use_pivot=True, source="hypothesis"):
              self.perm, self.order = perm, order
              self.signs, self.axes = tuple(signs), tuple(axes)
              self.fx, self.fy, self.fz, self.k = fx, fy, fz, float(k)
              self.use_pivot, self.source = bool(use_pivot), source
      
          def to_dict(self):
              return {f: (list(getattr(self, f)) if isinstance(getattr(self, f), tuple)
                          else getattr(self, f)) for f in self.FIELDS}
      
          @classmethod
          def from_dict(cls, d):
              return cls(**{f: d[f] for f in cls.FIELDS if f in d})
      
          def __repr__(self):
              return f"Convention({self.to_dict()})"
      
      
      # Hypothesis H0. fx, fy, fz: measured at the front view on fixtures/v03_sphere.obj (v01-v03
      # strokes). perm/order: the Maxon set_transform example calls (0, 90, 90) a top view; with
      # R = Rx.Ry.Rz that turns model Y onto the view axis [doc]. k = 1: Maxon's turntable example
      # frames with scale = min(w, h) / longest_side * 0.75 [doc]. All [verify] until live_04.
      _H0 = Convention(source="H0: front-view signs from v03 OBJ; order from Maxon examples [verify]")
      
      
      def load_convention(path=CONVENTION_FILE):
          if path and os.path.exists(path):
              with open(path) as fh:
                  return Convention.from_dict(json.load(fh))
          return _H0
      
      
      def save_convention(conv, path=CONVENTION_FILE, extra=None):
          d = conv.to_dict()
          if extra:
              d["fit"] = extra
          with open(path, "w") as fh:
              json.dump(d, fh, indent=1)
          return path
      
      
      DEFAULT_CONVENTION = load_convention()
      
      
      def _rot(axis, deg):
          a = math.radians(deg)
          c, s = math.cos(a), math.sin(a)
          if axis == "x":
              return ((1, 0, 0), (0, c, -s), (0, s, c))
          if axis == "y":
              return ((c, 0, s), (0, 1, 0), (-s, 0, c))
          return ((c, -s, 0), (s, c, 0), (0, 0, 1))
      
      
      def _mm(a, b):
          return tuple(tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3))
                       for i in range(3))
      
      
      def _mv(m, v):
          return tuple(m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2] for i in range(3))
      
      
      def rotation_matrix(rx, ry, rz, perm="xyz", order="xyz", signs=(1, 1, 1)):
          """3x3 rotation for transform angles (degrees). `perm[i]` is the axis angle i turns about;
          `order` lists angle names left to right in the product (so the last one applies first)."""
          ang = {"x": signs[0] * rx, "y": signs[1] * ry, "z": signs[2] * rz}
          axis = {"x": perm[0], "y": perm[1], "z": perm[2]}
          r = ((1, 0, 0), (0, 1, 0), (0, 0, 1))
          for name in order:
              r = _mm(r, _rot(axis[name], ang[name]))
          return r
      
      
      class Camera:
          """Projection for one get_transform() reading.
      
          transform: the 9 floats of zbc.get_transform() (pos xyz, scale xyz, rot xyz degrees).
          pivot: model point that sits at the transform position; default the bbox centre of the
          mesh the points come from (an exported OBJ) [verify pivot for multi-SubTool tools].
          """
      
          def __init__(self, transform, pivot=(0.0, 0.0, 0.0), convention=None):
              t = [float(v) for v in transform]
              if len(t) != 9:
                  raise ValueError("transform must have 9 values")
              self.t = t
              self.pos = (t[0], t[1])
              self.s = t[3]
              if max(abs(t[3] - t[4]), abs(t[3] - t[5])) > 1e-6 * max(1.0, abs(t[3])):
                  raise ValueError("non-uniform view scale: keep x, y, z scale equal (MadPony)")
              self.conv = convention or DEFAULT_CONVENTION
              c = self.conv
              self.pivot = tuple(pivot) if c.use_pivot else (0.0, 0.0, 0.0)
              self.r = rotation_matrix(t[6], t[7], t[8], c.perm, c.order, c.signs)
      
          @classmethod
          def from_zbrush(cls, zbc=None, pivot=None, convention=None):
              """Inside ZBrush: current transform, pivot = centre of the full bbox of all SubTools."""
              zbc = zbc or _zbc()
              if pivot is None:
                  b = zbc.query_mesh3d(2, 3)
                  pivot = ((b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2)
              return cls(zbc.get_transform(), pivot, convention)
      
          def to_view(self, p):
              c = self.conv
              q = ((p[0] - self.pivot[0]) * c.axes[0], (p[1] - self.pivot[1]) * c.axes[1],
                   (p[2] - self.pivot[2]) * c.axes[2])
              return _mv(self.r, q)
      
          def project(self, p):
              """(canvas_x, canvas_y, depth); larger depth = closer to the camera."""
              v = self.to_view(p)
              c = self.conv
              g = c.k * self.s
              return (self.pos[0] + g * c.fx * v[0], self.pos[1] + g * c.fy * v[1], c.fz * v[2])
      
          def project_many(self, points):
              return [self.project(p) for p in points]
      
          def facing(self, normal):
              """> 0 when a model-space normal points toward the camera."""
              c = self.conv
              n = (normal[0] * c.axes[0], normal[1] * c.axes[1], normal[2] * c.axes[2])
              return c.fz * _mv(self.r, n)[2]
      
          def pixels_per_unit(self):
              return self.conv.k * self.s
      
      
      def frame_transform(bbox, doc_w, doc_h, rotation=(0, 0, 0), margin=0.75, convention=None):
          """9-value transform that centres the model and makes its longest bbox side span
          `margin` of the short canvas side (Maxon's turntable rule, divided by k).
          bbox: (minx, miny, minz, maxx, maxy, maxz) as query_mesh3d(2, ...) returns it."""
          conv = convention or DEFAULT_CONVENTION
          longest = max(bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2])
          if longest <= 0:
              raise ValueError("empty bbox")
          s = min(doc_w, doc_h) / longest * margin / conv.k
          return [doc_w / 2.0, doc_h / 2.0, 0.0, s, s, s, float(rotation[0]), float(rotation[1]),
                  float(rotation[2])]
      
      
      # Review views as rotation triples. front: default view of a freshly drawn tool. top: the
      # Maxon set_transform example. back: key 2 of the Maxon turntable example. right and
      # threequarter: plain y rotations. Which side "right" shows is [verify] (live_03, live_04).
      VIEWS = {
          "front": (0.0, 0.0, 0.0),
          "right": (0.0, 90.0, 0.0),
          "back": (0.0, 180.0, 180.0),
          "left": (0.0, -90.0, 0.0),
          "threequarter": (0.0, 45.0, 0.0),
          "top": (0.0, 90.0, 90.0),
      }
      
      
      def visible_mask(cam, verts, points=None, cell_px=2.0, eps=None):
          """Which points are the front-most surface at their pixel (vertex z-buffer).
          verts: all mesh vertices (model space); points: queries (default: verts).
          eps: depth tolerance in model units (default 1 percent of the depth range)."""
          proj = cam.project_many(verts)
          zbuf = {}
          for x, y, d in proj:
              key = (int(x // cell_px), int(y // cell_px))
              if d > zbuf.get(key, -1e30):
                  zbuf[key] = d
          if eps is None:
              ds = [d for _, _, d in proj]
              eps = 0.01 * (max(ds) - min(ds) + 1e-12)
          qs = proj if points is None else cam.project_many(points)
          out = []
          for x, y, d in qs:
              key = (int(x // cell_px), int(y // cell_px))
              best = max(zbuf.get((key[0] + i, key[1] + j), -1e30)
                         for i in (-1, 0, 1) for j in (-1, 0, 1))
              out.append(d >= best - eps)
          return out
      
      
      def front_most_vertex(cam, verts, canvas_xy, radius_px=3.0):
          """Index of the vertex closest to the camera among those projecting within radius_px of
          canvas_xy, or None: what a stroke at that pixel would hit (approximately)."""
          best, best_d = None, -1e30
          r2 = radius_px * radius_px
          for i, p in enumerate(verts):
              x, y, d = cam.project(p)
              if (x - canvas_xy[0]) ** 2 + (y - canvas_xy[1]) ** 2 <= r2 and d > best_d:
                  best, best_d = i, d
          return best
      
      
      def _candidates():
          for perm in ("".join(p) for p in itertools.permutations("xyz")):
              for order in ("".join(p) for p in itertools.permutations("xyz")):
                  for signs in itertools.product((1, -1), repeat=3):
                      for axes in itertools.product((1, -1), repeat=3):
                          for fx, fy in itertools.product((1, -1), repeat=2):
                              yield perm, order, signs, axes, fx, fy
      
      
      def fit_convention(samples, use_pivot=(True, False)):
          """Find the convention that best explains measured samples.
      
          samples: list of dicts {"transform": 9 floats read with get_transform, "pivot": model bbox
          centre, "model": model point that the stroke hit, "canvas": (x, y) of the stroke}, plus
          optional "center" (model centre) to decide which depth sign faces the camera.
          k is solved by least squares for each candidate. Returns (Convention, rms_px, n_ties).
          Several candidates can explain the same data equally (equivalent Euler forms); any of
          them predicts the same pixels for the sampled kinds of transform.
          """
          if not samples:
              raise ValueError("no samples")
          best = None
          ties = 0
          for up in use_pivot:
              for perm, order, signs, axes, fx, fy in _candidates():
                  num = den = 0.0
                  pre = []
                  for smp in samples:
                      t = smp["transform"]
                      r = rotation_matrix(t[6], t[7], t[8], perm, order, signs)
                      pv = smp.get("pivot", (0, 0, 0)) if up else (0, 0, 0)
                      p = smp["model"]
                      v = _mv(r, ((p[0] - pv[0]) * axes[0], (p[1] - pv[1]) * axes[1],
                                  (p[2] - pv[2]) * axes[2]))
                      ux, uy = t[3] * fx * v[0], t[3] * fy * v[1]
                      dx, dy = smp["canvas"][0] - t[0], smp["canvas"][1] - t[1]
                      num += ux * dx + uy * dy
                      den += ux * ux + uy * uy
                      pre.append((ux, uy, dx, dy))
                  if den <= 0:
                      continue
                  k = num / den
                  if k <= 0:
                      continue
                  err = sum((k * ux - dx) ** 2 + (k * uy - dy) ** 2 for ux, uy, dx, dy in pre)
                  rms = math.sqrt(err / len(samples))
                  if best is None or rms < best[0] - 1e-9:
                      best, ties = (rms, perm, order, signs, axes, fx, fy, k, up), 0
                  elif abs(rms - best[0]) <= 1e-9:
                      ties += 1
          rms, perm, order, signs, axes, fx, fy, k, up = best
          fz = 1
          withc = [s for s in samples if "center" in s]
          if withc:
              votes = 0.0
              for smp in withc:
                  t = smp["transform"]
                  r = rotation_matrix(t[6], t[7], t[8], perm, order, signs)
                  vp = _mv(r, tuple((smp["model"][i] - smp["center"][i]) * axes[i] for i in range(3)))
                  votes += vp[2]
              fz = 1 if votes >= 0 else -1
          conv = Convention(perm, order, signs, axes, fx, fy, fz, k, up,
                            source=f"fit_convention on {len(samples)} samples, rms {rms:.2f} px")
          return conv, rms, ties
      
      
      def residuals(conv, samples):
          """Per-sample pixel error of a convention (for reporting H0 against the fit)."""
          out = []
          for smp in samples:
              cam = Camera(smp["transform"], smp.get("pivot", (0, 0, 0)), conv)
              x, y, _ = cam.project(smp["model"])
              out.append(math.dist((x, y), smp["canvas"]))
          return out
      
  • SKILL.md 21 KB
    ---
    name: scenario-zbrush-expert
    description: "Use when an agent drives Maxon ZBrush 2026 for any task (sculpt in ZBrush, DynaMesh, ZRemesher, UVs, export, polypaint, posing, 3D print, clean up an AI mesh in ZBrush), when writing ZBrush Python (zbrush.commands) or ZScript, when the ZBrush bridge is not answering, a palette path or set() does nothing, a scripted stroke does not sculpt or a dialog blocks a script, or when a ZBrush result has to be judged the way a professional artist would."
    license: MIT
    ---
    
    # ZBrush expert (router and agent protocol)
    
    An agent does expert ZBrush work as a loop: deterministic palette operations, synthesized strokes and canvas renders from fixed views, gated at every stage. One long script is not the method. This skill is the protocol every ZBrush task follows, the shared toolkit in [`scripts/`](scripts/), and the map to the team skills. Target: ZBrush 2026.2.1 on macOS, driven through the project's Python bridge. 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.
    
    ## 1. Execution channel
    
    | Channel                                                                                                                                       | Use for                                                                  | Rules                                                                                                                                                                                                                                                                                                                                                  |
    | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
    | Bridge into a running ZBrush ([`scripts/zb_launch.py`](scripts/zb_launch.py), over the client [`scripts/zb_bridge.py`](scripts/zb_bridge.py)) | all interactive work: palette operations, strokes, renders, measurements | `zb_launch.start()` spawns ZBrush with [`ZBRUSH_PLUGIN_PATH=scripts/zb_plugins`](scripts/zb_plugins/) and `ZB_BRIDGE_AUTOSERVE=1`. It waits for "plugin loaded", the port and a ping, then quits the ZHomePage helper. Work with `zb_launch.run(code)` or `zb_launch.call("zb_ops", "dynamesh", 256)`. End with `zb_launch.stop(save_to=".../x.ztl")`. |
    | `-script` batch launch                                                                                                                        | unattended jobs on saved files (export every SubTool, reports)           | `ZBrush.app/Contents/MacOS/ZBrush -script /abs/job.py args /abs/scene.ZPR`: the scene is the LAST argument, script args start at `sys.argv.index("-script") + 2`, report through files and `sys.exit(code)` (Maxon quickstart). Whether ZBrush quits on exit [verify].                                                                                 |
    
    Rules for the bridge:
    
    - **Main thread only.** Every `zbc` call runs in main mode, which is the default of `run` and `call`. The embedded VM keeps the GIL on ZBrush's main thread, so a socket thread alone starves. The proven design is a serve loop on the main thread that calls `update(redraw_ui=True)` between polls (README).
    - **One driver.** Only one agent drives ZBrush at a time. ZBrush is single-instance: `start()` refuses a second instance and never kills a ZBrush it did not spawn.
    - **Short, measured calls.** Each call returns numbers (faces, volume, file bytes). Long operations such as ZRemesher, Decimation or Divide into millions get minute-scale timeouts. A timeout does not cancel queued code: it still runs when the main thread frees. Ping and read state before sending anything else.
    - **No blocking UI.** Never call `message_*`, `ask_*` or `show_note(..., 0)`. For visible progress use `set_notebar_text(text, progress)`.
    - **One shared interpreter.** ZBrush keeps one embedded Python for the whole session: what a call leaves behind stays until restart. `run` and `call` leave `sys.path`, `sys.modules`, stdout and the working directory as found and pop every toolkit module, also after an error (Maxon Style Guide; the server runs the restore hook). Never add a folder to `sys.path` for good, never import toolkit code outside `zb_launch.run`. `zb_launch.hygiene()` must return empty lists; `purge()` removes a leak.
    - **Long sequences.** Group deterministic steps into one call: [`zb_ops.sequence([["divide", [2]], ["polish", [10]]])`](scripts/zb_ops.py) runs them inside `freeze` with `show_actions(0)`; `quiet()` and `frozen(fn)` are the parts (SDK GUI reference). Never freeze a plugin press or a canvas export; call `update(redraw_ui=True)` before a snapshot.
    - **Locked screen.** `Document:Export` still writes the canvas PNG (v03); `screencapture` shows only the lock screen. At 05:30 on 2026-09-24 a launch stalled after the Home Page press, while the 05:20 launch under the same lock worked. `start()` detects the stall, terminates the process it spawned and reports a diagnosis (details in `zb_launch.py`).
    - Check `zbrush_info(0)` = 2026.2 before trusting anything below.
    
    ## 2. Capability matrix (ZBrush 2026.2.1, 2026-09-24)
    
    | Action                                                                            | Status                                                           | Substitute                                                                                                                               |
    | --------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
    | Press, set and read palette items by path (DynaMesh, Divide, masks, exports)      | yes, proven (v01, v03)                                           | always `exists()` first: `set()` on a wrong path is silent                                                                               |
    | Synthesized strokes `Stroke("(ZObjStrokeV02n<N>=H<x>V<y>...)")` + `canvas_stroke` | yes, proven (v02: volume 4.189 to 4.193)                         |                                                                                                                                          |
    | Replaying a recorded stroke with an offset                                        | yes (v01: volume 4.184 to 4.189); rotation and scale [doc]       |                                                                                                                                          |
    | `canvas_click` drags to sculpt                                                    | no (v01: no change)                                              | `canvas_stroke`                                                                                                                          |
    | Dialog-free canvas PNG and OBJ export                                             | yes (v03: 0.04 s, 0.08 s)                                        |                                                                                                                                          |
    | ZTL save by path                                                                  | path exists, dialog-free [verify]                                |                                                                                                                                          |
    | Aiming strokes at model points                                                    | front view measured on the v03 OBJ; other views [verify live_04] | [`zb_stroke.fit_convention`](scripts/zb_stroke.py)                                                                                       |
    | Pen pressure in strings                                                           | unknown [verify live_02]                                         | Draw:Z Intensity and Draw Size per stroke                                                                                                |
    | Smoothing by stroke (Shift)                                                       | no: a Smooth brush only becomes the Shift brush                  | `Tool:Deformation:Polish`, hPolish brush                                                                                                 |
    | ZModeler polygon clicks, Gizmo drags, pivot placement                             | no                                                               | Deformation sliders, Geometry X/Y/Z Position and XYZ Size, DynaMesh with Booleans, ZRemesher; else hand to a human or computer-use agent |
    | Text prompts (SubTool Rename, New Folder)                                         | no                                                               | name parts at creation; `set_tool_path` renames the top SubTool only                                                                     |
    | FBX export options dialog                                                         | no ("You cannot escape any GUI", Maxon forum)                    | OBJ or `.GoZ` by path                                                                                                                    |
    | `press_key`                                                                       | no ("Does not work" in the stub)                                 | ZScript macro with `IKeyPress` [verify]; System Events from outside                                                                      |
    | `merge_undo`, undo control                                                        | no ("Not implemented")                                           | versioned ZTL saves, `Tool:Morph Target:StoreMT`                                                                                         |
    | Mask, layer, polygroup data                                                       | no API (Maxon forum 2026)                                        | buttons plus visual checks; OBJ export for geometry                                                                                      |
    | UV Master and Decimation Master presses                                           | paths exist; return of control [verify live_08, live_09]         | native `Tool:UV Map:Create (Unwrap):Unwrap`                                                                                              |
    
    ## 3. The expert loop in ZBrush
    
    1. **Brief in numbers.** Purpose (film, game, print, concept), polycount per stage, print height in mm, style, references. When the brief is silent, pick the domain skill's documented default and write it down.
    2. **Plan big to small.** Block with primitives, ZSpheres or low DynaMesh; then primary, secondary and tertiary forms. Topology, UVs and export come last (Pablo Munoz Gomez's order in _Understanding ZBrush's Logic_).
    3. **Build one stage** with deterministic operations first; they are exact and cheap. Use strokes only where the form needs them. Before any stroke: Edit mode on, symmetry state chosen on purpose, brush and Draw values set and read back, view fixed with `set_transform`. Record the volume before and after as evidence that the stroke sculpted.
    4. **Gate the stage.**
       - Numbers: `zb_ops.stats()`, plus [`zb_audit.audit()`](scripts/zb_audit.py) on an OBJ export. Every destructive wrapper (DynaMesh, ZRemesher, Divide, Decimate, Polish, Project All) returns a `gate`: volume ratio from `get_polymesh3d_volume()` and watertightness from `is_polymesh3d_solid()`. A collapsed or exploded remesh is a problem, a lost watertight state a warning; `zb_ops.require_gate(r)` raises. Bands are [added] start values.
       - Review sheet: [`zb_review.review(out_dir)`](scripts/zb_review.py) renders front, right, back, three-quarter and top with a neutral MatCap (MatCap Gray) and tiles one labeled sheet (the triples beyond front are [verify live_03]; an upside-down or wrong-side tile is visible on the sheet). Open it with the image reader and judge it with the domain skill's critique. Experts orbit constantly; the sheet is the agent's orbit.
    5. **Fix before detail.** A proportion error found at blockout costs one call; found after projection it costs the stage.
    6. **Checkpoint before anything destructive.** Save a versioned ZTL before DynaMesh, ZRemesher, Project All, Booleans, merges and Decimation. There is no undo control. `zb_ops.project_all(checkpoint="/abs/x.ztl")` refuses to run without one, since Project All "might crash the program" (cgside). It then stores a morph target and a New Layer at the top level (FlippedNormals), sets Dist 0.1 (Drust) and flags spikes (a vertex outside the visible bbox). Repair with `morph_repair(points)` (Morph brush back to the stored target) or the layer, never by smoothing (FlippedNormals).
    7. **Deliver with evidence:** the final sheet, audit numbers, file paths, and what was not verified.
    
    Never report a model as done without having looked at a canvas render of its final state.
    
    ## 4. File safety
    
    - Turn Edit mode on before any canvas stroke (`zb_ops.ensure_edit()`). With Edit off, a drag stamps another pixol copy of the tool (Pablo, Logic Part 1).
    - Save versioned `.ZTL` files: `zb_ops.save_ztl(path)` picks the next free `_vNNN` and never overwrites. Keep at least three increments; AutoSave alone is not enough (Maxon, Saving Your Work). QuickSave is crash recovery, not versioning.
    - Never open a project over unsaved tools. Opening a `.ZPR` deletes every loaded tool (Pavlovich 005). Save each changed tool first. Ctrl+S saves a project, not the model.
    - Name SubTools at creation: renaming later needs a text prompt. On save, the top SubTool takes the file name (Pavlovich 030).
    - Write only outside the install, which is read-only for ZBrush since 2026.1, and always use absolute paths: relative ones land in the app folder. Exports keep a `.bak` of any file they replace.
    - Keep originals. Duplicate before ZRemesher or Decimation (the sculpt stays the projection source). Merge Visible creates a new tool. Store a morph target before a risky stroke pass.
    - Do not touch the user's configuration (Store Config, hotkeys, `zbc.config()`, `zbc.reset()`) without consent.
    
    ## 5. Persona pipelines
    
    | Brief                                | Skill chain                                                                                                                    | Delivers                                        |
    | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
    | Realistic character or creature      | scenario-zbrush-sculpting, scenario-zbrush-character-creature, scenario-zbrush-retopology-export, scenario-zbrush-paint-render | ZTL stack, maps, renders                        |
    | Stylized character or collectible    | scenario-zbrush-sculpting, scenario-zbrush-stylized, then scenario-zbrush-pose-print or scenario-zbrush-paint-render           | sculpt, print parts or renders                  |
    | Hard-surface prop or weapon          | scenario-zbrush-hard-surface, scenario-zbrush-retopology-export                                                                | high poly, game low poly, bakes                 |
    | Retopology and export of a sculpt    | scenario-zbrush-retopology-export                                                                                              | clean low poly, UVs, maps, OBJ or GoZ           |
    | AI-generated mesh cleanup            | audit with zb_audit, scenario-zbrush-retopology-export (repair, remesh, project), scenario-zbrush-sculpting (forms)            | clean sculpt worth detailing                    |
    | Posing and 3D print                  | scenario-zbrush-pose-print                                                                                                     | posed, hollowed, keyed parts in mm (STL or 3MF) |
    | Polypaint, render, presentation      | scenario-zbrush-paint-render                                                                                                   | BPR or Redshift renders, turntables             |
    | Batch jobs, GoZ round trips, reports | scenario-zbrush-automation                                                                                                     | scripts, `-script` jobs, reports                |
    
    Handoffs between team skills are a versioned ZTL plus its `stats()` and last review sheet. Handoffs out of ZBrush are OBJ or GoZ plus maps and an audit report. Sister teams: scenario-maya-expert (rigging, animation, Arnold lookdev), scenario-blender-expert (retopology, lookdev, rendering, GoB round trips), scenario-3d (AI mesh generation to finish here).
    
    ## 6. Shared toolkit (`scripts/`)
    
    - `zb_launch.py` (agent side): `start`, `ping`, `stop(save_to)`, `is_running`, `status`, `screen_locked`, `diagnose`, `run(code, modules)`, `call(module, func, *args)`, `hygiene`, `purge`. CLI: `python3 zb_launch.py start|ping|status|stop|run`.
    - `zb_ops.py` (inside ZBrush): checked wrappers `new_sphere`, `dynamesh`, `divide`, `del_levels`, `zremesher`, `project_all` (safe: checkpoint, nets, Dist, gate), `top_level`, `store_morph_target`, `new_layer`, `set_layer_intensity`, `morph_repair`, `polish`, `deform`, `mask`, `polygroups`, `visibility`, `uv_unwrap`, `decimate`, `export_obj`, `export_canvas`, `save_ztl`, `select_brush`, `find_brush`, `set_draw`, `set_material`, `set_symmetry`, `sculpt_stroke`, `stats`, `subtools`, `gate`, `require_gate`, `quiet`, `frozen`, `sequence`. `PATHS` lists every item path with its evidence tag.
    - `zb_stroke.py` (pure Python, both sides): `encode`/`decode` V02 strings, `line`, `polyline`, `arc`, `zigzag`, `dab`, `resample`, `clip_to_canvas`, `Camera(transform, pivot).project(p)`, `frame_transform`, `visible_mask`, `front_most_vertex`, `fit_convention`, `VIEWS`.
    - `zb_review.py`: `review(out_dir, views, matcap)` (agent side), `capture_views` and `snapshot(path)` (inside ZBrush), `contact_sheet`, `silhouette_bbox`.
    - `zb_audit.py` (agent side, numpy): `audit(obj)` gives counts, poles, holes, non-manifold, winding, shells, symmetry, bbox, area, volume and edge lengths; `verdict(report, "sculpt"|"game"|"print", ...)`; `displaced(a, b)`.
    
    Offline tests pass (`python3 -m unittest discover -s tests/code/zbrush-expert -p "test_*.py"`). The ZBrush-side functions have not yet run in ZBrush as functions; the calls under them that v01 to v03 proved are listed in [`references/sdk-cookbook.md`](references/sdk-cookbook.md). `tests/code/zbrush-expert/run_live.sh` runs the live checks in one command.
    
    ```python
    # not yet run in ZBrush as a sequence
    import sys; sys.path.insert(0, "<this skill>/scripts")
    import zb_launch as zl, zb_review, zb_audit
    zl.start()
    zl.call("zb_ops", "new_sphere", 128)
    zl.call("zb_ops", "sculpt_stroke", [[500, 380], [620, 380]], brush="ClayBuildup", size=40,
            z_intensity=25)
    sheet = zb_review.review("/abs/out/stage1")["sheet"]          # look at it
    obj = zl.call("zb_ops", "export_obj", "/abs/out/stage1.obj")
    print(zb_audit.verdict(zb_audit.audit(obj["path"]), "sculpt"))
    zl.call("zb_ops", "save_ztl", "/abs/out/head.ztl")            # head_v001.ztl
    zl.stop()
    ```
    
    ## 7. Top 2026 traps
    
    - `set()` on a missing path does nothing and raises nothing. `Transform:Frame` and `Brush:Dam_Standard` do not exist; use `Transform:Fit` and `zb_ops.select_brush("Dam_Standard")`. PolyFrame is `Transform:Pf` (commands.xml id `Transform: Pf`) and perspective is `Draw:Perspective`; `Transform:PolyF` and `Transform:Persp` are not ids.
    - `query_mesh3d()` returns lists: `int(zbc.query_mesh3d(1)[0])`.
    - A ZScript that pressed Decimation Master or a UV plugin never got control back (ZBrushCentral 2013 and 2014, cgside 2022). Test each plugin press once, with a short timeout and a ping after.
    - DynaMesh on a SubTool with levels is expected to raise a modal note [verify]; the wrappers refuse instead. Never Divide a DynaMesh to detail; ZRemesher it first (Pablo, Logic Part 7).
    - The ZRemesher target is in thousands and approximate: read the face count back.
    - ZHomePage covers the canvas after launch. Cmd+W quits ZBrush on macOS: never send it.
    - Item paths ignore case and spaces (SDK docs). The Activity log (Asset Directory `Logs/Activity`) records the canonical path of each press and set, which makes it a free path oracle (strokes are not logged).
    
    ## References
    
    - `references/sdk-cookbook.md`: calls and paths by task with the v01 to v03 evidence and the dialog budget. Load before writing any bridge code.
    - [`references/zbrush-2026-traps.md`](references/zbrush-2026-traps.md): automation and modeling traps with fixes. Load when a call does nothing, hangs, or a tutorial step does not match 2026.
    - [`references/sources.md`](references/sources.md): every source, with credentials and URLs.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related