Claude Skill

scenario-zbrush-stylized

Use when sculpting a stylized or cartoon character, bust, collectible or toy in ZBrush (Overwatch, Fortnite, Disney look), judging shape language (S and C curves, parallel lines, 70/30, silhouette read at a distance), exaggerating proportions on purpose, keeping stylized planes c

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

Full trust report

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

Install

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

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

Skill manifest

ZBrush stylized characters and collectibles

Expert stylized work is anatomy simplified on purpose: real landmarks and proportions as the baseline, a few named exaggerations, and shape-language rules an agent can measure on a silhouette. Curves are the agent's best tool: strands, clumps, straps and scarf tails come from computed 3D paths, so a "tweak" regenerates a path instead of sculpting it. 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)

  • Shane Olson: stylizing is not "an excuse to do bad anatomy". Block a stylized skeleton by the bony landmarks that touch the surface (hip point, knee, both ankle bones, elbow, clavicle tops, base-of-neck vertebra, sacral triangle, skull) and drop bones that never show. Every departure from real proportion is a named knob measured against the real value (the knee halves the leg, hands land at mid-thigh); move knobs carefully, because motion exposes them.
  • Guillaume Tiberghien (Keos Masons): "simple is hard". Add anatomy, then simplify. Stylized means clear directional planes chipped like wood or rock, changes of direction creased so they stay straight when subdivided, and forms tucked into each other for small contact shadows. No color until the modeling is tight.
  • Rakan Khamash (Blizzard): shape language is a rule set, not taste. S and C curves, no parallel lines, no 50/50 splits (push 70/30), flare at the top and taper at the bottom, rest areas next to detail, and "say it or don't": the character must read from the back of the room. The blockout is 90 to 95% of the low poly.
  • Cedric Seaut (Keos Masons): start from silhouettes; a lineup of several designs forces distinct shapes.
  • Pablo Munoz Gomez: plan hair chunks before sculpting, then carve one long stroke per clump from one angle. DynaMesh a little above the picker's value, Polish 2 and ClayPolish get you "80% there".
  • Dan Eder: hair chunks are rig sections. Lay one tube strand per clump on a temporary mass, one polygroup per strand, front to back. Never Smooth a strand. Build a parted style without symmetry, then mirror it. Switch MatCaps to find gaps.
  • Paul Bennett (Hasbro): a toy is a product. Sculpt the neutral standing design with no joints first; articulation is spheres and discs camouflaged by the costume; walls never below 1 mm; fuse flyaways; no closed loops such as touching fingers; state the file scale (104% for PVC shrink).

Establish first

Input Changes Default when the brief is silent
Purpose game (loops, budget, rigged hair), collectible or toy (walls, parts, scale), concept or still portfolio still
Reference concept image, turnaround, style (Overwatch chunky, Disney round) none: real baseline plus only the named exaggerations
Hair strands (rigged or per-chunk retopology) or one sculpted mesh sculpted mesh (Pablo)
Budget triangles per hero and per hair version none for stills; Blizzard's hero was 16k [unit ?]
Scale print height or toy line (6 in, 3.75 in) a 200 mm ruler SubTool anyway (Shane)
Symmetry symmetric design or not symmetric, then a deliberate asymmetry pass

Workflow

  1. Design sheet. Write each knob as a number against its real value (a big nose: nose_third, brow to nose base over a third of the face, real 1.0; a heavy brow: brow depth; long shins: femur_tibia). Take each value from the concept; without one, push the named feature until it survives the 64 to 128 px thumbnail read, and record it. Write the three reads (Rakan): the silhouette idea, the secondary shapes that carry intent, the tertiary detail. Plan hair chunks and the part line. GATE: every knob has a target and a reason.
  2. Silhouette first. Block two or three primitive variants (scenario-zbrush-sculpting) and render them with zb_review.review. GATE: lineup() IoU under 0.85 between variants [added]; the chosen one reads at 64 to 128 px (squint()).
  3. Stylized skeleton by landmarks. Perspective off for everything orthographic (Shane: it skews masking, clipping and mirroring). Rules:
    • cranium plus jaw, split at the eye line; jaw corner under the ear at mid head depth;
    • head sides: from the top the head is not parallel-sided, so clip the cranium sides until the head narrows toward the face (Shane 02:00:26), then give each side an arc, not a flat plane (02:11:58). Agent: a two-point ClipCurve stroke in the orthographic top view or Deformation Taper on the cranium SubTool (both [verify]), then a Move or clay pass for the arc;
    • eyes: eyeball spheres first, lids wrapping them, each turned a few degrees outward (scenario-zbrush-sculpting P17); the outer corner well behind the inner one (check from the top);
    • pelvis leaning forward, ribcage leaning back, femur angled in, tibia straight. GATE: head_checks and body_checks on subtool_boxes() pass, or each failure is a recorded deliberate break; head_taper() on a top render of the cranium alone; front and side ortho renders against the concept; a three-quarter perspective look render (zb_sculpt.persp_snapshot_code) for the eye sockets.
  4. Merge and plane.
    • DynaMesh the blockout at 128 to 256 (remesh_code, scenario-zbrush-sculpting); cut planes with broad hPolish or TrimDynamic strokes (forgiving, so scripted strokes work); tuck forms into each other. ClayPolish works as a global planar filter [added for forms; Pablo uses it on hair].
    • Keep the planes through subdivision (Guillaume 00:46:42 to 00:47:50): ZRemesher the merged parts for a sculptable topology, then crease the plane borders so they "remain straight" when subdivided: crease_planes (Groups By Normals, Crease PG, CreaseLvl), then Divide by level discipline (scenario-zbrush-sculpting P15). Creases are edge tags: any later DynaMesh or ZRemesher needs them again [added]. GATE: shape_language() on front, side and three-quarter silhouettes: no parallel pairs, straight fraction at most 0.25, noise at most 4 flips per 1000 px (defaults [added]); no band_breaks() break in the 45 to 55% band; no dips under MatCap Metal01 (Plouffe); after Divide the creased borders stay crisp in a grazing view.
  5. Secondary shapes and props (scarf, straps, belts).
    • Rings and bands from scenario-zbrush-sculpting, knots and buckles off-center (70/30).
    • Tails and straps as curve ribbons: lay_strands with sides=0 (Curve Flat, Pavlovich) or 4 for a strap; paths from march_strand with gravity, bent into an S.
    • Folds only where pressure or joints put them (Rakan). A scarf that needs weight gets a Dynamics cloth drape (scenario-zbrush-character-creature), then its folds restated. GATE: each tail classifies S or C in the front view (classify_polyline); no parallel tails (strand_report).
  6. Hair. Strands for rigged or game hair; the sculpted mesh for stills, prints and single meshes. Both plan paths on an OBJ export of the mass (P3 to P5).
    • Plan: roots_along the part line and hairline, directions_away from the part, plan_strands with alternating C and S bends and vary() lengths; draw it with overlay_paths on front, right and top renders and look at it (Pablo polypaints chunks; Dan paints sections on the concept).
    • Sculpted (Pablo, P4): scalp_shell; fat CurveTubes per chunk, DynaMesh a few hundred to fuse; one stroke_from_view ClayBuildup stroke per clump from its best_view (sculptris=True); Dam_Standard Zsub along each crevice_path; rebalance; finish_hair.
    • Strands (Dan, P5): a tiny dummy SubTool inside the head (Pablo); one CurveTube per strand, front to back, more lift in front; Auto Groups; strand_mesh_report; mirror a parted style after. GATE: the plan shows no parallel neighbors and designed kinds read in the right and top views; hair reads at a squint; the five review views (top shows the parting) under two MatCaps show no gaps or clipping; rest areas stay calm.
  7. Asymmetry, value, color handoff.
    • Deliberate asymmetry pass: knot, fringe, a brow.
    • Guillaume's grayscale read (value_check): no pure black or white, strongest contrast at the face; before color, only darker lashes and pupils.
    • A material ID plan with distinct colors per material; polypaint at the top level, judged on the flat SkinShade4, since MatCaps lie about hue and value (Pablo VRisbJQAaZw 00:23:40, 00:24:54).
  8. Product constraints (toy or collectible).
    • toy_scale (height / 12 plus 4% for 6 in; / 18 for 3.75 in);
    • feature_check of strands and thin forms at print scale (1 mm walls, 0.5 mm trimmed edges);
    • hair a separate part keyed to the head, flyaways fused, strands bunched for repeatable paint; no closed loops, slits in skirts. Do not over-sculpt past what the print size shows (Carratala, 90 mm).

Numbers

Value Relative to Source
knee at half the leg; hands at mid-thigh; jaw corner at 0.5 of head depth real baseline for knobs Shane
eye line 0.5 of head height; face thirds equal within about 5% of head height real head Anatomy For Sculptors (character digest)
outer canthus behind inner by at least 0.10 of head depth starting threshold stylized digest [added]
head sides from the top: taper (narrow / wide width) at most 0.95; side sag at least 0.01 of depth head_taper defaults Shane rule, thresholds [added]
70/30, never 50/50; flag 0.45 to 0.55 break position along a span Rakan; band [added]
blockout = 90 to 95% of the low poly; 16k hero budget incl. two hairstyles game character Rakan
hair DynaMesh: 128 shell; 440 blocking; 1416 refined; 1032 to 1784 detailing; re-DynaMesh at 2.4M to 2.6M points Pablo's head, his machine Pablo frames
Deformation Polish 2; ClayPolish Max 25, Min 0, Sharp 0, Soft 0, RSharp and RSoft about 5 finishing Pablo frame
Curve Tube Brush Modifier 20 default, 4 square, 0 flat; Z Intensity 34 flattens tube profile IMM doc, Pavlovich
strand about 3 to 4 polygons across game strand Dan frame
1 mm wall (0.85 flagged), 0.5 mm edge, 0.12 mm fit offset, elbow 115 degrees toy production Bennett

Quality gates

  • In code: head_checks and body_checks pass or are deliberate; head_taper on the top view; split_check on the brow line, scarf band and part; shape_language flags empty on three silhouettes; no 50/50 band_breaks; lineup IoU; strand_report without parallel pairs and with designed kinds per view; strand_mesh_report with one shell per strand and faces within budget; points after every DynaMesh; feature_check in mm for toys.
  • Visual: zb_review.review under MatCap Gray plus Metal01 (dips) or another MatCap (hair gaps, Dan); squint at 64 and 128 px; annotate_shape; the plan overlay before hair is laid; the perspective look for appeal; top view for the parting and head sides; the back of the head finished too.

Common mistakes

Mistake Looks like Fix
Uniform exaggeration every feature big, nothing reads one or two named knobs; the rest at baseline
Eye corners on one plane flat mask face from the top pull the outer canthus back (canthus_recess)
Parallel head sides, or a flat side plane boxy or balloon cranium from the top clip the sides, keep an arc (head_taper)
Planes lost after Divide mushy, rounded stylized planes crease_planes after ZRemesher, before Divide
Parallel limb or strap edges tube legs, boring pants flare the top, taper the bottom
50/50 splits scarf or belt exactly mid-span move to 70/30 (split_check)
Detail everywhere noisy contour, no rest keep calm zones (Pablo, Rakan)
Hair as one lump or thousands of strands no chunks at a squint plan the big chunks first, then one strand or clump each
Strands pushed and smoothed by strokes soft, kinked strands regenerate the path and re-lay; Dan never smooths strands
ClayPolish mask left on the next brush "does nothing" finish_hair clears the mask
Toy with flyaways or touching fingers breaks, won't release from the mold fuse, separate, check at 1 mm

Handoffs

  • Receives: primitive and DynaMesh forms from scenario-zbrush-sculpting (versioned ZTL, stats(), review sheet); cloth drapes and realistic anatomy from scenario-zbrush-character-creature.
  • Delivers: to scenario-zbrush-paint-render the tight sculpt, value targets and material ID plan; to scenario-zbrush-pose-print the neutral design without joints, a part and joint plan (spheres and discs), hair as a keyed part, the scale factor and file percentage; to scenario-zbrush-retopology-export strand hair with one group per strand (rig chains), chunk polygroups, faces per strand, loop-aligned design lines (Rakan).

ZBrush 2026 notes

  • Curve Flat and Curve Flat Snap ship (2021.6.3), Bend is split into Bend Start and End, IMM brushes take any stroke (2024), and curve brush geometry bugs were fixed in 2026.2.1.
  • Pablo's hair brushes and DE_HairTubes are third party: stand-ins are ClayBuildup, Standard, Dam_Standard and CurveTube; custom brushes load from the Asset Directory LightBox/Brushes.
  • The curve API takes tool-space points and cannot read curves back; curves_to_ui alone may not build the mesh (Maxon's example): live_01 measures the apply and the axis mapping.
  • Perspective is Draw:Perspective (installed commands.xml, no Transform:Persp); PATHS tries the installed ids first. The Gizmo is the default and Local Symmetry follows it. Cmd+W quits ZBrush on macOS.

References

Files (skills)
  • references
    • critique.md 15 KB
      # Self-critique rubric: stylized characters, hair, collectibles
      
      The agent judges its own output with this rubric at every gate. Each line gives what experts look at, how the agent measures it, and the pass condition. Code thresholds tagged [added] are this skill's defaults: calibrate them on reference silhouettes before trusting a borderline result. A render always gets looked at; a number never replaces the look.
      
      ## 1. Design intent (before geometry)
      
      | Look at                                                                                                   | Measure                                  | Pass                                                                              |
      | --------------------------------------------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------- |
      | The three reads: silhouette idea, secondary intent shapes, tertiary detail (Rakan [FRZtVXpAokc 00:08:53]) | written in the design sheet              | each read is one sentence the renders can be checked against                      |
      | Knobs (Shane [t_gg7MIGSDM 00:25:08])                                                                      | targets in `head_checks` / `body_checks` | every break has a target and a reason; everything else stays at the real baseline |
      | Story fit (Guillaume [UthCuDB1IEQ 00:42:30])                                                              | list of costume details                  | no detail contradicts the character                                               |
      
      ## 2. Proportions and landmarks
      
      | Look at                                                                                             | Measure                                                 | Pass                                                                                                |
      | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
      | Cranium and jaw split at the eye line (Shane [02:04:29])                                            | `split_at_eye_line`                                     | within 0.05 of head height [added]                                                                  |
      | Jaw corner under the ear, mid depth (Shane [02:05:53])                                              | `jaw_corner_depth`, `jaw_under_ear`                     | within 0.10 of head depth [added]                                                                   |
      | Eyes on a curve from the top (Shane [02:06:27])                                                     | `canthus_recess`                                        | at least 0.10 of head depth [added]; the top render shows it                                        |
      | Head sides taper toward the face, each side an arc (Shane [02:00:26], [02:11:58])                   | `head_taper` on a solo top render of the cranium        | taper at most 0.95 and sag at least 0.01 of depth on both sides [added]; the narrow end is the face |
      | Eyes wrapped by lids, turned outward (Henning TpS0QdlfHWU [00:18:33]; Costa j5XLtLMN0P8 [00:29:10]) | perspective look render (scenario-zbrush-sculpting P17) | lids sit on an eyeball; the pupils diverge slightly, never a cross-eyed stare                       |
      | Face thirds, eye line (Anatomy For Sculptors)                                                       | `face_thirds`, `eye_line`                               | within 0.05 of head height, or a recorded break                                                     |
      | Leg split, hand drop, arm split (Shane)                                                             | `body_checks`                                           | at baseline or at the design target                                                                 |
      | Posture of the masses (Shane [00:59:24])                                                            | side render                                             | pelvis forward, ribcage back, femur angled, tibia straight                                          |
      | Landmarks read through the surface (Shane [01:10:33])                                               | three-quarter render                                    | hip point, knee, ankles, elbow, clavicles, neck vertebra visible as forms                           |
      
      ## 3. Silhouette and shape language
      
      | Look at                                                 | Measure                                                  | Pass                                                                         |
      | ------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
      | Reads from the back of the room (Rakan [00:05:00])      | `squint` at 64 and 128 px                                | the character idea and the big shapes survive                                |
      | Distinct from siblings (Cedric [00:08:31])              | `lineup` IoU                                             | under 0.85 per pair [added]                                                  |
      | S and C curves where the eye rests (Rakan [00:07:14])   | `shape_language`: `c_runs`, `s_pairs`, `curved_fraction` | curves dominate the contour; `annotate_shape` shows them where the eye lands |
      | No long straight runs (Rakan [00:06:07])                | `straight_fraction`                                      | at most 0.25, bust base ignored with `ignore_bottom_frac` [added]            |
      | No parallel lines (Rakan [00:06:07])                    | `parallel_pairs`                                         | none (within 5 degrees, facing, half overlap [added])                        |
      | No noisy contour (Rakan [00:06:07])                     | `noise_per_1000px`                                       | at most 4 [added]                                                            |
      | 70/30, not 50/50 (Rakan [00:16:37])                     | `band_breaks`, `split_check`                             | no main break in 0.45 to 0.55 [added band]                                   |
      | Flare top, taper bottom (Rakan [00:07:46])              | front render of limbs, straps, tails                     | widths differ end to end; no tube shapes                                     |
      | Rest versus detail (Rakan [00:06:40], Pablo [00:30:32]) | review sheet                                             | calm zones exist next to busy ones                                           |
      
      ## 4. Forms and planes
      
      | Look at                                                                    | Measure                                                            | Pass                                                   |
      | -------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------ |
      | Clear directional planes, creased turns (Guillaume [00:45:33], [00:46:42]) | MatCap Gray sheet, raking three-quarter                            | planes read as planes; turns are crisp, not mushy      |
      | Contact shadows where forms tuck (Guillaume [00:47:50])                    | close-up render                                                    | overlaps visible at brow, nose, ears, scarf            |
      | Surface dips (Plouffe [01:25:05])                                          | MatCap Metal01 sheet                                               | no wobbles on planes                                   |
      | Planes survive subdivision (Guillaume [00:47:16]-[00:47:50])               | after `crease_planes` and Divide: Metal01 sheet and a grazing view | plane borders stay straight and crisp, not rounded off |
      | Odd angles (Guillaume [00:44:57])                                          | top, bottom, back views                                            | relationships between forms still hold                 |
      
      ## 5. Hair
      
      | Look at                                                             | Measure                                           | Pass                                                                                                 |
      | ------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
      | Blockout reads as the hairstyle (Pablo [00:19:27])                  | squint of the fused blockout                      | flow and main chunks identifiable before any detail                                                  |
      | Plan before sculpting (Pablo [00:17:46], Dan [frame 00:01:26])      | `overlay_paths` on front, right, top              | the back is planned; the part and hairline read                                                      |
      | Clump flow (Pablo [00:26:10])                                       | `strand_report` kinds per view                    | designed C or S kinds read in the right and top views; no `noisy` clumps (kinks)                     |
      | No parallel clumps [added application of Rakan]                     | `strand_report` parallel pairs                    | none                                                                                                 |
      | Stroke evidence                                                     | `stroke_from_view` volumes                        | clump strokes add volume, crevice strokes remove it (70 percent or more)                             |
      | Crevices deep enough to read as strands (Pablo [00:31:05])          | three-quarter render                              | readable separation, not scratches                                                                   |
      | Stepped, crisp edges after ClayPolish (Pablo [00:35:02])            | close-up render                                   | clean planes, no lumps                                                                               |
      | Attachment at the hairline and scalp (Pablo [00:28:56], [00:32:42]) | front and side close-ups                          | strands grow from the scalp, no floating shell edge                                                  |
      | Strands round, clean, sharp (Dan [00:13:29])                        | strand close-up                                   | no smoothed or collapsed strands                                                                     |
      | Fullness and clipping (Dan [00:26:57])                              | two MatCaps, the five review views (top included) | no unintended gaps, little clipping                                                                  |
      | Budget                                                              | `strand_mesh_report`                              | one shell per strand (+ at most one dummy), faces per strand within the game budget, no thin strands |
      | Parting from the top (Dan frames)                                   | top render                                        | clean part, strands radiating                                                                        |
      
      ## 6. Props and accessories (scarf, straps)
      
      | Look at                                                     | Measure                                                  | Pass                                                                                             |
      | ----------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
      | Tails as S or C, not twins                                  | `classify_polyline` on projected pixels, `strand_report` | S or C; no parallel pair                                                                         |
      | Knot and band placement                                     | `split_check`                                            | off 50/50                                                                                        |
      | Folds with a reason (Rakan [00:20:28])                      | render                                                   | folds start at pressure points or joints, mostly V shapes                                        |
      | Weight where the brief wants it (stylized digest P1 step 7) | render                                                   | a scarf or cape that should hang does (Dynamics cloth drape, scenario-zbrush-character-creature) |
      
      ## 7. Value and color handoff
      
      | Look at                                                           | Measure              | Pass                                                                     |
      | ----------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------ |
      | Grayscale read (Guillaume [00:59:20])                             | `value_check`        | no pure black or white pixels, highest-contrast cell inside the face box |
      | Color held back (Guillaume [00:48:59])                            | render               | only lashes and pupils darker before the paint stage                     |
      | Material IDs (Guillaume [00:54:14])                               | ID list              | one clearly different color per material                                 |
      | Colors judged honestly (Pablo VRisbJQAaZw [00:23:40], [00:24:54]) | render on SkinShade4 | polypaint at the top level, read on the flat material, not a MatCap      |
      
      ## 8. Toy and collectible readiness
      
      | Look at                                                                     | Measure                             | Pass                                                    |
      | --------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------- |
      | Neutral standing design without joints (Bennett [00:19:34])                 | render                              | approved before engineering                             |
      | Thin features at print scale (Bennett [00:10:57], [00:48:28])               | `feature_check`                     | walls 1 mm or more, trimmed edges 0.5 mm or more        |
      | Flyaways, closed loops, skirts (Bennett [00:20:41], [00:19:34], [00:18:30]) | close-ups                           | flyaways fused, no touching fingers, slits planned      |
      | Scale stated (Bennett [00:17:51])                                           | handoff note                        | design mm, file mm and the file percentage written down |
      | Detail at real size (Bennett [00:09:47], Carratala)                         | render at about real size on screen | nothing sculpted that the size cannot show              |
      
      ## Honesty checks before reporting
      
      - Which numbers came from code, which from looking at renders, and which steps were not verified in ZBrush.
      - Every deliberate break listed with its baseline.
      - Any gate passed only by loosening a threshold is reported as such.
      
    • expert-notes.md 19.8 KB
      # Expert notes: stylized characters, stylized hair, toys and collectibles
      
      Principles and judgment by expert, each with its source and timestamp. Paraphrased from the project notes (`notes/stylized/`, `notes/hair-fibers/VJ2nMJRtIwQ`, `notes/character/FRZtVXpAokc` and `_digest_character_creature_studio.md`, `notes/3d-print/P08kuTIRziE`). "[added]" marks this skill's own additions. Brush settings quoted from video frames are readouts, not narration.
      
      ## Shane Olson: stylized anatomy from a skeleton of primitives (ZBrushLIVE, 4R8, t_gg7MIGSDM)
      
      Credential: stylized character sculptor and teacher (3D Character Workshop); says he worked at Disney Interactive on Disney Infinity [00:04:54], unverified beyond the stream.
      
      - Stylizing is not "an excuse to do bad anatomy" [01:11:09]. He models from a strong stylized reference and exaggerates on purpose.
      - Keep the bony landmarks that touch the surface: the hip bone point ("the thing I care about is this dot" [01:10:33]), the knee diamond, both ankle bones, the elbow, the clavicle tops, the base-of-neck vertebra, the sacral triangle and the skull [00:15:02], [01:50:21]. Bones that never show are not modeled [01:14:54].
      - The proportion knobs have a real baseline:
        - the knee halves the leg [00:25:08];
        - real hands land at mid-thigh [00:26:01];
        - the forearm against the upper arm, huge calves, four or five digits, two or three phalanges [00:26:01], [00:30:08], [00:45:11], [02:47:44].
      - "Be careful" moving knobs: motion reveals them [00:25:43]. Tron-style proportions look odd standing and great moving [00:29:02].
      - Posture of the masses: the pelvis leans forward and the ribcage leans back [00:59:24]; the femur angles toward the knee and the tibia stays straight [00:21:39].
      - Head block [02:00:26]-[02:11:58]:
        - cranium and jaw split at the eye line;
        - the jaw corner ends under the ear, around mid depth, and starts the cheekbone and socket line;
        - from the top, the outer eye corner sits well behind the inner;
        - the side of the head is an arc; clip the sides so the head tapers. From the top, "a lot of people think it's just straight like this but it's actually like this": he clips the cranium sides in the top view ("that's probably too much"), and later pulls the cranium out at the back because "the side of your head isn't a perfect plane either" [02:00:26]-[02:00:59], [02:11:58]. Agent form: `head_taper` on a top render (taper and side arc) [added metric].
      - Finger cross-section is a character decision: square (Wreck-It Ralph), round (Joy), flat inside and round on top (Stitch) [02:22:56]. Build hands from the fingertips inward [02:43:22].
      - Perspective off almost always. ZBrush perspective is a skew, and it breaks side-view masking, clipping and mirroring [01:41:03]-[01:43:25]. Turn it on for three-quarter screenshots and posing; the old default of about 60 matches no lens [01:41:39].
      - A 200 mm ruler SubTool in every file gives the front, the cross-app scale and the print box [01:50:21].
      - Blockout reuse: a fresh blockout per character, reuse only within a line, shared hands and face layouts because rigs are shared [01:05:39].
      - Mirror And Weld with the center line on the wrong side of the axis gives a tripled center line; move the model first, with local symmetry off [01:54:40].
      - Volume-keeping smooth: start a Shift stroke, then release Shift while dragging [01:08:43]. The agent cannot hold Shift: use Deformation Polish [added].
      
      ## Keos Masons: Cedric Seaut, Guillaume Tiberghien, Marco Plouffe (ZBrush Summit 2019, UthCuDB1IEQ)
      
      Credentials: Keos Masons, outsourcing studio for character modeling and design. Guillaume did Fortnite at Epic for more than a year; Marco did Sideshow collectibles.
      
      - **Cedric:**
        - Silhouettes first, whatever the project [00:08:31].
        - Lineups of several characters focus on content, not details, and build a coherent universe [00:10:46].
        - Lineup themes: size (big, thin, small), roles (scout, transporter, chief), constraints (fly, swim, run, dig) [00:12:28].
        - Setup [00:19:41]: low DynaMesh spheres, SnakeHook, Solo mode, arrow keys between SubTools.
        - Color changes personality, so do not stay gray too long in design exploration [00:18:36].
        - Variations in stages D1 to D7 over about a month [00:26:55].
      - **Guillaume:**
        - Start from three primitives, not a base body, to avoid an inherited anatomy [00:43:05]; production usually starts from a base.
        - Reduce each part to its simplest form, like a wooden mannequin; relationships between forms matter more than prettiness [00:43:48].
        - Clear directional planes, chipped like wood or rock [00:45:33].
        - Crease the direction changes: "the power of creases" [00:46:42]. Order on the leg: parts built from SubTools, DynaMeshed together, then ZRemesher for a topology he can sculpt on ("ZRemesher got really really good"), and "I creased my edges to make sure that when I'll subdivide those things will remain straight" [00:46:42]-[00:47:50]. Agent form: `crease_planes` after ZRemesher, before Divide (P9).
        - Forms tuck into each other for contact shadows [00:47:50].
        - No color until the modeling is tight, except darker lashes and pupils [00:48:59]. When color comes, Pablo Munoz Gomez paints at the top level and judges on a flat material (SkinShade4), because tinted MatCaps lie about hue and value (Logic Part 8, VRisbJQAaZw [00:23:40], [00:24:54]).
        - "Simple is hard": add anatomy, then scale back and simplify [00:55:57].
        - Grayscale value check, no pure black or white, the biggest contrast where attention should go, usually the face [00:58:45]-[00:59:53].
        - Material ID polypaint with very distinct colors [00:54:14].
        - A cornea mesh gives the eyes life [00:55:57].
        - Pose a mannequin first, open the negative spaces, contrapposto [00:52:21].
      - **Marco:**
        - Spend effort where the viewer looks; prints need everything [01:13:44].
        - Strip old detail back to a pure shape before polishing; hPolish with Alt; ZRemesh before the final polish [01:19:22]-[01:22:51].
        - A shiny metal MatCap finds dips; keep corner radii consistent [01:25:05].
      
      ## Rakan Khamash: shape language for game characters (Blizzard, ZBrush Summit 2023, FRZtVXpAokc)
      
      Credential: senior character artist on Overwatch 2, owner of the hero Illari, now at Epic Games.
      
      - "Say it or don't": readable from distance, "talking to the back of the room" [00:02:16], [00:05:00].
      - Rules:
        - S and C curves;
        - no parallel lines ("we hate parallel lines");
        - no noisy curves;
        - 70/30, never 50/50, "as much as you can push it";
        - flare at the top, taper at the bottom;
        - "crashing angles" [00:06:07]-[00:08:53], [00:16:37].
      - Balance detail with rest: one arm armored, the other nearly bare [00:06:40].
      - Hierarchy of reads:
        - silhouette first;
        - secondary shapes push intent (Widowmaker sharp, Doomfist chunky);
        - tertiary shapes specify it [00:08:53]-[00:10:32].
        - Intent reaches the screws: random and spiky for Junkrat, clean for Sojourn [00:03:22].
      - The blockout is 90 to 95% of the low poly [00:21:37]. Test it in animation poses and reshape pieces that clip [00:12:09].
      - Design for the rig: skins share the hero's topology and loop counts, and straps follow loops [00:11:04].
      - Floaters for anything you may change [00:13:48]; floater variants kept in an IMM brush to scroll through options [00:13:16]-[00:16:03].
      - Folds are never random: pressure and joints decide them, mostly V shapes [00:20:28]. Fold brushes are sculpted on a plane and stamped [00:21:00].
      - Polypaint the design on the approved nude body, decimate keeping polypaint, and trace the borders in the low poly [00:37:33].
      - Hair as its own stage; he regrets keeping everything in one SubTool [00:38:42]-[00:39:49].
      - Budget "I think 16k" for Illari including both hairstyles; the unit is not stated [00:42:31].
      - Agent translation [added, from the Overwatch note]:
        - thumbnails 64 to 128 px tall with IoU between siblings;
        - flag breaks between 0.45 and 0.55;
        - contour curvature analysis for straight runs.
        - `shape_language`, `band_breaks` and `lineup` implement these.
      
      ## Pablo Munoz Gomez: sculpted stylized hair (2023, WFqyj6lKgik)
      
      Credential: concept and character artist, ZBrushLIVE presenter, founder of ZBrushGuides and the Pablander Academy.
      
      - Four stages (setup, blockout, sculpting, detailing), one SubTool kept per stage for comparison and undo insurance [00:02:44]-[00:03:36].
      - The workflow matters more than the brushes. His Surface Blocking, Standard Blocking and Standard Refiner can be replaced by ClayBuildup, Standard with more intensity, and Dam_Standard [00:01:43].
      - Scalp shell [00:03:36]-[00:12:59]:
        - duplicate the head, perspective off;
        - lasso the scalp and Del Hidden;
        - Dynamic Subdiv Dynamic on, SmoothSubdiv 0, Thickness up (readout near 0.18), Offset -100 (inward only), Apply;
        - DynaMesh 128, one polygroup;
        - Deformation Inflate 1, then 3 (value or three applications [?]);
        - Move with AccuCurve pushes the hairline back;
        - symmetry off for an asymmetric style;
        - Standard Refiner with Sculptris Pro cuts the part.
      - Plan chunks on the reference: squint, mark, even polypaint each chunk a saturated color [00:15:24]-[00:18:56]. "If you get this block out of these secondary shapes done correctly, then the next step is going to be so much easier" [00:20:00]. Work the back and how lines meet there [00:17:12].
      - Blocking [00:20:42]-[00:24:00]:
        - Surface Blocking with Sculptris Pro, filling zones along the flow, alternating add and subtract;
        - frame readout: FreeHand, Focal Shift -12, Z Intensity 100, DynaMesh 440;
        - points went from 278k up by about 10k per stroke; the prepared blockout sits near 790k.
      - One long stroke from a good angle: "find a nice angle and do a single motion"; rotating mid-stroke kinks it [00:26:10]-[00:26:40]. Volume first, crevices last [00:26:40].
      - Standard Blocking readout: Focal Shift 14, Z Intensity 25 [00:25:41]. Standard Refiner readout: Focal Shift -49, Z Intensity 30, Zsub [00:27:11]-[00:28:41].
      - Re-DynaMesh when Sculptris Pro gets heavy, about 2.4M to 2.6M points; the readout shows 1416 [00:28:56].
      - Alt raises single strands as highlights [00:29:27]. Balance rest and detail areas, since "too many lines" is a failure [00:30:32].
      - Detailing [00:32:42]-[00:35:33]:
        - big Move nudges that keep the flow;
        - DynaMesh Resolution Picker, then "a bit higher" (readouts 1032 and 1784);
        - Deformation Polish 2;
        - ClayPolish (Max 25, Min 0, Sharp 0, Soft 0, RSharp and RSoft about 5);
        - clear the mask ClayPolish leaves, or the next brush seems dead;
        - "80% there".
      - Inserted strands [00:35:46]-[00:42:15]:
        - an appended QCube shrunk inside the hair, so curve strands land in a dummy SubTool;
        - CurveAlpha or other curve brushes;
        - a custom four-strand alpha from cylinders cut level with KnifeCurve;
        - Bend End and Lock End on, repel 0 [verify names];
        - Auto Groups, then Move Topological per strand.
      
      ## Dan Eder: stylized game hair with curve tube strands (Stylized Station 2020, VJ2nMJRtIwQ)
      
      Credential: senior 3D character artist for games, known for stylized women with long hair. The video is mostly a time-lapse; its knowledge comes from frames.
      
      - Plan the sections on the concept, each painted a flat color [frame 00:01:26]. Chunks are rig chains [00:01:05].
      - The blockout "just needs to serve a purpose" [00:01:38]:
        - spheres plus DynaMesh 128;
        - Move, Smooth, Pinch only;
        - low resolution, not pretty, clean silhouette curves from every angle.
      - DE_HairTubes (by Dylan Ekren, third party) is a curve brush, "a wire holding your shape together" [00:02:51].
        - Root and tip come out inverted on release: set the width falloff in Stroke > Curve Modifiers [00:03:23].
        - "Always check the curve step value before you start" [00:03:23]; the readout shows about 0.6 [?].
      - Strands [00:03:55]-[00:13:29]:
        - front to back;
        - one polygroup (or SubTool) per strand;
        - repetition is fine at placement, variation comes later;
        - "Use move, inflate, pinch and snake hooks ... Do not use smooth"; "keep everything round, clean and sharp".
      - Center-parted hair: no symmetry, then SubTool Master mirror, which keeps polygroups [00:26:38].
      - Clean-up [00:26:57]-[00:27:46]:
        - close unintended gaps and keep clipping low;
        - "Constantly switch up the materials";
        - ZModeler edge loops where a strand needs them;
        - strands are about 3 to 4 polygons across.
      - Braids [00:40:19]-[00:42:21]:
        - BadKing braid brush (third party);
        - split the three plaits, crease loops;
        - Frame Mesh with only Creased on;
        - tube brush on each curve, lower Brush > Depth.
      - Reviews in his frames: front, three-quarter, side, top (parting), back, through the braids.
      
      ## Michael Pavlovich: Curve Flat (2021, goZavCi515k)
      
      Credential: Director of Character, Weapon and Vehicle Art at Certain Affinity, ZBrushLIVE host.
      
      - Curve Tube's Brush Modifier is the number of sides (20 default). 4 makes a square. 0 is Curve Flat, the hair card. The strap is 4 with a lower Z Intensity (34 against 100) [00:01:31]-[00:02:50].
      - Tap off to commit (it deletes the curve), then draw the next one. Curve Res 3 adds spans; tap on a curve to update it with new settings [00:02:50]-[00:03:31].
      - Gradient polypaint along the curve [00:04:00]. Since 2021.6 the modifier value equals the division count [00:05:18].
      
      ## Paul Bennett: toy production (Hasbro Star Wars Black Series, ZBrush Summit 2019, P08kuTIRziE)
      
      Credential: digital product designer in the Hasbro sculpture department.
      
      - Every sculpt must survive injection molding: undercuts, walls, tooling and plastic choice shape it from the start [00:01:12].
        - Undercuts lock the part in steel; add draft, or slides at a cost [00:02:13].
        - Features below a size floor short-shot [00:03:21].
      - All articulation reduces to spheres and flat cylinders (discs), cut in with booleans and tested [00:07:36].
        - Fix manufacturing problems invisibly: thicken inward and take volume from hidden areas [00:16:46].
        - Camouflage joints with costume detail projected onto the disc [00:44:07], [01:12:39].
      - Sculpt the likeness first in the neutral standing pose the toy ships in, with no joints; build folds for standing [00:19:34], [00:33:06]. Decimate early and engineer on the decimated mesh [00:25:28].
      - Hair:
        - a separate part keyed to the head allows more undercut and detail;
        - flyaways are fused because of the minimum wall;
        - bunch strands so painters can repeat them identically [00:20:41]-[00:30:22].
      - Fingers touching (thumb to middle finger) make a closed loop the mold cannot release [00:19:34]. Skirts need slits [00:18:30].
      - Numbers:
        - wall 1 mm, 0.85 mm flagged [00:10:57], [00:16:46]; about 0.2 mm floats away on the Formlabs tray [00:11:31];
        - edges at least 0.5 mm [00:48:28];
        - fit offset 0.12 mm, Inflate 2 in his ZTool, so calibrate per ZTool [00:37:55], [00:56:20];
        - scale: height / 12 plus 4 percent for 6 in, / 18 for 3.75 in [00:12:54], [00:17:51], [00:57:52];
        - elbow at least 90 degrees, 115 degrees targeted [01:06:02].
      - Package fit is "the money part" [00:34:10]. Parts must assemble without thought, at 10,000 per day on one line [00:38:29].
      - "The process revealed the solution": articulation has no roadmap, only rules and tests [00:22:17].
      
      ## Alex Carratala: miniatures and collectibles (80.lv, 2023)
      
      About 90 mm collectibles. Pose first on a mannequin, no symmetry except props, and cut lines in mind. Over-sculpting beyond what 90 mm can show is the usual failure; remember the final size daily. Cut, decimate each part, scale to real size, STL.
      
      ## Anatomy For Sculptors (via the character digest)
      
      - Crown to hairline is 1/6 of head height; the rest splits into three equal thirds (hairline to brow, brow to nose base, nose base to chin), and the eyes sit at half the head height.
      - The adult hand equals chin to hairline; the middle finger is half the hand.
      - The note's own agent rule: for stylized characters, "know which ratio you are breaking, not to keep all of them". The 5 percent tolerance is [added].
      
      ## Disagreements and deciding conditions
      
      | Choice               | Option A                                                                                              | Option B                                                                        | Deciding condition                                                                                                                                                         |
      | -------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Stylized hair        | one DynaMesh sculpt carved with Sculptris Pro strokes, ClayPolish finish (Pablo)                      | separate curve-tube strands, one polygroup each, on a temporary mass (Dan)      | rigged or dynamic game hair, or per-chunk retopology: strands. Concept, print, still, single mesh: sculpt. Pablo's hybrid adds curve strands in a dummy SubTool for depth. |
      | Hair symmetry        | off, then mirror (Dan, center part)                                                                   | off for good (Pablo, asymmetric style)                                          | the design's symmetry; mirroring a parted style after building is safer than symmetric strokes                                                                             |
      | Perspective          | off almost always (Shane)                                                                             | on once the hair blockout exists (Pablo)                                        | orthographic for masking, clipping, mirroring and measuring; perspective for appeal                                                                                        |
      | Color early or late  | early in design lineups (Cedric), polypaint the blockout to read it (Rakan)                           | none until the modeling is tight (Guillaume)                                    | design exploration uses color; production modeling and anatomy approval stay gray                                                                                          |
      | Base mesh or scratch | base body in production (Guillaume concedes it [00:43:05]; Marco starts from a human base [01:06:58]) | primitives from scratch (Guillaume, Shane per new character)                    | production with shared rigs: base; new design or practice: scratch, reuse only within a line                                                                               |
      | Joints in the design | none until the design is approved (Bennett)                                                           | articulation planned from the start as spheres and discs (Bennett, engineering) | design stage first, engineering on the decimated approved sculpt; the design must leave room for discs                                                                     |
      | Straight lines       | never parallel, flare or taper them (Rakan)                                                           | hard-surface panels and bust cuts are straight by nature                        | measure with `shape_language(ignore_bottom_frac=...)`; accept straight runs that are cuts or hard parts, never twins                                                       |
      | Head sides           | clipped flat so the head tapers (Shane [02:00:26])                                                    | an arc, not a perfect plane (Shane [02:11:58])                                  | both, in that order: clip for the taper, then pull an arc into each side (`head_taper` checks both)                                                                        |
      | Plane breaks         | chipped with brushes on the DynaMesh (Guillaume [00:45:33])                                           | creased edges so subdivision keeps them (Guillaume [00:47:16])                  | both: chip on the DynaMesh, crease after ZRemesher, before Divide                                                                                                          |
      
    • gui-paths.md 20 KB
      # GUI paths, brushes, settings and hotkeys (for a computer-use agent)
      
      The same procedures as `procedures.md`, as a human does them in the ZBrush 2026 interface. Paths are UI menus (Palette > Sub-palette > Item). Hotkeys are the ones the experts use; on macOS "Ctrl" in the docs usually means Command, but Control+W groups and Cmd+W quits ZBrush (2023.0.1 and later): never press Cmd+W. Items marked [verify] were not checked in the 2026.2.1 UI. Settings quoted from video frames are the experts' readouts.
      
      ## Views and review
      
      | Action                                | GUI                                                                                                                                                    | Source             |
      | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
      | Perspective on or off                 | Draw > Persp, hotkey P (`Draw:Perspective` in commands.xml; there is no `Transform:Persp`); Draw > Angle Of View (Shane keeps it low, "20 ... 29" [?]) | Shane [01:41:03]   |
      | Snap to a side view                   | rotate with Shift held                                                                                                                                 | Maxon docs         |
      | Concept overlay                       | Spotlight or a semi-transparent reference in front of the model                                                                                        | Shane [00:22:11]   |
      | Blockout material                     | SkinShade4 with PolyFrame lines off, so only group colors show                                                                                         | Shane [02:32:12]   |
      | Surface dips                          | MatCap Metal01 or Metal02                                                                                                                              | Plouffe [01:25:05] |
      | Hair gaps                             | switch between two or three MatCaps (for example MatCap Gray and a dark shiny one)                                                                     | Dan [00:26:57]     |
      | Solo a SubTool, step through SubTools | Transform > Solo, arrow keys                                                                                                                           | Cedric [00:19:41]  |
      
      ## Stylized skeleton blockout (Shane Olson)
      
      | Action                                | GUI                                                                                                                                                                          | Source                             |
      | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
      | Limb primitive                        | the "appendage" insert brush (an elongated tapered sphere) or elongated spheres, then ZRemesher                                                                              | [00:14:44]                         |
      | Gizmo to a surface normal             | Alt+click the surface                                                                                                                                                        | [00:22:43]                         |
      | Center the Gizmo in the unmasked part | Alt+click the pin icon (with symmetry off, or it centers in half the part)                                                                                                   | [01:21:09], [02:09:14]             |
      | Reset the Gizmo to world orientation  | Alt+click the circular arrow                                                                                                                                                 | [01:21:09]                         |
      | Scale two axes                        | Alt while scaling                                                                                                                                                            | [02:09:48]                         |
      | Group a part                          | mask all but the part, invert, Control+W; Control+click a group to mask all but it                                                                                           | [01:20:03]                         |
      | Preview smooth                        | D (Dynamic Subdivision)                                                                                                                                                      | [00:59:45]                         |
      | Clip the ribs or the head sides       | ClipCurve: Ctrl+Shift drag after hiding the rest; for the head, from the top view, one clip per side so the head narrows toward the face, then pull the back out into an arc | [01:03:40], [02:00:26], [02:11:58] |
      | Stretched mesh                        | divide twice, ZRemesh, Inflate back what shrank                                                                                                                              | [00:36:57]                         |
      | Mirror a part                         | floor lines on, symmetry and local symmetry off, center line on the correct side of the axis, Tool > Geometry > Mirror And Weld                                              | [01:54:40]                         |
      | Volume-keeping smooth                 | start a Shift stroke, release Shift while dragging                                                                                                                           | [01:08:43]                         |
      
      ## Stylized forms (Keos Masons)
      
      | Action                                | GUI                                                                                                                                                                                                                                 | Source                                  |
      | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
      | Lineup scene                          | sphere, Make PolyMesh3D, subdivide, low DynaMesh, symmetry on, duplicate SubTools, Solo                                                                                                                                             | Cedric [00:19:41]                       |
      | Main shaping brush                    | SnakeHook                                                                                                                                                                                                                           | Cedric [00:20:49]                       |
      | Sphere-chain limbs                    | the SnakeSphere-type insert brush with Sculptris Pro on (required); raise the Sculptris Pro detail size; Brush Modifier 100 paints along the normal, 0 in the screen plane                                                          | Cedric [00:21:57]                       |
      | Plate polish                          | Polygroup It from polypaint, Group Loops with GPolish 10, Delete Loops, Panel Loops, Polish by Features, Auto Groups, Polish, hPolish, ClayPolish                                                                                   | Cedric [00:29:47]                       |
      | Crisp lines                           | Dam_Standard with Lazy Mouse raised                                                                                                                                                                                                 | Cedric [00:33:28]                       |
      | Crease direction changes              | after ZRemesher: Tool > Polygroups > Groups By Normals (MaxAngle), then Tool > Geometry > Crease > Crease PG and CreaseLvl (`Tool:Geometry:Crease PG`, `Tool:Geometry:CreaseLvl` in commands.xml), then Divide; or ZModeler creases | Guillaume [00:46:42]-[00:47:50]         |
      | Chipped planes (Guillaume [00:45:33]) | hPolish with Alt to spare neighbors and ClipCurve (Plouffe [01:20:30]); Trim Dynamic [added]                                                                                                                                        | Keos talk                               |
      | Build a hand at the world center      | move the part to the center, sculpt with symmetry, move it back                                                                                                                                                                     | Guillaume [00:46:07], Cedric [00:33:28] |
      
      ## Sculpted stylized hair (Pablo Munoz Gomez)
      
      | Stage                        | GUI                                                                                                                                                                                                                                                                             | Source                 |
      | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
      | Duplicate the head           | Tool > SubTool > Duplicate; Solo                                                                                                                                                                                                                                                | [00:04:21]             |
      | Keep the scalp               | Ctrl+Shift Select Lasso around it; Ctrl+Shift drag in empty canvas inverts; Display Properties > Double to see inside                                                                                                                                                           | [00:04:54]             |
      | Remove the rest              | Tool > Geometry > Modify Topology > Del Hidden                                                                                                                                                                                                                                  | [00:05:26]             |
      | Thicken inward               | Tool > Geometry > Dynamic Subdiv: Dynamic on, SmoothSubdiv 0, Thickness up (readout near 0.18), Offset -100, Apply                                                                                                                                                              | [00:07:04]             |
      | Shell                        | DynaMesh 128; Control+W one group; Inflate brush (B, I, N) to close gaps; Ctrl+drag in empty canvas re-DynaMeshes                                                                                                                                                               | [00:08:40]             |
      | Clear the head               | Tool > Deformation > Inflate 1, then 3                                                                                                                                                                                                                                          | [00:09:43]             |
      | Hairline                     | Move brush with Brush > Curve > AccuCurve on, side view, Alt                                                                                                                                                                                                                    | [00:10:15]             |
      | Part                         | symmetry off; Standard Refiner (or Dam_Standard, more intensity) with Stroke > Sculptris Pro on                                                                                                                                                                                 | [00:11:57]             |
      | Volume blockout              | Move plus Ctrl MaskLasso on half the head, invert, repeat; perspective on                                                                                                                                                                                                       | [00:13:15], [00:14:20] |
      | Chunk map                    | polypaint each chunk a saturated color (Rgb on, Sculptris Pro off), then back to white                                                                                                                                                                                          | [00:17:46]             |
      | Clump blocking               | Surface Blocking (substitutes ClayBuildup, ClayTubes), Sculptris Pro on, FreeHand, Focal Shift -12, Z Intensity 100; Alt subtracts                                                                                                                                              | [00:20:42]             |
      | Clump refining               | Standard Blocking, Focal Shift 14, Z Intensity 25; one motion from one angle                                                                                                                                                                                                    | [00:25:00]             |
      | Crevices and raised strands  | Standard Refiner, Focal Shift -49, Z Intensity 30, Zsub; Alt raises single strands                                                                                                                                                                                              | [00:28:21], [00:29:27] |
      | Finish                       | Tool > Geometry > DynaMesh Resolution Picker (drag from the slider onto the densest area, go a bit higher), Tool > Deformation > Polish 2, Tool > Geometry > ClayPolish (Max 25, Min 0, Sharp 0, Soft 0, RSharp and RSoft about 5), Ctrl+drag in empty canvas to clear the mask | [00:33:52]-[00:35:02]  |
      | Extra strands                | append a QCube (Initialize), shrink it inside the hair with the Gizmo, Transparency on; CurveAlpha or another curve brush; Stroke: Bend End, Lock End, repel 0 [verify names]; click the curve to edit, Shift to smooth it, click elsewhere to commit                           | [00:36:19]-[00:40:03]  |
      | Per-strand moves             | PolyFrame on (line off), Polygroups > Auto Groups, Move Topological (B, M, T)                                                                                                                                                                                                   | [00:40:36]             |
      | Brush pack install (2026.1+) | copy into `<Asset Directory>/LightBox/Brushes`                                                                                                                                                                                                                                  | version deltas         |
      
      ## Strand hair (Dan Eder) and hair cards (Pavlovich)
      
      | Action                       | GUI                                                                                                                                                     | Source                                                            |
      | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
      | Curve brush                  | DE_HairTubes (third party, B, D, E) or the shipped Curve Tube (B, C, T [verify])                                                                        | Dan [00:02:51]                                                    |
      | Before the first strand      | Stroke > Curve > Curve Step; Curve Mode and Lock Start on (readout Curve Step about 0.6 [?])                                                            | Dan [00:03:23]                                                    |
      | Width falloff                | Stroke > Curve Modifiers > Size and its curve                                                                                                           | Dan [00:03:23]                                                    |
      | Tube sides                   | Brush > Modifiers > Brush Modifier: 20 default, 4 square, 0 flat (Curve Flat); lower Z Intensity flattens (34)                                          | Pavlovich [00:01:31]-[00:02:50]                                   |
      | Spans along                  | Brush > Modifiers > Curve Res (3), tap the curve to update                                                                                              | Pavlovich [00:03:31]                                              |
      | Commit                       | click anywhere off the curve (Dan) or tap off (Pavlovich); Stroke > Curve Functions > Delete removes a leftover curve                                   | Dan [00:12:57], Pavlovich [00:02:50], Plouffe (hard-surface note) |
      | Tweak strands                | Move, Inflate, Pinch, SnakeHook; never Smooth                                                                                                           | Dan [00:13:29]                                                    |
      | Mirror a center-parted style | Zplugin > SubTool Master > Mirror                                                                                                                       | Dan [00:26:38]                                                    |
      | Edge loops per strand        | ZModeler (B, Z, M) Insert or Delete edge loop                                                                                                           | Dan [00:27:15]                                                    |
      | Braids                       | crease plait loops (ZModeler), Stroke > Curve Functions > Frame Mesh with only Creased on, click the tube brush on a curve, lower Brush > Depth (Imbed) | Dan [00:41:11]                                                    |
      | Snap cards to the scalp      | Curve Flat Snap (Stroke > Curve > Snap on); Curve Flat floats                                                                                           | Pavlovich [00:00:32]                                              |
      
      ## Toy and collectible checks (Paul Bennett)
      
      | Action                    | GUI                                                                                                          | Source                     |
      | ------------------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------- |
      | Measure walls and offsets | Transpose line (Y toggles from the Gizmo in 2026)                                                            | [00:16:15], version deltas |
      | Height in mm              | Zplugin > 3D Print Hub > Update Size Ratio                                                                   | [00:17:17]                 |
      | Clearance offset          | duplicate the part, Tool > Deformation > Inflate (2 gave 0.12 mm in his ZTool), use it as the boolean cutter | [00:53:04]-[00:56:20]      |
      | Tiny offsets              | record the Inflate on a layer and dial the layer slider                                                      | [00:57:21]                 |
      | Parting line              | Tool > Polygroups > Group Front from the view that faces one mold half                                       | [01:24:32]                 |
      | Collisions                | PolyFrame with lines off; flip normals                                                                       | [01:09:16]                 |
      | Camouflage a disc         | ZProject brush (B, Z, P), spray twice from the arm                                                           | [00:44:07]                 |
      | Soft masks                | lasso-mask while zoomed far out                                                                              | [01:08:46]                 |
      
    • procedures.md 21.3 KB
      # scenario-zbrush-stylized procedures (bridge code)
      
      Every procedure runs on the agent side (system python3 with numpy and PIL) and reaches ZBrush through the proven bridge: `zb_stylized.call(func, ...)` for this module's ZBrush-side functions, `zb_launch.call(module, func, ...)` for the scenario-zbrush-expert toolkit. Coordinates are OBJ-export space (+Y up, +Z toward the front camera, measured on the v03 sphere). The SDK curve API takes tool space; `curve_convention.json` (written by live_01) holds the mapping.
      
      **Status of everything below: not yet run in ZBrush.** The pure functions (proportions, head taper, silhouettes, curves, planning, reports, toy numbers, DynaMesh factor, the call builder) pass offline: `python3 tests/code/zbrush-stylized/run_offline.py` (43 tests, 2026-09-24 after the round 1 refactor, results in `offline_results.json`). The ZBrush-side functions pass against a fake `zbrush.commands` only; the live tests that will prove them are named per procedure. Only one agent drives ZBrush at a time.
      
      ```python
      # common prelude for every procedure (agent side)
      import sys
      sys.path.insert(0, "<project>/skills/scenario-zbrush-stylized/scripts")
      sys.path.insert(0, "<project>/skills/scenario-zbrush-expert/scripts")
      sys.path.insert(0, "<project>/skills/scenario-zbrush-sculpting/scripts")    # zb_sculpt: remesh, looks
      import zb_stylized as zs, zb_launch as zl, zb_review, zb_audit, zb_stroke, zb_sculpt
      zl.start()                                    # ZBrush with the bridge (scenario-zbrush-expert)
      ```
      
      ## P0. Measure the curve convention once (live_01)
      
      Why: Maxon's lightning example says the tube shows only after a curve is moved "a tiny bit", and curve points live in tool space. Until live_01 has run, `lay_strands` uses the hypothesis `axes [1, 1, 1]`, `apply "nudge"`, `batch True`.
      
      ```bash
      tests/code/zbrush-stylized/run_live.sh live_01_curves # writes scripts/curve_convention.json
      ```
      
      It records: which curve and ClayPolish paths exist (`probe_paths`), whether query bboxes equal OBJ bboxes, whether `curves_to_ui` alone builds geometry, which apply (nudge, tap, `create_mesh_from_curves`) and which axis signs put the tube on the planned path, tube radius per Draw Size pixel, which end Curve Modifiers > Size thickens, Brush Modifier 4, one apply for three curves, and whether the insert masks the support mesh. Test: `tests/code/zbrush-stylized/live_01_curves.py`.
      
      ## P1. Proportion sheet and landmark checks (Shane Olson, Anatomy For Sculptors)
      
      Build the primitives with scenario-zbrush-sculpting; keep one SubTool per mass and write down which index is which role (renaming needs a text prompt the API cannot fill).
      
      ```python
      boxes = zs.call("subtool_boxes")                     # [{index, id, bbox, center, size}]
      role = {"cranium": 0, "jaw": 1, "nose": 2, "brow": 3, "ear": 4, "eye": 5}
      lm = zs.head_landmarks({r: boxes[i] for r, i in role.items()},
                             points={"hairline": (0, 0.62, 0.8),            # marker spheres or
                                     "canthus_inner": (0.12, 0.08, 0.86),   # known primitive points
                                     "canthus_outer": (0.34, 0.08, 0.70)})
      rows = zs.head_checks(lm, targets={"nose_third": 1.45})              # the brief's big nose
      print(zs.summarize(rows))            # fails, deliberate breaks vs baseline, unjudged rows
      body = zs.body_checks({"hip": ..., "knee": ..., "ankle": ..., "fingertip": ...},
                            targets={"femur_tibia": 0.85})                  # longer shins, on purpose
      print(zs.split_check("scarf band on the neck", neck_bottom_y, chin_y, scarf_mid_y))
      ```
      
      Gate: `summarize()["ok"]` or every failure turned into a deliberate target with a reason. Perspective off first: `zb_ops.resolve(zs.PATHS["persp"])` tries `Draw:Perspective` first, the installed id (commands.xml has no `Transform:Persp`). Test: `live_03_strands_and_measure.py` part B (boxes), offline `ProportionTest`.
      
      **Head sides (Shane Olson t_gg7MIGSDM 02:00:26 to 02:00:59, 02:11:58).** Seen from the top, the side of the head is not straight: clip the cranium sides so the head planes taper toward the face, then pull the back out and keep each side an arc. Measure on a top render of the cranium alone:
      
      ```python
      zl.run(f"zbc.select_subtool({role['cranium']}); zbc.set(zb_ops.resolve(['Transform:Solo']), 1); result = 1",
             modules=("zb_ops",))
      top = zb_review.review("/abs/out/head_top", views=("top",))
      zl.run("zbc.set(zb_ops.resolve(['Transform:Solo']), 0); result = 0", modules=("zb_ops",))
      ht = zs.head_taper(top["views"][0]["path"], face=FACE_SIDE)   # "top" or "bottom" of the image
      print(ht["taper"], ht["arc"], ht["flags"])
      ```
      
      A plain sphere fails the taper (sides parallel); a hard clip passes the taper and fails the arc; the goal passes both. Routes to the taper, all [verify]: Deformation Taper on the cranium SubTool (`zl.call("zb_ops", "deform", "Taper", 30, 4)`, axis bit and sign measured by live_04), or a two-point ClipCurve stroke across each side in the orthographic top view (`zb_ops.select_brush("ClipCurve")`, then `zb_stroke.play` with the two canvas points), then a Move or ClayBuildup pass (scenario-zbrush-sculpting) for the arc. `FACE_SIDE`: which image side the face is on in the top view, recorded by live_04 (zb_stroke's top view convention is [verify live_04 of scenario-zbrush-expert]). Thresholds (taper 0.95, sag 0.01 of depth) are [added]: calibrate on reference heads.
      
      **Eyes.** Eyeball spheres first, lids that wrap them, each eye turned a few degrees outward: scenario-zbrush-sculpting P17 (Henning, Costa). Then Shane's canthus rule (`canthus_recess` from the top) and a three-quarter perspective look for the sockets (P9).
      
      ## P2. Silhouette and shape-language gates (Rakan Khamash, Cedric Seaut, Guillaume Tiberghien)
      
      ```python
      rv = zb_review.review("/abs/out/stage3")              # front, right, back, 3/4, top; MatCap Gray
      front, right = rv["views"][0]["path"], rv["views"][1]["path"]
      for png in (front, right, rv["views"][3]["path"]):
          sl = zs.shape_language(png, ignore_bottom_frac=0.05, keep_samples=True)   # bust cut ignored
          print(sl["flags"], sl["straight_fraction"], sl["parallel_pairs"], sl["noise_per_1000px"])
          zs.annotate_shape(png, sl, png.replace(".png", "_shape.png"))             # look at it
      print(zs.band_breaks(front)["breaks"])                 # 50/50 flagged
      print(zs.squint(front, "/abs/out/stage3/squint")["sheet"])                    # look at it
      print(zs.lineup(["/abs/a_front.png", "/abs/b_front.png", "/abs/c_front.png"], ["A", "B", "C"]))
      print(zs.value_check(front, focus_box=[430, 180, 700, 420]))   # after polypaint: face box
      metal = zb_review.review("/abs/out/stage3_metal", matcap="MatCap Metal01")   # dips (Plouffe)
      ```
      
      Gate (defaults [added], calibrate on references): no flags, straight fraction at most 0.25, no parallel pairs, noise at most 4 per 1000 px, no main break in 0.45 to 0.55, lineup IoU under 0.85, and the agent's own look at the sheet, the annotated contour and the squint. Test: `live_03` part D; offline `SilhouetteTest`.
      
      ## P3. Scalp shell and chunk plan (Pablo Munoz Gomez, Dan Eder)
      
      ```python
      sh = zs.call("scalp_shell", 0, 0.01, 1.0, 8, 128)     # source, gap_frac, inflate step, iters, res
      shell_idx = zl.run("result = zbc.get_active_subtool_index()")
      obj = zl.call("zb_ops", "export_obj", "/abs/out/shell.obj", overwrite=True)["path"]
      S = zs.Surface.from_obj(obj)
      import numpy as np
      lo, hi = np.asarray(S.bbox[0]), np.asarray(S.bbox[1]); c, half = (lo + hi) / 2, (hi - lo) / 2
      xp = c[0] + 0.3 * half[0]                              # part at 70/30 [added application]
      part = [[xp, c[1] + .95 * half[1], c[2] + .45 * half[2]], [xp, hi[1], c[2]],
              [xp, c[1] + .95 * half[1], c[2] - .45 * half[2]]]
      paths = []
      for side, n in ((+1, 3), (-1, 5)):                     # short side, long side
          line = [[p[0] + side * .06 * half[0], p[1], p[2]] for p in part]
          roots = zs.roots_along(S, line, n)
          dirs = zs.directions_away(S, roots, part, bias=(0, -1, -0.3), bias_weight=0.25)
          plan = zs.plan_strands(S, roots, dirs, zs.vary(n, 1.1 * half[1], 0.3, seed=side + 5),
                                 kinds=(["C", "S"] * n)[:n], amounts=zs.vary(n, .12, .4, seed=side),
                                 sides=[side] * n, gravity=0.12)
          paths += [p["path"] for p in plan]
      print(zs.strand_report(paths)["parallel_pairs"])       # must be empty
      cap = zl.call("zb_review", "capture_views", "/abs/out/plan", ["front", "right", "top"],
                    "MatCap Gray", "math", 0.75, None, "plan")
      for shot in cap["views"]:
          px = zs.project_paths(paths, shot["transform_read"], zs._center(cap["bbox"]))
          zs.overlay_paths(shot["path"], px, shot["path"].replace(".png", "_plan.png"))  # look at it
      ```
      
      The face side of a closed shell is cut with a Move stroke pushing the hairline back from the side view (Pablo [00:10:15]) through `stroke_from_view` with `brush="Move"`. Gate: plan overlays read as the hairstyle, the back is planned too, no parallel neighbors. Test: `live_02_sculpted_hair.py`.
      
      ## P4. Sculpted stylized hair (Pablo Munoz Gomez)
      
      ```python
      lay = zs.call("lay_strands", paths, size=40, subtool=shell_idx, timeout=600)   # fat chunks
      assert lay["created_geometry"], lay["events"]
      zl.call("zb_ops", "dynamesh", 256, timeout=600)       # fuse (Pablo blocks at 440 on his head)
      zl.call("zb_ops", "save_ztl", "/abs/out/hair_blockout.ztl")
      M = zs.Surface.from_obj(zl.call("zb_ops", "export_obj", "/abs/out/blockout.obj", overwrite=True)["path"])
      bb = zl.call("zb_ops", "stats", 3)["bbox"]
      doc = zl.run("result = [zbc.get('Document:Width'), zbc.get('Document:Height')]")
      for p in paths:                                        # one long stroke per clump, one angle
          on = [M.project(q)[0].tolist() for q in p]
          bv = zs.best_view(on, M, bb, doc)
          r = zs.call("stroke_from_view", bv["px"], bv["transform"], brush="ClayBuildup", size=30,
                      z_intensity=40, sculptris=True)
          assert r["volume_after"] > r["volume_before"]     # evidence the stroke sculpted
      for a, b in zip(paths, paths[1:]):                     # crevices last (Pablo [00:26:40])
          bv = zs.best_view(zs.crevice_path(a, b, M), M, bb, doc)
          zs.call("stroke_from_view", bv["px"], bv["transform"], brush="Dam_Standard", size=8,
                  z_intensity=30, zsub=True)
      zs.call("rebalance", 1500000, 440)                     # Pablo re-DynaMeshes at 2.4M to 2.6M
      L = float(max(np.asarray(S.bbox[1]) - np.asarray(S.bbox[0])))
      res = zs.dynamesh_resolution(L, finest_feature=0.01 * L)   # the picker, computed
      fin = zs.call("finish_hair", res, 2, True,
                    {"max": 25, "sharp": 0, "soft": 0, "rsharp": 5, "rsoft": 5}, timeout=900)
      zb_review.review("/abs/out/hair_final")                # plus MatCap Metal01 and squint
      ```
      
      Pablo's custom brushes (Surface Blocking, Standard Blocking, Standard Refiner) do not ship; his own substitutes are ClayBuildup or ClayTubes, Standard with more intensity, and Dam_Standard [00:01:43]. Brush sizes and intensities above are starting values [added]; his frame readouts belong to his brushes. Views other than front follow zb_stroke's camera convention [verify live_04 of scenario-zbrush-expert]. Gate: the hair reads at a squint, crevices deep enough to read as strands, calm areas remain, crisp stepped edges after ClayPolish, hairline attachment. Test: `live_02_sculpted_hair.py`.
      
      ## P5. Strand hair (Dan Eder), for games and rigged hair
      
      ```python
      H = zs.Surface.from_obj(zl.call("zb_ops", "export_obj", "/abs/out/head.obj", overwrite=True)["path"])
      zl.run("zbc.press(zb_ops.resolve('duplicate')); result = zbc.get_active_subtool_index()",
             modules=("zb_ops",))                          # dummy SubTool (Pablo's hidden QCube idea)
      for _ in range(10):                                   # shrink it inside the head
          zl.call("zb_ops", "deform", "Size", -50)          # Deformation Size semantics [verify]
      dummy = zl.run("result = zbc.get_active_subtool_index()")
      lo, hi = np.asarray(H.bbox[0]), np.asarray(H.bbox[1])
      part_line = [[0.05, 0.98 * hi[1], 0.5 * hi[2]], [0.05, hi[1], 0.0], [0.05, 0.9 * hi[1], 0.6 * lo[2]]]
      roots = zs.roots_along(H, part_line, 8)
      depth = np.asarray([r[2] for r in roots])
      lift = (0.015 + 0.02 * (depth - depth.min()) / np.ptp(depth)).tolist()   # front strands on top
      plan = zs.plan_strands(H, roots, zs.directions_away(H, roots, part_line, (1, -1, 0), .3),
                             zs.vary(8, 1.0, .25, 2), kinds=["C", "S"] * 4, lift=lift)
      paths = [plan[i]["path"] for i in sorted(range(8), key=lambda i: -roots[i][2])]  # front to back
      zs.call("lay_strands", paths, size=10, sides=6, subtool=dummy, timeout=600)
      zl.call("zb_ops", "polygroups", "auto")               # one group per strand (Dan)
      rep = zs.strand_mesh_report(zl.call("zb_ops", "export_obj", "/abs/out/strands.obj",
                                          overwrite=True)["path"])
      print(rep["strands"], rep["faces_per_strand"], rep["tris_equiv"])        # budget
      zl.run("zbc.press(zb_ops.resolve('mirror_weld')); result = 1", modules=("zb_ops",))  # parted style
      ```
      
      Rules: set Curve Step before the first strand (`curve_step=`), build front to back, never Smooth; to change a strand, regenerate its path and re-lay it [added]. Taper: Curve Modifiers > Size (`taper=True`); which end is thick is measured by live_01 (`thick_end`), and point order picks the root. Dan mirrors with SubTool Master (keeps polygroups); Mirror And Weld is the scripted substitute [verify it keeps groups]. Gate: strands equals the plan count, dummy at most one shell, faces per strand within budget and no thin strands, no gaps or clipping under two MatCaps, parting clean from the top. Test: `live_03_strands_and_measure.py` part A.
      
      Braids (Dan [00:40:19]): crease loops on each plait (ZModeler: GUI only) or use polygroup borders, Stroke > Curve Functions > Frame Mesh (`zs.PATHS["frame_mesh"]`), tube brush, one tap on a curve, lower Brush > Depth Imbed. Not scripted here: every step is [verify].
      
      ## P6. Scarf tails and straps as curve ribbons (Pavlovich, Rakan)
      
      ```python
      C = zs.Surface.from_obj(chest_obj)                    # the bust's chest and scarf export
      root = zs.roots_along(C, [knot_point, knot_point], 1, lift=0.01)
      tails = zs.plan_strands(C, root * 2, [(0.2, -1, 0.3), (-0.1, -1, 0.35)], [0.8, 0.55],
                              kinds=["S", "C"], amounts=[0.15, 0.1], lift=0.01, gravity=0.3)
      print(zs.strand_report(tails)["parallel_pairs"])      # tails must not run parallel
      zs.call("lay_strands", [t["path"] for t in tails], brush="CurveTube", size=40, sides=0)
      ```
      
      Two tails of different length (0.8 and 0.55) keep the 70/30 idea [added application]. `sides=0` is Curve Flat per Pavlovich (Brush Modifier 0); `sides=4` plus lower Z Intensity is the strap. Folds come from pressure points, not noise (Rakan). Gate: each tail reads S or C in the front view (`classify_polyline` on the projected pixels). Test: covered by live_01 (sides) and live_02 (tubes); no dedicated live test yet.
      
      ## P7. Toy and collectible numbers (Paul Bennett, Alex Carratala)
      
      ```python
      t = zs.toy_scale(1780, "6in")            # {'design_mm': 148.33, 'file_mm': 154.27, 'file_scale_pct': 104.0}
      mmpu = zs.mm_per_unit(model_height_units=2.04, print_height_mm=t["file_mm"])
      print(zs.feature_check({"flyaway strand": (0.012, "wall"), "scarf tail": (0.02, "wall"),
                              "trimmed hair edge": (0.007, "edge")}, mmpu))
      ```
      
      Then hand to scenario-zbrush-pose-print: part plan (hair keyed to the head, joints as spheres and discs where anatomy puts them, costume pulled over the joint), the scale factor and the file percentage. Gate: every feature at or above 1 mm (walls) and 0.5 mm (trimmed edges), no closed loops, nothing detailed below what the print size shows. Test: offline `ToyAndDynaMeshTest`.
      
      ## P8. DynaMesh resolution from the finest feature
      
      ```python
      L = max(stats_bbox_size)                               # longest side of the SubTool
      res = zs.dynamesh_resolution(L, finest_feature=crevice_width, edges_across=4)
      print(zs.dynamesh_edge(L, res))                        # expected mean edge
      ```
      
      The factor 1.083 comes from one measured sample (v03 sphere, 128); live_03 part C measures 256. Pablo reads the picker and goes "a little bit higher" [00:33:52]. Test: offline `test_dynamesh_factor_reproduces_the_fixture`, live `live_03` part C.
      
      ## P9. Planes kept through subdivision, and the perspective look (Guillaume Tiberghien, Shane Olson)
      
      ```python
      zl.run(zb_sculpt.remesh_code(192, keep="groups"), modules=("zb_ops",))    # merge the parts (scenario-zbrush-sculpting P16)
      # ... plane strokes (hPolish, TrimDynamic) and tucks on the DynaMesh ...
      zl.call("zb_ops", "save_ztl", "/abs/out/planes.ztl")
      zl.call("zb_ops", "zremesher", 5, timeout=900)                           # sculptable topology
      cr = zs.call("crease_planes", max_angle=40, crease_lvl=2)                # groups by plane, Crease PG
      zl.call("zb_ops", "divide", 2, timeout=900)                              # creases hold on 2 levels
      metal = zb_review.review("/abs/out/planes_metal", views=("front", "threequarter"), matcap="MatCap Metal01")
      look = zl.run(zb_sculpt.persp_snapshot_code("/abs/out/look_34.png", "threequarter"),
                    modules=("zb_ops", "zb_stroke"))                           # perspective on, restored after
      ```
      
      Guillaume builds each part from SubTools, DynaMeshes them together, ZRemeshes for a topology he can sculpt on, and creases his edges "to make sure that when I'll subdivide those things will remain straight" (UthCuDB1IEQ 00:46:42 to 00:47:50). Crease tags protect edges from smoothing when the mesh is subdivided; CreaseLvl sets for how many levels they stay hard; Crease PG creases every polygroup border (Geometry doc). So the order is: DynaMesh, planes, ZRemesher, creases, Divide. A DynaMesh or ZRemesher after creasing rebuilds the topology, so crease again [added]. `max_angle` (Groups By Normals MaxAngle) decides which plane changes become borders: Pavlovich's 90 behaves like Auto Groups on a cylinder, about 48 splits caps from sides (5JM1fb7wnTM 00:02:30); pick it by looking at the PolyFrame groups [added]. ZRemesher Keep Creases uses existing creases to guide the new topology (ZRemesher doc) when a creased mesh is remeshed again. Work the divided stack by level (scenario-zbrush-sculpting P15). `zb_sculpt` is the scenario-zbrush-sculpting module (imported in the prelude); the look render is for appeal and eye sockets only, never measured. Test: `live_04_head_planes.py` (parts B and C); offline `test_crease_planes_order_on_the_base_level`.
      
      ## Function map
      
      | Need                             | Function                                                                                                                                                        | Side                         |
      | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
      | boxes per SubTool                | `subtool_boxes()`                                                                                                                                               | ZBrush                       |
      | proportions                      | `head_landmarks`, `head_checks`, `body_checks`, `summarize`, `split_check`                                                                                      | agent                        |
      | silhouettes                      | `shape_language`, `annotate_shape`, `band_breaks`, `head_taper`, `lineup`, `squint`, `value_check`                                                              | agent                        |
      | curves                           | `classify_polyline`, `strand_report`                                                                                                                            | agent                        |
      | hair plan                        | `Surface`, `roots_along`, `directions_away`, `march_strand`, `bend_path`, `plan_strands`, `vary`, `crevice_path`, `best_view`, `project_paths`, `overlay_paths` | agent                        |
      | strands in ZBrush                | `lay_strands`, `load_curve_convention`, `probe_paths`                                                                                                           | ZBrush                       |
      | clump and crevice strokes        | `stroke_from_view`                                                                                                                                              | ZBrush                       |
      | shell, finish, budget            | `scalp_shell`, `finish_hair`, `rebalance`                                                                                                                       | ZBrush                       |
      | planes through subdivision       | `crease_planes`                                                                                                                                                 | ZBrush                       |
      | perspective look, checked remesh | `zb_sculpt.persp_snapshot_code`, `zb_sculpt.remesh_code` (scenario-zbrush-sculpting)                                                                            | ZBrush code built agent side |
      | strand meshes                    | `shells`, `strand_mesh_report`, `tube_radius_profile`                                                                                                           | agent                        |
      | toys                             | `toy_scale`, `mm_per_unit`, `feature_check`, `HASBRO`                                                                                                           | agent                        |
      
    • sources.md 11.8 KB
      # Sources of scenario-zbrush-stylized
      
      Each entry: source, who, credential, where, what it is best for, best timestamps. Notes live in the project's `notes/` folder; digests are `_digest_*.md`.
      
      ## Revisions
      
      - 2026-09-24 v1: first build (kept in `skills/_versions/zbrush-stylized v1 2026-09-24/`).
      - 2026-09-24 v2, round 1 refactor from the blind grade Z1 (missing from both, errors): stylized plane breaks kept through subdivision (DynaMesh merge, ZRemesher, `crease_planes` with Groups By Normals, Crease PG and CreaseLvl, then Divide; Guillaume [00:46:42]-[00:47:50]); head sides clipped to taper and kept an arc, measured by `head_taper` on a top view (Shane [02:00:26], [02:11:58]); eyes as spheres wrapped by lids, turned outward, judged on a perspective look render (pointer to scenario-zbrush-sculpting P17 and `persp_snapshot_code`); scarf drape by Dynamics cloth as an option; polypaint at the top level judged on SkinShade4 (Pablo VRisbJQAaZw [00:23:40], [00:24:54]); perspective path fixed to `Draw:Perspective` and installed command ids tried first in `PATHS`; examples generalized beyond the adventurer bust. Tests: 43 offline (was 40), live_04 added (not yet run in ZBrush).
      
      ## Experts (video and articles)
      
      - **Shane Olson, Stylized Anatomy Episode 1** (ZBrushLIVE stream on the Maxon ZBrush channel, 2017, ZBrush 4R8). Stylized character sculptor and teacher (3D Character Workshop); says he worked at Disney Interactive on Disney Infinity, unverified. https://www.youtube.com/watch?v=t_gg7MIGSDM. Note: `notes/stylized/t_gg7MIGSDM - Shane Olson Stylized Anatomy Skeleton Blockout.md`. Best for: the stylized skeleton by landmarks, proportion knobs with a real baseline, the head block, perspective policy, the ruler SubTool. Best timestamps:
        - [00:15:02] landmarks; [00:25:08]-[00:26:35] knobs; [00:59:24] posture;
        - [01:10:33] "this dot"; [01:11:09] not bad anatomy; [01:41:03] perspective;
        - [01:50:21] 200 mm ruler; [01:54:40] Mirror And Weld; [02:00:26]-[02:00:59] head sides clipped from the top; [02:04:29]-[02:11:58] head block, side of the head an arc [02:11:58];
        - [02:22:56] finger sections.
      - **Keos Masons, Character Design and Production** (ZBrush Summit 2019). Outsourcing studio: Cedric Seaut (concept, silhouettes, lineups), Guillaume Tiberghien (stylized production, Fortnite at Epic), Marco Plouffe (realistic and hard surface, Sideshow). https://www.youtube.com/watch?v=UthCuDB1IEQ. Note: `notes/stylized/UthCuDB1IEQ - Keos Masons Character Design and Production.md`. Best for: lineups, "simple is hard", planes and creases, contact shadows, grayscale value, material IDs, the metal MatCap check. Best timestamps:
        - [00:08:31], [00:10:46], [00:12:28] lineups;
        - [00:43:05]-[00:48:24] Gretel from primitives; [00:46:42]-[00:47:50] DynaMesh, ZRemesher, creases kept through subdivision;
        - [00:55:57] simple is hard; [00:58:45] value;
        - [01:19:22]-[01:26:54] polish routine.
      - **Rakan Khamash, Overwatch Character Creation Pipeline** (Blizzard, ZBrush Summit 2023). Senior character artist on Overwatch 2, owner of the hero Illari, now at Epic Games. https://www.youtube.com/watch?v=FRZtVXpAokc. Note: `notes/character/FRZtVXpAokc - Overwatch Character Creation Pipeline.md`; also `notes/character/_digest_character_creature_studio.md` (delta 10, checklists, P4 and P6). Best for: shape language rules, readability at distance, 70/30, blockout as 90 to 95% of the low poly, rig-driven design, fold logic, the 16k budget. Best timestamps:
        - [00:02:16], [00:05:00] readability;
        - [00:06:07]-[00:08:53] curves and parallel lines;
        - [00:16:37] 70/30; [00:20:28] folds; [00:21:37] blockout; [00:42:31] budget.
      - **Pablo Munoz Gomez, Stylized Sculpted Hair** (2023). Concept and character artist, ZBrushLIVE presenter, founder of ZBrushGuides and the Pablander Academy. https://www.youtube.com/watch?v=WFqyj6lKgik. Note: `notes/stylized/WFqyj6lKgik - Pablo Munoz Gomez Stylised Sculpted Hair.md`. Best for: the scalp shell, the chunk plan, Sculptris Pro blocking, one stroke per clump, the finishing stack, curve strands in a dummy SubTool. Best timestamps:
        - [00:04:21]-[00:09:43] shell; [00:15:24]-[00:20:00] chunk plan; [00:20:42] blocking;
        - [00:26:10] single motion; [00:30:32] rest versus detail;
        - [00:33:52]-[00:35:33] finish; [00:36:19] dummy SubTool.
      - **Dan Eder, The Ultimate Guide for Creating Stylized Hair in ZBrush** (Stylized Station, 2020). Senior 3D character artist for games; the ArtStation portfolio is confirmed, the current studio is not. https://www.youtube.com/watch?v=VJ2nMJRtIwQ. Note: `notes/hair-fibers/VJ2nMJRtIwQ - Dan Eder Stylized Hair With Curve Tube Strands.md`. Best for: the curve-strand route, sections as rig chains, Curve Step, front to back, no Smooth, mirroring parted styles, braids from creased loops. Best timestamps:
        - [frame 00:01:26] section plan; [00:01:38] blockout; [00:03:23] Curve Step and falloff;
        - [00:12:57]-[00:13:29] strand rules; [00:26:38] mirror; [00:26:57] MatCaps;
        - [00:40:19]-[00:42:21] braids.
      - **Michael Pavlovich, 082 Curve Flat Brushes for Hair Cards** (2021). Director of Character, Weapon and Vehicle Art at Certain Affinity, ZBrushLIVE host. https://www.youtube.com/watch?v=goZavCi515k. Note: `notes/hair-fibers/goZavCi515k - Curve Flat Brushes for Hair Cards.md`. Best for: the Curve Tube family by Brush Modifier (20, 4, 0), commit by tapping off, Curve Res. Best timestamps: [00:01:31]-[00:02:50], [00:03:31], [00:05:18].
      - **Paul Bennett, Hasbro Star Wars Rey Skywalker Toy and More** (ZBrush Summit 2019). Digital product designer in the Hasbro sculpture department, Star Wars Black Series. https://www.youtube.com/watch?v=P08kuTIRziE. Note: `notes/3d-print/P08kuTIRziE - Hasbro Star Wars Toy Articulation.md`. Best for: toy constraints (plastics, undercuts, walls, fused flyaways, closed loops), articulation from spheres and discs, scale and shrink, fit offsets. Best timestamps:
        - [00:07:36] spheres and cylinders; [00:10:57] 1 mm; [00:12:54], [00:17:51] scale;
        - [00:19:34] neutral pose and fingers; [00:20:41] hair parts; [00:37:55] 0.12 mm;
        - [00:48:28] 0.5 mm edges; [01:06:02] 115 degrees; [01:24:32] Group Front.
      - **Alex Carratala, The Rules and Intricacies of Sculpting Miniatures in ZBrush** (80.lv interview by Arti Burton, 2023). Sculptor of about 90 mm collectibles (Kimera Models), unverified beyond the article. https://80.lv/articles/the-rules-intricacies-of-sculpting-miniatures-in-zbrush. Note: `notes/3d-print/paint-pose-render-print__80lv-miniatures-collectibles-carratala - Carratala Miniatures Rules.md`. Best for: over-sculpting for the print size.
      - **Pablo Munoz Gomez, Understanding ZBrush's Logic Part 8** (2025), https://www.youtube.com/watch?v=VRisbJQAaZw. Note: `notes/fundamentals/VRisbJQAaZw - Pablo Logic Part 8 Dynamic Subdiv Sculptris Pro Reprojection Polypaint.md`. Best for: polypaint on a flat material (SkinShade4) at the highest level [00:23:40], [00:24:54].
      - **Henning Sanden (FlippedNormals), The Only 6 Brushes You Ever Need** (2020), https://www.youtube.com/watch?v=TpS0QdlfHWU, and **Kris Costa, Realism Idealized** (ZBrush Summit 2021), https://www.youtube.com/watch?v=j5XLtLMN0P8: used through scenario-zbrush-sculpting P17 for eyes (lids wrapping an eyeball with perspective on [00:17:28]-[00:18:33]; eyes 3 to 7 degrees outward [00:29:10]).
      - **Anatomy For Sculptors free articles** (Uldis Zarins and team, authors of the Anatomy for Sculptors books). https://anatomy4sculptors.com/blog/. Note: `notes/anatomy/character-creature__anatomy-for-sculptors-articles - Landmarks and Proportions.md`, used through the character digest. Best for: the real head and hand ratios that stylized knobs are measured against.
      
      ## Digests used
      
      - `notes/stylized/_digest_stylized_hair_polypaint_visual.md`: consensus, disagreements, expert delta, candidate checklists and procedures P1 to P4 for the stylized bust and hair (the polypaint half belongs to scenario-zbrush-paint-render).
      - `notes/character/_digest_character_creature_studio.md`: P4 (stylized bust block-in and readability gates), P5 (landmark markers), P6 (game readiness), the measurable checklist (70/30 band, IoU, head ratios).
      
      ## Maxon official
      
      - ZBrush Python SDK 2026 curve API: `new_curves`, `add_new_curve`, `add_curve_point` (points in tool coordinates), `curves_to_ui` ("necessary to update the active brush"), `delete_curves`, `create_mesh_from_curves(name, action, thickness)`. Stub: `/Applications/Maxon ZBrush 2026/Documentation/python-api/api/zbrush/commands.py`. Example: `examples/modeling/ex_mod_curve_lightning.py` by Ferdinand Hoppe: CurveTube, Draw Size 10; "to see the brush applied you must move one of the created curves a tiny bit"; curves cannot be read back. Best for: `lay_strands`.
      - ZScript command reference, Curves (`CurvesCreateMesh`, `CurvesToUI`...): `sources/docs/automation__zscript-command-reference.md`, https://help.maxon.net/zbr/en-us/Content/html/user-guide/customizing-zbrush/zscripting/command-reference/command-reference.html. Best for: the action codes of `create_mesh_from_curves`.
      - Curve Strokes, Curve brushes and Curve Bridge pages (IMM docs): https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/sculpting/sculpting-brushes/insert-mesh/curve-strokes/curve-strokes.html and https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/sculpting/sculpting-brushes/curve-brushes/curve-brushes.html (local `sources/docs/hard-surface__zbrush-docs-imm-arraymesh-nanomesh-curves.md`). Best for: Curve Step, "click once on the already active curve" to re-apply, Brush Modifier 4 for a square section, the insert auto-mask on the support mesh.
      - Tool palette reference, Geometry > ClayPolish and DynaMesh (Polish mode): https://help.maxon.net/zbr/en-us/Content/html/reference-guide/tool/polymesh/geometry/geometry.html (local `sources/docs/hard-surface__zbrush-ref-tool-palette-geometry-deformation-subtool.md`). Best for: ClayPolish sliders (Max, Sharp, Soft, RSharp, RSoft, Edge, Surface).
      - Dynamic Thickness (Offset: negative values build inward): https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/modeling-basics/dynamic-subdivision/dynamic-thickness/dynamic-thickness.html. Best for: Pablo's shell settings.
      
      ## Project evidence
      
      - Installed command list `/Applications/Maxon ZBrush 2026/ZData/ZLang/zcommands/commands.xml` (read 2026-09-24): `Draw:Perspective`, `Draw:Angle Of View`, `Stroke:CurveStep`, `Stroke:Curve Mode`, `Stroke:Lock Start`, `Stroke:Frame Mesh`, `Stroke:Creased edges`, `Stroke:SculptrisPro`, `Tool:Geometry:ClayPolish` with `Max`, `Sharp`, `Soft`, `RSharp`, `RSoft`, `Tool:Geometry:Del Hidden`, `Tool:Polygroups:Groups By Normals`, `Tool:Polygroups:MaxAngle`, `Tool:Geometry:Crease PG`, `Tool:Geometry:CreaseLvl`; no `Transform:Persp`. Best for: the [xml] candidates tried first in `PATHS`. `ZData/BrushPresets/ClipCurve.ZBP` ships (head-side clips).
      - `sources/zbrush-version-deltas.md` (2026-09-24): Curve Flat (2021.6.3), Bend split, IMM all strokes (2024), curve brush fix (2026.2.1), Asset Directory brushes (2026.1), Cmd+W, Local Symmetry, ZRemesher Legacy removed.
      - `tests/code/zbrush-expert/fixtures/v03_sphere.obj` (ZBrush 2026.2.1 export, DynaMesh 128), measured with `zb_audit`: longest side 2.0401, mean edge 0.017266, hence `DYNAMESH_EDGE_FACTOR` 1.083 (one sample).
      - UI strings in `/Applications/Maxon ZBrush 2026/ZData/ZLang/english/`: labels `CurveStep`, `Lock Start`, `Frame Mesh`, `Curve Res`, `Bend Start`, `ClayPolish`, `Tool:[Geometry]:{ClayPolish}` exist (location in the UI not proven).
      - Installed brush files (`ZData/BrushPresets`): CurveTube, CurveTubeSnap, CurveFlat, CurveFlatSnap, CurveStrapSnap, CurveMultiTube, ClayBuildup, ClayTubes, DamStandard, hPolish, Inflat, Pinch, SnakeHook, Move Topological, TrimDynamic. Materials: `ZData/Materials/MatCap/MatCap Gray`, `MatCap Metal01`, `MatCap Metal02`, `Startup/SkinShade4`.
      - scenario-zbrush-expert toolkit and its README evidence (strokes, exports, the bridge): see that skill's `references/sources.md`.
      
  • scripts
    • zb_stylized.py 78.5 KB
      """
      zb_stylized: measurable shape language, stylized proportions, hair strand planning and the
      ZBrush-side hair operations of the scenario-zbrush-stylized skill.
      
      Two halves, like zb_review:
      
        agent side (system python3 with numpy and PIL; numpy does not exist inside ZBrush)
          proportions and splits ...... head_landmarks, head_checks, body_checks, summarize,
                                        split_fraction, fifty_fifty, split_check
          silhouettes ................. silhouette_mask, largest_component, outer_contour,
                                        shape_language, annotate_shape, band_breaks,
                                        head_taper, silhouette_iou, lineup, squint, value_check
          open curves (hair, straps) .. classify_polyline
          hair planning on an OBJ ..... Surface, roots_along, directions_away, march_strand,
                                        bend_path, plan_strands, crevice_path, strand_report,
                                        project_paths, best_view, overlay_paths, shells,
                                        strand_mesh_report, tube_radius_profile
          toy and print ............... toy_scale, mm_per_unit, feature_check
          DynaMesh .................... dynamesh_edge, dynamesh_resolution
          bridge ...................... call(func, *args, **kw) runs a ZBrush-side function
      
        inside ZBrush (reached through call(); zb_launch.call only imports scenario-zbrush-expert modules)
          probe_paths, subtool_boxes, lay_strands, stroke_from_view, scalp_shell, finish_hair,
          rebalance, crease_planes, load_curve_convention
      
      It builds on the scenario-zbrush-expert toolkit (zb_ops, zb_stroke, zb_review, zb_audit, zb_launch)
      and does not re-implement it. Coordinates: hair paths live in the space of an OBJ exported
      with zb_ops.export_obj (+Y up, +Z toward the front camera, measured on the v03 sphere); the
      SDK curve API takes tool space, and the mapping between the two is curve_convention.json
      (default identity, [verify] live_01).
      
      Evidence: the ZBrush half has NOT run in ZBrush yet (tests/code/zbrush-stylized/live_*).
      The agent half runs offline in tests/code/zbrush-stylized/test_zb_stylized.py. Thresholds
      tagged [added] are this module's defaults, to calibrate; expert numbers carry their source.
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import importlib
      import json
      import math
      import os
      import random
      import sys
      
      HERE = os.path.dirname(os.path.abspath(__file__))
      EXPERT_SCRIPTS = os.path.abspath(os.path.join(HERE, "..", "..", "scenario-zbrush-expert", "scripts"))
      CURVE_CONVENTION_FILE = os.path.join(HERE, "curve_convention.json")
      
      _ADDED = EXPERT_SCRIPTS not in sys.path
      if _ADDED:
          sys.path.insert(0, EXPERT_SCRIPTS)
      try:
          import zb_ops  # noqa: E402  checked palette wrappers (zbc is None outside ZBrush)
          import zb_stroke  # noqa: E402  stroke strings and model-to-canvas projection
      finally:
          if _ADDED:
              sys.path.remove(EXPERT_SCRIPTS)
      
      
      class ZBStylizedError(RuntimeError):
          pass
      
      
      def _expert(name):
          """Import a scenario-zbrush-expert module on demand (zb_audit, zb_review, zb_launch)."""
          if name in sys.modules:
              return sys.modules[name]
          added = EXPERT_SCRIPTS not in sys.path
          if added:
              sys.path.insert(0, EXPERT_SCRIPTS)
          try:
              return importlib.import_module(name)
          finally:
              if added:
                  sys.path.remove(EXPERT_SCRIPTS)
      
      
      def _np():
          import numpy as np  # agent side only
          return np
      
      
      def _jsonable(o):
          if hasattr(o, "tolist"):
              return o.tolist()
          if isinstance(o, dict):
              return {k: _jsonable(v) for k, v in o.items()}
          if isinstance(o, (list, tuple)):
              return [_jsonable(v) for v in o]
          return o
      
      
      # ============================================================================================
      # Proportions: real baseline, deliberate breaks, 70/30 splits
      # ============================================================================================
      
      # value = the real-anatomy number the ratio is compared with (None: the source gives none).
      BASELINES = {
          "eye_line": (0.5, "Anatomy For Sculptors: eyes at mid-head (character digest, delta 28); "
                            "(eye y - chin y) / head height"),
          "split_at_eye_line": (0.0, "Shane Olson: cranium and jaw split at the eye line "
                                     "[t_gg7MIGSDM 02:04:29]; (jaw top y - eye y) / head height"),
          "jaw_corner_depth": (0.5, "Shane Olson: the jaw corner ends under the ear, around the "
                                    "middle of the head front to back [02:05:53]; (jaw corner z - "
                                    "head back z) / head depth"),
          "jaw_under_ear": (0.0, "Shane Olson [02:05:53]; (jaw corner z - ear z) / head depth"),
          "canthus_recess": (0.10, "Shane Olson: seen from the top the outer eye corner sits well "
                                   "behind the inner [02:06:27]; 0.10 of head depth is the stylized "
                                   "digest's starting threshold [added]; one-sided (at least)"),
          "face_thirds": (0.0, "Anatomy For Sculptors: hairline-brow, brow-nose base, nose base-chin "
                               "equal within about 5 percent of head height (character digest; "
                               "tolerance added); largest deviation of a third from their mean"),
          "nose_third": (1.0, "Anatomy For Sculptors: the middle third runs brow to nose base; a big "
                              "stylized nose is this ratio above 1 (character digest P4) [added]"),
          "femur_tibia": (1.0, "Shane Olson: the knee halves the leg in real anatomy [00:25:08]"),
          "hand_drop": (0.0, "Shane Olson: real hands land at mid-thigh [00:26:01]; (mid-thigh y - "
                             "fingertip y) / leg length, positive = lower"),
          "humerus_forearm": (None, "Shane Olson names it a knob (his design: upper arm slightly "
                                    "shorter than the forearm [00:26:01]); no real value given"),
          "hand_face": (1.0, "Anatomy For Sculptors: adult hand length equals chin to hairline "
                             "(character digest)"),
      }
      
      # Default tolerances [added]: how far a measurement may sit from its target and still pass.
      TOLERANCES = {"eye_line": 0.05, "split_at_eye_line": 0.05, "jaw_corner_depth": 0.10,
                    "jaw_under_ear": 0.10, "face_thirds": 0.05, "nose_third": 0.10,
                    "femur_tibia": 0.10, "hand_drop": 0.05, "humerus_forearm": 0.10,
                    "hand_face": 0.10}
      ONE_SIDED = {"canthus_recess"}          # measured >= target passes
      
      FIFTY_BAND = (0.45, 0.55)  # Rakan Khamash: 70/30, never 50/50 [FRZtVXpAokc 00:16:37];
                                 # band from the character digest [threshold added]
      
      
      def _box(b):
          """(min3, max3) from a subtool_boxes() row, a 6-list (-x,-y,-z,+x,+y,+z) or a pair."""
          if isinstance(b, dict):
              b = b["bbox"]
          if len(b) == 2:
              return tuple(b[0]), tuple(b[1])
          return (b[0], b[1], b[2]), (b[3], b[4], b[5])
      
      
      def _center(b):
          lo, hi = _box(b)
          return tuple((lo[i] + hi[i]) / 2.0 for i in range(3))
      
      
      def head_landmarks(boxes, points=None):
          """Scalar landmarks (OBJ space, +Y up, +Z front) from primitive boxes and point markers.
      
          boxes: {role: bbox} for roles cranium (required), jaw, nose, brow, ear (one side), eye
            (one eyeball). Values are subtool_boxes() rows or 6-lists.
          points: optional {name: (x, y, z)}: hairline, brow, nose_base, chin, crown, jaw_corner,
            ear, eye, canthus_inner, canthus_outer. Points override values derived from boxes.
          Shane Olson's head block: cranium plus jaw split at the eye line, jaw corner under the ear
          at mid depth [t_gg7MIGSDM 02:04:29-02:05:53].
          """
          points = points or {}
          lm = {}
          clo, chi = _box(boxes["cranium"])
          lm.update(crown_y=chi[1], back_z=clo[2], front_z=chi[2], chin_y=clo[1])
          if "jaw" in boxes:
              jlo, jhi = _box(boxes["jaw"])
              lm.update(chin_y=jlo[1], split_y=jhi[1], jaw_corner_z=jlo[2], jaw_corner_y=jlo[1])
          if "nose" in boxes:
              nlo, nhi = _box(boxes["nose"])
              lm.update(nose_base_y=nlo[1], nose_root_y=nhi[1], nose_tip_z=nhi[2])
          if "brow" in boxes:
              lm["brow_y"] = _center(boxes["brow"])[1]
          if "ear" in boxes:
              c = _center(boxes["ear"])
              lm.update(ear_y=c[1], ear_z=c[2])
          if "eye" in boxes:
              lm["eye_y"] = _center(boxes["eye"])[1]
          for name in ("hairline", "brow", "nose_base", "chin", "crown", "eye"):
              if name in points:
                  lm[name + "_y"] = float(points[name][1])
          for name in ("jaw_corner", "ear"):
              if name in points:
                  lm[name + "_y"] = float(points[name][1])
                  lm[name + "_z"] = float(points[name][2])
          for name in ("canthus_inner", "canthus_outer"):
              if name in points:
                  lm[name + "_z"] = float(points[name][2])
          lm["H"] = lm["crown_y"] - lm["chin_y"]
          lm["D"] = lm["front_z"] - lm["back_z"]
          if lm["H"] <= 0 or lm["D"] <= 0:
              raise ValueError("head height or depth <= 0: check the boxes and axes (+Y up, +Z front)")
          return lm
      
      
      def _row(name, measured, targets, tol):
          base, src = BASELINES[name]
          target = targets.get(name, base)
          t = tol.get(name, TOLERANCES.get(name, 0.1))
          if target is None:
              ok = None
          elif name in ONE_SIDED:
              ok = measured >= target - 1e-12
          else:
              ok = abs(measured - target) <= t + 1e-12
          return {"name": name, "measured": round(float(measured), 4), "target": target,
                  "baseline": base, "tol": None if name in ONE_SIDED else t, "ok": ok,
                  "deliberate": base is not None and target is not None and abs(target - base) > 1e-9,
                  "source": src}
      
      
      def head_checks(lm, targets=None, tol=None):
          """Rows for every ratio the landmarks allow. targets: deliberate breaks, e.g.
          {"nose_third": 1.5} for a big nose; each break is recorded against the real baseline
          (Shane Olson: move the knobs on purpose and carefully [00:25:43])."""
          targets, tol = targets or {}, tol or {}
          H, D = lm["H"], lm["D"]
          rows = []
          if "eye_y" in lm:
              rows.append(_row("eye_line", (lm["eye_y"] - lm["chin_y"]) / H, targets, tol))
              if "split_y" in lm:
                  rows.append(_row("split_at_eye_line", (lm["split_y"] - lm["eye_y"]) / H, targets, tol))
          if "jaw_corner_z" in lm:
              rows.append(_row("jaw_corner_depth", (lm["jaw_corner_z"] - lm["back_z"]) / D, targets, tol))
              if "ear_z" in lm:
                  rows.append(_row("jaw_under_ear", (lm["jaw_corner_z"] - lm["ear_z"]) / D, targets, tol))
          if "canthus_inner_z" in lm and "canthus_outer_z" in lm:
              rows.append(_row("canthus_recess", (lm["canthus_inner_z"] - lm["canthus_outer_z"]) / D,
                               targets, tol))
          if all(k in lm for k in ("hairline_y", "brow_y", "nose_base_y")):
              thirds = [(lm["hairline_y"] - lm["brow_y"]) / H, (lm["brow_y"] - lm["nose_base_y"]) / H,
                        (lm["nose_base_y"] - lm["chin_y"]) / H]
              mean = sum(thirds) / 3.0
              r = _row("face_thirds", max(abs(t - mean) for t in thirds), targets, tol)
              r["thirds"] = [round(t, 4) for t in thirds]
              if "face_thirds" not in targets:
                  r["ok"] = r["measured"] <= r["tol"] + 1e-12
              rows.append(r)
              face = lm["hairline_y"] - lm["chin_y"]
              rows.append(_row("nose_third", (lm["brow_y"] - lm["nose_base_y"]) / (face / 3.0),
                               targets, tol))
          return rows
      
      
      def body_checks(points, targets=None, tol=None):
          """Limb ratios from point markers {hip, knee, ankle, shoulder, elbow, wrist, fingertip,
          hand_base, hairline, chin} (any subset). Shane Olson's stylization knobs: leg split, arm
          split, where the hands land [t_gg7MIGSDM 00:25:08-00:26:35]."""
          targets, tol = targets or {}, tol or {}
          P = {k: tuple(float(c) for c in v) for k, v in points.items()}
          rows = []
          if all(k in P for k in ("hip", "knee", "ankle")):
              femur, tibia = math.dist(P["hip"], P["knee"]), math.dist(P["knee"], P["ankle"])
              rows.append(_row("femur_tibia", femur / tibia, targets, tol))
              if "fingertip" in P:
                  mid = (P["hip"][1] + P["knee"][1]) / 2.0
                  rows.append(_row("hand_drop", (mid - P["fingertip"][1]) / (femur + tibia),
                                   targets, tol))
          if all(k in P for k in ("shoulder", "elbow", "wrist")):
              rows.append(_row("humerus_forearm", math.dist(P["shoulder"], P["elbow"]) /
                               math.dist(P["elbow"], P["wrist"]), targets, tol))
          if all(k in P for k in ("hand_base", "fingertip", "hairline", "chin")):
              rows.append(_row("hand_face", math.dist(P["hand_base"], P["fingertip"]) /
                               math.dist(P["hairline"], P["chin"]), targets, tol))
          return rows
      
      
      def summarize(rows):
          fails = [r["name"] for r in rows if r["ok"] is False]
          return {"ok": not fails, "fails": fails,
                  "breaks": {r["name"]: {"target": r["target"], "baseline": r["baseline"],
                                         "measured": r["measured"]} for r in rows if r["deliberate"]},
                  "unjudged": [r["name"] for r in rows if r["ok"] is None]}
      
      
      def split_fraction(a, b, at):
          """Where `at` sits between a and b (0 at a, 1 at b)."""
          if b == a:
              raise ValueError("empty span")
          return (at - a) / (b - a)
      
      
      def fifty_fifty(frac, band=FIFTY_BAND):
          """True when a break sits in the 50/50 band (Rakan's rule says push it toward 70/30)."""
          return band[0] <= frac <= band[1]
      
      
      def split_check(name, a, b, at, band=FIFTY_BAND):
          f = split_fraction(a, b, at)
          return {"name": name, "fraction": round(f, 4), "fifty_fifty": fifty_fifty(f, band),
                  "rule": "70/30, not 50/50 (Rakan Khamash, FRZtVXpAokc 00:16:37)"}
      
      
      # ============================================================================================
      # Silhouettes and contours (canvas PNG from zb_ops.export_canvas or zb_review)
      # ============================================================================================
      
      def silhouette_mask(png_path, threshold=28, edge=4, largest=True):
          """Model pixels against ZBrush's gradient background: the background model of
          zb_review.silhouette_bbox (per-row median of the outer columns), which returns only the
          bbox. largest=True keeps the biggest 4-connected blob."""
          np = _np()
          from PIL import Image
          with Image.open(png_path) as src:
              im = np.asarray(src.convert("RGB")).astype(np.int16)
          ref = np.median(np.concatenate([im[:, :edge], im[:, -edge:]], axis=1), axis=1)
          mask = np.abs(im - ref[:, None, :]).max(axis=2) > threshold
          mask[:, :edge] = False
          mask[:, -edge:] = False
          return largest_component(mask) if largest else mask
      
      
      def largest_component(mask):
          """Biggest 4-connected blob (label propagation of zb_audit._shells over pixels)."""
          np = _np()
          mask = np.asarray(mask, dtype=bool)
          ys, xs = np.nonzero(mask)
          if len(ys) == 0:
              return mask.copy()
          idx = -np.ones(mask.shape, dtype=np.int64)
          idx[ys, xs] = np.arange(len(ys))
          ry, rx = np.nonzero(mask[:, :-1] & mask[:, 1:])
          dy, dx = np.nonzero(mask[:-1, :] & mask[1:, :])
          a = np.concatenate([idx[ry, rx], idx[dy, dx]])
          b = np.concatenate([idx[ry, rx + 1], idx[dy + 1, dx]])
          lab = _expert("zb_audit")._shells(len(ys), a, b)
          vals, counts = np.unique(lab, return_counts=True)
          keep = lab == vals[int(np.argmax(counts))]
          out = np.zeros_like(mask)
          out[ys[keep], xs[keep]] = True
          return out
      
      
      _DIRS8 = ((-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1))  # x, y
      
      
      def outer_contour(mask):
          """Ordered outer boundary pixels [(x, y), ...] of the blob that holds the top-left-most
          pixel (Moore neighbour tracing, Jacob's stop). Run largest_component first."""
          np = _np()
          mask = np.asarray(mask, dtype=bool)
          hgt, wid = mask.shape
          ys, xs = np.nonzero(mask)
          if len(ys) == 0:
              return []
          y0 = int(ys.min())
          x0 = int(xs[ys == y0].min())
      
          def on(x, y):
              return 0 <= x < wid and 0 <= y < hgt and mask[y, x]
      
          start = (x0, y0)
          p, b = start, (x0 - 1, y0)
          out = [start]
          for _ in range(4 * int(mask.sum()) + 16):
              k = _DIRS8.index((b[0] - p[0], b[1] - p[1]))
              nxt = None
              for i in range(1, 9):
                  dx, dy = _DIRS8[(k + i) % 8]
                  c = (p[0] + dx, p[1] + dy)
                  if on(*c):
                      pdx, pdy = _DIRS8[(k + i - 1) % 8]
                      b, nxt = (p[0] + pdx, p[1] + pdy), c
                      break
              if nxt is None:        # isolated pixel
                  return out
              if p == start and len(out) > 1 and nxt == out[1]:
                  out.pop()          # back at the start, about to repeat the first move
                  break
              p = nxt
              out.append(p)
          return out
      
      
      def _resample(pts, spacing, closed):
          np = _np()
          P = np.asarray(pts, dtype=float)
          if closed:
              P = np.vstack([P, P[:1]])
          seg = np.linalg.norm(np.diff(P, axis=0), axis=1)
          cum = np.concatenate([[0.0], np.cumsum(seg)])
          total = float(cum[-1])
          if total <= 0:
              return P[:1], 0.0
          n = max(8, int(round(total / spacing)))
          t = np.linspace(0.0, total, n, endpoint=not closed)
          out = np.stack([np.interp(t, cum, P[:, j]) for j in range(P.shape[1])], axis=1)
          return out, total
      
      
      def _smooth(P, w, closed):
          np = _np()
          if w <= 1 or len(P) < 3:
              return P
          k = np.ones(w) / w
          if closed:
              ext = np.vstack([P[-w:], P, P[:w]])
              return np.stack([np.convolve(ext[:, j], k, mode="same")[w:-w] for j in range(P.shape[1])],
                              axis=1)
          out = P.copy()
          h = w // 2
          for i in range(h, len(P) - h):
              out[i] = P[i - h:i + h + 1].mean(axis=0)
          return out
      
      
      def _wrap_deg(a):
          return (a + 180.0) % 360.0 - 180.0
      
      
      def _turns(P, k, closed):
          """Signed turn in degrees over a 2k window at each sample (y down: + is clockwise)."""
          np = _np()
          n = len(P)
          idx = np.arange(n)
          if closed:
              f = P[(idx + k) % n] - P
              b = P - P[(idx - k) % n]
          else:
              f = P[np.minimum(idx + k, n - 1)] - P
              b = P - P[np.maximum(idx - k, 0)]
          af = np.degrees(np.arctan2(f[:, 1], f[:, 0]))
          ab = np.degrees(np.arctan2(b[:, 1], b[:, 0]))
          t = _wrap_deg(af - ab)
          if not closed:
              t[:1] = 0.0
              t[-1:] = 0.0
          return t
      
      
      def _runs(signs, closed):
          n = len(signs)
          if n == 0:
              return []
          if closed and all(s == signs[0] for s in signs):
              return [(int(signs[0]), 0, n)]
          start = 0
          if closed:
              start = next(i for i in range(n) if signs[i] != signs[i - 1])
          runs, i = [], 0
          while i < n:
              s = signs[(start + i) % n]
              j = i
              while j < n and signs[(start + j) % n] == s:
                  j += 1
              runs.append((int(s), (start + i) % n, j - i))
              i = j
          return runs
      
      
      def _run_points(P, i0, cnt):
          np = _np()
          return P[(np.arange(i0, i0 + cnt)) % len(P)]
      
      
      def _segment(P, i0, cnt, spacing):
          np = _np()
          Q = _run_points(P, i0, cnt)
          c = Q.mean(axis=0)
          _, _, vt = np.linalg.svd(Q - c, full_matrices=False)
          u = vt[0]
          s = (Q - c) @ u
          return {"length_px": round(cnt * spacing, 1), "angle_deg": round(float(np.degrees(
              math.atan2(u[1], u[0])) % 180.0), 2), "mid": c.tolist(), "dir": u.tolist(),
              "p0": (c + u * s.min()).tolist(), "p1": (c + u * s.max()).tolist(), "i0": int(i0),
              "count": int(cnt)}
      
      
      def _parallel(segs, tol_deg, min_overlap, min_gap):
          np = _np()
          out = []
          for i in range(len(segs)):
              for j in range(i + 1, len(segs)):
                  a, b = segs[i], segs[j]
                  d = abs(a["angle_deg"] - b["angle_deg"]) % 180.0
                  d = min(d, 180.0 - d)
                  if d > tol_deg:
                      continue
                  u = np.asarray(a["dir"])
                  nrm = np.array([-u[1], u[0]])
                  gap = abs(float((np.asarray(b["mid"]) - np.asarray(a["mid"])) @ nrm))
                  if gap < min_gap:
                      continue
                  pa = sorted([float(np.asarray(a["p0"]) @ u), float(np.asarray(a["p1"]) @ u)])
                  pb = sorted([float(np.asarray(b["p0"]) @ u), float(np.asarray(b["p1"]) @ u)])
                  short = max(1e-9, min(pa[1] - pa[0], pb[1] - pb[0]))
                  ov = max(0.0, min(pa[1], pb[1]) - max(pa[0], pb[0])) / short
                  if ov >= min_overlap:
                      out.append({"a": i, "b": j, "angle_diff_deg": round(d, 2),
                                  "gap_px": round(gap, 1), "overlap": round(ov, 2)})
          return out
      
      
      def shape_language(src, spacing=2.0, scale_frac=0.05, straight_deg=3.0,
                         min_straight_frac=0.06, min_curve_frac=0.03, parallel_tol_deg=5.0,
                         ignore_bottom_frac=0.0, border_px=6, keep_samples=False,
                         max_straight_fraction=0.25, max_noise_per_1000px=4.0):
          """Rakan Khamash's shape rules measured on a silhouette (FRZtVXpAokc 00:06:07-00:08:53):
          S and C curves, no long straight runs, no parallel lines, no noisy curves.
      
          src: canvas PNG path or boolean mask. The outer contour is resampled every `spacing` px
          and smoothed; each sample's turn over a window of scale_frac x bbox diagonal is straight
          (|turn| < straight_deg), convex or concave. Runs of equal class give:
            straight_runs (>= min_straight_frac of the perimeter), their parallel pairs (direction
            within parallel_tol_deg, facing each other, overlapping by half),
            c_runs (curved runs >= min_curve_frac), s_pairs (adjacent long runs of opposite
            sign), noise_flips (sign changes involving a short curved run).
          ignore_bottom_frac: skip the bottom part of the bbox (the cut base of a bust).
          All thresholds are [added] defaults: calibrate them on reference silhouettes.
          """
          np = _np()
          mask = silhouette_mask(src) if isinstance(src, str) else largest_component(src)
          hgt, wid = mask.shape
          raw = outer_contour(mask)
          if len(raw) < 16:
              raise ValueError("silhouette too small for contour analysis")
          ys, xs = np.nonzero(mask)
          bbox = [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())]
          diag = math.hypot(bbox[2] - bbox[0], bbox[3] - bbox[1])
          P, perim = _resample(raw, spacing, True)
          P = _smooth(P, 5, True)
          k = max(1, int(round(scale_frac * diag / spacing / 2.0)))
          t = _smooth(_turns(P, k, True)[:, None], 2 * k + 1, True)[:, 0]  # kills pixel staircase
          signs = [0 if abs(v) < straight_deg else (1 if v > 0 else -1) for v in t]
          y_cut = bbox[3] - ignore_bottom_frac * (bbox[3] - bbox[1])
          ignore = [(p[1] > y_cut and ignore_bottom_frac > 0) or p[0] < border_px or
                    p[1] < border_px or p[0] > wid - 1 - border_px or p[1] > hgt - 1 - border_px
                    for p in P]
          runs = _runs(signs, True)
          min_s, min_c = min_straight_frac * perim, min_curve_frac * perim
          straight, curved = [], []
          for s, i0, cnt in runs:
              pts_ign = sum(ignore[(i0 + q) % len(P)] for q in range(cnt))
              if pts_ign > cnt / 2:
                  continue
              L = cnt * spacing
              if s == 0 and L >= min_s:
                  straight.append(_segment(P, i0, cnt, spacing))
              elif s != 0:
                  curved.append((s, i0, cnt, L))
          long_c = [c for c in curved if c[3] >= min_c]
          seq = [c for c in curved]
          noise = s_pairs = 0
          for a, b in zip(seq, seq[1:] + seq[:1] if len(seq) > 1 else []):
              if a[0] != b[0]:
                  if a[3] < min_c or b[3] < min_c:
                      noise += 1
                  else:
                      s_pairs += 1
          straight_len = sum(sg["length_px"] for sg in straight)
          pairs = _parallel(straight, parallel_tol_deg, 0.5, 3 * spacing)
          rep = {"perimeter_px": round(perim, 1), "bbox": bbox, "diag_px": round(diag, 1),
                 "straight_fraction": round(straight_len / perim, 4),
                 "curved_fraction": round(sum(c[3] for c in long_c) / perim, 4),
                 "c_runs": len(long_c), "s_pairs": s_pairs, "noise_flips": noise,
                 "noise_per_1000px": round(1000.0 * noise / perim, 3),
                 "straight_runs": straight, "parallel_pairs": pairs}
          flags = []
          if rep["straight_fraction"] > max_straight_fraction:
              flags.append("long straight runs (boring): flare or taper them")
          if pairs:
              flags.append(f"{len(pairs)} parallel pair(s): break one line (flare top, taper bottom)")
          if rep["noise_per_1000px"] > max_noise_per_1000px:
              flags.append("noisy contour: simplify to fewer, cleaner curves")
          if not long_c:
              flags.append("no readable C or S curve")
          rep["flags"] = flags
          if keep_samples:
              rep["samples"] = [[round(float(p[0]), 1), round(float(p[1]), 1), int(s)]
                                for p, s in zip(P, signs)]
          return rep
      
      
      def annotate_shape(png_path, report, out_path):
          """Draw the contour classes (needs keep_samples=True): straight red, convex green,
          concave cyan; parallel pairs linked in yellow. For the agent to look at."""
          from PIL import Image, ImageDraw
          col = {0: (230, 60, 60), 1: (60, 220, 90), -1: (60, 200, 230)}
          with Image.open(png_path) as src:
              im = src.convert("RGB")
          d = ImageDraw.Draw(im)
          for x, y, s in report.get("samples", []):
              d.ellipse([x - 1.5, y - 1.5, x + 1.5, y + 1.5], fill=col[s])
          segs = report["straight_runs"]
          for pr in report["parallel_pairs"]:
              a, b = segs[pr["a"]]["mid"], segs[pr["b"]]["mid"]
              d.line([tuple(a), tuple(b)], fill=(250, 220, 40), width=3)
          os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
          im.save(out_path)
          return out_path
      
      
      def band_breaks(src, top_k=3, jump_frac=0.03, min_sep_frac=0.08, end_frac=0.06,
                      min_jump_frac=0.08, band=FIFTY_BAND):
          """Main width changes down the silhouette (chin, neck to shoulders, scarf band) and where
          they sit as a fraction of the height from the top; a break in the 50/50 band is flagged
          (Rakan's 70/30 rule [threshold added]). A break is a width jump across +-jump_frac of
          the height of at least min_jump_frac of the widest row; the top and bottom end_frac are
          skipped (every silhouette narrows at its ends). Defaults [added]."""
          np = _np()
          mask = silhouette_mask(src) if isinstance(src, str) else np.asarray(src, dtype=bool)
          rows = np.nonzero(mask.any(axis=1))[0]
          y0, y1 = int(rows.min()), int(rows.max())
          h = y1 - y0 + 1
          w = np.zeros(h)
          for i, y in enumerate(range(y0, y1 + 1)):
              xs = np.nonzero(mask[y])[0]
              w[i] = xs.max() - xs.min() + 1 if len(xs) else 0
          d = max(1, int(jump_frac * h))
          g = np.zeros(h)
          lo_i, hi_i = max(d, int(end_frac * h)), min(h - d, int((1 - end_frac) * h))
          for i in range(lo_i, hi_i):
              g[i] = abs(w[i + d] - w[i - d])
          picked = []
          for i in np.argsort(-g):
              if len(picked) >= top_k or g[i] < min_jump_frac * w.max():
                  break
              if all(abs(int(i) - j) >= min_sep_frac * h for j in picked):
                  picked.append(int(i))
          out = []
          for i in sorted(picked):
              f = i / float(h)
              out.append({"y": y0 + i, "fraction": round(f, 4), "fifty_fifty": fifty_fifty(f, band),
                          "width_above_px": float(w[i - d]), "width_below_px": float(w[i + d])})
          return {"height_px": h, "breaks": out}
      
      
      HEAD_TAPER_MAX = 0.95      # narrow / wide width, above this the sides read parallel [added]
      HEAD_SIDE_ARC_MIN = 0.01   # side sag / head depth, below this the side is a flat plane [added]
      
      
      def head_taper(src, fracs=(0.2, 0.8), face=None, max_taper=HEAD_TAPER_MAX,
                     min_arc=HEAD_SIDE_ARC_MIN):
          """Shane Olson's head sides on a TOP view of the cranium alone (solo it): clip the sides
          so the head planes taper, because the side of the head is not straight front to back
          (t_gg7MIGSDM 02:00:26 to 02:00:59), and keep each side an arc, not a flat plane
          (02:11:58). Depth runs along the image rows. Measures the width at fracs of the depth
          (taper = narrow / wide) and each side's sag from its chord between those rows (/ depth,
          positive = bulging out). face: "top" or "bottom", the image side the face is on in this
          view (zb_stroke convention [verify live_04]); when given, a head narrowing toward the
          back is flagged. A plain sphere fails the taper and passes the arc; a hard clip passes
          the taper and fails the arc. Thresholds [added]: calibrate on reference heads."""
          np = _np()
          mask = silhouette_mask(src) if isinstance(src, str) else np.asarray(src, dtype=bool)
          rows = np.nonzero(mask.any(axis=1))[0]
          if len(rows) < 10:
              raise ZBStylizedError("no silhouette to measure")
          y0, y1 = int(rows.min()), int(rows.max())
          depth = y1 - y0 + 1
      
          def edges(y):
              xs = np.nonzero(mask[y])[0]
              return (int(xs.min()), int(xs.max())) if len(xs) else (None, None)
      
          ya, yb = (y0 + int(round(f * (depth - 1))) for f in fracs)
          (la, ra), (lb, rb) = edges(ya), edges(yb)
          wa, wb = ra - la + 1, rb - lb + 1
          taper = min(wa, wb) / float(max(wa, wb))
          narrow = "top" if wa < wb else "bottom" if wb < wa else None
          sags = {"left": 0.0, "right": 0.0}
          for y in range(ya + 1, yb):
              l, r = edges(y)
              if l is None:
                  continue
              t = (y - ya) / float(yb - ya)
              sags["left"] = max(sags["left"], (la + t * (lb - la)) - l)      # bulge to the left
              sags["right"] = max(sags["right"], r - (ra + t * (rb - ra)))    # bulge to the right
          arc = {k: round(v / depth, 4) for k, v in sags.items()}
          flags = []
          if taper > max_taper:
              flags.append(f"sides parallel from the top (taper {taper:.2f}): clip the cranium "
                           "sides so the head narrows toward the face (Shane 02:00:26)")
          for k, v in arc.items():
              if v < min_arc:
                  flags.append(f"{k} side is a flat plane (sag {v:.3f} of depth): keep an arc "
                               "(Shane 02:11:58)")
          if face and narrow and narrow != face and taper <= max_taper:
              flags.append(f"the head narrows toward the back ({narrow}), not the face ({face})")
          return {"depth_px": depth, "rows": [ya, yb], "widths_px": [wa, wb],
                  "taper": round(taper, 4), "narrow_end": narrow, "arc": arc,
                  "ok": not flags, "flags": flags}
      
      
      def _norm_sil(mask, height):
          np = _np()
          from PIL import Image
          m = np.asarray(mask, dtype=bool)
          ys, xs = np.nonzero(m)
          crop = m[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
          h, w = crop.shape
          nw = max(1, int(round(w * height / float(h))))
          near = getattr(Image, "Resampling", Image).NEAREST
          im = Image.fromarray((crop * 255).astype(np.uint8)).resize((nw, height), near)
          return np.asarray(im) > 127
      
      
      def silhouette_iou(a, b, height=96):
          """IoU of two silhouettes scaled to the same height and centred: the thumbnail read
          Rakan calls talking to the back of the room [FRZtVXpAokc 00:05:00]; 64 to 128 px tall
          per the Overwatch note [added]."""
          np = _np()
          A = _norm_sil(silhouette_mask(a) if isinstance(a, str) else a, height)
          B = _norm_sil(silhouette_mask(b) if isinstance(b, str) else b, height)
          W = max(A.shape[1], B.shape[1])
      
          def pad(M):
              left = (W - M.shape[1]) // 2
              return np.pad(M, ((0, 0), (left, W - M.shape[1] - left)))
      
          A, B = pad(A), pad(B)
          union = np.logical_or(A, B).sum()
          return float(np.logical_and(A, B).sum() / union) if union else 1.0
      
      
      def lineup(srcs, labels=None, height=96, too_similar=0.85):
          """Pairwise silhouette IoU for a lineup (Cedric Seaut: lineups force distinct
          silhouettes [UthCuDB1IEQ 00:08:31]); pairs above too_similar [added] are flagged."""
          labels = labels or [str(i) for i in range(len(srcs))]
          masks = [silhouette_mask(s) if isinstance(s, str) else s for s in srcs]
          n = len(masks)
          mat = [[1.0] * n for _ in range(n)]
          flagged = []
          for i in range(n):
              for j in range(i + 1, n):
                  v = round(silhouette_iou(masks[i], masks[j], height), 4)
                  mat[i][j] = mat[j][i] = v
                  if v > too_similar:
                      flagged.append((labels[i], labels[j], v))
          return {"labels": labels, "iou": mat, "too_similar": flagged}
      
      
      def squint(png_path, out_dir, heights=(128, 64), blur=1.5, view_h=384, sheet=True):
          """The agent's squint (Pablo Munoz Gomez squints to find hair chunks [WFqyj6lKgik
          00:15:56]): thumbnails at each height, blurred, blown back up for viewing, tiled with
          zb_review.contact_sheet. Look at the result: do the big chunks and the silhouette read?"""
          from PIL import Image, ImageFilter
          os.makedirs(out_dir, exist_ok=True)
          base = os.path.splitext(os.path.basename(png_path))[0]
          paths, labels = [], []
          with Image.open(png_path) as src:
              im = src.convert("RGB")
          for h in heights:
              w = max(1, int(round(im.width * h / float(im.height))))
              small = im.resize((w, h), getattr(Image, "Resampling", Image).LANCZOS)
              if blur:
                  small = small.filter(ImageFilter.GaussianBlur(blur))
              big = small.resize((int(round(w * view_h / float(h))), view_h),
                                 getattr(Image, "Resampling", Image).NEAREST)
              p = os.path.join(out_dir, f"{base}_squint_{h}px.png")
              big.save(p)
              paths.append(p)
              labels.append(f"{h} px tall, blur {blur}")
          out = {"thumbnails": paths}
          if sheet:
              out["sheet"] = _expert("zb_review").contact_sheet(
                  paths, labels, os.path.join(out_dir, f"{base}_squint_sheet.png"), cols=len(paths),
                  title="squint: chunks and silhouette must still read")
          return out
      
      
      def value_check(png_path, mask=None, focus_box=None, grid=6):
          """Guillaume Tiberghien's grayscale read [UthCuDB1IEQ 00:58:45-00:59:53]: no pure black
          or white, the strongest contrast where attention should go (usually the face).
          focus_box: [x0, y0, x1, y1] canvas px. Returns fractions and the highest-contrast cell."""
          np = _np()
          from PIL import Image
          with Image.open(png_path) as src:
              rgb = np.asarray(src.convert("RGB")).astype(float)
          L = rgb[..., 0] * 0.299 + rgb[..., 1] * 0.587 + rgb[..., 2] * 0.114
          m = silhouette_mask(png_path) if mask is None else np.asarray(mask, dtype=bool)
          vals = L[m]
          ys, xs = np.nonzero(m)
          x0, x1, y0, y1 = xs.min(), xs.max() + 1, ys.min(), ys.max() + 1
          best = None
          for gy in range(grid):
              for gx in range(grid):
                  cx0 = x0 + (x1 - x0) * gx // grid
                  cx1 = x0 + (x1 - x0) * (gx + 1) // grid
                  cy0 = y0 + (y1 - y0) * gy // grid
                  cy1 = y0 + (y1 - y0) * (gy + 1) // grid
                  cm = m[cy0:cy1, cx0:cx1]
                  if cm.size == 0 or cm.mean() < 0.5:
                      continue
                  sd = float(L[cy0:cy1, cx0:cx1][cm].std())
                  if best is None or sd > best[0]:
                      best = (sd, [int(cx0), int(cy0), int(cx1), int(cy1)])
          out = {"pure_black_frac": round(float((vals <= 3).mean()), 4),
                 "pure_white_frac": round(float((vals >= 252).mean()), 4),
                 "mean": round(float(vals.mean()), 2),
                 "max_contrast_cell": best[1] if best else None,
                 "max_contrast_std": round(best[0], 2) if best else None}
          if focus_box and best:
              c = ((best[1][0] + best[1][2]) / 2.0, (best[1][1] + best[1][3]) / 2.0)
              out["contrast_in_focus"] = bool(focus_box[0] <= c[0] <= focus_box[2] and
                                              focus_box[1] <= c[1] <= focus_box[3])
          return out
      
      
      # ============================================================================================
      # Open curves: hair clumps, strands, straps, scarf tails
      # ============================================================================================
      
      def _plane2d(points):
          np = _np()
          P = np.asarray(points, dtype=float)
          if P.shape[1] == 2:
              return P
          c = P.mean(axis=0)
          _, _, vt = np.linalg.svd(P - c, full_matrices=False)
          return np.stack([(P - c) @ vt[0], (P - c) @ vt[1]], axis=1)
      
      
      def _dev_changes(dev, hyst=0.15):
          """Sign changes of a deviation profile, ignoring wiggles under hyst x max |dev|."""
          np = _np()
          thr = hyst * float(np.abs(dev).max())
          state, changes = 0, 0
          for v in dev:
              s = 1 if v > thr else (-1 if v < -thr else 0)
              if s and state and s != state:
                  changes += 1
              if s:
                  state = s
          return changes
      
      
      def classify_polyline(points, samples=96, scale_frac=0.08, straight_deg=2.0,
                            min_run_frac=0.12, straight_dev=0.02):
          """straight, C, S, multi or noisy for an open curve (2D canvas px, or 3D projected on its
          best-fit plane). Rakan: a good curve is clean, a C or a mild S mix; avoid straight and
          noisy lines [FRZtVXpAokc 00:06:07]. The kind comes from the sign changes of the sideways
          deviation from the chord (0: C, 1: S, 2+: multi); noisy = two or more turn flips shorter
          than min_run_frac of the curve (kinks, stitched strokes: Pablo [WFqyj6lKgik 00:26:10]);
          straight = deviation under straight_dev of the chord. A curly design reads as multi or
          noisy by construction: judge it against the design. Thresholds [added]."""
          np = _np()
          Q = _plane2d(points)
          P, total = _resample(Q, 1.0, False)
          if total <= 0:
              return {"kind": "point", "length": 0.0}
          P, _ = _resample(Q, total / float(samples), False)
          P = _smooth(P, 5, False)
          k = max(1, int(round(scale_frac * len(P) / 2.0)))
          t = _smooth(_turns(P, k, False)[:, None], 2 * k + 1, False)[:, 0]
          signs = [0 if abs(v) < straight_deg else (1 if v > 0 else -1) for v in t]
          min_n = min_run_frac * len(P)
          nz = [(s, n) for s, _, n in _runs(signs, False) if s != 0]
          long_nz = [(s, n) for s, n in nz if n >= min_n]
          short_flips = sum(1 for a, b in zip(nz, nz[1:]) if a[0] != b[0] and (a[1] < min_n or b[1] < min_n))
          chord = P[-1] - P[0]
          cl = float(np.linalg.norm(chord)) or 1e-12
          nrm = np.array([-chord[1], chord[0]]) / cl
          dev = (P - P[0]) @ nrm
          dev_ratio = float(np.abs(dev).max()) / cl
          changes = _dev_changes(dev)
          if short_flips >= 2 and 2 * short_flips >= changes:
              kind = "noisy"
          elif dev_ratio < straight_dev and not long_nz:
              kind = "straight"
          else:
              kind = {0: "C", 1: "S"}.get(changes, "multi")
          return {"kind": kind, "length": round(total, 4), "sign_changes": changes,
                  "short_flips": short_flips, "max_dev_ratio": round(dev_ratio, 4)}
      
      
      # ============================================================================================
      # Hair planning on an exported mass (OBJ space)
      # ============================================================================================
      
      class Surface:
          """Nearest-vertex surface for strand planning on an OBJ export (zb_ops.export_obj).
          Vertex normals are area weighted and oriented outward by the signed volume. `limit`
          subsamples the query vertices of very dense meshes (normals use the full mesh)."""
      
          def __init__(self, verts, flat, sizes, limit=150000):
              np = _np()
              V = np.asarray(verts, dtype=float).reshape(-1, 3)
              flat = np.asarray(flat, dtype=np.int64)
              sizes = np.asarray(sizes, dtype=np.int64)
              starts = np.concatenate([[0], np.cumsum(sizes)[:-1]]) if len(sizes) else sizes
              ntri = np.maximum(sizes - 2, 0)
              fi = np.repeat(np.arange(len(sizes)), ntri)
              local = np.arange(int(ntri.sum())) - np.repeat(np.cumsum(ntri) - ntri, ntri)
              a = flat[starts[fi]]
              b = flat[starts[fi] + local + 1]
              c = flat[starts[fi] + local + 2]
              cr = np.cross(V[b] - V[a], V[c] - V[a])
              vol = float(np.einsum("ij,ij->i", V[a], np.cross(V[b], V[c])).sum()) / 6.0
              N = np.zeros_like(V)
              for idx in (a, b, c):
                  np.add.at(N, idx, cr)
              ln = np.linalg.norm(N, axis=1)
              ln[ln == 0] = 1.0
              N = N / ln[:, None] * (1.0 if vol >= 0 else -1.0)
              step = max(1, int(math.ceil(len(V) / float(limit))))
              self.V, self.N = V, N
              self.Q, self.QN = V[::step], N[::step]
              self._q2 = (self.Q ** 2).sum(axis=1)
              self.volume = vol
              self.bbox = (V.min(axis=0).tolist(), V.max(axis=0).tolist())
              self.center = ((V.min(axis=0) + V.max(axis=0)) / 2.0).tolist()
              self.diag = float(np.linalg.norm(V.max(axis=0) - V.min(axis=0)))
      
          @classmethod
          def from_obj(cls, path, limit=150000):
              m = _expert("zb_audit").load_obj(path)
              return cls(m.verts, m.flat, m.sizes, limit)
      
          def closest(self, p):
              np = _np()
              p = np.asarray(p, dtype=float)
              i = int(np.argmin(self._q2 - 2.0 * (self.Q @ p)))
              return self.Q[i], self.QN[i]
      
          def project(self, p, lift=0.0):
              """Point on the tangent plane of the nearest vertex, lifted along its normal."""
              np = _np()
              p = np.asarray(p, dtype=float)
              v, n = self.closest(p)
              return p - float((p - v) @ n) * n + lift * n, n
      
          def distance(self, p):
              np = _np()
              v, n = self.closest(p)
              return float((np.asarray(p, dtype=float) - v) @ n)
      
      
      def _unit(v):
          np = _np()
          n = float(np.linalg.norm(v))
          return v / n if n > 1e-12 else v * 0.0
      
      
      def roots_along(surface, polyline, n, lift=0.0):
          """n roots evenly spaced along a 3D polyline (a parting line or hairline), on the surface."""
          np = _np()
          P, _ = _resample(np.asarray(polyline, dtype=float), 1e-3 * surface.diag, False)
          cum = np.concatenate([[0.0], np.cumsum(np.linalg.norm(np.diff(P, axis=0), axis=1))])
          ts = np.linspace(0.0, cum[-1], n) if n > 1 else np.array([cum[-1] / 2.0])
          pts = np.stack([np.interp(ts, cum, P[:, j]) for j in range(3)], axis=1)
          return [surface.project(p, lift)[0] for p in pts]
      
      
      def directions_away(surface, roots, line, bias=(0.0, -1.0, 0.0), bias_weight=0.3):
          """Tangent directions pointing away from a parting line, pulled toward `bias` (down by
          default): hair flows from the part [added, stylized digest P4]."""
          np = _np()
          L, _ = _resample(np.asarray(line, dtype=float), 1e-3 * surface.diag, False)
          bias = _unit(np.asarray(bias, dtype=float))
          out = []
          for r in roots:
              r = np.asarray(r, dtype=float)
              _, n = surface.closest(r)
              q = L[int(np.argmin(((L - r) ** 2).sum(axis=1)))]
              d = (r - q) - float((r - q) @ n) * n        # in the tangent plane: ignore the lift
              d = _unit(d) if float(np.linalg.norm(d)) > 1e-3 * surface.diag else bias
              d = _unit(d + bias_weight * bias)
              t = _unit(d - float(d @ n) * n)
              out.append(t if float(np.linalg.norm(t)) > 0 else _unit(bias - float(bias @ n) * n))
          return out
      
      
      def march_strand(surface, root, direction, length, step=None, lift=0.0, gravity=0.15,
                       down=(0.0, -1.0, 0.0), max_steps=600):
          """A strand path that walks along the mass from `root`, tangent to it, bending toward
          `down` by `gravity` per step (0: none, 1: straight down): Dan Eder's strands laid over a
          temporary mass [VJ2nMJRtIwQ 00:12:57]; the flow-field march is the stylized digest's
          agent method [added]. Stops early when the direction turns into the surface normal."""
          np = _np()
          step = step or length / 40.0
          down = _unit(np.asarray(down, dtype=float))
          p, n = surface.project(root, lift)
          d = np.asarray(direction, dtype=float)
          d = _unit(d - float(d @ n) * n)
          if float(np.linalg.norm(d)) < 1e-9:
              d = _unit(down - float(down @ n) * n)
          path, done = [p], 0.0
          for _ in range(max_steps):
              if done >= length - 1e-9:
                  break
              d = _unit((1.0 - gravity) * d + gravity * down)
              t = d - float(d @ n) * n
              if float(np.linalg.norm(t)) < 1e-6:
                  break
              t = _unit(t)
              q, n2 = surface.project(p + min(step, length - done) * t, lift)
              seg = float(np.linalg.norm(q - p))
              if seg < 1e-12:
                  break
              done += seg
              d, p, n = _unit(q - p), q, n2
              path.append(q)
          return np.asarray(path)
      
      
      def bend_path(surface, path, kind="C", amount=0.08, lift=0.0, side=1.0):
          """Sideways bend inside the surface's tangent plane so a strand reads as a C (sin pi s),
          an S (sin 2 pi s) or a sweep (s squared, the tip travels). amount: peak offset as a
          fraction of the strand length. Rakan: S and C curves, never straight [FRZtVXpAokc
          00:06:07]; the parametrization is [added]."""
          np = _np()
          P = np.asarray(path, dtype=float)
          if len(P) < 3 or kind in (None, "none", "straight"):
              return P.copy()
          seg = np.linalg.norm(np.diff(P, axis=0), axis=1)
          cum = np.concatenate([[0.0], np.cumsum(seg)])
          L = float(cum[-1])
          s = cum / L
          f = {"C": np.sin(np.pi * s), "S": np.sin(2.0 * np.pi * s), "sweep": s ** 2}[kind]
          out = []
          for i, p in enumerate(P):
              t = _unit(P[min(i + 1, len(P) - 1)] - P[max(i - 1, 0)])
              _, n = surface.closest(p)
              b = _unit(np.cross(n, t))
              q = p + side * amount * L * float(f[i]) * b
              out.append(surface.project(q, lift)[0] if i else p)
          return np.asarray(out)
      
      
      def plan_strands(surface, roots, directions, lengths, kinds=None, amounts=None, sides=None,
                       lift=0.0, gravity=0.15, step=None):
          """One path per root: march, then bend. Returns [{root, path, kind, target_length}]."""
          n = len(roots)
          kinds = kinds or ["C"] * n
          amounts = amounts or [0.08] * n
          sides = sides or [1.0] * n
          lengths = lengths if hasattr(lengths, "__len__") else [lengths] * n
          lifts = lift if hasattr(lift, "__len__") else [lift] * n
          out = []
          for i in range(n):
              p = march_strand(surface, roots[i], directions[i], lengths[i], step, lifts[i], gravity)
              p = bend_path(surface, p, kinds[i], amounts[i], lifts[i], sides[i])
              out.append({"root": [float(c) for c in p[0]], "path": p.tolist(), "kind": kinds[i],
                          "target_length": float(lengths[i])})
          return out
      
      
      def vary(n, base, spread=0.3, seed=0):
          """n values around `base` (+- spread), deterministic: breaks equal lengths and bends
          (Dan Eder: placement first, variation later [VJ2nMJRtIwQ 00:13:29]). Own Random
          instance: never touch the shared interpreter's random.seed."""
          rng = random.Random(seed)
          return [base * (1.0 + spread * (2.0 * rng.random() - 1.0)) for _ in range(n)]
      
      
      def crevice_path(path_a, path_b, surface=None, lift=0.0, samples=32):
          """Midline between two neighbouring clump paths: where Pablo recuts the crevice with
          Standard Refiner (Dam_Standard as the shipped substitute) [WFqyj6lKgik 00:28:21]."""
          np = _np()
          A, _ = _resample(np.asarray(path_a, dtype=float), 1.0, False)
          B, _ = _resample(np.asarray(path_b, dtype=float), 1.0, False)
          la = float(np.linalg.norm(np.diff(A, axis=0), axis=1).sum())
          lb = float(np.linalg.norm(np.diff(B, axis=0), axis=1).sum())
          A, _ = _resample(A, la / samples, False)
          B, _ = _resample(B, lb / samples, False)
          m = min(len(A), len(B))
          M = (A[:m] + B[:m]) / 2.0
          if surface is not None:
              M = np.asarray([surface.project(p, lift)[0] for p in M])
          return M
      
      
      def strand_report(paths, cameras=None, parallel_tol_deg=8.0, spacing_cv=0.15,
                        length_band=(0.85, 1.18), surface=None):
          """Numbers for a set of strand or clump paths: lengths, kinds (C, S, straight, noisy),
          parallel neighbours (Rakan: no parallel lines [FRZtVXpAokc 00:06:07]; thresholds
          [added]), lift above the surface. cameras: {view: zb_stroke.Camera} to classify each
          path as that view sees it."""
          np = _np()
          Ps = [np.asarray(p["path"] if isinstance(p, dict) else p, dtype=float) for p in paths]
          rows = []
          for i, P in enumerate(Ps):
              r = {"index": i, "length": round(float(np.linalg.norm(np.diff(P, axis=0), axis=1).sum()), 5),
                   "kind": classify_polyline(P)["kind"]}
              if cameras:
                  r["views"] = {v: classify_polyline([c.project(q)[:2] for q in P])["kind"]
                                for v, c in cameras.items()}
              if surface is not None:
                  r["mean_lift"] = round(float(np.mean([surface.distance(q) for q in P])), 5)
              rows.append(r)
          pairs = []
          R = [P[0] for P in Ps]
          for i, P in enumerate(Ps):
              if len(Ps) < 2:
                  break
              d = [float(np.linalg.norm(R[i] - R[j])) if j != i else 1e30 for j in range(len(Ps))]
              j = int(np.argmin(d))
              if any(pp["a"] == min(i, j) and pp["b"] == max(i, j) for pp in pairs):
                  continue
              A, _ = _resample(P, rows[i]["length"] / 16.0, False)
              B, _ = _resample(Ps[j], rows[j]["length"] / 16.0, False)
              m = min(len(A), len(B))
              ta, tb = np.diff(A[:m], axis=0), np.diff(B[:m], axis=0)
              cos = np.einsum("ij,ij->i", ta, tb) / (np.linalg.norm(ta, axis=1) *
                                                     np.linalg.norm(tb, axis=1) + 1e-12)
              ang = float(np.degrees(np.arccos(np.clip(cos, -1, 1))).mean())
              gap = np.linalg.norm(A[:m] - B[:m], axis=1)
              cv = float(gap.std() / (gap.mean() + 1e-12))
              lr = rows[i]["length"] / max(rows[j]["length"], 1e-12)
              if ang < parallel_tol_deg and cv < spacing_cv and length_band[0] <= lr <= length_band[1]:
                  pairs.append({"a": min(i, j), "b": max(i, j), "mean_angle_deg": round(ang, 2),
                                "spacing_cv": round(cv, 3), "length_ratio": round(lr, 3)})
          lens = [r["length"] for r in rows]
          kinds = {}
          for r in rows:
              kinds[r["kind"]] = kinds.get(r["kind"], 0) + 1
          return {"count": len(rows), "rows": rows, "kinds": kinds, "parallel_pairs": pairs,
                  "length_min": min(lens) if lens else None, "length_max": max(lens) if lens else None,
                  "length_ratio_max_min": round(max(lens) / max(min(lens), 1e-12), 3) if lens else None}
      
      
      def project_paths(paths, transform, pivot, verts=None, cell_px=2.0):
          """Canvas pixels of 3D paths for one view (the 9 floats of get_transform, e.g. a shot's
          transform_read from zb_review.capture_views, and pivot = centre of its bbox). With verts
          (the mass OBJ vertices) each path also gets the fraction of its points that are the
          front-most surface (zb_stroke.visible_mask)."""
          cam = zb_stroke.Camera(transform, pivot)
          out = []
          for p in paths:
              P = p["path"] if isinstance(p, dict) else p
              P = [tuple(float(c) for c in q) for q in P]
              proj = cam.project_many(P)
              r = {"px": [[round(float(x), 2), round(float(y), 2)] for x, y, _ in proj],
                   "mean_depth": float(sum(d for _, _, d in proj)) / max(1, len(proj))}
              if verts is not None:
                  vis = zb_stroke.visible_mask(cam, [tuple(v) for v in verts], P, cell_px)
                  r["visible"] = round(sum(vis) / float(len(vis)), 3)
              out.append(r)
          return out
      
      
      def best_view(path, surface, bbox, doc_wh, views=("front", "right", "left", "back",
                                                        "threequarter", "top"), margin=0.75):
          """The canonical view that faces a clump path best, with its transform and pixels:
          Pablo's "find a nice angle and do a single motion" [WFqyj6lKgik 00:26:40]. Transforms
          come from zb_stroke.frame_transform, the same framing as zb_review "math". Non-front
          views follow zb_stroke's convention (front proven; others [verify live_04])."""
          np = _np()
          P = np.asarray(path["path"] if isinstance(path, dict) else path, dtype=float)
          normals = [surface.closest(q)[1] for q in P]
          pivot = _center(bbox)
          best = None
          for v in views:
              t = zb_stroke.frame_transform(bbox, doc_wh[0], doc_wh[1], zb_stroke.VIEWS[v], margin)
              cam = zb_stroke.Camera(t, pivot)
              face = float(np.mean([cam.facing(tuple(n)) for n in normals]))
              px = [cam.project(tuple(q))[:2] for q in P]
              inside = sum(1 for x, y in px if 0 <= x < doc_wh[0] and 0 <= y < doc_wh[1]) / float(len(px))
              score = face * inside
              if best is None or score > best["score"]:
                  best = {"view": v, "score": round(score, 4), "facing": round(face, 4),
                          "transform": [float(c) for c in t],
                          "px": [[round(float(x), 2), round(float(y), 2)] for x, y in px]}
          return best
      
      
      def overlay_paths(png_path, px_paths, out_path, width=3, labels=None):
          """Draw planned paths on a canvas render: the agent's chunk map (Pablo polypaints chunks,
          Dan paints sections on the concept, before sculpting [WFqyj6lKgik 00:17:46;
          VJ2nMJRtIwQ 00:01:26]). Look at it before laying anything."""
          from PIL import Image, ImageDraw
          pal = [(230, 70, 70), (70, 170, 240), (90, 210, 90), (240, 180, 40), (200, 90, 220),
                 (40, 210, 200), (250, 120, 30), (160, 160, 250)]
          with Image.open(png_path) as src:
              im = src.convert("RGB")
          d = ImageDraw.Draw(im)
          for i, pts in enumerate(px_paths):
              pts = pts["px"] if isinstance(pts, dict) else pts
              c = pal[i % len(pal)]
              d.line([tuple(p) for p in pts], fill=c, width=width)
              x, y = pts[0]
              d.ellipse([x - 4, y - 4, x + 4, y + 4], outline=c, width=2)
              if labels:
                  d.text((x + 6, y - 6), str(labels[i]), fill=c)
          os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
          im.save(out_path)
          return out_path
      
      
      def shells(mesh):
          """Vertex index arrays per connected shell, largest first (zb_audit's labelling)."""
          np = _np()
          za = _expert("zb_audit")
          m = za.load_obj(mesh) if isinstance(mesh, str) else mesh
          a, b = za._edges(m)
          lab = za._shells(len(m.verts), a, b)
          used = np.zeros(len(m.verts), dtype=bool)
          used[m.flat] = True
          groups = {}
          for i in np.nonzero(used)[0]:
              groups.setdefault(int(lab[i]), []).append(int(i))
          return sorted((np.asarray(g) for g in groups.values()), key=len, reverse=True)
      
      
      def strand_mesh_report(mesh, dummy_max_faces=12, min_faces=8):
          """Strand hair exported as OBJ: one shell per strand (Dan Eder: one polygroup or SubTool
          per strand [VJ2nMJRtIwQ 00:12:57]). Shells of dummy_max_faces or fewer faces are the
          dummy QCube (Pablo hides a tiny cube inside the hair so curve strands land in their own
          SubTool [WFqyj6lKgik 00:36:19]). Faces per strand feed the game budget."""
          np = _np()
          za = _expert("zb_audit")
          m = za.load_obj(mesh) if isinstance(mesh, str) else mesh
          a, b = za._edges(m)
          lab = za._shells(len(m.verts), a, b)
          used = np.zeros(len(m.verts), dtype=bool)
          used[m.flat] = True
          face_lab = lab[m.flat[m.starts]]
          ids, fcount = np.unique(face_lab, return_counts=True)
          rows = []
          for sid, fc in zip(ids.tolist(), fcount.tolist()):
              V = m.verts[np.nonzero((lab == sid) & used)[0]]
              rows.append({"faces": int(fc), "points": int(len(V)), "bbox_min": V.min(axis=0).tolist(),
                           "bbox_max": V.max(axis=0).tolist(),
                           "dummy": fc <= dummy_max_faces})
          strands = [r for r in rows if not r["dummy"]]
          fc = sorted(r["faces"] for r in strands)
          tris = int(np.maximum(m.sizes - 2, 0).sum())
          return {"shells": len(rows), "strands": len(strands),
                  "dummies": sum(1 for r in rows if r["dummy"]),
                  "faces_per_strand": {"min": fc[0], "median": fc[len(fc) // 2], "max": fc[-1]} if fc else None,
                  "thin_strands": sum(1 for f in fc if f < min_faces), "faces": int(len(m.sizes)),
                  "tris_equiv": tris, "rows": rows}
      
      
      def tube_radius_profile(verts, path, bins=8):
          """Median distance of tube vertices to their path, per stretch of the path: tells which
          end is thick (Dan: root and tip come out inverted, fix the width falloff [VJ2nMJRtIwQ
          00:03:23]; point order is the scripted lever) and how wide Draw Size made the tube."""
          np = _np()
          V = np.asarray(verts, dtype=float)
          P = np.asarray(path, dtype=float)
          A, B = P[:-1], P[1:]
          AB = B - A
          L2 = (AB ** 2).sum(axis=1) + 1e-18
          seg_len = np.sqrt(L2)
          cum = np.concatenate([[0.0], np.cumsum(seg_len)])
          best_d = np.full(len(V), np.inf)
          best_t = np.zeros(len(V))
          for i in range(len(A)):
              u = np.clip(((V - A[i]) @ AB[i]) / L2[i], 0.0, 1.0)
              q = A[i] + u[:, None] * AB[i]
              d = np.linalg.norm(V - q, axis=1)
              better = d < best_d
              best_d[better] = d[better]
              best_t[better] = (cum[i] + u[better] * seg_len[i]) / cum[-1]
          edges = np.linspace(0.0, 1.0, bins + 1)
          rad = []
          for i in range(bins):
              sel = (best_t >= edges[i]) & (best_t <= edges[i + 1])
              rad.append(float(np.median(best_d[sel])) if sel.any() else None)
          r0 = next((r for r in rad if r), None)
          r1 = next((r for r in reversed(rad) if r), None)
          return {"t": [round((edges[i] + edges[i + 1]) / 2.0, 3) for i in range(bins)],
                  "radius": rad, "start_over_end": round(r0 / r1, 3) if r0 and r1 else None,
                  "median_radius": float(np.median(best_d))}
      
      
      # ============================================================================================
      # Toys and collectibles (design-side numbers; engineering belongs to scenario-zbrush-pose-print)
      # ============================================================================================
      
      HASBRO = {"min_wall_mm": 1.0, "violation_example_mm": 0.85, "min_edge_mm": 0.5,
                "floats_away_mm": 0.2, "fit_offset_mm": 0.12, "shrink_pct_6in": 4.0,
                "elbow_rom_target_deg": 115, "elbow_rom_min_deg": 90,
                "source": "Paul Bennett, Hasbro Star Wars Black Series, ZBrush Summit 2019 "
                          "[P08kuTIRziE 00:10:57-01:06:02]"}
      
      
      def toy_scale(real_height_mm, line="6in"):
          """Hasbro: a 6 in figure is the actor's height / 12, then +4 percent (the 104 percent
          file) for PVC shrink; a 3.75 in figure is height / 18 [P08kuTIRziE 00:12:54, 00:17:51,
          00:57:52]. No shrink figure is given for the 3.75 in line. Say which scale a file is at:
          partners work at 100 percent."""
          if line in ("6in", "6", 6):
              design = real_height_mm / 12.0
              return {"line": "6in", "design_mm": round(design, 3),
                      "file_mm": round(design * (1 + HASBRO["shrink_pct_6in"] / 100.0), 3),
                      "file_scale_pct": 100 + HASBRO["shrink_pct_6in"]}
          if line in ("3.75in", "3.75", 3.75):
              return {"line": "3.75in", "design_mm": round(real_height_mm / 18.0, 3),
                      "file_mm": None, "file_scale_pct": None,
                      "note": "no shrink compensation stated for this line"}
          raise ValueError("line must be 6in or 3.75in (the two Hasbro rules)")
      
      
      def mm_per_unit(model_height_units, print_height_mm):
          if model_height_units <= 0:
              raise ValueError("model height must be positive")
          return print_height_mm / float(model_height_units)
      
      
      def feature_check(features, mmpu, min_wall_mm=HASBRO["min_wall_mm"],
                        min_edge_mm=HASBRO["min_edge_mm"]):
          """features: {name: (width_in_units, "wall" | "edge")}. Hasbro: never below 1 mm of
          wall (0.85 mm was flagged), trimmed edges at least 0.5 mm [P08kuTIRziE 00:10:57,
          00:16:46, 00:48:28]. Flyaway hair strands fail this first: fuse them to the head."""
          rows = []
          for name, (w, kind) in features.items():
              mm = w * mmpu
              lim = min_wall_mm if kind == "wall" else min_edge_mm
              rows.append({"name": name, "mm": round(mm, 3), "min_mm": lim, "ok": mm >= lim})
          return {"ok": all(r["ok"] for r in rows), "rows": rows}
      
      
      # ============================================================================================
      # DynaMesh resolution from the finest feature (the Resolution Picker is a drag)
      # ============================================================================================
      
      # Mean edge length = factor * longest bbox side / resolution. One sample: the v03 sphere,
      # DynaMesh 128, longest side 2.0401, mean edge 0.017266 (tests/code/zbrush-expert/fixtures/
      # v03_sphere.obj through zb_audit) -> 1.083. Recalibrate with live_03 at 256.
      DYNAMESH_EDGE_FACTOR = 1.083
      
      
      def dynamesh_edge(longest_side, resolution, factor=DYNAMESH_EDGE_FACTOR):
          return fact
  • SKILL.md 17.1 KB
    ---
    name: scenario-zbrush-stylized
    description: "Use when sculpting a stylized or cartoon character, bust, collectible or toy in ZBrush (Overwatch, Fortnite, Disney look), judging shape language (S and C curves, parallel lines, 70/30, silhouette read at a distance), exaggerating proportions on purpose, keeping stylized planes crisp through subdivision, sculpting stylized hair (chunky clumps, curve tube strands, ClayPolish), or designing a figure for toy production (articulation, wall thickness, scale)."
    license: MIT
    ---
    
    # ZBrush stylized characters and collectibles
    
    Expert stylized work is anatomy simplified on purpose: real landmarks and proportions as the baseline, a few named exaggerations, and shape-language rules an agent can measure on a silhouette. Curves are the agent's best tool: strands, clumps, straps and scarf tails come from computed 3D paths, so a "tweak" regenerates a path instead of sculpting it. 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)
    
    - **Shane Olson:** stylizing is not "an excuse to do bad anatomy". Block a stylized skeleton by the bony landmarks that touch the surface (hip point, knee, both ankle bones, elbow, clavicle tops, base-of-neck vertebra, sacral triangle, skull) and drop bones that never show. Every departure from real proportion is a named knob measured against the real value (the knee halves the leg, hands land at mid-thigh); move knobs carefully, because motion exposes them.
    - **Guillaume Tiberghien (Keos Masons):** "simple is hard". Add anatomy, then simplify. Stylized means clear directional planes chipped like wood or rock, changes of direction creased so they stay straight when subdivided, and forms tucked into each other for small contact shadows. No color until the modeling is tight.
    - **Rakan Khamash (Blizzard):** shape language is a rule set, not taste. S and C curves, no parallel lines, no 50/50 splits (push 70/30), flare at the top and taper at the bottom, rest areas next to detail, and "say it or don't": the character must read from the back of the room. The blockout is 90 to 95% of the low poly.
    - **Cedric Seaut (Keos Masons):** start from silhouettes; a lineup of several designs forces distinct shapes.
    - **Pablo Munoz Gomez:** plan hair chunks before sculpting, then carve one long stroke per clump from one angle. DynaMesh a little above the picker's value, Polish 2 and ClayPolish get you "80% there".
    - **Dan Eder:** hair chunks are rig sections. Lay one tube strand per clump on a temporary mass, one polygroup per strand, front to back. Never Smooth a strand. Build a parted style without symmetry, then mirror it. Switch MatCaps to find gaps.
    - **Paul Bennett (Hasbro):** a toy is a product. Sculpt the neutral standing design with no joints first; articulation is spheres and discs camouflaged by the costume; walls never below 1 mm; fuse flyaways; no closed loops such as touching fingers; state the file scale (104% for PVC shrink).
    
    ## Establish first
    
    | Input     | Changes                                                                                       | Default when the brief is silent                      |
    | --------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
    | Purpose   | game (loops, budget, rigged hair), collectible or toy (walls, parts, scale), concept or still | portfolio still                                       |
    | Reference | concept image, turnaround, style (Overwatch chunky, Disney round)                             | none: real baseline plus only the named exaggerations |
    | Hair      | strands (rigged or per-chunk retopology) or one sculpted mesh                                 | sculpted mesh (Pablo)                                 |
    | Budget    | triangles per hero and per hair version                                                       | none for stills; Blizzard's hero was 16k [unit ?]     |
    | Scale     | print height or toy line (6 in, 3.75 in)                                                      | a 200 mm ruler SubTool anyway (Shane)                 |
    | Symmetry  | symmetric design or not                                                                       | symmetric, then a deliberate asymmetry pass           |
    
    ## Workflow
    
    1. **Design sheet.** Write each knob as a number against its real value (a big nose: `nose_third`, brow to nose base over a third of the face, real 1.0; a heavy brow: brow depth; long shins: `femur_tibia`). Take each value from the concept; without one, push the named feature until it survives the 64 to 128 px thumbnail read, and record it. Write the three reads (Rakan): the silhouette idea, the secondary shapes that carry intent, the tertiary detail. Plan hair chunks and the part line.
       GATE: every knob has a target and a reason.
    2. **Silhouette first.** Block two or three primitive variants (scenario-zbrush-sculpting) and render them with `zb_review.review`.
       GATE: `lineup()` IoU under 0.85 between variants [added]; the chosen one reads at 64 to 128 px (`squint()`).
    3. **Stylized skeleton by landmarks.** Perspective off for everything orthographic (Shane: it skews masking, clipping and mirroring). Rules:
       - cranium plus jaw, split at the eye line; jaw corner under the ear at mid head depth;
       - head sides: from the top the head is not parallel-sided, so clip the cranium sides until the head narrows toward the face (Shane 02:00:26), then give each side an arc, not a flat plane (02:11:58). Agent: a two-point ClipCurve stroke in the orthographic top view or Deformation Taper on the cranium SubTool (both [verify]), then a Move or clay pass for the arc;
       - eyes: eyeball spheres first, lids wrapping them, each turned a few degrees outward (scenario-zbrush-sculpting P17); the outer corner well behind the inner one (check from the top);
       - pelvis leaning forward, ribcage leaning back, femur angled in, tibia straight.
         GATE: `head_checks` and `body_checks` on `subtool_boxes()` pass, or each failure is a recorded deliberate break; `head_taper()` on a top render of the cranium alone; front and side ortho renders against the concept; a three-quarter perspective look render (`zb_sculpt.persp_snapshot_code`) for the eye sockets.
    4. **Merge and plane.**
       - DynaMesh the blockout at 128 to 256 (`remesh_code`, scenario-zbrush-sculpting); cut planes with broad hPolish or TrimDynamic strokes (forgiving, so scripted strokes work); tuck forms into each other. ClayPolish works as a global planar filter [added for forms; Pablo uses it on hair].
       - Keep the planes through subdivision (Guillaume 00:46:42 to 00:47:50): ZRemesher the merged parts for a sculptable topology, then crease the plane borders so they "remain straight" when subdivided: `crease_planes` (Groups By Normals, Crease PG, CreaseLvl), then Divide by level discipline (scenario-zbrush-sculpting P15). Creases are edge tags: any later DynaMesh or ZRemesher needs them again [added].
         GATE: `shape_language()` on front, side and three-quarter silhouettes: no parallel pairs, straight fraction at most 0.25, noise at most 4 flips per 1000 px (defaults [added]); no `band_breaks()` break in the 45 to 55% band; no dips under MatCap Metal01 (Plouffe); after Divide the creased borders stay crisp in a grazing view.
    5. **Secondary shapes and props (scarf, straps, belts).**
       - Rings and bands from scenario-zbrush-sculpting, knots and buckles off-center (70/30).
       - Tails and straps as curve ribbons: `lay_strands` with `sides=0` (Curve Flat, Pavlovich) or 4 for a strap; paths from `march_strand` with gravity, bent into an S.
       - Folds only where pressure or joints put them (Rakan). A scarf that needs weight gets a Dynamics cloth drape (scenario-zbrush-character-creature), then its folds restated.
         GATE: each tail classifies S or C in the front view (`classify_polyline`); no parallel tails (`strand_report`).
    6. **Hair.** Strands for rigged or game hair; the sculpted mesh for stills, prints and single meshes. Both plan paths on an OBJ export of the mass (P3 to P5).
       - Plan: `roots_along` the part line and hairline, `directions_away` from the part, `plan_strands` with alternating C and S bends and `vary()` lengths; draw it with `overlay_paths` on front, right and top renders and look at it (Pablo polypaints chunks; Dan paints sections on the concept).
       - Sculpted (Pablo, P4): `scalp_shell`; fat CurveTubes per chunk, DynaMesh a few hundred to fuse; one `stroke_from_view` ClayBuildup stroke per clump from its `best_view` (`sculptris=True`); Dam_Standard Zsub along each `crevice_path`; `rebalance`; `finish_hair`.
       - Strands (Dan, P5): a tiny dummy SubTool inside the head (Pablo); one CurveTube per strand, front to back, more lift in front; Auto Groups; `strand_mesh_report`; mirror a parted style after.
         GATE: the plan shows no parallel neighbors and designed kinds read in the right and top views; hair reads at a squint; the five review views (top shows the parting) under two MatCaps show no gaps or clipping; rest areas stay calm.
    7. **Asymmetry, value, color handoff.**
       - Deliberate asymmetry pass: knot, fringe, a brow.
       - Guillaume's grayscale read (`value_check`): no pure black or white, strongest contrast at the face; before color, only darker lashes and pupils.
       - A material ID plan with distinct colors per material; polypaint at the top level, judged on the flat SkinShade4, since MatCaps lie about hue and value (Pablo VRisbJQAaZw 00:23:40, 00:24:54).
    8. **Product constraints (toy or collectible).**
       - `toy_scale` (height / 12 plus 4% for 6 in; / 18 for 3.75 in);
       - `feature_check` of strands and thin forms at print scale (1 mm walls, 0.5 mm trimmed edges);
       - hair a separate part keyed to the head, flyaways fused, strands bunched for repeatable paint; no closed loops, slits in skirts.
         Do not over-sculpt past what the print size shows (Carratala, 90 mm).
    
    ## Numbers
    
    | Value                                                                                                            | Relative to                 | Source                                   |
    | ---------------------------------------------------------------------------------------------------------------- | --------------------------- | ---------------------------------------- |
    | knee at half the leg; hands at mid-thigh; jaw corner at 0.5 of head depth                                        | real baseline for knobs     | Shane                                    |
    | eye line 0.5 of head height; face thirds equal within about 5% of head height                                    | real head                   | Anatomy For Sculptors (character digest) |
    | outer canthus behind inner by at least 0.10 of head depth                                                        | starting threshold          | stylized digest [added]                  |
    | head sides from the top: taper (narrow / wide width) at most 0.95; side sag at least 0.01 of depth               | `head_taper` defaults       | Shane rule, thresholds [added]           |
    | 70/30, never 50/50; flag 0.45 to 0.55                                                                            | break position along a span | Rakan; band [added]                      |
    | blockout = 90 to 95% of the low poly; 16k hero budget incl. two hairstyles                                       | game character              | Rakan                                    |
    | hair DynaMesh: 128 shell; 440 blocking; 1416 refined; 1032 to 1784 detailing; re-DynaMesh at 2.4M to 2.6M points | Pablo's head, his machine   | Pablo frames                             |
    | Deformation Polish 2; ClayPolish Max 25, Min 0, Sharp 0, Soft 0, RSharp and RSoft about 5                        | finishing                   | Pablo frame                              |
    | Curve Tube Brush Modifier 20 default, 4 square, 0 flat; Z Intensity 34 flattens                                  | tube profile                | IMM doc, Pavlovich                       |
    | strand about 3 to 4 polygons across                                                                              | game strand                 | Dan frame                                |
    | 1 mm wall (0.85 flagged), 0.5 mm edge, 0.12 mm fit offset, elbow 115 degrees                                     | toy production              | Bennett                                  |
    
    ## Quality gates
    
    - **In code:** `head_checks` and `body_checks` pass or are deliberate; `head_taper` on the top view; `split_check` on the brow line, scarf band and part; `shape_language` flags empty on three silhouettes; no 50/50 `band_breaks`; `lineup` IoU; `strand_report` without parallel pairs and with designed kinds per view; `strand_mesh_report` with one shell per strand and faces within budget; points after every DynaMesh; `feature_check` in mm for toys.
    - **Visual:** `zb_review.review` under MatCap Gray plus Metal01 (dips) or another MatCap (hair gaps, Dan); `squint` at 64 and 128 px; `annotate_shape`; the plan overlay before hair is laid; the perspective look for appeal; top view for the parting and head sides; the back of the head finished too.
    
    ## Common mistakes
    
    | Mistake                                   | Looks like                           | Fix                                                       |
    | ----------------------------------------- | ------------------------------------ | --------------------------------------------------------- |
    | Uniform exaggeration                      | every feature big, nothing reads     | one or two named knobs; the rest at baseline              |
    | Eye corners on one plane                  | flat mask face from the top          | pull the outer canthus back (`canthus_recess`)            |
    | Parallel head sides, or a flat side plane | boxy or balloon cranium from the top | clip the sides, keep an arc (`head_taper`)                |
    | Planes lost after Divide                  | mushy, rounded stylized planes       | `crease_planes` after ZRemesher, before Divide            |
    | Parallel limb or strap edges              | tube legs, boring pants              | flare the top, taper the bottom                           |
    | 50/50 splits                              | scarf or belt exactly mid-span       | move to 70/30 (`split_check`)                             |
    | Detail everywhere                         | noisy contour, no rest               | keep calm zones (Pablo, Rakan)                            |
    | Hair as one lump or thousands of strands  | no chunks at a squint                | plan the big chunks first, then one strand or clump each  |
    | Strands pushed and smoothed by strokes    | soft, kinked strands                 | regenerate the path and re-lay; Dan never smooths strands |
    | ClayPolish mask left on                   | the next brush "does nothing"        | `finish_hair` clears the mask                             |
    | Toy with flyaways or touching fingers     | breaks, won't release from the mold  | fuse, separate, check at 1 mm                             |
    
    ## Handoffs
    
    - **Receives:** primitive and DynaMesh forms from scenario-zbrush-sculpting (versioned ZTL, `stats()`, review sheet); cloth drapes and realistic anatomy from scenario-zbrush-character-creature.
    - **Delivers:** to scenario-zbrush-paint-render the tight sculpt, value targets and material ID plan; to scenario-zbrush-pose-print the neutral design without joints, a part and joint plan (spheres and discs), hair as a keyed part, the scale factor and file percentage; to scenario-zbrush-retopology-export strand hair with one group per strand (rig chains), chunk polygroups, faces per strand, loop-aligned design lines (Rakan).
    
    ## ZBrush 2026 notes
    
    - Curve Flat and Curve Flat Snap ship (2021.6.3), Bend is split into Bend Start and End, IMM brushes take any stroke (2024), and curve brush geometry bugs were fixed in 2026.2.1.
    - Pablo's hair brushes and DE_HairTubes are third party: stand-ins are ClayBuildup, Standard, Dam_Standard and CurveTube; custom brushes load from the Asset Directory `LightBox/Brushes`.
    - The curve API takes tool-space points and cannot read curves back; `curves_to_ui` alone may not build the mesh (Maxon's example): live_01 measures the apply and the axis mapping.
    - Perspective is `Draw:Perspective` (installed commands.xml, no `Transform:Persp`); `PATHS` tries the installed ids first. The Gizmo is the default and Local Symmetry follows it. Cmd+W quits ZBrush on macOS.
    
    ## References
    
    - [`references/procedures.md`](references/procedures.md): bridge code per stage. Load before running anything.
    - [`references/expert-notes.md`](references/expert-notes.md): principles by expert with timestamps; disagreements and deciding conditions.
    - [`references/critique.md`](references/critique.md): the self-review rubric.
    - [`references/gui-paths.md`](references/gui-paths.md): palettes, brushes, settings, hotkeys (computer-use agent).
    - [`references/sources.md`](references/sources.md): sources, credentials, URLs, timestamps.
    - [`scripts/zb_stylized.py`](scripts/zb_stylized.py): the module; reach its ZBrush side with `zb_stylized.call()`, not `zb_launch.call`. Tests: `tests/code/zbrush-stylized/`.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related