Claude Skill

scenario-zbrush-retopology-export

Use when a ZBrush sculpt, scan or AI-generated mesh must become production topology and files (ZRemesher and guides, the 2026 Retopo brush, reprojecting detail with Project All, rebuilding subdivision levels, UV Master or native unwrap, normal and displacement maps with Multi Map

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-retopology-export-dc592af.zip · 106 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-retopology-export
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 retopology and export (pipeline sculptor)

Expert level means the sculpt leaves ZBrush as a light, clean exchange mesh at its lowest level, detail kept on a rebuilt subdivision stack or in maps, at real size, every file proved by parsing it. Most of it is deterministic palette work an agent does well; click-by-click topology, modifier clicks and a few dialogs stay with a human or computer-use agent. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.

REQUIRED BACKGROUND: scenario-zbrush-expert (bridge, stroke engine, review loop, 2026 traps).

Toolkit: scripts/zb_retopology_export.py (rx) on top of the lead's zb_ops, zb_audit and zb_launch: rx.zcall("func", ...) inside ZBrush, audits on the agent side. Every projection pass is the lead's safe zb_ops.project_all (versioned checkpoint, nets, gate). Not yet run in ZBrush (88 offline tests pass; live tests in references/procedures.md).

Stance (the expert delta)

  • Set symmetry on purpose before every ZRemesher run. With symmetry on, ZRemesher remeshes half and mirrors it: a silent Mirror And Weld that ruins posed or asymmetric meshes (Drust, Kjce71jjWJU 00:01:11). rx.zremesh makes symmetric a required argument.
  • Give ZRemesher an easy problem. A face that will animate goes in neutral, folds relaxed, the expression sculpted back on the new topology (Pavlovich n5_cZK-9peg 00:01:39). Ears and hands are remeshed alone, open borders polished first (00:44:36). Unintended holes are closed first: they survive and inflate the count (ZRemesher doc).
  • ZRemesher is for flow; detail comes back by projection. Aim low (Gaboury: 5k to 18k for an arm, 34D6VljTjBs 00:02:18), then Project All with Dist 0.1, which fixes "90 percent" of artifacts (Drust nxMYYsyJt3o 00:02:19). Only source and target visible, even in Solo (Pavlovich 01:07:26). With Adapt on and Keep Groups, Target 5 gave 8.0k to 9.4k points (frames 00:18:50, 00:22:09); Adapt off hits the count.
  • Projection has danger zones: eyes, mouth, armpits, between fingers, crotch; what is left there becomes hard lines in displacement (FlippedNormals Zp07GW3rND0 00:07:43). Mask an eye-interior group grown just past the lid; repair with the morph target or a masked reprojection, never smoothing (00:05:31, 00:06:36).
  • Steer with polygroups, borders smoothed first: ZRemesher builds jagged borders into the loops (Pavlovich 00:05:27). Smooth Groups 0 on clean borders, 1 for ragged DynaMesh groups (Drust 8D-xqasgUws 00:02:17). Failed eyelids, mouth corners and nostrils are deleted and rebuilt (Pavlovich 00:21:36); the Retopo brush's "No" answer makes a light ZRemesher mesh editable (Pablo Munoz Gomez, I4nePXDTrhQ 00:06:36).
  • The lowest level is the exchange mesh. UVs are made there (Gaboury cemmalvugFk 00:00:51). Maps compare the current level with the top of the same SubTool, never two SubTools (Drust n0qqpwvn-jA 00:01:14); level 1 carries forms, max-1 micro detail only (2zDAtaQqwh8 00:02:53).
  • Film displacement is a fixed recipe (FlippedNormals -ThBTEc8L_M): Flip V on ("an absolute must"), Adaptive off (100 UDIMs in about 1 h, not 24 h), DpSubPix 0, 32-bit EXR, 3 Channels off, renderer zero value = Mid (MME doc: Mid 0 for 32-bit; FlippedNormals keep 0.5; record it).
  • Exported size = internal extent x Export Scale (Drust n2xPrwI9o1U 00:04:19). Keep XYZ Size between 1 and 4 (Gallagher EXjfH_X2hkM 00:28:23). Export Scale and offsets live per SubTool, so the same values go on every SubTool [added].
  • A generated or scanned mesh is a form reference, not topology to repair. DynaMesh early, after thickening thin shells, which DynaMesh's hole fill "shoots straight across" (Drust PrFQXjs_6_w 00:02:53). Rebuild the stack from the cleaned DynaMesh, never the noisy original [added].

Establish first

Input Changes Default when the brief is silent
Destinations mesh type, maps, units, axis Maya: OBJ level 1 quads in cm + 32-bit EXR displacement, Mid 0.5
Deforms? face, joints exact loops (human Retopo pass), neutral face yes for characters; an expression goes back to scenario-zbrush-character-creature (neutral version, or its layer off)
Budget ZRemesher target, Adapt rig or film body 15k to 25k quads [added]; animated head 10k to 20k (Retopo doc); game from the brief
Posed or asymmetric symmetry off symmetric only if unposed and designed symmetric
Real size Export Scale 180 cm human (Gallagher's example)
Maps and tiles MME preset, UDIM plan one 4096 tile per SubTool; UDIM layouts come from Maya or Blender
Source type route levels: a level under 2M; none: DynaMesh copy; AI or scan: cleanup first

Workflow

  1. Preflight, checkpoint. rx.zcall("preflight", 180.0); zb_ops.save_ztl. GATE: flags resolved (over 8M vertices, XYZ Size outside 1 to 4, Export Scale 0, duplicate names).
  2. ZRemesher input. remesh_input("auto", max_points=2_000_000) duplicates the sculpt and hides the original (the projection source); fill_holes=True adds Close Holes and Fix Mesh when holes are unintended. DynaMesh and Close Holes also shut eye sockets and mouth bags: openings_closed sets the hole gate (0, else the intended openings). GATE: copy under 8M vertices, source intact, face neutral.
  3. Guides. Polygroups by buttons (Auto Groups, Groups By Normals, From Polypaint); a mask a human drew becomes a group with support loops through Edgeloop Masked Border (Drust IpTYcGxxsdM 00:05:08); polish_band("groups"). Ears and hands: own SubTools (Groups Split, Split Hidden), polish_band("border"), own runs (hand Adaptive Size about 14: Pavlovich 00:59:42), then merge_down_run(..., weld=True); Freeze Border only where borders must re-weld exactly, after remeshing the whole limb, at a cost to animation (01:03:44). GATE: Polyframe sheet shows closed, smooth rings; no seam loops after the re-join.
  4. ZRemesher. zremesh(target_k, symmetric, keep_groups=True, smooth_groups=0) (Half, Same, Double off), then retry(adaptive_size=...) over 0, 5, 21. Keep Groups, Keep Creases, Smooth Groups or a sculpt break the Retry cache. The sweep leaves the LAST variant: pick_retry, retry(**pick["retry"]), confirm_retry. Optional Half pass with Adapt off, or a retopology over the retopology (Drust R2MzFqWMaWY 00:01:44). Poor center line at nape or brow: Alt+ZRemesher, a modifier click press cannot send: ask computer use, compare centre_line_components (Pavlovich 00:48:31). When snap_back is true, project_stack(copy, source, levels=0, checkpoint=ztl) before judging (00:22:05). GATE: in_band; topo_report + retopo_verdict(purpose="rig", expected_holes=...) on a Grp-on QA export; loops follow forms. Failed orifices: the script stops for a human Retopo pass.
  5. Rebuild the stack. project_stack(target, source, checkpoint=ztl): two visible, Dist 0.1, level 1 then Divide + Project All per level up to half the source count, a morph target per pass, color last with Colorize on for both SubTools (Drust PrFQXjs_6_w 00:16:35). Danger zones or a film face: order="top_first", stop_after="nets" (divisions, then StoreMT and a layer at the top: FlippedNormals 00:02:13), a human isolates the eye-interior group, protect_isolated(grow=1), project_levels(..., nets_done=True). GATE: projection_from_stack (level 1 unchanged, bbox 0.5 percent, volume 2 percent [added]); same-view sheet plus danger-zone close-ups per level. Busted areas: zb_ops.morph_repair; a target inside the source: masked Inflate, or ProjectionShell with Inner and Dist 1 (Gaboury pQbPtH0p5Bg 00:02:30). Then drop_morph_target(): bakes refuse a stored morph target, a possible map base (Drust 2zDAtaQqwh8 00:01:44) [verify].
  6. UVs at level 1 (they carry through the levels, Gaboury cemmalvugFk 00:03:30). Quick: zb_ops.uv_unwrap("native"). Explicit seams: unwrap_creases() (Pavlovich cvqoVUX5aBw). Painter islands: uvmaster_roundtrip(). Eyes get their own simple UVs. GATE: uv_verdict on the Txr export; seams hidden, face uncut.
  7. Maps. mme_create_all(dir, name, settings="arnold") or bake_normal_map / bake_displacement_map. GATE: map_verdict per file (size, bits, flat at Mid, normal flat at 128/128/255: Polycount); orientation calibrated once per build (live_rx_04 fixture).
  8. Scale, names, export. set_export_scale(180, axis=1) (every SubTool); set_export_switches(quads=True, uvs=True, groups=False); export_subtools(dir, level="lowest"). FBX: ZScript could not suppress its options window, Python is unverified (Maxon forum), AppleScript System Events clicks ZBrush dialogs on this Mac [verify for it]; default: OBJ, converted by scenario-maya-expert or scenario-blender-expert. GATE: obj_header reads the Export Scale back from #Auto scale (written by 2026.2.1, lead v03 export); scale_report passes (1 percent, feet at 0, centered).
  9. Handoff. handoff_report(json, ...): meshes, maps, unit, axis, Mid and scale, normal convention, audits, sheets, not-verified list.

Generated or scanned mesh (Z6)

  1. GLB from scenario-3d: glb_to_obj, triage_ai_mesh(obj, expected_parts), then orient_for_import: it rotates only a Z-up hint, centers only an off-center mesh (glTF is Y-up) and names the file to import.
  2. import_mesh (Weld; Tri2Quad rebuilds quads from AI triangles [verify range]); raw ZTL; duplicate_keep(): the copy is the form and color source, tracked by name (index_of), since splits and merges renumber SubTools.
  3. fix_mesh; split_parts, hide_small_parts(protect_names=[raw]). Fused parts of different colors: color_to_groups, Groups Split [added]; else scenario-3d segmentation or human SliceCurve cuts.
  4. close_holes per part. Swiss cheese after DynaMesh means too thin: thicken first.
  5. merge_down_run the parts of one body inside the Tool (Drust 00:09:27). dynamesh_keep(res, keep="groups"): with polypaint on, DynaMesh keeps paint INSTEAD of polygroups (DynaMesh doc; general rule: scenario-zbrush-sculpting). Blur 0, Project off. Residual holes: hole_plugs(obj) on an export, plug_hole(center, diameter), merge_into(body, [plug]), re-DynaMesh (Drust PrFQXjs_6_w 00:07:16). Polish; Mirror And Weld if symmetric. Local rebuilds: Sculptris Pro (scenario-zbrush-sculpting).
  6. GATE: cleanup_verdict(raw, clean) (closed, manifold, shells, bbox 2 percent, roughness at most 0.8x raw [added]); MatCap Gray sheet, Double on, Colorize off for the sheet only (set_display returns the old value).
  7. ZRemesher per part (5 to 20 thousand), snap back, project geometry from the cleaned DynaMesh, then color only from the raw copy: project_levels(..., geometry=False, color_last=True) [verify]. Hand to scenario-zbrush-sculpting (big changes at SDiv 1, secondary at 2 to 3).

Numbers

Value Relative to Source
ZRemesher target in thousands; 5 gave 4,963 Target Polygons Count Pablo rArw79xEpvE 00:03:16
Adaptive Size 50 default; 0 even quads, 5 to 21 curvy; hand about 14 density ZRemesher doc; Pavlovich n5
Color Density 2 = more (pink), 0.25 = fewer (blue), white neutral polypaint density Pavlovich n5 01:01:32
XYZ Size 1 to 4; imports normalized to 2 internal size Gallagher; Drust
Export Scale = target / internal extent (180 / 2 = 90) units Drust; Gallagher 00:28:55

Per-target settings

Target Mesh Displacement Normal
Maya + Arnold OBJ level 1, Qud, Txr, Grp off; cm 32-bit EXR, Mid 0.5 = Scalar Zero Value, Catmull-Clark about 3, Auto Bump tangent, OpenGL [added]
Unreal, Unity triangulated, FBX via Maya or Blender; cm, meters [added] none engine tangent basis; Unreal DirectX green [added]
Substance Painter Substance Bridge Low & High, Auto-Bake, Force Auto-Unwrap off none baked by Painter
Blender OBJ; meters 32-bit EXR, Midlevel = Mid [added] OpenGL; game bakes with scenario-blender-uv-baking

Redshift, vector displacement and full rows: rx.RENDERERS, references/procedures.md.

Quality gates

  • Code, on every exported file: topo_report, projection_verdict, uv_report, scale_report, map_verdict, names_report, cleanup_verdict.
  • Visual (zb_review.review sheets): Polyframe front, side and three-quarter for loops; source and target at one view after projection, plus danger-zone close-ups at every level; MatCap Gray with Double on for cleanup. Judge with references/critique.md.

Common mistakes

Mistake Looks like Fix
Symmetry left on posed limbs come out mirrored zremesh(symmetric=False)
Remeshing a smiling face loops follow the smile, not the rig neutral first, expression back on the new mesh
Retry sweep left as is kept mesh is not the chosen one pick_retry, retry back, confirm_retry
Dist too small holes of detail, speckles Dist 0.1; engulf the source
Smoothing projection spikes rogue vertices stay morph target, masked reprojection
Colorize off on one side projected color missing color_last=True turns both on
Morph target left at bake map against the wrong base [verify] drop_morph_target()
SDK normal bake on defaults world-space map create_normal_map(..., local_coordinates=True)
OBJ Grp on for Maya mesh split in parts Grp off (FlippedNormals 00:15:08)
Export Scale on one SubTool only eyes float off the head set_export_scale(..., all_subtools=True)
Unify on a character with layers layers and morphs break MultiAppend into a scaled tool (Gallagher 00:27:16)
Polypaint on at an AI-mesh DynaMesh part polygroups gone dynamesh_keep(keep="groups")
MergeVisible during cleanup new Merged_ Tool without the color source merge_down_run in the Tool

Handoffs

  • Receives from scenario-zbrush-sculpting, scenario-zbrush-character-creature, scenario-zbrush-stylized, scenario-zbrush-hard-surface: a versioned ZTL with named SubTools, stats() and the last sheet; from scenario-3d: GLB or OBJ.
  • Delivers to scenario-maya-retopology-uv and scenario-maya-rigging: level-1 OBJ per SubTool, cm, Y-up, centered, feet at 0, eyes separate; to scenario-maya-lookdev: maps plus handoff_report; to scenario-blender-uv-baking or Substance: low and decimated high with matching names; to scenario-zbrush-paint-render: the stack with UVs; to scenario-zbrush-pose-print: a closed copy.

ZBrush 2026 notes

  • Retopo brushes (2026.1); UV Master with Polygroups fixed in 2026.2.0; Substance Bridge (2026.2) labels "Auto-Bake", "Force Auto-Unwrap" [uistr]: Force Auto-Unwrap is global and strips good UVs.
  • SDK: press() takes no modifier (Alt-clicks go to computer use); create_normal_map() defaults to world space.
  • macOS: Control+W makes a polygroup; Command+W quits ZBrush (version deltas 3.13).
  • A partial ZRemesh with Freeze Border deletes the subdivision levels (Drust 0TlG4Ex9lQA 00:02:26): freeze_levels() around it.

References

  • references/procedures.md: full bridge procedures (Z4, Z6, maps per target, fixtures), test paths, path evidence. Load before running a stage.
  • references/expert-notes.md: judgment by expert with timestamps and the deciding conditions. Load when choosing between routes.
  • references/critique.md: the review rubric (loops, projection, UVs, maps, scale, cleanup). Load at every gate.
  • references/gui-paths.md: palettes, hotkeys and Retopo brush gestures for a human or computer-use agent.
  • references/sources.md: every source with credential, URL and best timestamps.
Files (skills)
  • references
    • critique.md 11.8 KB
      # Critique rubric: what the experts look at
      
      Use at every gate. Each section has the numbers (from the `rx` checks on exported files) and the look (from `zb_review.review` sheets). A stage passes when every "must" holds; "watch" items go into the report. Thresholds marked [added] are this skill's defaults, meant to be overridden by the brief.
      
      How to take the sheets:
      
      - Loops: `rx.zcall("set_polyframe", True)`, `zb_review.review(dir, views=("front", "right", "threequarter", "back"))`, then Polyframe off. Groups: the same with the lines off if the Line switch is reachable [verify]; else judge group colors in the default Polyframe.
      - Surfaces: MatCap Gray (the review default). Cleanup: also `rx.zcall("set_display", double=True)` and once with Double off to reveal back faces.
      - Comparison views: identical transform for both states (the review uses fixed view triples), one sheet per state, side by side.
      
      ## 1. Low mesh topology (after ZRemesher or a manual pass)
      
      Must (numbers: `rx.topo_report` + `rx.retopo_verdict`):
      
      - Face count inside `expected_band` of the target, or the budget (game: `budget_tris`).
      - Deforming mesh (rig, film): quads at least 98 percent [added], zero n-gons (ZBrush triangulates n-gons and blend shapes break: scenario-maya-retopology-uv), no non-manifold or flipped edges.
      - Holes only where intended (eyes, mouth, neck cut): `boundary_loops` count = expected.
      - One shell per SubTool unless parts are separate on purpose.
      - Symmetric subjects: mirror match at least 99 percent [added] and a center line in one piece (Mirror And Weld repairs it: Pavlovich n5 00:41:14).
      - Ring groups (when groups were exported with Grp on): `ring_ok` true for eye and mouth rings, rows as the brief (game head 3 to 4 loops around each opening, film 5 to 8 [added, generalist defaults]).
      - Shrink ratio after ZRemesher recorded; when `snap_back` is true, the level-1 snap-back pass (`project_stack(..., levels=0)`) ran before this judgment (Pavlovich n5 00:22:05, 01:03:12).
      - The mesh in the scene is the Retry variant that was chosen: `confirm_retry(pick, faces_now)` true (the sweep otherwise leaves its last variant).
      - Hole count uses `openings_closed`: 0 after DynaMesh or Close Holes, else the intended openings.
      - Ears and hands remeshed apart and merged back with Weld: no boundary loop left along the seams.
      
      Look (Polyframe sheet, front, side, three-quarter; close-ups of eyes, mouth corners, nostrils, ears, nape):
      
      - Concentric loops around eyes and mouth, "just concentric edge rings around the eyes and mouth so that they animate" (Pavlovich n5 00:06:32). The outer mouth loop continues up the nasolabial fold [added].
      - Loops perpendicular to bend axes at elbows, knees, wrists, fingers; closed rings on limbs, no spirals (a closed guide curve cures them: ZRemesher doc Spirals).
      - Poles (valence 3 and 5) between rings and out of deforming areas; mouth corners carry continuous lip loops; a fan at a mouth corner is a delete-and-rebuild, not a fix (Pavlovich n5 00:19:04 to 00:22:40).
      - Even polygon size across the skull; density raised where curvature needs it (hands, face) and lower on broad areas (Pavlovich n5 01:02:38).
      - Hard surface: loops held along hard edges, no polygons bridging a crease (Drust IpTYcGxxsdM 00:03:16).
      - "Only your eyes can tell you which topology will better suit your needs" (ZRemesher doc): compare Retry variants on the same view before choosing.
      - Face that will animate: the input was neutral (no smile, folds relaxed), so the loops follow the rig, not the expression (Pavlovich n5 00:01:39 to 00:02:46).
      - Center line at nape and brow: if it wanders, request the Alt+ZRemesher variant from computer use and compare both sheets plus `centre_line_components` and `symmetry_x_pct` (Pavlovich n5 00:48:31 to 00:49:05).
      
      Common verdicts: ragged loops around group borders = borders not smoothed (`polish_band("groups")`); mirrored pose = symmetry was on; eaten fingers = Adaptive Size too low or no density (Pavlovich n5 01:00:48).
      
      ## 2. Projection (rebuilt subdivision stack)
      
      Must (`rx.projection_from_stack(ps)` or `rx.projection_verdict` on exports):
      
      - Level-1 point count unchanged by projection.
      - Top-level bbox within 0.5 percent of the source per axis [added from the retopology digest checklist]; volume within 2 percent [added].
      - Top level holds at least half the source points, or the detail is softer (Drust R2MzFqWMaWY 00:02:16).
      - Only source and target were visible during every Project All (the `show_only` result; `exclusive=True` in `zb_ops.project_all`).
      - Every pass gate clean: `ps["pass_problems"]` empty (point count kept, no vertex outside the visible bbox); a versioned checkpoint ZTL exists before each pass.
      - Color pass: Colorize was on for source and target (`colorize_was` recorded), polypaint projected on the last level only (Drust PrFQXjs_6_w 00:16:35, _ips3GhWI0s 00:05:57).
      - Before any bake: no morph target left (`morph_target_present()` false after `drop_morph_target()`).
      
      Look (same view, source alone, then target alone at its top level; then both visible):
      
      - No spikes, no speckled holes of detail (Dist too small: Drust nxMYYsyJt3o 00:02:19).
      - Crisp edges and pores kept; smeared areas where the target sat under the source (Drust 00:03:57: toggle both visible to see where).
      - Step down the levels: level 1 clean and even, each level adds detail without pinching (Gaboury 34D6VljTjBs 00:05:24).
      - Danger zones at every level, zoomed: eyes, mouth, armpits, between the fingers, crotch (FlippedNormals Zp07GW3rND0 00:07:10). Anything busted there becomes a hard line in the displacement "down the pipe" (00:07:43): fix it before maps.
      - Eyelids kept their pre-projection shape where the eye-interior mask was (the mask state cannot be read: this view is the check).
      - Repairs were made with the morph target or a masked reprojection, not smoothing ("you're never gonna be able to smooth these kind of things out properly", 00:06:36).
      - For film: the low level plus displacement rendered (BPR with the map applied, or the renderer) matches the sculpt at grazing light [added from FlippedNormals -ThBTEc8L_M 00:20:35].
      
      ## 3. UVs
      
      Must (`rx.uv_report` + `rx.uv_verdict` on a Txr export at level 1):
      
      - UVs present on every face; inside 0 to 1, or exactly the planned UDIM tiles.
      - Overlap under 0.5 percent of used texels [added] (the raster counts shared border texels); mirrored stacking only when planned (Polycount: offset copies by whole tiles for bakes).
      - No flipped-winding islands unless mirrored on purpose.
      - Texel density spread (`texel_density_cv`) recorded; games often want it even [added], film per UDIM plan.
      - Polygons under 150k at level 1 when UV Master was used (UV Master doc).
      
      Look (New From UV Check texture on the model, Morph UV or Flatten sheet; Check Seams front and back):
      
      - No red in New From UV Check (overlaps) (Pavlovich cvqoVUX5aBw 00:01:06).
      - Readable islands: head, hands, feet recognizable (Gaboury cemmalvugFk 00:05:05); no seam across the face or between the eyes (UV Master doc Tutorial).
      - Seams "where the camera is not going to see it" (Pavlovich 00:06:00): inner arms, under the chin line, behind the ears, the back.
      - Hands and tails flattened without twisting (Pavlovich 00:04:23).
      - Checker squares even, not stretched (UV Master doc, Tutorial conclusion).
      
      ## 4. Maps
      
      Must (`rx.map_info` + `rx.map_verdict` per file; one set per UDIM tile):
      
      - Resolution equals the map size; one file per map type per tile; tile numbers from 1001.
      - Displacement: 32-bit float EXR, one channel (3 Channels off), flat areas at Mid (0 or 0.5 as recorded).
      - Normal: 8-bit RGB after a 16-bit bake, flat areas near (128, 128, 255) (Polycount); tangent space for anything that deforms (MME doc); world space only for static objects.
      - Orientation: the live_rx_04 fixture decided Flip V and the green convention for this build; the handoff states the convention (OpenGL or DirectX) and FlipG.
      - No NaN pixels.
      
      Look (open the files, and apply them back when possible):
      
      - Frequency as intended: level-1 displacement shows forms and wrinkles, a max-1 normal shows only pores and stitches (Drust 2zDAtaQqwh8 00:03:27).
      - A gray displacement at 0.5 with the face readable (FlippedNormals 00:02:27); black maps are Mid 0 (expected) or a failed bake.
      - Seams: no step where UV islands meet; the Map Border was high enough (FlippedNormals 00:05:41).
      - In the renderer: no swelling (zero value mismatch), no stair steps (DpSubPix or subdivision iterations too low), no seams at grazing light.
      
      ## 5. Scale, axes, names, files
      
      Must:
      
      - `rx.obj_header`: `#Auto scale` equals the Export Scale set, on every file; `#Auto offset` as planned.
      - `rx.scale_report`: size within 1 percent of the brief in destination units; lowest point at 0 when feet go on the ground; centered in X and Z [added tolerances].
      - All SubTools registered: union bbox of every exported part matches, eyes sit in their sockets (the same Export Scale and offsets on every SubTool).
      - `rx.names_report`: unique, alphanumeric or underscore names (GoZ, Decimation Master).
      - Grp off for Maya deliveries (one group per file); Qud for rigging, Tri for a baked game mesh; Txr on when UVs exist.
      - Internal XYZ Size stayed in 1 to 4 (Gallagher).
      
      Look:
      
      - A primitive toggled against the model occupies the same box in ZBrush (Gallagher 00:30:32); in the destination app, a reference box of the intended size (receiving skill).
      
      ## 6. Cleaned generated or scanned mesh (Z6)
      
      Must (`rx.cleanup_verdict(raw, clean)` + `rx.triage_ai_mesh` on the result):
      
      - No holes (unless an opening is designed), no non-manifold or flipped edges, `solid` true.
      - Shells = the intended parts; junk gone or hidden.
      - Silhouette kept: bbox per axis within 2 percent of the raw mesh (5 percent while parts are still merged and polished) [added]; volume change under 5 percent [added].
      - Surface noise down: `roughness_index` at most 0.8 x the raw value at similar density [added].
      - Symmetric designs: mirror match at least 99 percent after Mirror And Weld.
      - Part groups survived the DynaMesh: a Grp-on QA export (`obj_header(...)["group_lines"]`) still shows more than one group when parts were grouped, i.e. `dynamesh_keep(keep="groups")` ran with polypaint off (DynaMesh doc, PolyGroups > Important).
      - The raw copy is still in the Tool, found by name (`index_of`), with its polypaint: it is the color source.
      
      Look (MatCap Gray, colorize off, Double on, then off; front, back, both sides, three-quarter, top and underside; Drust PrFQXjs_6_w 00:00:42, 00:03:26):
      
      - Silhouette matches or improves on the source from every view; nothing lost at thin parts (fingers, horns, tails).
      - Primary forms read without noise; lumps gone; transitions deliberate rather than mushy.
      - Former seams between fused parts smooth; parts that should be separate are separate SubTools with closed ends.
      - No floating junk, no back faces showing through the front.
      - Stepping through the rebuilt levels shows a clean progression (PrFQXjs_6_w 00:17:42).
      - Forms under a moving light: switch to a Standard material and move the light front, side and behind; a MatCap has its lighting baked in and hides planes (Pavlovich aR02CwyPTUw 00:04:07, 00:04:39). Red Wax exaggerates surface differences (00:03:32).
      - The rebuilt stack is ready for the sculptor's level discipline: big corrections at SDiv 1, secondary forms at SDiv 2 to 3 (Pablo rArw79xEpvE 00:12:28 to 00:14:16).
      - Then the forms themselves, judged with the sculpting skills' critique (big to small, planes, rhythm): this skill hands a clean base, not a finished design.
      
      ## 7. The report
      
      Must:
      
      - Every stage lists its numbers, the sheet path and pass or fail.
      - Anything done by a human or not done (face rings, Retopo passes, FBX options, GoZ first run, Painter) is named.
      - Values the receiver needs are written in `handoff_report`: unit, axis, Export Scale, displacement Mid and scale, normal convention, map list per tile.
      - Every snippet that has not run in ZBrush is labeled "not yet run in ZBrush".
      
    • expert-notes.md 30 KB
      # Expert notes: retopology, projection, UVs, maps, scale, export, cleanup
      
      The depth behind the SKILL.md stance, by expert, with source and timestamp. Distilled from `notes/retopology-uv/`, `notes/maps-export/`, `notes/pipeline/` (and their two digests); version facts from `sources/zbrush-version-deltas.md`. `[added]` marks this skill's own synthesis. Timestamps are `[hh:mm:ss]` in the named video.
      
      ## Joseph Drust (Pixologic, then Maxon; #AskZBrush presenter)
      
      ZRemesher control (0TlG4Ex9lQA, 8D-xqasgUws, IpTYcGxxsdM, Kjce71jjWJU, N1WABlp45ts):
      
      - ZRemesher silently obeys the scene state: only visible polygons are remeshed, and with symmetry on it remeshes half and mirrors it, "an implicit Mirror and Weld" [Kjce71jjWJU 00:01:11 to 00:02:07]. The posed Earthquake came out with both arms matching. Check symmetry before every run on posed, scanned or asymmetric input.
      - Polygroups are his preferred steering, over guide curves: "exactly the process I usually prefer" [IpTYcGxxsdM 00:04:02]. Clean borders come from Slice Curve or Edgeloop Masked Border (a mask becomes a group with support loops) [IpTYcGxxsdM 00:05:08].
      - Keep Groups hides a Smooth Groups slider at 1 that rounds group borders; 0 holds them exactly, 1 is right for ragged DynaMesh groups [8D-xqasgUws 00:02:17 to 00:03:25].
      - Curves from groups: Stroke > Curve Functions with Border and Creased off, Polygroups on, Frame Mesh, then Keep Groups OFF and Curve Strength 100 [8D-xqasgUws 00:03:41]. Curve Strength 50 is the default; 100 puts loops on the curves [IpTYcGxxsdM 00:02:56].
      - Iterate: aim low (Target 1, about 1,000 polygons, on a head), then Half, then fix the few badly served features by hand rather than raising the count [N1WABlp45ts 00:01:25 to 00:02:32].
      - Partial remesh: hide all but the region, Freeze Border on, ZRemesher: the patch connects cleanly, but the subdivision levels are removed [0TlG4Ex9lQA 00:01:51, 00:02:26].
      
      Reprojection (R2MzFqWMaWY, _ips3GhWI0s, nxMYYsyJt3o, NrsuP4Vj4Lg):
      
      - Duplicate, ZRemesh the copy, divide once, Project All with only the two visible, repeat per level until the top is near the source count [R2MzFqWMaWY 00:02:16 to 00:03:52; _ips3GhWI0s 00:04:53].
      - Dist 0.1: "usually 90% of the time just changing this distance slider here to 0.1 ... will often give you the result" [nxMYYsyJt3o 00:02:19].
      - StoreMT before projecting; remaining spikes mean the target sits under the source. Morph-brush them back, raise the target with a big ClayBuildup until it engulfs the source, reproject, or reproject masked areas only (faster) [nxMYYsyJt3o 00:02:52 to 00:07:11].
      - Geometry first, polypaint only at the last level with polypaint enabled on the target; answer No to the "no polypaint" note on geometry passes [_ips3GhWI0s 00:05:25 to 00:06:29].
      - Decimation gives triangles for print and bake files, never rig-ready quads [_ips3GhWI0s 00:00:35]. The level-1 ZRemesher mesh is also where UV Master can work [00:07:03].
      - Freeze SubDivision Levels at the top level unlocks ZModeler, DynaMesh and ZRemesher on a subdivided sculpt; pressing it again rebuilds and reprojects [NrsuP4Vj4Lg 00:01:17 to 00:02:26].
      
      Maps (n0qqpwvn-jA, 2zDAtaQqwh8, RYGkbSUaHHg, ADIRhNlvXbI):
      
      - ZBrush maps compare the selected level with the highest level of the same SubTool: "you can't have a subtool that is your low resolution version and then a subtool that is your high resolution version" [n0qqpwvn-jA 00:01:14].
      - The low level filters the content: level 1 against 6 gives wrinkles and forms, level 5 against 6 only pores and stitching [2zDAtaQqwh8 00:02:53 to 00:04:00].
      - Color ID from polygroups needs Unweld Groups Border on low poly meshes, or colors bleed across shared vertices [RYGkbSUaHHg 00:03:24].
      - HD Geometry exports only what Sculpt HD preview shows; isolate polygroups first; Duplicate in HD preview converts HD levels to regular levels [ADIRhNlvXbI 00:02:45 to 00:08:52].
      
      Scale (n2xPrwI9o1U, fiIVK6t-2x0, YrD29hiokJo):
      
      - Imports are normalized to XYZ Size 2; the real size moves into Export Scale. Exported value = XYZ Size x Export Scale: 2 x 63.5 = 127 [n2xPrwI9o1U 00:04:19]. Scale = target / XYZ Size: 254 / 3.5 = 72.57 [00:06:37].
      - Check in a text editor: read the OBJ vertex coordinates [n2xPrwI9o1U 00:07:45]. The agent parses the file (`scale_report`) and reads ZBrush's `#Auto scale` header line.
      - Known-feature sizing: a bar between the pupils at 63 mm, or a 1 inch cube, then resize everything with All on [fiIVK6t-2x0 00:03:27; YrD29hiokJo 00:04:16]. STL carries no unit: tell the printer [YrD29hiokJo 00:05:21].
      
      Decimation (mRfA_WDvvrg): Decimation Master drops polygroups; recover them with Unweld Groups Border, Freeze Borders, decimate, Auto Groups, Weld Points, then divide once to prove it is watertight [00:02:35 to 00:05:04].
      
      Scan and single-sided cleanup (PrFQXjs_6_w, udiPVJIODX0), the closest official recipe to Z6:
      
      - Inspect with MatCap Gray and colorize off; the diffuse hides holes [PrFQXjs_6_w 00:00:09]. Duplicate first: the untouched copy is the projection source for shape and color [00:01:15].
      - Try DynaMesh 512, Blur off, Project off. Swiss cheese means the shell is too thin, not a resolution problem: DynaMesh's close-holes "just doesn't have anything else to do but shoot straight across" [00:02:21 to 00:02:53].
      - Close Holes, isolate the fill polygroup, push it back to make a volume, DynaMesh again [00:03:26 to 00:07:16]. Plug remaining holes with Unified, embedded PolySpheres, MergeDown, re-DynaMesh [00:07:16 to 00:10:33].
      - Junk: group it, hide, Delete Hidden, re-DynaMesh [00:10:33]. Flat base: straight SliceCurve, hide below, Delete Hidden, re-DynaMesh [00:11:39].
      - Light working mesh: DynaMesh 128 kept the silhouette at about 50k points; divide to about 3M; Project All Dist 0.1 with colorize on for both [00:14:58 to 00:17:42].
      - Texture to geometry: Mask By Intensity, colorize off, Inflate a little [00:19:21]. BumpViewer shows polypaint as bump while sculpting [00:17:42].
      - Close Holes is global: fills that must stay open (a fabric edge, the back of a relief) are isolated by polygroup and deleted [udiPVJIODX0 00:04:30].
      
      ## Paul Gaboury (Pixologic, then Maxon; #AskZBrush)
      
      - "It's not really going to be possible to remesh with the remesher and maintain high fidelity detail, that's not what its goal is" [34D6VljTjBs 00:00:39]. On a 3M arm: 90k keeps a lot, 18k is "pretty good", 5k still enough [00:01:10 to 00:02:51].
      - Project History avoids the duplicate: remesh and divide in place, Ctrl-click the detailed undo state, Project History with Color off [34D6VljTjBs 00:04:20 to 00:05:24]. No SDK call sets the history point, so the agent uses the duplicate route.
      - ProjectionShell for wildly different shapes: drag it until only the target color shows (Inner turns on), Dist to the maximum 1, "like a fishing line" [pQbPtH0p5Bg 00:02:30 to 00:03:02].
      - UVs belong on the lowest level; UV Master's Work on Clone, Unwrap, Copy UVs, select the original, Paste UVs [cemmalvugFk 00:00:51, 00:04:00 to 00:07:28]. AUV, GUV and PUV tiles fill space but are unreadable for a painter [00:01:25 to 00:03:30].
      
      ## Ian Robinson (Maxon; #AskZBrush)
      
      - Decimation Master's Use and Keep Polypaint is off by default; enable it before pre-processing; presets pre-process and decimate in one click [bVX9utHc_ZI 00:01:17, 00:02:15].
      - Scale Master: New Bounding Box SubTool, Set Scene Scale, type the size, Resize Subtool with All, Export to Unit Scale [bICt_XruvQM]. The dialogs make it a human route; the agent does the Export Scale arithmetic.
      
      ## Michael Pavlovich (games sculptor: Halo 4, Call of Duty, Doom; Maxon ZBrushLIVE host)
      
      Topology, face and body (n5_cZK-9peg, ZBrush 2023):
      
      - ZRemesher gives even quads but not reliable loops at eyes, mouth, nose points and lip corners; past a point "it's easier to just build what I need to rather than trying to finagle ZRemesher" [00:06:00], so delete those regions and rebuild them [00:21:36, 00:22:40].
      - Give ZRemesher an easier problem: neutral expression, folds smoothed away, the smile sculpted back later on the new topology [00:01:39 to 00:02:46].
      - Concentric polygroup rings around eyes and mouth, most important areas first, then fill the blanks [00:06:32, 00:08:55].
      - Smooth the group borders before remeshing, because ZRemesher builds aliased borders into the flow: Shift with Weighted Smooth Mode 6 (Groups Border), or the button route Mask By Feature (Groups), Grow Mask, invert, Polish By Features [00:05:27, 00:07:48, 00:15:18]. `rx.polish_band` is the button route.
      - Smooth Groups 0 once borders are smooth; the default 1 "melts" the mesh [00:17:08].
      - Numbers he read on screen: Target 5, Adaptive Size 0 gave about 8k points, 21 gave 9,397, 5 gave 8,551 (7 s) on a grouped head of 169,089 points [frames 00:18:28 to 00:22:09]. Adaptive Size 0 hits the count and even quads but fails on tight turns [00:17:40, 00:18:13].
      - Store a projectable state before any topology change and project back after every ZRemesher, Smooth Groups, density or smoothing pass: they all shrink the surface [00:03:31, 00:22:05, 01:03:12]. `rx.zremesh` returns the shrink ratio.
      - Ears and hands remeshed as separate SubTools, their open borders polished first (Mask By Feature Border, Grow, invert, Polish By Features open circle), stitched back where deformation is low [00:44:36 to 00:47:31].
      - Freeze Border re-welds separately remeshed parts exactly, "probably not great for animation" [01:03:44 to 01:04:17]. [added] Remesh the whole limb to the wanted density first, then split and Freeze Border, or the frozen border keeps a dense DynaMesh ring.
      - Polypaint density read on screen: white = 1 (neutral), Color Density 2 picks pink for more polygons, 0.25 light blue for fewer; Colorize on to see it; start from a white fill [01:01:32 to 01:02:38]. This settles the ZRemesher doc's contradictory paragraphs.
      - Groups place loops, polypaint sets density: the arm went from 11,384 to 2,780 points with 2x density on the hand [frames 01:02:44, 01:03:29].
      - Alt+ZRemesher switches the midline algorithm; compare nape and brow at low targets (1k, 1.5k) with a snapshot [00:48:31, 00:49:05].
      - Project All reads every SubTool whose eye is on, even in Solo mode [01:07:26]. Project History needs no second SubTool [01:08:33].
      - DynaMesh and color (Masking Basics, 8kWFv1cZlCE, `notes/sculpting/`): his polypaint was lost because he re-DynaMeshed without Colorize on [00:20:06]. The DynaMesh doc states the other half: with polypaint on, DynaMesh keeps polypaint INSTEAD of polygroups ("PolyGroups > Important"). On an AI or scan mesh the choice is made on purpose: `dynamesh_keep(keep="groups")` keeps the part groups, and color returns by projection from the raw copy. scenario-zbrush-sculpting owns the general rule.
      - Materials (aR02CwyPTUw, `notes/sculpting/`): MatCaps bake their lighting; to judge whether forms read under changing light, use a Standard material and move the light front, side and behind [00:04:07, 00:04:39]; Red Wax "will really enhance differences between your surfaces" [00:03:32]. Used in the cleanup critique.
      - Borrowed topology: wrap a base head (DemoHead) with Dynamics shrink-wrap: Gravity off, Contract with C Amount 0.03 (head) or 0.29 (arm), CollisionVolume Resolution 291, Inflate 0, then Project All [00:54:08 to 00:55:14]; Wrap or ZWrap are faster third-party tools [00:52:23].
      - Camera-projection brushes (ZProject, HistoryRecall) need a straight-on view and symmetry off [00:57:02, 00:57:33].
      
      UVs with crease seams (cvqoVUX5aBw, ZBrush 2023):
      
      - Auto Seams makes one island with no overlap, fine when only maps matter [00:02:12]. Explicit seams: consolidate polygroups to the islands you want, Crease PG, Unwrap with Crease Edges [00:02:44 to 00:04:23]. Hands pelt-map and tails unwrap into circles until extra seams are creased with ZModeler Crease Shortest Path [00:04:23 to 00:08:11]. "Creases do not cut until you unwrap again" [00:08:11].
      - "You generally want to put your UV seams where the camera is not going to see it" [00:06:00].
      - Layout edits on a UV Master clone: Gizmo moves of isolated groups with symmetry off; islands outside 0 to 1 are rescaled on the next Flatten [00:08:11 to 00:09:51].
      
      ## Pablo Munoz Gomez (ZBrushGuides founder, Maxon ZBrushLIVE presenter, Retopo brush beta tester)
      
      The 2026 Retopo brush (I4nePXDTrhQ, ZBrush 2026.1.1):
      
      - It is "literally just a brush" that combines ZSphere topology, the Topology brush and ZModeler [00:00:38]. Its job: a clean quad mesh when ZRemesher's result is not what you want [00:03:01].
      - First click: Yes creates a new retopo SubTool (for a dense sculpt); No converts the current mesh, right for a light ZRemesher result that is "99 percent ready"; No on a dense mesh "is gonna be pretty heavy and it's gonna potentially crash" [00:05:59 to 00:07:09].
      - Points project onto every visible SubTool: leave the eyes visible to wrap head and eyes as one mesh [00:08:33].
      - "The only thing that you really need to remember when working with this brush is the spacebar": the normal size grabs and moves, the Space size connects points into polygons [00:12:46, 00:05:23].
      - Loops first, then fill; smooth (Shift, Smooth Retopo) only at the end [00:16:26, 00:19:13]. Patches (228 bundled) save hours on humanoids; stylized and exaggerated characters are done by hand [00:04:09, 00:29:03].
      - "A good topology is just whatever works for whatever you're doing" [00:32:18].
      - Pablo also sets the production order behind this skill: topology, UVs and export last (Understanding ZBrush's Logic), and ZRemesher 5 gave 4,963 [rArw79xEpvE 00:03:16].
      - Level discipline (Logic Part 7): big changes at SDiv 1, secondary forms at SDiv 2 or 3, then up; moving large forms or detailing only at the top level are two beginner mistakes [rArw79xEpvE 00:12:28 to 00:14:16]. The rebuilt stack is handed over with that rule.
      - Projection density (Logic Part 8): the target's top level needs at least the source's point count, "as long as it's more or slightly more than that, we will be able to recover all those details" (745k against a 400k DynaMesh) [VRisbJQAaZw 00:17:24]. Sculptris Pro adds resolution only where a small detail needs it, instead of raising a whole DynaMesh [00:10:41, 00:11:53]: the local-rebuild tool for a damaged region of a cleaned AI mesh.
      - MergeVisible creates a NEW tool and leaves the working tool intact; MergeDown merges inside the tool [gitoJ7B8FmY 00:03:31, 00:04:11]. Cleanup merges down (`merge_down_run`, `merge_into`).
      
      ## FlippedNormals (Henning Sanden and Morten Jaeger, ex-MPC and ex-Framestore film artists)
      
      ZBrush to Arnold, 32-bit displacement with UDIMs (-ThBTEc8L_M, ZBrush 4R8, Maya 2018):
      
      - A 2M sculpt cannot be rigged; the low mesh plus a displacement map carries the detail [00:03:00]. MME does every SubTool and UDIM unattended [00:04:09].
      - Most switches are wrong by default for film: Flip V on ("an absolute must") [00:06:13], Adaptive off ("the quality difference is so negligible"; 100 UDIMs in about 1 h versus 24 h) [00:08:12], DpSubPix 0 [00:08:45], SmoothUV on if the renderer smooths UVs [00:09:52], Mid 0.5 [00:10:01], 32Bit and exr on [00:10:34], 3Channels off [00:11:08], Map Border at maximum [00:05:41], SubDiv level 1 (the level the rig uses) [00:07:40].
      - Merge Maps could not output EXR in 2018; they give each object its own UDIM tiles instead [00:04:43, 00:11:08]. Later builds fixed MME merging with UDIMs (2021.7.1) [verify EXR plus Merge Maps on 2026].
      - UDIM file names with a dot after the DM suffix so the files form a sequence [00:13:50].
      - Switch MT was "currently broken" in 2018 [00:07:09]; the 2026 doc documents it [verify].
      - Export the low mesh with Grp off, or Maya splits it [00:15:08].
      - Arnold: File node Filter off, UV Tiling Mode UDIM (Mari); Catmull-Clark about 3 iterations; Auto Bump; zero value matched to Mid (the video description corrects the field to Scalar Zero Value) [00:17:29 to 00:20:35].
      - "Most times you have issues with displacement ... simply just caused by bad geometry in ZBrush" [00:21:41].
      
      Reprojecting details (Zp07GW3rND0, 2018; `notes/sculpting/`), the production view of Project All:
      
      - Routine work, done "all the time" whenever topology or reduction changes [00:00:03]. Subdivide the new low mesh to "a few million", about 3M [00:01:02].
      - Order at the highest level: Store MT, then a New Layer, then Project All: "It's important that you do all of this on the highest level" [00:02:13, 00:02:47]. PA Blur lowered for a clean source. Staged alternative when the shapes differ a lot: step down to level 1 and project upward level by level, fixing gross mismatches low [00:03:17, 00:03:49]. `project_stack(order="top_first")` is this order; the lead's `zb_ops.project_all` sets the same two nets.
      - Danger zones: parts that sit close together grab the wrong surface: eyes, mouth, armpits, between the fingers, the crotch [00:01:41, 00:07:10]; artifacts left there become hard lines in displacement maps "down the pipe" [00:07:43].
      - Eyes: keep a polygroup inside the eye, grow it to just outside the lid, mask by it, reproject [00:05:31, 00:06:03]. `protect_isolated` is the scripted half; isolating the group is a Ctrl+Shift click.
      - Repair with the Morph brush back to the stored target, not smoothing: "you're never gonna be able to smooth these kind of things out properly"; rogue vertices "go to infinity" [00:04:27, 00:06:36]. Local reprojection: mask, invert, project only there [00:09:27].
      - Do not take a concept sculpt to final micro detail before retopology: tight areas get destroyed and must be resculpted [00:02:13, 00:08:22]; a proper sculpting pass follows reprojection [00:08:55].
      
      ZBrush to Substance Painter (p56N-dN11zY, 2019):
      
      - For offline work, skip the low poly: Decimation Master 150k with Keep UVs, MergeVisible with UV on, paint the decimated high directly, no normal map [00:00:42 to 00:02:59].
      - Polygroups become texture sets (Export Polygroups as Mats) [00:03:46]; smooth normals on export or every baked map inherits faceting [00:06:03].
      - ID map from polypaint: MME Texture From Polypaint, SubTools off, Flip V on [00:09:45 to 00:10:51].
      - In 2026.2+ the Substance Bridge replaces the manual FBX route for most cases (version deltas); the decisions (UVs kept, polygroups as sets, smooth normals, ID from polypaint) still apply.
      
      ## Laura Gallagher (Outgang; former Lead Character Artist at Eidos Montreal, Guerrilla Games)
      
      Units and scale (EXjfH_X2hkM, ZBrush 2021):
      
      - "ZBrush doesn't really have internally a concept of centimeters or inches or meters, it's just units" [00:04:27]. Every primitive fits a 2 x 2 x 2 box [00:07:21].
      - Two scale problems: exported size (Tool > Export > Scale) and internal size (XYZ Size about 2) [00:38:11]. Keep internal size "within the same order of amplitude essentially as two": 1 to 4 are fine, 0.1, 10 or 20 are not [00:28:23 to 00:28:55].
      - DynaMesh resolution subdivides the scene's unit space, not the object [00:11:41 to 00:14:22].
      - Export Scale also drives the transpose-line readout: measure first, export second [00:19:46 to 00:22:27]. Rule of thumb: Export Scale about half the real longest dimension; a 180 cm character reads Export Scale 90 [00:26:16, 00:28:55]. Scale 0 means never set [00:24:03].
      - Offsets are internal units: Y Offset 1 stands a 2-unit model on Maya's grid [00:33:04, 00:33:40].
      - Unify is fine for simple objects but can "screw up everything" with layers and morph targets; rescale a complex character by Save As then MultiAppend into a correctly scaled tool [00:27:16, 00:38:48 to 00:44:24].
      - Relying on Scale Master without understanding the model gets you into trouble [00:45:17].
      
      ## Maxon documentation (help.maxon.net, 2026 help)
      
      - ZRemesher: prepare the input (a level that holds the shape, a decimated copy above about 8M vertices, Close Holes, Fix Mesh); Adaptive Size default 50; Adapt off to hit the count; Curve Strength "the higher you set this slider, the fewer Curves you should draw"; Fast Retry keeps its cache for target, Adapt, Keep Polypaint, Detect Edges, Freeze Groups, Freeze Borders, Adaptive Size, curves, Use Polypaint and symmetry, and discards it for any sculpt, Smooth Groups, Keep Creases or Keep Groups change; retopology over retopology improves flow; Legacy mode "has been removed" per the 4.0 page, while the reference page and the 2026.2.1 UI strings still list it.
      - Retopo brush (2026.1): manual topology is for deforming areas with prescribed loops; a 100k+ head needs about 10,000 to 20,000 polygons for animation; "A common mistake is creating retopology that's still too dense to be useful"; hide the reference and look for triangles and broken loops.
      - Projection and subdivision: Project All works between different topologies "broadly similar in shape", best with only source and target visible; Dist maximum 1; positive ProjectionShell turns Inner on; Close Holes only without levels; Divide adds a level only when nothing is masked or hidden; Reproject Higher Subdiv from three or more levels below the top.
      - UV Master: a creator, not an editor: "Seams can be attracted, restricted, but not exactly placed"; 100k to 150k polygons; Work on Clone whenever more than one click; Protect counts from 70 percent intensity; a closed protected ring forces a seam; control maps bind to the Tool name; Flatten cannot make multi-tile UVs.
      - Multi Map Exporter: Mid 0.5 for 16-bit, "for 32 bit maps best results are with a setting of 0", Scale 1 for 32-bit; Get Scale and the lower value for 16-bit; Tangent for anything animated; back up before running (ESC can lose UVs); Merge Maps needs equal sizes.
      - Decimation Master: options, pre-process, decimate; edits after pre-processing are ignored; masks move polygons, not the count; polygroups are not kept; Keep UVs costs 50 percent more memory; unique SubTool names.
      - Export, GoZ, FBX, Substance Bridge: GoZ needs unique names across all loaded Tools and sends the lowest level; FBX axis presets with "If in doubt then MayaYUp"; Substance Bridge Low & High plus Auto-Bake, and "Force UV Auto-Unwrap is a global setting"; Scale Master: "Subtools function best in ZBrush when their XYZ Size is set to 2"; SubTool Master ScaleOffset resets Export Scale and offsets for all SubTools (so they are per-SubTool values).
      
      ## Polycount wiki (game-art practitioners)
      
      - Bake from high to low with rays from outside; decimate ZBrush highs first: "Sculpting tools like Zbrush create triangles smaller than the bake pixels" (Baking Workflow 8).
      - Triangulate the low poly before baking and keep that triangulation in the engine; split UVs at every hard edge; explode or bake-group intersecting parts; do not rotate or mirror UVs after baking.
      - Flat normal color is 128, 128, 255; bake 16-bit, reduce to 8-bit last; re-normalize after edits; displacement needs 16 or 32-bit.
      - "It is best to assume all tutorials are incorrect, until you can verify the results yourself": the reason for the live_rx_04 calibration fixture.
      
      ## Disagreements and the deciding condition
      
      | Topic                         | Position A                                                  | Position B                                                                                             | Decide by                                                                                                                           |
      | ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
      | 32-bit displacement Mid       | MME doc: Mid 0, Scale 1                                     | FlippedNormals: Mid 0.5, renderer zero 0.5                                                             | either, if the renderer zero value matches and the handoff records it; 0.5 keeps maps readable and layerable                        |
      | ZRemesher steering            | Drust, Pavlovich: polygroups                                | ZRemesher doc: curves at strength 100, fewer at high strength                                          | agent: polygroups (buttons); hard surface: Group By Normals + Keep Groups + Frame Mesh; exact organic loops: human curves or Retopo |
      | ZRemesher target              | Gaboury: as low as the silhouette allows                    | ZRemesher doc: not low for hard surface; Retopo doc: 10k to 20k animated head                          | sculpt base low; hard surface higher; animation mesh from the rig and budget                                                        |
      | Adapt                         | doc: on for quality                                         | Drust: off for Half passes                                                                             | off when the count must be predictable (budgets, halving); on for shape                                                             |
      | Detail transfer               | duplicate + Project All per level (doc, Drust)              | Freeze Subdivision Levels (Drust, doc) or Project History (Gaboury, Pavlovich)                         | agent: duplicate route (no SDK call for the history point); existing stack with base edits: Freeze                                  |
      | Project Dist                  | 0.1 (Drust)                                                 | 1 with ProjectionShell and Inner (Gaboury)                                                             | same shape, new topology: 0.1; very different shapes: shell + Inner + 1                                                             |
      | Decimate or ZRemesh           | Drust: decimation for print and bake files, never rigging   | FlippedNormals: decimated high straight to Painter for film                                            | realtime or rigging: ZRemesher + maps; offline texturing, print, previews: decimate                                                 |
      | Seams                         | UV Master attracts and protects, cannot place exactly (doc) | Pavlovich: explicit creases                                                                            | games and 2D painting: creases; organic speed: UV Master with polygroups and AO attract                                             |
      | Relaxing UVs                  | Drust: Polish a flattened clone with borders masked         | UV Master doc: smoothing distorts UVs against the geometry                                             | mask borders, check with a checker, prefer re-unwrapping with existing seams                                                        |
      | Painter handoff               | FlippedNormals 2019: decimate, merge, FBX                   | Substance Bridge 2026.2: Low & High, Auto-Bake                                                         | ZBrush 2026.2+ with a real low poly: Bridge; film high-poly painting: the decimate route                                            |
      | Scale fixing                  | Gallagher: the arithmetic, not Scale Master blindly         | Robinson, Drust: Scale Master                                                                          | scripts: arithmetic (no dialogs); humans and print: Scale Master; complex mis-scaled tool: MultiAppend                              |
      | Manual topology tool          | Pavlovich 2023: ZSphere topology, Topology brush, ZModeler  | Pablo 2026: Retopo brush                                                                               | 2026.1+: Retopo brush; Topology brush for a quick strip                                                                             |
      | Smooth Groups                 | 0 on pre-smoothed borders (Pavlovich)                       | 1 for ragged DynaMesh groups (Drust)                                                                   | pre-smoothed: 0; ragged and no time: 1, then project back                                                                           |
      | Symmetry while retopologizing | Pavlovich: X symmetry on throughout                         | Pablo: optional, Mirror And Weld at the end                                                            | symmetric volume: either; posed or asymmetric: off, never Mirror And Weld                                                           |
      | Projection order              | Drust: divide once, project, repeat per level               | FlippedNormals: divide to about 3M first, nets at the top, project in one go or staged from level 1 up | default per_level; top_first when zones need a mask (masks block Divide) or one layer should hold the whole projection              |
      | Top-level density             | Pablo: at least the source's points                         | Drust: divide until close to the source; digest: within a factor of about 2                            | `top_ratio=1.0` for full detail capture; 0.5 when memory is the limit, reported as softer detail                                    |
      | Hole plugs                    | Drust: PolySpheres placed by hand, MergeDown, re-DynaMesh   | Close Holes and decide per fill group (scan-cleanup agent translation)                                 | Close Holes first; plugs where a fill fails, placed from the OBJ's boundary loops (`hole_plugs`, `plug_hole`) [added]               |
      
      ## What an agent can and cannot do here [added]
      
      - Deterministic and strong: every ZRemesher option, Retry sweeps, visibility control, projection loops, native and UV Master unwraps, Crease PG seams, MME and Tool palette bakes, Export Scale and offsets, OBJ exports, Decimation Master, Close Holes, Fix Mesh, Split To Parts, MergeVisible, DynaMesh, Polish, Mirror And Weld, Polygroups From Polypaint, Mask By Intensity + Inflate, and every check on the files.
      - New since the Z4 and Z6 grades [added]: Retry sweeps that end on the chosen variant (`pick_retry`), level-1 snap-backs, color passes with Colorize on both sides, DynaMesh that keeps groups on purpose, in-Tool merges by name (`merge_into`), plugs placed from the file (`hole_plugs`), masks for danger zones once a zone is isolated (`protect_isolated`).
      - Weak or human: ZRemesherGuide curves, lasso and pen masks for face rings, isolating a polygroup (Ctrl+Shift click) before `protect_isolated`, neutralizing an expression (scenario-zbrush-character-creature), Retopo brush gestures and patches, ZModeler Crease Shortest Path and edge fixes, Protect and Attract painting, Transpose pushes (thickening a fill), PolySphere plug placement, Undo History Ctrl-clicks, Alt-clicks (alternative midline), FBX options, GoZ's first app chooser, anything inside Painter.
      - The honest report names which of the weak steps the brief needed and who should do them.
      
    • gui-paths.md 35.7 KB
      # GUI paths, hotkeys and gestures (for a human or a computer-use agent)
      
      The same procedures as `procedures.md`, as a person works them in ZBrush 2026.2.1 on macOS. Palette paths read Palette > Sub-palette > Item. Hotkeys come from the sources (recorded on Windows); on macOS the docs' "Ctrl" usually means Command, but Control+W (not Command+W) makes a polygroup and Command+W QUITS ZBrush since 2023.0.1 (version deltas 3.13): never send Command+W. Map any other modifier per shortcut before relying on it [verify].
      
      Item labels checked in the 2026.2.1 resources are marked [uistr] (UI strings) or [cmdxml] (command list); the rest come from the videos and docs.
      
      ## Display and navigation for topology review
      
      | Action                                   | GUI                                                                    | Hotkey                                                   |
      | ---------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------- |
      | Polyframe (loops and group colors)       | Transform > PolyF (id " Pf" [cmdxml])                                  | Shift+F                                                  |
      | Solo the active SubTool                  | Transform > Solo                                                       |                                                          |
      | Transparency / Ghost                     | Transform > Transp, Ghost                                              |                                                          |
      | Double-sided display (holes, back faces) | Tool > Display Properties > Double; Flip for inverted normals [cmdxml] |                                                          |
      | Neutral material                         | Material > MatCap Gray                                                 |                                                          |
      | Snapshot for side-by-side compare        | Transform > Snapshot                                                   | Shift+S (clear with Ctrl+N)                              |
      | Symmetry                                 | Transform > Activate Symmetry, >X<                                     | X                                                        |
      | Isolate a polygroup                      |                                                                        | Ctrl+Shift+click the group (again to hide it)            |
      | Selection rectangle / hide               |                                                                        | Ctrl+Shift drag (Space moves it), add Alt to hide        |
      | Show all                                 |                                                                        | Ctrl+Shift+click empty canvas                            |
      | Grow / shrink visibility                 | Tool > Visibility > Grow, Shrink [cmdxml]                              | Ctrl+Shift+X, Ctrl+Shift+S (Pavlovich; [verify] on 2026) |
      
      ## Polygroups and border smoothing
      
      | Action                           | GUI or gesture                                                                                                                                               | Source                     |
      | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- |
      | Group from mask or visible       | mask, then Control+W                                                                                                                                         | Pavlovich n5 00:04:52      |
      | Mask a ring                      | MaskPen, Ctrl+Alt tap on the model = Sharpen Mask, Ctrl tap = Blur Mask                                                                                      | Pavlovich n5 00:04:20      |
      | Fill a masked ring               | Tool > Masking > Mask Region: Analyze Region, Fill Region                                                                                                    | Pavlovich n5 00:04:52      |
      | Straight group borders           | Ctrl+Shift, pick SliceCurve, draw; Alt tap once bends, twice straightens; Space moves                                                                        | Pavlovich n5 00:13:02      |
      | Groups per shell                 | Tool > Polygroups > Auto Groups [cmdxml]                                                                                                                     |                            |
      | Hard-edge groups                 | Tool > Polygroups > Groups By Normals, MaxAngle (45 isolated cylinder caps) [cmdxml]                                                                         | Pavlovich n5 01:05:34      |
      | Groups from color                | Tool > Polygroups > From Polypaint, PTolerance [cmdxml]                                                                                                      |                            |
      | Merge strays                     | Tool > Polygroups > Merge Stray Groups, Merge Similar Groups [cmdxml]                                                                                        | Pavlovich n5 00:14:45      |
      | Mask to group with support loops | Tool > Geometry > Edge Loop > Edgeloop Masked Border [cmdxml]                                                                                                | Drust IpTYcGxxsdM 00:05:08 |
      | Smooth group borders (brush)     | hold Shift, Brush > Smooth Brush Modifiers > Weighted Smooth Mode 6, stroke along borders                                                                    | Pavlovich n5 00:05:27      |
      | Smooth group borders (buttons)   | Tool > Masking > Mask By Feature (Groups on, Border and Crease off), GrowMask, Inverse; Tool > Deformation > Polish By Features (closed circle keeps volume) | Pavlovich n5 00:15:18      |
      | Masks through thin ears          | Ctrl, Brush > Auto Masking > BackfaceMask                                                                                                                    | Pavlovich n5 00:11:13      |
      | Paint groups with ZModeler       | B Z M, Alt-paint polygons, tap Shift to pick a group                                                                                                         | Pavlovich n5 00:11:16      |
      
      ## ZRemesher (Tool > Geometry > ZRemesher)
      
      Items [cmdxml] [uistr]: ZRemesher, Retry, Legacy (2018), FreezeBorder, FreezeGroups, SmoothGroups (slider next to KeepGroups, default 1), KeepGroups, KeepCreases, DetectEdges, Target Polygons Count (thousands), Half, Same, Double, Adapt, AdaptiveSize (default 50), Curves Strength (default 50), Use Polypaint, ColorDensity, KeepPolypaint.
      
      | Action                                  | GUI or gesture                                                                                                                                                     | Source                               |
      | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ |
      | Type a target                           | turn Same off, type Target Polygons Count                                                                                                                          | Pavlovich n5 00:16:26                |
      | Alternative midline algorithm           | Alt+click ZRemesher                                                                                                                                                | ZRemesher doc; Pavlovich n5 00:48:31 |
      | Guide curves                            | ZRemesherGuide brush (Brush palette, filter Z); Alt-draw across to delete one; closed curve around a limb against spirals                                          | Drust IpTYcGxxsdM 00:02:08; doc      |
      | Curves from groups                      | Stroke > Curve Functions: Border and Creased edges off, Polygroups on, Frame Mesh [cmdxml "Stroke:Frame Mesh"]; then KeepGroups off, Curves Strength 100           | Drust 8D-xqasgUws 00:03:41           |
      | Density by paint                        | Color > FillObject white first, Colorize on; Standard brush Rgb on, Zadd off; Color Density 2 (pink, more), 0.25 (blue, fewer); Use Polypaint on                   | Pavlovich n5 01:00:48 to 01:02:38    |
      | Partial remesh                          | hide all but the region, FreezeBorder on, ZRemesher (levels are deleted)                                                                                           | Drust 0TlG4Ex9lQA                    |
      | Compare variants                        | Retry after Retry-compatible changes, then scrub the Undo History bar; go back to the chosen one before moving on (the last Retry stays in the scene)              | ZRemesher 4.0 doc                    |
      | Alternative midline (computer-use step) | Shift+S snapshot of the normal result, undo, Alt+click ZRemesher at the same target (Pavlovich tried 1 and 1.5), compare nape and brow; Ctrl+N clears the snapshot | Pavlovich n5 00:48:05 to 00:49:05    |
      
      ## 2026 Retopo brush (B, R, E) and legacy manual tools
      
      Setup: select Retopo (228 patches on its icon in 2026.1.1), X for symmetry, first click on the model: Yes = new retopo SubTool (dense sculpt), No = convert the current light mesh (a ZRemesher result); Polyframe and Transparency turn on. Brush > Retopology: Adaptive Draw Size, Move by Draw Size, Focal Shift Adjust, Alternate Smooth, Snap Threshold (frame I4 00:14:32). Leave visible exactly the SubTools the new mesh should wrap (Pablo I4nePXDTrhQ 00:08:33).
      
      | Gesture                                                                                     | Effect                                                                        | Source                  |
      | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------- |
      | click                                                                                       | virtual point, projected on visible SubTools                                  | I4 00:07:56             |
      | hold Space, hover, click                                                                    | preview and create a polygon from the points inside the Space circle          | I4 00:08:33             |
      | right-click then Space                                                                      | reach the Space-mode Draw Size                                                | I4 00:10:21             |
      | Space, click-drag along a point line                                                        | a strip in one gesture                                                        | I4 00:11:38             |
      | drag a point, edge or polygon                                                               | move it (Move by Draw Size off = one point)                                   | I4 00:13:53             |
      | Alt-click                                                                                   | delete a point or polygon                                                     | I4 00:12:46, 00:15:58   |
      | Ctrl-drag in empty canvas                                                                   | clear stray virtual points and temporary geometry                             | I4 00:17:03; Retopo doc |
      | Alt-drag an edge; tap Alt while dragging                                                    | extrude the edge; toggle whole border (edgeloop, polyloop)                    | I4 00:16:30; doc        |
      | Alt-drag an edge, release Alt, Ctrl and drag sideways                                       | set the number of loops                                                       | Retopo doc              |
      | Alt-drag from a vertex                                                                      | extrude a quad from the point                                                 | I4 00:19:59             |
      | Space over an edge, drag across                                                             | insert one edge loop                                                          | I4 00:20:58             |
      | Space, click, release Space with the button down, drag sideways                             | several evenly spaced loops                                                   | I4 00:22:04             |
      | Alt-drag across a loop (red line)                                                           | delete the loop                                                               | I4 00:23:05             |
      | Alt-drag from a point, then Alt again                                                       | strip line, then a strip remembering the point count; click outside to accept | I4 00:25:32             |
      | IMM Viewer > Human patch: click pivot, Alt-drag, Alt on a corner, click outside, Space-drag | place, scale, skew, accept, average a patch                                   | I4 00:29:38 to 00:32:06 |
      | Shift                                                                                       | Smooth Retopo (at the end, not while building)                                | I4 00:18:42             |
      | Tool > Geometry > Modify Topology > Mirror And Weld                                         | rebuild a clean center line                                                   | I4 00:34:48             |
      
      Checks while working: hide the reference, look for triangles and broken loops (Retopo doc). Legacy routes: Topology brush (B, T, O; no subdivision levels, Draw Size 1 for zero thickness), ZSphere topology (Tool > Topology > Select Topo, about 15,000 points maximum, Clone the target first), ZModeler Topo (Pavlovich n5 00:25:05 to 00:41:14).
      
      ## Projection and the subdivision stack
      
      | Action                            | GUI                                                                                                                                                                                               | Source                                           |
      | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
      | Duplicate the source              | Tool > SubTool > Duplicate (Edit mode on)                                                                                                                                                         | Drust R2MzFqWMaWY                                |
      | Store a safety state              | Tool > Morph Target > StoreMT                                                                                                                                                                     | Drust nxMYYsyJt3o 00:02:52                       |
      | Project                           | Tool > SubTool > Project: Dist 0.1, Mean, PA Blur, ProjectionShell, Farthest, Outer, Inner, Project All                                                                                           | Drust; Gaboury                                   |
      | Divide                            | Tool > Geometry > Divide (Smt, Suv)                                                                                                                                                               | Ctrl+D                                           |
      | Project History                   | Undo History: Ctrl-click the detailed state, move the orange marker to the latest, Tool > SubTool > Project History (Color off)                                                                   | Gaboury 34D6VljTjBs 00:04:20                     |
      | Fix spikes                        | Morph brush (B M, pick Morph) over the spikes; engulf with a big ClayBuildup or Move; mask the rest, Ctrl-click canvas to invert, Project All                                                     | Drust nxMYYsyJt3o 00:03:25 to 00:07:11           |
      | Freeze levels                     | Tool > Geometry > Freeze SubDivision Levels (below SDiv), edit, press again                                                                                                                       | Drust NrsuP4Vj4Lg                                |
      | Relax a stretched top level       | three or more levels below the top: Tool > SubTool > Reproject Higher Subdiv                                                                                                                      | Projection doc                                   |
      | Safety nets before projecting     | at the TOP level: Tool > Morph Target > StoreMT, then Tool > Layers > New (REC turns on)                                                                                                          | FlippedNormals Zp07GW3rND0 00:02:13              |
      | Protect the eyes                  | Ctrl+Shift click the eye-interior polygroup (isolates it), Tool > Visibility > Grow once or twice (past the lid), Tool > Masking > MaskAll, Ctrl+Shift click empty canvas (show all), Project All | FlippedNormals 00:05:31 to 00:06:03              |
      | Repair a busted area              | Morph brush (B M, pick Morph) back to the stored target; or mask the good part, Ctrl-click canvas to invert, Project All again (local reprojection); never Shift-smooth rogue vertices            | FlippedNormals 00:04:27, 00:06:36, 00:09:27      |
      | Color pass                        | the paintbrush icon of BOTH SubTools on in the SubTool list (Tool > Polypaint > Colorize), last level only, Project All                                                                           | Drust PrFQXjs_6_w 00:16:35, _ips3GhWI0s 00:05:57 |
      | Drop the morph target before maps | Tool > Morph Target > DelMT                                                                                                                                                                       | Drust 2zDAtaQqwh8 00:01:44 [verify]              |
      
      ## UVs
      
      | Action              | GUI                                                                                                                                                  | Source                         |
      | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
      | Map size and border | Tool > UV Map > UV Map Size (presets to 4096, slider to 8192), UV Map Border                                                                         | Gaboury cemmalvugFk 00:00:51   |
      | Native unwrap       | Tool > UV Map > Create (Unwrap): Auto Seams or Creased Edges [uistr], Symmetry, Unwrap                                                               | Pavlovich cvqoVUX5aBw          |
      | Seams from groups   | Tool > Geometry > Crease > UnCreaseAll, Crease PG [cmdxml]                                                                                           | Pavlovich 00:03:49             |
      | Surgical seams      | ZModeler (B Z M), hover a point, Space, Crease Shortest Path, click point to point; set the edge action to Do Nothing first                          | Pavlovich 00:05:26 to 00:08:11 |
      | Seams back from UVs | Create (Unwrap) > Crease Seams (Shift keeps existing creases)                                                                                        | UV doc; Pavlovich 00:10:25     |
      | View the layout     | Bump 0, Tool > UV Map > Morph UV                                                                                                                     | Pavlovich 00:02:12             |
      | Overlap check       | Tool > Texture Map > New From UV Check (red = overlap)                                                                                               | Pavlovich 00:01:06             |
      | UV Master           | Zplugin > UV Master: Work On Clone, Symmetry, Polygroups, Unwrap, CheckSeams, Flatten, UnFlatten, Copy UVs, (select the original), Paste UVs [uistr] | Gaboury cemmalvugFk            |
      | Control painting    | Enable Control Painting, Protect (face), Attract (back), Density x2 to x4 or /2 to /4, AttractFromAmbientOccl [uistr]                                | UV Master doc                  |
      | Move islands        | on a flattened clone: W (Gizmo), Ctrl-tap a group, symmetry off, move and scale                                                                      | Pavlovich 00:08:11             |
      
      ## Maps
      
      | Action                          | GUI                                                                                                                                                                                                                                                                                                                       | Source                                          |
      | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
      | MME                             | Zplugin > Multi Map Exporter: Displacement, Vector Displacement, Normal, Texture from Polypaint, Ambient Occlusion, Cavity, Export Mesh; SubTools, Merge Maps, Layers, FlipV, Map Size, Map Border; Export Options (File names, Load/Save Presets, Switch MT, Estimate Time) and per-map options; Create All Maps [uistr] | MME doc; FlippedNormals                         |
      | Film displacement preset        | Displacement only, SubTools on, Merge Maps off (EXR), Map Border max, FlipV on, SubDiv level 1, Adaptive off, DpSubPix 0, SmoothUV on, Mid 0.5, 32Bit, exr, 3 Channels off; UDIM file names with a dot before the tile                                                                                                    | FlippedNormals -ThBTEc8L_M 00:04:09 to 00:14:22 |
      | One SubTool normal map          | Tool > Normal Map: Tangent, Adaptive, SmoothUV, SwitchRG, SNormals, FlipR/G/B, Create NormalMap, Clone NM; Texture > Export                                                                                                                                                                                               | Drust n0qqpwvn-jA                               |
      | One SubTool displacement        | Tool > Displacement Map: Mid (0 to 100, default 50), Scale, 32Bit, 3 Channels, Adaptive, DPSubPix, SmoothUV, Flip V, Create And Export Map                                                                                                                                                                                | MME doc                                         |
      | Color ID                        | Tool > Polypaint > Polypaint From Polygroups; Tool > Texture Map > New From Polypaint (Unweld Groups Border first on low poly)                                                                                                                                                                                            | Drust RYGkbSUaHHg                               |
      | Vector displacement diagnostics | Tool > Vector Displacement Map > Create Diagnostic Files; Preferences > ImportExport > FlipAndSwitch                                                                                                                                                                                                                      | MME doc                                         |
      | Never                           | press ESC during MME (UVs can be lost)                                                                                                                                                                                                                                                                                    | MME doc                                         |
      
      ## Ears and hands on their own
      
      | Action                 | GUI or gesture                                                                                                                                         | Source                          |
      | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------- |
      | Split the part         | Ctrl+Shift drag over the ear or hand, Tool > SubTool > Split Hidden (or Groups Split on its group)                                                     | Pavlovich n5 00:43:57, 00:58:36 |
      | Polish the open border | Tool > Masking > Mask By Feature with Border only, GrowMask, Inverse, Tool > Deformation > Polish By Features (open circle)                            | Pavlovich n5 00:44:36           |
      | Remesh the part        | ZRemesher; hand Adaptive Size about 14; ear: Half repeatedly without groups, projecting after each pass                                                | n5 00:45:42, 00:59:42           |
      | Exact re-weld          | remesh the whole limb first, split, FreezeBorder on for each part ("probably not great for animation")                                                 | n5 01:03:44                     |
      | Re-join                | MergeDown with Weld on (Uv off without UVs) or Modify Topology > WeldPoints; stitch leftovers with ZModeler Stitch Two Points where deformation is low | n5 00:47:31, 00:59:42           |
      
      ## Scale and export
      
      | Action                   | GUI                                                                                                                                                                                                                                                                          | Source                       |
      | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
      | Read internal size       | Tool > Geometry > Size: XYZ Size (longest axis), X/Y/Z Size                                                                                                                                                                                                                  | Gallagher 00:15:42           |
      | Measure                  | W then Y for the Transpose line; the readout uses Export Scale                                                                                                                                                                                                               | Gallagher 00:06:15, 00:19:46 |
      | Export scale and offsets | Tool > Export: Scale, X Offset, Y Offset, Z Offset (internal units)                                                                                                                                                                                                          | Drust; Gallagher             |
      | OBJ switches             | Tool > Export: Qud or Tri, Txr, Flp, Mrg ("Merge Uv Cords" [uistr]), Grp ("Export Sub Groups" [uistr]), Smooth Normals; Export                                                                                                                                               | EXP doc                      |
      | Normalize a simple tool  | Tool > Deformation > Unify (never on a character with layers)                                                                                                                                                                                                                | Gallagher 00:27:16           |
      | Rescale a complex tool   | Save As, new primitive with Export Scale set, Zplugin > SubTool Master > MultiAppend                                                                                                                                                                                         | Gallagher 00:38:48           |
      | Scale Master             | Zplugin > Scale Master: Set Scene Scale (dialog), Sliders to Subtool Size, X/Y/Z, All, Resize Subtool, ZBrush Scale Unify, Export to Unit Scale [uistr]                                                                                                                      | Robinson bICt_XruvQM         |
      | GoZ                      | top of the Tool palette: GoZ, All, Visible, R (first press picks the app)                                                                                                                                                                                                    | GoZ doc                      |
      | FBX                      | Zplugin > FBX ExportImport: Selected/Visible/All, FBX 2009 to 2020, bin, Layers, Tris, axis preset (MayaYUp, MayaZUp, 3ds Max, Toolbag, Unity, MotionB, OpenGL, DirectX, LightWv), SNormals, Embed Maps; Options: Export Polygroups as Mats; Export (options window) [uistr] | FBX doc                      |
      | Substance Bridge         | Texture > Substance Bridge: Send to Painter (All, Visible, Active), Texture Sets (Per Subtool, Per PolyGroup), PolyPaint, Subdivision Level (Current, Low & High), Auto-Bake, Force Auto-Unwrap, Smooth Normals [uistr]                                                      | Substance Bridge doc         |
      
      ## Decimation
      
      Zplugin > Decimation Master [uistr]: Options (Freeze borders, Keep UVs, Use and Keep Polypaint, Polypaint weight), Pre-process Current or All, % of decimation or k Points / k Polys, Decimate Current or All, presets 20 k, 35 k, 75 k, 150 k, 250 k, Custom, Utilities (Delete Caches, Export All SubTools). Keep polygroups: Tool > Geometry > Modify Topology > Unweld Groups Border, Freeze borders on, decimate, Tool > Polygroups > Auto Groups, Modify Topology > WeldPoints (Drust mRfA_WDvvrg).
      
      ## Cleanup of scans and generated meshes
      
      | Action                           | GUI or gesture                                                                                                                                                                                                  | Source                                                               |
      | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
      | Inspect                          | MatCap Gray, colorize off, Double on, Polyframe                                                                                                                                                                 | Drust PrFQXjs_6_w 00:00:42                                           |
      | Repair                           | Tool > Geometry > Mesh Integrity > Check Mesh Integrity, Fix Mesh [cmdxml]                                                                                                                                      | doc                                                                  |
      | Fill holes                       | Tool > Geometry > Modify Topology > Close Holes (no levels)                                                                                                                                                     | Drust                                                                |
      | Fill one hole, keep single-sided | isolate it (Ctrl+Shift rectangle), ZModeler, hover an edge, Space, Close, Concave Hole, click the border                                                                                                        | Drust udiPVJIODX0 00:02:47                                           |
      | Thicken a thin shell             | W, Ctrl-click the fill group (masks the rest), side view, perspective off, Shift-drag the middle circle to push the fill back                                                                                   | Drust PrFQXjs_6_w 00:04:32 to 00:06:43                               |
      | Plug a hole                      | SubTool > Append PolySphere, Tool > Deformation > Unify, E to scale, move in, Move brush (B M V) into shape, MergeDown ("Always OK")                                                                            | Drust PrFQXjs_6_w 00:07:16 to 00:10:33                               |
      | Remove junk                      | Control+W all into one group, mask the junk, Control+W, Ctrl+Shift click to isolate then again to hide, Modify Topology > Del Hidden                                                                            | Drust 00:10:33                                                       |
      | Split shells                     | Tool > SubTool > Split > Split To Parts (after Auto Groups)                                                                                                                                                     | doc                                                                  |
      | Flat base                        | Ctrl+Shift, SliceCurve line under the lowest point, hide below, Del Hidden, re-DynaMesh                                                                                                                         | Drust 00:11:39                                                       |
      | Re-DynaMesh                      | Ctrl-drag on empty canvas                                                                                                                                                                                       | Drust 00:09:27                                                       |
      | Texture to geometry              | Tool > Masking > Mask By Intensity, colorize off, Tool > Deformation > Inflate a little                                                                                                                         | Drust 00:19:21                                                       |
      | See texture while sculpting      | Material > BumpViewer                                                                                                                                                                                           | Drust 00:17:42                                                       |
      | Symmetrize                       | Tool > Geometry > Modify Topology > Mirror And Weld; Tool > Deformation > Smart ReSym [cmdxml]                                                                                                                  |                                                                      |
      | Keep polygroups through DynaMesh | turn the SubTool's paintbrush icon off (polypaint off) before DynaMesh; on, DynaMesh keeps polypaint instead of groups                                                                                          | DynaMesh doc, PolyGroups > Important; Pavlovich 8kWFv1cZlCE 00:20:06 |
      | Merge parts inside the Tool      | order them with the SubTool list arrows (Tool > SubTool > MoveUp, MoveDown [cmdxml]), select the upper one, Tool > SubTool > Merge > MergeDown (Weld on to weld borders); MergeVisible makes a new Tool instead | Drust PrFQXjs_6_w 00:09:27; Pablo gitoJ7B8FmY 00:03:31               |
      | Place a plug                     | SubTool > Append a sphere, then Tool > Geometry > X, Y, Z Position and XYZ Size [cmdxml] (typed values), MergeDown, re-DynaMesh; by hand Drust scales with E and moves it in                                    | Drust PrFQXjs_6_w 00:07:16                                           |
      | Read forms under light           | Material > a Standard material (BasicMaterial), dock the Light palette, drag the orange dot, tap it to send the light behind (rim)                                                                              | Pavlovich aR02CwyPTUw 00:04:07, 00:04:39                             |
      
    • procedures.md 63.1 KB
      # Procedures: retopology, projection, UVs, maps, scale, export, cleanup
      
      Full bridge procedures for scenario-zbrush-retopology-export. Every ZBrush-side call goes through `rx.zcall` (this skill's module) or `zb_launch.call("zb_ops", ...)` (the lead's), on ZBrush's main thread. Every file is checked on the agent side.
      
      **Status of everything on this page: not yet run in ZBrush.** The logic is tested offline: `python3 tests/code/zbrush-retopology-export/run_offline.py` (88 tests, 0 failures on 2026-09-24, results in `offline_results.json`): ZBrush-side wrappers against a multi-SubTool fake (`rx_fake.py`), agent-side audits on generated fixtures and on the real 2026.2.1 OBJ of the lead's v03 test, and a bridge round trip through the lead's real server module. `test_rx_grade_fixes.py` covers the 2026-09-24 refactor after the blind grades Z4 and Z6. Each projection pass calls the lead's `zb_ops.project_all`, the toolkit's safe reprojection: use it, do not re-implement it. The live tests that will prove each procedure are named per section; run them with `tests/code/zbrush-retopology-export/run_live.sh` only when this skill holds the ZBrush session.
      
      Evidence tags used below: `[bridge]` proven through the bridge (lead tests v01 to v03), `[cmdxml]` id listed in `ZData/ZLang/zcommands/commands.xml`, `[uistr]` label in `ZData/ZLang/english/UInterface.zsc`, `[log]` written by ZBrush in the Activity log, `[obj]` read from a ZBrush OBJ export, `[doc]` Maxon docs or SDK, `[verify]` unconfirmed.
      
      ## 0. Setup on the agent side
      
      ```python
      import sys
      sys.path.insert(0, "<project>/skills/scenario-zbrush-expert/scripts")
      sys.path.insert(0, "<project>/skills/scenario-zbrush-retopology-export/scripts")
      import zb_launch as zl, zb_audit, zb_review
      import zb_retopology_export as rx
      
      zl.start()                                   # lead skill: ZBrush with the bridge
      state = rx.zcall("preflight", 180.0)         # target height in destination units
      for f in state["flags"]:
          print(f)
      zl.call("zb_ops", "save_ztl", "/abs/work/hero.ztl")   # hero_v001.ztl, never overwrites
      ```
      
      `build_call_code` imports this module inside ZBrush with both toolkit folders on `sys.path` only during the import and pops every `zb_*` module afterwards (shared-interpreter rule); `test_rx_bridge.py` proves that on the lead's server code. Timeouts: ZRemesher, projection into millions and MME take minutes; pass `timeout=900` or more, and ping before the next call after a timeout (the queued code still runs).
      
      ## 1. Preflight (live_rx_01, live_rx_05)
      
      `preflight(target_size, axis)` returns `stats`, `xyz_size`, per-axis sizes, `export` (Scale and X/Y/Z Offset), symmetry state, the SubTool table and `names` (duplicates and unsafe names), plus `flags`:
      
      | Flag                                   | Why                                                             | Source                                |
      | -------------------------------------- | --------------------------------------------------------------- | ------------------------------------- |
      | over 8M vertices                       | ZRemesher memory limit                                          | ZRemesher doc, High Polycounts        |
      | XYZ Size outside 1 to 4                | DynaMesh resolution and dynamic brushes depend on internal size | Gallagher EXjfH_X2hkM 00:28:23        |
      | Export Scale 0                         | never imported or set; exports are unitless nonsense            | Gallagher 00:24:03                    |
      | names not unique or not `[A-Za-z0-9_]` | GoZ and Decimation Master pick the wrong SubTool                | GoZ doc, Restrictions; Decimation doc |
      
      Renaming needs a text prompt the API cannot fill (lead skill): ask the sculpting skill or a human to rename, or accept file names made from the SubTool index. XYZ Size out of range on a single simple SubTool: `Tool:Deformation:Unify` (`zb_ops` key `unify`); on a character with layers or morph targets use Gallagher's route: set Export Scale so the real size reads right, Save As, then SubTool Master MultiAppend into a correctly scaled primitive (human: the plugin opens a file dialog [verify]).
      
      ## 2. ZRemesher input (live_rx_02)
      
      ```python
      inp = rx.zcall("remesh_input", "auto", max_points=2_000_000, timeout=900)
      # {"source": 0, "copy": 1, "method": "level"|"dynamesh", "points_source", "points_copy", ...}
      ```
      
      - `level`: duplicate, step down from the top level to the highest level under `max_points`, delete the other levels on the copy.
      - `dynamesh`: duplicate, go to the top level, delete levels, DynaMesh at `resolution_for_points(area, max_points)`. The formula is calibrated on one point [added]: the v03 radius-1 sphere at resolution 128 gave 43,480 points on area 4 pi, so points = 0.845 x area x (res / 2)^2 and a cell is about 2 / res internal units. DynaMesh resolution counts the scene's unit space, not the object (Gallagher 00:11:41; Pablo Logic Part 5).
      - Decimation Master as the reducer (the ZRemesher doc's other option) is a plugin press: `rx.zcall("decimate_copy", target_points=1_500_000)` after testing the plugin once (section 10).
      - The source is hidden afterwards, and stays the projection source. The copy lands below it (SubTool doc) [verify index].
      - Holes: `remesh_input(..., fill_holes=True)` presses Close Holes then Fix Mesh on the level-less copy (ZRemesher doc, Workflow step 2 and Tips: small holes survive retopology and inflate the count; Close Holes needs a mesh without levels, Projection doc). Use it for unintended holes (scan and AI input, sculpting fallout, micro holes in a flat DynaMesh). It also fills intended openings, and the DynaMesh route closes them anyway, so the result carries `openings_closed`: the hole gate in section 4 expects 0 holes when it is True, else the intended openings (eye sockets, mouth bag, neck cut). Openings that must stay open after a fill are its polygroups: isolate, Del Hidden (Drust udiPVJIODX0 00:04:30; the isolation is a Ctrl+Shift click, human).
      - Neutral face first: when the face will animate, the input must be neutral with folds relaxed; the expression is sculpted back on the new topology (Pavlovich n5 00:01:39 to 00:02:46), ideally on a 3D layer that becomes a blend shape. If the sculpt carries a smile, hand it back to scenario-zbrush-character-creature for a neutral version (or turn its expression layer off) before `remesh_input`: ZRemesher follows the forms it sees.
      
      ## 3. Guides (live_rx_02 for the buttons; polygroup painting is human)
      
      Deterministic group sources, in the order an agent should try them:
      
      | Source           | Call                                                                                                                       | Good for                                    |
      | ---------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
      | separate shells  | `zb_launch.call("zb_ops", "polygroups", "auto")`                                                                           | parts, fills, patches                       |
      | hard edges       | `zb_ops.polygroups("normals")` after setting `Tool:Polygroups:MaxAngle` (Pavlovich used 45 for cylinder caps, n5 01:05:34) | hard surface (ZRemesher doc, Hard Surfaces) |
      | color regions    | `rx.zcall("color_to_groups", tolerance=None)`                                                                              | textured or painted inputs [added]          |
      | existing creases | `Tool:Geometry:ZRemesher:KeepCreases` via `zremesh(keep_creases=True)`                                                     | CAD, Live Boolean results                   |
      | masks            | human: MaskLasso rings around eyes and mouth, Ctrl+W (Control+W on macOS)                                                  | faces (Pavlovich n5 00:04:20)               |
      
      Then smooth the borders, so ZRemesher does not follow the stair steps (Pavlovich n5 00:07:48, 00:15:18):
      
      ```python
      rx.zcall("polish_band", "groups", grow=1, value=10, passes=2)   # Mask By Feature (Groups), Grow, invert,
                                                                      # Polish By Features, clear
      rx.zcall("polish_band", "border", grow=1, value=10, passes=2)   # open borders of split ears or hands
      ```
      
      Polish By Features with the "closed circle" curve keeps volume in Pavlovich's video; its switch has no known path [verify]. Check with a Polyframe sheet (Line off shows only group colors):
      
      ```python
      rx.zcall("set_polyframe", True)
      sheet = zb_review.review("/abs/work/groups", views=("front", "right", "threequarter"))["sheet"]
      rx.zcall("set_polyframe", False)
      ```
      
      Masks a human drew (rings, a jaw line) become clean groups with support loops through `Tool:Geometry:Edgeloop Masked Border` (`rx.P("edgeloop_masked")`, Drust IpTYcGxxsdM 00:05:08), then Keep Groups.
      
      Ears and hands on their own (Pavlovich n5 00:44:36 to 00:47:31, 00:58:36 to 01:04:17; retopology digest 6.2):
      
      ```python
      # the part is its own SubTool: Groups Split on a group made earlier, or Split Hidden after a
      # human isolated it (Ctrl+Shift drag); names, not indices, afterwards
      hand = rx.zcall("index_of", "hero_hand")
      zl.run(f"zbc.select_subtool({hand})\nresult = zb_ops.stats()", ("zb_ops",))
      rx.zcall("polish_band", "border", grow=1, value=10, passes=2)   # open-border polish: Mask By
                                                                      # Feature Border, Grow, invert,
                                                                      # Polish By Features
      z = rx.zcall("zremesh", 2, False, adaptive_size=14, timeout=900)  # hand: Adaptive Size about 14
      body = rx.zcall("index_of", "hero_body")                          # the part sits right below it
      rx.zcall("merge_down_run", body, 1, weld=True, uv=False)          # MergeDown with Weld
      # gate: rx.topo_report(export)["boundary_loops"] lost the seam loops (only intended openings left)
      ```
      
      - Ear: ZRemesher Half repeatedly without groups, projecting after each pass (n5 00:45:42). Where borders do not match after the weld, a human stitches with ZModeler Stitch Two Points in a low-deformation area (00:47:31).
      - Freeze Border keeps border vertices so separately remeshed parts re-weld exactly, "probably not great for animation" (01:03:44 to 01:04:17): remesh the whole limb to the wanted density first, then split and remesh each part with `zremesh(..., freeze_border=True)`, or the frozen border keeps a dense DynaMesh ring [added, retopology digest delta 9]. Freeze Border forces Adaptive Density: `expected_band(freeze_border=True)` is 0.5 to 2.0.
      
      Curves (ZRemesherGuide brush) and polypaint density painting are strokes: a human, or the stroke-free substitutes Frame Mesh from groups (`Stroke:Frame Mesh` with `Stroke:Polygroups` on, Border and Creased edges off [cmdxml], then Curves Strength 100: Drust 8D-xqasgUws 00:03:41) and polypaint fills on split parts (Pavlovich n5 01:02:05) [verify both].
      
      ## 4. ZRemesher, Retry, QA export (live_rx_02)
      
      ```python
      zl.call("zb_ops", "save_ztl", "/abs/work/hero.ztl")                  # before anything destructive
      z = rx.zcall("zremesh", 15, False, adaptive=True, adaptive_size=5, keep_groups=True,
                   smooth_groups=0, timeout=900)
      # {"faces_after", "ratio_to_target", "band", "in_band", "shrink", "snap_back", "symmetry", ...}
      runs = [{"adaptive_size": 5, "faces_after": z["faces_after"]}]
      for size in (0, 21):                                                  # Pavlovich's sweep, cached
          r = rx.zcall("retry", adaptive_size=size, timeout=900)
          runs.append({"adaptive_size": size, "faces_after": r["faces_after"]})
          zb_review.review(f"/abs/work/retry_{size}", views=("front", "right", "threequarter"))
      # the sweep leaves the LAST variant (21) in the scene: pick, go back, prove it
      pick = rx.pick_retry(runs, target_faces=15000, band=z["band"], choice=None)  # choice = the
                                                                         # value picked on the sheets
      back = rx.zcall("retry", timeout=900, **pick["retry"])
      assert rx.confirm_retry(pick, back["faces_after"]), (pick, back)
      if z["snap_back"]:                                                    # ZRemesher shrank it
          rx.zcall("project_stack", inp["copy"], inp["source"], levels=0,
                   checkpoint="/abs/work/hero_snap.ztl", timeout=900)
      ```
      
      - `symmetric` is positional and required. True only when the subject is unposed and meant to be symmetric; ZRemesher then symmetrizes the topology even on asymmetric scan input (ZRemesher doc, Symmetry).
      - Alt+ZRemesher (the alternative midline algorithm) is a modifier click and `zbc.press()` takes no modifier (SDK stub; `press_key` with modifiers is reported unreliable, version deltas 3.12): route it to a computer-use agent when the center line is poor. The script runs the normal variant, sheets nape and brow, asks for the Alt variant at the same target (Pavlovich compared at 1k and 1.5k, n5 00:48:05 to 00:49:05), sheets again and compares `topo_report(...)["centre_line_components"]` and `symmetry_x_pct` for both.
      - Same, Half and Double override a typed target (ZRemesher doc Reference; Pavlovich n5 00:16:26): `zremesh(mode=None)` switches all three off; `mode="half"` uses Half on purpose.
      - Snap back: ZRemesher, Smooth Groups, density and smoothing passes all shrink the surface; project once at level 1 before judging it (Pavlovich n5 00:22:05, 01:03:12; retopology digest delta 7). `zremesh` returns `snap_back` when the extents shrank by more than 0.1 percent [added]; `project_stack(..., levels=0)` is that pass.
      - `mode="half"` for a second, lighter pass with `adaptive=False` (Drust R2MzFqWMaWY 00:01:44). "Retopology over a retopology" improves flow at the same count (ZRemesher doc Tips).
      - `retry()` refuses Keep Groups, Keep Creases, Smooth Groups (they discard the cache, ZRemesher 4.0 doc).
      - Partial remesh (hide all but a region, Freeze Border on) deletes the subdivision levels (Drust 0TlG4Ex9lQA 00:02:26) and forces Adaptive Density: use `freeze_levels()` around it when levels matter.
      
      QA export and gate:
      
      ```python
      rx.zcall("set_export_switches", quads=True, uvs=False, groups=True)   # Grp on for the QA copy only
      qa = zl.call("zb_ops", "export_obj", "/abs/work/qa/low_groups.obj", overwrite=True)
      rep = rx.topo_report(qa["path"], ring_groups=("eyelid_ring", "lip_ring"))
      holes = 0 if inp["openings_closed"] else 3          # DynaMesh or Close Holes shut eyes and mouth
      v = rx.retopo_verdict(rep, purpose="rig", target_faces=15000, band=z["band"],
                            ring_groups=("eyelid_ring", "lip_ring"), expected_holes=holes)
      ```
      
      `topo_report` adds to `zb_audit.audit`: per-group faces, area density, interior poles, border loops and the ring test of the distiller's `topo_qa.py` (a group whose border is two closed loops of equal size, no pole inside, is a clean concentric strip; rows = faces / loop size), plus the center-line chain. ZBrush writes one `g GroupN` line per polygroup with Grp on [obj: one group seen on the v03 export; several groups: verify]. Visual gate: Polyframe sheet with Line on, then `references/critique.md` section 1.
      
      Where the rings fail, the fix is manual: hide everything but the region, Del Hidden, and let a human rebuild it with the Retopo brush on the ZRemesher mesh (answer "No" to convert it; never on a dense sculpt: Pablo I4nePXDTrhQ 00:05:59). `references/gui-paths.md` lists the gestures. The script STOPS here: save a versioned ZTL, write the request (regions, target loops, the sheet) into the report, and resume at section 5 only after the human pass, re-running this gate on a new QA export.
      
      ## 5. Rebuild the subdivision stack (live_rx_02)
      
      ```python
      ps = rx.zcall("project_stack", inp["copy"], inp["source"], top_ratio=0.5, dist=0.1,
                    pa_blur=0.0, store_mt=True, color_last=False,
                    checkpoint="/abs/work/hero_project.ztl", timeout=3600)
      print(rx.projection_from_stack(ps))     # bbox 0.5 %, volume 2 %, level 1 unchanged, the gate of
                                              # every zb_ops.project_all pass, no export needed
      # small subjects: compare OBJ exports instead (live_rx_02)
      #   rx.projection_verdict(zb_audit.audit(source_obj), zb_audit.audit(top_obj), l1_before, l1_after)
      rx.zcall("show_only", [inp["copy"], inp["source"]])
      sheet = zb_review.review("/abs/work/projection")["sheet"]   # then hide the source, review again
      ```
      
      Two orders, same gates:
      
      | Order                 | Steps                                                                                                                                                                                              | Use when                                                                       | Source                                                    |
      | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------- |
      | `per_level` (default) | Project All at level 1, then Divide + Project All per level; each pass stores a morph target at the current top (a Divide destroys it: Morph Targets doc), the last pass also makes the layer      | the usual rebuild                                                              | Drust R2MzFqWMaWY, _ips3GhWI0s; Maxon Transferring Detail |
      | `top_first`           | all divisions, then at the TOP one morph target and one recording layer (`safety_nets`, or the first `zb_ops.project_all`), then Project All from level 1 up (`project_levels`) with the nets kept | danger zones to protect, film faces, one layer that holds the whole projection | FlippedNormals Zp07GW3rND0 00:02:13, 00:03:17             |
      
      Every pass is the lead's `zb_ops.project_all`: a versioned checkpoint ZTL before it (Project All "might crash the program", cgside; Python has no undo), so `checkpoint=` is required here too (a path, saved as `_vNNN`; False only right after a save), exclusive visibility, Dist 0.1, and a gate (point count kept, no vertex outside the visible bbox) whose problems land in `ps["pass_problems"]` and fail `projection_from_stack`. Its `repair` text names the lead's `zb_ops.morph_repair(points)` (Morph brush strokes back to the stored target) and `zb_ops.set_layer_intensity(v)`.
      
      Danger zones: eyes, mouth, armpits, between the fingers, crotch: close surfaces grab the wrong one, and artifacts left there become hard lines in the displacement map (FlippedNormals Zp07GW3rND0 00:01:41, 00:07:10, 00:07:43). Protect the eyes before projecting:
      
      ```python
      ps = rx.zcall("project_stack", inp["copy"], inp["source"], order="top_first", stop_after="nets",
                    checkpoint="/abs/work/hero_project.ztl", timeout=3600)  # divisions, nets at the top
      # HUMAN or computer-use: Ctrl+Shift click the eye-interior polygroup on the target (isolates it)
      rx.zcall("protect_isolated", grow=1)                     # Visibility Grow past the lid, MaskAll,
                                                               # show all (Zp07GW3rND0 00:05:31)
      pl = rx.zcall("project_levels", inp["copy"], inp["source"], color_last=False, nets_done=True,
                    checkpoint="/abs/work/hero_project.ztl", timeout=3600)
      zl.call("zb_ops", "mask", "clear")
      ```
      
      Masks block Divide ("Divide adds a level only when nothing is masked or hidden", Projection doc), which is why the mask goes on after the divisions; `project_stack` raises when a Divide adds no level instead of projecting on the wrong level. The mask state cannot be read (lead skill): the sheet of the eyes is the check. Review every level zoomed on the danger zones (`zb_review.review` with close-up views) before maps.
      
      Repairs, in order: Dist first; busted regions go back to the morph target (`zb_ops.morph_repair(points)`, scripted Morph-brush strokes over the area; or mask the rest and use `Tool:Morph Target:Switch` or the Morph slider [verify]), never smoothing: "you're never gonna be able to smooth these kind of things out properly" (Zp07GW3rND0 00:06:36); then a masked local reprojection (mask the good part, Project All again: Drust nxMYYsyJt3o 00:06:09). A proper sculpting pass after projection resculpts what was lost (00:08:22): hand it to the sculpting skill.
      
      After the gate: `rx.zcall("drop_morph_target")` on every projected SubTool. Drust: ZBrush "is going to take the highest subdivision level on your mesh and it's going to compare it to either a Morph target or the subdivision level you currently have selected" (2zDAtaQqwh8 00:01:44), so a morph target left from projection may become the map base [verify]; `mme_create_all` and the `bake_*` functions refuse while one is stored (`allow_morph_target=True` overrides). DelMT is enabled only while a morph target exists [verify, live_rx_02].
      
      - Levels: `levels_needed(level1, source, top_ratio)`: each Divide is x4; 25k to a 12M source gives 4 divisions at top_ratio 0.5 (6.4M) or 5 at 1.0 (25.6M). Drust divided until close to the source (R2MzFqWMaWY 00:02:16).
      - `color_last=True` projects polypaint at the last level only (Drust _ips3GhWI0s 00:05:57), with Colorize turned on for source AND target before that pass ("Projected color missing: colorize must be on for source and target", PrFQXjs_6_w 00:16:35); `colorize_was` in the result lists the previous values. The Project "Color" switch is kept off before, to avoid the "target has no polypaint" note [verify that the switch governs Project All; if the note appears, a human answers No].
      - Color from a different source (the raw copy of a scan or AI mesh, after the geometry came from the cleaned DynaMesh): `project_levels(target, raw, from_level=top, color_last=True, geometry=False)` turns the Project "Geometry" switch off for the pass [verify that Project All honors it]. Fallback: StoreMT at the top, project, Morph Target Switch back to the stored shape (morph targets hold positions, not polypaint) [verify], DelMT.
      - Artifacts (Drust nxMYYsyJt3o): Dist first; where the target sits inside the source, the morph target is the undo (Morph brush: human), then raise the target locally (mask the rest, `zb_ops.deform("Inflate", v)`) and reproject with the mask; large shape differences: `project_stack(..., shell=<value>)` sets ProjectionShell, Inner turns on, Dist goes to 1 (Gaboury pQbPtH0p5Bg 00:02:30 to 00:03:02). The threshold "until only the target shows" is visual: step the value and take snapshots.
      - Existing stack whose base must change (ZModeler loops, DynaMesh, ZRemesher): `freeze_levels()`, edit the base, `freeze_levels()` again (Drust NrsuP4Vj4Lg; ZRemesher doc recipe 1). If level 1 is too coarse: set SDiv to the level with the right silhouette, `Tool:Geometry:Del Lower`, go up, then freeze.
      - Project History (Ctrl-click on the Undo History bar) has no SDK call [verify]: the duplicate route above is the agent's.
      - Delete the source only after the gate passes (ZRemesher doc); the agent hides it instead.
      
      ## 6. UVs (live_rx_03)
      
      All at the lowest level (Gaboury cemmalvugFk 00:00:51). Set `Tool:UV Map:UV Map Size` and Border first (4096 typical, 8192 max).
      
      ```python
      # A. quick, one island, maps only: Auto Seams (Pavlovich cvqoVUX5aBw 00:02:12)
      zl.call("zb_ops", "uv_unwrap", "native", auto_seams=True, symmetry=True, timeout=300)
      # B. explicit seams on polygroup borders: UnCrease All, Crease PG, Creased Edges, Unwrap
      rx.zcall("unwrap_creases", symmetry=True, from_polygroups=True, timeout=300)
      # C. UV Master, painter-friendly islands, under 150k polygons at level 1
      rx.zcall("uvmaster_roundtrip", symmetry=True, polygroups=True, attract_ao=True, timeout=600)
      # check from the file
      rx.zcall("set_export_switches", quads=True, uvs=True, groups=False)
      ex = zl.call("zb_ops", "export_obj", "/abs/work/qa/uv.obj", overwrite=True)
      u = rx.uv_report(ex["path"])            # bbox, UDIM tiles, islands, overlap %, flipped faces, texel CV
      print(rx.uv_verdict(u, udim=False))
      ```
      
      - Route B cuts exactly where the groups are, including bad choices: pelt-mapped hands and a circular tail need extra seams (ZModeler Crease Shortest Path, human; Pavlovich 00:05:26).
      - Route C: `AttractFromAmbientOccl` [uistr] pulls seams into hidden areas without painting (UV Master doc). Protect and Attract painting is human (paint large areas; Protect counts from 70 percent intensity; a closed protected ring forces a seam anyway). Control maps are bound to the Tool name (UV Master doc): Clear Control Maps only on a clone, it erases polypaint.
      - UDIMs: Flatten rescales into 0 to 1, so ZBrush cannot lay out multi-tile UVs (UV Master doc). Lay out UDIMs in scenario-maya-retopology-uv or scenario-blender-uv-baking and bring the UVs back on the level-1 mesh [verify the import route], or give each SubTool its own tile with Tool > UV Map AdjU and ApplyAdj [verify].
      - Relaxing UVs by polishing a flattened clone (Drust tl_FOvZOqHU) conflicts with the UV Master doc ("a common mistake is to use the Smooth brush"): mask the island borders first and check with a checker, or re-unwrap with Use Existing UV Seams.
      
      ## 7. Maps (live_rx_03)
      
      MME presets live in `rx.MME_PRESETS` (tuple keys) and travel through the bridge as a preset name or as `"group:label"` keys (`"main:FlipV"`, `"disp:Mid"`):
      
      ```python
      zl.call("zb_ops", "save_ztl", "/abs/work/hero.ztl")                 # ESC during MME can lose UVs
      m = rx.zcall("mme_create_all", "/abs/work/maps", "hero", settings="arnold", timeout=7200)
      for f in m["files"]:
          info = rx.map_info(f"{m['dir']}/{f}")
          print(f, rx.map_verdict(info, "displacement", mid=0.5, bits=32, channels=1))
      ```
      
      MME item paths are stored as `Zplugin:[Multi Map Exporter]:[Options]:[Displacement Map |]:Mid` in the UI strings [uistr]. Labels such as Mid, SubDiv level, Adaptive, SmoothUV, 32Bit and exr repeat across groups, so `mme_path` tries the grouped forms first and the short one last; `mme_set` returns the path that took each value [verify which form resolves: live_rx_01]. `Create All Maps` opens a save dialog; the name preset is expected to satisfy it [verify]. File names, the UDIM tile format ("UDIM", Mari 1001) and the dot before the tile number sit under Export Options > File names, a sub-window [verify path]; FlippedNormals' tip: end names with ".1001" so they read as an image sequence (-ThBTEc8L_M 00:13:50).
      
      Single SubTool, no plugin:
      
      ```python
      rx.zcall("bake_normal_map", "/abs/work/maps/hero_nm.png", level=1, tangent=True, flip_g=None)
      rx.zcall("bake_displacement_map", "/abs/work/maps/hero_dm.tif", mid=0.5, scale=1.0, bits32=True)
      ```
      
      The Tool palette Mid slider runs 0 to 100 (default 50), the MME Mid 0 to 1 (doc). SDK trap: `zbc.create_normal_map(width, height, smooth=True, sub_poly=0, border=8, uv_tile=1000000, local_coordinates=False)` defaults to WORLD space: pass `local_coordinates=True` for tangent maps, which is what games and most renderers want (SDK stub; topology digest delta 13). `bake_normal_map` uses the Tool palette with Tangent set explicitly; `map_verdict(..., "normal")` names the trap when a map's median is far from (128, 128, 255). Displacement needs UVs and the lowest level (SDK stub). All bakes refuse while a morph target is stored (section 5).
      
      ### Full per-target table (`rx.RENDERERS`)
      
      | Target                        | ZBrush side                                                                                                                                                                                               | Receiving side                                                                                                                                                                                               | Source                                                                         |
      | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
      | Maya + Arnold                 | MME Displacement, 32Bit + exr, 3 Channels off, Mid 0.5 (or 0), SubDiv level 1, Adaptive off, DpSubPix 0, SmoothUV on, Flip V on, UDIM names; OBJ level 1 Qud, Txr, Grp off, cm                            | File node Filter off, UV Tiling Mode UDIM (Mari); Arnold subdivision Catmull-Clark, about 3 iterations (ideally the ZBrush level count), Auto Bump on; Scalar Zero Value = Mid; Raw color space [added]      | FlippedNormals -ThBTEc8L_M 00:04:09 to 00:20:35 and its description correction |
      | Redshift (Maya, C4D, Houdini) | 32Bit + exr, Mid 0, Scale 1                                                                                                                                                                               | tessellation and displacement on per object; with Mid 0.5 remap so 0.5 is zero [added, verify node fields]                                                                                                   | MME doc                                                                        |
      | Unreal                        | no film displacement; low mesh triangulated as the engine will (Tri on export or in the DCC); decimated high for an external bake, or Substance Bridge Low & High                                         | normal DirectX (Y-): flip green for OpenGL maps [added]; bake 16-bit, reduce to 8-bit last; FBX from scenario-maya-expert or scenario-blender-expert, cm                                                     | Polycount (Triangulation, Workflow 16)                                         |
      | Unity                         | as Unreal                                                                                                                                                                                                 | normal OpenGL (Y+) [added]; FBX Unity preset exists in the plugin [uistr]; meters                                                                                                                            | FBX doc                                                                        |
      | Substance Painter             | Texture > Substance Bridge: Low & High, Auto-Bake, Smooth Normals, Texture Sets Per PolyGroup or Per Subtool, Force Auto-Unwrap off when UVs exist; ID map from MME Texture from Polypaint with Flip V on | every send is a new project; project normal format OpenGL by default [added]                                                                                                                                 | Substance Bridge doc, labels [uistr]; FlippedNormals p56N-dN11zY               |
      | Blender (Cycles)              | 32Bit + exr, Mid 0.5 (or 0), SmoothUV on                                                                                                                                                                  | Displacement node Midlevel = Mid, Scale 1 times the unit ratio [added, verify with live_rx_04]; Displacement and Bump plus Subdivision modifier [added]; Normal Map node OPENGL (scenario-blender-uv-baking) | [added]                                                                        |
      | 16-bit displacement, any      | Mid 0.5, Get Scale, take the lower value; 16Bit Scale on when maps merge                                                                                                                                  | shader Intensity from the file name                                                                                                                                                                          | MME doc                                                                        |
      | Vector displacement           | vd Tangent for anything animated, World for static; 32-bit EXR; Create Diagnostic Files and set FlipAndSwitch per renderer                                                                                | GoZ does not carry VDMs                                                                                                                                                                                      | MME doc (its FlipAndSwitch numbers are for 2012 apps)                          |
      
      Frequency split when the target supports both: displacement from level 1 (forms) plus a tangent normal map from a higher level (micro detail) (Drust 2zDAtaQqwh8; MME doc). Game normals in the engine's tangent basis come from an external baker: export the triangulated low and a decimated high with matching names (`_low`, `_high`) and hand them to scenario-blender-uv-baking (`bake_high_to_low`) or Painter [added: ZBrush bakes from its own levels in its own tangent space].
      
      ## 8. Map calibration fixture (live_rx_04)
      
      The per-target table depends on two facts nobody wrote down for 2026.2.1: ZBrush's normal-map green convention and V orientation, and whether 32-bit displacement values follow Export Scale. The fixture settles both once per build:
      
      1. `rx.zcall("import_mesh", ".../fixtures/plane_grid.obj")` (32 x 32 quads, u = x, v = y), `zb_ops.divide(3, smt=False)`.
      2. Export the top level, push a dome up at UV (0.25, 0.75) OUTSIDE ZBrush on the same vertex order, import it back at the top level (export-modify-import keeps the order) [verify that Import replaces the level].
      3. Bake with FlipG off and on: `rx.normal_orientation(png, (0.25, 0.75))` reports `flip_v_ok`, `looks_v_flipped` and `green` (upper half of the dome greener than 128 means OpenGL, Y+).
      4. Bake 32-bit displacement at Export Scale 1 and 10: `rx.height_orientation` compares the dome heights (ratio 10 means the values follow Export Scale).
      
      Record the result in this file and in `rx.RENDERERS` before trusting any "flip green" advice.
      
      ## 9. Scale, axes, names, export (live_rx_05)
      
      ```python
      s = rx.zcall("set_export_scale", 180.0, axis=1, all_subtools=True, feet_on_ground=True)
      # one Export Scale (target / union extent) and one set of offsets (x, z centered, y = -ymin, internal
      # units) on EVERY SubTool
      rx.zcall("set_export_switches", quads=True, uvs=True, groups=False, merge_uv=False)
      files = rx.zcall("export_subtools", "/abs/out/maya", only_visible=True, level="lowest")
      for f in files:
          h = rx.obj_header(f["path"])            # '#Auto scale x= y= z=' = the Export Scale applied [obj:
                                                  # written by 2026.2.1 in the lead's v03 export]
          print(f["name"], h["auto_scale"], h["auto_offset"])
      print(rx.scale_report(files[0]["path"], 180.0, unit="cm"))   # one part; check the union for all parts
      ```
      
      - Destination units [added]: Maya and Unreal cm, Unity and Blender meters. ZBrush is Y-up like Maya; Blender's OBJ importer converts to Z-up; for Unreal and Unity convert in the DCC.
      - The offset sign (`placement_offsets`: y = -ymin) follows Gallagher's "Y Offset 1 for a 2-unit model" (00:33:04); live_rx_05 confirms it from the exported bbox [verify].
      - FBX: `Zplugin:FBX ExportImport:Export` raises "FBX Export Options". What is known: the window could not be suppressed from ZScript (Maxon forum), `set_next_filename` plus `press` still opened a dialog for one Python user on FBX import (forum), and Python export is [verify] (version deltas 3.6, open checklist). On this Mac AppleScript System Events can click ZBrush dialogs (README "Side channels", Accessibility allowed), a possible route to confirm the window [verify]. Default agent route [added]: export OBJ, then scenario-maya-expert or scenario-blender-expert writes the FBX with the right axis, unit and triangulation. Human route: in the plugin pick the FBX version (2009 to 2020 listed [uistr]), bin, Tris per target, SNormals on, axis preset (MayaYUp if in doubt, Unity, 3ds Max, Toolbag...), Export Polygroups as Mats for texture sets (FlippedNormals p56N-dN11zY 00:03:46).
      - GoZ: `Tool:GoZ`, `Tool:All`, `Tool:Visible` [cmdxml]; the first press opens an app chooser (human once), names must be unique and space-free across all loaded Tools, and GoZ sends the lowest level (GoZ doc). Maya 2026 and 2027 targets on macOS (version deltas).
      - Import side (other apps into ZBrush): Weld and Tri2Quad sliders [cmdxml]; `Preferences:ImportExport:iSwitchYZ` and `eSwitchYZ` exist for Z-up data [cmdxml] [verify semantics]; prefer `rx.obj_transform` on the file.
      - OBJ Mrg is "Merge Uv Cords" and Grp "Export Sub Groups" per the 2026.2.1 help strings [uistr]; Grp on splits the mesh in Maya (FlippedNormals -ThBTEc8L_M 00:15:08).
      - STL and print sizing belong to scenario-zbrush-pose-print (3D Print Hub).
      
      ## 10. Decimation (live_rx_07, last in a run)
      
      ```python
      d = rx.zcall("decimate_copy", target_points=150_000, keep_uvs=True, keep_polypaint=True, timeout=900)
      g = rx.zcall("decimate_keep_groups", 25, timeout=900)     # Unweld Groups Border, Freeze borders,
                                                                  # decimate, Auto Groups, WeldPoints
      ```
      
      - Never the master (Decimation doc; Polycount: decimate the high before exporting it for a bake).
      - `% of decimation` keeps that percentage (`percent_for_target`); the plugin also shows k Points and k Polys switches and presets 20k, 35k, 75k, 150k, 250k and Custom [uistr]. Pre-process again after any edit, mask or level change (doc). Unique SubTool names (doc).
      - Decimation Master and UV Master are ZScript plugins; a ZScript that pressed them lost control (ZBrushCentral). Test each once through the bridge with a checkpoint saved; `run_live.sh` runs them last.
      
      ## 11. Generated or scanned mesh to a clean sculpt (Z6, live_rx_06)
      
      v2 after the Z6 grade, which found four defects in the v1 code: the orientation branch rotated any off-center mesh and imported a file that might not exist; the Retry sweep kept the last variant; an index taken before Split To Parts pointed at a part afterwards; and Colorize was left off, so the color pass would move nothing. It also flagged MergeVisible (a new Tool) where Drust merges down inside the Tool.
      
      ```python
      # agent side, before ZBrush
      conv = rx.glb_to_obj("/abs/in/creature.glb", "/abs/work/creature.obj")    # scenario-3d download
      tri = rx.triage_ai_mesh(conv["obj"], expected_parts=6)
      print("\n".join(tri["plan"]))
      ori = rx.orient_for_import(conv["obj"], tri, "/abs/work/creature_fixed.obj")
      # rotates -90 about X ONLY for a Z-up hint (glTF is Y-up), centers ONLY when off-center,
      # and ori["import"] is the file that exists: the original when nothing was needed
      
      # in ZBrush
      rx.zcall("import_mesh", ori["import"], weld=None, tri2quad=None)   # Tri2Quad: quads from the
                                                                         # AI triangles [verify range]
      zl.call("zb_ops", "save_ztl", "/abs/work/creature_raw.ztl")
      zl.call("zb_ops", "export_obj", "/abs/work/qa/raw.obj", overwrite=True)    # the raw reference
      dup = rx.zcall("duplicate_keep")                     # {"work": {...}, "copy": {...}}
      RAW = dup["copy"]["name"]                            # form and color source, kept BY NAME
      rx.zcall("set_visible", dup["copy"]["index"], False)
      rx.zcall("fix_mesh")
      parts = rx.zcall("split_parts")                      # Auto Groups, Split To Parts: renumbers
      rx.zcall("hide_small_parts", 0.005, protect_names=[RAW])    # floating junk, raw copy protected
      # fused parts that differ in color: rx.zcall("color_to_groups"); then Tool:SubTool:Groups Split
      # parts of one body: MergeDown inside the Tool (they sit together at the top after the split)
      body_parts = 4                                       # from the table in `parts`
      rx.zcall("merge_down_run", 0, body_parts - 1, weld=False)   # parts must be adjacent: MergeDown
                                                         # takes the SubTool right below, hidden or
                                                         # not [verify]; else merge_into(body, names)
      res = rx.dynamesh_resolution_for(0.12, cells=8)      # thinnest part, internal units
      rx.zcall("dynamesh_keep", res, keep="groups", timeout=900)   # polypaint OFF at the press
      # residual holes: plugs placed from the file, then MergeDown and re-DynaMesh
      q = zl.call("zb_ops", "export_obj", "/abs/work/qa/dyn.obj", overwrite=True)
      BODY = rx.zcall("subtool_table", with_counts=False)[0]["name"]
      for h in rx.hole_plugs(q["path"])[:4]:
          pl = rx.zcall("plug_hole", h["center"], h["diameter"])  # appended LAST
          plug = rx.zcall("subtool_table", with_counts=False)[pl["index"]]["name"]
          rx.zcall("merge_into", BODY, [plug])                    # MoveUp under the body, MergeDown
      rx.zcall("dynamesh_keep", res, keep="groups", timeout=900)
      zl.call("zb_ops", "polish", 10)
      clean = zl.call("zb_ops", "export_obj", "/abs/work/qa/clean.obj", overwrite=True)
      v = rx.cleanup_verdict(zb_audit.audit("/abs/work/qa/raw.obj"), zb_audit.audit(clean["path"]),
                             expected_shells=1, raw_rough=rx.roughness("/abs/work/qa/raw.obj"),
                             clean_rough=rx.roughness(clean["path"]))
      rx.zcall("set_display", double=True, colorize=False)   # for the sheet only; returns colorize_was
      sheet = zb_review.review("/abs/work/clean_review")["sheet"]               # six views, MatCap Gray
      # then sections 2 to 5 on the cleaned DynaMesh: remesh_input("level") duplicates it as is,
      # zremesh, snap back if asked, project_stack(copy, cleaned) for geometry, and finally color
      # only from the raw copy at the top level:
      #   raw = rx.zcall("index_of", RAW)
      #   rx.zcall("project_levels", copy, raw, from_level=top, color_last=True, geometry=False,
      #            checkpoint="/abs/work/creature_colour.ztl")
      ```
      
      Details and choices:
      
      - Polypaint versus polygroups: with the SubTool's polypaint on (Colorize, its brush icon in the SubTool list), DynaMesh keeps polypaint INSTEAD of polygroups (DynaMesh doc, "PolyGroups > Important"); with it off the paint is gone (Pavlovich 8kWFv1cZlCE 00:20:06). scenario-zbrush-sculpting owns the general rule; in this path the default is `keep="groups"`: the part groups (Auto Groups, From Polypaint) steer the later ZRemesher and color returns by projection from the raw copy, which keeps its paint. `keep="polypaint"` only when no groups are needed and no raw copy exists. `glb_to_obj` writes `#MRGB` polypaint, so an imported AI mesh arrives with polypaint on.
      - Color: `glb_to_obj` writes ZBrush `#MRGB` polypaint from COLOR_0 or from the baseColor texture sampled per vertex; `import_mesh` clears the mask because the mask byte of `#MRGB` is [verify]. A texture-only mesh imported with UVs needs Polypaint From Texture before any DynaMesh, which deletes UVs (DynaMesh doc; subdivide to about texture resolution first).
      - Inspection toggles: Colorize off, Double on for the sheet (Drust PrFQXjs_6_w 00:00:09, 00:03:26), then back: `set_display` returns `colorize_was`. The color pass turns Colorize on for both SubTools itself.
      - Holes: `close_holes` needs a level-less SubTool; every fill gets its own polygroup, the handle to delete fills that must stay open (group, isolate, Del Hidden: Drust udiPVJIODX0 00:04:30). A dome-shaped fill across a thin shell is the swiss-cheese warning: give thickness first. Drust pushes the fill back with the Transpose line (human); stroke-free substitutes are DynaMesh Create Shell with Thickness [cmdxml] or `zb_ops.deform("Inflate", v)` on a masked copy [verify].
      - Plugs: Drust appends a PolySphere, Unifies it, places it by hand and merges it down before a re-DynaMesh (PrFQXjs_6_w 00:07:16 to 00:10:33). The SDK cannot read vertices, the exported file can: `hole_plugs` turns each boundary loop into a center and diameter in internal units (through `#Auto scale` and `#Auto offset`), `plug_hole` appends a primitive and sets Tool > Geometry X/Y/Z Position and XYZ Size [cmdxml] (primitives placed without brushes, as in Pablo's blockout, gitoJ7B8FmY 00:05:26). The appended SubTool lands last: `merge_into(body, [plug])` moves it right below the body with Tool > SubTool MoveUp [cmdxml] and merges down once; MergeDown may show a note (Drust answered "Always OK") [verify].
      - Resolution: start where the thinnest part gets about 8 cells [added]; Drust used 512 on a unified scan and dropped to 128 (about 50k points) to keep only the silhouette before dividing to about 3M and projecting back (PrFQXjs_6_w 00:14:58 to 00:17:42).
      - Snap back: after the ZRemesher pass on the cleaned DynaMesh, `project_stack(copy, cleaned, levels=0)` restores the volume before the topology is judged; after Polish passes on the DynaMesh itself the volume gate (`cleanup_verdict`) is the check, since the only source is the noisy raw mesh.
      - Texture detail into geometry: Mask By Intensity from polypaint, then a little Inflate on the unmasked part (Drust PrFQXjs_6_w 00:19:21): `Tool:Masking:Mask By Intensity` [cmdxml] then `zb_ops.deform("Inflate", v)`.
      - Symmetric designs: Mirror And Weld on the DynaMesh (`zb_ops` key `mirror_weld`), then ZRemesher with symmetry on.
      - Separation of fused parts without color: scenario-3d part segmentation upstream [added], else straight SliceCurve cuts (Ctrl+Shift, SliceCurve, a line: human) then Groups Split, then Close Holes per part.
      - Local rebuilds of a fused or damaged region: Sculptris Pro on a mesh without levels, instead of raising the whole DynaMesh (DynaMesh and Sculptris Pro doc, "Refinement"; Pablo VRisbJQAaZw 00:10:41): the sculpting skill's tool.
      - Level discipline on the rebuilt stack when handing over: big corrections at SDiv 1, secondary forms at SDiv 2 to 3, never detail only at the top (Pablo rArw79xEpvE 00:12:28 to 00:14:16).
      - Judge the result with `references/critique.md` section 6 before any detail pass; hand the forms to scenario-zbrush-sculpting.
      
      ## 12. Z4 end to end: 12M character to Maya and a game engine
      
      ```python
      state = rx.zcall("preflight", 180.0)                                  # 1 flags, names, scale
      zl.call("zb_ops", "save_ztl", "/abs/work/hero.ztl")
      # neutral face? if the sculpt smiles, scenario-zbrush-character-creature neutralizes it first (section 2)
      inp = rx.zcall("remesh_input", "auto", max_points=1_500_000, fill_holes=False,
                     timeout=1800)                                          # 2 under 8M; eye sockets
                                                                            # and mouth stay open
      # 3 groups: human rings around eyes and mouth if the face deforms; buttons for the rest;
      #   ears and hands split and remeshed on their own (section 3)
      rx.zcall("polish_band", "groups")
      z = rx.zcall("zremesh", 15, False, keep_groups=True, smooth_groups=0, timeout=1800)  # 4 posed? False
      runs = []
      for size in (0, 5, 21):                                               # sheets per variant
          r = rx.zcall("retry", adaptive_size=size, timeout=1800)
          runs.append({"adaptive_size": size, "faces_after": r["faces_after"]})
      pick = rx.pick_retry(runs, target_faces=15000, band=z["band"])        # or choice= from the sheets
      back = rx.zcall("retry", timeout=1800, **pick["retry"])
      assert rx.confirm_retry(pick, back["faces_after"])
      if z["snap_back"]:
          rx.zcall("project_stack", inp["copy"], inp["source"], levels=0,
                   checkpoint="/abs/work/hero_snap.ztl", timeout=1800)
      # gate: in band, topo_report + retopo_verdict(purpose="rig",
      #       expected_holes=0 if inp["openings_closed"] else 3), Polyframe sheet.
      # STOP if rings fail: human Retopo pass (section 4), then re-run the gate
      rx.zcall("unwrap_creases")            # 5 UVs at level 1 before levels (or "uvmaster_roundtrip")
      ps = rx.zcall("project_stack", inp["copy"], inp["source"], top_ratio=0.5, order="top_first",
                    stop_after="nets", checkpoint="/abs/work/hero_project.ztl",
                    timeout=7200)                                            # 6 film face: protect eyes
      # HUMAN: isolate the eye-interior group; then
      rx.zcall("protect_isolated", grow=1)
      pl = rx.zcall("project_levels", inp["copy"], inp["source"], nets_done=True,
                    checkpoint="/abs/work/hero_project.ztl", timeout=7200)
      zl.call("zb_ops", "mask", "clear")
      # gate: projection verdict from exports or stack numbers, danger-zone close-ups per level
      rx.zcall("drop_morph_target")                                         # before any bake
      m = rx.zcall("mme_create_all", "/abs/out/maps", "hero", settings="arnold", timeout=7200)  # 7 film
      rx.zcall("set_export_scale", 180.0, axis=1)                           # 8 cm for Maya, all SubTools
      rx.zcall("set_export_switches", quads=True, uvs=True, groups=False)
      maya = rx.zcall("export_subtools", "/abs/out/maya", level="lowest")   # body and eyes, level 1
      # game: a triangulated copy of level 1 plus a decimated high for scenario-blender-uv-baking
      rx.zcall("set_export_switches", quads=False, uvs=True, groups=False)
      game = rx.zcall("export_subtools", "/abs/out/game", level="lowest", suffix="_low")
      rx.zcall("decimate_copy", target_points=2_000_000, keep_uvs=False, timeout=1800)
      rx.handoff_report("/abs/out/handoff.json", target="arnold", meshes=[f["path"] for f in maya],
                        maps=m["files"], unit="cm", axis="Y-up", export_scale=None,
                        displacement={"mid": 0.5, "scale": 1.0, "bits": 32}, normal="tangent OpenGL",
                        not_verified=["MME UDIM file names", "normal green convention (live_rx_04)",
                                      "morph target as map base (drop_morph_target)"])
      ```
      
      UVs can go before or after the stack: made at level 1 they carry through the levels (Gaboury cemmalvugFk 00:03:30); the retopology digest's Route A makes them on the level-1 target before projecting, Drust makes them on the rebuilt stack's level 1 (_ips3GhWI0s 00:07:03). The eyes keep their own simple topology and UVs and are exported as separate OBJs; maps for them only if the lookdev asks [added]. The per_level order (`project_stack(copy, source, top_ratio=0.5)` in one call) is the simpler route when no zone needs a mask.
      
      ## 13. Item paths this module adds (evidence)
      
      | Key                                                                                                             | Path tried first                                                                                            | Evidence                                                                               |
      | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
      | close_holes, del_hidden, weld_points, unweld_groups                                                             | `Tool:Geometry:Modify Topology:<label>` then `Tool:Geometry:<label>`                                        | [cmdxml] short forms `Close Holes`, `Del Hidden`, `WeldPoints`, `Unweld Groups Border` |
      | check_mesh, fix_mesh                                                                                            | `Tool:Geometry:Mesh Integrity:<label>`                                                                      | [cmdxml] `Check Mesh Integrity`, `Fix Mesh`                                            |
      | freeze_sdiv, reconstruct                                                                                        | `Tool:Geometry:Freeze SubDivision Levels`, `Reconstruct Subdiv`                                             | [cmdxml]                                                                               |
      | zr_retry, zr_keep_polypaint                                                                                     | `Tool:Geometry:ZRemesher:Retry`, `KeepPolypaint`                                                            | [uistr] only                                                                           |
      | zr_freeze_border, zr_smooth_groups, zr_curves_strength, zr_use_polypaint, zr_color_density, zr_legacy           | `Tool:Geometry:ZRemesher:<label>`                                                                           | [cmdxml] short forms, [uistr]                                                          |
      | split_parts, split_hidden, groups_split, merge_down, merge_visible                                              | `Tool:SubTool:Split:...`, `Tool:SubTool:Merge:...`                                                          | [cmdxml] short forms                                                                   |
      | project_history, proj_shell, proj_farthest, proj_outer, proj_inner, proj_geometry, proj_color, reproject_higher | `Tool:SubTool:Project:<label>`                                                                              | [cmdxml] `Tool:SubTool:ProjectAll` etc.                                                |
      | uv_creased_edges, uv_crease_seams                                                                               | `Tool:UV Map:Create (Unwrap):Creased Edges`                                                                 | [uistr] label; group form as in the proven `...:Create (Unwrap):Unwrap` [bridge]       |
      | uvm_copy, uvm_paste, uvm_ao_attract, uvm_check_seams, uvm_flatten                                               | `Zplugin:UV Master:<label>`                                                                                 | [uistr] `Copy UVs`, `Paste UVs`, `AttractFromAmbientOccl`, `CheckSeams`                |
      | nm__, dm__                                                                                                      | `Tool:Normal Map:<label>`, `Tool:Displacement Map:<label>`                                                  | [cmdxml]                                                                               |
      | exp__, imp__                                                                                                    | `Tool:Export:Qud/Tri/Txr/Flp/Mrg/Grp/Smooth Normals/Scale/X Offset...`, `Tool:Import:Mrg/Add/Tri2Quad/Weld` | [cmdxml]                                                                               |
      | dmx_freeze_borders, dmx_keep_polypaint, dmx_k_points                                                            | `Zplugin:Decimation Master:Freeze borders` (lower-case b), `Use and Keep Polypaint`, `k Points`             | [uistr]                                                                                |
      | sb_*                                                                                                            | `Texture:Substance Bridge:Send to Painter`, `Low & High`, `Auto-Bake`, `Force Auto-Unwrap`                  | [uistr]                                                                                |
      | fbx_*                                                                                                           | `Zplugin:FBX ExportImport:Export`, `MayaYUp`, `Unity`, `Tris`, `SNormals`                                   | [uistr]; FBX 2009 to 2020 listed                                                       |
      | polyframe                                                                                                       | `Transform:PolyF`, then `Transform:Pf`                                                                      | [cmdxml] id `Transform: Pf`                                                            |
      | macro_delete                                                                                                    | `Macro:Delete` (answers the confirmation with IKeyPress '2')                                                | [macro] shipped `ZData/Macros/Delete.TXT` [verify press from Python]                   |
      
      | del_mt, layer_new | `Tool:Morph Target:DelMT`, `Tool:Layers:New` | [cmdxml]; DelMT enabled only with a morph target [verify] |
      | (zb_ops) append, x_pos, y_pos, z_pos, xyz_size, vis_grow, vis_show, mask_all | `Tool:SubTool:Append` then `PopUp:<primitive>`, `Tool:Geometry:X Position` ..., `Tool:Geometry:XYZ Size`, `Tool:Visibility:Grow`, `Tool:Visibility:ShowPt`, `Tool:Masking:MaskAll` | [cmdxml] for the Tool items; PopUp names from the shipped macros [verify] |
      
      `probe_paths()` checks every candidate (and the MME grouped forms) with `exists()`; live_rx_01 records the forms that resolve. Both the grouped form (`Tool:Geometry:Modify Topology:Close Holes`) and the commands.xml id (`Tool:Geometry:Close Holes`) are tried, never assumed: the lead's DynaMesh calls with grouped paths worked through the bridge and ZBrush logged the short form (zb_ops, Activity log), and `set()` on a missing path is silent, so every wrapper resolves with `exists()` first. SubTool indices are never cached across Duplicate, Split, MergeDown or Append: `index_of(name)` (SDK `locate_subtool_by_name`, table fallback).
      
      ## 14. Tests
      
      | File                            | What it proves                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | Status                |
      | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------- |
      | `test_rx_zbrush_side.py`        | guards, read-backs, press order, numbers of every ZBrush-side wrapper against `rx_fake.py`                                                                                                                                                                                                                                                                                                                                                                                                                                                       | offline, pass         |
      | `test_rx_agent.py`              | topology, rings, UVs, scale, maps (EXR header without libraries, OpenCV values, 16-bit TIFF, normal orientation), GLB conversion, triage, cleanup verdict, targets, on fixtures and the real v03 export                                                                                                                                                                                                                                                                                                                                          | offline, pass         |
      | `test_rx_bridge.py`             | `zcall` through the lead's real server module and `zb_launch.run`, module hygiene                                                                                                                                                                                                                                                                                                                                                                                                                                                                | offline, pass         |
      | `test_rx_grade_fixes.py`        | the Z4 and Z6 grade fixes: fill_holes and openings_closed, snap_back, pi
    • sources.md 15.1 KB
      # Sources of scenario-zbrush-retopology-export
      
      Revision 2026-09-24 (v2, after the blind grades `tests/grading/Z4_grade.md` and `Z6_grade.md`; v1 kept in `skills/_versions/zbrush-retopology-export v1 2026-09-24/`): added the neutral-face, ears-and-hands, Close Holes before ZRemesher, Alt+ZRemesher (computer use), Retry return, snap-back, danger-zone and eye-protection, top-first projection with nets, Colorize for color passes, morph target before bakes, `create_normal_map` world-space, DynaMesh polypaint versus polygroups, MergeDown-not-MergeVisible, scripted plugs and orientation-branch teachings; corrected the FBX dialog claim, the hole gate after DynaMesh and the `#Auto scale` evidence; projection now runs through the lead's safe `zb_ops.project_all`. New sources below: FlippedNormals Zp07GW3rND0, Pavlovich 8kWFv1cZlCE and aR02CwyPTUw, Pablo Logic Parts 3 and 8, the DynaMesh doc (all under `notes/sculpting/` or `notes/fundamentals/`).
      
      Each entry: source, who, credential, where, what it is best for, best timestamps. Notes live in the project's `notes/retopology-uv/`, `notes/maps-export/` and `notes/pipeline/` (digests: `_digest_topology_maps_export.md`, `_digest_retopology_visual.md`).
      
      ## Evidence from this install (highest weight for paths and labels)
      
      - `/Applications/Maxon ZBrush 2026/ZData/ZLang/zcommands/commands.xml` (2026.2.1, dated 2026-06-01): 2,357 command ids with full item paths, e.g. `Tool:SubTool:ProjectAll`, `Tool:Geometry:Close Holes`, `Tool:Geometry:Legacy (2018)`, `Transform: Pf`. Best for: `[cmdxml]` path evidence. Read only.
      - `/Applications/Maxon ZBrush 2026/ZData/ZLang/english/UInterface.zsc` (UI strings): plugin items stored as `Zplugin:[Decimation Master]:Freeze borders`, the Multi Map Exporter option groups (`[Options]:[Displacement Map |]:Mid`), Substance Bridge labels (Auto-Bake, Force Auto-Unwrap), FBX versions to 2020, help strings (Tool > Export Mrg = "Merge Uv Cords"). Best for: `[uistr]` label evidence. A string proves the label exists, not the path form.
      - `/Applications/Maxon ZBrush 2026/ZData/Macros/Delete.TXT`: the shipped macro answers the SubTool Delete confirmation with `[IKeyPress,'2',[IPress,Tool:SubTool:Delete]]`.
      - `tests/code/zbrush-expert/fixtures/v03_sphere.obj` (ZBrush 2026.2.1 export, 2026-09-24): header `#Auto scale x=1.000000 y=1.000000 z=1.000000`, `#Auto offset x=0.000000 ...`, one `g Group10389`. Best for: what ZBrush writes in an OBJ (`[obj]`). The `#Auto scale` line is observed in this file, not documented in any note: live_rx_05 confirms it carries a non-1 Export Scale.
      - README "Agent bridge into ZBrush", "Side channels": AppleScript System Events (Accessibility allowed) clicks ZBrush dialogs on this Mac, a candidate route for the FBX options window [verify].
      - Commands.xml also lists `Tool:SubTool:MoveUp` and `MoveDown` (used by `move_subtool`), `Tool:Morph Target:DelMT`, `Tool:Layers:New`, `Tool:Geometry:X Position` to `Z Position`, `Tool:Visibility:Grow` and `ShowPt`, `Tool:Masking:MaskAll`.
      - ZBrush Activity log `~/Library/Preferences/Maxon/ZBrush_03C27D49/Logs/Activity/Activity 2026-09-24-05-20-14.txt`: canonical short paths (`Tool:Geometry:Resolution`), plugin sliders set at startup (`Zplugin:3D Print Hub:Y(mm)`).
      - SDK stub `/Applications/Maxon ZBrush 2026/Documentation/python-api/api/zbrush/commands.py` (also: `press(item_path)` has no modifier argument, so Alt-clicks are computer-use steps; `locate_subtool_by_name`; `is_enabled`) and examples (`examples/modeling/ex_mod_subtool_export.py`, `fragments/zbrush.commands.set_subtool_status.0.py`): `create_normal_map(..., local_coordinates=False)` = world space; displacement needs UVs and the lowest level; `query_mesh3d` 3 to 8 (UV bbox, tiles, per-tile counts and areas); SubTool name from `get_active_tool_path()`; status flags written as absolute values.
      - `sources/zbrush-version-deltas.md` (project): 2026 version facts (Retopo brush 2026.1, Substance Bridge 2026.2, UV Master Polygroups fix 2026.2.0, Cmd+W quits).
      - Lead skill toolkit and tests (`<skills>/scenario-zbrush-expert/scripts`, `tests/code/zbrush-expert`): the proven bridge, dialog-free OBJ export, `zb_audit`; the distiller's `archive/tests/retopology_visual/topo_qa.py` (ring test, reused in `rx.topo_report`).
      
      ## Maxon official documentation (help.maxon.net, 2026 help)
      
      - ZRemesher guide (13 pages: V3, 4.0, Introduction, Using Curves, Adaptive Size, Partial Mesh, Symmetry, Hard Surfaces, High Polycounts, Spirals, Transferring Detail, Tips and Tricks, reference), https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/topology/zremesher/zremesher.html (4.0: .../zremesher-4-0/zremesher-4-0.html; Transferring Detail: .../transferring-detail/transferring-detail.html). Best for: option semantics, the Retry cache rules, the 8M-vertex limit, both detail-transfer recipes, the Legacy contradiction.
      - Retopo brush pages 01 to 07, Topology, Topology Brush, ZSphere Topology, https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/topology/Retopo/01-retopo.html. Best for: when manual beats ZRemesher, the 10k to 20k animated-head target, gestures, patches, legacy tools.
      - Subdivision Levels, Project UndoHistory, Tool > SubTool and Tool > Geometry reference, https://help.maxon.net/zbr/en-us/Content/html/reference-guide/tool/polymesh/subtool/subtool.html and .../geometry/geometry.html. Best for: Project All parameters, Close Holes, Mesh Integrity, Freeze, Reconstruct Subdiv, Unweld Group Border.
      - UV Master, UV Master Unwrap Tutorials, UV Mapping, UV Map Unwrap, Tool > UV Map, https://help.maxon.net/zbr/en-us/Content/html/user-guide/zbrush-plugins/uv-master/uv-master.html and .../uv-mapping/uv-map-unwrap/uv-map-unwrap.html. Best for: polycount ceiling, Protect and Attract rules, Work on Clone, control-map pitfalls, Flatten limits, native unwrap options.
      - Multi Map Exporter, Exporting Your Model, Displacement, Normal, Vector Displacement and Texture maps, Tool > Displacement Map and Normal Map references, https://help.maxon.net/zbr/en-us/Content/html/user-guide/zbrush-plugins/multi-map-exporter/multi-map-exporter.html. Best for: Mid and Scale per bit depth, UDIM naming, Get Scale, Switch MT, vector displacement diagnostics, the ESC warning.
      - Decimation Master, its preferences, 3D Print Hub, https://help.maxon.net/zbr/en-us/Content/html/user-guide/zbrush-plugins/decimation-master/decimation-master.html. Best for: the pre-process model, memory costs, masks and polygroups, cache pitfalls.
      - Tool > Export and Import, Preferences > Importexport, FBX ExportImport and settings, Texture > Substance Bridge, Scale Master, GoZ pages, SubTool Master, https://help.maxon.net/zbr/en-us/Content/html/reference-guide/tool/polymesh/export/export.html, .../zbrush-plugins/fbx-exportimport/fbx-settings/fbx-settings.html, .../reference-guide/texture/substance-bridge/substance-bridge.html, .../zbrush-other-programs/goz/restrictions/restrictions.html, .../zbrush-plugins/scale-master/scale-master.html. Best for: export switches, axis presets ("If in doubt then MayaYUp"), GoZ restrictions, Substance Bridge options, Scale Master units, ScaleOffset (per-SubTool export values).
      - Maxon developer forum, ZBrush SDK category, https://developers.maxon.net/forum/category/55/zbrush-sdk. Best for: the FBX Export Options window that script cannot escape; no SubTool rename API.
      
      ## Experts
      
      - Joseph Drust, Pixologic then Maxon support and #AskZBrush presenter. ZRemesher control: https://www.youtube.com/watch?v=0TlG4Ex9lQA (partial remesh, levels lost [00:02:26]), https://www.youtube.com/watch?v=8D-xqasgUws (Keep Groups, Smooth Groups [00:02:17], Frame Mesh [00:03:41]), https://www.youtube.com/watch?v=IpTYcGxxsdM (curves at 100 [00:02:56], polygroups preferred [00:04:02], Edgeloop Masked Border [00:05:08]), https://www.youtube.com/watch?v=Kjce71jjWJU (symmetry mirror and weld [00:01:11]), https://www.youtube.com/watch?v=N1WABlp45ts (ZSphere edits of ZRemesher output). Reprojection: https://www.youtube.com/watch?v=R2MzFqWMaWY (duplicate, divide, project [00:02:16 to 00:03:52]), https://www.youtube.com/watch?v=_ips3GhWI0s (quads for rigging, polypaint last [00:05:25]), https://www.youtube.com/watch?v=nxMYYsyJt3o (Dist 0.1 [00:02:19], StoreMT, engulf [00:02:52 to 00:07:11]), https://www.youtube.com/watch?v=NrsuP4Vj4Lg (Freeze SubDivision Levels [00:01:17]). UVs: https://www.youtube.com/watch?v=tl_FOvZOqHU (relaxing a flattened clone [00:03:19]). Maps: https://www.youtube.com/watch?v=n0qqpwvn-jA (same-SubTool rule [00:01:14]), https://www.youtube.com/watch?v=2zDAtaQqwh8 (level filters frequency [00:02:53]), https://www.youtube.com/watch?v=RYGkbSUaHHg (Color ID, Unweld [00:03:24]), https://www.youtube.com/watch?v=ADIRhNlvXbI (HD Geometry export). Scale: https://www.youtube.com/watch?v=n2xPrwI9o1U (Export Scale math [00:04:19]), https://www.youtube.com/watch?v=fiIVK6t-2x0 (scan sizing, 63 mm pupils [00:03:27]), https://www.youtube.com/watch?v=YrD29hiokJo (STL at scale, no units [00:05:21]). Decimation: https://www.youtube.com/watch?v=mRfA_WDvvrg (keep polygroups [00:02:35]). Cleanup: https://www.youtube.com/watch?v=PrFQXjs_6_w (single-sided scan to clean sculpt, the Z6 base [00:00:42 to 00:20:58]), https://www.youtube.com/watch?v=udiPVJIODX0 (filling holes, deleting fills [00:04:30]). Best for: button-level behavior and the official numbers.
      - Paul Gaboury, Pixologic then Maxon ZBrush staff, #AskZBrush. https://www.youtube.com/watch?v=34D6VljTjBs (ZRemesher is not for detail [00:00:39], counts for an arm [00:02:18], Project History [00:04:20]), https://www.youtube.com/watch?v=pQbPtH0p5Bg (ProjectionShell and Inner, Dist 1 [00:02:30 to 00:03:02]), https://www.youtube.com/watch?v=cemmalvugFk (UVs on a subdivided mesh, Work on Clone [00:04:00 to 00:07:28]). Best for: the alternatives to the duplicate route and UV Master on stacks.
      - Ian Robinson, Maxon, #AskZBrush. https://www.youtube.com/watch?v=bVX9utHc_ZI (Keep Polypaint off by default [00:01:17]), https://www.youtube.com/watch?v=bICt_XruvQM (Scale Master). Best for: plugin defaults.
      - Michael Pavlovich, games sculptor (Halo 4, Halo MCC, Call of Duty, Doom), Maxon ZBrushLIVE "Pavlovich Workshop" host, CGMA instructor. ZBrush Topology: Face, Body, Whatever! (2023-11-05) https://www.youtube.com/watch?v=n5_cZK-9peg (delete and rebuild [00:21:36], border smoothing [00:05:27, 00:15:18], Smooth Groups 0 [00:17:08], overshoot frames [00:18:50, 00:22:09], ears and hands [00:44:36], density colors [01:01:32], eye icon and Solo [01:07:26], shrink-wrap values [00:54:08]); UV Map Unwrap and crease seams (2023-01-17) https://www.youtube.com/watch?v=cvqoVUX5aBw (Crease PG [00:03:49], Crease Shortest Path [00:05:26], seams out of sight [00:06:00]). Best for: production judgment on ZRemesher and explicit seams.
      - Pablo Munoz Gomez, ZBrushGuides founder, Maxon ZBrushLIVE presenter, Retopo brush beta tester. The fastest way to retopo in ZBrush 2026 (2026-01-30) https://www.youtube.com/watch?v=I4nePXDTrhQ (Yes or No [00:05:59 to 00:07:09], visible SubTools [00:08:33], Space [00:12:46], loops first [00:16:26], patches [00:29:38], "whatever works" [00:32:18]); Understanding ZBrush's Logic Part 7 https://www.youtube.com/watch?v=rArw79xEpvE (5 gave 4,963 [00:03:16], never divide a DynaMesh [00:09:44]). Best for: the 2026 manual route and the production order.
      - FlippedNormals (Henning Sanden and Morten Jaeger, ex-MPC and ex-Framestore film artists). ZBrush to Arnold 32-bit displacement UDIM (2018-04-02) https://www.youtube.com/watch?v=-ThBTEc8L_M (MME switches with reasons [00:04:09 to 00:14:22], Grp off [00:15:08], Arnold setup [00:17:29 to 00:20:35], bad geometry causes most issues [00:21:41]); Working with ZBrush and Substance Painter (2019-05-31) https://www.youtube.com/watch?v=p56N-dN11zY (decimated high to Painter [00:00:42], polygroups as texture sets [00:03:46], smooth normals [00:06:03], ID map [00:09:45]); ZBrush to Blender with GoB https://www.youtube.com/watch?v=2-pFspCbykk (via the lead's sources). Best for: film displacement and the Painter handoff.
      - Laura Gallagher, Outgang; former Lead Character Artist at Eidos Montreal (Deus Ex: Mankind Divided) and Guerrilla Games, CGMA instructor. How does ZBrush deal with units and scale? (2021-10-05) https://www.youtube.com/watch?v=EXjfH_X2hkM (no units [00:04:27], DynaMesh and scale [00:11:41], Export Scale and the readout [00:19:46], Unify vs MultiAppend [00:27:16], 1 to 4 [00:28:23], offsets [00:33:04], MultiAppend fix [00:38:48]). Best for: the two-scales model and safe rescaling.
      - FlippedNormals, Reprojecting Details in ZBrush: Top Production Tip (2018-09-10) https://www.youtube.com/watch?v=Zp07GW3rND0 (note in `notes/sculpting/`): Store MT then New Layer at the top level [00:02:13], staged projection from level 1 [00:03:17], eye protection by a grown eye-interior group [00:05:31], Morph brush not smoothing [00:06:36], danger zones and hard lines in displacement [00:07:10, 00:07:43], local reprojection [00:09:27]. Best for: projection safety and repair.
      - Michael Pavlovich, Masking Basics (2021-03-19) https://www.youtube.com/watch?v=8kWFv1cZlCE (polypaint lost when DynaMesh ran with Colorize off [00:20:06]) and Materials, MatCaps and Lights (2021-03-19) https://www.youtube.com/watch?v=aR02CwyPTUw (Red Wax [00:03:32], Standard material and a moving light for form reading [00:04:07, 00:04:39]); notes in `notes/sculpting/`. Best for: the DynaMesh color trap and cleanup review materials.
      - Pablo Munoz Gomez, Understanding ZBrush's Logic Part 3 (2025-12-07) https://www.youtube.com/watch?v=gitoJ7B8FmY (MergeVisible makes a new tool, MergeDown merges in the tool [00:03:31, 00:04:11]; brush-free blockout with primitives [00:05:26]) and Part 8 (2025-12-31) https://www.youtube.com/watch?v=VRisbJQAaZw (Sculptris Pro for local resolution [00:10:41, 00:11:53], target points at least the source's [00:17:24]); Part 7 level discipline [rArw79xEpvE 00:12:28 to 00:14:16]; notes in `notes/fundamentals/`. Best for: merges, local rebuilds, projection density, handover rules.
      - Maxon DynaMesh, Sculptris Pro and Subdivision documentation, https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/modeling-basics/creating-meshes/dynamesh/dynamesh.html (note `notes/sculpting/fundamentals-sculpting__dynamesh-sculptris-subdivision`): "PolyGroups > Important" (polypaint on keeps polypaint instead of polygroups), DynaMesh deletes UVs (convert textures to polypaint first). Best for: what survives a remesh.
      - Polycount wiki (game-art practitioners, pages credited to Eric Chadwick, Joe "EarthQuake" Wilson and others), http://wiki.polycount.com/wiki/Texture_Baking, http://wiki.polycount.com/wiki/Normal_map, http://wiki.polycount.com/wiki/Displacement_map, http://wiki.polycount.com/wiki/ReTopologyModeling. Best for: external baking, triangulation, flat color 128/128/255, "assume all tutorials are incorrect until you can verify".
      
      ## Sister skills used for shape and handoffs
      
      - scenario-zbrush-expert (lead): protocol, toolkit, dialog budget, 2026 traps.
      - scenario-maya-retopology-uv, scenario-maya-lookdev (Maya Expert Skills project): what the Maya side expects (cm, Y-up, eyes separate, no n-gons; displacement zero value and scale recorded).
      - scenario-blender-uv-baking (Blender Expert Skills project): `bake_high_to_low` for game normals in MikkTSpace; Normal Map node convention.
      - scenario-3d: GLB outputs of text-to-3D and image-to-3D; part segmentation and remesh utilities upstream.
      
  • scripts
    • zb_retopology_export.py 142.2 KB
      """
      zb_retopology_export ("rx"): the pipeline sculptor's toolkit (skill scenario-zbrush-retopology-export).
      
      It builds on the lead toolkit in <skills>/scenario-zbrush-expert/scripts and never re-implements it:
      zb_ops does every checked press and set (resolve with exists(), read-back, levels guards,
      dialog-free exports), zb_audit measures OBJ exports, zb_launch talks to the bridge. This file
      adds the retopology, projection, UV, map, scale, export and cleanup logic on top.
      
      Inside ZBrush (always through rx.zcall("func", ...), main thread):
        PATHS, probe_paths()            extra item paths with evidence tags, and a live probe
        subtool_table(), set_visible(), show_only(), set_polyframe(), set_display()
        preflight()                     counts, levels, XYZ Size, Export Scale, offsets, names
        remesh_input(), zremesh(), retry(), project_stack(), freeze_levels()
        uv_state(), unwrap_creases(), uvmaster_roundtrip()
        bake_normal_map(), bake_displacement_map(), mme_path(), mme_set(), mme_create_all()
        set_export_switches(), set_export_scale(), set_export_placement(), export_subtools()
        decimate_copy(), decimate_keep_groups()
        import_mesh(), fix_mesh(), close_holes(), split_parts(), hide_small_parts(),
        merge_visible(), merge_down_run(), move_subtool(), merge_into(), color_to_groups(),
        polish_band(), duplicate_keep(), index_of(),
        dynamesh_keep(), plug_hole()
        safety_nets(), protect_isolated(), project_levels(), morph_target_present(),
        drop_morph_target()
      Agent side (system python3 with numpy; PIL and cv2 optional):
        zcall(), build_call_code()      run a function of this module inside ZBrush
        obj_header(), obj_groups(), topo_report(), retopo_verdict(), pick_retry(), confirm_retry(),
        projection_verdict(), projection_from_stack()
        uv_report(), uv_verdict(), scale_report(), names_report(), obj_transform()
        map_info(), map_verdict(), normal_orientation(), height_orientation()
        glb_to_obj()                    scenario-3d GLB to OBJ (UVs flipped to OBJ, colour as #MRGB)
        roughness(), triage_ai_mesh(), orient_for_import(), hole_plugs(), cleanup_verdict()
        RENDERERS, MME_PRESETS, renderer_plan(), handoff_report()
      Pure helpers (both sides): expected_band(), levels_needed(), export_scale_for(),
        placement_offsets(), percent_for_target(), dynamesh_resolution_for(), resolution_for_points()
      
      Evidence tags, as in zb_ops:
        [uistr]  label present in ZData/ZLang/english/UInterface.zsc of 2026.2.1 (plugin labels are
                 stored there as "Zplugin:[Plugin]:Label"); proves the label, not the path form
        [cmdxml] id listed in ZData/ZLang/zcommands/commands.xml (2026.2.1, dated 2026-06-01)
        [bridge] [log] [doc] [macro] [obj] [verify]  see zb_ops
      Item paths ignore case and spaces (SDK docs), so "Tool:SubTool:Project All" and the
      commands.xml id "Tool:SubTool:ProjectAll" are the same path.
      
      The agent-side functions are tested offline (tests/code/zbrush-retopology-export).
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import importlib
      import json
      import math
      import os
      import re
      import struct
      import sys
      import time
      
      _HERE = os.path.dirname(os.path.abspath(__file__))
      EXPERT_SCRIPTS = os.path.normpath(os.path.join(_HERE, "..", "..", "scenario-zbrush-expert", "scripts"))
      
      
      def _import_sibling(name):
          """Import a lead-toolkit module without leaving its folder on sys.path (shared-interpreter
          rule of the ZBrush SDK style guide)."""
          try:
              return importlib.import_module(name)
          except ImportError:
              sys.path.insert(0, EXPERT_SCRIPTS)
              try:
                  return importlib.import_module(name)
              finally:
                  if EXPERT_SCRIPTS in sys.path:
                      sys.path.remove(EXPERT_SCRIPTS)
      
      
      zb_ops = _import_sibling("zb_ops")      # pure Python at import time; zbc is None outside ZBrush
      ZBOpError = zb_ops.ZBOpError
      
      # --------------------------------------------------------------------------------------------
      # Item paths not in zb_ops.PATHS. Keys shared with zb_ops are used through zb_ops directly.
      # --------------------------------------------------------------------------------------------
      
      def _grp(section, label, *extra):
          """Grouped form first (unambiguous), then the short form ZBrush logs (Activity log)."""
          palette, sub = section.rsplit(":", 1) if section.count(":") >= 2 else (section, None)
          out = [f"{section}:{label}"]
          if sub:
              out.append(f"{palette}:{label}")
          return out + list(extra)
      
      
      PATHS = {
          # SubTool list [cmdxml]
          "split_parts": ["Tool:SubTool:Split:Split To Parts", "Tool:SubTool:Split To Parts"],
          "split_hidden": ["Tool:SubTool:Split:Split Hidden", "Tool:SubTool:Split Hidden"],
          "groups_split": ["Tool:SubTool:Split:Groups Split", "Tool:SubTool:Groups Split"],
          "merge_down": ["Tool:SubTool:Merge:MergeDown", "Tool:SubTool:MergeDown"],  # note on press [verify]
          "move_up": ["Tool:SubTool:MoveUp"],            # [cmdxml]; the selection follows [verify]
          "move_down": ["Tool:SubTool:MoveDown"],        # [cmdxml]
          "merge_visible": ["Tool:SubTool:Merge:MergeVisible", "Tool:SubTool:MergeVisible"],
          "merge_weld": ["Tool:SubTool:Merge:Weld", "Tool:SubTool:Weld"],
          "merge_uv": ["Tool:SubTool:Merge:Uv", "Tool:SubTool:Uv"],
          # projection [cmdxml]; zb_ops has project_all, project_dist, project_mean, project_pa_blur
          "project_history": _grp("Tool:SubTool:Project", "Project History"),
          "proj_shell": _grp("Tool:SubTool:Project", "ProjectionShell"),
          "proj_farthest": _grp("Tool:SubTool:Project", "Farthest"),
          "proj_outer": _grp("Tool:SubTool:Project", "Outer"),
          "proj_inner": _grp("Tool:SubTool:Project", "Inner"),
          "proj_geometry": _grp("Tool:SubTool:Project", "Geometry"),   # History switch; All? [verify]
          "proj_color": _grp("Tool:SubTool:Project", "Color"),         # History switch; All? [verify]
          "reproject_higher": _grp("Tool:SubTool:Project", "Reproject Higher Subdiv"),
          # geometry [cmdxml]
          "freeze_sdiv": ["Tool:Geometry:Freeze SubDivision Levels"],
          "reconstruct": ["Tool:Geometry:Reconstruct Subdiv"],
          "close_holes": _grp("Tool:Geometry:Modify Topology", "Close Holes"),
          "del_hidden": _grp("Tool:Geometry:Modify Topology", "Del Hidden"),
          "weld_points": _grp("Tool:Geometry:Modify Topology", "WeldPoints"),
          "unweld_groups": _grp("Tool:Geometry:Modify Topology", "Unweld Groups Border"),
          "check_mesh": _grp("Tool:Geometry:Mesh Integrity", "Check Mesh Integrity"),  # may raise a note
          "fix_mesh": _grp("Tool:Geometry:Mesh Integrity", "Fix Mesh"),
          "create_shell": _grp("Tool:Geometry:DynaMesh", "Create Shell"),
          "shell_thickness": _grp("Tool:Geometry:DynaMesh", "Thickness"),
          "crease_pg": _grp("Tool:Geometry:Crease", "Crease PG"),
          "uncrease_all": _grp("Tool:Geometry:Crease", "UnCreaseAll"),
          "edgeloop_masked": _grp("Tool:Geometry:Edge Loop", "Edgeloop Masked Border"),
          "x_size": _grp("Tool:Geometry:Size", "X Size"),
          "y_size": _grp("Tool:Geometry:Size", "Y Size"),
          "z_size": _grp("Tool:Geometry:Size", "Z Size"),
          # ZRemesher options zb_ops does not cover [cmdxml] [uistr]
          "zr_retry": _grp("Tool:Geometry:ZRemesher", "Retry"),               # [uistr] only
          "zr_freeze_border": _grp("Tool:Geometry:ZRemesher", "FreezeBorder"),
          "zr_freeze_groups": _grp("Tool:Geometry:ZRemesher", "FreezeGroups"),
          "zr_smooth_groups": _grp("Tool:Geometry:ZRemesher", "SmoothGroups"),
          "zr_curves_strength": _grp("Tool:Geometry:ZRemesher", "Curves Strength"),
          "zr_use_polypaint": _grp("Tool:Geometry:ZRemesher", "Use Polypaint"),
          "zr_color_density": _grp("Tool:Geometry:ZRemesher", "ColorDensity"),
          "zr_keep_polypaint": _grp("Tool:Geometry:ZRemesher", "KeepPolypaint"),  # [uistr] only
          "zr_legacy": _grp("Tool:Geometry:ZRemesher", "Legacy (2018)"),       # listed, removed per doc
          # deformation, masking, polygroups, polypaint [cmdxml]
          "polish_features": ["Tool:Deformation:Polish By Features"],
          "polish_groups": ["Tool:Deformation:Polish By Groups"],
          "smart_resym": ["Tool:Deformation:Smart ReSym"],
          "relax": ["Tool:Deformation:Relax"],
          "mask_by_feature": ["Tool:Masking:MaskByFeature", "Tool:Masking:Mask By Feature"],
          "mbf_border": ["Tool:Masking:Border"],
          "mbf_groups": ["Tool:Masking:Groups"],
          "mbf_crease": ["Tool:Masking:Crease"],
          "mask_intensity": ["Tool:Masking:Mask By Intensity"],
          "pg_from_polypaint": ["Tool:Polygroups:From Polypaint"],
          "pg_tolerance": ["Tool:Polygroups:PTolerance"],
          "pg_merge_stray": ["Tool:Polygroups:Merge Stray Groups"],
          "pg_max_angle": ["Tool:Polygroups:MaxAngle"],
          "pp_from_texture": ["Tool:Polypaint:Polypaint From Texture"],
          "pp_from_groups": ["Tool:Polypaint:Polypaint From Polygroups"],
          "colorize": ["Tool:Polypaint:Colorize"],
          # UV Map and Texture Map [cmdxml]; Create (Unwrap) labels [uistr]
          "uv_creased_edges": ["Tool:UV Map:Create (Unwrap):Creased Edges", "Tool:UV Map:Creased Edges"],
          "uv_crease_seams": ["Tool:UV Map:Create (Unwrap):Crease Seams", "Tool:UV Map:Crease Seams"],
          "uv_delete": ["Tool:UV Map:Delete UV"],
          "uv_size": ["Tool:UV Map:UV Map Size"],
          "uv_border": ["Tool:UV Map:UV Map Border"],
          "uv_morph": ["Tool:UV Map:Morph UV"],
          "uv_flip_v": ["Tool:UV Map:Flip V"],
          "uv_check_texture": ["Tool:Texture Map:New From UV Check"],
          "texture_from_polypaint": ["Tool:Texture Map:New From Polypaint"],
          # UV Master [uistr]; zb_ops has uvm_unwrap, uvm_symmetry, uvm_polygroups, uvm_clone
          "uvm_copy": ["Zplugin:UV Master:Copy UVs"],
          "uvm_paste": ["Zplugin:UV Master:Paste UVs"],
          "uvm_ao_attract": ["Zplugin:UV Master:AttractFromAmbientOccl"],
          "uvm_check_seams": ["Zplugin:UV Master:CheckSeams"],
          "uvm_flatten": ["Zplugin:UV Master:Flatten"],
          "uvm_unflatten": ["Zplugin:UV Master:UnFlatten"],
          "uvm_existing_seams": ["Zplugin:UV Master:Use Existing UV Seams"],
          "uvm_control_painting": ["Zplugin:UV Master:Enable Control Painting"],
          "uvm_clear_maps": ["Zplugin:UV Master:Clear Maps"],
          # Tool palette maps [cmdxml]
          "nm_tangent": ["Tool:Normal Map:Tangent"],
          "nm_adaptive": ["Tool:Normal Map:Adaptive"],
          "nm_smooth_uv": ["Tool:Normal Map:SmoothUV"],
          "nm_flip_r": ["Tool:Normal Map:FlipR"],
          "nm_flip_g": ["Tool:Normal Map:FlipG"],
          "nm_create": ["Tool:Normal Map:Create NormalMap"],
          "nm_clone": ["Tool:Normal Map:Clone NM"],
          "dm_mid": ["Tool:Displacement Map:Mid"],
          "dm_scale": ["Tool:Displacement Map:Scale"],
          "dm_32bit": ["Tool:Displacement Map:32Bit"],
          "dm_3ch": ["Tool:Displacement Map:3 Channels"],
          "dm_adaptive": ["Tool:Displacement Map:Adaptive"],
          "dm_dpsubpix": ["Tool:Displacement Map:DPSubPix"],
          "dm_smooth_uv": ["Tool:Displacement Map:SmoothUV"],
          "dm_flip_v": ["Tool:Displacement Map:Flip V"],
          "dm_create": ["Tool:Displacement Map:Create DispMap"],
          "dm_create_export": ["Tool:Displacement Map:Create And Export Map"],
          "texture_export": ["Texture:Export"],
          # Tool > Export and Import [cmdxml]; help strings: Mrg "Merge Uv Cords", Grp "Export Sub
          # Groups" [uistr]
          "exp_qud": ["Tool:Export:Qud"], "exp_tri": ["Tool:Export:Tri"], "exp_txr": ["Tool:Export:Txr"],
          "exp_flp": ["Tool:Export:Flp"], "exp_mrg": ["Tool:Export:Mrg"], "exp_grp": ["Tool:Export:Grp"],
          "exp_snormals": ["Tool:Export:Smooth Normals"], "exp_scale": ["Tool:Export:Scale"],
          "exp_xoff": ["Tool:Export:X Offset"], "exp_yoff": ["Tool:Export:Y Offset"],
          "exp_zoff": ["Tool:Export:Z Offset"],
          "imp_mrg": ["Tool:Import:Mrg"], "imp_add": ["Tool:Import:Add"],
          "imp_tri2quad": ["Tool:Import:Tri2Quad"], "imp_weld": ["Tool:Import:Weld"],
          "goz": ["Tool:GoZ"], "goz_all": ["Tool:All"], "goz_visible": ["Tool:Visible"],
          "tool_clone": ["Tool:Clone"],
          "display_double": ["Tool:Display Properties:Double"],
          "display_flip": ["Tool:Display Properties:Flip"],
          "polyframe": ["Transform:PolyF", "Transform:Pf"],   # commands.xml id is "Transform: Pf"
          "pref_eswitch_yz": ["Preferences:ImportExport:eSwitchYZ"],
          "pref_iswitch_yz": ["Preferences:ImportExport:iSwitchYZ"],
          "pref_import_groups": ["Preferences:ImportExport:Import PolyGroups"],
          # Decimation Master [uistr]; zb_ops has dm_percent, dm_preprocess, dm_decimate, dm_keep_uvs
          "dmx_freeze_borders": ["Zplugin:Decimation Master:Freeze borders"],
          "dmx_keep_polypaint": ["Zplugin:Decimation Master:Use and Keep Polypaint"],
          "dmx_k_points": ["Zplugin:Decimation Master:k Points"],
          "dmx_k_polys": ["Zplugin:Decimation Master:k Polys"],
          "dmx_preprocess_all": ["Zplugin:Decimation Master:Pre-process All"],
          "dmx_decimate_all": ["Zplugin:Decimation Master:Decimate All"],
          # Substance Bridge 2026.2 [uistr] (labels differ from the doc: "Auto-Bake", "Force
          # Auto-Unwrap", "PolyPaint")
          "sb_send": ["Texture:Substance Bridge:Send to Painter"],
          "sb_low_high": ["Texture:Substance Bridge:Low & High"],
          "sb_autobake": ["Texture:Substance Bridge:Auto-Bake"],
          "sb_force_unwrap": ["Texture:Substance Bridge:Force Auto-Unwrap"],
          "sb_smooth_normals": ["Texture:Substance Bridge:Smooth Normals"],
          "sb_per_polygroup": ["Texture:Substance Bridge:Per PolyGroup"],
          "sb_per_subtool": ["Texture:Substance Bridge:Per Subtool"],
          "sb_polypaint": ["Texture:Substance Bridge:PolyPaint"],
          # FBX ExportImport [uistr]: the Export press raises "FBX Export Options" (Maxon forum 2025)
          "fbx_export": ["Zplugin:FBX ExportImport:Export"],
          "fbx_maya_yup": ["Zplugin:FBX ExportImport:MayaYUp"],
          "fbx_unity": ["Zplugin:FBX ExportImport:Unity"],
          "fbx_tris": ["Zplugin:FBX ExportImport:Tris"],
          "fbx_snormals": ["Zplugin:FBX ExportImport:SNormals"],
          "fbx_visible": ["Zplugin:FBX ExportImport:Visible"],
          # Scale Master [uistr]
          "sm_unify": ["Zplugin:Scale Master:ZBrush Scale Unify"],
          "sm_center": ["Zplugin:Scale Master:Center Subtools to World"],
          # shipped macro that answers the Delete confirmation with [IKeyPress,'2',...] [macro]
          "macro_delete": ["Macro:Delete"],
          "polymesh_star": ["Tool:PolyMesh3D"],
      }
      
      MME_ROOT = "Zplugin:Multi Map Exporter"
      MME_GROUPS = {"disp": "Displacement Map", "normal": "Normal Map", "vd": "Vector Disp Map",
                    "ao": "Ambient Occlusion Map", "cavity": "Cavity Map", "mesh": "Mesh Export"}
      
      
      def mme_path(label, group=None):
          """Candidate paths for a Multi Map Exporter item. The string table stores option items as
          "Zplugin:[Multi Map Exporter]:[Options]:[Displacement Map |]:Mid" [uistr]; labels such as
          Mid, SubDiv level, Adaptive, SmoothUV, 32Bit and exr repeat across groups, so the short
          form is ambiguous and comes last. Which form resolves is [verify] (live_rx_01)."""
          if group is None:
              return [f"{MME_ROOT}:{label}", f"{MME_ROOT}:Options:{label}"]
          g = MME_GROUPS[group]
          return [f"{MME_ROOT}:{g} |:{label}", f"{MME_ROOT}:Options:{g} |:{label}",
                  f"{MME_ROOT}:{g}:{label}", f"{MME_ROOT}:Options:{g}:{label}", f"{MME_ROOT}:{label}"]
      
      
      # zb_ops keys this module relies on (probed live with the rest)
      OPS_KEYS = ("duplicate", "project_all", "project_dist", "project_mean", "project_pa_blur",
                  "store_mt", "zr_button", "zr_target", "zr_half", "zr_same", "zr_double", "zr_adapt",
                  "zr_adaptive_size", "zr_keep_groups", "zr_keep_creases", "zr_detect_edges",
                  "uv_unwrap", "uv_auto_seams", "uv_symmetry", "uvm_unwrap", "uvm_symmetry",
                  "uvm_polygroups", "uvm_clone", "dm_percent", "dm_preprocess", "dm_decimate",
                  "dm_keep_uvs", "export", "import", "xyz_size", "sdiv", "del_lower", "del_higher",
                  "mirror_weld", "pg_auto", "pg_normals", "mask_clear", "mask_inverse", "mask_grow",
                  "vis_hide", "vis_show", "vis_grow", "mask_all", "append", "x_pos", "y_pos", "z_pos",
                  "dyn_res", "dyn_button", "dyn_blur", "dyn_project", "del_mt", "layer_new", "switch_mt",
                  "polish", "inflate", "unify", "symmetry", "sym_x")
      
      VISIBLE = 0x0001        # eye of the SubTool (SDK get_subtool_status)
      FOLDER_VISIBLE = 0x0002
      
      # --------------------------------------------------------------------------------------------
      # Pure helpers (both sides)
      # --------------------------------------------------------------------------------------------
      
      
      def P(key):
          """Candidate list for a key of this module, or the zb_ops key itself."""
          return PATHS[key] if key in PATHS else key
      
      
      def expected_band(adaptive=True, keep_groups=False, freeze_border=False):
          """Face count / target band to accept a ZRemesher run before looking at it.
          Adapt on + Keep Groups: 5k gave 8.0k to 9.4k points on a grouped face (Pavlovich n5,
          frames 00:18:50, 00:22:09), so 0.8 to 2.0. Adapt off hits the count (ZRemesher doc) ->
          0.9 to 1.1. Freeze Border forces Adaptive Density (doc) -> 0.5 to 2.0. Adapt on without
          groups: Pablo got 4,963 for 5 (rArw79xEpvE 00:03:16) -> 0.8 to 1.3. Bands [added].
          Units: Pavlovich read POINTS; the band is applied to faces, which on a closed quad mesh of
          genus 0 differ from points by 2 (V = F + 2), and by a few per opening on open meshes."""
          if freeze_border:
              return (0.5, 2.0)
          if not adaptive:
              return (0.9, 1.1)
          return (0.8, 2.0) if keep_groups else (0.8, 1.3)
      
      
      def levels_needed(level1_points, source_points, top_ratio=0.5, max_levels=7):
          """Divisions so the top level reaches top_ratio x the source point count (each Divide is
          x4: Pablo Logic Part 7; Drust divides until close to the source, R2MzFqWMaWY 00:02:16)."""
          if level1_points <= 0 or source_points <= 0:
              raise ValueError("point counts must be positive")
          n = 0
          while level1_points * 4 ** n < top_ratio * source_points and n < max_levels:
              n += 1
          return n
      
      
      def export_scale_for(target_size, internal_extent):
          """Exported size = internal size x Export Scale (Drust n2xPrwI9o1U 00:04:19), so
          Scale = target / internal extent. 254 / 3.5 = 72.57 (Drust 00:06:37); 15 / 2 = 7.5
          (Gallagher EXjfH_X2hkM 00:21:22)."""
          if internal_extent <= 0:
              raise ValueError("internal extent must be positive")
          return target_size / internal_extent
      
      
      def placement_offsets(bbox, feet_on_ground=True, center_xz=True):
          """Export offsets in internal units from a bbox (-x,-y,-z,+x,+y,+z). Gallagher: offsets
          are internal units and Y Offset 1 stands a 2-unit model on Maya's grid (EXjfH_X2hkM
          00:33:04), i.e. y_off = -ymin; x and z centre the model [added: sign to confirm with the
          OBJ header '#Auto offset' and the exported bbox, live_rx_05]."""
          x0, y0, z0, x1, y1, z1 = [float(v) for v in bbox]
          return {"x": -(x0 + x1) / 2 if center_xz else 0.0,
                  "y": -y0 if feet_on_ground else 0.0,
                  "z": -(z0 + z1) / 2 if center_xz else 0.0}
      
      
      def percent_for_target(points_now, target_points):
          """Decimation Master "% of decimation" for a point target: target / current x 100
          (community ZScript formula, topology-export__decimation-master note) [verify units]."""
          if points_now <= 0:
              raise ValueError("points_now must be positive")
          return max(0.01, min(100.0, 100.0 * target_points / points_now))
      
      
      # DynaMesh calibration from the v03 export: a radius-1 sphere at resolution 128 gave 43,480
      # points on area 12.566 (4 pi). points = K * area * (res / 2)^2 -> K = 0.845, i.e. a cell of
      # about 2 / res internal units. Resolution counts the scene's unit space, not the object
      # (Gallagher 00:11:41; Pablo Logic Part 5 00:13:40). One calibration point: [added].
      DYNAMESH_K = 43480.0 / (4 * math.pi * 64.0 ** 2)
      
      
      def dynamesh_resolution_for(feature_size, cells=8):
          """Resolution that gives `cells` voxels across the thinnest feature (internal units)."""
          if feature_size <= 0:
              raise ValueError("feature_size must be positive")
          return int(math.ceil(2.0 * cells / feature_size))
      
      
      def resolution_for_points(area, points):
          """Resolution expected to give about `points` on a surface of `area` (internal units)."""
          if area <= 0 or points <= 0:
              raise ValueError("area and points must be positive")
          return int(round(2.0 * math.sqrt(points / (DYNAMESH_K * area))))
      
      
      def names_report(names):
          """GoZ needs unique Tool and SubTool names without spaces or special characters, across
          all loaded Tools (GoZ doc, Restrictions); Decimation Master needs unique names (doc)."""
          seen, dup = set(), []
          for n in names:
              if n in seen and n not in dup:
                  dup.append(n)
              seen.add(n)
          bad = [n for n in names if not re.fullmatch(r"[A-Za-z0-9_]+", n or "")]
          return {"names": list(names), "duplicates": dup, "unsafe": bad, "ok": not dup and not bad}
      
      
      # --------------------------------------------------------------------------------------------
      # Inside ZBrush (not yet run in ZBrush)
      # --------------------------------------------------------------------------------------------
      
      def _z():
          return zb_ops._z()
      
      
      def _get(key, default=None):
          p = zb_ops.resolve(P(key), required=default is None)
          return _z().get(p) if p else default
      
      
      def _switch(key, on, required=True):
          """Set a switch to 0/1 with read-back; skip silently when optional and absent."""
          if not required and zb_ops.resolve(P(key), required=False) is None:
              return None
          return zb_ops.set_checked(P(key), 1 if on else 0, tol=0.5)
      
      
      def _press(key, check_enabled=True):
          p = zb_ops.press(P(key), check_enabled=check_enabled)
          _z().update(redraw_ui=True)
          return p
      
      
      def probe_paths(keys=None, include_ops=True, include_mme=True):
          """exists() on every candidate path. Read-only. Run once per build (live_rx_01) and record
          the forms that resolve in references/procedures.md."""
          z = _z()
          out = {k: {p: bool(z.exists(p)) for p in PATHS[k]} for k in (keys or sorted(PATHS))}
          if keys is None and include_ops:
              for k in OPS_KEYS:
                  out["zb_ops." + k] = {p: bool(z.exists(p)) for p in zb_ops.PATHS[k]}
          if keys is None and include_mme:
              for g, label in MME_PROBE:
                  out[f"mme.{g or 'main'}.{label}"] = {p: bool(z.exists(p)) for p in mme_path(label, g)}
          return out
      
      
      MME_PROBE = [(None, "Displacement"), (None, "Normal"), (None, "Cavity"),
                   (None, "Ambient Occlusion"), (None, "Texture from Polypaint"),
                   (None, "Vector Displacement"), (None, "Export Mesh"), (None, "SubTools"),
                   (None, "Merge Maps"), (None, "FlipV"), (None, "Map Size"), (None, "Map Border"),
                   (None, "Create All Maps"), (None, "File names"), (None, "Load/Save Presets"),
                   ("disp", "SubDiv level"), ("disp", "Mid"), ("disp", "Scale"), ("disp", "32Bit"),
                   ("disp", "exr"), ("disp", "3 Channels"), ("disp", "Adaptive"),
                   ("disp", "DpSubPix"), ("disp", "SmoothUV"), ("normal", "Tangent"),
                   ("normal", "FlipG"), ("normal", "SubDiv level"), ("normal", "SmoothUV"),
                   ("ao", "16Bit"), ("cavity", "16Bit")]
      
      
      def _name_from_path(path):
          return str(path).replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
      
      
      def subtool_table(with_counts=True):
          """One row per SubTool: index, name, visibility (folder-aware, zb_ops.subtools), and with
          counts the points, faces, levels, bbox, UVs. The name comes from get_active_tool_path()
          after select_subtool(i), as in Maxon's ex_mod_subtool_export example [doc]."""
          z = _z()
          active = z.get_active_subtool_index()
          vis = {r["index"]: r for r in zb_ops.subtools()}
          rows = []
          try:
              for i in range(z.get_subtool_count()):
                  z.select_subtool(i)
                  row = {"index": i, "name": _name_from_path(z.get_active_tool_path()),
                         "visible": vis[i]["visible"], "status": vis[i]["status"],
                         "folder": vis[i]["folder"]}
                  if with_counts:
                      s = zb_ops.stats()
                      row.update({k: s.get(k) for k in ("points", "faces", "bbox", "volume", "solid",
                                                         "sdiv", "sdiv_max")})
                      row["has_uvs"] = s.get("uv_bbox") is not None
                  rows.append(row)
          finally:
              z.select_subtool(active)
          return rows
      
      
      def set_visible(index, on):
          """Eye of one SubTool, with read-back. Maxon's fragment writes the absolute status
          (status | 0x1); the stub text calls the value "toggle logic". Absolute first, read back,
          then a toggle write if the bit did not follow [verify which one ZBrush uses]."""
          z = _z()
          want = bool(on)
          s = int(z.get_subtool_status(index))
          if bool(s & VISIBLE) == want:
              method = "unchanged"
          else:
              z.set_subtool_status(index, (s | VISIBLE) if want else (s & ~VISIBLE))
              method = "absolute"
              if bool(int(z.get_subtool_status(index)) & VISIBLE) != want:
                  z.set_subtool_status(index, VISIBLE)
                  method = "toggle"
          s2 = int(z.get_subtool_status(index))
          f = int(z.get_subtool_folder_index(index))
          if want and f >= 0 and not int(z.get_subtool_status(f)) & FOLDER_VISIBLE:
              fs = int(z.get_subtool_status(f))
              z.set_subtool_status(f, fs | FOLDER_VISIBLE)
          if bool(s2 & VISIBLE) != want:
              raise ZBOpError(f"SubTool {index}: eye bit does not follow set_subtool_status "
                              f"(status {s:#x} -> {s2:#x})")
          return {"index": index, "visible": want, "method": method}
      
      
      def show_only(indices):
          """Only these SubTools visible. Project All reads every SubTool whose eye is on, even in
          Solo mode (Pavlovich n5 01:07:26): control sources with visibility, never with Solo."""
          keep = {int(i) for i in indices}
          res = [set_visible(i, i in keep) for i in range(_z().get_subtool_count())]
          _z().update(redraw_ui=True)
          return [r for r in res if r["method"] != "unchanged"]
      
      
      def set_polyframe(on=True):
          """Polyframe for topology screenshots (Shift+F for a human). Label ' Pf' [cmdxml]."""
          return _switch("polyframe", on)
      
      
      def set_display(double=None, flip=None, colorize=None):
          """Display Properties: Double off reveals holes and flipped faces (Pavlovich n5 00:34:20;
          Drust PrFQXjs_6_w 00:03:26 inspects with Double on). colorize: Tool > Polypaint >
          Colorize of the ACTIVE SubTool; the previous value comes back as "colorize_was" so the
          caller restores it after the review sheet. Colorize is not only a display switch: a
          later DynaMesh keeps polypaint instead of polygroups while it is on, and Project All
          moves colour only while it is on for source and target (see dynamesh_keep and
          project_stack)."""
          out = {}
          if double is not None:
              out["double"] = _switch("display_double", double)
          if flip is not None:
              out["flip"] = _switch("display_flip", flip)
          if colorize is not None:
              out["colorize_was"] = bool(_get("colorize", 0.0) >= 0.5)
              out["colorize"] = _switch("colorize", colorize)
          return out
      
      
      def _bbox(mode=1):
          return [float(v) for v in _z().query_mesh3d(2, mode)]
      
      
      def _extent_ratio(b0, b1):
          ext0 = [b0[3] - b0[0], b0[4] - b0[1], b0[5] - b0[2]]
          ext1 = [b1[3] - b1[0], b1[4] - b1[1], b1[5] - b1[2]]
          return round(min(a / b for a, b in zip(ext1, ext0) if b), 5)
      
      
      def preflight(target_size=None, axis=1):
          """Everything to read before touching topology: counts, levels, internal size, Export
          Scale and offsets, UVs, symmetry, SubTool names. Flags carry their source."""
          z = _z()
          s = zb_ops.stats()
          out = {"stats": s, "xyz_size": _get("xyz_size"),
                 "size": {a: _get(a + "_size", 0.0) for a in ("x", "y", "z")},
                 "export": {"scale": _get("exp_scale"), "x_off": _get("exp_xoff"),
                            "y_off": _get("exp_yoff"), "z_off": _get("exp_zoff")},
                 "symmetry": {"on": z.get(zb_ops.resolve("symmetry")),
                              "x": z.get(zb_ops.resolve("sym_x"))}}
          rows = subtool_table(with_counts=False)
          out["subtools"] = rows
          out["names"] = names_report([r["name"] for r in rows])
          flags = []
          if (s.get("points") or 0) > 8_000_000:
              flags.append("ZRemesher input above about 8M vertices: use a lower level or a reduced "
                           "copy (ZRemesher doc, High Polycounts)")
          xyz = out["xyz_size"] or 0
          if not 1.0 <= xyz <= 4.0:
              flags.append(f"XYZ Size {xyz:.3f} outside 1 to 4: DynaMesh resolution and dynamic "
                           "brushes misbehave (Gallagher EXjfH_X2hkM 00:28:23)")
          if not out["export"]["scale"]:
              flags.append("Export Scale 0: never set or imported (Gallagher 00:24:03)")
          if target_size and xyz:
              ext = s["bbox"][axis + 3] - s["bbox"][axis] if s.get("bbox") else None
              if ext:
                  out["real_size_now"] = round(ext * (out["export"]["scale"] or 1.0), 6)
                  out["scale_for_target"] = round(export_scale_for(target_size, ext), 6)
          if not out["names"]["ok"]:
              flags.append("SubTool names not unique or not alphanumeric: GoZ and Decimation Master "
                           "need unique names (GoZ doc, Restrictions)")
          out["flags"] = flags
          return out
      
      
      def remesh_input(method="auto", max_points=2_000_000, level=None, resolution=None,
                       fill_holes=False):
          """Build the ZRemesher input on a DUPLICATE; the original stays the projection source
          (Maxon ZRemesher doc, Transferring Detail; Drust R2MzFqWMaWY 00:00:56).
          method "level": duplicate, go to `level` (or the highest level under max_points), delete
          the other levels. "dynamesh": duplicate, DynaMesh at `resolution` (or the one expected to
          give max_points, DYNAMESH_K). "auto": level when the SubTool has levels, else dynamesh.
          fill_holes: Close Holes then Fix Mesh on the level-less copy (ZRemesher doc, Workflow
          step 2 and Tips: small holes survive retopology and inflate the count). Use it when the
          holes are unintended (scans, AI meshes, sculpting fallout); it also fills intended
          openings (eye sockets, mouth bag), and so does DynaMesh. "openings_closed" in the result
          tells the hole gate what to expect: 0 holes when True, else the intended openings.
          Returns the source and copy indices. The copy lands below the source (SubTool doc)
          [verify index]."""
          z = _z()
          zb_ops.ensure_edit()
          src = z.get_active_subtool_index()
          s0 = zb_ops.stats()
          has_levels = (s0.get("sdiv_max") or 1) > 1
          if method == "auto":
              method = "level" if has_levels else "dynamesh"
          n0 = z.get_subtool_count()
          z.press(zb_ops.resolve("duplicate"))
          z.update(redraw_ui=True)
          if z.get_subtool_count() != n0 + 1:
              raise ZBOpError("Duplicate did not add a SubTool (Edit mode on? usd-portal note)")
          copy = src + 1
          z.select_subtool(copy)
          out = {"source": src, "copy": copy, "method": method, "points_source": s0.get("points")}
          if method == "level":
              sdiv = zb_ops.resolve("sdiv")
              top = int(z.get_max(sdiv))
              if level is None:
                  level = 1
                  for lv in range(top, 0, -1):
                      z.set(sdiv, lv)
                      if int(z.query_mesh3d(0)[0]) <= max_points:
                          level = lv
                          break
              z.set(sdiv, level)
              out["level"] = level
              out["del_levels"] = zb_ops.del_levels()
          elif method == "dynamesh":
              if has_levels:
                  sdiv = zb_ops.resolve("sdiv")
                  z.set(sdiv, z.get_max(sdiv))     # keep the most detailed level for DynaMesh
                  zb_ops.del_levels()
              if resolution is None:
                  resolution = resolution_for_points(float(z.get_polymesh3d_area()), max_points)
              out["resolution"] = resolution
              out["dynamesh"] = zb_ops.dynamesh(resolution)
          else:
              raise ValueError("method must be auto, level or dynamesh")
          if fill_holes:
              out["close_holes"] = close_holes()
              out["fix_mesh"] = fix_mesh()
          out["openings_closed"] = method == "dynamesh" or bool(fill_holes)
          out["solid_copy"] = bool(z.is_polymesh3d_solid())
          out["points_copy"] = int(z.query_mesh3d(0)[0])
          set_visible(src, False)
          return out
      
      
      def zremesh(target_k, symmetric, axis="x", adaptive=True, adaptive_size=None, keep_groups=None,
                  smooth_groups=None, freeze_border=None, freeze_groups=None, curves_strength=None,
                  use_polypaint=None, color_density=None, keep_creases=None, detect_edges=None,
                  mode=None):
          """ZRemesher with the symmetry state set ON PURPOSE (required argument): with symmetry on,
          ZRemesher remeshes half and mirrors it, a silent Mirror And Weld that ruins posed or
          asymmetric meshes (Drust Kjce71jjWJU 00:01:11). Wraps zb_ops.zremesher and adds Smooth
          Groups (0 holds clean borders, 1 for ragged DynaMesh groups: Drust 8D-xqasgUws 00:02:17),
          Freeze Border, Curves Strength, polypaint density, the shrink ratio and the band check.
          With mode None, Half, Same and Double are switched off so the typed target counts
          (Pavlovich n5 00:16:26: "turn off Same to type a count").
          Not reachable from here: Alt+ZRemesher (the alternative midline algorithm, ZRemesher doc
          Symmetry; Pavlovich n5 00:48:31) is a modifier click and zbc.press() takes no modifier
          (SDK stub). Ask a computer-use agent for that variant and compare both with topo_report
          (centre_line_components, symmetry) on the same view of nape and brow."""
          z = _z()
          sym = zb_ops.set_symmetry(bool(symmetric), axis)
          for key, val, tol in (("zr_smooth_groups", smooth_groups, 0.01),
                                ("zr_curves_strength", curves_strength, 0.5),
                                ("zr_color_density", color_density, 0.01)):
              if val is not None:
                  zb_ops.set_checked(P(key), val, tol=tol)
          for key, val in (("zr_freeze_border", freeze_border), ("zr_freeze_groups", freeze_groups),
                           ("zr_use_polypaint", use_polypaint)):
              if val is not None:
                  _switch(key, val)
          if mode is None:
              for k in ("zr_half", "zr_same", "zr_double"):
                  if zb_ops.resolve(k, required=False):
                      zb_ops.set_checked(k, 0, tol=0.5)
          b0 = _bbox(1)
          faces0 = int(z.query_mesh3d(1)[0])
          r = zb_ops.zremesher(target_k, adaptive=adaptive, adaptive_size=adaptive_size,
                               keep_groups=keep_groups, mode=mode, keep_creases=keep_creases,
                               detect_edges=detect_edges)
          r["symmetry"] = sym
          r["shrink"] = _extent_ratio(b0, _bbox(1))
          # ZRemesher, Smooth Groups, density and smoothing passes all shrink the surface: project
          # back once at level 1 before judging it (Pavlovich n5 00:22:05, 01:03:12):
          # project_stack(copy, source, levels=0). Threshold 0.1 percent [added].
          r["snap_back"] = r["shrink"] is not None and r["shrink"] < 0.999
          if mode in ("half", "same", "double"):
              r["target"] = faces0 * {"half": 0.5, "same": 1.0, "double": 2.0}[mode]
              r["ratio_to_target"] = r["faces_after"] / r["target"] if r["target"] else None
          r["band"] = expected_band(adaptive is not False, bool(keep_groups), bool(freeze_border))
          ratio = r.get("ratio_to_target")
          r["in_band"] = ratio is not None and r["band"][0] <= ratio <= r["band"][1]
          return r
      
      
      RETRY_OK = {"target_k", "adaptive", "adaptive_size", "curves_strength", "freeze_border",
                  "freeze_groups", "detect_edges", "use_polypaint", "color_density", "symmetric",
                  "keep_polypaint"}
      
      
      def retry(**changes):
          """Retry reuses the ZRemesher cache after target, Adapt, Adaptive Size, curves and Curves
          Strength, Freeze Border, Freeze Groups, Detect Edges, Use Polypaint, Keep Polypaint and
          global symmetry changes; Keep Groups, Keep Creases, Smooth Groups or any sculpt force a
          full run (ZRemesher 4.0, Fast Retry Requirements). Refuses the cache-breaking ones."""
          bad = sorted(set(changes) - RETRY_OK)
          if bad:
              raise ValueError(f"{bad} discard the Retry cache: run zremesh() again instead "
                               "(ZRemesher 4.0 doc)")
          z = _z()
          if "symmetric" in changes:
              zb_ops.set_symmetry(bool(changes["symmetric"]))
          if "target_k" in changes:
              zb_ops.set_checked("zr_target", changes["target_k"], tol=0.01)
          for key, zkey in (("adaptive", "zr_adapt"), ("detect_edges", "zr_detect_edges")):
              if key in changes:
                  zb_ops.set_checked(zkey, 1 if changes[key] else 0, tol=0.5)
          if "adaptive_size" in changes:
              zb_ops.set_checked("zr_adaptive_size", changes["adaptive_size"], tol=0.5)
          for key, pkey in (("freeze_border", "zr_freeze_border"), ("freeze_groups", "zr_freeze_groups"),
                            ("use_polypaint", "zr_use_polypaint"), ("keep_polypaint", "zr_keep_polypaint")):
              if key in changes:
                  _switch(pkey, changes[key])
          for key, pkey, tol in (("curves_strength", "zr_curves_strength", 0.5),
                                 ("color_density", "zr_color_density", 0.01)):
              if key in changes:
                  zb_ops.set_checked(P(pkey), changes[key], tol=tol)
          t = time.time()
          _press("zr_retry")
          return {"changes": changes, "faces_after": int(z.query_mesh3d(1)[0]),
                  "seconds": round(time.time() - t, 2)}
      
      
      def freeze_levels():
          """Freeze SubDivision Levels: press at the highest level, change the base (ZRemesher,
          DynaMesh, ZModeler), press again to rebuild and reproject (Drust NrsuP4Vj4Lg 00:01:17 to
          00:02:26; ZRemesher doc recipe 1). If level 1 is too coarse: Del Lower at the level with
          the right silhouette first. The level count may change."""
          z = _z()
          before = zb_ops.stats()
          _press("freeze_sdiv")
          return {"before": before, "after": zb_ops.stats(), "state": z.get(zb_ops.resolve(P("freeze_sdiv")))}
      
      
      def _colorize(indices, on=True):
          """Colorize (the SubTool's polypaint icon) on or off for several SubTools; returns the
          previous values. Project All moves colour only when it is on for source AND target
          (Drust PrFQXjs_6_w 00:16:35: "Projected color missing"; _ips3GhWI0s 00:05:57: turn on the
          target's polypaint at the last level)."""
          z = _z()
          active = z.get_active_subtool_index()
          was = {}
          try:
              for i in indices:
                  z.select_subtool(int(i))
                  was[int(i)] = bool(_get("colorize", 0.0) >= 0.5)
                  _switch("colorize", on)
          finally:
              z.select_subtool(active)
          return was
      
      
      def _divide_checked(target_index):
          """One Divide that must add a level. Divide adds a level only when nothing is masked or
          hidden (Projection and Subdivision doc), and the SDK cannot read masks (lead skill), so a
          silent no-op is caught here by the level count."""
          before = zb_ops.stats().get("sdiv_max") or 1
          zb_ops.divide(1)
          after = zb_ops.stats().get("sdiv_max") or 1
          if after <= before:
              raise ZBOpError(f"Divide added no level on SubTool {target_index}: something is masked "
                              "or hidden (Projection doc). Clear it, or protect zones with "
                              "order='top_first' (mask after the divisions)")
          return after
      
      
      def morph_target_present():
          """True when a morph target is stored on the active SubTool: Tool > Morph Target > DelMT
          is enabled only then [verify, live_rx_02]."""
          p = zb_ops.resolve("del_mt", required=False)
          return bool(p and _z().is_enabled(p))
      
      
      def drop_morph_target():
          """DelMT after the projection gate passes. Maps compare the top level "to either a Morph
          target or the subdivision level you currently have selected" (Drust 2zDAtaQqwh8
          00:01:44), so a morph target left from projection may become the map base [verify]."""
          if not morph_target_present():
              return {"deleted": False}
          _press("del_mt", check_enabled=False)
          return {"deleted": True, "still_present": morph_target_present()}
      
      
      def safety_nets(store_mt=True, layer=True):
          """At the TOP level, before Project All: a morph target (the Morph brush or Switch paints
          back to the clean surface) and a new 3D layer (toggle it to compare, dial it to soften),
          the FlippedNormals order (Zp07GW3rND0 00:02:13: "It's important that you do all of this
          on the highest level"). The lead's zb_ops.top_level, store_morph_target and new_layer do
          the work (a morph target is tied to its level and destroyed by any point-count change,
          so this comes after the last Divide). Used on its own before masking danger zones;
          otherwise zb_ops.project_all sets the nets itself."""
          lv = zb_ops.top_level()
          return {"level": lv.get("top"),
                  "morph_target": zb_ops.store_morph_target() if store_mt else {"stored": False},
                  "layer": zb_ops.new_layer() if layer else {"created": False}}
      
      
      def protect_isolated(grow=1):
          """Mask a zone before Project All so it keeps its pre-projection surface. Precondition: only
          the zone is visible on the target, e.g. the eye-interior polygroup isolated by a human or
          computer-use agent with Ctrl+Shift click (no SDK call selects a polygroup). Grows the
          visibility `grow` times so the mask reaches just past the lid, MaskAll, shows everything
          again (FlippedNormals Zp07GW3rND0 00:05:31 to 00:06:03; their agent translation). Project
          All then skips the masked zone (Drust nxMYYsyJt3o 00:06:09: only unmasked areas project).
          Masks block Divide, so use it after the divisions: project_stack(order="top_first",
          stop_after="nets"), protect, then project_levels(..., nets_done=True). Mask state is not
          readable [verify that MaskAll masks only the visible part]."""
          for _ in range(int(grow)):
              zb_ops.visibility("grow")
          zb_ops.mask("all")
          zb_ops.visibility("show")
          return {"grow": grow, "mask": "visible zone masked, all shown"}
      
      
      def _need_checkpoint(checkpoint):
          if checkpoint is None:
              raise ZBOpError("projection needs checkpoint='/abs/name.ztl' (a versioned save before each "
                              "Project All, zb_ops.project_all) or checkpoint=False right after a save")
      
      
      def _pass(level, dist, mean, pa_blur, checkpoint, store_mt, layer):
          """One Project All through the lead's safe zb_ops.project_all (versioned checkpoint, nets at
          the top when asked, Dist, gate: points kept, no vertex outside the visible bbox)."""
          r = zb_ops.project_all(dist=dist, mean=mean, pa_blur=pa_blur, checkpoint=checkpoint,
                                 store_mt=store_mt, layer=layer, level=level, exclusive=True)
          s = zb_ops.stats()
          g = r.get("gate") or {}
          return {"level": level, "points": s.get("points"), "bbox": s.get("bbox"),
                  "gate_ok": g.get("ok", True), "problems": g.get("problems", []),
                  "warnings": g.get("warnings", []), "repair": r.get("repair"),
                  "morph_target": (r.get("morph_target") or {}).get("stored"),
                  "layer": (r.get("layer") or {}).get("created"),
                  "volume_before": r.get("volume_before"), "volume_after": r.get("volume_after")}
      
      
      def project_levels(target, source, from_level=1, dist=0.1, mean=None, pa_blur=0.0,
                         color_last=False, color_switch=True, geometry=True, checkpoint=None,
                         nets_done=False):
          """Project All at every level from `from_level` to the top of an EXISTING stack
          (FlippedNormals' staged variant, Zp07GW3rND0 00:03:17: gross mismatches fixed low, then up
          a level at a time). Each pass is the lead's zb_ops.project_all. The first pass sets the
          safety nets at the top (morph target + layer) unless nets_done; later passes keep them,
          so the morph target stays the clean pre-projection surface and one layer records all.
          Colour on the last pass only, with Colorize on for source and target.
          geometry=False: a colour-only pass from a DIFFERENT source (the raw duplicate of a scan or
          AI mesh, whose noise must not come back): the Project "Geometry" switch goes off for the
          pass [verify that Project All honours it; PROJ ties the switch to Project History].
          Fallback when it does not: StoreMT at the top, project, Tool > Morph Target > Switch back
          to the stored shape (morph targets hold positions, not polypaint) [verify], DelMT."""
          _need_checkpoint(checkpoint)
          z = _z()
          show_only([target, source])
          z.select_subtool(target)
          sdiv = zb_ops.resolve("sdiv")
          top = int(z.get_max(sdiv))
          color_path = zb_ops.resolve(P("proj_color"), required=False) if color_switch else None
          if color_path:
              z.set(color_path, 0)
          geo_path = None
          if not geometry:
              geo_path = zb_ops.resolve(P("proj_geometry"), required=False)
              if geo_path is None:
                  raise ZBOpError("no Project Geometry switch in this build: use the morph-target "
                                  "fallback for a colour-only pass (see docstring)")
              z.set(geo_path, 0)
          passes, colour = [], None
          first = not nets_done
          try:
              for lv in range(int(from_level), top + 1):
                  if color_last and lv == top:
                      colour = _colorize([source, target], True)
                      if color_path:
                          z.set(color_path, 1)
                  z.set(sdiv, lv)
                  passes.append(_pass(lv, dist, mean, pa_blur, checkpoint, first and geometry,
                                      first and geometry))
                  first = False
          finally:
              if geo_path:
                  z.set(geo_path, 1)
          return {"passes": passes, "colorize_was": colour, "top": top, "geometry": geometry}
      
      
      def project_stack(target, source, levels=None, top_ratio=0.5, max_levels=7, dist=0.1, mean=None,
                        pa_blur=0.0, store_mt=True, project_level1=True, color_last=False,
                        color_switch=True, shell=None, order="per_level", layer=None, stop_after=None,
                        checkpoint=None):
          """Rebuild `target`'s subdivision stack from `source` (SubTool indices; re-read them by
          name with index_of() after any split, merge or duplicate). Every pass is the lead's safe
          zb_ops.project_all: checkpoint is required (a versioned ZTL path, or False right after a
          save), only source and target visible (exclusive), Dist 0.1 (Drust nxMYYsyJt3o 00:02:19),
          a gate per pass (points kept, nothing outside the visible bbox) with its repair advice.
          - order "per_level" (Drust, Maxon Transferring Detail): project at level 1 (restores
            volume after ZRemesher: Pavlovich n5 00:22:05), then Divide + Project All per level
            until the top holds top_ratio x the source points. A morph target dies with every
            Divide (Morph Targets doc), so each pass stores one; the layer goes on the last pass
            only. levels=0 is the snap-back pass after a ZRemesher or smoothing pass.
          - order "top_first" (FlippedNormals Zp07GW3rND0 00:02:13): all divisions first, then the
            nets at the top (one morph target, one recording layer), then Project All from level 1
            up. To protect danger zones: stop_after="nets", mask with protect_isolated(), then
            project_levels(..., nets_done=True).
          - Geometry first, colour only at the last pass with Colorize on for BOTH SubTools (Drust
            _ips3GhWI0s 00:05:57; PrFQXjs_6_w 00:16:35); color_switch keeps the Project "Color"
            switch off before [verify it governs Project All].
          - shell: ProjectionShell for very different shapes; Inner turns on and Dist goes to 1
            (Gaboury pQbPtH0p5Bg 00:02:30 to 00:03:02).
          - A Divide that adds no level (mask or hidden part) raises instead of projecting on the
            wrong level.
          Returns per-level numbers and gates, the level-1 point count before and after, and
          whether a morph target is left (drop_morph_target before maps)."""
          if order not in ("per_level", "top_first"):
              raise ValueError("order: per_level or top_first")
          _need_checkpoint(checkpoint)
          z = _z()
          z.select_subtool(source)
          src = zb_ops.stats()
          show_only([target, source])
          z.select_subtool(target)
          t0 = zb_ops.stats()
          if (t0.get("sdiv_max") or 1) > 1:
              raise ZBOpError("target already has levels: start from the level-less remesh, or use "
                              "freeze_levels() for an existing stack")
          l1 = t0["points"]
          out = {"source": source, "target": target, "order": order, "source_points": src.get("points"),
                 "source_volume": src.get("volume"), "source_bbox": src.get("bbox"),
                 "level1_points_before": l1, "passes": []}
          if shell is not None:
              zb_ops.set_checked(P("proj_shell"), shell, tol=0.5)
              dist = 1.0
          n = levels if levels is not None else levels_needed(l1, src["points"], top_ratio, max_levels)
          out["divisions"] = n
          if order == "top_first":
              for _ in range(n):
                  _divide_checked(target)
              if stop_after == "nets":
                  out["nets"] = safety_nets(store_mt=store_mt, layer=True if layer is None else layer)
                  out["stopped"] = "after safety nets: protect zones, then project_levels(nets_done=True)"
                  return out
              pl = project_levels(target, source, 1, dist, mean, pa_blur, color_last, color_switch,
                                  checkpoint=checkpoint)
              out["passes"] = pl["passes"]
              out["colorize_was"] = pl["colorize_was"]
          else:
              color_path = zb_ops.resolve(P("proj_color"), required=False) if color_switch else None
              if color_path:
                  z.set(color_path, 0)
              want_layer = True if layer is None else bool(layer)
              if project_level1:
                  out["passes"].append(_pass(1, dist, mean, pa_blur, checkpoint, store_mt,
                                             want_layer and n == 0))
              for k in range(n):
                  last = k == n - 1
                  if color_last and last:
                      out["colorize_was"] = _colorize([source, target], True)
                      if color_path:
                          z.set(color_path, 1)
                  lv = _divide_checked(target)
                  out["passes"].append(_pass(int(lv), dist, mean, pa_blur, checkpoint, store_mt,
                                             want_layer and last))
          sdiv = zb_ops.resolve("sdiv")
          top = z.get_max(sdiv)
          z.set(sdiv, 1)
          out["level1_points_after"] = int(z.query_mesh3d(0)[0])
          z.set(sdiv, top)
          last = zb_ops.stats()
          out["top_points"] = last.get("points")
          out["top_volume"] = last.get("volume")
          out["top_bbox"] = last.get("bbox")
          out["morph_target_left"] = morph_target_present()
          out["pass_problems"] = [f"level {p['level']}: {q}" for p in out["passes"] for q in p["problems"]]
          if src.get("volume") and last.get("volume"):
              out["volume_ratio"] = round(last["volume"] / src["volume"], 5)
          return out
      
      
      def uv_state():
          """UV bbox (query_mesh3d 3), tiles (4 and 5), polygons and area per tile (6, 7), UV Map
          Size. Reads only."""
          z = _z()
          out = {"uv_map_size": _get("uv_size", 0.0)}
          try:
              out["uv_bbox"] = [round(float(v), 6) for v in z.query_mesh3d(3)]
          except Exception:
              out["uv_bbox"] = None
          tiles = zb_ops._uv_tiles(z) if out["uv_bbox"] is not None else None
          out["tiles"] = []
          for t in tiles or []:
              row = {"tile": t}
              try:
                  row["polygons"] = int(z.query_mesh3d(6, t)[0])
                  row["area"] = round(float(z.query_mesh3d(7, t)[0]), 6)
              except Exception:
                  pass
              out["tiles"].append(row)
          return out
      
      
      def _lowest_level():
          z = _z()
          p = zb_ops.resolve("sdiv", required=False)
          if p and z.get_max(p) > 1:
              prev = z.get(p)
              z.set(p, 1)
              return p, prev
          return None, None
      
      
      def unwrap_creases(symmetry=True, from_polygroups=True, delete_existing=True):
          """Native unwrap with explicit seams (Pavlovich cvqoVUX5aBw 00:03:49 to 00:08:11): UnCrease
          All, Crease PG (seams on polygroup borders), Create (Unwrap) with Creased Edges, Unwrap.
          Creases do nothing until the next Unwrap. Runs at the lowest level; the label is "Creased
          Edges" [uistr]. Hands and tails need extra creases (ZModeler Crease Shortest Path: human)."""
          z = _z()
          p, prev = _lowest_level()
          t = time.time()
          try:
              if delete_existing and uv_state()["uv_bbox"] is not None:
                  _press("uv_delete", check_enabled=False)
              if from_polygroups:
                  _press("uncrease_all", check_enabled=False)
                  _press("crease_pg", check_enabled=False)
              if zb_ops.resolve("uv_auto_seams", required=False):
                  zb_ops.set_checked("uv_auto_seams", 0, tol=0.5)
              _switch("uv_creased_edges", True)
              if zb_ops.resolve("uv_symmetry", required=False):
                  zb_ops.set_checked("uv_symmetry", 1 if symmetry else 0, tol=0.5)
              zb_ops.press("uv_unwrap")
              z.update(redraw_ui=True)
          finally:
              if p:
                  z.set(p, prev)
          return {"seconds": round(time.time() - t, 2), **uv_state()}
      
      
      def uvmaster_roundtrip(symmetry=True, polygroups=True, attract_ao=False, max_polys=150_000):
          """UV Master on a subdivided SubTool: Work On Clone, Unwrap, Copy UVs, back to the
          original Tool and SubTool, Paste UVs (Gaboury cemmalvugFk 00:04:00 to 00:07:28). Under
          100k to 150k polygons (UV Master doc 7). AttractFromAmbientOccl pulls seams into hidden
          areas without painting (UV Master doc). Plugin presses: whether control returns to Python
          is [verify] (live_08 of scenario-zbrush-expert, live_rx_03)."""
          z = _z()
          tool0, sub0 = z.get_active_tool_index(), z.get_active_subtool_index()
          p, prev = _lowest_level()
          faces = int(z.query_mesh3d(1)[0])
          if faces > max_polys:
              if p:
                  z.set(p, prev)
              raise ZBOpError(f"{faces} polygons at the lowest level > {max_polys}: UV Master slows "
                              "down past 100k to 150k (UV Master doc); ZRemesh lower or use native")
          for key, val in (("uvm_symmetry", symmetry), ("uvm_polygroups", polygroups)):
              if zb_ops.resolve(key, required=False):
                  zb_ops.set_checked(key, 1 if val else 0, tol=0.5)
          t = time.time()
          zb_ops.press("uvm_clone", check_enabled=False)
          clone_tool = z.get_active_tool_index()
          if attract_ao:
              _press("uvm_ao_attract", check_enabled=False)
          zb_ops.press("uvm_unwrap", check_enabled=False)
          _press("uvm_copy", check_enabled=False)
          z.select_tool(tool0)
          z.select_subtool(sub0)
          _press("uvm_paste", check_enabled=False)
          if p:
              z.set(p, prev)
          return {"clone_tool": clone_tool, "clone_path": z.get_tool_path(clone_tool),
                  "seconds": round(time.time() - t, 2), **uv_state()}
      
      
      def _no_morph_target(allow):
          """Refuse a bake while a morph target is stored, unless allowed: the map may be computed
          against it instead of the selected level (Drust 2zDAtaQqwh8 00:01:44) [verify]."""
          if not allow and morph_target_present():
              raise ZBOpError("a morph target is stored (StoreMT from projection): drop_morph_target() "
                              "before baking, or pass allow_morph_target=True on purpose")
      
      
      def bake_normal_map(path, level=1, tangent=True, flip_g=None, adaptive=False, smooth_uv=None,
                          overwrite=False, allow_morph_target=False):
          """One SubTool: Tool > Normal Map at `level` against the top (the level filters the
          frequencies: level 1 forms and wrinkles, max-1 micro detail only: Drust 2zDAtaQqwh8
          00:02:53 to 00:04:00), Clone NM, Texture:Export. Size = Tool > UV Map > UV Map Size.
          Same-SubTool rule: the low and the high cannot be two SubTools (Drust n0qqpwvn-jA
          00:01:14). The SDK create_normal_map() defaults to world space (local_coordinates=False);
          this palette route sets Tangent explicitly. Clone and export [verify]."""
          _no_morph_target(allow_morph_target)
          z = _z()
          sdiv = zb_ops.resolve("sdiv")
          prev = z.get(sdiv)
          z.set(sdiv, level)
          try:
              _switch("nm_tangent", tangent)
              _switch("nm_adaptive", adaptive)
              if smooth_uv is not None:
                  _switch("nm_smooth_uv", smooth_uv)
              if flip_g is not None:
                  _switch("nm_flip_g", flip_g)
              _press("nm_create")
              _press("nm_clone", check_enabled=False)
              res = zb_ops._file_op(P("texture_export"), path, overwrite)
          finally:
              z.set(sdiv, prev)
          res.update({"level": level, "tangent": tangent, "flip_g": flip_g})
          return res
      
      
      def bake_displacement_map(path, mid=0.5, scale=1.0, bits32=True, adaptive=False, dpsubpix=0,
                                smooth_uv=None, three_channels=False, overwrite=False,
                                allow_morph_target=False):
          """One SubTool: Tool > Displacement Map at level 1 (SDK: the tool must be at the lowest
          level), Create And Export Map with the name preset. 32-bit with Scale 1; Mid 0 per the MME
          doc or 0.5 with the renderer zero value matched (FlippedNormals -ThBTEc8L_M 00:10:01).
          Adaptive off and DPSubPix 0 (FlippedNormals 00:08:12, 00:08:45). [verify] dialog-free."""
          _no_morph_target(allow_morph_target)
          z = _z()
          sdiv = zb_ops.resolve("sdiv")
          prev = z.get(sdiv)
          z.set(sdiv, 1)
          try:
              _switch("dm_32bit", bits32)
              zb_ops.set_checked(P("dm_mid"), mid * 100.0 if mid <= 1.0 else mid, tol=0.6)
              if bits32:
                  zb_ops.set_checked(P("dm_scale"), scale, tol=1e-3)
              _switch("dm_adaptive", adaptive)
              zb_ops.set_checked(P("dm_dpsubpix"), dpsubpix, tol=0.5)
              _switch("dm_3ch", three_channels, required=False)
              if smooth_uv is not None:
                  _switch("dm_smooth_uv", smooth_uv)
              res = zb_ops._file_op(P("dm_create_export"), path, overwrite)
          finally:
              z.set(sdiv, prev)
          res.update({"mid": mid, "scale": scale, "bits32": bits32})
          return res
      
      
      def _mme_items(settings):
          """Accept a preset name, {(group, label): v} or the JSON form {"group:label": v} with
          "main" for the main panel (what travels through the bridge)."""
          if isinstance(settings, str):
              settings = MME_PRESETS[settings]
          items = []
          for key, value in settings.items():
              if isinstance(key, str):
                  g, label = key.split(":", 1)
                  key = (None if g == "main" else g, label)
              items.append((key, value))
          return items
      
      
      def mme_set(settings):
          """Apply MME settings (a preset name such as "arnold", {(group, label): value} or
          {"group:label": value}). Values are read back; the path that took each value is returned
          (grouped forms first because short labels repeat across groups) [verify all, live_rx_03]."""
          out = {}
          for (group, label), value in _mme_items(settings):
              cands = mme_path(label, group)
              p = zb_ops.resolve(cands)
              tol = 0.5 if isinstance(value, bool) or value in (0, 1) else max(0.01, abs(value) * 1e-3)
              out[f"{group or 'main'}:{label}"] = {"path": p,
                                                   "value": zb_ops.set_checked([p], float(value), tol=tol)}
          return out
      
      
      def mme_create_all(out_dir, base_name="maps", settings=None, timeout_hint_s=600,
                         allow_morph_target=False):
          """Create All Maps with a name preset, then list the files that appeared. Back up the ZTL
          first and never interrupt: ESC during a bake can lose UVs (MME doc, Warning). A plugin
          press: control return is [verify] (a ZScript that pressed plugin buttons lost control,
          ZBrushCentral). Adaptive off keeps 100 UDIMs near 1 h instead of 24 h (FlippedNormals
          -ThBTEc8L_M 00:08:45). Refuses while the active SubTool holds a morph target (MME runs
          over every SubTool; drop_morph_target() on each projected one first)."""
          _no_morph_target(allow_morph_target)
          z = _z()
          out_dir = os.path.abspath(os.path.expanduser(out_dir))
          os.makedirs(out_dir, exist_ok=True)
          applied = mme_set(settings) if settings else {}
          before = set(os.listdir(out_dir))
          t0 = time.time()
          z.set_next_filename(os.path.join(out_dir, base_name))
          zb_ops.press(mme_path("Create All Maps"), check_enabled=False)
          z.update(redraw_ui=True)
          new = sorted(set(os.listdir(out_dir)) - before)
          return {"dir": out_dir, "files": new, "seconds": round(time.time() - t0, 1),
                  "preset_consumed": not z.has_next_filename(), "applied": applied,
                  "timeout_hint_s"
  • SKILL.md 18.7 KB
    ---
    name: scenario-zbrush-retopology-export
    description: 'Use when a ZBrush sculpt, scan or AI-generated mesh must become production topology and files (ZRemesher and guides, the 2026 Retopo brush, reprojecting detail with Project All, rebuilding subdivision levels, UV Master or native unwrap, normal and displacement maps with Multi Map Exporter, GoZ, OBJ or FBX export, scale and axes, Decimation Master, "clean up an AI mesh in ZBrush"), or when detail is lost after ZRemesher, a map is upside down or an export arrives at the wrong size.'
    license: MIT
    ---
    
    # ZBrush retopology and export (pipeline sculptor)
    
    Expert level means the sculpt leaves ZBrush as a light, clean exchange mesh at its lowest level, detail kept on a rebuilt subdivision stack or in maps, at real size, every file proved by parsing it. Most of it is deterministic palette work an agent does well; click-by-click topology, modifier clicks and a few dialogs stay with a human or computer-use agent. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap.
    
    **REQUIRED BACKGROUND:** scenario-zbrush-expert (bridge, stroke engine, review loop, 2026 traps).
    
    Toolkit: [`scripts/zb_retopology_export.py`](scripts/zb_retopology_export.py) (`rx`) on top of the lead's `zb_ops`, `zb_audit` and `zb_launch`: `rx.zcall("func", ...)` inside ZBrush, audits on the agent side. Every projection pass is the lead's safe `zb_ops.project_all` (versioned checkpoint, nets, gate). Not yet run in ZBrush (88 offline tests pass; live tests in [`references/procedures.md`](references/procedures.md)).
    
    ## Stance (the expert delta)
    
    - **Set symmetry on purpose before every ZRemesher run.** With symmetry on, ZRemesher remeshes half and mirrors it: a silent Mirror And Weld that ruins posed or asymmetric meshes (Drust, Kjce71jjWJU 00:01:11). `rx.zremesh` makes `symmetric` a required argument.
    - **Give ZRemesher an easy problem.** A face that will animate goes in neutral, folds relaxed, the expression sculpted back on the new topology (Pavlovich n5_cZK-9peg 00:01:39). Ears and hands are remeshed alone, open borders polished first (00:44:36). Unintended holes are closed first: they survive and inflate the count (ZRemesher doc).
    - **ZRemesher is for flow; detail comes back by projection.** Aim low (Gaboury: 5k to 18k for an arm, 34D6VljTjBs 00:02:18), then Project All with Dist 0.1, which fixes "90 percent" of artifacts (Drust nxMYYsyJt3o 00:02:19). Only source and target visible, even in Solo (Pavlovich 01:07:26). With Adapt on and Keep Groups, Target 5 gave 8.0k to 9.4k points (frames 00:18:50, 00:22:09); Adapt off hits the count.
    - **Projection has danger zones:** eyes, mouth, armpits, between fingers, crotch; what is left there becomes hard lines in displacement (FlippedNormals Zp07GW3rND0 00:07:43). Mask an eye-interior group grown just past the lid; repair with the morph target or a masked reprojection, never smoothing (00:05:31, 00:06:36).
    - **Steer with polygroups, borders smoothed first**: ZRemesher builds jagged borders into the loops (Pavlovich 00:05:27). Smooth Groups 0 on clean borders, 1 for ragged DynaMesh groups (Drust 8D-xqasgUws 00:02:17). Failed eyelids, mouth corners and nostrils are deleted and rebuilt (Pavlovich 00:21:36); the Retopo brush's "No" answer makes a light ZRemesher mesh editable (Pablo Munoz Gomez, I4nePXDTrhQ 00:06:36).
    - **The lowest level is the exchange mesh.** UVs are made there (Gaboury cemmalvugFk 00:00:51). Maps compare the current level with the top of the same SubTool, never two SubTools (Drust n0qqpwvn-jA 00:01:14); level 1 carries forms, max-1 micro detail only (2zDAtaQqwh8 00:02:53).
    - **Film displacement is a fixed recipe** (FlippedNormals -ThBTEc8L_M): Flip V on ("an absolute must"), Adaptive off (100 UDIMs in about 1 h, not 24 h), DpSubPix 0, 32-bit EXR, 3 Channels off, renderer zero value = Mid (MME doc: Mid 0 for 32-bit; FlippedNormals keep 0.5; record it).
    - **Exported size = internal extent x Export Scale** (Drust n2xPrwI9o1U 00:04:19). Keep XYZ Size between 1 and 4 (Gallagher EXjfH_X2hkM 00:28:23). Export Scale and offsets live per SubTool, so the same values go on every SubTool [added].
    - **A generated or scanned mesh is a form reference, not topology to repair.** DynaMesh early, after thickening thin shells, which DynaMesh's hole fill "shoots straight across" (Drust PrFQXjs_6_w 00:02:53). Rebuild the stack from the cleaned DynaMesh, never the noisy original [added].
    
    ## Establish first
    
    | Input                 | Changes                                       | Default when the brief is silent                                                                                      |
    | --------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
    | Destinations          | mesh type, maps, units, axis                  | Maya: OBJ level 1 quads in cm + 32-bit EXR displacement, Mid 0.5                                                      |
    | Deforms? face, joints | exact loops (human Retopo pass), neutral face | yes for characters; an expression goes back to scenario-zbrush-character-creature (neutral version, or its layer off) |
    | Budget                | ZRemesher target, Adapt                       | rig or film body 15k to 25k quads [added]; animated head 10k to 20k (Retopo doc); game from the brief                 |
    | Posed or asymmetric   | symmetry off                                  | symmetric only if unposed and designed symmetric                                                                      |
    | Real size             | Export Scale                                  | 180 cm human (Gallagher's example)                                                                                    |
    | Maps and tiles        | MME preset, UDIM plan                         | one 4096 tile per SubTool; UDIM layouts come from Maya or Blender                                                     |
    | Source type           | route                                         | levels: a level under 2M; none: DynaMesh copy; AI or scan: cleanup first                                              |
    
    ## Workflow
    
    1. **Preflight, checkpoint.** `rx.zcall("preflight", 180.0)`; `zb_ops.save_ztl`. GATE: flags resolved (over 8M vertices, XYZ Size outside 1 to 4, Export Scale 0, duplicate names).
    2. **ZRemesher input.** `remesh_input("auto", max_points=2_000_000)` duplicates the sculpt and hides the original (the projection source); `fill_holes=True` adds Close Holes and Fix Mesh when holes are unintended. DynaMesh and Close Holes also shut eye sockets and mouth bags: `openings_closed` sets the hole gate (0, else the intended openings). GATE: copy under 8M vertices, source intact, face neutral.
    3. **Guides.** Polygroups by buttons (Auto Groups, Groups By Normals, From Polypaint); a mask a human drew becomes a group with support loops through Edgeloop Masked Border (Drust IpTYcGxxsdM 00:05:08); `polish_band("groups")`. Ears and hands: own SubTools (Groups Split, Split Hidden), `polish_band("border")`, own runs (hand Adaptive Size about 14: Pavlovich 00:59:42), then `merge_down_run(..., weld=True)`; Freeze Border only where borders must re-weld exactly, after remeshing the whole limb, at a cost to animation (01:03:44). GATE: Polyframe sheet shows closed, smooth rings; no seam loops after the re-join.
    4. **ZRemesher.** `zremesh(target_k, symmetric, keep_groups=True, smooth_groups=0)` (Half, Same, Double off), then `retry(adaptive_size=...)` over 0, 5, 21. Keep Groups, Keep Creases, Smooth Groups or a sculpt break the Retry cache. The sweep leaves the LAST variant: `pick_retry`, `retry(**pick["retry"])`, `confirm_retry`. Optional Half pass with Adapt off, or a retopology over the retopology (Drust R2MzFqWMaWY 00:01:44). Poor center line at nape or brow: Alt+ZRemesher, a modifier click `press` cannot send: ask computer use, compare `centre_line_components` (Pavlovich 00:48:31). When `snap_back` is true, `project_stack(copy, source, levels=0, checkpoint=ztl)` before judging (00:22:05). GATE: `in_band`; `topo_report` + `retopo_verdict(purpose="rig", expected_holes=...)` on a Grp-on QA export; loops follow forms. Failed orifices: the script stops for a human Retopo pass.
    5. **Rebuild the stack.** `project_stack(target, source, checkpoint=ztl)`: two visible, Dist 0.1, level 1 then Divide + Project All per level up to half the source count, a morph target per pass, color last with Colorize on for both SubTools (Drust PrFQXjs_6_w 00:16:35). Danger zones or a film face: `order="top_first", stop_after="nets"` (divisions, then StoreMT and a layer at the top: FlippedNormals 00:02:13), a human isolates the eye-interior group, `protect_isolated(grow=1)`, `project_levels(..., nets_done=True)`. GATE: `projection_from_stack` (level 1 unchanged, bbox 0.5 percent, volume 2 percent [added]); same-view sheet plus danger-zone close-ups per level. Busted areas: `zb_ops.morph_repair`; a target inside the source: masked Inflate, or ProjectionShell with Inner and Dist 1 (Gaboury pQbPtH0p5Bg 00:02:30). Then `drop_morph_target()`: bakes refuse a stored morph target, a possible map base (Drust 2zDAtaQqwh8 00:01:44) [verify].
    6. **UVs at level 1** (they carry through the levels, Gaboury cemmalvugFk 00:03:30). Quick: `zb_ops.uv_unwrap("native")`. Explicit seams: `unwrap_creases()` (Pavlovich cvqoVUX5aBw). Painter islands: `uvmaster_roundtrip()`. Eyes get their own simple UVs. GATE: `uv_verdict` on the Txr export; seams hidden, face uncut.
    7. **Maps.** `mme_create_all(dir, name, settings="arnold")` or `bake_normal_map` / `bake_displacement_map`. GATE: `map_verdict` per file (size, bits, flat at Mid, normal flat at 128/128/255: Polycount); orientation calibrated once per build (live_rx_04 fixture).
    8. **Scale, names, export.** `set_export_scale(180, axis=1)` (every SubTool); `set_export_switches(quads=True, uvs=True, groups=False)`; `export_subtools(dir, level="lowest")`. FBX: ZScript could not suppress its options window, Python is unverified (Maxon forum), AppleScript System Events clicks ZBrush dialogs on this Mac [verify for it]; default: OBJ, converted by scenario-maya-expert or scenario-blender-expert. GATE: `obj_header` reads the Export Scale back from `#Auto scale` (written by 2026.2.1, lead v03 export); `scale_report` passes (1 percent, feet at 0, centered).
    9. **Handoff.** `handoff_report(json, ...)`: meshes, maps, unit, axis, Mid and scale, normal convention, audits, sheets, not-verified list.
    
    ### Generated or scanned mesh (Z6)
    
    1. GLB from scenario-3d: `glb_to_obj`, `triage_ai_mesh(obj, expected_parts)`, then `orient_for_import`: it rotates only a Z-up hint, centers only an off-center mesh (glTF is Y-up) and names the file to import.
    2. `import_mesh` (Weld; Tri2Quad rebuilds quads from AI triangles [verify range]); raw ZTL; `duplicate_keep()`: the copy is the form and color source, tracked by name (`index_of`), since splits and merges renumber SubTools.
    3. `fix_mesh`; `split_parts`, `hide_small_parts(protect_names=[raw])`. Fused parts of different colors: `color_to_groups`, Groups Split [added]; else scenario-3d segmentation or human SliceCurve cuts.
    4. `close_holes` per part. Swiss cheese after DynaMesh means too thin: thicken first.
    5. `merge_down_run` the parts of one body inside the Tool (Drust 00:09:27). `dynamesh_keep(res, keep="groups")`: with polypaint on, DynaMesh keeps paint INSTEAD of polygroups (DynaMesh doc; general rule: scenario-zbrush-sculpting). Blur 0, Project off. Residual holes: `hole_plugs(obj)` on an export, `plug_hole(center, diameter)`, `merge_into(body, [plug])`, re-DynaMesh (Drust PrFQXjs_6_w 00:07:16). Polish; Mirror And Weld if symmetric. Local rebuilds: Sculptris Pro (scenario-zbrush-sculpting).
    6. GATE: `cleanup_verdict(raw, clean)` (closed, manifold, shells, bbox 2 percent, roughness at most 0.8x raw [added]); MatCap Gray sheet, Double on, Colorize off for the sheet only (`set_display` returns the old value).
    7. ZRemesher per part (5 to 20 thousand), snap back, project geometry from the cleaned DynaMesh, then color only from the raw copy: `project_levels(..., geometry=False, color_last=True)` [verify]. Hand to scenario-zbrush-sculpting (big changes at SDiv 1, secondary at 2 to 3).
    
    ## Numbers
    
    | Value                                                                | Relative to           | Source                      |
    | -------------------------------------------------------------------- | --------------------- | --------------------------- |
    | ZRemesher target in thousands; 5 gave 4,963                          | Target Polygons Count | Pablo rArw79xEpvE 00:03:16  |
    | Adaptive Size 50 default; 0 even quads, 5 to 21 curvy; hand about 14 | density               | ZRemesher doc; Pavlovich n5 |
    | Color Density 2 = more (pink), 0.25 = fewer (blue), white neutral    | polypaint density     | Pavlovich n5 01:01:32       |
    | XYZ Size 1 to 4; imports normalized to 2                             | internal size         | Gallagher; Drust            |
    | Export Scale = target / internal extent (180 / 2 = 90)               | units                 | Drust; Gallagher 00:28:55   |
    
    ## Per-target settings
    
    | Target            | Mesh                                                          | Displacement                                                              | Normal                                             |
    | ----------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------- |
    | Maya + Arnold     | OBJ level 1, Qud, Txr, Grp off; cm                            | 32-bit EXR, Mid 0.5 = Scalar Zero Value, Catmull-Clark about 3, Auto Bump | tangent, OpenGL [added]                            |
    | Unreal, Unity     | triangulated, FBX via Maya or Blender; cm, meters [added]     | none                                                                      | engine tangent basis; Unreal DirectX green [added] |
    | Substance Painter | Substance Bridge Low & High, Auto-Bake, Force Auto-Unwrap off | none                                                                      | baked by Painter                                   |
    | Blender           | OBJ; meters                                                   | 32-bit EXR, Midlevel = Mid [added]                                        | OpenGL; game bakes with scenario-blender-uv-baking |
    
    Redshift, vector displacement and full rows: `rx.RENDERERS`, `references/procedures.md`.
    
    ## Quality gates
    
    - Code, on every exported file: `topo_report`, `projection_verdict`, `uv_report`, `scale_report`, `map_verdict`, `names_report`, `cleanup_verdict`.
    - Visual (`zb_review.review` sheets): Polyframe front, side and three-quarter for loops; source and target at one view after projection, plus danger-zone close-ups at every level; MatCap Gray with Double on for cleanup. Judge with [`references/critique.md`](references/critique.md).
    
    ## Common mistakes
    
    | Mistake                             | Looks like                                | Fix                                                 |
    | ----------------------------------- | ----------------------------------------- | --------------------------------------------------- |
    | Symmetry left on                    | posed limbs come out mirrored             | `zremesh(symmetric=False)`                          |
    | Remeshing a smiling face            | loops follow the smile, not the rig       | neutral first, expression back on the new mesh      |
    | Retry sweep left as is              | kept mesh is not the chosen one           | `pick_retry`, retry back, `confirm_retry`           |
    | Dist too small                      | holes of detail, speckles                 | Dist 0.1; engulf the source                         |
    | Smoothing projection spikes         | rogue vertices stay                       | morph target, masked reprojection                   |
    | Colorize off on one side            | projected color missing                   | `color_last=True` turns both on                     |
    | Morph target left at bake           | map against the wrong base [verify]       | `drop_morph_target()`                               |
    | SDK normal bake on defaults         | world-space map                           | `create_normal_map(..., local_coordinates=True)`    |
    | OBJ Grp on for Maya                 | mesh split in parts                       | Grp off (FlippedNormals 00:15:08)                   |
    | Export Scale on one SubTool only    | eyes float off the head                   | `set_export_scale(..., all_subtools=True)`          |
    | Unify on a character with layers    | layers and morphs break                   | MultiAppend into a scaled tool (Gallagher 00:27:16) |
    | Polypaint on at an AI-mesh DynaMesh | part polygroups gone                      | `dynamesh_keep(keep="groups")`                      |
    | MergeVisible during cleanup         | new Merged_ Tool without the color source | `merge_down_run` in the Tool                        |
    
    ## Handoffs
    
    - **Receives** from scenario-zbrush-sculpting, scenario-zbrush-character-creature, scenario-zbrush-stylized, scenario-zbrush-hard-surface: a versioned ZTL with named SubTools, `stats()` and the last sheet; from scenario-3d: GLB or OBJ.
    - **Delivers** to scenario-maya-retopology-uv and scenario-maya-rigging: level-1 OBJ per SubTool, cm, Y-up, centered, feet at 0, eyes separate; to scenario-maya-lookdev: maps plus `handoff_report`; to scenario-blender-uv-baking or Substance: low and decimated high with matching names; to scenario-zbrush-paint-render: the stack with UVs; to scenario-zbrush-pose-print: a closed copy.
    
    ## ZBrush 2026 notes
    
    - Retopo brushes (2026.1); UV Master with Polygroups fixed in 2026.2.0; Substance Bridge (2026.2) labels "Auto-Bake", "Force Auto-Unwrap" [uistr]: Force Auto-Unwrap is global and strips good UVs.
    - SDK: `press()` takes no modifier (Alt-clicks go to computer use); `create_normal_map()` defaults to world space.
    - macOS: Control+W makes a polygroup; Command+W quits ZBrush (version deltas 3.13).
    - A partial ZRemesh with Freeze Border deletes the subdivision levels (Drust 0TlG4Ex9lQA 00:02:26): `freeze_levels()` around it.
    
    ## References
    
    - `references/procedures.md`: full bridge procedures (Z4, Z6, maps per target, fixtures), test paths, path evidence. Load before running a stage.
    - [`references/expert-notes.md`](references/expert-notes.md): judgment by expert with timestamps and the deciding conditions. Load when choosing between routes.
    - `references/critique.md`: the review rubric (loops, projection, UVs, maps, scale, cleanup). Load at every gate.
    - [`references/gui-paths.md`](references/gui-paths.md): palettes, hotkeys and Retopo brush gestures for a human or computer-use agent.
    - [`references/sources.md`](references/sources.md): every source with credential, URL and best timestamps.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related