scenario-zbrush-sculpting
Use when an agent sculpts organic forms in ZBrush 2026 (sculpt a head, bust or creature in ZBrush from a sphere, ZSpheres or primitives); when blocking primary and secondary forms, choosing DynaMesh, Sculptris Pro or subdivision levels, or setting ClayBuildup, Dam_Standard, Move
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/zbrush/scenario-zbrush-sculpting
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
git clone https://github.com/scenario-labs/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
ZBrush sculpting (core forms, scripted strokes)
Expert sculpting goes big to small with gates: light meshes raised in resolution only when the forms ask, clay built across the form in thin low-intensity layers, sharpness last and never on weak shapes. An agent has no pen: it uses exact operations first (primitives, ZSpheres, DynaMesh, masks plus Deformation sliders), then strokes planned from model-space forms, aimed through the view, measured in volume and reviewed after every pass (scripts/zb_sculpt.py). 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).
Stance (the expert delta)
- Pressure is the hidden setting. Henning Sanden blocks at ClayBuildup Z 20 with "maybe 30%" of his pen pressure (0PaYUUvgwYM 00:04:58); a scripted stroke has constant pressure. Start clay at Z 6, refine at 2 to 1, calibrate once per session (
zs.run_calibration), and dial a whole pass after review: a partial Morph value softens it, a negative one exaggerates it (Drust, B_wKwXwjcZs 00:05:20); layer intensity does the same on levels. - Clay across the form, lines along it. Henning: across puts a peak in the center that fades to the edges; along is "drawing the shape" (0PaYUUvgwYM 00:02:13, 00:30:39). Pablo Munoz Gomez builds long masses along first, then smaller strokes across, never a muscle in one stroke (tbQqC6tyDBQ 00:06:32). Each form is a center line plus a width.
- Standard sets edges, clay builds, ClayBuildup erases. Standard pushes what is there, so it gives specificity (Henning 0PaYUUvgwYM 00:27:26); ClayBuildup replaces what is under it, never after detail (TpS0QdlfHWU 00:07:44). Pablo sets a hard edge with Standard at raised Z, then blends it with clay (tbQqC6tyDBQ 00:10:27).
- Refine instead of smoothing, except on low-res blockouts. Henning removes marks with passes of falling intensity and smooths once at "good enough" (TpS0QdlfHWU 00:22:26, 00:22:58); Pablo smooths freely during blockout (tbQqC6tyDBQ 00:02:29). Low-res S1: a Polish slider; skin from S2 on: the descent.
- DynaMesh while the forms move, levels once they settle. A high DynaMesh early is a "lumpy mess" (Pablo tbQqC6tyDBQ 00:04:38) and resolution is relative to object size (FrqUnna1jns 00:13:40). Never Divide a DynaMesh: its triangles and star poles become lumps no smoothing fixes (rArw79xEpvE 00:09:44). ZRemesher a copy, Divide, project.
- Level discipline. Big moves at SDiv 1 (at the top a push "looks very wonky"); secondary forms blocked at SDiv 2 or 3, then refined upward level by level; never all the work at the top (Pablo rArw79xEpvE 00:12:28, 00:14:16; tbQqC6tyDBQ 00:12:26). Details never change the silhouette (tbQqC6tyDBQ 00:13:27).
- Strokeless first. Pablo builds a whole blockout "without even actually using any of the brushes" (gitoJ7B8FmY 00:05:26). For an agent these are exact: primitives placed by number, ZSpheres by code, masks plus Deformation sliders, Mirror then Mirror And Weld.
- Mid frequency is where a sculpt is won. FlippedNormals call it the backbone of detailing; pores added early put you "two levels behind" (G2o6fdoACIQ 00:05:46, 00:08:17). Details "won't fix a weak sculpt" (Pablo tbQqC6tyDBQ 00:12:50).
Establish first
| Input | What it changes | Default when the brief is silent |
|---|---|---|
| Subject | base route and point ladder (STAGES[...]["points"]): head, creature |
head bust from a sphere |
| Purpose | concept: stop at S3 or S4, even quads suffice (tbQqC6tyDBQ 00:11:43); film or game: retopology before micro detail (Zp07GW3rND0 00:08:22); print: scenario-zbrush-pose-print | concept |
| Style | realistic: scenario-zbrush-character-creature; stylized: scenario-zbrush-stylized | neutral forms |
| Forms list | zs.Form center lines plus widths, from the brief and the domain skill's proportions |
none: ask the domain skill |
Head width in model units (ref_width) |
every brush size | model width in the front view |
| Views | front is measured [obj]; others need the live_04 convention | front |
Workflow
Every pass: Scene.capture, plan (clay_pass, move_pass, crease_pass), preview_plan (look at it), run_plan, pass_report, zb_review.review, then name the ONE weakest item and fix it. Save a versioned ZTL at every gate.
Session.
zb_launch.start(), neutral state (P1), calibration (P2). Every pass turns perspective (Draw:Perspective) and LazyMouse off: its radius swallows the start of a synthesized path (Pavlovich AdkZe1yKFTU 00:03:31). GATE: document zoom 1, calibration present or the v02 prior noted.S0 base. One of: a sphere DynaMesh at 128 (about 43k points); a ZSphere armature (
zsphere_code) with an Adaptive Skin at Density 1 or 2 and DynaMesh Resolution 0 (Pablo), then DynaMesh 64; primitives appended and placed with Geometry Position and Size (P3). GATE: the silhouette reads front and side; points within S0.S1 primary blockout.
- Move then re-DynaMesh (
move_pass,remesh_due, thenremesh_code, P16: Draw mode, mask cleared, polypaint off so polygroups survive, proof that it remeshed). - Clay across the forms (
clay_pass, presetclay_block); sockets as carve forms (sign=-1); TrimDynamic planes (preset="trim"). Symmetry on: plans cover the +X half.
GATE:
stage_gate("S1")passes; the sheet shows right proportions, every element present (G2o6fdoACIQ 00:02:50), big simple masses.- Move then re-DynaMesh (
S2 primary refinement.
- Resolution up: head by the square law (
next_dynamesh_resolution) toward 400k to 800k points; creature: MergeDown the pieces (in place; MergeVisible makes a new Tool; its confirmation may block the bridge [verify], so ZTL first), DynaMesh 256. - Descent passes Z 6, 3, 2, 1, zooming in on each region (
descent, P7); TrimDynamic on junk bumps. - Eyes (P17): eyeball spheres placed by number, each turned 3 to 7 degrees outward (Costa j5XLtLMN0P8 00:29:10); lids cut with Dam_Standard, then moved out so they wrap the ball (Henning TpS0QdlfHWU 00:18:33). Henning shapes them with perspective on (00:17:28): strokes stay orthographic, the read is
persp_snapshot_code. - Checkpoint risky passes and dial them (P8) before any re-DynaMesh, ZRemesher or Divide: a point-count change destroys the Morph Target, and
dial_code(..., expect_points=)refuses.
GATE: clean shapes under Red Wax, no stretched faces, stroke marks falling.
- Resolution up: head by the square law (
Leave DynaMesh when the forms are settled and S3 needs levels, retopology is coming, or later big changes must survive detail. Henning's DynaMesh-only ladder to 648 suits a concept still: state which route and why. Levels (P11): versioned ZTL, Duplicate, ZRemesher the copy, Divide until
projection_ready, then the toolkit'szb_ops.project_all(checkpoint=...), which carries the safe order (versioned save, StoreMT and a New Layer at the top level, PA Blur lowered, a gate,morph_repair); never re-implement it.GATE: target points at least the sketch's, volume within 1 % [added], the sheet unchanged, eyes and mouth checked level by level (repair: scenario-zbrush-retopology-export).
S3 secondary.
- On levels (P15):
level_for("secondary")(SDiv 2 or 3), then"refine"one level up at a time; big fixes return tolevel_for("big")= SDiv 1; the top level only for tertiary and layers. - Symmetry off for the asymmetry pass;
pattern="two_direction"on long masses; presetclay_secondary.
GATE:
mirror_asymmetryabove zero, the mid band above S2, the sculpt reads at thumbnail size without detail.- On levels (P15):
S4 contrast. Dam_Standard along the creases (
crease_pass), deeper than feels right (0PaYUUvgwYM 00:19:49); Standard at raised Z on lids, lips and brow edges (preset="standard"), blended with clay;fill_betweentwo cuts. GATE: shape IoU against S3 of at least 0.98 [added]; crease depth varies along the line.Hand off S5 and S6 to the domain skill with the P14 package.
Numbers
| Value | Number | Relative to | Source |
|---|---|---|---|
| Scripted Z, clay | block 6, refine 2, finish 2; descent 6, 3, 2, 1 | expert Z times 0.3 pressure | Henning 00:04:58; digest derivation |
| Brush diameter | clay block 15 to 35 %, secondary 10 to 15 %, refine 3 to 8 %, Dam 3 to 7 %, Standard 1 to 2 %, Move design 60 to 90 %, Move local 10 to 15 % | head width in view (Draw Size is canvas pixels: zb_sculpt converts through ppu); clay kept within 0.5 to 1.0 of the form width |
FlippedNormals frames; digest T1 |
| ClayBuildup | FreeHand, Focal -56, Alpha 06 or Off; Imbed 20, Roll Dist about 6 | Henning; Morten; Pablo frame 00:05:55 | |
| Dam_Standard | Dots, Focal -14, Zsub, Z 15 (33 for big cuts) | "ten below where it starts" | 0PaYUUvgwYM 00:18:42; TpS0QdlfHWU |
| Move / TrimDynamic | Focal 0, Z 51 / Focal -56, Z 43 lowered, Zsub | TpS0QdlfHWU frames | |
| Head point ladder | 43k sphere, 96k to 99k S1, 400k to 826k S2, 1.6M to 2.46M S3 and S4 | points | FlippedNormals frames |
| Creature ladder | skin 6k, pieces 28k to 43k (DynaMesh 64 to 128), merged 256 = 367k, SDiv 5 = 3.8M | points | Pablo tbQqC6tyDBQ frames |
| DynaMesh | points grow with resolution squared; limits: details safe in a 1024 cube, cap 2048 (about 24M polys) | bbox longest side; per SubTool | TpS0QdlfHWU ladder; DynaMesh doc |
| Divide, ZRemesher | x4 per level; target in thousands, 5 gave 4,963 | Pablo rArw79xEpvE 00:03:16 | |
| SDiv per operation | big moves 1; secondary 2 or 3; tertiary and layers top | levels of the stack | Pablo rArw79xEpvE 00:12:28, 00:14:16 |
| Projection | target points at or above the sketch's (745k vs 400k); FlippedNormals about 3M | VRisbJQAaZw 00:17:24; Zp07GW3rND0 00:01:02 | |
| Eye angle | 3 to 7 degrees outward each, about 5 | straight ahead | Costa j5XLtLMN0P8 00:29:10 |
| Pass gates | dead strokes up to 10 %; re-DynaMesh at 1.5 x edge cv; Polish drift 2 % | [added] |
Quality gates
- Measured:
pass_reportafter every pass (dead strokes, volume sign, view drift, pivot shift, settings not found);remesh_code(...)["ok"]; points againstSTAGESand the SDiv for the operation;stage_gate(budget, big Move above SDiv 1, shape IoU, asymmetry, bands);zb_audit.verdict(..., "sculpt");remesh_due, Polish drift,projection_ready. - Seen: the review sheet in MatCap Gray (front, right, back, three-quarter, top); Flat Color silhouettes and the sheet at thumbnail size; a grazing view, Red Wax for marks, a Standard material under a moving light (MatCaps bake their light) or BPR for form reading; the perspective look render for eyes; the stage checklist in
references/critique.md. Never call a stage done without looking at its sheet.
Common mistakes
| Mistake | What it looks like | Fix |
|---|---|---|
| Expert Z as a script Z (ClayBuildup 20) | harsh ridges, one-stroke muscles | Z 6, calibrate, dial the pass |
| Clay dragged along the form | drawn grooves, no volume | clay_pass hatches across |
| Stroke starting off the model | strokes change nothing, the view may turn | plans clip to the canvas mask; re-capture |
| LazyMouse left on | stroke starts missing, short strokes dead | pass_spec default lazymouse=False |
| Smoothing marks away on skin | "semi smooth with details", volume loss | descent passes; Polish only low-res or at a lower SDiv |
| ClayBuildup over finished detail | lines and pores erased | Standard for edges; clay before detail |
| Dividing a DynaMesh | lumps, star poles | ZRemesher, Divide, project_all |
| Big Move at the top level | wonky lumps | level_for("big"), then back up |
| Re-DynaMesh with a mask on or in Move mode | nothing remeshes (the gesture only clears the mask) | remesh_code |
| DynaMesh with polypaint on | polygroups gone after the remesh | keep="groups" (Colorize off), or "polypaint": never both |
| Morph dial after a remesh or Divide | the target is gone | dial first; expect_points |
| Symmetry assumed after a SubTool change | the stroke lands on one side | it is per SubTool (Pablo RABWfzWNw0c 00:27:02): every pass sets it |
Brush:Smooth picked as the brush |
nothing smooths (it only becomes the Shift brush) | Deformation Polish, lower SDiv |
| Mirror And Weld with LSym on, or the bad side as source | mirrors locally or copies the bad side | LSym off; Deformation Mirror first (9a9S1OcYHx4 00:09:35) |
Handoffs
- Receives: from scenario-zbrush-expert a session, brief and versioned ZTL; from scenario-zbrush-retopology-export a cleaned AI or scan mesh (scenario-3d meshes arrive this way); from domain skills proportions and the forms list.
- Delivers the P14 package (versioned ZTL,
stats(), last sheet, forms as JSON, calibration, gate): realistic S3 or S4 forms on levels to scenario-zbrush-character-creature; shape-language work and hair to scenario-zbrush-stylized; organic bases to scenario-zbrush-hard-surface; a finished sculpt to scenario-zbrush-retopology-export.
ZBrush 2026 notes
- Perspective is
Draw:Perspective(installed commands.xml);Transform:Perspdoes not exist (zs.PERSP_PATHS). PolyFrame isTransform: Pf. Brush:Dam_Standarddoes not resolve [v03];zb_ops.select_brush("Dam_Standard")finds DamStandard.- Smooth brushes only bind to Shift and
press_keydoes not work: Zsub replaces Alt, Deformation Polish replaces Shift, Masking buttons and Mask Changed Points [verify] replace Ctrl gestures. - No undo control (
merge_undois a stub): Morph Target or layer per pass, versioned ZTLs per stage. - Gizmo deformers and Transpose drags are GUI-only; the Gizmo deforms softly unless Focal Shift is -100. Local Symmetry follows the Gizmo: off before Mirror And Weld.
- Sculptris Pro: no levels; Dots, FreeHand and Spray only. Chisel's default stroke changed in 2025.1: every pass sets its stroke type.
References
references/procedures.md: bridge code per step (P0 to P17) with its live test. Load before writing bridge code.references/expert-notes.md: the experts in depth and the disagreement table. Load when choosing between approaches.references/critique.md: per-stage rubric, AI-mesh judgment, stroke-plan review. Load at every gate.references/gui-paths.md: hotkeys, palette paths and brush settings (computer-use agent).references/sources.md: sources, credentials, URLs, timestamps.scripts/zb_sculpt.py: stages, presets, Scene, generators, calibration, pass execution, remesh, levels, review metrics. Tests:tests/code/zbrush-sculpting/(offline passed; live not yet run in ZBrush).
Files (skills)
-
references
-
critique.md 8.7 KB
# Critique rubric: judging core forms the way the experts do Use at every stage gate, on the `zb_review.review()` sheet (MatCap Gray, front, right, back, three-quarter, top) plus the extra looks listed here, and with the numbers from `zb_sculpt.stage_gate`, `pass_report` and `compare_reviews`. Answer each line yes or no, name the ONE weakest item, fix it, re-review. A metric that passes while the sheet looks wrong: the sheet wins. Masks and silhouette metrics come from the canvas PNGs (background = the vertical gradient of each row); with MatCap Gray the bottom of a silhouette can blend into the gray end of the gradient, so look at `zs.silhouette_mask` output once per material before trusting IoU numbers [added]. ## Looks to capture (beyond the default sheet) | Look | How | What it shows | Source | | ------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------- | | Silhouette | `Material:Flat Color` [verify name] or the canvas mask (`zs.silhouette_mask`), front and side | the design read, boxiness, missing elements | Henning G2o6fdoACIQ 00:14:09 | | Thumbnail | the sheet downscaled to about 128 px (squint) | does it read without detail | Pablo tbQqC6tyDBQ 00:14:05 | | Grazing view | a view 60 to 80 degrees off the surface normal of the worked area | lumps, stroke seams, bumps between forms | Pablo 9a9S1OcYHx4 00:02:34 | | Moving light | a Standard material (BasicMaterial) with the light front, side and behind, or BPR; never a MatCap, which bakes its light | whether forms read under changing light | Pavlovich aR02CwyPTUw 00:04:39; Costa j5XLtLMN0P8 00:19:51 | | Perspective look | `zs.persp_snapshot_code(path, "threequarter")` (perspective on for this render only) | eye placement, eye sockets, appeal | Henning TpS0QdlfHWU 00:17:28; Shane t_gg7MIGSDM 01:41:03 | | Surface differences | MatCap Red Wax | stroke marks, uneven surfaces | Pavlovich aR02CwyPTUw 00:03:32 | | Planes | Chrome MatCap on polished or planar areas | waviness in planes meant to be flat | Pavlovich aR02CwyPTUw 00:03:32 | | Close-up | `Scene.capture(..., center=form, ppu=2 to 3 x)` then the canvas PNG | crease quality, mark removal | Henning TpS0QdlfHWU 00:22:26 | ## S0 and S1: base and primary blockout - Silhouette reads instantly from front and side; no unintended straight runs or right-angle corners on an organic subject (Henning G2o6fdoACIQ 00:15:17). Judge from the side when the front is occluded (00:16:23). - Proportions match the brief (limb lengths, head in the body, landmark positions from the domain skill). - Every element the subject needs is present now: ears, eyes, nose, mouth, teeth, horns, accessories (G2o6fdoACIQ 00:02:50). - Masses are big and simple; no small scribbles or detail (Pablo tbQqC6tyDBQ 00:02:29). - Inserted primitives feel part of one body; no hard shading break at joins (tbQqC6tyDBQ 00:03:16). - Measured: points inside the S1 range (not a lumpy high-resolution mesh, tbQqC6tyDBQ 00:04:38); `pass_report` clean for every pass; symmetric where intended (`zb_audit` symmetry_x_pct); every re-DynaMesh through `remesh_code` with `ok` true, and the polygroups still there when they had to survive (polypaint was off). ## S2: primary refinement - Clean shapes with no ambiguous bumps ("junk sculpting", Henning TpS0QdlfHWU 00:15:51). - Each form has a peak in its center that fades to its edges (0PaYUUvgwYM 00:02:45). - Stroke marks falling pass after pass (Red Wax, close-ups); marks left on purpose only for a clay look (TpS0QdlfHWU 00:21:53). - No stretched faces after Move passes (smeared shading along a moved area): re-DynaMesh (`remesh_due`). - Eyes are spheres placed first, each turned 3 to 7 degrees outward (Costa j5XLtLMN0P8 00:29:10); the lids wrap the eyeball (TpS0QdlfHWU 00:18:33). Judged on the perspective look render (00:17:28); review sheets and strokes stay orthographic. ## S3: secondary (mid frequency) - Shapes within shapes: how skin wraps around mouth and eyes, fat pads, muscle transitions (Henning 0PaYUUvgwYM 00:14:18; G2o6fdoACIQ 00:05:13). - Symmetry broken on purpose (front mirror difference above zero: `mirror_asymmetry`) (G2o6fdoACIQ 00:06:19; Costa 00:35:03). - Not "semi smooth with details" and not "primary forms plus crazy alphas" (0PaYUUvgwYM 00:11:04 to 00:11:35). - Forms collide and ripple together in an expression (Costa 00:11:54); nothing "screams" (00:16:40). - The sculpt reads strongly at thumbnail size without any detail (Pablo tbQqC6tyDBQ 00:14:05). - The work sat at the right level: secondary forms at SDiv 2 or 3 and refined upward, big fixes at SDiv 1, nothing done only at the top; no visible stroke starts and ends (Pablo rArw79xEpvE 00:12:28 to 00:14:16; tbQqC6tyDBQ 00:12:26). - A Morph Target dialed in this stage was stored after the last remesh or Divide (`expect_points` passed). - Measured: mid band (`band_energy`) up against S2 in the same view; fine band not above the mid band. ## S4: contrast - Sharp where reality is sharp (lids, lips, nasal wings, deep folds), deeper than feels right (0PaYUUvgwYM 00:19:49 to 00:20:22); hard edges set with Standard and blended with clay (Pablo tbQqC6tyDBQ 00:10:27), no ClayBuildup over finished detail (TpS0QdlfHWU 00:07:44). - Crease lines vary in weight and overlap; not one constant line (00:20:55). Measured in live_s03 as depth variation along the crease. - Soft areas next to sharp ones; the eye goes to what is different (00:24:46). - Dominance: some folds lead, others are softened back (00:25:49). - Measured: silhouette shape IoU against S3 at or above 0.98 [added]: detail stages do not change the outline (Pablo tbQqC6tyDBQ 00:13:27). ## S5 and S6 (owned by the domain skills; the checks this skill still runs) - Details sit on mid forms, never on flat unbroken forms (G2o6fdoACIQ 00:07:45). - Feature details first (scars, deep creases), texture around them, fading at transitions (Pablo tbQqC6tyDBQ 00:15:19 to 00:18:00). - Skin reads as skin, not clay; one light smooth at the end at most (TpS0QdlfHWU 00:22:58). - Hierarchy: quiet areas and focus areas (detail density not uniform). ## Judging a cleaned AI or scan mesh as "forms worth detailing" (scenario Z6) After scenario-zbrush-retopology-export has repaired, separated and remeshed the parts: - The AI tell is gone: noise and lumps spread evenly everywhere, forms that neither begin nor end. A professional base has a hierarchy of big, medium and small shapes with quiet areas (Henning 0PaYUUvgwYM 00:25:18). - Every form has a clear start and end: tucks and overlaps (brow over eye, lip over teeth), clean creases where forms meet, not mushy blends [added wording of the digest checks]. - Planes are stated where the design wants them (brow, cheek, jaw planes: TrimDynamic or hPolish passes), not balloon surfaces. - Fused parts are separate SubTools or separated by clean creases; no bridges between close parts (DynaMesh fuses intersections, Pablo FrqUnna1jns 00:15:39). - The silhouette still carries the character's appeal in identical views against the raw import (`compare_reviews` of the raw and cleaned review folders). - Measured: `zb_audit.verdict(..., "sculpt")` clean; one shell per SubTool unless intended; edge length cv near a fresh DynaMesh value; volume change against the raw mesh explained (smoothing costs volume). ## Stroke-plan review (before sending a pass) Open `zs.preview_plan` output and check: - Clay strokes cross the form (perpendicular to its center line); line brushes follow it. - Brush circles are about the form's width at S1 and S2, smaller for refinement; nothing large at the top level. - No stroke crosses the symmetry plane with symmetry on; strokes stay inside the model and away from the silhouette edge. - The worked surface faces the camera (no "faces away" warning); otherwise pick `best_view`. -
expert-notes.md 23.4 KB
# Expert notes: core-form sculpting in ZBrush The depth behind SKILL.md, by expert, with source and timestamp. Video ids resolve to `https://www.youtube.com/watch?v=<id>`; the per-source notes are in `notes/sculpting/` and `notes/fundamentals/`. "Frame" values were read from the ZBrush UI on screen; brush sizes relative to the head were measured on frames and are approximate. [added] marks this skill's own inference or default. ## Henning Sanden and Morten Jaeger (FlippedNormals; ex-MPC and ex-Framestore film character artists) Sources: Learn to Sculpt Like a Pro (0PaYUUvgwYM, 2018), The Only 6 Brushes You Ever Need (TpS0QdlfHWU, 2020), Top Tips for Sculpting (G2o6fdoACIQ, 2020), Reprojecting Details (Zp07GW3rND0, 2018). **Pressure is the technique.** Henning blocks at ClayBuildup's default Z 20 with "maybe 30% of the actual pressure", which is what lets strokes blend; a mouse gives "the same pressure all around", "you need a tablet" (0PaYUUvgwYM 00:01:40, 00:04:58). This is the handicap of every scripted stroke. Constant-pressure equivalent: Z 20 at 30 % is about Z 6; his refinement "Z 5 or even 2" at a light hand is about Z 1 to 2 (sculpting digest inference, used as defaults in `zb_sculpt.BRUSHES`; the live calibration replaces them). **Across the form for clay, along for lines.** ClayBuildup strokes go perpendicular to the form so the peak lands on its center and fades to its edges; along the form is "drawing the shape" (0PaYUUvgwYM 00:02:13 to 00:02:45; G2o6fdoACIQ 00:10:08, 00:10:40). "I'm only going across the form when it comes to the clay brushes"; Standard and Dam_Standard go along the line they draw (0PaYUUvgwYM 00:30:39, 00:22:34). **Refine, do not smooth.** Blend by crossing the join at low pressure, never Shift (0PaYUUvgwYM 00:04:58 to 00:06:37); remove stroke marks with a refinement pass at Z 5 or 2 across the form, which also builds tertiary variation: "I don't regard it as a smoothing pass, I regard it as a refinement pass" (00:08:51, 00:09:57). The descent on the dwarf bust: Z 23, 13, 3, 1, then 5 for touch-ups, zooming in a little each time, "like a progressive renderer"; when the last marks need ten more passes at 1, say "good enough" and smooth once (TpS0QdlfHWU 00:21:53 to 00:23:31, frames 00:21:27 to 00:24:09). Smoothing has legitimate jobs: destroying a sculpt to rebuild it, integrating added volume, selectively smoothing some areas (TpS0QdlfHWU 00:11:42 to 00:12:46). **Stage ladder with numbers on screen.** Head on a DynaMesh sphere at about 99k points (Resolution slider 192, 98,787 points), Divide to 399,028 (SDiv 2), Divide to 1.596M (SDiv 3) for Dam_Standard and Standard (0PaYUUvgwYM frames 00:00:45, 00:07:30, 00:18:49). Bust by DynaMesh only: 128 = 96,222 points at the end of blocking, 376 = 825,837, 648 = 2.457M (TpS0QdlfHWU frames 00:14:16, 00:18:45, 00:21:27). Points scale with Resolution squared, so `res_next = res * sqrt(target / now)` (digest derivation; `zb_sculpt.next_dynamesh_resolution`). A PolyMesh3D sphere is 8,320 points and DynaMesh 128 gives 43,401 (TpS0QdlfHWU frames 00:08:55, 00:09:40); this install gave 43,480 [obj]. **Six brushes and their jobs** (TpS0QdlfHWU 00:01:41 to 00:16:55): - Move (key 4): Dots, Alpha Off, Focal Shift 0, Z 51; drag clean shapes then DynaMesh, repeat (00:02:02, 00:02:34); brush 0.6 to 0.9 of the head for design moves, about 0.12 of the face for eyes (frames 00:17:15, 00:18:00). - Standard (2): pushes and pulls what is there, so it enhances existing detail; Z 25 then 21 then 11 on the head, about 1 to 2 % of head width (0PaYUUvgwYM frames 00:27:33 to 00:30:32; TpS0QdlfHWU 00:03:55). - Dam_Standard (5, B D S): Dots, Focal Shift -14, Zsub; "maybe 15, ten points below where it starts" (0PaYUUvgwYM 00:18:42, frame 00:19:34) or 33 for big cuts (TpS0QdlfHWU frame 00:05:54); soft, harder, softer, harder, with small overlaps, never "ticka ticka" constant lines (0PaYUUvgwYM 00:20:55 to 00:21:29); bigger brush for a more voluminous cut, Alt for a sharp ridge (TpS0QdlfHWU 00:06:18). - ClayBuildup (3): FreeHand, Focal Shift -56, Alpha 06 (Henning) or Alpha Off (Morten), never the default square alpha (TpS0QdlfHWU 00:07:18, 00:08:04); it replaces what is under it, so never after pores (00:07:44). - Smooth and Smooth Stronger (Shift): Smooth Stronger for very dense meshes where Smooth does nothing, about Z 30 for an in-between that also cuts planes; one click destroys a low-res mesh (00:13:15 to 00:14:53). - TrimDynamic (6, B T D): FreeHand, Alpha Off, Focal Shift -56, Z 43, Zsub; early, not final; removes "junk sculpting" (00:15:19 to 00:15:51); as the Shift brush it "forces me to think about shape" (00:16:24). **Frequencies.** Mid frequency is "the backbone of the detailing step" and the one most artists skip (G2o6fdoACIQ 00:05:46); two beginner signatures: "semi smooth with details" (secondary smoothed away) and primary forms plus "crazy alphas" (mid frequency skipped) (0PaYUUvgwYM 00:11:04, 00:11:35). If you wonder whether to add pores, "you're two levels behind" (G2o6fdoACIQ 00:08:17). Add every needed element early, the troll's teeth change the whole read (00:02:50). Break symmetry in the mid-frequency stage (00:06:19). **Contrast and dominance.** Carve deeper than feels right; shading, SSS and soft light eat sharpness (0PaYUUvgwYM 00:19:49 to 00:20:22). A contrast stage after secondary: sharp cuts next to soft areas, "like a white thing next to a black thing" (00:23:39 to 00:24:46). Do not detail everything: soften some wrinkles back, keep dominant ones (00:25:18 to 00:25:49). "Coloring inside the lines": build volume between two Dam_Standard cuts (00:26:22). **Eyes.** On the dwarf bust Henning moves the eyes a bit in "making sure perspective is on", sharpens cartoony eyes into realistic ones with Move (brush about 0.12 of the face), raises DynaMesh to 376, forces the upper and lower lids in with Dam_Standard and moves them out "so they conform to an eyeball", then smooths the area a little (TpS0QdlfHWU 00:17:28 to 00:18:33, frames 00:17:15, 00:18:45). For an agent: eyeball spheres first, strokes planned orthographic, the look judged on a perspective render (P17). **Silhouette as a metric.** Keep the Silhouette thumbnail on (TpS0QdlfHWU, every frame); "the less your silhouette changes between the different levels, the more solid your design" (G2o6fdoACIQ 00:17:27); judge from the side when the body hides the front (00:16:23); no unintended straight lines or right angles (00:15:17). **Reprojection.** Subdivide the new low-res to "a few million", about 3M; at the top level Store MT, then New Layer, then Project All with PA Blur lowered (Zp07GW3rND0 00:01:02 to 00:02:47); repair with the Morph brush, not smoothing (00:04:27, 00:06:36); eyes protected with an eye-interior polygroup grown past the lid (00:05:31); danger zones: eyes, mouth, armpits, between fingers, crotch (00:07:10). Stop the concept sculpt before micro detail if retopology is coming (00:08:22). Owned downstream by scenario-zbrush-retopology-export; this skill only uses it to get subdivision levels for S3. ## Pablo Munoz Gomez (3D concept artist and educator, ZBrushGuides founder, Maxon ZBrushLIVE host) Sources: Understanding ZBrush's Logic Parts 1 to 8 (NckHjyO6GNk, RABWfzWNw0c, gitoJ7B8FmY, mVp_jNfEI3I, FrqUnna1jns, 9a9S1OcYHx4, rArw79xEpvE, VRisbJQAaZw, 2025), Dos and Don'ts when Sculpting (tbQqC6tyDBQ, 2025, ZBrush 2025.3). **Blockout without brushes.** A whole blockout with primitives and the Gizmo, "without even actually using any of the brushes" (gitoJ7B8FmY 00:05:26): cylinders with Gizmo deformers (Smooth, Taper, Bend Arc), Duplicate, Mirror And Weld, MergeDown (RABWfzWNw0c 00:09:18 to 00:24:19). Parts built in their own tools, MergeVisible (a new tool), then Append (gitoJ7B8FmY 00:03:31, 00:04:11). For an agent the Gizmo deformers are GUI-only; the scripted equivalents are Tool > Deformation sliders on a SubTool made for the part and Tool > Geometry X/Y/Z Position and XYZ Size. **ZSpheres for proportion and silhouette only** (tbQqC6tyDBQ 00:01:13): 66 spheres on the creature, Adaptive Skin with DynaMesh Resolution 0 (Density 2 on screen, 5,994 points; frame 00:01:58), or Density 1 for a light skin (gitoJ7B8FmY 00:17:35 to 00:18:12). "Don't crank up the DynaMesh resolution too high, at least not too early because then you're gonna get this lumpy mess" (tbQqC6tyDBQ 00:04:38). **Light meshes, raised in steps.** DynaMesh per SubTool at 64 to 128 (80 most often, 28k to 43k points), merge, DynaMesh 256 for transitions (366,728 points) (tbQqC6tyDBQ frames 00:05:55 to 00:11:55). DynaMesh resolution is relative to the object's size: the same value gives more detail on a bigger object (FrqUnna1jns 00:13:40); powers of two after 128, 256 (9a9S1OcYHx4 00:10:11); raise it before cutting sharp Dam_Standard lines. Intersecting parts always fuse; separate polygroups plus Groups keep them apart (FrqUnna1jns 00:15:39, 00:16:16). Merge is not DynaMesh (00:23:25). A faint mask eats the re-DynaMesh gesture (00:07:28). **Never Divide a DynaMesh to detail.** 390,000 to 1.5M to 6M of triangles and star poles that no smoothing fixes; ZRemesher first (rArw79xEpvE 00:09:44 to 00:11:28). ZRemesher target is in thousands and approximate: 5 gave 4,963, 1 gave about 2,700 on a small horn (00:03:16, 00:05:12). Sketch to clean: store the history point, ZRemesher 5, Divide until the top level has at least the sketch's points (745,000 against 400,000), Project History (VRisbJQAaZw 00:16:51 to 00:19:13); scripted substitute: Duplicate plus Project All. **Level discipline.** ZRemesher when the form is settled, DynaMesh while it is not (rArw79xEpvE 00:05:50). Big moves at SDiv 1; at the top level a Move push "looks very wonky" (00:12:28 to 00:13:05); block secondary forms at SDiv 2 or 3 and refine upward, smoothing and refining at each level, because detailing only at the top shows where strokes start and stop (00:13:42 to 00:15:28); do not stay at the highest level for everything (tbQqC6tyDBQ 00:12:26). Dam_Standard on levels: a larger size at lower intensity, smoothed right after each stroke (rArw79xEpvE 00:15:41). Agent form: `zs.level_for` and `zs.set_level_code` (P15). **Brushes by profile** (9a9S1OcYHx4): Standard rounded and raised (stacked Standard strokes are the beginner's lumpiness, 00:02:34); Clay flat strips; ClayBuildup fast mass, gentle with soft pressure (00:04:31); Dam_Standard a knife (00:06:10); Alt inverts any brush (00:07:15); Inflate along normals, re-DynaMesh after (00:11:33, 00:15:06); Pinch for crisp edges, rarely on organic work (00:15:20). Move vs Move Topological: Move Topological follows connectivity, not color (mVp_jNfEI3I 00:10:21, 00:13:58); a bigger Move brush is easier than a tiny one (00:08:48). Symmetry fix when the wrong side is the reference: Deformation > Mirror, then Mirror And Weld (9a9S1OcYHx4 00:09:35); Local Symmetry is global and must be off before Mirror And Weld (gitoJ7B8FmY 00:10:22). **Stroke building.** Low intensity, many short strokes: "from left to right multiple times, with multiple separate strokes", then smaller strokes perpendicular; never a muscle in one hard stroke (tbQqC6tyDBQ 00:05:22 to 00:06:32). ClayBuildup on screen: Imbed 20, LazyMouse on, LazyRadius 1, Roll Dist about 6, Focal Shift -56 (frame 00:05:55). Keep surfaces clean at all times and smooth constantly during blockout (00:06:47); early cuts are guides that may go away (00:08:12). Sharp masks, not blurred ones, for Move edits, then re-DynaMesh and smooth (00:09:09, 00:09:46). Standard at raised Z to set an edge, then blend with clay (00:10:27). **Gates.** "The details won't fix a weak sculpt... They will just highlight them" (tbQqC6tyDBQ 00:12:50); details do not change the silhouette, a good sculpt "should already look strong... even without the details" (00:13:27, 00:14:05). Feature details first by hand (scars, deep creases, strong folds), texture brushes around them, fading at transitions (00:15:19 to 00:18:00). Evenly distributed quads are enough for sculpting, printing and concepts; perfect loops are for animation (00:11:43). **SubTools and merges** (Logic Parts 2 and 3). Symmetry is stored per SubTool: "the important thing about symmetry is that it is connected to the sub tool that you're working on", so re-check it after every SubTool change (RABWfzWNw0c 00:27:02). MergeDown merges the selected SubTool into the one below and shows a confirmation he keeps on as a safety net (00:24:19, 00:24:54); MergeVisible creates a new Tool and leaves the working Tool with separate pieces (gitoJ7B8FmY 00:03:31). Merging is not DynaMesh: re-DynaMesh to fuse (FrqUnna1jns 00:23:25). **Polypaint judgment** (Logic Part 8): judge colors on a flat material (SkinShade4), because shiny or tinted MatCaps lie about hue and value, and paint at the highest level (All High) for paint resolution (VRisbJQAaZw 00:23:40 to 00:24:54). Owned by scenario-zbrush-paint-render; listed here because a remesh or projection decides whether the paint survives. **Sculptris Pro** is "an exploration tool rather than a polishing tool", also for local detail without raising the whole DynaMesh; Subdivide Size 0.045 jumped a mesh to 3.4M polygons (VRisbJQAaZw 00:10:41, 00:13:49). Dynamic Subdiv: edit the cage low, see it high; Apply makes real levels (00:02:20, 00:04:08). ## Michael Pavlovich (Director of Character, Weapon and Vehicle Art, Certain Affinity; ZBrushLIVE host) Sources: Intro to ZBrush 005, 010, 015, 017, 019, 021, 026, 029, 030 (rEIoFEEgS80, 1sYMoWz_Mk4, AdkZe1yKFTU, aR02CwyPTUw, 8kWFv1cZlCE, 5JM1fb7wnTM, TjGlVX4w1nM, 9qLbeAapUH0, bBHqeaKNOzg; 2021). - **Stroke settings decide whether a scripted stroke behaves like a hand stroke** (AdkZe1yKFTU). Standard has LazyMouse on at radius 1, which swallows taps and short strokes; he turns it off for dots, pores and veins and uses radius about 27 for designed lines (00:03:31, 00:05:05). ClayBuildup resets to LazyStep 0.1, LazyRadius 1, "just enough to smooth out the stroke" (00:10:33); stepping fix order: LazyStep down, Roll Distance up (4), Mouse Avg last (00:11:06, 00:18:37). Stroke Jitter and Brush Imperfection are global and follow you onto every brush (00:09:52). Crisp stamps come from Focal Shift -100 (00:08:17). Replay Last (1) strengthens the last stroke in place (00:16:21). - **Masks as the bridge to deterministic operations** (8kWFv1cZlCE): a mask plus Tool > Deformation Inflate is "one of my favorites"; blur the mask first for falloff (00:17:10, 00:17:44); mask then Polish or Smooth, or Color > Fill Object, instead of strokes (00:20:38, 00:21:11). DynaMesh without Colorize on loses the polypaint (00:20:06). Edge-respecting masks: Depth Mask on the mask brush with tiny non-zero depths (00:14:48); ZBrush 2026.2.1 ships a Toggle Mask Depth macro (InnerDepth -0.01, OuterDepth 0.01). - **Polygroups as selection sets** (5JM1fb7wnTM): Groups By Normals Max Angle 90 behaves like Auto Groups on a cylinder, about 48 splits caps from sides (00:02:30); Ctrl+W is Group Masked Clear Mask (00:03:33). - **Gizmo for almost everything, Transpose for exact axes** (9qLbeAapUH0 00:14:08, 00:16:14). Since 2022 the Gizmo deforms softly unless Focal Shift is -100 (Gizmo doc). - **Materials for judging** (aR02CwyPTUw): MatCap Gray to sculpt (00:01:26); Red Wax to enhance surface differences, Chrome for polished surfaces (00:03:32); a Standard material with the light moved front, side and behind to see whether forms read (00:04:39). - **Files and names** (rEIoFEEgS80, bBHqeaKNOzg): Tool > Save As for .ZTL, Ctrl+S saves a project; the top SubTool takes the file name. Owned by scenario-zbrush-expert. - **Rhythm** (1sYMoWz_Mk4 00:02:50 to 00:05:01): carve with Dam_Standard, build with Clay, smooth, trim, Move, Ctrl+drag re-DynaMesh, carve again; check each brush's Z before using it (00:03:54). ## Kris Costa (former lead creature modeler at ILM; portrait instructor), Realism Idealized (j5XLtLMN0P8, ZBrush Summit 2021) - Primary, secondary and tertiary are a matter of viewing distance: eyelids are primary when you work on the eye, a "fractal progression" (00:08:12 to 00:10:25). Sketch primary and secondary together to catch the expression, then fix proportions through the lower levels without touching it (00:11:12). - Only ClayBuildup for forms: it "respects the boundaries between the different forms"; Standard only for veins (00:12:58 to 00:13:38). Subtle forms in very thin layers at ClayBuildup 2 or 3; forms must not "scream" (00:16:13 to 00:17:12). - Judge under physically accurate renders with several lights from the first sketch; a volume right under one light looks off under another (00:18:46 to 00:19:51). Asymmetry is "the only way" to capture likeness (00:35:03). - Eyes placed straight ahead at 90 degrees look cross-eyed: rotate each outward 3 to 7 degrees (the kappa angle), about 5 on average, more for a wall-eyed look; eyeball about 24 mm (00:29:10 to 00:30:16, 00:39:06). `zs.eye_rotation` carries the range. ## Joseph Drust (Pixologic, #AskZBrush) and Outpost VFX - Morph Target stores every vertex of the SubTool; the Morph slider blends back globally, partial values soften a too-strong pass, negative values exaggerate; a mask limits it; the Morph brush reverts locally with crisp edges (B_wKwXwjcZs 00:00:41 to 00:05:20). One target per SubTool, point count must not change (Morph Targets doc). - Outpost VFX 17 Pro Tips (2022): primary before secondary before tertiary, silhouette checked constantly (tip 1); two or three brushes learned deeply (tip 3); test alpha depth on a layer and dial it (tip 5); Morph Target to recover volume lost when smoothing thin parts (tip 7); add one or two subdivision levels to test sharper detail on a DynaMesh without committing (tip 9); BackfaceMask on thin meshes, in Brush > Auto Masking per the 2026 docs (tip 11). ## Maxon documentation (help.maxon.net, 2026) - DynaMesh is "a concept creation tool and not intended for highly detailed sculpting"; quit it when detailing starts. Details survive a remesh inside a 1024 cube; the cap is a 2048 cube, about 24M polygons per SubTool; low resolution tolerates bigger pushes. Remesh only in Draw mode with the mask cleared. DynaMesh deletes UVs and keeps polypaint instead of polygroups when polypaint is on. - Sculptris Pro works only without subdivision levels, not on primitives or ZSpheres, only with Dots, FreeHand and Spray strokes; slowdown from about 200k to 4M triangles; decimate to 20 %. - Divide adds a level only when nothing is masked or hidden; smoothing scale is set by the subdivision level (smooth big forms low, return up). - Clicking a Smooth brush only binds it to Shift; it cannot be the active sculpting brush. - Layer brush sculpts at constant depth after a fresh Store MT; LazyMouse non-buildup needs Chisel or Layer, LazyMouse on and a Morph Target. - Tool > Deformation sliders act on global axes and apply once per set; masks restrict them; Gizmo deformers need a mesh without subdivision levels. - Masking modifies "all editing (sculpting) actions, whether applied directly or adjusted in the Deformation sub-palette"; Mask Changed Points and Groups Changed Points turn a stroke's footprint into a mask or a group (2021.7). - ZSpheres are the only geometry the Python SDK can read and write point by point (`add_zsphere` inside `edit_zsphere`, SDK modeling docs); curves built with `add_curve_point` live in the tool's coordinate system, not canvas space (SDK example). ## Where the experts disagree, and the deciding condition | Choice | Positions | Decide by | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Smoothing | Pablo smooths constantly in blockout, Move plus Smooth (tbQqC6tyDBQ 00:02:29); Henning resculpts, smooths only to destroy or at "good enough" (0PaYUUvgwYM 00:05:32; TpS0QdlfHWU 00:22:58) | low-res blockout: smooth freely (agent: Polish slider); organic skin from S2 on: resculpt; dense meshes: Smooth Stronger at about 30 | | First remesh route | Pablo: light skin, low DynaMesh per piece, merge, 256, ZRemesher, SDiv 5; Henning: DynaMesh ladder to 648, or Divide the sphere mesh | limbs from ZSpheres: Pablo; a head sketch from a sphere: DynaMesh ladder; levels needed for big later changes: ZRemesher plus projection | | Clay stroke direction | Henning across only; Pablo along first, then smaller across | both finish across; along-first suits long masses (limbs, neck, muscles) | | ClayBuildup alpha | Henning Alpha 06, Morten no alpha, Pablo keeps the alpha with Roll Dist about 6 | Alpha 06 to build with texture, Alpha Off for the last passes | | Dam_Standard Z | 15 (0PaYUUvgwYM frame 00:19:34) vs 33 (TpS0QdlfHWU frame 00:05:54) | fine carving on a dense mesh low, big cuts higher [inferred in the digest] | | Order of primary and secondary | Henning, Pablo: gate strictly; Costa: sketch both together, then fix proportions low | a portrait's expression: Costa; a design from scratch: gate | | Brush set | six stock brushes (Henning) vs stock plus custom detail packs (Pablo) | stock for forms; custom brushes only around feature details | | Perspective | Henning shapes the eyes with perspective on (TpS0QdlfHWU 00:17:28); Shane keeps it off almost always, it skews masking, clipping and mirroring (t_gg7MIGSDM 01:41:03) | aimed strokes, masks and measurements orthographic; perspective only for a look render (`persp_snapshot_code`) [added] | | Stay on DynaMesh or go to levels | Henning's bust stays DynaMesh to 648 (TpS0QdlfHWU frames 00:14:16 to 00:21:27); Pablo ZRemeshes and divides once the form is settled (rArw79xEpvE 00:05:50) | concept still with no later big changes: DynaMesh ladder; levels needed for big later changes, retopology or detail past the 648 rung (2.46M points): ZRemesher, Divide, project | -
gui-paths.md 22.9 KB
# GUI paths, hotkeys and settings (for a computer-use agent or a human) The same procedures as `procedures.md`, the way the experts do them by hand in ZBrush 2026. Item paths in `Palette:SubPalette:Item` form are what the bridge presses; [xml] marks an id listed in the installed `ZData/ZLang/zcommands/commands.xml` (it lists no brushes or materials, and its ids have no section levels); paths not confirmed on this install are [verify]. macOS: the docs write Ctrl for Command, but Control+W assigns a polygroup while Command+W QUITS ZBrush since 2023.0.1 (never send Cmd+W); treat each modifier mapping as [verify] (version deltas 3.1, 3.13). ## Canvas, Edit mode and views | Action | Hotkey / gesture | Path | Note | | ------------------ | -------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | Edit mode | T | `Transform:Edit` [bridge] | draw a tool once, then Edit; with Edit off every drag stamps a copy (Pablo NckHjyO6GNk 00:12:20) | | Clear canvas | Ctrl+N | `Layer:Clear` [macro] | clears pixols, the tool stays in the Tool palette | | Frame | F | `Transform:Fit` [macro] | `Transform:Frame` does not exist [v03] | | Rotate, pan, zoom | drag background or right-drag; Alt+right-drag; Ctrl+right-drag | `set_transform` by script | right-click navigation keeps the left button for strokes (Pablo 00:20:42) | | Snap to front/side | Shift while rotating | canonical rotation triples | | | Draw mode | Q [verify] | `Transform:Draw Pointer` [xml] | needed for a re-DynaMesh (not Move, Scale, Rotate) | | Perspective | P (Shane) | `Draw:Perspective` [xml]; `Transform:Persp` does not exist | off for aimed strokes and measuring; on for the eye and appeal look (Henning TpS0QdlfHWU 00:17:28) | | PolyFrame | Shift+F (DynaMesh doc) | `Transform: Pf` [xml] | groups and topology | | Solo | Solo button | `Transform:Solo` [macro] | | | Symmetry | X | `Transform:Activate Symmetry` [bridge], `Transform:>X<` [macro] | per SubTool: re-check after changing SubTool (Pablo RABWfzWNw0c 00:27:02) | | Local Symmetry | L.Sym button | `Transform:LSym` [macro] | global; off before Mirror And Weld (gitoJ7B8FmY 00:10:22) | ## Brushes and Draw settings | Brush | Popup keys (Pablo, Henning) | FlippedNormals hotkey | Pavlovich hotkey | Path | Settings on the experts' screens | | ---------------- | --------------------------------------------------------- | --------------------- | ---------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Move | B M V | 4 | Alt+W | `Brush:Move` [bridge] | Dots, Alpha Off, Focal Shift 0, Z 51 | | Move Topological | B M T | | | `Brush:Move Topological` [verify] | Focal Shift 0 (Pablo) | | Standard | B S T | 2 | Alt+S | `Brush:Standard` [bridge] | Dots, Alpha Off, Focal 0, Z 25 to 11 | | Dam_Standard | B D S | 5 | Alt+D | `Brush:Dam_Standard` NOT found [v03]; `zb_ops.select_brush("Dam_Standard")` | Dots, Focal -14, Zsub, Z 15 (33 for big cuts) | | ClayBuildup | B C B | 3 | Alt+B | `Brush:ClayBuildup` [bridge] | FreeHand, Alpha 06 or Off, Focal -56, Z 20 at about 30 % pressure; Pablo Imbed 20, Roll Dist about 6 | | Clay | B C L [verify] | | Alt+C | `Brush:Clay` [verify] | flat strips (Pablo) | | TrimDynamic | B T D | 6 | Alt+T | `Brush:TrimDynamic` [verify] | FreeHand, Alpha Off, Focal -56, Z 43 lowered, Zsub | | hPolish | | | Alt+H | `Brush:hPolish` [bridge] | planes | | Inflat | B I N | | | `Brush:Inflat` (file Inflat.ZBP) | re-DynaMesh after heavy use | | Pinch | B P I [verify] | | Alt+Y | `Brush:Pinch` [doc] | crisp edges, rarely organic | | SnakeHook | B S K | | | `Brush:SnakeHook` [verify] | with Sculptris Pro or DynaMesh | | Smooth | hold Shift | Shift | Shift | clicking a Smooth brush only binds it to Shift (BR doc) | hold Shift to set its own Z (Pablo about 70, Pavlovich 100) | | Smooth Stronger | LightBox > Brushes > Smooth > Smooth Stronger, then Shift | | | `Lightbox/Brushes/Smooth/Smooth Stronger.ZBP` (file exists) | about Z 30 (Henning TpS0QdlfHWU 00:14:53) | | Setting | Hotkey | Path | | -------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Draw Size | S, [ and ] | `Draw:Draw Size` [bridge]; Dynamic mode by double-clicking "Dynamic" on the slider, keep it off | | Z Intensity | U | `Draw:Z Intensity` [bridge] | | Focal Shift | O | `Draw:Focal Shift` [bridge] | | Zadd / Zsub | Alt inverts while held | `Draw:Zadd` [bridge], `Draw:Zsub` [verify]; the script sets Zsub instead of holding Alt | | Stroke type | Stroke palette | `Stroke:Dots`, `Stroke:FreeHand` (ZBrushCentral macro) [verify live_s01] | | Alpha | Alpha palette | `Alpha:Alpha 06`, `Alpha:Alpha Off` (ZBrushCentral macro) [verify live_s01] | | LazyMouse | L | `Stroke:Lazy Mouse:LazyMouse` [bridge], `Stroke:LazyMouse`, `Stroke:LazyRadius`, `Stroke:LazyStep` [xml]; off for scripted strokes (Pavlovich AdkZe1yKFTU 00:03:31) | | Roll, Roll Dist | Stroke > Modifiers (since 2021.7) | [verify live_s01] | | Stroke Jitter, Brush Imperfection, Mouse Avg | Stroke > Modifiers | `Stroke:Stroke Jitter`, `Stroke:Brush Imperfection`, `Stroke:Mouse Avg` [xml]; global: reset to 0, 0, 1 | | Replay Last | 1 (Shift+1 relative) | Stroke palette; the script replays with `canvas_stroke` offsets instead | ## DynaMesh, Sculptris Pro, subdivision | Action | Gesture | Path | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | DynaMesh on / Resolution | Tool > Geometry > DynaMesh | `Tool:Geometry:DynaMesh:DynaMesh`, `...:Resolution` [bridge]; the installed ids are `Tool:Geometry:DynaMesh`, `Tool:Geometry:Resolution` [xml] (the Activity log writes these) | | Re-DynaMesh | Ctrl+drag on empty canvas (no mask anywhere, Draw mode; with a mask the first drag only clears it, Pablo FrqUnna1jns 00:07:28) | `zs.remesh_code(res, keep=...)` (P16) around `zb_ops.dynamesh`, which toggles off and on [verify live_06] | | Groups, Blur, Project | DynaMesh section buttons | `Tool:Geometry:Groups`, `Blur`, `Project` [xml] | | Polypaint on the SubTool | the SubTool's brush icon, or Tool > Polypaint > Colorize | `Tool:Polypaint:Colorize` [xml]: off before a remesh that must keep polygroups (DynaMesh doc) | | Resolution picker | drag from the slider onto the mesh (2022) | GUI only | | Sculptris Pro | button above the canvas, purple cursor | `Stroke:SculptrisPro` [xml]; only without levels, Dots, FreeHand, Spray strokes | | Divide | Ctrl+D | `Tool:Geometry:Divide` [bridge] | | Up / down a level | D / Shift+D [verify] | `Tool:Geometry:SDiv` [bridge] slider, `zs.set_level_code(zs.level_for(op, max))` (P15) | | Del Lower / Del Higher | Geometry buttons | [macro] | | ZRemesher | Geometry > ZRemesher, Target Polygons Count in thousands | `Tool:Geometry:ZRemesher`, `Tool:Geometry:Target Polygons Count` [bridge] [xml]; KeepCreases, KeepGroups [xml] | | Dynamic Subdiv | Geometry > Dynamic Subdiv > Dynamic, Apply | [verify] | | All Low / All High | SubTool palette | [verify] | ## Masks, polygroups, deformation | Action | Gesture | Path | | --------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | Paint mask | Ctrl+paint on the model | MaskPen via Ctrl only; not reachable by script (press_key does not work) | | Clear / invert mask | Ctrl+drag / Ctrl+click on empty canvas | `Tool:Masking:Clear`, `Inverse` [bridge] | | Blur / sharpen mask | Ctrl+click masked area / Ctrl+Alt+click unmasked area | `Tool:Masking:BlurMask`, `SharpenMask` [macro] | | Mask from last stroke | Tool > Masking > Mask Changed Points (2021.7) | [verify live_s04] | | Depth mask brush | Brush > Depth on the mask brush | shipped macro Toggle Mask Depth | | Auto Groups | Tool > Polygroups | `Tool:Polygroups:Auto Groups` [bridge] | | Groups By Normals | Tool > Polygroups, Max Angle (90 like Auto Groups, about 48 caps vs sides) | [verify] | | Group masked | Ctrl+W (Control on macOS) | `Tool:Polygroups:Group Masked Clear Mask` [macro] | | Isolate a group | Ctrl+Shift+click | visibility: `Tool:Visibility:HidePt/ShowPt/Grow/Shrink` [macro] | | Deformation sliders | Tool > Deformation | `Tool:Deformation:Polish`, `Inflate` [bridge]; Mirror, Smooth, Size, Offset, Rotate, Bend, Taper, Twist [xml], semantics [verify] | | Mirror And Weld | Geometry > Modify Topology | `Tool:Geometry:Mirror And Weld` [macro] | | Morph Target | Tool > Morph Target > StoreMT, Morph slider, Switch | `Tool:Morph Target:StoreMT`, `Switch` [macro]; `Tool:Morph Target:Morph` [xml]; lost on any point-count change | | Layers | Tool > Layers > New; eye and REC icons; intensity slider per layer | `Tool:Layers:New` [macro]; `Tool:Layers:Intensity` [xml], which layer it drives [verify] | | Merge in place | Tool > SubTool > MergeDown (confirmation, no undo; Pablo RABWfzWNw0c 00:24:54) | `Tool:SubTool:MergeDown` [xml]; MergeVisible makes a new Tool (gitoJ7B8FmY 00:03:31) | ## Gizmo, Transpose, primitives, ZSpheres | Action | Gesture | Script substitute | | ----------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | Gizmo move / scale / rotate | W / E / R; Y toggles Transpose | Geometry X/Y/Z Position and XYZ Size for whole SubTools; Deformation sliders on a mask | | Rigid Gizmo move | Focal Shift -100 first (soft deformation since 2022, Gizmo doc) | same | | Gizmo deformers (Taper, Bend Arc, Smooth) | gear icon, orange cones, Accept | Deformation Taper, Bend, Smooth sliders; GUI only otherwise | | Move the pivot | Alt while dragging the Gizmo | none (use Geometry sliders) | | Append a primitive | Tool > SubTool > Append, pick in the popup | `Tool:SubTool:Append` then `PopUp:<name>` [macro] | | Initialize a primitive | Tool > Initialize before Make PolyMesh3D | `Tool:Initialize:HDivide`, `VDivide`, `QCube` [macro] | | ZSpheres | Tool > ZSphere; Q draw a child; W/E/R move, scale, rotate; A preview; Alt+click delete | `add_zsphere` inside `edit_zsphere` | | Adaptive Skin | Tool > Adaptive Skin: Density, DynaMesh Resolution, Make Adaptive Skin | same items [verify live_s05] | ## Materials for review | Look | Path | | ------------------- | ------------------------------------------------------------------------------------------ | | Sculpting | `Material:MatCap Gray` [verify live_03] | | Surface differences | `Material:MatCap Red Wax` [macro] | | Silhouette | `Material:Flat Color` [macro] | | Moving light | `Material:BasicMaterial` plus the Light palette [verify]; MatCaps ignore the Light palette | | Polypaint colors | `Material:SkinShade4` (flat), at the top level (Pablo VRisbJQAaZw 00:23:40, 00:24:54) | | Final read | `Render:BPR` [bridge] | -
procedures.md 23.8 KB
# Bridge procedures for core-form sculpting Every procedure below is agent-side Python that drives ZBrush 2026.2.1 through the scenario-zbrush-expert bridge (`zb_launch`) and this skill's `scripts/zb_sculpt.py`. Status of all of them: **not yet run in ZBrush**. The offline logic is tested (`tests/code/zbrush-sculpting/test_zb_sculpt.py`, 45 tests, run 2026-09-24 after the round 1 refactor) and every live script, live_s08 included, runs end to end against an in-process fake ZBrush (`dryrun_live.py`, catches Python errors only). The live test that will verify each procedure is named under it; run them with `tests/code/zbrush-sculpting/run_live.sh` after the scenario-zbrush-expert live suite (live_04 installs the camera convention the aimed strokes use). ## P0. Imports and session ```python # not yet run in ZBrush import sys SK = "skills" sys.path[:0] = [SK + "/scenario-zbrush-expert/scripts", SK + "/scenario-zbrush-sculpting/scripts"] import zb_launch as zl, zb_review, zb_audit, zb_stroke import zb_sculpt as zs zl.start() # lead toolkit: spawn ZBrush with the bridge OUT = "/abs/out/head" # absolute paths only (relative ones land in the app) ``` All ZBrush-side work runs through `zl.run(code, modules=("zb_ops", "zb_stroke"))` or `zl.call("zb_ops", ...)`; `zb_sculpt` itself never runs inside ZBrush. ## P1. Neutral state before scripted strokes ```python # not yet run in ZBrush (paths reported by live_s01_calibrate) state = zl.run(""" out = {"zoom": zbc.get_canvas_zoom(), "missing": []} for cands, v in ((["Stroke:Stroke Jitter", "Stroke:Modifiers:Stroke Jitter"], 0), (["Stroke:Brush Imperfection", "Stroke:Modifiers:Brush Imperfection"], 0), (["Stroke:Mouse Avg", "Stroke:Modifiers:Mouse Avg"], 1), (["Stroke:Lazy Mouse:LazyMouse", "Stroke:LazyMouse"], 0)): p = zb_ops.resolve(cands, required=False) # installed ids first [xml] if p: zbc.set(p, v); out[p] = zbc.get(p) else: out["missing"].append(cands[0]) pp = zb_ops.resolve(%r, required=False) # zs.PERSP_PATHS: Draw:Perspective first if pp and zbc.get(pp) >= 0.5: zbc.set(pp, 0) out["perspective"] = zbc.get(pp) if pp else "missing" out["symmetry"] = zb_ops.set_symmetry(False) result = out """ % (zs.PERSP_PATHS,), modules=("zb_ops",)) assert abs(state["zoom"] - 1.0) < 1e-6, "Document zoom must be 1: Draw Size is in canvas pixels" ``` Why: Stroke Jitter and Brush Imperfection are global and follow every brush (Pavlovich AdkZe1yKFTU 00:09:52); LazyMouse at radius 1 swallows taps and the start of a stroke (AdkZe1yKFTU 00:03:31), and a synthesized path is already smooth; perspective breaks the linear projection the plans use [added]; Draw Size Dynamic mode must stay off (brushes doc). Perspective is `Draw:Perspective` in the installed `ZData/ZLang/zcommands/commands.xml`; the toolkit's `PATHS["persp"]` (`Transform:Persp`) does not exist, so this skill resolves `zs.PERSP_PATHS`. The Stroke ids without the Modifiers level are the installed ones [xml]; exists() on each form is [verify live_s01]. `pass_code` sets brush, stroke type, alpha, Draw values, LazyMouse (off unless the spec says `lazymouse=True`), perspective off, symmetry and the view for every pass anyway, and reports anything it could not find in `missing`. ## P2. Calibrate intensity and brush size (once per session) ```python # not yet run in ZBrush (live_s01_calibrate, --install writes scripts/sculpt_calibration.json) cal = zs.run_calibration(OUT + "/cal", brush="ClayBuildup", zs=(2, 4, 6, 10, 20), draw_size=24, install=True) print(cal["radius_per_draw_size"]) # 1.0: Draw Size is the radius; 0.5: the diameter m = zs.intensity_model("ClayBuildup") # c in dV = c * Z * R^2 * 2L z, passes = m.z_for_raise(0.01, m.radius(20, 250.0)) # Z for a 0.01-unit mean raise, R from Draw 20 ``` It builds a fresh DynaMesh sphere (128), frames it front at 0.6, strokes one row per Z, exports an OBJ after each stroke and measures the volume change and the footprint radius (`zb_audit.displaced`). Until it has run, `intensity_model()` returns the prior fitted on the proven v02 stroke (c = 0.00104 under the radius hypothesis). Also calibrate DamStandard (Zsub, volume must fall) and read the Move gain from live_s01. ## P3. Base (S0): three strokeless starts ```python # not yet run in ZBrush (live_s05_blockout, live_s02) # a) head sketch on a sphere (Henning): PolyMesh3D sphere, DynaMesh 128 = about 43k points zl.call("zb_ops", "new_sphere", 128) # b) ZSphere armature (Pablo): proportion and silhouette only, then a light Adaptive Skin neck, last = zs.chain((0, 0, 0), (0, 0.9, 0.1), 0.45, 0.35, 2) head, _ = zs.chain((0, 0.9, 0.1), (0, 1.7, 0.15), 0.55, 0.7, 2, parent=last, first_index=len(neck)) print(zl.run(zs.zsphere_code(neck + head), modules=("zb_ops",))) print(zl.run(zs.adaptive_skin_code(density=2, dynamesh_resolution=0), modules=("zb_ops",))) zl.call("zb_ops", "ensure_edit") zl.call("zb_ops", "dynamesh", 64) # c) primitives as SubTools placed by number (Pablo's brush-free blockout) zl.run(zs.append_primitive_code("Sphere3D", position=(0.0, -0.3, 0.9), size=0.5), modules=("zb_ops",)) ``` Proportions (head heights, feature positions) come from the domain skill (scenario-zbrush-character-creature, scenario-zbrush-stylized). The ZSphere tool's coordinate space, the `PopUp:<name>` items after Append and the Position slider units are [verify live_s05]. GATE: `zb_review.review(OUT + "/s0")` sheet shows the intended silhouette front and side; `zb_ops.stats()` points in `zs.STAGES["S0"]["points"]`. ## P4. One aimed clay pass: plan, look, apply, check ```python # not yet run in ZBrush (live_s02_aim_clay) sc = zs.Scene.capture(OUT + "/s1", view="front", margin=0.7) # OBJ, canvas PNG, transform forms = [zs.Form("brow", [(-0.6, 0.28, 0.76), (0.0, 0.34, 0.94), (0.6, 0.28, 0.76)], width=0.18), zs.Form.blob("cheekbone", (0.45, -0.05, 0.86), 0.15, axis=(0.8, -0.3, 0)), zs.Form.blob("socket", (0.3, 0.12, 0.92), 0.11, sign=-1)] # sign -1 = carve specs = zs.clay_pass(sc, forms, stage="S1", ref_width=2.0) # ref_width = head width, model units print(zs.plan_summary(specs)) zs.preview_plan(sc, specs, OUT + "/s1/plan.png") # LOOK at it before stroking reports = zs.run_plan(specs, ppu=sc.ppu) # stops at the first flagged pass for r in reports: print(r["label"], r["ok"], r["flags"]) ``` What it does: snaps each form to the surface (nearest vertex), projects it through the view (`zb_stroke.Camera`, vectorized), sizes the brush from the stage (S1 clay: 15 to 35 % of the head width) kept within 0.5 to 1.0 of the form width, hatches across the center line (T1), clips every stroke to the canvas model mask eroded by a quarter of the brush radius, splits build and carve into separate passes, and with S1's symmetry on plans the +X half only. `pass_code` restores the planned transform before stroking and records the volume after each stroke. Aim error budget: on the v03 fixture the front projection fits the canvas silhouette within about 1 px with pivot (0, 0, 0) and misses by about 4 px with the post-stroke bbox center, so the pivot rule is still open (live_04) [obj]; a few pixels are small against 20 to 100 px brushes. Flags to act on: strokes that changed nothing (off the model, LazyMouse radius, Edit mode), wrong volume sign, view drift during the pass (a stroke started off the model), pivot shift (re-capture the Scene), settings not found. Views other than front need the live_04 convention. ## P5. Move then DynaMesh (T4) ```python # not yet run in ZBrush (live_s03_move_crease, live_s01 for the Move gain) sc = zs.Scene.capture(OUT + "/s1m", view="front", margin=0.7) specs = zs.move_pass(sc, [((0.0, -0.6, 0.8), (0.0, -0.25, 0.0))], size_frac=0.35, symmetry=False) rep = zs.run_plan(specs, ppu=sc.ppu) cv0 = zb_audit.audit(sc.obj)["edge_len_cv"] obj = zl.call("zb_ops", "export_obj", OUT + "/s1m/after.obj", overwrite=True)["path"] if zs.remesh_due(zb_audit.audit(obj)["edge_len_cv"], cv0): r = zl.run(zs.remesh_code(128, keep="groups"), modules=("zb_ops",)) # P16 assert r["ok"], r ``` The Move brush moves in the screen plane, so `move_pass` refuses a displacement that is not mostly in the screen plane (choose a view that looks across it). Displacements longer than half the brush are split into chained drags [added]. One Move set per pass, then re-capture the Scene (the bbox and possibly the pivot move). Big moves only at the lowest level (`stage_gate(..., big_move=True, sdiv=...)` fails otherwise). ## P6. Creases and fill between (T5, T6) ```python # not yet run in ZBrush (live_s03_move_crease) sc = zs.Scene.capture(OUT + "/s4", view="front", margin=0.7) fold = zs.Form.crease("fold", [(0.1, 0.05, 0.99), (0.3, -0.15, 0.94), (0.45, -0.35, 0.82)], 0.05) specs = zs.crease_pass(sc, [fold], symmetry=False) # DamStandard, Dots, Focal -14, Zsub reports = zs.run_plan(specs, ppu=sc.ppu) # expect the volume to fall a = sc.form_to_canvas(zs.Form.crease("A", [(-0.35, -0.2, 0.9), (-0.05, -0.2, 0.97)], 0.03))["path"] b = sc.form_to_canvas(zs.Form.crease("B", [(-0.35, -0.35, 0.86), (-0.05, -0.35, 0.93)], 0.03))["path"] d = 0.12 * sc.ppu fill = zs.pass_spec("fill", "ClayBuildup", zs.fill_between(a, b, d), zs.draw_size_for(d), 2, focal=-56, symmetry=False, transform=sc.transform) zs.run_plan([fill], ppu=sc.ppu) ``` Depth variation without pen pressure: three overlapping sub-strokes (full length, 0.6, 0.35) with a lateral wobble up to 0.1 D [added]; live_s03 checks that the depth along the crease varies (cv above 0.1). If live_02 finds a pressure token, a pressure profile can replace the sub-strokes. ## P7. Intensity descent with zoom-in (T3) ```python # not yet run in ZBrush (live_s07_ladder) base = zs.Scene.capture(OUT + "/s2", view="front", margin=0.7) cheek = zs.Form.blob("cheek", (0.45, -0.05, 0.86), 0.15, axis=(0.8, -0.3, 0)) for it in zs.descent(0.24 * base.ppu, z_ladder=zs.DESCENT_Z): # Z 6, 3, 2, 1 sc = zs.Scene.capture(OUT + f"/s2_{it['iteration']}", view="front", ppu=base.ppu * it["zoom"], center=cheek.path[0]) specs = zs.clay_pass(sc, [cheek], stage="S2", preset="clay_refine", z=it["z"], size_frac=it["diam_px"] * it["zoom"] / sc.model_width_px()) for s in specs: s["alpha"] = it["alpha"] # Alpha Off for the last two zs.run_plan(specs, ppu=sc.ppu) sheet = zb_review.review(OUT + f"/r2_{it['iteration']}", views=("front", "threequarter")) # stop when the marks no longer fall (band_energy fine band, or the eye) or Z reached 1 ``` Each iteration offsets its stations by half a station so strokes land between the previous ones (Henning "fill in the gaps"); zoom and shrink numbers are [added]. Finish with one light smoothing only where needed (P9). ## P8. Checkpoint a pass and dial it afterwards ```python # not yet run in ZBrush (live_s04_checkpoints_masks) cp = zl.run(zs.checkpoint_code("morph"), modules=("zb_ops",)) # DynaMesh stages: StoreMT zs.run_plan(specs, ppu=sc.ppu) sheet = zb_review.review(OUT + "/check") # too strong? # dial BEFORE any re-DynaMesh, ZRemesher or Divide; the code refuses a lost target zl.run(zs.dial_code("morph", 40, expect_points=cp["stats"]["points"]), modules=("zb_ops",)) # subdivision stages (levels exist, create at the top level): a Layer instead zl.run(zs.checkpoint_code("layer"), modules=("zb_ops",)) zs.run_plan(specs, ppu=sc.ppu) zl.run(zs.dial_code("layer", 0.6), modules=("zb_ops",)) # layer intensity 0.6 of the pass ``` Drust: partial Morph values soften a pass, negative values exaggerate it (B_wKwXwjcZs 00:05:20); layer intensity 1 is as sculpted, above 1 exaggerates (Layers doc). This turns intensity into a deterministic after-the-fact dial, the agent's answer to missing pen pressure [added]. `Tool:Morph Target:Morph` and `Tool:Layers:Intensity` are installed ids [xml]; their semantics are [verify live_s04]. A Morph Target needs a static point count (Morph Targets doc; fundamentals digest procedure I step 4): a re-DynaMesh (`remesh_due` inside the same pass), ZRemesher or Divide between StoreMT and the dial destroys it, so dial first or keep the versioned ZTL as the checkpoint (scenario-zbrush-expert). `expect_points` makes the dial fail loudly instead of acting on a lost target (live_s08). ## P9. Deterministic substitutes for stroke work ```python # not yet run in ZBrush (live_s04_checkpoints_masks) # a) smoothing: Polish at a lower level, then back up (smoothing scale follows the level, docs) zl.run("p = zb_ops.resolve('sdiv'); zbc.set(p, 1); result = zbc.get(p)", modules=("zb_ops",)) print(zl.call("zb_ops", "polish", 10)) # volume before/after: drift gate zl.run("p = zb_ops.resolve('sdiv'); zbc.set(p, zbc.get_max(p)); result = zbc.get(p)", modules=("zb_ops",)) # b) local inflate through the footprint of a stroke (mask from a stroke) zs.run_plan([zs.pass_spec("footprint", "Standard", specs[0]["strokes"], 20, 5, symmetry=False, transform=sc.transform)]) zl.run(""" zbc.press(zb_ops.resolve(["Tool:Masking:Mask Changed Points"])) zb_ops.mask("invert") # the stroked area is now the unmasked one r = zb_ops.deform("Inflate", 10) zb_ops.mask("clear") result = r """, modules=("zb_ops",)) # c) symmetry repair when the wrong side is the reference (Pablo 9a9S1OcYHx4 00:09:35) zl.run(""" l = zb_ops.resolve("lsym", required=False) if l and zbc.get(l) >= 0.5: zbc.set(l, 0) # Local Symmetry must be off (gitoJ7B8FmY 00:10:22) zbc.press(zb_ops.resolve(["Tool:Deformation:Mirror"])) zbc.press(zb_ops.resolve("mirror_weld")) result = zb_ops.stats() """, modules=("zb_ops",)) ``` Also deterministic: polygroup masks (`zb_ops.polygroups("auto")`, Groups By Normals), visibility grow (`zb_ops.visibility("grow")`), whole-SubTool Inflate or Polish, Geometry position and size for parts. Polish and Smooth cost volume: gate the drift with `stats()` (the digest starts at 2 % [added]). `Mask Changed Points` and `Tool:Deformation:Mirror` are [verify live_s04]. ## P10. Raise resolution by the square law ```python # not yet run in ZBrush (live_s07_ladder) st = zl.call("zb_ops", "stats") res = zs.next_dynamesh_resolution(128, st["points"], 800e3) # toward S2 on a head print(res, zs.dynamesh_faces_estimate(res, st["area"], max(b - a for a, b in zip(st["bbox"][:3], st["bbox"][3:])))) print(zl.run(zs.remesh_code(res, keep="groups"), modules=("zb_ops",), timeout=600)) # P16 ``` Measure K on the agent's own mesh after a remesh (`dynamesh_k_from`) when estimates matter; one sample on this install gave K = 1.063 (sqrt(area / faces) = K x longest / resolution). ## P11. Sketch to levels (when S3 needs subdivision levels) ```python # not yet run in ZBrush (live_s07_ladder) zl.call("zb_ops", "save_ztl", OUT + "/head.ztl") # versioned, before anything destructive src = zl.call("zb_ops", "stats") zl.run(""" i0 = zbc.get_active_subtool_index() zbc.press(zb_ops.resolve("duplicate")) if zbc.get_active_subtool_index() == i0: zbc.select_subtool(i0 + 1) # copy expected right below the original [verify] result = zbc.get_active_subtool_index() """, modules=("zb_ops",)) zr = zl.call("zb_ops", "zremesher", 5, timeout=900) # never Divide the DynaMesh itself zl.call("zb_ops", "divide", zs.divides_needed(zr["faces_after"], src["points"]), timeout=900) assert zs.projection_ready(src["points"], zl.call("zb_ops", "stats")["points"]) pa = zl.call("zb_ops", "project_all", checkpoint=OUT + "/head.ztl", timeout=900) # toolkit, safe assert pa["gate"]["ok"], (pa["gate"], pa.get("repair")) # else follow pa["repair"] ``` When (Pablo rArw79xEpvE 00:05:50, 00:09:44): ZRemesher once the form is settled, DynaMesh while it is not; never Divide a DynaMesh (390k to 1.5M to 6M of triangles and star poles that smoothing cannot remove). A DynaMesh-only route (Henning's bust at 128, 376, 648, TpS0QdlfHWU frames 00:14:16 to 00:21:27) is fine for a concept still with no later big changes: say which route and why. Safe reprojection is the toolkit's job: `zb_ops.project_all` (scenario-zbrush-expert) refuses to run without `checkpoint` (a versioned ZTL first: Project All can crash ZBrush and Python has no undo), stores a Morph Target and a New Layer at the top level (FlippedNormals Zp07GW3rND0 00:02:13, 00:02:47), sets Dist, takes `pa_blur` (lower it for a clean source), returns to `level` for a staged projection, and gates the result (points, spikes outside the visible bbox, volume); `zb_ops.morph_repair(points)` paints a busted area back with the Morph brush instead of smoothing (00:04:27, 00:06:36). This skill only adds the density rule before it (`projection_ready`: target points at least the source's, Pablo VRisbJQAaZw 00:17:24); do not repeat the toolkit's checkpoints here. Only source and target visible (`exclusive=True` refuses more). After projecting, work by level (P15). Guides, target counts for animation, danger-zone repair (eyes, mouth, armpits, fingers, crotch; eye-interior polygroup grown past the lid, Zp07GW3rND0 00:05:31, 00:07:10) and UVs belong to scenario-zbrush-retopology-export. ## P12. Stage review and gate ```python # not yet run in ZBrush (live_s02, live_s07) views = ("front", "right", "threequarter", "top") rev = zb_review.review(OUT + "/r_s1", views=views) # open rev["sheet"] and judge it cmp = zs.compare_reviews(OUT + "/r_s0", OUT + "/r_s1") # IoU, shape IoU, asymmetry, bands gate = zs.stage_gate("S1", kind="head", stats=zl.call("zb_ops", "stats"), reports=reports, review_cmp=cmp) print(gate) # then critique.md for the visual part zl.call("zb_ops", "save_ztl", OUT + "/head.ztl") ``` Keep the view scale fixed between stages when comparing raw IoU; `shape_iou` compares silhouettes normalized to their bounding boxes and is the stable-design metric. ## P13. Experimental: creases from model-space curves ```python # not yet run in ZBrush (live_s06_curves): does a curve brush sculpt along an SDK curve? path = [(-0.5, 0.0, 0.87), (0.0, 0.1, 0.99), (0.5, 0.0, 0.87)] zl.run(f""" zb_ops.select_brush("CurveStandard") zbc.new_curves() c = zbc.add_new_curve() for p in {path!r}: zbc.add_curve_point(c, *p) # tool coordinates, not canvas pixels (SDK example) result = zbc.curves_to_ui() """, modules=("zb_ops",)) ``` If it works, creases need no camera mapping; live_s06 also tries a click on the projected curve to apply the brush. ## P14. Handoff package ```python # not yet run in ZBrush import json pkg = {"ztl": zl.call("zb_ops", "save_ztl", OUT + "/head.ztl")["path"], "stats": zl.call("zb_ops", "stats"), "sheet": rev["sheet"], "stage": "S3", "forms": [f.to_dict() for f in forms], "calibration": zs.current_calibration(), "gate": gate} json.dump(pkg, open(OUT + "/handoff.json", "w"), indent=1, default=repr) ``` The receiving skill gets the file, the numbers, the last review sheet and the forms (model-space center lines) so it can aim at the same anatomy. ## P15. Level discipline on a subdivision stack ```python # not yet run in ZBrush (live_s08_remesh_levels_eyes) st = zl.call("zb_ops", "stats") zl.run(zs.set_level_code(zs.level_for("big", st["sdiv_max"])), modules=("zb_ops",)) # SDiv 1 specs = zs.move_pass(zs.Scene.capture(OUT + "/big", view="front"), moves, symmetry=False) zs.run_plan(specs) lv = zs.level_for("secondary", st["sdiv_max"]) # 2 or 3 for level in range(lv, int(st["sdiv_max"]) + 1): # step up zl.run(zs.set_level_code(level), modules=("zb_ops",)) sc = zs.Scene.capture(OUT + f"/sec_{level}", view="front", margin=0.7) zs.run_plan(zs.clay_pass(sc, forms, stage="S3", ref_width=2.0), ppu=sc.ppu) gate = zs.stage_gate("S3", stats=zl.call("zb_ops", "stats"), big_move=True, sdiv=1) ``` Pablo: large proportion changes at the top level look "very wonky" and damage the blockout; at SDiv 1 the same push is easy and the top follows cleanly (rArw79xEpvE 00:12:28 to 00:13:05). Detailing only at the top shows where strokes start and stop: block secondary forms at SDiv 2 or 3, then step up, refining at each level (00:13:42 to 00:15:28); "do not stay at the highest level" (tbQqC6tyDBQ 00:12:26). Smoothing acts at the scale of the current level (subdivision doc), so smooth big forms low. New layers and StoreMT before projection go at the top level (docs). Re-capture the Scene after every level change: the mesh the plan snaps to changes. ## P16. Safe re-DynaMesh (mask, Draw mode, polypaint or polygroups) ```python # not yet run in ZBrush (live_s08_remesh_levels_eyes) r = zl.run(zs.remesh_code(256, keep="groups", groups=True), modules=("zb_ops",), timeout=600) assert r["ok"], r # faces changed and DynaMesh is on; r["steps"] says what was fixed print(r["missing"]) # Draw mode or Colorize not found: look at the canvas before going on ``` What it guards (DynaMesh doc, Manual Update and PolyGroups; Pablo FrqUnna1jns 00:07:28, 00:16:16; Pavlovich 8kWFv1cZlCE 00:20:06): - the update needs Edit plus Draw mode (`Transform:Draw Pointer` [xml]), not Move, Scale or Rotate; - with any mask, the gesture only clears the mask: the mask is cleared first; - with polypaint on, DynaMesh keeps the polypaint INSTEAD of the polygroups. `keep="groups"` turns `Tool:Polypaint:Colorize` [xml] off; `keep="polypaint"` turns it on (a color pass that must survive); you cannot keep both. If both matter, store the groups as color first (`Tool:Polypaint:Polypaint From Polygroups` [xml]) and rebuild them after with `Tool:Polygroups:From Polypaint` [xml] [added route, verify]; - `groups=True` keeps separate polygroups as separate shells (Pablo's teeth); with Groups off intersecting parts fuse; - the toggle: `zb_ops.dynamesh` switches the lit button off and on [verify live_06]; if DynaMesh is left off, the code presses once more and re-reads the faces. That Colorize is the same switch as the SubTool list's brush icon is [verify live_s08]. ## P17. Eyes: spheres first, lids wrap them, a perspective look ```python # not yet run in ZBrush (live_s08_remesh_levels_eyes) for side in (+1, -1): # one SubTool per eye zl.run(zs.append_primitive_code("Sphere3D", position=(side * 0.33, 0.12, 0.78), size=0.2), modules=("zb_ops",)) zl.call("zb_ops", "deform", "Rotate", zs.eye_rotation(side), 2) # Y axis; sign [verify] zl.run(f"zbc.select_subtool({HEAD}); result = zb_ops.stats()", modules=("zb_ops",)) # head index from the role map lids = [zs.Form.crease("upper lid", upper_lid_path, 0.03), zs.Form.crease("lower lid", lower_lid_path, 0.03)] zs.run_plan(zs.crease_pass(sc, lids, symmetry=True), ppu=sc.ppu) # cut the lids in zs.run_plan(zs.move_pass(sc, lid_out_moves, preset="move_local"), ppu=sc.ppu) # then out onto the ball shot = zl.run(zs.persp_snapshot_code(OUT + "/eyes_persp.png", "threequarter"), modules=("zb_ops", "zb_stroke")) ``` `HEAD`, `sc`, the lid paths and `lid_out_moves` are the caller's (lid center lines and outward displacements in model space, planned on the eyeball like any other form). Henning moves the eyes in with perspective on, forces the upper and lower lids in with Dam_Standard, then moves them out so they conform to an eyeball (TpS0QdlfHWU 00:17:28 to 00:18:33). Costa: eyes placed straight ahead look cross-eyed; rotate each 3 to 7 degrees outward, about 5 (j5XLtLMN0P8 00:29:10 to 00:30:16). Positions and sizes come from the domain skill (scenario-zbrush-character-creature for realistic heads, scenario-zbrush-stylized for the canthus rule and big stylized eyes). Aimed strokes stay orthographic (the plans assume it); `persp_snapshot_code` is for looking only, and it restores the perspective switch and the view. The Deformation Rotate axis bit and sign are [verify live_s08]; check the result on the top render (the pupils diverge slightly). -
sources.md 10.5 KB
# Sources of scenario-zbrush-sculpting Each line: source, who, credential, where, what it is best for, best timestamps. Per-source notes live in `notes/sculpting/` and `notes/fundamentals/`; the digests are `notes/sculpting/_digest_sculpting_tools.md`, `notes/sculpting/_digest_sculpting_workflow_visual.md` and `notes/fundamentals/_digest_fundamentals_logic_files.md`. Version facts: `sources/zbrush-version-deltas.md`. ## Revisions - 2026-09-24 v1: first build (kept in `skills/_versions/zbrush-sculpting v1 2026-09-24/`). - 2026-09-24 v2, round 1 refactor from the blind grades Z1 (missing from both, risks) and Z6 (missing from both, item 1): subdivision discipline for the final stage (when to leave DynaMesh, never Divide a DynaMesh, SDiv 1 for big moves, 2 or 3 for secondary, never all at the top: `level_for`, `set_level_code`, P15); safe reprojection referenced to the toolkit's `zb_ops.project_all(checkpoint=...)` (StoreMT, New Layer, gate, `morph_repair`) instead of this skill's own checkpoints (P11, live_s07 updated); checked re-DynaMesh with mask cleared, Draw mode and polypaint off when polygroups must survive (`remesh_code`, P16); Morph Target lifetime guard (`dial_code(expect_points=)`); LazyMouse off by default for scripted strokes; perspective path fixed to `Draw:Perspective` (`PERSP_PATHS`) and a perspective look render (`persp_snapshot_code`); eyes as spheres wrapped by lids and turned outward (P17); Standard for edges and ClayBuildup erasing detail; symmetry per SubTool; MergeDown vs MergeVisible; installed item ids from commands.xml in P1 and gui-paths. Tests: 45 offline (was 38), live_s08 added (not yet run in ZBrush). ## Evidence produced by this project (highest weight: it ran on this install or on its files) - Installed command list `/Applications/Maxon ZBrush 2026/ZData/ZLang/zcommands/commands.xml` (read 2026-09-24): ids such as `Draw:Perspective`, `Transform: Pf`, `Transform:Draw Pointer`, `Tool:Polypaint:Colorize`, `Tool:Morph Target:Morph`, `Tool:Layers:Intensity`, `Tool:SubTool:MergeDown`, `Stroke:LazyMouse`, `Stroke:Stroke Jitter`, `Stroke:SculptrisPro`. It lists no brushes or materials and no `Transform:Persp`. Best for: which item ids exist ([xml] in the references); exists() through the bridge stays the proof. - scenario-zbrush-expert bridge tests v01 to v03 (`tests/code/zbrush-expert/v0N_*`, 2026-09-24, ZBrush 2026.2.1 build 53205). Best for: synthesized V02 strokes sculpt (v02: 11 points, volume 4.189 to 4.193), Draw Size 64 and Z Intensity 20 defaults, the item paths that exist. - `tests/code/zbrush-expert/fixtures/v03_sphere.obj` and `v03_canvas.png`, measured offline for this skill: DynaMesh 128 on the default sphere = 44,020 faces, 43,480 points, area 12.63, K = 1.063; the front-view projection at 207 px per unit with pivot (0, 0, 0) matches the canvas silhouette at IoU 0.98 (x 352 to 767), while the post-stroke bbox center as pivot misses by about 4 px vertically [pivot question: live_04]. Best for: DynaMesh estimates, the Scene fixture of `test_zb_sculpt.py`. - `tests/code/zbrush-sculpting/` offline tests (45, passed 2026-09-24 after the round 1 refactor; 38 in v1) and `dryrun_live.py` (live_s01 to live_s08 executed against a fake ZBrush without Python errors). Not ZBrush evidence. ## Experts - Henning Sanden (ex-MPC modeling and texturing) and Morten Jaeger (ex-Framestore modeler), FlippedNormals founders: - Learn to Sculpt Like a Pro in ZBrush (2018), https://www.youtube.com/watch?v=0PaYUUvgwYM. Best for: pressure at 30 % [00:04:58], across the form [00:02:13 to 00:03:19], refinement instead of smoothing [00:08:51, 00:09:57], polycount ladder on screen (frames 00:00:45, 00:07:30, 00:18:49), Dam_Standard at 15 with varied pressure [00:18:42, 00:20:55], contrast stage [00:23:39], dominance [00:25:49], Standard for specificity [00:27:26], ClayBuildup no alpha finish [00:31:28]. - The Only 6 Brushes You Ever Need in ZBrush (2020), https://www.youtube.com/watch?v=TpS0QdlfHWU. Best for: the six brushes with settings (frames 00:02:15 to 00:15:45), eyes with perspective on and lids wrapping the eyeball [00:17:28 to 00:18:33], Move then DynaMesh [00:02:34], ClayBuildup erases what is under it [00:07:44], Smooth Stronger at 30 [00:14:53], TrimDynamic as the Shift brush [00:16:24], DynaMesh ladder 128/376/648 (frames 00:14:16, 00:18:45, 00:21:27), intensity descent 23, 13, 3, 1 [00:21:53 to 00:22:58]. - Top Tips for Sculpting in ZBrush (2020), https://www.youtube.com/watch?v=G2o6fdoACIQ. Best for: seven stages and never skipping a frequency [00:00:19], all elements early [00:02:50], mid frequency as the backbone [00:05:46], pores two levels early [00:08:17], across vs along [00:10:08], silhouette stability [00:14:09 to 00:17:27]. - Reprojecting Details in ZBrush (2018), https://www.youtube.com/watch?v=Zp07GW3rND0. Best for: about 3M before Project All [00:01:02], Store MT and New Layer at the top level [00:02:13], Morph brush repair [00:04:27], danger zones [00:07:10]. - Pablo Munoz Gomez, 3D concept artist and educator, ZBrushGuides founder, Maxon ZBrushLIVE host: - Understanding ZBrush's Logic, Parts 1 to 8 (2025): Part 1 canvas and Edit mode https://www.youtube.com/watch?v=NckHjyO6GNk [00:12:20]; Part 2 primitives, Gizmo deformers, SubTools, symmetry per SubTool https://www.youtube.com/watch?v=RABWfzWNw0c [00:09:18, 00:21:39, 00:27:02]; Part 3 parts in their own tools, Local Symmetry trap, ZSpheres and Adaptive Skin at Resolution 0 https://www.youtube.com/watch?v=gitoJ7B8FmY [00:05:26, 00:10:22, 00:17:35]; Part 4 Move vs Move Topological https://www.youtube.com/watch?v=mVp_jNfEI3I [00:10:21]; Part 5 DynaMesh resolution relative to size, Groups, merge is not DynaMesh https://www.youtube.com/watch?v=FrqUnna1jns [00:13:40, 00:16:16, 00:23:25]; Part 6 six core brushes, lumpiness, Mirror then Mirror And Weld https://www.youtube.com/watch?v=9a9S1OcYHx4 [00:02:34, 00:09:35, 00:10:11]; Part 7 ZRemesher targets, ZRemesher once the form is settled, never Divide a DynaMesh, level discipline https://www.youtube.com/watch?v=rArw79xEpvE [00:03:16, 00:05:50, 00:09:44, 00:12:28, 00:14:16]; Part 8 Dynamic Subdiv, Sculptris Pro, projection density rule, polypaint on a flat material at the top level https://www.youtube.com/watch?v=VRisbJQAaZw [00:10:41, 00:13:49, 00:17:24, 00:23:40, 00:24:54]. Part 2 also: MergeDown confirmation [00:24:54]. - Dos and Don'ts when Sculpting in ZBrush (2025, ZBrush 2025.3), https://www.youtube.com/watch?v=tbQqC6tyDBQ. Best for: ZSpheres for proportion only [00:01:13], light skin (frame 00:01:58), big Move plus Smooth [00:02:29], primitives as SubTools [00:03:51], low DynaMesh early [00:04:38], two-direction clay building [00:05:22 to 00:06:32], sharp masks for Move [00:09:09], details do not fix a weak sculpt [00:12:50 to 00:14:05], feature details first [00:15:19]. - Michael Pavlovich, Director of Character, Weapon and Vehicle Art at Certain Affinity, Maxon ZBrushLIVE host, Intro to ZBrush (2021): - 015 Brush Strokes and Lazy Mouse https://www.youtube.com/watch?v=AdkZe1yKFTU. Best for: LazyMouse radius 1 swallows taps [00:03:31], radius 27 for designed lines [00:05:05], global Jitter and Imperfection [00:09:52], ClayBuildup stepping fixes [00:10:33 to 00:11:06], Focal Shift -100 stamps [00:08:17]. - 017 Materials https://www.youtube.com/watch?v=aR02CwyPTUw. Best for: MatCap Gray, Red Wax, Chrome, moving light [00:01:26 to 00:04:39]. - 019 Masking https://www.youtube.com/watch?v=8kWFv1cZlCE. Best for: mask plus Deformation Inflate [00:17:10], blur for falloff [00:17:44], depth mask [00:14:48], mask instead of polypaint [00:20:38]. - 021 Polygroups https://www.youtube.com/watch?v=5JM1fb7wnTM [00:02:30]. 029 Transpose https://www.youtube.com/watch?v=9qLbeAapUH0 [00:14:08, 00:16:14]. 026 Splitting https://www.youtube.com/watch?v=TjGlVX4w1nM. 030 SubTool options https://www.youtube.com/watch?v=bBHqeaKNOzg. 005 ZTool and QuickSave https://www.youtube.com/watch?v=rEIoFEEgS80. 010 Hotkeys https://www.youtube.com/watch?v=1sYMoWz_Mk4 [00:02:50 block-in rhythm]. - Kris Costa, former lead creature modeler at ILM, portrait instructor, Realism Idealized (ZBrush Summit 2021), https://www.youtube.com/watch?v=j5XLtLMN0P8. Best for: hierarchy by viewing distance [00:08:12], ClayBuildup respects form boundaries [00:12:58], thin layers at 2 to 3 [00:16:40], renders under several lights [00:19:51], eyes rotated 3 to 7 degrees outward [00:29:10], asymmetry for likeness [00:35:03]. - Joseph Drust, Pixologic, #AskZBrush Morph Target and Morph Brush (2015), https://www.youtube.com/watch?v=B_wKwXwjcZs. Best for: Morph slider softening and negative exaggeration [00:00:59, 00:05:20]. - Outpost VFX 3D team, 17 Pro Tips and Tricks for ZBrush (2022), https://outpost-vfx.com/en/news/17-pro-tips-and-tricks-for-zbrush. Best for: layers for alpha tests (tip 5), Morph Target volume recovery (tip 7), subdivision levels to test detail on a DynaMesh (tip 9). ## Maxon documentation and SDK (2026) - Brushes, strokes, alphas, auto masking, Lazy Mouse, Smooth, Layer brush (help.maxon.net, `sources/docs/fundamentals-sculpting__brushes-strokes-alphas.md`). Best for: Smooth only via Shift, Draw Size Dynamic mode, Layer constant depth, stroke types. - DynaMesh, Sculptris Pro, subdivision levels, Geometry palette (`sources/docs/fundamentals-sculpting__dynamesh-sculptris-subdivision.md`). Best for: 1024 and 2048 cube limits, remesh conditions, Sculptris Pro restrictions, Divide needs no mask or hidden parts. - Gizmo 3D, Transpose, deformers, Tool > Deformation (`sources/docs/fundamentals-sculpting__gizmo-transpose-deformers.md`). Best for: soft Gizmo unless Focal Shift -100, global axes of the Deformation sliders, deformers need no levels. - Masking and polygroups (`sources/docs/fundamentals-sculpting__masking-polygroups.md`). Best for: masks modify all sculpting and Deformation actions, Mask Changed Points, Merge Stray Groups, QuickPolygroup. - ZSpheres, layers, morph targets, SubTool palette (`sources/docs/fundamentals-sculpting__zspheres-layers-morph-extract-subtool.md`). Best for: Adaptive Skin, one Morph Target per SubTool, layer intensity, Groups Split keeps levels at the top level. - Python SDK 2026.1 stubs and examples (`/Applications/Maxon ZBrush 2026/Documentation/python-api/`): `canvas_stroke` transforms from the stroke start in canvas space, `add_zsphere` only inside `edit_zsphere`, curves in tool coordinates (`zbrush.commands.add_curve_point.0.py`), `set_transform` example (0, 90, 90) = top view. ## Sister skills used for shape - scenario-blender-sculpting (Blender Expert Skills project): the stance-and-gates layout and the "render, name one weakest link, fix, re-render" loop.
-
-
scripts
-
zb_sculpt.py 96 KB
""" zb_sculpt: turn the experts' stroke work into agent actions in ZBrush 2026 (agent side). Runs on the agent side only (system python3 with numpy and PIL). It never runs inside ZBrush: it plans strokes from model-space forms, aims them through the view transform, and sends short code to ZBrush through zb_launch, where the scenario-zbrush-expert toolkit (zb_ops, zb_stroke) does the work. Import it next to the lead toolkit: import sys sys.path[:0] = ["<skills/scenario-zbrush-expert/scripts>", "<skills/scenario-zbrush-sculpting/scripts>"] import zb_sculpt as zs sc = zs.Scene.capture("/abs/out/s1", view="front") # OBJ + transform + canvas mask brow = zs.Form("brow", [(-0.45, 0.35, 0.8), (0.45, 0.35, 0.8)], width=0.18) for spec in zs.clay_pass(sc, [brow], stage="S1"): # ClayBuildup across the brow print(zs.pass_report(zs.apply_pass(spec), expect_sign=+1)) What it holds: * STAGES and BRUSHES: the stage ladder and brush settings the experts show, with sources. * Resolution helpers: DynaMesh square law, Divide x4, projection density rule. * Scene: one view of the active SubTool (transform, pivot, vertices, normals, canvas mask) and the projection of model points to canvas pixels (vectorised zb_stroke.Camera). * Stroke-set generators in canvas pixels: hatch_across (T1), two_direction (T2), descent (T3), move_drags (T4), crease (T5), fill_between (T6). * IntensityModel: per-stroke volume calibration for constant-pressure scripted strokes. * Pass specs and execution: pass_spec, pass_code, apply_pass, pass_report. * Review metrics on canvas PNGs: silhouette masks, IoU, shape IoU, mirror asymmetry, band energies; stage_gate. * Code builders for ZSphere armatures, Adaptive Skin, appended primitives, checkpoints (the Morph dial refuses a lost target), a checked re-DynaMesh (remesh_code), subdivision level discipline (level_for, set_level_code) and a perspective look render (persp_snapshot_code); eye_rotation for Costa's outward eye angle. * PERSP_PATHS: perspective lives at Draw:Perspective in 2026.2.1 [xml]. Evidence tags as in scenario-zbrush-expert: [v01]..[v03] ran through the bridge on 2026-09-24, [obj] measured offline on tests/code/zbrush-expert/fixtures/v03_sphere.obj, [doc] Maxon docs or SDK examples, [macro] a shipped 2026 macro path, [verify] not confirmed (the live test that settles it is named), [added] this module's own default. Expert numbers carry name + timestamp. NOT YET RUN IN ZBRUSH: every function that talks to ZBrush (Scene.capture, apply_pass, run_calibration, the code builders) is covered by tests/code/zbrush-sculpting/live_s0*.py. The pure functions are covered offline by tests/code/zbrush-sculpting/test_zb_sculpt.py. """ __version__ = "0.1" # ZBrush Expert Skills v0.1 (2026-09-24) import glob import json import math import os import random import sys HERE = os.path.dirname(os.path.abspath(__file__)) LEAD_SCRIPTS = os.path.abspath(os.path.join(HERE, "..", "..", "scenario-zbrush-expert", "scripts")) if os.path.isdir(LEAD_SCRIPTS) and LEAD_SCRIPTS not in sys.path: sys.path.insert(0, LEAD_SCRIPTS) import zb_stroke # noqa: E402 (lead toolkit: stroke strings, Camera, views) try: import numpy as np except ImportError: # the image and mesh helpers need numpy; the planners do not np = None CALIBRATION_FILE = os.path.join(HERE, "sculpt_calibration.json") DEFAULT_PORT = 7788 # -------------------------------------------------------------------------------------------- # Expert tables # -------------------------------------------------------------------------------------------- # Henning Sanden blocks with ClayBuildup at the default Z 20 and "maybe 30% of the actual # pressure" (0PaYUUvgwYM 00:04:58). A synthesized stroke has no pen pressure (zb_stroke: # PRESSURE_TOKEN unknown [verify live_02]), so the constant-pressure equivalent of an expert # value is z * 0.3 when pressure scales intensity linearly [inference, sculpting digest 5.1]. PRESSURE_EQUIV = 0.30 def constant_pressure_z(z_hand, pressure=PRESSURE_EQUIV, z_min=1, z_max=100): """Z Intensity for a scripted stroke that imitates an expert's Z at light pen pressure.""" return int(max(z_min, min(z_max, round(z_hand * pressure)))) # Brush settings read from the experts' screens. z_hand is the Z Intensity on screen, used with # pen pressure. z is the start value for a scripted (constant-pressure) stroke: the pressure # equivalent for the clay brushes, the displayed value for the others until calibrated # (no expert gives their pressure). size = brush diameter as a fraction of the head (model) # width in the current view, measured on the video frames (approximate). BRUSHES = { "clay_block": dict(brush="ClayBuildup", stroke_type="FreeHand", alpha="Alpha 06", focal=-56, z_hand=20, z=constant_pressure_z(20), size=(0.15, 0.35), direction="across", src="Henning, 0PaYUUvgwYM frame 00:00:45, 00:04:58; TpS0QdlfHWU 00:07:20"), "clay_refine": dict(brush="ClayBuildup", stroke_type="FreeHand", alpha="Alpha 06", focal=-56, z_hand=6, z=constant_pressure_z(6), size=(0.03, 0.08), direction="across", src="Henning Z 5 'or even 2', 0PaYUUvgwYM 00:08:51, frames 00:09:45 to " "00:15:38; z 2 = digest T3 derivation"), "clay_finish": dict(brush="ClayBuildup", stroke_type="FreeHand", alpha="Alpha Off", focal=-56, z_hand=7, z=constant_pressure_z(7), size=(0.03, 0.04), direction="across", src="Morten, 0PaYUUvgwYM 00:31:28, frames 00:31:35 (Z 7), 00:33:05 (Z 3)"), "clay_secondary": dict(brush="ClayBuildup", stroke_type="FreeHand", alpha="Alpha 06", focal=-56, z_hand=23, z=constant_pressure_z(23), size=(0.10, 0.15), direction="across", src="Henning, TpS0QdlfHWU frames 00:19:39, 00:20:51; 0PaYUUvgwYM " "frame 00:18:04"), "move": dict(brush="Move", stroke_type="Dots", alpha="Alpha Off", focal=0, z_hand=51, z=51, size=(0.6, 0.9), direction="drag", src="Henning, TpS0QdlfHWU frames 00:02:15, 00:17:15 (design moves)"), "move_local": dict(brush="Move", stroke_type="Dots", alpha="Alpha Off", focal=0, z_hand=51, z=51, size=(0.1, 0.15), direction="drag", src="Henning, TpS0QdlfHWU frame 00:18:00 (eye shaping, about 0.12 of the face)"), "dam": dict(brush="DamStandard", stroke_type="Dots", alpha=None, focal=-14, z_hand=15, z=15, zsub=True, size=(0.03, 0.07), direction="along", src="Henning 'ten points below where it starts', 0PaYUUvgwYM 00:18:42, frame " "00:19:34; Z 33 in TpS0QdlfHWU frame 00:05:54 for big cuts"), "standard": dict(brush="Standard", stroke_type="Dots", alpha="Alpha Off", focal=0, z_hand=25, z=25, size=(0.01, 0.02), direction="along", src="Henning, 0PaYUUvgwYM frames 00:27:33 to 00:30:32 (Z 25, 21, 11)"), "trim": dict(brush="TrimDynamic", stroke_type="FreeHand", alpha="Alpha Off", focal=-56, z_hand=43, z=30, zsub=True, sign_mode="fixed", size=(0.08, 0.20), direction="across", src="Henning, TpS0QdlfHWU frame 00:15:45 (Z 43, 'lower the intensity' " "00:15:51); z 30 [added]; size about a form's width, TpS0QdlfHWU frames"), } # The stage ladder (sculpting digests, section 1 of _digest_sculpting_workflow_visual.md). # points: (low, high) point counts at the stage, per kind of subject. A head is a bust sketched # from a sphere (Henning 2018 and 2020); a creature is Pablo's ZSphere-based full creature. STAGES = { "S0": dict(name="base", goal="proportions and silhouette", points={"head": (40e3, 100e3), "creature": (5e3, 7e3)}, how={"head": "Sphere3D, Make PolyMesh3D, DynaMesh 128 (43,401 points, TpS0QdlfHWU " "frame 00:09:40; 43,480 on this install [obj])", "creature": "ZSpheres, Adaptive Skin Density 2, DynaMesh Resolution 0 (5,994 " "points, tbQqC6tyDBQ frame 00:01:58)"}, brushes=(), symmetry=True, sdiv="none", gate="limb lengths and silhouette read (Flat Color, front and side)"), "S1": dict(name="primary blockout", goal="big forms and every landmark present", points={"head": (40e3, 100e3), "creature": (28e3, 43e3)}, how={"head": "Move then DynaMesh loop (TpS0QdlfHWU 00:02:34); ClayBuildup across; " "blocking end 96,222 points (TpS0QdlfHWU frame 00:14:16)", "creature": "big Move plus Smooth (tbQqC6tyDBQ 00:02:29); primitives as " "SubTools; DynaMesh 64 to 128 per piece (frames 00:05:55 to 00:10:25)"}, brushes=("move", "clay_block", "trim"), symmetry=True, sdiv="lowest", gate="silhouette and proportions right, every element present (G2o6fdoACIQ 00:02:50)"), "S2": dict(name="primary refinement", goal="clean shapes, no junk bumps", points={"head": (400e3, 830e3), "creature": (300e3, 400e3)}, how={"head": "DynaMesh 376 = 825,837 points (TpS0QdlfHWU frame 00:18:45) or Divide " "to 399,028 (0PaYUUvgwYM frame 00:07:30)", "creature": "merge pieces, DynaMesh 256 = 366,728 points (tbQqC6tyDBQ frame " "00:11:55)"}, brushes=("clay_refine", "trim", "move_local"), symmetry=True, sdiv="lowest", gate="no ambiguous bumps, stroke marks falling (TpS0QdlfHWU 00:15:51)"), "S3": dict(name="secondary (mid frequency)", goal="shapes within shapes, broken symmetry", points={"head": (400e3, 2.5e6), "creature": (3e6, 4e6)}, how={"head": "Divide to about 400k (0PaYUUvgwYM) or DynaMesh 648 = 2.457M " "(TpS0QdlfHWU frame 00:21:27)", "creature": "ZRemesher, Divide to SDiv 4 or 5 (3.819M on the body, tbQqC6tyDBQ " "frame 00:12:38), project the sketch back"}, brushes=("clay_secondary", "clay_refine"), symmetry=False, sdiv="2 to 3", gate="reads strongly at thumbnail size without detail (tbQqC6tyDBQ 00:14:05)"), "S4": dict(name="contrast", goal="sharp where reality is sharp, soft next to it", points={"head": (1.5e6, 1.7e6), "creature": (3e6, 4e6)}, how={"head": "Divide to 1.596M (0PaYUUvgwYM frame 00:18:49)", "creature": "stay at SDiv 5 (tbQqC6tyDBQ)"}, brushes=("dam", "clay_refine"), symmetry=False, sdiv="top", gate="creases sharp and varied in weight (0PaYUUvgwYM 00:20:55)"), "S5": dict(name="tertiary", goal="specificity; hand off skin detail", points={"head": (1.5e6, 11e6), "creature": (3e6, 11e6)}, how={"head": "top level; film troll at SDiv 7, 10.9M points (TpS0QdlfHWU frame 00:00:45)", "creature": "top level"}, brushes=("standard",), symmetry=False, sdiv="top", gate="details sit on mid forms; silhouette unchanged (tbQqC6tyDBQ 00:13:27)"), "S6": dict(name="integration and finish", goal="no brush strokes on skin, clear focus areas", points={"head": (1.5e6, 11e6), "creature": (3e6, 11e6)}, how={"head": "ClayBuildup Alpha Off at low Z, one small light smooth (TpS0QdlfHWU " "00:22:58)", "creature": "same"}, brushes=("clay_finish",), symmetry=False, sdiv="top", gate="skin reads as skin, not clay (TpS0QdlfHWU 00:21:53)"), } # Constant-pressure refinement ladder (digest T3, derived from Henning's 23, 13, 3, 1 and 6, 2 # at pen pressure, TpS0QdlfHWU frames 00:21:27 to 00:24:09; 0PaYUUvgwYM 00:08:51). DESCENT_Z = (6, 3, 2, 1) def brush_preset(key, **override): """Copy of a BRUSHES entry with overrides (the source string is kept).""" if key not in BRUSHES: raise KeyError(f"unknown preset {key!r}; one of {sorted(BRUSHES)}") d = dict(BRUSHES[key]) d.update(override) return d # -------------------------------------------------------------------------------------------- # Resolution and budgets # -------------------------------------------------------------------------------------------- # DynaMesh face size. On this install a radius-1 sphere at Resolution 128 gave 44,020 faces for # an area of 12.63 and a longest side of 2.04 [obj], i.e. sqrt(area / faces) = K * longest / res # with K = 1.063. One sample: treat as a first guess and re-measure on the agent's own mesh # (dynamesh_k_from). Henning's sphere gave 43,401 points at 128 (TpS0QdlfHWU frame 00:09:40), # which matches the 43,480 points measured here. DYNAMESH_K = 1.063 def next_dynamesh_resolution(res_now, points_now, points_target): """Points scale with Resolution squared (Henning's bust: 128, 376, 648 gave 96,222, 825,837, 2.457M; TpS0QdlfHWU frames 00:14:16, 00:18:45, 00:21:27), so res_next = res_now * sqrt(target / now) [derived in the sculpting digest].""" if points_now <= 0 or points_target <= 0: raise ValueError("point counts must be positive") return int(round(res_now * math.sqrt(points_target / points_now))) def dynamesh_faces_estimate(resolution, area, longest, k=DYNAMESH_K): """Faces a DynaMesh at `resolution` should produce on a surface of `area` whose bounding box longest side is `longest` (same units, from zb_ops.stats or zb_audit).""" edge = k * longest / float(resolution) return int(round(area / (edge * edge))) def dynamesh_resolution_for(faces_target, area, longest, k=DYNAMESH_K): """Inverse of dynamesh_faces_estimate: the Resolution that should give `faces_target`.""" return int(round(k * longest * math.sqrt(faces_target / float(area)))) def dynamesh_k_from(resolution, area, longest, faces): """Measure K on the agent's own mesh right after a DynaMesh (for the next estimate).""" return math.sqrt(area / float(faces)) * resolution / float(longest) def divides_needed(points_now, points_target): """Divide multiplies points by about 4 (98,787 to 399,028 to 1.596M, 0PaYUUvgwYM frames; Pablo 390k to 1.5M to 6M, rArw79xEpvE 00:10:18). Smallest n reaching the target.""" n = 0 p = float(points_now) while p * 1.02 < points_target: # "about 4": 4.04 on Henning's head, 3.85 on Pablo's body p *= 4 n += 1 if n > 12: raise ValueError("more than 12 divides: check the target") return n def projection_ready(source_points, target_points): """Pablo: the target's top level needs at least the sketch's point count ('more or slightly more', 745,000 against about 400,000; VRisbJQAaZw 00:17:24).""" return target_points >= source_points def remesh_due(edge_cv_now, edge_cv_after_remesh, factor=1.5): """Re-DynaMesh when polygons stretch (DynaMesh doc, Methods). Measurable proxy [added]: the edge-length coefficient of variation from zb_audit has grown by `factor` since the last remesh (v03 sphere right after a remesh: 0.19 [obj]).""" return edge_cv_now > factor * edge_cv_after_remesh def stage_budget(stage, kind="head"): s = STAGES[stage] return s["points"].get(kind) # -------------------------------------------------------------------------------------------- # Projection helpers (numpy versions of zb_stroke.Camera, same convention object) # -------------------------------------------------------------------------------------------- def _need_np(): if np is None: raise RuntimeError("numpy is required for this helper (agent side)") return np def _cam_parts(cam): c = cam.conv r = np.asarray(cam.r, dtype=float) axes = np.asarray(c.axes, dtype=float) pivot = np.asarray(cam.pivot, dtype=float) return c, r, axes, pivot def project_np(cam, verts): """(N, 3) model points (OBJ export space) to (N, 3) = canvas x, canvas y, depth (larger = closer to the camera). Same result as zb_stroke.Camera.project, vectorised.""" _need_np() v = np.asarray(verts, dtype=float).reshape(-1, 3) c, r, axes, pivot = _cam_parts(cam) view = ((v - pivot) * axes) @ r.T g = c.k * cam.s out = np.empty_like(view) out[:, 0] = cam.pos[0] + g * c.fx * view[:, 0] out[:, 1] = cam.pos[1] + g * c.fy * view[:, 1] out[:, 2] = c.fz * view[:, 2] return out def facing_np(cam, normals): """> 0 where model-space normals point toward the camera (cosine of the view angle).""" _need_np() n = np.asarray(normals, dtype=float).reshape(-1, 3) c, r, axes, _ = _cam_parts(cam) return c.fz * ((n * axes) @ r.T)[:, 2] def vertex_normals(mesh): """Area-weighted vertex normals of a zb_audit.Mesh (first triangle of each face).""" _need_np() v = mesh.verts st = mesh.starts i0 = mesh.flat[st] i1 = mesh.flat[st + 1] i2 = mesh.flat[st + 2] fn = np.cross(v[i1] - v[i0], v[i2] - v[i0]) if mesh.sizes.max(initial=3) > 3: # add the second triangle of quads q = mesh.sizes >= 4 j0, j2, j3 = mesh.flat[st[q]], mesh.flat[st[q] + 2], mesh.flat[st[q] + 3] fn[q] += np.cross(v[j2] - v[j0], v[j3] - v[j0]) n = np.zeros_like(v) for idx in (i0, i1, i2): np.add.at(n, idx, fn) q = mesh.sizes >= 4 if q.any(): np.add.at(n, mesh.flat[st[q] + 3], fn[q]) ln = np.linalg.norm(n, axis=1) ln[ln == 0] = 1.0 return n / ln[:, None] def frame_on(point, ppu, rotation, doc_w, doc_h, pivot, convention=None): """9-value transform that puts model `point` at the canvas centre at `ppu` canvas pixels per model unit, for a view `rotation` (degrees). Uses the same convention as zb_stroke.Camera, so it inherits its [verify live_03/live_04] status for views other than front.""" conv = convention or zb_stroke.DEFAULT_CONVENTION s = ppu / conv.k r = zb_stroke.rotation_matrix(rotation[0], rotation[1], rotation[2], conv.perm, conv.order, conv.signs) pv = pivot if conv.use_pivot else (0.0, 0.0, 0.0) q = [(point[i] - pv[i]) * conv.axes[i] for i in range(3)] v = [sum(r[i][j] * q[j] for j in range(3)) for i in range(3)] px = doc_w / 2.0 - conv.k * s * conv.fx * v[0] py = doc_h / 2.0 - conv.k * s * conv.fy * v[1] return [px, py, 0.0, s, s, s, float(rotation[0]), float(rotation[1]), float(rotation[2])] def best_view(normal, views=None, convention=None, allowed=None): """The canonical view (zb_stroke.VIEWS) whose camera faces `normal` most: strokes land best on surfaces seen head-on. Returns (name, facing cosine). allowed: names to consider (only the views live_04 has verified, once it ran).""" views = views or zb_stroke.VIEWS best = None for name, rot in views.items(): if allowed and name not in allowed: continue cam = zb_stroke.Camera([0, 0, 0, 1, 1, 1, rot[0], rot[1], rot[2]], (0, 0, 0), convention) f = cam.facing(normal) if best is None or f > best[1] + 1e-9: best = (name, f) return best def screen_plane_fraction(cam, direction): """How much of a model-space displacement lies in the screen plane (1 = fully). The Move brush moves in the screen plane (brushes doc, Move), so a move is aimed from a view where this is near 1.""" d = [float(x) for x in direction] ln = math.sqrt(sum(x * x for x in d)) or 1.0 f = cam.facing([x / ln for x in d]) return math.sqrt(max(0.0, 1.0 - f * f)) # -------------------------------------------------------------------------------------------- # Canvas masks (what is model and what is background on the exported canvas PNG) # -------------------------------------------------------------------------------------------- def _bg_distance(rgb, edge=4): """Per-pixel distance to the row background. ZBrush's canvas background is a vertical gradient; each row's background is the median of its outer `edge` columns (the same model as zb_review.silhouette_bbox).""" im = rgb.astype(np.int16) ref = np.median(np.concatenate([im[:, :edge], im[:, -edge:]], axis=1), axis=1) return np.abs(im - ref[:, None, :]).max(axis=2) def _flood_exterior(bg, max_iter=2000): """Background pixels connected to the image border (so dark cavities inside the model that look like background are not treated as holes). Straight runs from the four borders first, then 4-neighbour growth into concave pockets.""" left = np.cumprod(bg, axis=1).astype(bool) right = np.cumprod(bg[:, ::-1], axis=1)[:, ::-1].astype(bool) top = np.cumprod(bg, axis=0).astype(bool) bottom = np.cumprod(bg[::-1, :], axis=0)[::-1, :].astype(bool) ext = left | right | top | bottom for _ in range(max_iter): grown = ext.copy() grown[1:, :] |= ext[:-1, :] grown[:-1, :] |= ext[1:, :] grown[:, 1:] |= ext[:, :-1] grown[:, :-1] |= ext[:, 1:] grown &= bg if np.array_equal(grown, ext): break ext = grown return ext def silhouette_mask(png_or_array, threshold=28, edge=4, fill_holes=True): """Boolean model mask of a canvas PNG (True = model).""" _need_np() if isinstance(png_or_array, str): from PIL import Image with Image.open(png_or_array) as im: rgb = np.asarray(im.convert("RGB")) else: rgb = np.asarray(png_or_array) if rgb.ndim == 2: rgb = np.stack([rgb] * 3, axis=2) bg = _bg_distance(rgb, edge) <= threshold bg[:, :edge] = True bg[:, -edge:] = True if fill_holes: return ~_flood_exterior(bg) return ~bg def erode(mask, radius): """Square erosion by `radius` pixels (integral image, numpy only).""" _need_np() r = int(radius) if r <= 0: return mask.copy() m = mask.astype(np.int64) h, w = m.shape ii = np.zeros((h + 1, w + 1), dtype=np.int64) ii[1:, 1:] = m.cumsum(0).cumsum(1) y0 = np.clip(np.arange(h) - r, 0, h) y1 = np.clip(np.arange(h) + r + 1, 0, h) x0 = np.clip(np.arange(w) - r, 0, w) x1 = np.clip(np.arange(w) + r + 1, 0, w) s = (ii[np.ix_(y1, x1)] - ii[np.ix_(y0, x1)] - ii[np.ix_(y1, x0)] + ii[np.ix_(y0, x0)]) area = (y1 - y0)[:, None] * (x1 - x0)[None, :] return s == area def footprint_from_points(proj_xy, width, height, splat_px=2): """Model mask from projected vertices (when no canvas PNG is at hand): each vertex marks a (2*splat+1)^2 block. Coarse; the canvas PNG mask is exact.""" _need_np() m = np.zeros((int(height), int(width)), dtype=bool) xy = np.round(np.asarray(proj_xy, dtype=float)[:, :2]).astype(int) ok = (xy[:, 0] >= 0) & (xy[:, 0] < width) & (xy[:, 1] >= 0) & (xy[:, 1] < height) xy = xy[ok] for dy in range(-splat_px, splat_px + 1): for dx in range(-splat_px, splat_px + 1): x = np.clip(xy[:, 0] + dx, 0, width - 1) y = np.clip(xy[:, 1] + dy, 0, height - 1) m[y, x] = True return m def clip_runs(points, inside, min_len_px=0.0, min_points=2): """Split a stroke into the runs of consecutive points for which inside(x, y) is True; keep runs of at least `min_points` points and `min_len_px` length. Scripted strokes should start and stay on the model (a stroke that starts off the mesh may turn the view [verify live_02]).""" runs, cur = [], [] for p in points: if inside(p[0], p[1]): cur.append(p) else: if cur: runs.append(cur) cur = [] if cur: runs.append(cur) return [r for r in runs if len(r) >= min_points and zb_stroke.path_length(r) >= min_len_px] # -------------------------------------------------------------------------------------------- # Paths in canvas space # -------------------------------------------------------------------------------------------- def _cumlen(path): out = [0.0] for a, b in zip(path, path[1:]): out.append(out[-1] + math.dist(a, b)) return out def _point_at(path, cum, s): s = max(0.0, min(cum[-1], s)) for i in range(len(path) - 1): if cum[i + 1] >= s or i == len(path) - 2: seg = cum[i + 1] - cum[i] t = 0.0 if seg <= 0 else (s - cum[i]) / seg a, b = path[i], path[i + 1] return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) return tuple(path[-1]) def _tangent_at(path, cum, s, half_window): a = _point_at(path, cum, s - half_window) b = _point_at(path, cum, s + half_window) dx, dy = b[0] - a[0], b[1] - a[1] ln = math.hypot(dx, dy) if ln < 1e-9: return (1.0, 0.0) return (dx / ln, dy / ln) def sub_path(path, s0, s1, spacing): """The part of a polyline between arc lengths s0 and s1, resampled at `spacing`.""" cum = _cumlen(path) s0, s1 = max(0.0, s0), min(cum[-1], s1) if s1 <= s0: return [_point_at(path, cum, s0)] n = max(1, int(math.ceil((s1 - s0) / spacing))) return [_point_at(path, cum, s0 + (s1 - s0) * i / n) for i in range(n + 1)] def point_spacing(diam_px, frac=0.1, lo=2.0, hi=16.0): """Distance between synthesized stroke points: 0.1 D or less (digest 7.3 [added]), kept between 2 px and 16 px (the proven v02 stroke used 20 px steps) [added].""" return max(lo, min(hi, frac * diam_px)) def _rot(v, deg): a = math.radians(deg) c, s = math.cos(a), math.sin(a) return (v[0] * c - v[1] * s, v[0] * s + v[1] * c) def _round_pts(pts): return [(float(round(x)), float(round(y))) for x, y in pts] # -------------------------------------------------------------------------------------------- # Stroke-set generators (canvas pixels) # -------------------------------------------------------------------------------------------- def hatch_across(path_px, width_px, diam_px, station=0.4, overhang=0.55, spacing=None, jitter_deg=12.0, len_jitter=0.1, alternate=True, phase=0.0, seed=0): """T1, across the form (Henning: strokes perpendicular to the form so the peak lands on its centre and fades to the edges, 0PaYUUvgwYM 00:02:13 to 00:02:45; 'stroke stroke stroke', TpS0QdlfHWU 00:08:04). Stations every station*D along the centre line (digest 0.3 to 0.5 D [added]); each stroke spans -overhang*W to +overhang*W around the centre line; angle jitter and length jitter and alternating start side imitate a hand so marks do not line up [added]. phase shifts the stations by a fraction of a station (0.5 = in the valleys of the previous pass, 'fill in the gaps', 0PaYUUvgwYM 00:06:04). Returns a list of point lists.""" if len(path_px) < 2: raise ValueError("path needs 2 or more points") rnd = random.Random(seed) sp = spacing or point_spacing(diam_px) cum = _cumlen(path_px) total = cum[-1] step = max(1.0, station * diam_px) out = [] i = 0 s = phase * step while s <= total + 1e-6: p = _point_at(path_px, cum, s) t = _tangent_at(path_px, cum, s, max(1.0, min(diam_px / 2.0, total / 4.0))) n = (-t[1], t[0]) if jitter_deg: n = _rot(n, rnd.uniform(-jitter_deg, jitter_deg)) half = overhang * width_px * (1.0 + (rnd.uniform(-len_jitter, len_jitter) if len_jitter else 0.0)) a = (p[0] - n[0] * half, p[1] - n[1] * half) b = (p[0] + n[0] * half, p[1] + n[1] * half) if alternate and i % 2: a, b = b, a out.append(_round_pts(zb_stroke.line(a, b, sp))) i += 1 s += step return out def strokes_along(path_px, width_px, diam_px, count=3, spread=0.4, spacing=None, seed=0, jitter_px=None): """T2 pass A (Pablo: 'from left to right multiple times, with multiple separate strokes', tbQqC6tyDBQ 00:05:59): `count` copies of the centre line offset across it by spread*D steps, clipped to the form width.""" rnd = random.Random(seed) sp = spacing or point_spacing(diam_px) base = zb_stroke.resample(path_px, sp) cum = _cumlen(path_px) out = [] offs = [(k - (count - 1) / 2.0) * spread * diam_px for k in range(count)] lim = width_px / 2.0 jit = diam_px * 0.05 if jitter_px is None else jitter_px for o in offs: o = max(-lim, min(lim, o)) pts = [] for j, p in enumerate(base): s = cum[-1] * j / max(1, len(base) - 1) t = _tangent_at(path_px, cum, s, max(1.0, diam_px / 2.0)) n = (-t[1], t[0]) e = rnd.uniform(-jit, jit) if jit else 0.0 pts.append((p[0] + n[0] * (o + e), p[1] + n[1] * (o + e))) out.append(_round_pts(pts)) return out def two_direction(path_px, width_px, diam_px, count=3, spread=0.4, across_scale=0.6, seed=0): """T2 (Pablo, tbQqC6tyDBQ 00:05:59 to 00:06:32: several strokes in one direction, then smaller strokes perpendicular to them; never a muscle in one stroke). Returns two passes: [("along", strokes, D), ("across", strokes, 0.6 D)].""" a = strokes_along(path_px, width_px, diam_px, count, spread, seed=seed) d2 = diam_px * across_scale b = hatch_across(path_px, width_px, d2, seed=seed + 1) return [("along", a, diam_px), ("across", b, d2)] def descent(diam0_px, z_ladder=DESCENT_Z, shrink=0.75, zoom=1.3, alpha_off_last=2): """T3 intensity descent (Henning: 'set the intensity lower and lower and lower; every time you decrease the intensity you can zoom in a little bit more', TpS0QdlfHWU 00:21:53 to 00:22:26; Alpha Off for the last passes, 0PaYUUvgwYM 00:31:28). zoom and shrink per iteration are [added] (digest 7.3). Each pass shifts stations by half a station. Returns per-pass parameters: zoom is cumulative (frame the next Scene at base ppu * zoom); diam_px is the brush size in base-view pixels, so the on-screen diameter in the zoomed view is diam_px * zoom (the brush shrinks on the model by `shrink` per pass).""" out = [] for i, z in enumerate(z_ladder): out.append({"iteration": i, "z": int(z), "diam_px": diam0_px * shrink ** i, "zoom": zoom ** i, "phase": 0.5 * (i % 2), "alpha": "Alpha Off" if i >= len(z_ladder) - alpha_off_last else "Alpha 06"}) return out def move_drags(grab_px, target_px, diam_px, max_step_frac=0.5, points=6): """T4 big Move (Henning: Move then DynaMesh, TpS0QdlfHWU 00:02:34; Pablo: big Move then Smooth, tbQqC6tyDBQ 00:02:29). A displacement longer than max_step*D is split into several drags, each grabbing where the previous one ended, to limit stretching [added, digest T4].""" dist = math.dist(grab_px, target_px) if dist < 1e-9: return [] n = max(1, int(math.ceil(dist / (max_step_frac * diam_px) - 1e-9))) out = [] for k in range(n): a = (grab_px[0] + (target_px[0] - grab_px[0]) * k / n, grab_px[1] + (target_px[1] - grab_px[1]) * k / n) b = (grab_px[0] + (target_px[0] - grab_px[0]) * (k + 1) / n, grab_px[1] + (target_px[1] - grab_px[1]) * (k + 1) / n) seg = [(a[0] + (b[0] - a[0]) * j / (points - 1), a[1] + (b[1] - a[1]) * j / (points - 1)) for j in range(points)] out.append(_round_pts(seg)) return out def crease(path_px, diam_px, fractions=(1.0, 0.6, 0.35), wobble=0.1, spacing=None, seed=0): """T5 Dam_Standard along the crease (Henning: not one line of constant weight, 'soft, then harder, then softer, then harder again, with small overlaps', 0PaYUUvgwYM 00:20:55 to 00:21:29; along the form, never across, 00:22:34). Without pen pressure the depth variation comes from overlapping sub-strokes: the full path, then shorter ones of `fractions` of its length placed at random along it, each with a lateral wobble of up to wobble*D [added numbers, digest T5]. If zb_stroke.PRESSURE_TOKEN becomes known (live_02), a pressure profile can replace the sub-strokes.""" rnd = random.Random(seed) sp = spacing or point_spacing(diam_px) cum = _cumlen(path_px) total = cum[-1] out = [] for f in fractions: f = max(0.05, min(1.0, f)) s0 = 0.0 if f >= 1.0 else rnd.uniform(0.0, (1.0 - f) * total) pts = sub_path(path_px, s0, s0 + f * total, sp) amp = rnd.uniform(0.3, 1.0) * wobble * diam_px ph = rnd.uniform(0, 2 * math.pi) wav = max(2.0, diam_px * 4.0) wob = [] cum_s = _cumlen(pts) for p, s in zip(pts, cum_s): t = _tangent_at(path_px, cum, s0 + s, max(1.0, diam_px / 2.0)) n = (-t[1], t[0]) o = amp * math.sin(ph + 2 * math.pi * s / wav) wob.append((p[0] + n[0] * o, p[1] + n[1] * o)) out.append(_round_pts(wob)) return out def fill_between(path_a, path_b, diam_px, station=0.4, inset=0.15, spacing=None): """T6 'coloring inside the lines' (Henning, 0PaYUUvgwYM 00:26:22): after two cuts, short clay strokes across the gap between them. Strokes stop `inset` of the gap short of each cut so they do not fill the cuts [added].""" sp = spacing or point_spacing(diam_px) ca, cb = _cumlen(path_a), _cumlen(path_b) n = max(2, int(max(ca[-1], cb[-1]) / max(1.0, station * diam_px)) + 1) out = [] for i in range(n): t = i / (n - 1) a = _point_at(path_a, ca, t * ca[-1]) b = _point_at(path_b, cb, t * cb[-1]) a2 = (a[0] + (b[0] - a[0]) * inset, a[1] + (b[1] - a[1]) * inset) b2 = (b[0] + (a[0] - b[0]) * inset, b[1] + (a[1] - b[1]) * inset) out.append(_round_pts(zb_stroke.line(a2, b2, sp))) return out # -------------------------------------------------------------------------------------------- # Forms (model space) and the Scene they are aimed through # -------------------------------------------------------------------------------------------- class Form: """A form the agent intends to sculpt, in model space (the space of zb_ops.export_obj). path: centre line, 2 or more points (use Form.blob for a round mass). width: model units across the form. sign: +1 build (Zadd), -1 carve (Zsub). kind: "mass" (clay across), "crease" (Dam_Standard along), "plane" (TrimDynamic across).""" def __init__(self, name, path, width, sign=+1, kind="mass", note=""): pts = [tuple(float(c) for c in p) for p in path] if len(pts) < 2: raise ValueError("a Form path needs 2 or more points (Form.blob for a round mass)") if any(len(p) != 3 for p in pts): raise ValueError("Form points are 3D model-space points") self.name, self.path, self.width = name, pts, float(width) self.sign, self.kind, self.note = (1 if sign >= 0 else -1), kind, note @classmethod def crease(cls, name, path, width, sign=-1, note=""): """A crease for crease_pass: sign -1 cuts (Dam_Standard's default Zsub), +1 raises a sharp ridge (Alt, TpS0QdlfHWU 00:06:18).""" return cls(name, path, width, sign, "crease", note) @classmethod def blob(cls, name, center, radius, axis=(1.0, 0.0, 0.0), sign=+1, kind="mass", note=""): ln = math.sqrt(sum(a * a for a in axis)) or 1.0 u = [a / ln * radius * 0.8 for a in axis] return cls(name, [[c - d for c, d in zip(center, u)], [c + d for c, d in zip(center, u)]], 2.0 * radius, sign, kind, note) def half(self, side=+1, axis=0): """The part of the path on one side of the symmetry plane (model x = 0 by default). With symmetry on, a stroke that crosses the plane is mirrored onto itself and doubles at the centre [added]; plan one side only.""" keep = [] pts = self.path for a, b in zip(pts, pts[1:]): ina, inb = a[axis] * side >= 0, b[axis] * side >= 0 if ina: keep.append(a) if ina != inb: t = a[axis] / (a[axis] - b[axis]) keep.append(tuple(a[i] + (b[i] - a[i]) * t for i in range(3))) if pts[-1][axis] * side >= 0: keep.append(pts[-1]) dedup = [keep[0]] if keep else [] for p in keep[1:]: if math.dist(p, dedup[-1]) > 1e-9: dedup.append(p) if len(dedup) < 2: return None return Form(self.name + ("_R" if side > 0 else "_L"), dedup, self.width, self.sign, self.kind, self.note) def resampled(self, step): """Model-space path resampled every `step` units (for surface snapping).""" out = [self.path[0]] for a, b in zip(self.path, self.path[1:]): n = max(1, int(math.ceil(math.dist(a, b) / step))) out += [tuple(a[i] + (b[i] - a[i]) * k / n for i in range(3)) for k in range(1, n + 1)] return out def to_dict(self): return {"name": self.name, "path": self.path, "width": self.width, "sign": self.sign, "kind": self.kind, "note": self.note} class Scene: """One view of the active SubTool, everything a stroke plan needs. transform: the 9 floats of zbc.get_transform() read back after framing; pivot: centre of query_mesh3d(2, 3) (the full bbox, as zb_stroke.Camera.from_zbrush); verts/normals: numpy arrays from the OBJ export; mask: model mask of the canvas PNG (None when not exported); doc: (width, height). Strokes planned here are valid for this transform only: every pass_spec carries it and pass_code restores it before stroking.""" def __init__(self, transform, pivot, doc, verts=None, normals=None, mask=None, obj=None, png=None, stats=None, convention=None): self.transform = [float(v) for v in transform] self.pivot = tuple(float(v) for v in pivot) self.doc = (int(doc[0]), int(doc[1])) self.cam = zb_stroke.Camera(self.transform, self.pivot, convention) self.verts, self.normals, self.mask = verts, normals, mask self.obj, self.png, self.stats = obj, png, stats or {} self._proj = None self._eroded = {} # construction --------------------------------------------------------------------------- @classmethod def from_files(cls, obj_path, transform, pivot, doc, png=None, convention=None, stats=None): """Scene from an OBJ export, a transform and a pivot (offline, no ZBrush).""" import zb_audit m = zb_audit.load_obj(obj_path) mask = silhouette_mask(png) if png else None return cls(transform, pivot, doc, m.verts, vertex_normals(m), mask, obj_path, png, stats, convention) @classmethod def capture(cls, out_dir, view=None, ppu=None, center=None, margin=0.75, obj=True, png=True, port=DEFAULT_PORT, timeout=180, tag=None, convention=None): """Frame the view in ZBrush, export the OBJ and the canvas PNG, read the transform back. view: a zb_stroke.VIEWS name or a rotation triple (None keeps the current view). ppu: canvas pixels per model unit (None: Maxon's framing rule, the model's longest side spans `margin` of the short canvas side). center: model point to put at the canvas centre (zoomed work on one region). NOT YET RUN IN ZBRUSH (live_s02).""" import zb_launch out_dir = os.path.abspath(out_dir) os.makedirs(out_dir, exist_ok=True) info = zb_launch.run( "zb_ops.ensure_edit()\n" "result = {'doc': [zbc.get('Document:Width'), zbc.get('Document:Height')]," " 't': [float(v) for v in zbc.get_transform()]," " 'bbox': [float(v) for v in zbc.query_mesh3d(2, 3)]}", modules=("zb_ops",), port=port, timeout=timeout) w, h = info["doc"] b = info["bbox"] pivot = ((b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2) conv = convention or zb_stroke.DEFAULT_CONVENTION t = None if view is not None or ppu is not None or center is not None: rot = (zb_stroke.VIEWS[view] if isinstance(view, str) else tuple(view) if view is not None else tuple(info["t"][6:9])) if ppu is None and center is None: t = zb_stroke.frame_transform(b, w, h, rot, margin, conv) else: cur_ppu = conv.k * info["t"][3] t = frame_on(center or pivot, ppu or cur_ppu, rot, w, h, pivot, conv) n = len(glob.glob(os.path.join(out_dir, "scene_*.json"))) tag = tag or f"scene_{n:03d}" objp = os.path.join(out_dir, tag + ".obj") if obj else None pngp = os.path.join(out_dir, tag + ".png") if png else None code = ( "zb_ops.ensure_edit()\n" f"t = {t!r}\n" "if t:\n" " zbc.set_transform(*t)\n" " zbc.update(redraw_ui=True)\n" "p = zb_ops.resolve('persp', required=False)\n" "if p and zbc.get(p) >= 0.5:\n" " zbc.set(p, 0)\n" " zbc.update(redraw_ui=True)\n" "r = {'t': [float(v) for v in zbc.get_transform()], 'stats': zb_ops.stats(3)}\n" f"if {objp!r}:\n" f" r['obj'] = zb_ops.export_obj({objp!r}, overwrite=True)\n" f"if {pngp!r}:\n" f" r['png'] = zb_ops.export_canvas({pngp!r}, overwrite=True)\n" "result = r\n") r = zb_launch.run(code, modules=("zb_ops",), port=port, timeout=timeout) meta = {"view": view, "ppu": ppu, "center": center, "transform_set": t, "transform_read": r["t"], "pivot": pivot, "doc": [w, h], "stats": r["stats"], "obj": objp, "png": pngp} with open(os.path.join(out_dir, tag + ".json"), "w") as fh: json.dump(meta, fh, indent=1, default=repr) if objp: sc = cls.from_files(objp, r["t"], pivot, (w, h), pngp, conv, r["stats"]) else: sc = cls(r["t"], pivot, (w, h), mask=silhouette_mask(pngp) if pngp else None, png=pngp, stats=r["stats"], convention=conv) return sc # geometry ------------------------------------------------------------------------------- @property def ppu(self): return self.cam.pixels_per_unit() @property def proj(self): if self._proj is None and self.verts is not None: self._proj = project_np(self.cam, self.verts) return self._proj def model_width_px(self): """Width of the model in this view (projected vertices, else the mask).""" if self.proj is not None: return float(self.proj[:, 0].max() - self.proj[:, 0].min()) if self.mask is not None and self.mask.any(): xs = np.nonzero(self.mask.any(axis=0))[0] return float(xs.max() - xs.min()) raise ValueError("no vertices and no mask in this Scene") def snap(self, points): """Nearest surface vertex to each model point: (positions, indices).""" _need_np() p = np.asarray(points, dtype=float).reshape(-1, 3) idx = [] for q in p: idx.append(int(np.argmin(((self.verts - q) ** 2).sum(axis=1)))) idx = np.asarray(idx) return self.verts[idx], idx def visible(self, points, cell_px=2.0, eps=None): """Which model points are the front-most surface at their pixel (vertex z-buffer).""" _need_np() pr = self.proj keys = np.floor(pr[:, :2] / cell_px).astype(np.int64) kx, ky = keys[:, 0] - keys[:, 0].min() + 1, keys[:, 1] - keys[:, 1].min() + 1 buf = np.full((kx.max() + 2, ky.max() + 2), -np.inf) np.maximum.at(buf, (kx, ky), pr[:, 2]) if eps is None: eps = 0.01 * float(pr[:, 2].max() - pr[:, 2].min() + 1e-12) q = project_np(self.cam, points) qk = np.floor(q[:, :2] / cell_px).astype(np.int64) qx = qk[:, 0] - keys[:, 0].min() + 1 qy = qk[:, 1] - keys[:, 1].min() + 1 out = [] for x, y, d in zip(qx, qy, q[:, 2]): if x < 1 or y < 1 or x >= buf.shape[0] - 1 or y >= buf.shape[1] - 1: out.append(False) continue out.append(bool(d >= buf[x - 1:x + 2, y - 1:y + 2].max() - eps)) return out def inside_fn(self, margin_px=0): """inside(x, y): the pixel is model in the canvas mask eroded by margin_px (falls back to a splat of projected vertices when no PNG was exported).""" m = int(round(margin_px)) if m not in self._eroded: base = self.mask if self.mask is not None else footprint_from_points( self.proj, self.doc[0], self.doc[1]) self._eroded[m] = erode(base, m) em = self._eroded[m] hgt, wid = em.shape def inside(x, y): xi, yi = int(round(x)), int(round(y)) return 0 <= xi < wid and 0 <= yi < hgt and bool(em[yi, xi]) return inside def form_to_canvas(self, form, snap=True, step=None): """Centre line in canvas pixels, width in pixels, and the facing cosines along it. snap=True moves the path onto the surface first (nearest vertex).""" step = step or max(form.width / 4.0, 1e-4) pts = form.resampled(step) facing = None vis = None if snap and self.verts is not None: spts, idx = self.snap(pts) pts = [tuple(p) for p in spts] if self.normals is not None: facing = facing_np(self.cam, self.normals[idx]).tolist() v = self.visible(pts) vis = sum(v) / float(len(v)) pr = project_np(self.cam, pts) if np is not None else [self.cam.project(p) for p in pts] path = [(float(p[0]), float(p[1])) for p in pr] clean = [path[0]] for p in path[1:]: if math.dist(p, clean[-1]) > 0.5: clean.append(p) if len(clean) < 2: clean = [path[0], path[-1]] return {"path": clean, "width_px": form.width * self.ppu, "facing": facing, "min_facing": min(facing) if facing else None, "visible_frac": vis} def symmetry_axis_px(self): """Canvas x of the model plane x = 0 at mid height (front and back views).""" c = self.cam.project((0.0, self.pivot[1], self.pivot[2])) return c[0] # -------------------------------------------------------------------------------------------- # Brush size and intensity calibration # -------------------------------------------------------------------------------------------- # Draw Size is in canvas pixels (brushes doc, Draw Size; Dynamic mode must stay off). Whether it # is the brush radius or the diameter is [verify live_s01]: the live test measures the footprint # of a stroke and writes radius_per_draw_size into sculpt_calibration.json. RADIUS_PER_DRAW_SIZE = 1.0 def draw_size_for(diam_px, radius_per_draw_size=None): """Draw Size that gives a brush of diameter diam_px on the canvas.""" k = radius_per_draw_size or current_calibration().get("radius_per_draw_size", RADIUS_PER_DRAW_SIZE) return int(max(1, min(1000, round(diam_px / 2.0 / k)))) class IntensityModel: """Volume change of one scripted stroke (constant pressure) as a function of Z Intensity. dV = c * Z * R^2 * 2 L (R brush radius, L stroke length, model units) [added] so the mean raise over the stroke footprint is c * Z * R. Fit c per brush from measured strokes (live_s01): each sample is {z, draw_size, ppu, length_px, dv}. First-order model: check the residuals the fit reports before trusting z_for_raise far from the samples.""" def __init__(self, brush, c, radius_per_draw_size=RADIUS_PER_DRAW_SIZE, samples=(), rms_rel=None, source=""): self.brush, self.c = brush, float(c) self.radius_per_draw_size = float(radius_per_draw_size) self.samples, self.rms_rel, self.source = list(samples), rms_rel, source @staticmethod def _x(sample, k): r = sample["draw_size"] * k / sample["ppu"] length = sample["length_px"] / sample["ppu"] return sample["z"] * r * r * 2.0 * length @classmethod def fit(cls, brush, samples, radius_per_draw_size=RADIUS_PER_DRAW_SIZE, source="fit"): xs = [cls._x(s, radius_per_draw_size) for s in samples] ys = [abs(float(s["dv"])) for s in samples] den = sum(x * x for x in xs) if den <= 0: raise ValueError("no usable samples") c = sum(x * y for x, y in zip(xs, ys)) / den rel = [((c * x) - y) / y for x, y in zip(xs, ys) if y > 0] rms = math.sqrt(sum(e * e for e in rel) / len(rel)) if rel else None return cls(brush, c, radius_per_draw_size, samples, rms, source) def radius(self, draw_size, ppu): return draw_size * self.radius_per_draw_size / ppu def mean_raise(self, z, radius): return self.c * z * radius def predict_dv(self, z, draw_size, ppu, length_px): return self.c * self._x({"z": z, "draw_size": draw_size, "ppu": ppu, "length_px": length_px}, self.radius_per_draw_size) def z_for_raise(self, target_raise, radius, z_min=1, z_max=100): """(Z, passes) for a mean raise of `target_raise` model units with brush radius `radius` (model units). Above z_max the raise is split into passes; below z_min the answer is z_min with a note that one pass overshoots.""" if target_raise <= 0 or radius <= 0: raise ValueError("target_raise and radius must be positive") z = target_raise / (self.c * radius) if z > z_max: passes = int(math.ceil(z / z_max)) return int(round(z / passes)), passes return int(max(z_min, round(z))), 1 def to_dict(self): return {"brush": self.brush, "c": self.c, "radius_per_draw_size": self.radius_per_draw_size, "rms_rel": self.rms_rel, "source": self.source, "samples": self.samples} @classmethod def from_dict(cls, d): return cls(d["brush"], d["c"], d.get("radius_per_draw_size", RADIUS_PER_DRAW_SIZE), d.get("samples", ()), d.get("rms_rel"), d.get("source", "")) # Prior from the proven v02 stroke (ClayBuildup, Draw Size 64, Z 20 as read in v01, 11 points, # 207.4 px on the front of a radius-1 sphere at 207 px per unit, volume 4.189 -> 4.193, three # decimals) [v02]. The v01 replay ran mostly off the sphere and is not used. Radius hypothesis # [verify live_s01]. c = 0.00104; one sample, rounding alone is +-12 %. PRIOR_SAMPLES = {"ClayBuildup": [{"z": 20, "draw_size": 64, "ppu": 207.0, "length_px": 207.39, "dv": 0.004, "evidence": "v02"}]} def prior_model(brush="ClayBuildup"): return IntensityModel.fit(brush, PRIOR_SAMPLES[brush], RADIUS_PER_DRAW_SIZE, source="prior: v02 stroke, radius hypothesis [verify live_s01]") def current_calibration(path=CALIBRATION_FILE): if path and os.path.exists(path): with open(path) as fh: return json.load(fh) return {} def intensity_model(brush="ClayBuildup", path=CALIBRATION_FILE): """The installed calibration for `brush` (live_s01 --install), else the v02 prior for ClayBuildup, else None.""" cal = current_calibration(path) d = cal.get("models", {}).get(brush) if d: return IntensityModel.from_dict(d) if brush in PRIOR_SAMPLES: return prior_model(brush) return None def calibration_strokes(doc_w, doc_h, ppu, zs=(2, 4, 6, 10, 20), length_frac=0.45, reps=1): """Horizontal strokes for the intensity calibration on the front of a framed DynaMesh sphere (radius 1, centred): one row per Z, rows spread over the front where the surface faces the camera (|y| <= 0.45 of the radius) [added]. Returns [(z, points)].""" cx, cy = doc_w / 2.0, doc_h / 2.0 half = length_frac * ppu rows = [] n = len(zs) * reps for i in range(n): y = cy + (i - (n - 1) / 2.0) * (0.9 * ppu / max(1, n)) rows.append((zs[i % len(zs)], _round_pts(zb_stroke.line((cx - half, y), (cx + half, y), 6.0)))) return rows def footprint_radius_px(disp, cam, stroke_pts, before_verts): """Brush radius seen on the mesh: the largest distance, in canvas pixels, between a displaced vertex (zb_audit.displaced indices) and the stroke polyline.""" _need_np() idx = np.asarray(disp["indices"], dtype=int) if idx.size == 0: return 0.0 pr = project_np(cam, before_verts[idx])[:, :2] best = np.full(len(pr), np.inf) for a, b in zip(stroke_pts, stroke_pts[1:]): a, b = np.asarray(a, float), np.asarray(b, float) ab = b - a L2 = float(ab @ ab) or 1e-12 t = np.clip(((pr - a) @ ab) / L2, 0, 1) d = np.linalg.norm(pr - (a + t[:, None] * ab), axis=1) best = np.minimum(best, d) return float(np.percentile(best, 98)) def run_calibration(out_dir, brush="ClayBuildup", zs=(2, 4, 6, 10, 20), draw_size=24, port=DEFAULT_PORT, install=False, focal=None, alpha=None, stroke_type=None): """Measure dV per stroke and the footprint radius on a fresh DynaMesh sphere (128) framed front at 0.6 margin (rows about 45 px apart, so Draw Size 24 keeps footprints apart under either radius hypothesis) [added]. Writes calibration.json in out_dir; install=True also writes sculpt_calibration.json next to this module. NOT YET RUN IN ZBRUSH (live_s01).""" import zb_audit import zb_launch out_dir = os.path.abspath(out_dir) os.makedirs(out_dir, exist_ok=True) zb_launch.run("r = zb_ops.new_sphere(resolution=128)\nzb_ops.set_symmetry(False)\nresult = r", modules=("zb_ops",), port=port, timeout=180) sc = Scene.capture(out_dir, view="front", margin=0.6, port=port, tag="cal_base") rows = calibration_strokes(sc.doc[0], sc.doc[1], sc.ppu, zs) samples, radii = [], [] prev = sc.obj for i, (z, pts) in enumerate(rows): objp = os.path.join(out_dir, f"cal_{i:02d}_z{z}.obj") spec = pass_spec(f"cal z{z}", brush, [pts], draw_size, z, focal=focal, alpha=alpha, stroke_type=stroke_type, symmetry=False, transform=sc.transform) res = apply_pass(spec, port=port) zb_launch.run(f"result = zb_ops.export_obj({objp!r}, overwrite=True)", modules=("zb_ops",), port=port) disp = zb_audit.displaced(prev, objp) before = zb_audit.load_obj(prev).verts rpx = footprint_radius_px(disp, sc.cam, pts, before) dv = res["volume_after"] - res["volume_before"] samples.append({"z": z, "draw_size": draw_size, "ppu": sc.ppu, "length_px": zb_stroke.path_length(pts), "dv": dv, "displaced": disp["count"], "max_disp": disp["max_disp"], "footprint_radius_px": rpx}) if rpx > 0: radii.append(rpx / draw_size) prev = objp k = sorted(radii)[len(radii) // 2] if radii else RADIUS_PER_DRAW_SIZE model = IntensityModel.fit(brush, [s for s in samples if abs(s["dv"]) > 0], k, source=f"run_calibration {out_dir}") cal = {"radius_per_draw_size": k, "models": {brush: model.to_dict()}, "samples": samples, "ppu": sc.ppu, "draw_size": draw_size} with open(os.path.join(out_dir, "calibration.json"), "w") as fh: json.dump(cal, fh, indent=1) if install: old = current_calibration() old.setdefault("models", {}).update(cal["models"]) old["radius_per_draw_size"] = k old.setdefault("history", []).append({"dir": out_dir, "brush": brush, "c": model.c, "rms_rel": model.rms_rel, "k": k}) with open(CALIBRATION_FILE, "w") as fh: json.dump(old, fh, indent=1) return cal # -------------------------------------------------------------------------------------------- # Pass specs and execution # -------------------------------------------------------------------------------------------- # Perspective is a Draw palette switch: the installed ZData/ZLang/zcommands/commands.xml lists # Draw:Perspective and no Transform:Persp [xml] (Z1 grade, 2026-09-24). The lead toolkit's v1 # PATHS["persp"] only tried Transform:Persp; its round 1 version has the same list as here. This # module keeps its own copy so its perspective handling does not depend on the toolkit version. PERSP_PATHS = ["Draw:Perspective", "Draw:Persp", "Transform:Persp"] def pass_spec(label, brush, strokes, draw_size, z, zsub=False, focal=None, stroke_type=None, alpha=None, lazymouse=False, symmetry=None, sym_axis="x", transform=None, measure="stroke", note="", source=""): """One pass: one brush setting, a list of strokes in canvas pixels, the view they were planned in. JSON-able; validated here so ZBrush never gets a bad stroke. lazymouse defaults to False: LazyMouse (on at radius 1 on Standard and ClayBuildup) swallows taps and the start of a stroke, and a synthesized path is already smooth (Pavlovich AdkZe1yKFTU 00:03:31; sculpting digest P1 step 4). None leaves it as it is.""" good = [] for s in strokes: pts = [(float(p[0]), float(p[1])) for p in s] if not pts: continue if min(min(p) for p in pts) < 0: raise ValueError(f"{label}: negative canvas coordinate; clip strokes first") good.append(pts) if not good: raise ValueError(f"{label}: no strokes") z = int(round(z)) if not 0 <= z <= 100: raise ValueError(f"{label}: Z Intensity {z} outside 0..100") ds = int(round(draw_size)) if not 1 <= ds <= 1000: raise ValueError(f"{label}: Draw Size {ds} outside 1..1000") if transform is not None and len(transform) != 9: raise ValueError("transform must have 9 values") return {"label": label, "brush": brush, "strokes": good, "draw_size": ds, "z": z, "zsub": bool(zsub), "focal": focal, "stroke_type": stroke_type, "alpha": alpha, "lazymouse": lazymouse, "symmetry": symmetry, "sym_axis": sym_axis, "transform": [float(t) for t in transform] if transform else None, "measure": measure, "note": note, "source": source} _PASS_CODE = r''' import json as _zs_json spec = _zs_json.loads(__SPEC__) out = {"label": spec["label"], "missing": [], "set": {}, "strokes": []} zb_ops.ensure_edit() def _press_opt(path): if zbc.exists(path): zbc.press(path) out["set"][path] = True return True out["missing"].append(path) return False _p = zb_ops.resolve(__PERSP__, required=False) if _p and zbc.get(_p) >= 0.5: zbc.set(_p, 0) out["persp_turned_off"] = True elif _p is None: out["missing"].append("perspective switch") if spec.get("symmetry") is not None: out["symmetry"] = zb_ops.set_symmetry(bool(spec["symmetry"]), spec.get("sym_axis") or "x") out["brush"] = zb_ops.select_brush(spec["brush"]) if spec.get("stroke_type"): _press_opt("Stroke:" + spec["stroke_type"]) if spec.get("alpha"): _press_opt("Alpha:" + spec["alpha"]) if spec.get("lazymouse") is not None: _lm = zb_ops.resolve(["Stroke:Lazy Mouse:LazyMouse", "Stroke:LazyMouse"], required=False) if _lm: zbc.set(_lm, 1 if spec["lazymouse"] else 0) out["lazymouse"] = zbc.get(_lm) else: out["missing"].append("LazyMouse") out["draw"] = zb_ops.set_draw(size=spec["draw_size"], z_intensity=spec["z"], focal=spec.get
-
-
SKILL.md 20.7 KB
--- name: scenario-zbrush-sculpting description: "Use when an agent sculpts organic forms in ZBrush 2026 (sculpt a head, bust or creature in ZBrush from a sphere, ZSpheres or primitives); when blocking primary and secondary forms, choosing DynaMesh, Sculptris Pro or subdivision levels, or setting ClayBuildup, Dam_Standard, Move or TrimDynamic; when using masks, polygroups, Morph Targets or layers; or when scripted strokes miss, do nothing, come out too strong or leave marks, or a re-DynaMesh does nothing or loses polygroups." license: MIT --- # ZBrush sculpting (core forms, scripted strokes) Expert sculpting goes big to small with gates: light meshes raised in resolution only when the forms ask, clay built across the form in thin low-intensity layers, sharpness last and never on weak shapes. An agent has no pen: it uses exact operations first (primitives, ZSpheres, DynaMesh, masks plus Deformation sliders), then strokes planned from model-space forms, aimed through the view, measured in volume and reviewed after every pass ([`scripts/zb_sculpt.py`](scripts/zb_sculpt.py)). 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). ## Stance (the expert delta) - **Pressure is the hidden setting.** Henning Sanden blocks at ClayBuildup Z 20 with "maybe 30%" of his pen pressure (0PaYUUvgwYM 00:04:58); a scripted stroke has constant pressure. Start clay at Z 6, refine at 2 to 1, calibrate once per session (`zs.run_calibration`), and dial a whole pass after review: a partial Morph value softens it, a negative one exaggerates it (Drust, B_wKwXwjcZs 00:05:20); layer intensity does the same on levels. - **Clay across the form, lines along it.** Henning: across puts a peak in the center that fades to the edges; along is "drawing the shape" (0PaYUUvgwYM 00:02:13, 00:30:39). Pablo Munoz Gomez builds long masses along first, then smaller strokes across, never a muscle in one stroke (tbQqC6tyDBQ 00:06:32). Each form is a center line plus a width. - **Standard sets edges, clay builds, ClayBuildup erases.** Standard pushes what is there, so it gives specificity (Henning 0PaYUUvgwYM 00:27:26); ClayBuildup replaces what is under it, never after detail (TpS0QdlfHWU 00:07:44). Pablo sets a hard edge with Standard at raised Z, then blends it with clay (tbQqC6tyDBQ 00:10:27). - **Refine instead of smoothing, except on low-res blockouts.** Henning removes marks with passes of falling intensity and smooths once at "good enough" (TpS0QdlfHWU 00:22:26, 00:22:58); Pablo smooths freely during blockout (tbQqC6tyDBQ 00:02:29). Low-res S1: a Polish slider; skin from S2 on: the descent. - **DynaMesh while the forms move, levels once they settle.** A high DynaMesh early is a "lumpy mess" (Pablo tbQqC6tyDBQ 00:04:38) and resolution is relative to object size (FrqUnna1jns 00:13:40). Never Divide a DynaMesh: its triangles and star poles become lumps no smoothing fixes (rArw79xEpvE 00:09:44). ZRemesher a copy, Divide, project. - **Level discipline.** Big moves at SDiv 1 (at the top a push "looks very wonky"); secondary forms blocked at SDiv 2 or 3, then refined upward level by level; never all the work at the top (Pablo rArw79xEpvE 00:12:28, 00:14:16; tbQqC6tyDBQ 00:12:26). Details never change the silhouette (tbQqC6tyDBQ 00:13:27). - **Strokeless first.** Pablo builds a whole blockout "without even actually using any of the brushes" (gitoJ7B8FmY 00:05:26). For an agent these are exact: primitives placed by number, ZSpheres by code, masks plus Deformation sliders, Mirror then Mirror And Weld. - **Mid frequency is where a sculpt is won.** FlippedNormals call it the backbone of detailing; pores added early put you "two levels behind" (G2o6fdoACIQ 00:05:46, 00:08:17). Details "won't fix a weak sculpt" (Pablo tbQqC6tyDBQ 00:12:50). ## Establish first | Input | What it changes | Default when the brief is silent | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | Subject | base route and point ladder (`STAGES[...]["points"]`): head, creature | head bust from a sphere | | Purpose | concept: stop at S3 or S4, even quads suffice (tbQqC6tyDBQ 00:11:43); film or game: retopology before micro detail (Zp07GW3rND0 00:08:22); print: scenario-zbrush-pose-print | concept | | Style | realistic: scenario-zbrush-character-creature; stylized: scenario-zbrush-stylized | neutral forms | | Forms list | `zs.Form` center lines plus widths, from the brief and the domain skill's proportions | none: ask the domain skill | | Head width in model units (`ref_width`) | every brush size | model width in the front view | | Views | front is measured [obj]; others need the live_04 convention | front | ## Workflow Every pass: `Scene.capture`, plan (`clay_pass`, `move_pass`, `crease_pass`), `preview_plan` (look at it), `run_plan`, `pass_report`, `zb_review.review`, then name the ONE weakest item and fix it. Save a versioned ZTL at every gate. 0. **Session.** `zb_launch.start()`, neutral state (P1), calibration (P2). Every pass turns perspective (`Draw:Perspective`) and LazyMouse off: its radius swallows the start of a synthesized path (Pavlovich AdkZe1yKFTU 00:03:31). GATE: document zoom 1, calibration present or the v02 prior noted. 1. **S0 base.** One of: a sphere DynaMesh at 128 (about 43k points); a ZSphere armature (`zsphere_code`) with an Adaptive Skin at Density 1 or 2 and DynaMesh Resolution 0 (Pablo), then DynaMesh 64; primitives appended and placed with Geometry Position and Size (P3). GATE: the silhouette reads front and side; points within S0. 2. **S1 primary blockout.** - Move then re-DynaMesh (`move_pass`, `remesh_due`, then `remesh_code`, P16: Draw mode, mask cleared, polypaint off so polygroups survive, proof that it remeshed). - Clay across the forms (`clay_pass`, preset `clay_block`); sockets as carve forms (`sign=-1`); TrimDynamic planes (`preset="trim"`). Symmetry on: plans cover the +X half. GATE: `stage_gate("S1")` passes; the sheet shows right proportions, every element present (G2o6fdoACIQ 00:02:50), big simple masses. 3. **S2 primary refinement.** - Resolution up: head by the square law (`next_dynamesh_resolution`) toward 400k to 800k points; creature: MergeDown the pieces (in place; MergeVisible makes a new Tool; its confirmation may block the bridge [verify], so ZTL first), DynaMesh 256. - Descent passes Z 6, 3, 2, 1, zooming in on each region (`descent`, P7); TrimDynamic on junk bumps. - Eyes (P17): eyeball spheres placed by number, each turned 3 to 7 degrees outward (Costa j5XLtLMN0P8 00:29:10); lids cut with Dam_Standard, then moved out so they wrap the ball (Henning TpS0QdlfHWU 00:18:33). Henning shapes them with perspective on (00:17:28): strokes stay orthographic, the read is `persp_snapshot_code`. - Checkpoint risky passes and dial them (P8) before any re-DynaMesh, ZRemesher or Divide: a point-count change destroys the Morph Target, and `dial_code(..., expect_points=)` refuses. GATE: clean shapes under Red Wax, no stretched faces, stroke marks falling. 4. **Leave DynaMesh** when the forms are settled and S3 needs levels, retopology is coming, or later big changes must survive detail. Henning's DynaMesh-only ladder to 648 suits a concept still: state which route and why. Levels (P11): versioned ZTL, Duplicate, ZRemesher the copy, Divide until `projection_ready`, then the toolkit's `zb_ops.project_all(checkpoint=...)`, which carries the safe order (versioned save, StoreMT and a New Layer at the top level, PA Blur lowered, a gate, `morph_repair`); never re-implement it. GATE: target points at least the sketch's, volume within 1 % [added], the sheet unchanged, eyes and mouth checked level by level (repair: scenario-zbrush-retopology-export). 5. **S3 secondary.** - On levels (P15): `level_for("secondary")` (SDiv 2 or 3), then `"refine"` one level up at a time; big fixes return to `level_for("big")` = SDiv 1; the top level only for tertiary and layers. - Symmetry off for the asymmetry pass; `pattern="two_direction"` on long masses; preset `clay_secondary`. GATE: `mirror_asymmetry` above zero, the mid band above S2, the sculpt reads at thumbnail size without detail. 6. **S4 contrast.** Dam_Standard along the creases (`crease_pass`), deeper than feels right (0PaYUUvgwYM 00:19:49); Standard at raised Z on lids, lips and brow edges (`preset="standard"`), blended with clay; `fill_between` two cuts. GATE: shape IoU against S3 of at least 0.98 [added]; crease depth varies along the line. 7. **Hand off** S5 and S6 to the domain skill with the P14 package. ## Numbers | Value | Number | Relative to | Source | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | Scripted Z, clay | block 6, refine 2, finish 2; descent 6, 3, 2, 1 | expert Z times 0.3 pressure | Henning 00:04:58; digest derivation | | Brush diameter | clay block 15 to 35 %, secondary 10 to 15 %, refine 3 to 8 %, Dam 3 to 7 %, Standard 1 to 2 %, Move design 60 to 90 %, Move local 10 to 15 % | head width in view (Draw Size is canvas pixels: `zb_sculpt` converts through ppu); clay kept within 0.5 to 1.0 of the form width | FlippedNormals frames; digest T1 | | ClayBuildup | FreeHand, Focal -56, Alpha 06 or Off; Imbed 20, Roll Dist about 6 | | Henning; Morten; Pablo frame 00:05:55 | | Dam_Standard | Dots, Focal -14, Zsub, Z 15 (33 for big cuts) | "ten below where it starts" | 0PaYUUvgwYM 00:18:42; TpS0QdlfHWU | | Move / TrimDynamic | Focal 0, Z 51 / Focal -56, Z 43 lowered, Zsub | | TpS0QdlfHWU frames | | Head point ladder | 43k sphere, 96k to 99k S1, 400k to 826k S2, 1.6M to 2.46M S3 and S4 | points | FlippedNormals frames | | Creature ladder | skin 6k, pieces 28k to 43k (DynaMesh 64 to 128), merged 256 = 367k, SDiv 5 = 3.8M | points | Pablo tbQqC6tyDBQ frames | | DynaMesh | points grow with resolution squared; limits: details safe in a 1024 cube, cap 2048 (about 24M polys) | bbox longest side; per SubTool | TpS0QdlfHWU ladder; DynaMesh doc | | Divide, ZRemesher | x4 per level; target in thousands, 5 gave 4,963 | | Pablo rArw79xEpvE 00:03:16 | | SDiv per operation | big moves 1; secondary 2 or 3; tertiary and layers top | levels of the stack | Pablo rArw79xEpvE 00:12:28, 00:14:16 | | Projection | target points at or above the sketch's (745k vs 400k); FlippedNormals about 3M | | VRisbJQAaZw 00:17:24; Zp07GW3rND0 00:01:02 | | Eye angle | 3 to 7 degrees outward each, about 5 | straight ahead | Costa j5XLtLMN0P8 00:29:10 | | Pass gates | dead strokes up to 10 %; re-DynaMesh at 1.5 x edge cv; Polish drift 2 % | | [added] | ## Quality gates - **Measured:** `pass_report` after every pass (dead strokes, volume sign, view drift, pivot shift, settings not found); `remesh_code(...)["ok"]`; points against `STAGES` and the SDiv for the operation; `stage_gate` (budget, big Move above SDiv 1, shape IoU, asymmetry, bands); `zb_audit.verdict(..., "sculpt")`; `remesh_due`, Polish drift, `projection_ready`. - **Seen:** the review sheet in MatCap Gray (front, right, back, three-quarter, top); Flat Color silhouettes and the sheet at thumbnail size; a grazing view, Red Wax for marks, a Standard material under a moving light (MatCaps bake their light) or BPR for form reading; the perspective look render for eyes; the stage checklist in [`references/critique.md`](references/critique.md). Never call a stage done without looking at its sheet. ## Common mistakes | Mistake | What it looks like | Fix | | ------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------ | | Expert Z as a script Z (ClayBuildup 20) | harsh ridges, one-stroke muscles | Z 6, calibrate, dial the pass | | Clay dragged along the form | drawn grooves, no volume | `clay_pass` hatches across | | Stroke starting off the model | strokes change nothing, the view may turn | plans clip to the canvas mask; re-capture | | LazyMouse left on | stroke starts missing, short strokes dead | `pass_spec` default `lazymouse=False` | | Smoothing marks away on skin | "semi smooth with details", volume loss | descent passes; Polish only low-res or at a lower SDiv | | ClayBuildup over finished detail | lines and pores erased | Standard for edges; clay before detail | | Dividing a DynaMesh | lumps, star poles | ZRemesher, Divide, `project_all` | | Big Move at the top level | wonky lumps | `level_for("big")`, then back up | | Re-DynaMesh with a mask on or in Move mode | nothing remeshes (the gesture only clears the mask) | `remesh_code` | | DynaMesh with polypaint on | polygroups gone after the remesh | `keep="groups"` (Colorize off), or `"polypaint"`: never both | | Morph dial after a remesh or Divide | the target is gone | dial first; `expect_points` | | Symmetry assumed after a SubTool change | the stroke lands on one side | it is per SubTool (Pablo RABWfzWNw0c 00:27:02): every pass sets it | | `Brush:Smooth` picked as the brush | nothing smooths (it only becomes the Shift brush) | Deformation Polish, lower SDiv | | Mirror And Weld with LSym on, or the bad side as source | mirrors locally or copies the bad side | LSym off; Deformation Mirror first (9a9S1OcYHx4 00:09:35) | ## Handoffs - **Receives:** from scenario-zbrush-expert a session, brief and versioned ZTL; from scenario-zbrush-retopology-export a cleaned AI or scan mesh (scenario-3d meshes arrive this way); from domain skills proportions and the forms list. - **Delivers** the P14 package (versioned ZTL, `stats()`, last sheet, forms as JSON, calibration, gate): realistic S3 or S4 forms on levels to scenario-zbrush-character-creature; shape-language work and hair to scenario-zbrush-stylized; organic bases to scenario-zbrush-hard-surface; a finished sculpt to scenario-zbrush-retopology-export. ## ZBrush 2026 notes - Perspective is `Draw:Perspective` (installed commands.xml); `Transform:Persp` does not exist (`zs.PERSP_PATHS`). PolyFrame is `Transform: Pf`. - `Brush:Dam_Standard` does not resolve [v03]; `zb_ops.select_brush("Dam_Standard")` finds DamStandard. - Smooth brushes only bind to Shift and `press_key` does not work: Zsub replaces Alt, Deformation Polish replaces Shift, Masking buttons and Mask Changed Points [verify] replace Ctrl gestures. - No undo control (`merge_undo` is a stub): Morph Target or layer per pass, versioned ZTLs per stage. - Gizmo deformers and Transpose drags are GUI-only; the Gizmo deforms softly unless Focal Shift is -100. Local Symmetry follows the Gizmo: off before Mirror And Weld. - Sculptris Pro: no levels; Dots, FreeHand and Spray only. Chisel's default stroke changed in 2025.1: every pass sets its stroke type. ## References - [`references/procedures.md`](references/procedures.md): bridge code per step (P0 to P17) with its live test. Load before writing bridge code. - [`references/expert-notes.md`](references/expert-notes.md): the experts in depth and the disagreement table. Load when choosing between approaches. - `references/critique.md`: per-stage rubric, AI-mesh judgment, stroke-plan review. Load at every gate. - [`references/gui-paths.md`](references/gui-paths.md): hotkeys, palette paths and brush settings (computer-use agent). - [`references/sources.md`](references/sources.md): sources, credentials, URLs, timestamps. - `scripts/zb_sculpt.py`: stages, presets, Scene, generators, calibration, pass execution, remesh, levels, review metrics. Tests: `tests/code/zbrush-sculpting/` (offline passed; live not yet run in ZBrush).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.