scenario-zbrush-paint-render
Use when polypainting in ZBrush (skin color zones, fills, cavity or AO passes, Spotlight), baking polypaint to a texture or vertex color, rendering a ZBrush model with BPR or Redshift, making render passes, a turntable or portfolio shots, or when paint looks like uniform beige or
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/zbrush/scenario-zbrush-paint-render
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
git clone https://github.com/scenario-labs/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
ZBrush paint and render (texture and presentation artist)
Expert paint in ZBrush is observation made systematic: color zones placed by what lies under the skin, pushed enough to survive the shader, judged on neutral and flat views, and baked at a resolution the mesh can hold. For an agent without a tablet most of it is buttons: fills at a chosen intensity through generated masks, zone images projected onto the mesh, and renders that are both measured and looked at. 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)
- Paint last, at the top level. Polypaint is one color per vertex: a lower level paints blurry, adding levels afterwards breaks it, and the map follows the point count (about 4M points per 2K map; 4K is wasted there) (FlippedNormals).
- Judge on neutral and flat. SkinShade4 or MatCap White01, plus Flat Color. A tinted MatCap makes you overcompensate and the export looks wrong elsewhere (FlippedNormals, Pablo).
- Structure before beauty. Start with a pure yellow, red and blue zone map placed by anatomy: bone and fat yellow, thin tissue red, hollows blue, more blue low on a man. Then cover it with skin color at low opacity so the zones shimmer through (Pablo). For an agent this is also the route that can be checked in numbers.
- Masks are "the real trick" (Pablo): generator, Mask Adjust, Inverse, color through the mask. The agent's big soft brush is FillObject at low RGB Intensity through the inverted mask: it scales with RGB Intensity (FlippedNormals), Pavlovich fills through an inverted AO mask, and the tooltip reads "Fill 3D Object Masked".
- Saturation is the stylization dial (Pablo); paint to be diluted (FlippedNormals). Shaders wash polypaint out. Pick the final renderer before deciding how far to push, and keep realistic skin under the middle of the picker.
- Contrast directs the eye. The strongest value or saturation contrast goes to the eyes, and the eyes and mouth get the most time (FlippedNormals).
- Small and fast, then big and slow (Pavlovich). Test small at a high Error Threshold, compare renders side by side, and rebuild the beauty from passes instead of re-rendering.
- Visibility traps (Maxon docs). A displayed texture hides polypaint. In Redshift only Polypaint materials show paint (Use Material Color 0, white Base), SSS Weight 1 hides it, and MatCaps show no texture.
Establish first
| Input | Changes | Default when the brief is silent |
|---|---|---|
| Purpose: game albedo, film lookdev reference, stills, color print, concept paintover | whether painted shadow is allowed; map size; deliverable | presentation stills, color kept as polypaint |
| Renderer | materials, passes, turntable route | BPR. Redshift is a separate application and mx1 product list shows it uninstalled on this Mac (2026-09-24); ask before installing it |
| Style | saturation target, zone simplification | the upstream skill's style: scenario-zbrush-character-creature is realistic, scenario-zbrush-stylized is stylized |
| Subject: sex, age, build, skin tone | zone layout and hues | ask once; if still silent, assume an adult of average build with light skin and say so |
| Color deliverable | UVs, map size, Flip V | vertex color; add a texture only when UVs exist |
| Images | document size, views, turntable | 1920 x 1080 (Pavlovich); front, three-quarter, profile and back; turntable 36 frames for review, 180 (Movie default) at 30 fps for delivery [added] |
Workflow
Toolkit: scripts/zb_paint.py, ZBrush-side functions through zp.remote("name", ...); full code in references/procedures.md. Save a versioned ZTL after every stage: there is no undo control.
- Preflight.
paint_state()(level, points, UVs, texture display),recommend_map_size(points),texture_off(), save a ZTL. GATE: top level,map_budget(points, side)passes, v001 on disk. Divide now if needed, never after painting. - Setup and base.
set_paint_mode("rgb")(Zadd and Zsub off),fill(colour, 100): a dull skin base (FlippedNormals, doc) or pure yellow for the zone route (Pablo). GATE: the Flat front shows the fill color; Colorize reads on. - Zone map. Parts (eyes, scarf, teeth, clothes):
fill_subtools. Polygroups: review withpolypaint_from_polygroups(), fill exactly withzone_fill_by_split()(Duplicate, Groups Split, fill each piece, Project All back with Colorize on). Anatomy on one mesh:skin_zone_plan→zone_image→polypaint_from_image(planar UVs, before real UVs); synthesized Color Spray strokes as the fallback. GATE:skin_zone_checks(stage="zones"): forehead yellower than midface, midface redder, a man's lower third bluer, each by at least one JND (2.3 Lab); visually a clown on purpose (Pablo), every zone on its anatomy. - Cover and breakup. Skin color at 20 to 40 percent over the zones (doc RGB about 30; Pablo's low-opacity cover); breakup from zone-image noise or Color Spray strokes. GATE:
stage="covered": no clown (at most 5 percent of skin pixels above saturation 0.75 [added]), speckle present. - Mask passes with
mask_pass: cavity darker and redder at 20 to 40 percent; PeaksAndValleys (PVRange 14) or Smoothness with a pale warm fill;ao_pluginmask with a purple-red undertone (ping after). Pablo's Adjust Colors is a dialog [verify live_p06]; a low fill through the same mask replaces it. Mask data cannot be read: prove a mask withmask_coverage(masked, cleared). GATE: each pass shifts the region statistics a little, together they read as skin, back of the head included. - Focus and finish. Eyes: an iris image (dark ring, lighter bottom, darker top, lighter toward the pupil) projected onto the eye SubTool, then slight divergence; lips; selective darkening only where light never reaches unless the renderer adds no AO; a 5 to 10 percent wash for realism. GATE: final
skin_zone_checkswith eye contrast, then the Flat, SkinShade4 and MatCap White01 sheets (paint_review) judged withreferences/critique.md. - Deliver the color.
texture_from_polypaint(path, side, flip_v=True)needs UVs (UV Master on a clone, Paste UVs keeps polypaint); or OBJ vertex color; or Substance Bridge Send PolyPaint. GATE: map budget passes, an asymmetric mark lands where expected, file on disk. - Render. Save,
set_document_size(small for tests; Resize clears the canvas, the function redraws),render_setup(perspective, shadows, AO),bpr_renderorpaint_views(..., "bpr")for matched views. GATE:render_checkspass and the renders were looked at at 100 percent. - Passes and turntable.
export_bpr_pass(may open a dialog); clown pass bysubtool_solo_viewsplusid_pass(paint and polygroups untouched);composite(shadow and AO Multiply through the mask);turntable_framesin short chunks,assemble_turntable. GATE: every frame upright with constant framing; the composite beats the beauty toggled against it. - Portfolio and report. Gray form views (
colorize_all(False),zb_review.review, restore), albedo and beauty views, ID pass, turntable, one sheet. Report what was measured, what was only looked at, what did not run.
Numbers
| Value | Relative to | Source |
|---|---|---|
| About 4M points per 2K map; 2K = 4M pixels, about 3M usable at 70 percent UV coverage | map side vs vertex count | FlippedNormals 00:30:32; doc |
| FillObject at N percent RGB Intensity = N percent blend | the current color | FlippedNormals 00:02:51 |
| Zones at RGB about 30, wash about 10; cover Draw Size 150, zones 100 or less | brush strokes | doc Painting a Head |
| PVRange 14 | Mask PeaksAndValleys | Pablo 01:26:37 |
| Map Border high (frames 4 and 16), FlipV on | Multi Map Exporter | FlippedNormals 00:31:22 |
| 1920 x 1080 final; 1280 x 720 or 720 x 405 tests; 3200 x 2133 postcard; inches x 300 ppi print | document | Pavlovich; doc |
| Error Threshold 0.01 final, 1 tests (4 s vs 9 s); Progressive 64, 25 with denoise | Redshift | Pavlovich 040h 00:44:03 |
| Main light 0.1 (never 0) with an HDRI; EXR gamma 2.2; macOS Redshift gamma 1.8 | Light, Background | Pavlovich 00:23:12; doc |
| Crease Level one below SmoothSubdiv (3 and 4) | soft edge highlight | Pavlovich 00:21:26 |
| Movie Turntable 180 frames by default | Movie > Modifiers | Pavlovich FvpG 00:19:56 |
| Realistic mean HSV saturation < 0.5 | skin regions, Flat render | Pablo 01:10:30 (middle of the picker) |
| 1 JND = 2.3 Lab; clown share 5 percent; L* span 50; clipping 0.5 percent; noise sigma 2 | gates | [added], calibrate on live renders |
Quality gates
- Measurable:
paint_state(top level, Rgb on, Zadd off, texture off);map_budget;mask_coverageabove 0 before a masked fill;skin_zone_checkson a front Flat render withhead_regions_front()or regions projected from landmarks;render_checks(framing, value span, clipping, noise);upright_scoreon turntable frames with a top marker; files on disk with sizes. - Visual: Flat, SkinShade4 and MatCap White01 sheets in five views for color, and the BPR or Redshift beauty for the final read. Check each at 100 percent for breakup and edges, and as a thumbnail for zones and values. Look for beige or clown, lips, eye focus, the back of the head, bruises, cut shadows and grain. The rubric is
references/critique.md.
Common mistakes
| Mistake | Looks like | Fix |
|---|---|---|
| Painting below the top level, or dividing afterwards | blurry, pixelated paint; artifacts | paint last at the top level; divide first |
| Judging on a tinted MatCap | colors fine in ZBrush, wrong elsewhere | SkinShade4, MatCap White01, Flat Color |
| Mrgb left on while painting | the review MatCap no longer shows; material painted in | Rgb only while painting; M or Mrgb fills only for render materials, on a saved ZTL [added] |
| Gray sheet of a painted model | color shows on MatCap Gray | colorize_all(False), then restore |
| Texture displayed | new paint invisible | texture_off() |
| Raw cavity mask | harsh rings | Mask Adjust with Blur; low intensity |
| Mask By AO on millions of polygons | slow, inaccurate | the Ambient Occlusion plugin |
| UV Master on the painted original | polypaint erased | Work on Clone, Copy UVs, Paste UVs |
| 4K map on 4M points | wasted map, soft texels | recommend_map_size |
| Redshift with a preset material or SSS Weight 1 | paint gone | Polypaint material, Use Material Color 0, white Base |
| Smooth Surface on low-poly hard parts | shading bruises | off; creases with Dynamic Subdivision |
| Orthographic beauty render | flat, technical look | perspective on |
| Movie > Turntable from a script | cannot stop; asks about an existing movie | scripted frames plus assemble_turntable |
| Adding degrees for each frame | flips past 90 degrees | absolute triples from turntable_rotations |
Handoffs
- Receives: from scenario-zbrush-sculpting, scenario-zbrush-character-creature or scenario-zbrush-stylized, a final versioned ZTL at its top level with
stats()and its last review sheet, plus eyes and parts as named SubTools. From scenario-zbrush-hard-surface, parts as SubTools with creases. From scenario-zbrush-pose-print, a posed ZTL (paint with symmetry before posing when possible [added]). - Delivers to scenario-zbrush-retopology-export: the painted ZTL, the map side from
recommend_map_size, and either the exported color map (texture_from_polypaint) or a request for UVs on a clone before the bake. Multi Map Exporter's Texture from Polypaint runs in that skill's map batch. - Delivers to the user: the beauty renders, gray and albedo sheets, passes and composite, the turntable MP4 or GIF, the portfolio sheet and the ZTL path, with what was verified.
- Sister teams: scenario-blender-texturing-shading and scenario-maya-expert for lookdev with vertex color or maps; scenario-zbrush-automation for batch renders.
ZBrush 2026 notes
- Redshift needs its separate application; AOVs with OIDN since 2025.1, Cinema 4D-matched materials since 2025.2. BPR is unchanged.
- Substance Bridge (2026.2) sends polypaint to Painter as a fill layer; its Force UV Auto-Unwrap strips every SubTool's UVs.
- Movies: MP4 (MOV on macOS), resized above 4095 px. Mask by Smoothness fixed in 2024; ZRemesher Keep Polypaint since 2023; Ambient Occlusion plugin since 2021.6.
zb_paint.PATHStags each path; many are [strings] (label exists in this build) or [verify]. Runlive_p01_paint_paths.pyfirst:set()on a missing path is silent.- Nothing in
zb_painthas run in ZBrush yet; 36 offline tests pass; live teststests/code/zbrush-paint-render/live_p01top06.
References
references/procedures.md: full bridge code for every stage. Load before writing any paint or render code.references/critique.md: the rubric, the Z1 and Z2 finishing test cases, and the two evaluation prompts. Load at every gate.references/expert-notes.md: the depth by expert with timestamps, the disagreements and what transfers without a tablet. Load when a decision is not covered here.references/gui-paths.md: palettes, hotkeys and settings for a human or computer-use agent. Load for Spotlight, dialogs and GUI-only steps.references/sources.md: every source with credentials, URLs and timestamps.
Files (skills)
-
references
-
critique.md 23.1 KB
# Critique: how the experts judge paint, renders and presentation Use this rubric on every review sheet before moving on and before delivering. Each item says what to look at, the measurable check when there is one (`zb_paint` function), and whose rule it is. Severity: **B** blocker (do not deliver), **M** major (fix in this stage), **m** minor (note it). Thresholds marked [added] are this skill's starting values, to calibrate on live renders. ## 0. How to look - Three looks for color, every time: Flat Color (pure albedo), a neutral material (SkinShade4 or MatCap White01), and the final renderer (BPR or Redshift). Never judge color on a tinted MatCap (FlippedNormals 8iAbH3zSQak 00:10:51; Pablo d03QU-eUaPo 00:44:58). `zp.paint_review(out, look)` with "flat", "skin", "white", "bpr". - Views: front, three-quarter, profile, back, top. The back of the head gets the same finish as the face (Pablo 01:39:49). - Two scales: 100 percent crop for breakup, noise and edges; a thumbnail (about 256 px) for the read of zones and values, the agent's squint [added method]. - Stage comparisons side by side, never from memory: stage duplicates or ZTL versions (Pablo 00:55:58) and Render Recall for renders (Pavlovich 040hAJ3-cTw 00:41:10). ## 1. Skin color, realistic | # | Look for | Measurable | Rule | Sev | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --- | | 1.1 | Not one beige: warm and cool zones exist | `skin_zone_checks`: "not uniform beige" (zone spread at least one JND, 2.3 Lab) | FlippedNormals 00:00:56 | B | | 1.2 | Forehead yellower than the midface; cheeks, nose, ears redder; lower third of a man bluer or grayer | "forehead yellower", "midface redder", "lower third bluer (male)" | Gurney via FlippedNormals 00:00:31; Pablo 00:47:39 | M | | 1.3 | Zones follow anatomy: yellow over bone and fat, red on thin tissue (eyelids, ears, nostrils, lips), blue in hollows (orbits, under the cheekbones) | visual on Flat front and profile | Pablo 00:49:16 to 00:52:37 | M | | 1.4 | Sex, age, build and skin tone respected: less blue low on a woman; more yellow on a fat character; purple and orange instead of blue and red on dark skin; heavier red in strained creases on an older face | visual | Pablo 00:48:11 to 00:54:18, 01:06:35; FlippedNormals 00:28:20 | M | | 1.5 | Realistic saturation: stays under the middle of the picker; photo reference saturation not copied | "realistic saturation under the middle of the picker" (mean HSV S < 0.5) | Pablo 01:10:30, 01:35:06 | M | | 1.6 | No clown after the cover pass: pure zone hues do not show as patches | "no clown" (share of pixels with S > 0.75 at most 5 percent [added]) | Pablo 01:05:27 | B | | 1.7 | Organic breakup, not airbrush gradients | "organic speckle" (L* high-pass std at least 1.0 [added]); visual at 100 percent | FlippedNormals 00:05:07; Pablo 01:02:36 | M | | 1.8 | Strongest value or saturation contrast at the eyes; eyes and mouth got the most time | "strongest value contrast at the eyes" | FlippedNormals 00:18:44 to 00:20:22 | M | | 1.9 | Lips: edge definition fits the character (tight for a young woman, looser for an old man) | visual close-up | FlippedNormals 00:13:19 to 00:13:51 | m | | 1.10 | Shadows are not painted in, except where light never reaches (nostrils, deep creases) unless the target is a polypaint-only presentation | visual on Flat | FlippedNormals 00:14:16 (see the disagreement table in expert-notes) | M | | 1.11 | Cavity and AO passes compound subtly: each barely shows alone, together they read as complex skin; no dirty rings | `mask_coverage` proved each mask; visual | Pablo 01:20:29 | M | | 1.12 | Pushed enough for the renderer: the final render still shows the zones (shaders dilute polypaint) | compare Flat and final render region stats | FlippedNormals 00:25:26 | M | | 1.13 | Eyes: dark iris over most of the visible eye, lighter bottom and darker top, lighter toward the pupil; eyes slightly divergent | visual close-up | Pablo 01:41:30 to 01:47:03 | M | | 1.14 | Back of the head and ears finished to the face's level | back and profile views | Pablo 01:39:49 | m | ## 2. Color, stylized | # | Look for | Measurable | Rule | Sev | | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | ----------------------------------------- | --- | | 2.1 | Saturation is a choice: pushed past the middle of the picker on purpose, consistently across parts | `zone_report` mean saturation per part | Pablo 01:10:30 | M | | 2.2 | Value read before hue: a grayscale version of the color render still separates face, clothes and props, with the strongest contrast at the face; darkest values kept for lashes and pupils | convert the beauty to L*; compare regions | Guillaume, character digest (UthCuDB1IEQ) | M | | 2.3 | Temperature zones still present but simplified (fewer, cleaner transitions) | visual | FlippedNormals thirds, simplified [added] | m | | 2.4 | Parts are flat and clean where the design says flat (scarf, boots), with breakup only where the design wants texture | visual on Flat | [added] | m | ## 3. Color deliverable (texture or vertex color) | # | Look for | Measurable | Rule | Sev | | --- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------- | --- | | 3.1 | Painted at the highest level, no level added afterwards | `paint_state()["at_top_level"]`, level count unchanged since painting | FlippedNormals 00:08:17 to 00:08:50 | B | | 3.2 | Map size matches the vertex count (about 4M points per 2K; a 4K on a 4M mesh is wasted) | `map_budget(points, side)["pass"]`; `recommend_map_size` | FlippedNormals 00:30:32; doc Texture Maps | M | | 3.3 | UVs exist before the bake; unwrapped on a clone so polypaint survived | `stats()["uv_bbox"]`; colors unchanged on the Flat review after the UV step | FlippedNormals 00:29:59; AskZBrush cemmalvugFk 00:04:00 | B | | 3.4 | Flip V set for other applications; orientation checked with an asymmetric mark | a known mark lands where expected in the exported map (live_p04 method) | FlippedNormals 00:31:54 | M | | 3.5 | No background color at UV seams; no overlaps | Texture Map > Fix Seam; New From UV Check shows no red | doc Texture Map reference | M | | 3.6 | Texture display off again after the bake, so later paint is visible | `paint_state()["tex_on"] == 0` | doc Spotlight basics | m | | 3.7 | Vertex color route: the target app reads it (OBJ `#MRGB` lines or GoZ), or Substance Bridge Send PolyPaint used | file check | Pablo 01:49:50; version deltas 3.7 | M | ## 4. Render | # | Look for | Measurable | Rule | Sev | | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------- | --- | | 4.1 | Perspective on for beauty renders (orthographic only for technical views) | `render_setup` read-back | Pavlovich 040h 00:02:56 | M | | 4.2 | Subject framed, not clipped, same framing across views | `render_checks`: "subject not clipped"; same bbox rule in every view | [added] | B | | 4.3 | Value range used: deep shadows and bright highlights without clipping | `render_checks`: L* span at least 50, clipped whites and blacks at most 0.5 percent [added] | [added] | M | | 4.4 | No shading bruises on low-poly parts; hard edges catch a soft highlight | visual close-up; Smooth Surface off on low-poly hard parts, Crease Level one below SmoothSubdiv | Pavlovich 040h 00:14:01 to 00:21:26; doc Smooth Surface | M | | 4.5 | Shadows not cut by the floor edge; floor or shadow catcher grounds the object | visual; Grid Size raised | Pavlovich 040h 00:37:50; doc Tips | M | | 4.6 | Grain acceptable at 100 percent after denoise (finals) | `noise_sigma` at most 2.0 on 0..255 [added] | Pavlovich 040h 00:44:36 | M | | 4.7 | Redshift only: polypaint visible (Polypaint material, Use Material Color 0, white Base, SSS Weight below 1); texture on a Redshift material, not a MatCap | visual; material parameters read back | doc Redshift Materials, Tips; Pavlovich 00:52:05 | B | | 4.8 | Lighting intent: key, fill and rim readable; ZBrush main light at 0.1 when an HDRI lights the scene, never 0 | visual | Pavlovich 040h 00:23:12 | m | | 4.9 | Test and final settings differ as intended: small document and Error Threshold 1 for tests, full size and 0.01 for finals | recorded settings | Pavlovich 040h 00:28:52, 00:44:03 | m | ## 5. Passes and composite | # | Look for | Rule | Sev | | --- | --------------------------------------------------------------------------------------------------- | ---------------------------------- | --- | | 5.1 | Every pass lines up with the beauty (one stored camera, same framing) | Pavlovich lEP7 00:12:21 | B | | 5.2 | Passes composited through the Mask pass; the BPR Shaded pass edge is not anti-aliased on purpose | doc BPR passes; Pavlovich 00:04:50 | M | | 5.3 | Shadow and AO tinted cool, not muddy; reflections tinted to the environment | Pavlovich 00:08:29 to 00:10:11 | m | | 5.4 | The composite improves on the beauty when toggled against it | Pavlovich 00:11:41 | M | | 5.5 | ID pass: one flat color per part, full coverage of the subject (`id_pass` coverage and part pixels) | Pavlovich 00:16:50 | m | ## 6. Turntable | # | Look for | Measurable | Sev | | --- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --- | | 6.1 | Upright in every frame | `upright_score` > 0 on a model with a top marker (live_p05 settles the Euler rule) | B | | 6.2 | Constant framing and scale; no drift | same silhouette height within a few percent across frames [added] | M | | 6.3 | Frame count and length as briefed; the loop does not repeat frame 0 at the end | frames = n, yaw step = 360 / n | M | | 6.4 | Render quality matches the stills (BPR per frame, or a Redshift render first for Movie > Turntable, Pavlovich FvpG 00:19:56) | visual | M | ## 7. Portfolio presentation - Grayscale form renders separate from color renders (Berger, character digest PcuC8K-bz44 00:06:33). **M** - The set: beauty in four views, albedo (Flat) views, a turntable, the ID pass, and a close-up of the face or focal area. **M** [added set] - Resolution fits the medium: 1920 x 1080 for screens (Pavlovich), pixels = inches x 300 ppi for print (doc), Pavlovich's 3200 x 2133 postcard. **m** - The report says what was measured, what was only looked at, and what did not run. **B** (honesty) ## 8. Test cases from the scenarios (finishing stages) ### Z1 finishing: stylized cartoon adventurer bust (big nose, strong brow, simple ears, scarf) A strong finishing plan contains: - Parts as SubTools filled per part (skin, scarf, eyes, hair or hat) with `fill_subtools`; stylized saturation chosen on purpose (Pablo 01:10:30). - Skin temperature zones still present on the face, simplified: yellow forehead and brow, red nose, cheeks and ears, a cooler lower third if male (FlippedNormals thirds; Pablo zones), by generated zone image or synthesized strokes, with `skin_zone_checks(style="stylized")`. - Eyes with the iris lens fake and a slight divergence (Pablo 01:41:30 to 01:47:03); strongest contrast at the eyes (FlippedNormals 00:18:44). - A grayscale value read of the color render (Guillaume). - BPR renders front, three-quarter and profile with perspective on, floor shadow, 1920 x 1080; a scripted turntable; a portfolio sheet with MatCap Gray, albedo and beauty. - Honest limits: organic breakup and lip edges are the weak spots without a tablet. ### Z2 finishing: realistic reptilian creature head for a film close-up A strong finishing plan contains: - Color is a lookdev reference: painted at the top level after the sculpt is final, never before adding levels (FlippedNormals 00:08:17). - Mask-driven passes: darker scale crevices through the inverted cavity mask, lighter scale tops through the cavity mask itself, which protects the grooves (doc Mask By Cavity: paint the surface of scales without the grooves; `mask_pass(..., invert=False)`), AO plugin undertone on millions of polygons (Pablo 01:37:25), a desaturating wash if realism demands (Pablo 01:31:16), each pass through `mask_pass` at low intensity. - Zone logic transferred to the creature: thin tissue (eyelids, gular skin, nostrils) warmer, bony plates lighter and less saturated (FlippedNormals 00:20:50; Pablo zones) [added transfer]. - Albedo kept clean of painted shadow when the film renderer does ray-traced occlusion and SSS (expert-notes disagreement table). - Texture: UVs from scenario-zbrush-retopology-export (UV Master Work on Clone keeps polypaint), `texture_from_polypaint` at the size `recommend_map_size` gives, Flip V, or Multi Map Exporter Texture from Polypaint in the map batch; vertex count checked against map size. - Presentation: grayscale approval renders for anatomy (Berger) and color lookdev renders separately; BPR with raking key and rim; passes composited through the Mask pass. ## 9. New evaluation prompts for this domain (for the orchestrator's GREEN tests) ### PR1. Realistic skin polypaint without a tablet Prompt: "A finished sculpt of a 50-year-old man's head (single SubTool, 4.2 million points, eyes as separate SubTools, no UVs) is open in ZBrush 2026. Polypaint realistic skin without a mouse or tablet, bake a color texture for Substance Painter or Marmoset, and prove that the result is neither uniform beige nor clown makeup. Give the stages, the exact buttons, sliders or SDK calls, the numbers, and the checks you run on renders." A strong answer contains: paint at the top level and never add levels afterwards; Rgb on with Zadd and Zsub off; neutral material plus Flat for judging; a zone map by anatomy (yellow bone and fat, red thin tissue, blue hollows and the male lower third) made without strokes (generated zone image and planar projection, or synthesized strokes) then a low-intensity cover; FillObject honoring RGB Intensity and masks; cavity, peaks and valleys or smoothness, and AO plugin passes with Mask Adjust and Inverse; a wash for realism; eyes with the lens fake; UVs on a clone (Work on Clone, Paste UVs) before the bake; a 2K map for about 4M points with Flip V; numeric checks on Flat renders (zone hue order, saturation under the middle of the picker, no clown, speckle, eye contrast) plus looking at the sheets; honest limits (organic breakup, C-key sampling, the Adjust Colors dialog). ### PR2. Portfolio presentation and turntable on a Mac without Redshift Prompt: "A painted stylized collectible (body, clothes, scarf, eyes and a base as separate SubTools) must be presented for a portfolio: four beauty views and matching grayscale views at 1920 x 1080, a clown (ID) pass, shadow and AO passes composited over the beauty, and a 10-second turntable MP4. Redshift is not installed on this Mac. Which renderer and settings, how do you script it without a mouse, which traps do you avoid, and what do you check before delivery?" A strong answer contains: BPR (Redshift missing, so no Redshift materials or AOVs); perspective on, floor shadows with enough Grid Size, test at small size then final at 1920 x 1080, resizing the document clears the canvas so redraw and re-enter Edit; Document:Export as the dialog-free way to save renders; BPR pass thumbnails may open a save dialog, so a fallback (configuration renders, solo renders per SubTool for the ID pass instead of Ctrl+W on every SubTool); composite in Python with Multiply for shadow and AO through the mask; a scripted turntable with absolute Euler triples (never added degrees) checked for upright frames, assembled with ffmpeg (for example 120 frames at 12 fps for 10 s), because Movie > Turntable cannot be stopped from a script and asks about an existing movie; the gray-view trap (polypaint shows over any MatCap, so gray views need Colorize off on every SubTool, Pavlovich's Shift-click, then restored; SubTools filled in M or MRGB also keep their material); render checks (framing, value span, clipping, noise) and looking at the contact sheet; delivering the paths and what was not verified. -
expert-notes.md 26.3 KB
# Expert notes: polypaint, rendering, presentation The depth behind SKILL.md, by expert, with source and timestamp. Video ids resolve to URLs in `sources.md`. `[added]` marks this skill's own reasoning or thresholds; `[strings]` marks labels read from this build's UI string table (`ZData/ZLang/english/UInterface.zsc`, 2026-09-24), which prove a label exists, not its palette position. ## 1. FlippedNormals: Henning Sanden and Morten Jaeger (8iAbH3zSQak, 2018) Former senior film character artists (MPC, Cinesite). ZBrush 2018 on screen, tools unchanged in 2026. Principles - The common failure is uniform beige "skin color". Real skin gets its hues from veins, fat and bone underneath [00:00:56 to 00:02:03]. - Color zones in thirds, from James Gurney's "Color Zones of the Face": golden or yellow forehead; red middle third (cheeks, nose, ears); blue, green or gray lower third in men (beard shadow). "Not a hard rule, just a tool" [00:00:31 to 00:01:29]. - Depth decides the hue. Where bone is near the surface the color is lighter and less saturated, "like red latex stretched over a fist" [00:20:50]. Thin skin shows blood; fat hides it [00:01:29]. - Paint broad first, like an underpainting or an airbrusher. The start looks bad on purpose [00:04:01 to 00:05:40]. - Organic speckle beats smooth gradients: Stroke Color Spray with Alpha 07 or 08 gives it "for free" [00:04:35 to 00:05:40]. Blend by sampling a neighbor color (C) and painting across, never by blurring [00:06:01]. - Polypaint is per vertex ("every vertex a color, not every polygon"). A lower level paints blurry. Paint only when the sculpt is done, and never add levels afterwards [00:08:17 to 00:08:50]. - Judge color on a neutral material: SkinShade4 (neutral but bright) or MatCap White01 (no crazy specular), plus a Flat Color view. A red or skin-tinted MatCap makes you overcompensate [00:10:51 to 00:12:29]. - Value: darken selectively where light never reaches (inside the nostrils, deep creases). Avoid painting shadows in general [00:14:16 to 00:15:57]. - Contrast directs the eye. Put the strongest value or saturation contrast at the eyes, and spend most time on eyes and mouth, where digital doubles usually fail [00:18:44 to 00:20:22]. - Paint to be diluted. Matcaps flatter depth, and much of the variation "disappears the moment you put it into a shader", so exaggerate [00:25:26 to 00:26:13]. - Texture size follows vertex count. About 4M polygons needs about a 2K map (2048 x 2048 is about 4M pixels). A 4K map (16M pixels) is wasted on a 4M mesh, and soft paint would even survive at 1K [00:30:32 to 00:31:22]. - Hand paint the broad pass in ZBrush; pore-level color comes from polarized photos or projected scan maps in Mari [00:21:56 to 00:24:21]. Numbers - FillObject applies at the current RGB Intensity: 10 percent fills at 10 percent [00:02:16 to 00:02:51]. - Paint mode: Rgb on, Zadd and Zsub off, else you sculpt while painting [00:03:09]. - Rgb Intensity 100 at first, around 41 later; Z Intensity 25; Draw Size about 6 to 16 (frame readouts) [00:03:08 to 00:22:38]. - Alpha 22 at full intensity gives veins [00:21:23]. - Multi Map Exporter, Texture From Polypaint: Map Size by vertex count, Map Border high (frames show 4 and 16), FlipV on, Create All Maps; UVs required [00:29:59 to 00:31:54]. Mistakes: painting on a lower level; adding levels after painting; a tinted matcap; shadows painted everywhere; smeared lips on a character that needs tight lips [00:13:51]; a map too big for the mesh; forgetting FlipV [00:31:54]. ## 2. Pablo Munoz Gomez (d03QU-eUaPo, livestream 2025-10-01) Concept and character artist, ZBrushLIVE presenter, Pablander Academy founder. The most agent-friendly recipe in the sources: after a short zone map, almost every refinement is a mask plus a color operation. Order of work 1. Material: SkinShade4, or his free "Albedo Polypaint" matcap (no reflection, a little shading). A colored matcap contaminates the color and the export looks awful elsewhere [00:44:58 to 00:46:36]. 2. Zone map in pure hues. Fill pure yellow (Color > FillObject), then paint yellow at the top, red in the middle, blue (more cyan) at the bottom with symmetry on [00:46:36 to 00:48:11]. The choices "are not arbitrary" [00:55:26]: - Yellow where fat or bone blocks light: cranium, glabella, nose bridge, fat around the eyes, prominent cheekbones [00:49:16 to 00:49:49, 00:55:26]. - Red where tissue is thin or SSS is strong: eyelids, ear cartilage, nostrils, mouth [00:50:21 to 00:50:56]. - Blue in hollows and in areas that receive less light: cheeks over the teeth, orbits, above the clavicle, parietal and occipital regions [00:50:56 to 00:52:37, 00:54:51]. - Male: more blue low (stubble) [00:48:11]. Fat character: more yellow [00:48:42]. Female: less blue at the bottom, more red [00:54:18]. - Duplicate the SubTool at each stage as a comparison frame [00:55:58]. 3. Blend with texture: Color Spray with Alpha 08 in between colors, noisy breakup, checked in Render > Flat against Preview [01:02:03 to 01:03:46]. "Still looks like a clown" is the expected state here [01:05:27]. 4. Cover with skin: a fleshy neutral color over the whole face at low opacity, so the zones shine through; then a lighter yellowish color; then saturated orange-red accents on membranes with a higher RGB Intensity and a smaller brush [01:06:35 to 01:12:08]. Darker skin: purple instead of blue, orange instead of red, same zones [01:06:35]. Experienced painters can skip the pure-hue stage [01:08:14]. 5. Integrate: Solo so the eyes stop recoloring with the active SubTool; bluish desaturated tones, pink cheeks, a darker tone toward the center of the face, a dark mouth interior [01:12:40 to 01:14:54]. 6. Cavity pass at the highest level: Mask By Cavity; the raw mask is harsh, so shape it in Mask Adjust and Apply; invert; hide the mask display; paint a darker, redder color with a large brush; clear [01:16:40 to 01:20:29]. 7. Smoothness pass: PeaksAndValleys tried with PVRange 14 and less coverage, then Mask By Smoothness with a larger Range; Mask Adjust; invert; Tool > Polypaint > Adjust Colors with the mask hidden: saturation down, hue toward yellow, intensity up, RGB contrast on blue; clear [01:26:05 to 01:29:59]. 8. Peaks and valleys pass on a region, a little more saturated and darker [01:29:59]. 9. Wash: a very light color at large size and low intensity desaturates for realism [01:31:16]. He keeps the unwashed version because he likes saturation. 10. Creature tint on the whole mesh: Adjust Colors, Tint 1 to preview a pinkish red, then reduce; Hue, Saturation, Intensity; RGB Intensity per channel; RGB Contrast [01:31:53 to 01:33:29]. 11. Color variance on the brush (Stroke > Modifiers, about 0.5) [01:34:01, uncertain caption]. Paint very saturated red where a later wash will pull it back [01:35:39]. 12. Ambient Occlusion plugin: Zplugin > Ambient Occlusion > Compute makes a ray-traced AO mask with contact shadows, far more accurate and faster than Mask By AO on millions of polygons; invert; tint (test with green first), then a purple-red undertone [01:37:25 to 01:39:04]. 13. Finish the back of the head to the level of the face [01:39:49]. 14. Eyes: a dark iris over most of the visible eye; lighter at the bottom and darker at the top to fake the cornea lens; lighter toward the pupil; then rotate the eyes slightly apart, because straight eyes look dead [01:41:30 to 01:47:03]. 15. Export: polypaint is vertex color. OBJ with polypaint into Marmoset or Blender, or Substance Painter as an albedo base [01:49:50 to 01:52:39]. Principles - Saturation is the stylization dial. Realistic skin stays in the lower-left, desaturated half of the picker; crossing the middle takes it into stylized territory [01:10:30 to 01:11:35]. - Highlights desaturate toward the light color and shadows look more saturated. Pushing saturation past the natural norm reads as stylized, even under realistic light [01:22:45 to 01:25:00]. - Photo references are usually color-boosted: do not copy their saturation [01:35:06]. - Mask Adjust is the control point of every generated mask [01:17:45]. - Polypaint during sculpting is a "working polypaint" that holds the palette, not a texture [01:01:32]. ## 3. Maxon documentation: polypaint, color, masking, Spotlight, texture maps `sources/docs/paint-pose-render-print__polypaint-spotlight.md` (12 pages, 2026 help site). - Polypaint needs no UVs and no fixed map size; resolution lives in the mesh ("Make sure there are sufficient polygons"). Spotlight is polypaint too (Polypaint; Spotlight basics). - A displayed texture map hides polypaint: Texture Off before painting or projecting (Spotlight basics; Painting a Head). - FillObject obeys the draw mode: Mrgb assigns color and material, Rgb color only, M material only (Color palette). The shipped CreateEyeballs macro stores Draw:Mrgb, Rgb, M and Color:R, G, B, sets Mrgb 1, fills, then restores all of them (macros doc). - Colorize on an unpainted SubTool fills it white; afterwards it toggles the display (Polypaint reference). - Polypaint From Polygroups turns group colors into paint; the reverse trip through a texture is "unlikely to be precise" (Polypaint reference). - Painting a Head values: cover with a dull tone (Colorized Spray, Draw Size 150); zones with Draw Size 100 or less at RGB Intensity about 30; Alpha 25 for spatter; unify with a light near-white color at RGB Intensity about 10 "as a wash", never opaque in one spot. "The forehead is usually yellowish, the muzzle of the mouth is reddish and the ears have a slightly purplish tint. Think in terms of warm and cool color combinations." - Mask By Cavity: Blur 100 softens, Intensity sharpens (negative inverts), a Cavity Profile curve maps depth to mask. View it with Preferences > Edit > Masked Object Dimming at 5 and the Flat Color material. - Mask Ambient Occlusion: Occlusion Intensity enlarges the dark area, AO ScanDist the reach, AO Aperture the spread; view with Flat Color. Mask By Smoothness: set Range and Falloff before pressing. PeaksAndValleys: PVRange (falloff distance), PVCoverage (amount). Mask By Color (Intensity, Hue, Saturation) reads the texture or, without one, the polypaint. - Mask Adjust Apply is "absolute rather than accumulative": repeated presses change nothing unless Blur changes. - Mask by PolyPaint: eight color channels with tolerance, Blur up to 25; grayed out without visible polypaint. - Flip and Mirror by Posable Symmetry: store an undo marker, Mask Changed Points, Flip mask by posable symmetry, Inverse, Mirror By Posable Symmetry. Without a mask, Mirror averages both sides (faded). - Spotlight: import to the Texture palette, Add To Spotlight; Shift+Z toggles, Z paints; Radius 0 disables painting; Opacity does not change the amount painted (RGB and Z Intensity do); pure black is transparent; Pin Spotlight repeats one spot; Nudge distorts to fit a sculpt. - Convert a texture to polypaint: divide until the polycount is near the texture's pixel count ("a 2k texture map has 4 million pixels in it. Your UVs only use 70% or less of that space so the pixel count is close to 3 million"), then Polypaint From Texture. - Bake polypaint to a map: UVs at SDiv 1 (PUV, UV Master or outside, Store MT trick), UV Map Size 2048, SDiv highest, Texture Map > New From Polypaint. Fix Seam repairs background color at seams; New From UV Check shows overlaps in red. ## 4. Michael Pavlovich: Redshift in depth (040hAJ3-cTw, 2025) Director of Character, Weapon and Vehicle Art at Certain Affinity, ZBrushLIVE streamer. ZBrush 2025 on Windows. - Turn Render > Redshift Renderer > Redshift on once; BPR or Shift+R then renders with Redshift; every render stays in the BPR and AOV slots [00:02:25 to 00:02:56]. - Perspective on (P) before rendering, or the render is orthographic [00:02:56]. - Floor: Shift+P; Elevation -1 snaps to the lowest SubTool; raise Grid Size so shadows are not cut [00:03:51 to 00:04:58, 00:37:50]. Store Cam for repeatable views; focal length 50 [00:05:16 to 00:05:48]. - Color and material per SubTool: standard brush, ZAdd off, M or MRGB, RGB Intensity 100, Color > Fill Object. Filling turns Colorize on. Shift-click the Colorize icon toggles it for all SubTools [00:09:02 to 00:12:54]. - Ctrl+R renders a region; Escape pauses; Escape during a bucket render stops it and still denoises [00:13:25, 00:45:09]. - Smooth Surface is on for every SubTool by default and averages normals: "bruises" on low-poly meshes. Turn it off and use control loops or creases with Dynamic Subdivision. Crease Level one below SmoothSubdiv (3 with 4) gives a soft edge highlight; equal gives razor CG edges [00:14:01 to 00:21:26]. - Keep the ZBrush main light on at 0.1 intensity: off makes material previews vanish [00:23:12]. The background slot lights the scene even with Use Default Panorama off [00:33:32]. EXR gamma 2.2; a JPEG loads at 1 and sticks [00:34:06 to 00:35:11]. - Shadow catcher: floor material Shadow Catcher, or any material with Is Shadow Catcher 1; floor reflections with Metalness 1 and low roughness [00:37:19 to 00:40:16]. - Render Recall keeps the last 100 renders with material, light, color and filter settings; compare side by side, re-render from a recalled state; depth of field is not stored [00:41:10 to 00:42:17, 00:51:34]. - Speed ladder: a smaller document first, then Error Threshold (0.01 default; 1 took 4 s instead of 9 s), then iterations; Progressive 64 default, 25 with denoise for checks, 1 is useless [00:28:52, 00:43:00 to 00:47:55]. - Image size: Document Width 1920, Pro off, Height 1080, Resize, clear, redraw, Edit; tests at 720 x 405 or 1280 x 720 [00:27:45 to 00:30:33]. - Use Material Color 0: polypaint drives color; keep the Base swatch white [00:52:05 to 00:53:52]. Glass IOR 1.5, water 1.333, iron 2.95 [00:57:47]. SSS scale depends on units (C4D cm, ZBrush mm), mode 2 Random Walk most accurate [00:59:07]. Emission Weight 100 overrides polypaint and adds noise [01:00:34]. - Camera movie: keyframe camera positions on the Timeline, one Redshift render first, then Ctrl+Shift click the slider to render every frame; Export with H [01:02:00 to 01:04:42]. Save a render preset (Render:Save) and a ZPR for materials and lights [01:05:28]. ## 5. Michael Pavlovich: Redshift quickstart and turntable (FvpG2tCP8Lw, 2025) - ZPlugin > SubTool Master > Fill "color and material" fills every SubTool at once [00:06:50]. - Drag-and-drop of Redshift materials crashed his 2025 build: select the material, M, Fill Object [00:04:25]. - Use Material Color 1 overrides polypaint; setting it back to 0 still tints with the swatch, so set the swatch white [00:05:13 to 00:06:16]. - Loading the default EXR looks dark: raise Redshift Exposure from 0.35 to 1 [00:10:02]. - Turntable: Movie palette, Document, Large, overlay and background image opacity 0, frames (180 default; he meant 30), then a Redshift render first; Movie > Turntable renders every frame with Redshift. Without the prior Redshift render it records viewport quality, a quick preview [00:19:24 to 00:20:28]. Escape did not stop it [00:21:00]. Check the frame count before launching. ## 6. Michael Pavlovich: Redshift and BPR passes for compositing (lEP73nEu3Tc, 2025) - Rebuild the beauty from passes so each component gets its own levels, hue and opacity; tuning beats re-rendering [00:04:34, 00:11:41]. - Document at print size: 3200 x 2133 px (4 x 6 postcard proportion) [00:01:13]. - Redshift AOVs: All, Denoise All; save Beauty, Diffuse, Reflection, Shadow, AO, GI, Depth, World Position, Bump Normal; skip unused ones. Add BPR Depth and Mask [00:02:18 to 00:02:38]. - Utility passes hide masks in single channels: check R, G, B of normals and world position [00:03:30]. - Blend modes: Diffuse base; Reflection Screen (or Soft Light for BPR reflections) with clipped Levels and Hue/Sat; Shadow and AO Multiply, tinted cool; GI Lighten; rim and fill Screen; bloom Screen [00:05:46 to 00:26:32]. - Keep a stored camera so every pass lines up [00:12:21]. Turn Redshift off before BPR passes [00:12:53]. - BPR AO: blur 1, resolution up, rays up a little; his Redshift AO looked better [00:12:55, 00:20:25]. - Cheap BPR light passes: Standard Basic material (not Redshift), color black so only light shows, Specular 84 with a tighter curve, one light sent behind and left for "light rim", the other side broader and weaker for "light fill" [00:14:55 to 00:16:27]. Generic reflection: Colorize off for all, white, a shiny MatCap [00:13:49]. - Clown pass: Polyframe lines off, Flat Color, one polygroup per SubTool, white [00:16:50]. Selections by part in the composite [00:19:20]. - Finishing: lens blur from depth, dodge, burn, fake SSS on midtones, bloom, background gradient [00:23:20 to 00:27:25]. ## 7. Michael Pavlovich: Redshift Baker 360 (mW4P0T6tR7k, 2025) - Any material can be baked: set up lights and materials as for a render, then Redshift Baker 360 projects the look into polypaint from many angles [00:01:22 to 00:03:11]. Total steps = Longitude x Latitude (doc). - Judge the bake with Flat Color in a neutral scene; if moving the light changes nothing, the bake worked [00:05:47]. MicroPoly in Dynamic Subdivision makes the preview look wrong [00:04:15]. - Polish: Adjust Colors; ZPlugin Ambient Occlusion mask with Occlusion Volume for all SubTools, inverted, a navy color, Color > Fill Object adds baked AO [00:06:18 to 00:07:06]. This is the expert evidence that FillObject paints through a mask. - Texture: New From Polypaint, Clone Texture, Texture > Export as JPEG [00:07:49 to 00:08:36]. "An easy way to bake in some ambient occlusion, some lighting, and material properties to get a reliable color map" [00:08:05]. ## 8. Maxon documentation: rendering (Redshift, BPR, LightCap, KeyShot, movies) `sources/docs/paint-pose-render-print__rendering-redshift-bpr-keyshot.md`. - Redshift is a separate application that must be installed; GPU if supported, else CPU. On this Mac `mx1 product list` shows Redshift "uninstalled, unlicensed" (2026-09-24): BPR is the renderer until Emmanuel installs it. - BPR renders 3D models anti-aliased at full document size with all SubTools; Best is for 2D work. - Smooth Surface: "designed for making low resolution and faceted meshes look good, so don't use it with high resolution meshes!" - Redshift Polypaint Materials have Use Material Color off, so polypaint drives color; Preset Color Materials override it. Textures need a Redshift material (MatCaps will not show them); higher SSS Weight shows less polypaint (1 shows none); LightCaps are not supported by Redshift; Redshift materials hide polyframes. - PBR: Metalness 0 to 1, keep Reflection Weight 1, dielectric IOR 1.4 to 1.6, SSS Mode 2 random walk for thin detailed geometry, Coat IOR 1 disables coat. Transmission needs Double on. - Gamma Correction 2.2 on Windows, 1.8 on macOS. - Tests: Disable DynamicSubdiv, ArrayMesh, NanoMesh. Render Recall does not reload the model. Region render Ctrl+R. - AOVs: Beauty, Shadow (cast shadows as white), SSS, GI (multiplied by diffuse), Background, Diffuse, Reflection (no light speculars), Refraction; possibly AO, World Position, Bump Normal, Depth (flagged "may not make it into the release"). The 2026.2.1 string table lists Beauty, Shadow, AO, SSS, GI, Depth, Background, Bump Normal, Diffuse, Object ID, World Position, Reflection, Refraction and "Export All to PSD" [strings]. Depth filter Full for anti-aliased, Center Sample for exact depth. - BPR passes: click a pass icon to save; Shadow and AO need their options on before rendering. The Shaded pass is not anti-aliased at the silhouette on purpose: composite with the Mask pass. - Print resolution: pixels = inches x ppi (300 ppi for magazine or book); resize the document before starting. - Redshift and Turntable/TimeLine: "Simply do a Redshift render first then do the Turntable." - KeyShot bridge: one-way; save KeyShot renders yourself. - Movies: MP4 on both systems, MOV on macOS since 2025; width or height above 4095 px is resized (version deltas 3.9). ## 9. Related experts outside this skill's folders - Pavlovich, topology video (n5_cZK-9peg, 2023): for ZRemesher density he isolates a polygroup and uses Color > FillObject at Rgb Intensity 100, starting from a white fill [01:00:48 to 01:02:38]. That is a zone fill by polygroup, done with Ctrl+Shift clicks the SDK cannot send. - #AskZBrush, Joseph Drust (_ips3GhWI0s, 2016): Project All transfers polypaint only when the target has polypaint enabled; otherwise a popup asks. Project geometry first, color last [00:05:25 to 00:06:29]. - #AskZBrush UV clips (cemmalvugFk): never unwrap the subdivided, painted SubTool in UV Master directly; Work on Clone, Copy UVs, Paste UVs, because control painting erases polypaint [00:04:00 to 00:07:28]. - Character digest: Berger (Lightstorm) keeps color as a separate approval and presents grayscale to discuss anatomy [PcuC8K-bz44 00:06:33, 00:37:47]; Blizzard polypaints the blockout to read the design [FRZtVXpAokc 00:37:33]; Guillaume adds no color until the modeling is tight and checks a grayscale value read with the strongest contrast at the face [UthCuDB1IEQ 00:48:59]. Deciding condition: design exploration uses color, production and anatomy approval stay gray. ## 10. Where the experts disagree | Choice | Option A | Option B | Deciding condition | | ---------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | First fill | A dull skin base (FlippedNormals 00:02:16; doc Painting a Head) | Pure yellow, zones, skin only later (Pablo 00:46:36) | For an agent the zone-first route is more checkable (zone_report on each stage); experienced painters skip it (Pablo 01:08:14) | | Blending | Sample with C and paint across, never blur (FlippedNormals 00:06:01) | Color Spray passes, washes, Adjust Colors through masks (Pablo 01:02:36, 01:31:16) | Local transitions: sampling. Global temperature or saturation: washes and masked fills | | Painted shadow | Only where light never goes (FlippedNormals 00:14:16) | AO-driven purple undertone and dark mouth interior on purpose (Pablo 01:37:25, 01:14:54) | Final renderer with ray-traced occlusion and SSS: keep albedo clean. Polypaint-only presentation, stylized look or game albedo without AO: paint the undertone | | Saturation | Exaggerate, shaders wash it out (FlippedNormals 00:25:26) | Stay desaturated for realism, saturate for stylized, over-saturate before a wash (Pablo 01:10:30, 01:35:39) | Target style, and whether a wash or shader follows | | Mask Adjust Apply | Pressed twice (Pablo 01:18:52) | Absolute, repeats do nothing unless Blur changes (doc) | Trust the doc; change Blur between presses; live_p02 records the 2026.2.1 behavior | | Mask By AO vs AO plugin | Mask By AO (doc) | Zplugin Ambient Occlusion, faster and more accurate on dense meshes (Pablo 01:37:25) | Millions of polygons or contact shadows between SubTools: the plugin (Occlusion Volume, Pavlovich 00:06:30) | | Render path | Redshift with AOVs (Pavlovich) | BPR with cheap light, reflection and clown passes (Pavlovich) | Redshift only when installed; BPR for utility passes and on this Mac today | | Low-poly smoothing at render | Dynamic Subdivision with creases (Pavlovich 00:15:43) | Redshift Smooth Surface | Hard edges: creases. Smooth organic low-res shapes only: Smooth Surface; never on high-res (doc) | | Turntable | Movie > Turntable after a Redshift render (Pavlovich FvpG 00:20:28) | Scripted frames plus Document:Export [added] | An agent cannot stop Movie > Turntable (Escape failed for Pavlovich) and it asks about an existing movie [strings]: scripted frames, assembled outside ZBrush | | Main light "off" | 0.1 (040h 00:23:12) | 0.001 (FvpG 00:07:23, caption) | Either keeps material previews alive; never 0 | ## 11. What transfers to an agent without a mouse [added] - Transfers well (deterministic): FillObject at a chosen RGB Intensity, per SubTool, through a generated mask, through a hidden or split polygroup; every mask generator plus Mask Adjust, Inverse, Clear; AO plugin; Polypaint From Polygroups; Polypaint From Texture from an image the agent generates; New From Polypaint; BPR and Document:Export; scripted turntables; Python composites of passes; numeric color gates on Flat renders. - Transfers partly: zone layout (a generated zone image through planar UVs, or synthesized Color Spray strokes with the proven stroke engine; placement is good, organic breakup is weaker than a hand); eyes and lips (a generated iris image on a spherical-UV eye; lips by mask or stroke). - Stays weak: sampling blends with C (pixol_pick plus a low-intensity stroke is a rough stand-in), Spotlight dial work (clicks and drags on a widget: a computer-use path), curve widgets (Mask Adjust and Cavity profiles: keep defaults, vary Blur), the Adjust Colors dialog until live_p06 shows it is scriptable. -
gui-paths.md 23 KB
# GUI paths, hotkeys and settings (for a computer-use agent or a human) The same operations as `procedures.md`, as a person does them in the ZBrush 2026.2.1 interface. Tags: [doc] Maxon help; [expert] shown by the named expert; [strings] label present in this build's UI string table (`ZData/ZLang/english/UInterface.zsc`, 2026-09-24); [verify] not confirmed. macOS: docs write Ctrl for Command, but Control+W assigns a polygroup and Command+W quits ZBrush (version deltas 3.13): never press Command+W. ## Paint setup | Action | Where | Notes | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Paint without sculpting | top shelf: Rgb on, Zadd and Zsub off | FlippedNormals 00:03:09 [expert] | | Color plus material / color only / material only | Draw: Mrgb, Rgb, M | decides what FillObject assigns [doc] | | Paint strength | Draw: Rgb Intensity | also scales FillObject (FlippedNormals 00:02:51) [expert] | | Main and secondary color | Color palette picker; V swaps (SwitchColor [strings]); C picks the color under the cursor; Alt paints the secondary color (Alternate) | [doc] [expert] | | Fill the SubTool | Color > FillObject (tooltip "Fill 3D Object Masked" [strings]) | masked and hidden parts stay untouched [expert Pavlovich mW4P 00:06:30; n5 01:00:48] | | Fill every SubTool | Zplugin > SubTool Master > Fill, "color and material" | Pavlovich FvpG 00:06:50 [expert] | | Show or hide paint | Tool > Polypaint > Colorize, or the paintbrush icon of a SubTool; Shift-click the icon for all SubTools | on an unpainted SubTool Colorize fills white [doc]; Shift-click [expert Pavlovich 040h 00:12:23] | | Paint from groups | Tool > Polypaint > Polypaint From Polygroups | [doc] | | Paint from a texture | Tool > Polypaint > Polypaint From Texture | divide to about the texture's pixel count first [doc] | | Global recolor | Tool > Polypaint > Adjust Colors (dialog: Tint, HSV Hue, HSV Saturation, HSV Intensity, RGB Intensity, RGB Contrast, RGB Gamma; OK, Cancel [strings]) | Pablo 01:27:43, 01:31:53 [expert]; blocks a script? [verify live_p06] | | Solo one SubTool | Solo button on the right shelf | eyes stop recoloring with the active SubTool (Pablo 01:12:40) [expert] | | Stage snapshot | Shift+S (canvas snapshot); or Duplicate the SubTool per stage | Pablo 00:55:58, 01:05:27 [expert] | ## Brushes, strokes, alphas for paint - Standard brush for everything (FlippedNormals "one brush, one alpha, one hotkey" 00:16:00) [expert]. - Stroke > Color Spray with Alpha 07 or 08 for speckled zones; Alpha 22 at full intensity for veins; doc: Colorized Spray, Draw Size 150 cover, 100 or less zones at RGB about 30, Alpha 25 spatter, Alpha 07 wash at RGB about 10 [expert] [doc]. - Stroke > Modifiers color variance about 0.5 (Pablo 01:34:01, uncertain caption) [verify]. - Soft custom brush and Color Spray blends; C to sample and paint across instead of blurring (FlippedNormals 00:06:01) [expert]. ## Symmetry for paint - Transform > Activate Symmetry, X. For a posed or asymmetric sculpt with symmetric topology: Transform > Use Posable Symmetry; fails after DynaMesh or Sculptris Pro (FlippedNormals 00:03:09 to 00:04:01) [expert]. - Mirror paint side to side: paint one side; in Undo History Ctrl+click the marker before the paint; Tool > Masking > Mask Changed Points; Flip By Posable Symmetry; Inverse; Tool > Polypaint > Mirror By Posable Symmetry; Ctrl+click the marker again. Without a mask the mirror averages both sides [doc]. The undo-marker step is not scriptable; an agent paints with symmetry before posing instead [added]. ## Masking for paint passes (Tool > Masking) | Generator | Controls | Notes | | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | Mask By Cavity | Blur (100 softens), Intensity (sharpens; negative inverts), Cavity Profile curve | view with Preferences > Edit > Masked Object Dimming 5 and Flat Color [doc] | | Mask Ambient Occlusion (group Mask By AO) | Occlusion Intensity, AO ScanDist, AO Aperture, AO Rays | slow on millions of polygons (Pablo 01:37:25) [expert] | | Mask By Smoothness | Range, Falloff: set before pressing | fixed again in 2024 (version deltas 3.2) [doc] | | Mask PeaksAndValleys | PVRange (Pablo 14), PVCoverage | [doc] [expert] | | Mask By Color | Mask By Intensity, Mask By Hue, Mask By Saturation (texture or polypaint) | [doc] | | Mask By Polypaint | dialog, 8 color channels, tolerance, Blur up to 25 | grayed out without visible polypaint [doc] | | Mask Adjust | Apply, Blur, Mask Adjust Profile curve | absolute: change Blur between presses [doc] | | Mask Changed Points, Boost, Dilute, Grow, Shrink, Grow All, Shrink All | buttons | [doc] [strings] | | Canvas gestures | Ctrl+click canvas inverts; Ctrl+drag on empty canvas clears; Ctrl+click the mesh blurs; View Mask toggles the display (Pablo maps Ctrl+H) | [expert] | Zplugin > Ambient Occlusion (ships since 2021.6): distance, aperture, samples, resolution, OcclusionVolume (include other SubTools), Recalc, Compute, output mask or color (polypaint), smooth [strings]. Pablo: invert, tint through the mask [01:37:25]; Pavlovich: invert, navy, Fill Object [mW4P 00:06:30]. ## Spotlight (dial driven: a computer-use path) - Texture > Import, Add To Spotlight (or double-click a texture in LightBox); Shift+Z toggles Spotlight; Z switches between the dial and painting; Radius above 0 or Z does nothing [doc]. - Dial: rotate, scale, move, nudge, clone, opacity (does not change the amount painted), fade, pin (repeat the spot under the dial center), restore, fill. Pure black is transparent. Save Spotlight and Load Spotlight store sets [doc]. - Brush > Samples > Spotlight Projection off uses the images as reference only; MidValue and Alpha Blur sliders since 2023.2 (version deltas 3.2) [doc]. - No texture displayed on the model while painting [doc]. ## Texture and vertex color | Action | Where | Notes | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Map size | Tool > UV Map > UV Map Size; presets 512 to 4096 | [doc] | | Planar, cylindrical, spherical, box UVs | Tool > UV Map: Uvp, Uvc, Uvs, Uvb [strings]; Delete UV [doc] | Uvp maps from the Preview window orientation [doc] | | Real UVs | Zplugin > UV Master > Work on Clone, Unwrap, Copy UVs, back to the original, Paste UVs; or Tool > UV Map > Create (Unwrap) | Work on Clone keeps polypaint (AskZBrush) [expert]; owner: scenario-zbrush-retopology-export | | Bake paint to a map | Tool > Texture Map > New From Polypaint; Clone Txtr; Texture > Flip V (Flpv [strings]); Texture > Export | Pavlovich mW4P 00:07:49 to 00:08:36 [expert] | | Fix seams, check overlaps | Tool > Texture Map > Fix Seam; New From UV Check (red = overlap) | [doc] | | Map batch | Zplugin > Multi Map Exporter: Texture from Polypaint, Map Size, Map Border high, FlipV, Create All Maps | FlippedNormals 00:29:59 to 00:31:54 [expert] | | To Substance 3D Painter | Texture > Substance Bridge > Send to Painter, Send PolyPaint (fill layer) | 2026.2.0 (version deltas 3.6) [doc] [strings] | | Vertex color OBJ | Tool > Export with polypaint (Preferences > ImportExport) | [verify preference name] | ## Materials for judging and rendering - Judge color: SkinShade4, MatCap White01, Flat Color (FlippedNormals 00:10:51; Pablo 00:44:58); Pablo's free Albedo Polypaint matcap is not installed here [expert]. - Form read: MatCap Gray (lead review default). - BPR light passes: BasicMaterial, black color, Specular 84 (Pavlovich lEP7 00:15:28) [expert]. - Redshift materials (shipped in `ZData/Materials/RedshiftMaterials`): Basic, Clay, Plastic, RSPlastic, ShadowCatcher and about 60 more. Material > Modifiers: Use Material Color (0 = polypaint drives color), Is Shadow Catcher, Metalness, Reflect roughness, Reflect IOR, SSS amount, SSS scale, SSS Mode, Emission weight [strings] [doc]. Drag-and-drop crashed in 2025: select, M, Fill Object (Pavlovich FvpG 00:04:25) [expert]. ## Render | Action | Where / hotkey | Notes | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | Render | Render > BPR, Shift+R | also Redshift when it is on [doc] | | Region render | Ctrl+R with the cursor over the area; Render > Cursor | [doc] [strings] | | Stop | Esc (a bucket render stops and still denoises) | Pavlovich 040h 00:45:09 [expert] | | Display modes | Render > Flat, Fast, Preview, Best | Flat for pure color (Pablo 01:03:11) [doc] | | BPR switches | Render > Render Properties: Shadows, AOcclusion, SSS, Transparent; BPR Shadow and BPR AO sub-palettes (Rays, Angle, Res, Blur) | turn Shadow and AO on before rendering to get those passes [doc] | | BPR passes | Render > BPR RenderPass thumbnails: Com, Img, Dep, Shdw, AmOc, mask, SSS, Floor [strings]; click to save | Shaded pass edge not anti-aliased on purpose [doc] | | Redshift | Render > Redshift Renderer: Redshift, Floor Material, Floor Color, Gamma Correction (1.8 macOS), Render Quality, Denoising, Error Threshold, Samples Per Pixel, Progressive Rendering, Progressive Iterations, Primary and Secondary GI Engine Quality, GI Bounce, GI Rays, Depth Of Field, Focus Depth, Focus Radius, Disable DynamicSubdiv, Disable ArrayMesh, Disable NanoMesh, Redshift Baker 360 with Bake Longitude Steps, Bake Latitude Steps, Bake Color Mode, Preserve Highlights [strings] | needs the Redshift application (uninstalled on this Mac, 2026-09-24) | | Redshift AOVs | Render > Redshift AOV Passes: P.All, N.All (denoise all), per-pass buttons (Bty, GI, Bkgrd, BuNo, Obj ID, W Pos...), Export All to PSD [strings] | Pavlovich lEP7 00:00:40 [expert] | | Render Recall | Render: Store Renders, Render Recall slider, Previous Recall, Next Recall [strings] | last 100 renders; DOF not stored [expert] | | Per-SubTool smoothing | Tool > SubTool > Redshift Properties > Smooth Surface | low-res organic only [doc] | | Save presets | Render > Save, Load; File > Save As a ZPR for lights and materials | Pavlovich 01:05:28 [expert] | ## Scene, camera, lights, document - Perspective P; floor Shift+P; Draw > Elevation (-1 snaps to the lowest SubTool), Grid Size (raise it for shadows), Angle Of View (focal length 50 in Pavlovich's Universal camera) [expert] [strings]. - Draw > Cameras > Store Cam, Select Camera (repeatable views; the name prompt is a text dialog [verify]) [strings]. - Light palette: click the light dot to place it, click again to send it behind (rim), tap a bulb to select and again to switch it on; Intensity (keep 0.1 when an HDRI lights the scene), Shadow, color; Redshift Light Properties: Shadow Strength, Shadow Softness, Shadow Samples, Light Distance (1.1 default), Light Range, Intensity Multiplier, Decay Type, Decay Start [expert] [strings]. - Light > Background: environment texture, Longitude, Latitude, Tilt, Rotate With Object, Exposure, Gamma (2.2 for EXR; a JPEG sets 1), Redshift Exposure (default EXR looks dark: 0.35 to 1), Redshift HDR Shadows [expert] [strings]. LightBox > Texture > Panoramas. - Document: Width, Height, Pro off to unlink them, Resize; the canvas clears, redraw the tool and press T (Edit); Zoom to see the frame, Actual for real pixels; Back color swatch [expert]. ## Movies and turntables - Movie > Doc (document only), Large (100 percent), Turntable (Record Turntable), Modifiers: turntable frames (180 default), Overlay Image and Title Image opacity 0; Play Movie; Export with H for highest quality; MP4, MOV on macOS; above 4095 px resized [expert] [doc]. - A Redshift render first, then Turntable, or it records viewport quality (Pavlovich FvpG 00:19:56). Escape did not stop it (00:21:00). With a movie in memory, Turntable asks "Export Existing" or "New Turntable" [strings]. - Timeline: Movie > TimeLine Show; click the timeline to key the camera; Ctrl+Shift click the slider renders every frame with Redshift (Pavlovich 040h 01:03:36) [expert]. ## Hotkeys used here C pick color; V swap colors; Alt secondary color; Shift+S snapshot; Shift+R render; Ctrl+R region render; Esc stop; P perspective; Shift+P floor; Shift+Z Spotlight on or off; Z Spotlight paint mode; T Edit; comma LightBox; D and Shift+D Dynamic Subdivision on and off [doc] [expert]. -
procedures.md 19.2 KB
# Procedures: polypaint, texture, render, turntable through the bridge Every procedure is agent-side Python that drives ZBrush 2026.2.1 through the lead bridge (`zb_launch`) and this skill's module `scripts/zb_paint.py`. Status of every block: **not yet run in ZBrush**. Offline logic tests pass (`python3 tests/code/zbrush-paint-render/run_offline.py`: 36 tests on 2026-09-24). The live test that will verify each block is named under it; run them with `tests/code/zbrush-paint-render/run_live.sh` (p01 first: it records which candidate paths exist, and its JSON updates the tags in `zb_paint.PATHS`). What is already proven by the lead (v01 to v03): the bridge, `Document:Export` and `Tool:Export` without a dialog, synthesized strokes that sculpt, `set()` on a missing path being silent. Everything paint-specific below is new. ## P0. Session header (used by every procedure) ```python # not yet run in ZBrush import sys SKILLS = "/abs/path/to/skills" # this project's skills/ folder sys.path[:0] = [SKILLS + "/scenario-zbrush-expert/scripts", SKILLS + "/scenario-zbrush-paint-render/scripts"] import zb_launch as zl, zb_review, zb_stroke, zb_paint as zp zl.start() # bridge up, Home Page closed OUT = "/abs/out/paint" # absolute paths only # zp.remote("func", *args, **kw) runs zb_paint.func inside ZBrush (main thread); # zp.remote_code("...") runs code with zb_paint, zb_ops, zb_stroke, zb_review, zbc bound. ``` ## P1. Preflight and checkpoint Live test: `live_p01_paint_paths.py` (paths, paint_state). ```python # not yet run in ZBrush st = zp.remote("paint_state") assert st["at_top_level"], "paint at the highest subdivision level (FlippedNormals 00:08:17)" plan = zp.recommend_map_size(st["points"]) # 4M points -> 2048 (FlippedNormals 00:30:32) print(st["points"], plan) zp.remote("texture_off") # a shown texture hides polypaint (doc) zl.call("zb_ops", "save_ztl", OUT + "/head_paint.ztl") # head_paint_v001.ztl, never overwrites ``` Gate: top level, points recorded, map side chosen, ZTL v001 on disk. If the texture will be baked and `st["points"]` is under 0.7 of the map's usable pixels (`zp.map_budget`), divide further before painting, never after (FlippedNormals 00:08:50). ## P2. Paint mode, neutral material, base fill Live test: `live_p02_fill_and_masks.py` checks 1 and 2 (fill color, intensity scaling). ```python # not yet run in ZBrush zp.remote("set_paint_mode", "rgb", False, False, 100) # Rgb only, Zadd and Zsub off (FlippedNormals 00:03:09) # Realistic, painter's route: a dull skin base (FlippedNormals 00:02:16; doc Painting a Head) zp.remote("fill", [196, 150, 128], 100) # Zone-first route (Pablo 00:46:36): pure yellow base, zones next # zp.remote("fill", list(zp.ZONE_HUES["yellow"]), 100) rev = zp.paint_review(OUT + "/p2", look="flat", views=("front", "threequarter")) ``` Gate: the Flat front render shows the fill color at the center within about 8 levels per channel (live_p02 measures it). Colorize reads on after the fill (Pavlovich 040hAJ3-cTw 00:10:08). ## P3. Zones that are parts (eyes, scarf, teeth, clothes, hard parts) Live test: `live_p03_zones_polygroups.py` (fill_subtools). ```python # not yet run in ZBrush subs = zl.call("zb_ops", "subtools") # index, visibility, folder plan = {0: [196, 150, 128], # skin 1: [240, 238, 232], # eyes (sclera base; iris in P8) 2: [150, 40, 35]} # scarf zp.remote("fill_subtools", {str(k): v for k, v in plan.items()}, 100, "rgb") ``` Gate: `paint_review(look="flat")` shows one flat color per part; the active SubTool is restored. ## P4. Zones that are polygroups on one mesh Live test: `live_p03_zones_polygroups.py`. ```python # not yet run in ZBrush # a) review map: every group gets its group color (doc Polypaint From Polygroups) zp.remote("polypaint_from_polygroups") zp.paint_review(OUT + "/p4_groups", look="flat", views=("front", "threequarter", "back")) # b) exact fills: Duplicate, Groups Split, fill each piece, Project All back with Colorize on # the checkpoint argument is the versioned save zb_ops.project_all makes before Project All rep = zp.remote("zone_fill_by_split", [[196, 150, 128], [150, 40, 35], [60, 55, 50]], 100, True, OUT + "/head_paint.ztl", timeout=600) print(rep["pieces"], rep["groups"]) # pieces stay hidden in the tool ``` Gate: the original shows the zone colors after projection; SubTool count went up by the number of groups; `zl.ping()` answers (Project All is long on dense meshes). Transitions at group borders are soft after projection; that is usually wanted for skin and wrong for hard-surface parts, which should be separate SubTools (P3). ## P5. Anatomical skin zones on one mesh (no groups) Live test: `live_p04_texture_and_projection.py` part 2 (image projection convention). Route A, generated zone image through planar UVs (stylized digest P5). Use it before UVs exist. ```python # not yet run in ZBrush plan = zp.skin_zone_plan(sex="male", build="average", tone="light") # Pablo 00:47:08-00:55:26 # The planar UVs span the SubTool's bbox. If the SubTool is more than a head (bust, neck), # place the head's box inside the image: head u0, v0, u1, v1 from a front Flat render. shot = zp.remote("paint_views", OUT + "/p5_ref", ["front"], "flat")["views"][0]["path"] full = zb_review.silhouette_bbox(shot)["bbox"] head = [full[0], full[1], full[2], full[1] + 0.62 * (full[3] - full[1])] # measure yours [added] def remap(z): u0 = (head[0] - full[0]) / (full[2] - full[0]); v0 = (head[1] - full[1]) / (full[3] - full[1]) su = (head[2] - head[0]) / (full[2] - full[0]); sv = (head[3] - head[1]) / (full[3] - full[1]) z = dict(z) z["center"] = [u0 + z["center"][0] * su, v0 + z["center"][1] * sv] z["radius"] = [z["radius"][0] * su, z["radius"][1] * sv] return z FLIP_V = False # set from live_p04 quadrant probes img = zp.zone_image(OUT + "/zones.png", [remap(z) for z in plan["zones"]], plan["base"], (1024, 1024), noise=0.15, seed=1, flip_v=FLIP_V) zp.remote("polypaint_from_image", img) # Import, Uvp, Polypaint From Texture, Delete UV rep = zp.paint_review(OUT + "/p5", "flat", ("front", "right", "threequarter"), regions=zp.head_regions_front()) print(zp.skin_zone_checks(rep["zones"], "male", "realistic", stage="zones")) ``` Route B, synthesized paint strokes (proven stroke engine; organic breakup weaker than a hand). ```python # not yet run in ZBrush x0, y0, x1, y1 = zb_review.silhouette_bbox(shot)["bbox"] def px(u, v): return (x0 + u * (x1 - x0), y0 + v * (y1 - y0)) strokes = [] for z in plan["zones"]: (cu, cv), (ru, rv) = z["center"], z["radius"] a, b = px(cu - ru * 0.8, cv), px(cu + ru * 0.8, cv) strokes.append((z["color"], zb_stroke.zigzag(a, b, rv * (y1 - y0) * 0.7, 18, 3.0))) zp.remote_code(""" bbox = [float(v) for v in zbc.query_mesh3d(2, 3)] zbc.set_transform(*zb_stroke.frame_transform(bbox, zbc.get('Document:Width'), zbc.get('Document:Height'), (0, 0, 0), 0.75)) # same framing as paint_views zb_paint.set_paint_mode('rgb', intensity=35) # doc: RGB about 30 for zones zb_ops.select_brush('Standard') zb_ops.set_draw(size=40) for p in ('Stroke:Color Spray', 'Stroke:Spray'): # [verify] stroke path if zbc.exists(p): zbc.press(p) break if zbc.exists('Alpha:Alpha 08'): # [verify] FlippedNormals 00:04:35 zbc.press('Alpha:Alpha 08') zb_ops.set_symmetry(True, 'x') for col, pts in __STROKES__: zb_paint.set_color(col) zb_stroke.play(pts) zbc.update(redraw_ui=True) result = len(__STROKES__) """.replace("__STROKES__", repr(strokes))) ``` Gate (both routes, `stage="zones"`): forehead yellower than midface, midface redder, male lower third bluer, by at least one JND (2.3 in Lab). Visual: the map reads as a clown on purpose (Pablo 01:05:27) and zones sit on the anatomy (bone and fat yellow, thin tissue red, hollows blue). ## P6. Cover, mask passes, wash Live test: `live_p02_fill_and_masks.py` checks 3 to 6. ```python # not yet run in ZBrush SKIN = [205, 165, 140] zp.remote("fill", SKIN, 30) # cover at low opacity: zones shimmer through zl.call("zb_ops", "save_ztl", OUT + "/head_paint.ztl") # one checkpoint per stage (Pablo 00:55:58) # crevices darker and redder (Pablo 01:16:40-01:20:29) zp.remote("mask_pass", "cavity", [125, 45, 45], 30, True, 20, True) # breakup on smooth areas: smoothness or peaks and valleys, then a pale warm tone # (Pablo 01:26:05: PVRange 14, lower coverage; Adjust Colors replaced by a low fill) zp.remote("mask_pass", "peaks_valleys", [215, 185, 150], 15, True, 20, True, pv_range=14) # occlusion undertone, purple-red, from the AO plugin (Pablo 01:37:25; Pavlovich mW4P 00:06:30) zp.remote("mask_pass", "ao_plugin", [120, 55, 85], 20, True, None, True, timeout=180) zl.ping() # a ZScript plugin press may not return # realism only: a pale wash desaturates (doc RGB about 10; Pablo 01:31:16) zp.remote("wash", [240, 230, 222], 8) zl.call("zb_ops", "save_ztl", OUT + "/head_paint.ztl") ``` Gate per pass: prove the mask before filling through it when the pass matters: ```python # not yet run in ZBrush zp.remote("mask_by", "cavity") m = zp.remote("paint_views", OUT + "/mask_on", ["front"], "flat")["views"][0]["path"] zl.call("zb_ops", "mask", "clear") c = zp.remote("paint_views", OUT + "/mask_off", ["front"], "flat")["views"][0]["path"] print(zp.mask_coverage(m, c)) # masked share of the subject; 0 means the mask did nothing ``` Then `paint_review` with `skin_zone_checks(stage="covered")`: no clown (share of pixels above HSV saturation 0.75 at most 5 percent [added]), realistic mean saturation under 0.5 (Pablo 01:10:30), speckle present. ## P7. Review and gates at every stage Live test: offline `test_zb_paint.CanvasMeasurements`; live renders in p02 to p05. ````python # not yet run in ZBrush reviews = {} for look in ("flat", "skin", "white"): # Flat = albedo; SkinShade4 and MatCap White01 reviews[look] = zp.paint_review(OUT + f"/review_{look}", look, ("front", "threequarter", "right", "back", "top"), regions=zp.head_regions_front() if look == "flat" else None) print(reviews[look]["sheet"]) # open it and judge with critique.md checks = zp.skin_zone_checks(reviews["flat"]["zones"], sex="male", style="realistic", stage="final") print(checks["failed"]) ``` Always open the sheets with the image reader: numbers catch beige, clown, missing zones and missing eye contrast; the eye catches lips, transitions, likeness and the back of the head. ## P8. Eyes Live test: `live_p04` projection convention; eye specifics [verify]. ```python # not yet run in ZBrush # Pablo 01:41:30-01:47:03: dark iris over most of the visible eye, lighter bottom and darker top # (lens fake), lighter toward the pupil, black pupil. Planar projection from the front covers the # visible half of an eyeball SubTool without UVs. iris = [ {"shape": "ellipse", "center": [0.5, 0.5], "radius": [0.30, 0.30], "color": (70, 45, 30), "feather": 0.01}, {"shape": "ellipse", "center": [0.5, 0.60], "radius": [0.22, 0.14], "color": (125, 85, 50), "feather": 0.04, "opacity": 0.7}, {"shape": "ellipse", "center": [0.5, 0.40], "radius": [0.24, 0.12], "color": (40, 25, 18), "feather": 0.04, "opacity": 0.6}, {"shape": "ellipse", "center": [0.5, 0.5], "radius": [0.11, 0.11], "color": (160, 110, 60), "feather": 0.03, "opacity": 0.5}, {"shape": "ellipse", "center": [0.5, 0.5], "radius": [0.08, 0.08], "color": (8, 8, 8), "feather": 0.005}] img = zp.zone_image(OUT + "/iris.png", iris, (238, 234, 228), (512, 512), flip_v=FLIP_V) zl.run("zbc.select_subtool(1); result = zbc.get_active_tool_path()") # the eye SubTool zp.remote("polypaint_from_image", img) ```` Then rotate the eyes a few degrees apart (Pablo: straight eyes look dead). Gizmo rotation is not scriptable; hand it to scenario-zbrush-sculpting or do it with Tool > Deformation > Rotate on a masked eye [verify]. With Mirror And Weld eyes (one SubTool), paint before mirroring, or paint one and mirror (Mirror And Weld transfers polypaint, projection doc). ## P9. Deliver the color: texture, vertex color or Painter Live test: `live_p04_texture_and_projection.py` part 1 and 3. ```python # not yet run in ZBrush st = zp.remote("paint_state") if st["uv_bbox"]: tex = zp.remote("texture_from_polypaint", OUT + "/head_color.png", zp.recommend_map_size(st["points"])["side"], True, False, timeout=300) print(tex["size"], tex["budget"]) # budget["pass"] must be True else: print("no UVs: hand the painted ZTL to scenario-zbrush-retopology-export (UV Master Work on Clone," " Copy UVs, Paste UVs keeps polypaint), then run texture_from_polypaint") # vertex color route (Pablo 01:49:50): OBJ with polypaint; check for #MRGB lines obj = zl.call("zb_ops", "export_obj", OUT + "/head_vc.obj") print("#MRGB" in open(obj["path"], errors="ignore").read(4_000_000)) ``` Map rules: side from `recommend_map_size` (about 4M points per 2K), Flip V for other apps (FlippedNormals 00:31:54), texture display off afterwards. Multi Map Exporter's Texture from Polypaint (Map Border high, FlipV) runs in scenario-zbrush-retopology-export's map batch; Texture > Substance Bridge > Send PolyPaint (2026.2) sends it as a fill layer (version deltas 3.7). ## P10. BPR presentation render Live test: `live_p05_render_turntable.py` parts 1 and 5. ```python # not yet run in ZBrush zl.call("zb_ops", "save_ztl", OUT + "/head_paint.ztl") # resizing clears the canvas zp.remote("set_document_size", 1280, 720, timeout=60) # tests (Pavlovich 00:29:27) zp.remote("render_setup", True, True, True, None, None, False) # shadows, AO, perspective, no Redshift test = zp.remote("bpr_render", OUT + "/bpr_test.png", True, timeout=300) print(zp.render_checks(test["path"], final=False)) # framing, value span, clipping zp.remote("set_document_size", 1920, 1080, timeout=60) # final (Pavlovich 00:27:45) final = zp.remote("bpr_render", OUT + "/bpr_final.png", False, timeout=600) print(zp.render_checks(final["path"])) # plus noise at full size ``` Views: for a portfolio set, render front, three-quarter, profile and back with `zp.remote("paint_views", OUT + "/beauty", views, "bpr")`, which frames every view the same way. For print: pixels = inches x ppi (300 ppi); Pavlovich's 4 x 6 postcard is 3200 x 2133. ## P11. Passes and the composite Live test: `live_p05` part 2 (pass thumbnails), `live_p03` (ID pass), offline blend tests. ```python # not yet run in ZBrush passes = {} for name in ("shadow", "ao", "depth", "mask"): try: passes[name] = zp.remote("export_bpr_pass", name, OUT + f"/pass_{name}.png", True, timeout=60)["path"] except Exception as e: # a save dialog opened: stop, ping, fall back print(name, e); zl.ping(); break # ID (clown) pass and subject mask without touching paint or polygroups solo = zp.remote("subtool_solo_views", OUT + "/solo", "front", "flat", timeout=600) idp = zp.id_pass([s["path"] for s in solo["solo"]], OUT + "/pass_id.png", OUT + "/pass_mask.png") # composite (Pavlovich lEP73nEu3Tc: shadow and AO Multiply, rim Screen, masked to the subject) MODES = {"shadow": ("multiply", 0.35), "ao": ("multiply", 0.4)} # opacities are [added] starts layers = [{"path": passes[k], "mode": m, "opacity": o, "mask_path": OUT + "/pass_mask.png"} for k, (m, o) in MODES.items() if k in passes] zp.composite(OUT + "/bpr_final.png", layers, OUT + "/comp.png") # then look at it next to the beauty ``` BPR light passes (Pavlovich 00:14:55): a black Basic material with Specular 84 and one light behind gives a rim pass; build them only after the color is approved, on a saved ZTL, because M or MRGB fills write the material onto the SubTools [added]. ## P12. Redshift branch (only when Redshift is installed) Live test: none yet (Redshift "uninstalled" in `mx1 product list`, 2026-09-24). Installing it needs Emmanuel's consent. ```python # not yet run in ZBrush if not zl.run("result = bool(zbc.exists('Render:Redshift Renderer:Redshift'))"): raise SystemExit("no Redshift switch in this build: stay on BPR") zp.remote_code(""" for key, val in (('redshift', 1), ('rs_denoise', 1), ('rs_error_threshold', 1.0)): p = zb_paint.resolve(key); zbc.set(p, val) result = {k: zbc.get(zb_paint.resolve(k)) for k in ('redshift', 'rs_denoise', 'rs_error_threshold')} """) # tests: Error Threshold 1 (Pavlovich 00:44:03) t = zp.remote("bpr_render", OUT + "/rs_test.png", True, timeout=900) # final: Error Threshold back to 0.01, full document size, Denoising on; polypaint needs a Redshift # Polypaint material with Use Material Color 0 and a white Base; SSS Weight below 1 (doc Tips) ``` Also available once installed: Redshift Baker 360 (`rs_baker360`) to bake the lit look into polypaint for hand-painted bases or color prints (Pavlovich mW4P0T6tR7k); judge it on Flat Color. ## P13. Turntable Live test: `live_p05_render_turntable.py` parts 3 and 4. ```python # not yet run in ZBrush RULE = "maxon" # or "plain": the rule live_p05 found upright frames = [] for first in range(0, 36, 6): # short calls: 6 frames each r = zp.remote("turntable_frames", OUT + "/tt", 36, "bpr", RULE, 0.75, first, 6, timeout=900) frames += r["frames"] print([zp.upright_score(f) for f in frames[:4]]) # with a top marker model only zp.assemble_turntable(frames, OUT + "/turntable.mp4", fps=12) # review: 36 frames = 3 s at 12 fps # delivery [added]: n=180 (Movie > Turntable's default count) at fps=30 = 6 s, rendered in chunks zp.assemble_turntable(frames[::2], OUT + "/turntable.gif", fps=6) ``` Frame counts and fps are [added] defaults (36 for review, 180 at 30 fps for delivery); Movie > Turntable defaults to 180 frames (Pavlovich FvpG 00:19:56). Check every frame is upright and framed the same (the math framing uses the full bbox, so the model never drifts). ## P14. Portfolio sheet Live test: covered by the pieces above; sheet assembly offline (`zb_review.contact_sheet` tests in the lead folder). ```python # not yet run in ZBrush prev = zp.remote("colorize_all", False) # paint shows over any MatCap: hide it grey = zb_review.review(OUT + "/sheet_grey") # MatCap Gray form read, five views zp.remote("colorize_all", False, prev) # each SubTool back to its own state views = ("front", "threequarter", "right", "back") beauty = zp.remote("paint_views", OUT + "/sheet_bpr", views, "bpr", timeout=900)["views"] flat = zp.remote("paint_views", OUT + "/sheet_flat", views, "flat")["views"] paths = [v["path"] for v in beauty] + [v["path"] for v in flat] + [OUT + "/pass_id.png"] labels = [f"BPR {v['view']}" for v in beauty] + [f"albedo {v['view']}" for v in flat] + ["ID pass"] zb_review.contact_sheet(paths, labels, OUT + "/portfolio_sheet.png", cols=4, title="Character | paint and render") ``` Keep grayscale form renders separate from color (Berger presents grayscale for anatomy approval, character digest). Deliver the sheet, the turntable, the final beauty, the passes and the ZTL path. -
sources.md 8.4 KB
# Sources of scenario-zbrush-paint-render Each entry: source, who, credential, where, what it is best for, best timestamps. Notes live in `notes/polypaint/`, `notes/rendering/` and the stylized digest `notes/stylized/_digest_stylized_hair_polypaint_visual.md` of the project. ## Evidence produced by this project (highest weight for what exists on this install) - Offline tests `tests/code/zbrush-paint-render/test_zb_paint.py` (36 tests, pass 2026-09-24, results in `offline_results.json`). Best for: the color maths (Lab, HSV), zone checks, map budgets, blend modes, turntable rules, and the call order of the ZBrush-side functions against a fake. - Live tests `tests/code/zbrush-paint-render/live_p01..p06_*.py` with `run_live.sh`: written, **not yet run in ZBrush**. - UI string table of the installed build, `/Applications/Maxon ZBrush 2026/ZData/ZLang/english/UInterface.zsc`, read 2026-09-24 (the [strings] tag). Best for: labels that exist in 2026.2.1: Rgb Intensity; FillObject's tooltip "Fill 3D Object Masked"; Tool:[Masking] groups (Mask By Cavity, Mask By AO, Mask By Smoothness, Mask PeaksAndValleys, Mask By Color, Mask Adjust); Zplugin > Ambient Occlusion items including mask and color outputs; the Adjust Colors dialog buttons and its MaskByColor sliders; Render modes Flat, Fast, Preview, Best; Redshift Renderer and AOV items; BPR pass thumbnails; Draw:[Cameras]:Store Cam; the Movie Turntable prompt about an existing recording. - Shipped files: `ZData/Materials/Startup/SkinShade4.ZMT`, `ZData/Materials/MatCap/MatCap White01.ZMT`, `MatCap Gray.ZMT`, `ZData/Materials/RedshiftMaterials/*.zmt` (including ShadowCatcher), `ZData/Redshift/DefaultPanorama.exr`, `ZData/ZPlugs64/AmbientOcclusion.zsc`, `MultiMapExporter.zsc`, `SubToolMaster.zsc`. - Maxon CLI `mx1 product list` (2026-09-24): Redshift "uninstalled, unlicensed"; ZBrush 2026.2.1 on a trial. Best for: the BPR-first default on this Mac. - Lead evidence (`<skills>/scenario-zbrush-expert`, tests v01 to v03): bridge, dialog-free `Document:Export` and `Tool:Export`, synthesized strokes, silent `set()` on wrong paths. - `sources/zbrush-version-deltas.md` sections 3.7 (polypaint and texturing) and 3.9 (rendering). Best for: 2026 facts (Substance Bridge Send PolyPaint, Redshift AOVs and OIDN, movie formats, Mask by Smoothness fixed in 2024, ZRemesher Keep Polypaint). ## Experts - **FlippedNormals: Henning Sanden and Morten Jaeger**, former senior film character artists (MPC, Cinesite). "Polypaint a Head in ZBrush: Color Theory" (2018-04-12, 33:00), https://www.youtube.com/watch?v=8iAbH3zSQak. Best for: color zones in thirds, depth decides hue, neutral material, per-vertex resolution, value and eye contrast, map size from vertex count, Flip V. Timestamps: zones 00:00:31; FillObject at RGB Intensity 00:02:16 to 00:02:51; Rgb and Zadd 00:03:09; Color Spray 00:04:35; sampling blend 00:06:01; per vertex 00:08:17 to 00:08:50; neutral matcaps 00:10:51 to 00:12:29; value pass 00:14:16; eyes 00:18:44; bone near surface 00:20:50; exaggerate for shaders 00:25:26; map size 00:30:32 to 00:31:22; FlipV 00:31:54. - **Pablo Munoz Gomez**, concept and character artist, Maxon ZBrushLIVE presenter, Pablander Academy founder. "Let's Make It Happen" livestream, polypainting skin with masks and Adjust Colors (2025-10-01, 1:55:17), https://www.youtube.com/watch?v=d03QU-eUaPo. Best for: zone-first skin with anatomical reasons, cover at low opacity, mask plus Mask Adjust plus color passes, AO plugin, saturation as the stylization dial, eyes. Timestamps: materials 00:44:58; zone map 00:46:36 to 00:55:26; stage duplicates 00:55:58; blend 01:02:03; cover 01:06:35; saturation 01:10:30; cavity pass 01:16:40 to 01:20:29; smoothness and peaks 01:26:05 to 01:30:43; wash 01:31:16; tint 01:31:53; AO plugin 01:37:25; back of head 01:39:49; eyes 01:41:30 to 01:47:03; export 01:49:50. - **Michael Pavlovich**, Director of Character, Weapon and Vehicle Art at Certain Affinity, Maxon ZBrushLIVE streamer. Four ZBrush 2025 videos: - "Redshift In-Depth Guide" (2025-01-13, 1:06:31), https://www.youtube.com/watch?v=040hAJ3-cTw. Best for: every Redshift control, Smooth Surface bruises, creases, lights, HDRI, shadow catcher, Render Recall, speed ladder, document sizes, materials, movie. Timestamps 00:02:25, 00:02:56, 00:09:02, 00:14:01 to 00:21:26, 00:23:12, 00:27:45, 00:33:32, 00:37:19, 00:41:10, 00:43:00 to 00:47:55, 00:52:05, 01:02:00. - "Redshift Quickstart: Lights, Materials, Settings and Turntables" (2025-01-13, 22:00), https://www.youtube.com/watch?v=FvpG2tCP8Lw. Best for: SubTool Master Fill, Use Material Color, turntable settings and traps. Timestamps 00:04:25, 00:05:13, 00:06:50, 00:10:02, 00:19:24 to 00:21:10. - "Redshift and BPR Passes: AOV Rendering and Photoshop Compositing" (2025-01-13, 28:37), https://www.youtube.com/watch?v=lEP73nEu3Tc. Best for: which passes, blend modes, cheap BPR rim, fill, reflection and clown passes, print size. Timestamps 00:01:13, 00:02:18, 00:04:50, 00:05:46, 00:08:29, 00:12:21, 00:14:55 to 00:16:50, 00:18:16, 00:23:20. - "Redshift Baker 360" (2025-01-15, 8:59), https://www.youtube.com/watch?v=mW4P0T6tR7k. Best for: baking a lit look into polypaint, AO plugin plus Fill Object through the inverted mask, New From Polypaint, Clone, Export. Timestamps 00:02:40, 00:05:47, 00:06:30 to 00:07:06, 00:07:49 to 00:08:36. - Also "ZBrush Topology: Face, Body, Whatever!" (2023-11-05), https://www.youtube.com/watch?v=n5_cZK-9peg, for FillObject on an isolated polygroup [01:00:48 to 01:02:38] (note in `notes/retopology-uv/`). - **Joseph Drust**, Pixologic, #AskZBrush "How can I decimate a model into quads so it's easier to rig and animate?" (2016-10-28), https://www.youtube.com/watch?v=_ips3GhWI0s. Best for: Project All transfers polypaint only with the target's polypaint on [00:05:25 to 00:06:29] (note in `notes/retopology-uv/`). - **#AskZBrush UV clips** (Maxon/Pixologic), note `notes/retopology-uv/askzbrush-uvs - AskZBrush UVs on Subdivided Mesh and UV Relax (2 clips).md`. Best for: UV Master Work on Clone keeps polypaint [cemmalvugFk 00:04:00 to 00:07:28]. - **Zachary Berger** (Lightstorm, Avatar), **Rakan Khamash** (Blizzard), **Guillaume Tiberghien** (Keos Masons), via `notes/character/_digest_character_creature_studio.md`. Best for: grayscale approval renders, color early for design and late for production, value read with the strongest contrast at the face. ## Maxon documentation - ZBrush 2026 help, polypaint, color, masking, Spotlight and texture maps (12 pages): https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/painting-your-model/polypaint/polypaint.html and linked pages; local copy `sources/docs/paint-pose-render-print__polypaint-spotlight.md`. Best for: button names and behaviors (FillObject and draw modes, Colorize, Polypaint From Texture and Polygroups, every mask generator and Mask Adjust, Posable Symmetry mirror, Spotlight dial, texture creation), Painting a Head values. - ZBrush 2026 help, rendering (15 pages): https://help.maxon.net/zbr/en-us/Content/html/user-guide/materials-lights-rendering/rendering/rendering.html and https://help.maxon.net/zbr/en-us/Content/html/user-guide/materials-lights-rendering/rendering/redshift/redshift.html; local copy `sources/docs/paint-pose-render-print__rendering-redshift-bpr-keyshot.md`. Best for: Redshift settings and materials, AOVs, BPR passes, Smooth Surface, print resolution, KeyShot bridge, movies. - Multi Map Exporter and UV Map docs (`sources/docs/topology-export__multi-map-exporter-maps.md`, `topology-export__uv-master-uv-map.md`). Best for: Map Size, Flip V, UV Map Size, Uvp, Delete UV. - ZBrush Python SDK 2026.1 examples: `ex_sys_timeline_camera.py` (Maxon turntable framing rule, 0.75 margin, Euler keys (0,90,180), (0,180,180), (0,270,0)), the SubTool clone example with `Draw:Mrgb`, `set_color`, `Color:FillObject`; the GUI reference for `set_color` and the modeling reference for `pixol_pick` (local `sources/docs/automation__zbrush-py-sdk-*.md`). - Shipped macro CreateEyeballs (`sources/docs/automation__zscript-shipped-macros-2026.md`): `Draw:Mrgb`, `Draw:Rgb`, `Draw:M`, `Color:R/G/B`, `Color:FillObject`, `Material:ToyPlastic`, store and restore of the draw state. ## Other references used for maths [added] - CIELAB conversion (sRGB D65, CIE 1976) and the just-noticeable difference of about 2.3 (Sharma, Digital Color Imaging Handbook, 2003) for zone thresholds. - Immerkaer, "Fast Noise Variance Estimation" (Computer Vision and Image Understanding, 1996) for `noise_sigma`. - W3C Compositing and Blending Level 1 for the soft light and overlay formulas in `blend`.
-
-
scripts
-
zb_paint.py 72 KB
""" zb_paint: stroke-free polypaint, mask passes, paint statistics on canvas renders, polypaint to texture, BPR presentation renders, passes and scripted turntables (skill scenario-zbrush-paint-render). Two halves, like zb_review: inside ZBrush (call them with zb_paint.remote("func", ...)): paint_state, set_paint_mode, set_color, fill, fill_subtools, colorize, colorize_all, texture_off, mask_by, mask_adjust, mask_pass, wash, ao_plugin, polypaint_from_polygroups, zone_fill_by_split, polypaint_from_image, texture_from_polypaint, set_render_mode, render_setup, bpr_render, export_bpr_pass, paint_views, subtool_solo_views, turntable_frames, set_document_size agent side (system python3 with numpy + PIL): remote, remote_code, paint_review, subject_mask, rgb_to_lab, rgb_to_hsv, region_masks, head_regions_front, zone_report, skin_zone_checks, value_report, render_checks, noise_sigma, mask_coverage, recommend_map_size, map_budget, skin_zone_plan, zone_image, blend, composite, id_pass, turntable_rotations, upright_score, assemble_turntable It reuses the lead toolkit (<skills>/scenario-zbrush-expert/scripts): zb_ops for checked presses, sets, masks, stats and dialog-free file writes; zb_stroke for framing; zb_review for view capture, silhouettes and contact sheets; zb_launch for the bridge. Nothing here duplicates them. Evidence tags on PATHS (same scheme as zb_ops.PATHS): [bridge] exists() or ran through the bridge on 2026.2.1 (lead tests v01 to v03) [doc] Maxon docs or SDK example [macro] shipped 2026 macro [strings] label present in this build's UI string table ZData/ZLang/english/UInterface.zsc (read 2026-09-24); the label exists, the palette path around it is inferred [expert] shown on screen by an expert (source in the comment) [verify] not confirmed NOTHING IN THIS MODULE HAS RUN IN ZBRUSH YET. Offline tests: tests/code/zbrush-paint-render/ test_zb_paint.py. Live checks (not yet run): tests/code/zbrush-paint-render/live_p0*.py. """ __version__ = "0.1" # ZBrush Expert Skills v0.1 (2026-09-24) import json import math import os import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) EXPERT_SCRIPTS = os.path.normpath(os.path.join(HERE, "..", "..", "scenario-zbrush-expert", "scripts")) try: # inside ZBrush the remote prelude has put both script folders on sys.path from zbrush import commands as _zbc_probe # noqa: F401 IN_ZBRUSH = True except ImportError: IN_ZBRUSH = False if os.path.isdir(EXPERT_SCRIPTS) and EXPERT_SCRIPTS not in sys.path: sys.path.insert(0, EXPERT_SCRIPTS) import zb_ops # noqa: E402 lead toolkit import zb_stroke # noqa: E402 try: import zb_review # noqa: E402 (imports zb_ops, zb_stroke) except ImportError: # pragma: no cover zb_review = None class ZBPaintError(RuntimeError): pass PATHS = { # draw mode and intensity "rgb": ["Draw:Rgb"], # [macro] CreateEyeballs reads it "mrgb": ["Draw:Mrgb"], # [macro] [doc] "m": ["Draw:M"], # [macro] "zadd": ["Draw:Zadd"], # [bridge] "zsub": ["Draw:Zsub"], # [verify] "rgb_intensity": ["Draw:Rgb Intensity"], # [strings] UI_DRAW_EDIT_MRGB_INTENSITY "color_r": ["Color:R"], # [macro] "color_g": ["Color:G"], # [macro] "color_b": ["Color:B"], # [macro] "fill_object": ["Color:FillObject"], # [doc] [macro]; tooltip "Fill 3D Object Masked" [strings] "switch_color": ["Color:SwitchColor"], # [strings] # Tool > Polypaint "colorize": ["Tool:Polypaint:Colorize"], # [doc]; tooltip "Colorize Mesh" [strings] "pp_from_texture": ["Tool:Polypaint:Polypaint From Texture"], # [doc] [strings] "pp_from_groups": ["Tool:Polypaint:Polypaint From Polygroups"], # [doc] [strings] "adjust_colors": ["Tool:Polypaint:Adjust Colors"], # [strings]; opens a dialog [verify] "pp_mirror_posable": ["Tool:Polypaint:Mirror By Posable Symmetry", "Tool:Masking:Mirror By Posable Symmetry"], # [doc] [strings] "mask_flip_posable": ["Tool:Masking:Flip By Posable Symmetry"], # [doc] [strings] # Tool > Masking generators. Group names come from Tool:[Masking]:{...} [strings]; the # Activity log drops group names (lead cookbook), so the short form is tried second. Slider # labels that repeat across groups (Blur, Intensity, Range) are only tried in grouped form. "mask_cavity": ["Tool:Masking:Mask By Cavity:Mask By Cavity", "Tool:Masking:Mask By Cavity"], "cavity_blur": ["Tool:Masking:Mask By Cavity:Blur", "Tool:Masking:Mask By Cavity:Cavity Blur"], "cavity_intensity": ["Tool:Masking:Mask By Cavity:Intensity", "Tool:Masking:Mask By Cavity:Cavity Intensity"], "mask_ao": ["Tool:Masking:Mask By AO:Mask Ambient Occlusion", "Tool:Masking:Mask Ambient Occlusion"], "ao_intensity": ["Tool:Masking:Mask By AO:Occlusion Intensity", "Tool:Masking:Occlusion Intensity"], "ao_scandist": ["Tool:Masking:Mask By AO:AO ScanDist", "Tool:Masking:AO ScanDist"], "ao_aperture": ["Tool:Masking:Mask By AO:AO Aperture", "Tool:Masking:AO Aperture"], "ao_rays": ["Tool:Masking:Mask By AO:AO Rays", "Tool:Masking:AO Rays"], "mask_smooth": ["Tool:Masking:Mask By Smoothness:Mask By Smoothness", "Tool:Masking:Mask By Smoothness"], "smooth_range": ["Tool:Masking:Mask By Smoothness:Range", "Tool:Masking:Mask By Smoothness:Mask Range"], "smooth_falloff": ["Tool:Masking:Mask By Smoothness:Falloff", "Tool:Masking:Mask By Smoothness:Mask falloff"], "mask_pv": ["Tool:Masking:Mask PeaksAndValleys:Mask PeaksAndValleys", "Tool:Masking:Mask PeaksAndValleys"], "pv_range": ["Tool:Masking:Mask PeaksAndValleys:PVRange", "Tool:Masking:PVRange"], "pv_coverage": ["Tool:Masking:Mask PeaksAndValleys:PVCoverage", "Tool:Masking:PVCoverage"], "mask_intensity": ["Tool:Masking:Mask By Color:Mask By Intensity", "Tool:Masking:Mask By Intensity"], "mask_hue": ["Tool:Masking:Mask By Color:Mask By Hue", "Tool:Masking:Mask By Hue"], "mask_saturation": ["Tool:Masking:Mask By Color:Mask By Saturation", "Tool:Masking:Mask By Saturation"], "mask_adjust_apply": ["Tool:Masking:Mask Adjust:Apply"], # [strings] "Apply Mask Adjust" "mask_adjust_blur": ["Tool:Masking:Mask Adjust:Blur", "Tool:Masking:Mask Adjust:Mask Adjust Blur"], # Zplugin > Ambient Occlusion (explicit paths in [strings]; plugin ships since 2021.6) "aop_compute": ["Zplugin:Ambient Occlusion:Compute"], "aop_mask": ["Zplugin:Ambient Occlusion:mask"], # "Output ambient occlusion as masking" "aop_color": ["Zplugin:Ambient Occlusion:color"], # "Output ambient occlusion as polypainting" "aop_distance": ["Zplugin:Ambient Occlusion:distance"], "aop_aperture": ["Zplugin:Ambient Occlusion:aperture"], "aop_samples": ["Zplugin:Ambient Occlusion:samples"], "aop_resolution": ["Zplugin:Ambient Occlusion:resolution"], "aop_volume": ["Zplugin:Ambient Occlusion:OcclusionVolume"], "aop_smooth": ["Zplugin:Ambient Occlusion:smooth"], # SubTool split for polygroup zones "split_groups": ["Tool:SubTool:Split:Groups Split", "Tool:SubTool:Groups Split"], # [verify] # UV and texture map "uv_size": ["Tool:UV Map:UV Map Size"], # [doc] "uv_planar": ["Tool:UV Map:Create (Projection):Uvp", "Tool:UV Map:Uvp"], # [strings] "Uv Planar" "uv_delete": ["Tool:UV Map:Delete UV"], # [doc] [strings] "tex_new_from_pp": ["Tool:Texture Map:Create:New From Polypaint", "Tool:Texture Map:New From Polypaint"], # [doc] "tex_clone": ["Tool:Texture Map:Clone Txtr"], # [doc] [strings] "tex_on": ["Tool:Texture Map:Texture On"], # [strings] "tex_flipv_tool": ["Tool:Texture Map:Flip V"], # [strings] "tex_import": ["Texture:Import"], # [verify] with set_next_filename "tex_export": ["Texture:Export"], # [expert] Pavlovich mW4P0T6tR7k 00:08:36 [verify] "tex_flipv": ["Texture:Flip V", "Texture:Flpv"], # [strings] UI_TXTR_FLIP_V "mme_texture": ["Zplugin:Multi Map Exporter:Texture from Polypaint"], # [strings] # materials (files present in ZData/Materials; material paths are Material:<name> [doc]) "mat_flat": ["Material:Flat Color"], # [verify] built-in "mat_skinshade": ["Material:SkinShade4"], # file ZData/Materials/Startup/SkinShade4.ZMT "mat_white": ["Material:MatCap White01"], # file ZData/Materials/MatCap/MatCap White01.ZMT "mat_basic": ["Material:BasicMaterial"], # file ZData/Materials/Startup/BasicMaterial.ZMT # render "render_flat": ["Render:Flat"], # [strings] "Flat Renderer" "render_fast": ["Render:Fast"], # [strings] "render_preview": ["Render:Preview"], # [strings] "render_best": ["Render:Best"], # [strings] "bpr": ["Render:BPR"], # [bridge] "rp_shadows": ["Render:Render Properties:Shadows"], # [strings] "Render Shadows" "rp_ao": ["Render:Render Properties:AOcclusion"], # [strings] "Activates Ambient Occlusion Rendering" "redshift": ["Render:Redshift Renderer:Redshift", "Render:Redshift"], # [strings] "Render With Redshift" "rs_error_threshold": ["Render:Redshift Renderer:Error Threshold"], # [strings] "rs_denoise": ["Render:Redshift Renderer:Denoising"], # [strings] "rs_progressive": ["Render:Redshift Renderer:Progressive Rendering"], # [strings] "rs_iterations": ["Render:Redshift Renderer:Progressive Iterations"], # [strings] "rs_baker360": ["Render:Redshift Renderer:Redshift Baker 360"], # [strings] "persp": ["Transform:Persp"], # [verify] (zb_ops key "persp") "floor": ["Transform:Floor", "Draw:Floor"], # [verify] "grid_size": ["Draw:Grid Size"], # [strings] "store_cam": ["Draw:Cameras:Store Cam"], # [strings] (name prompt [verify]) "light_intensity": ["Light:Intensity"], # [doc] "doc_width": ["Document:Width"], # [bridge] "doc_height": ["Document:Height"], # [bridge] "doc_pro": ["Document:Pro"], # [verify] "doc_resize": ["Document:Resize"], # [strings] "Resize Document" "movie_turntable": ["Movie:Turntable"], # [strings] "Record Turntable"; modal if a movie exists } # BPR pass thumbnails in Render > BPR RenderPass: labels from [strings] # (UI_RENDER_BEST_PREVIEW_OUTPUT_*_MAP): Com, Img, Dep, Shdw, AmOc, SSS, Floor, and a mask map. BPR_PASSES = {"composite": "Com", "shaded": "Img", "depth": "Dep", "shadow": "Shdw", "ao": "AmOc", "mask": "Mask", "sss": "SSS", "floor": "Floor"} # Mask generators: button key, parameter keys. Mask data cannot be read back (no API, Maxon # forum 2026): gate a mask with mask_coverage() on two canvas renders instead. MASKS = { "cavity": ("mask_cavity", {"blur": "cavity_blur", "intensity": "cavity_intensity"}), "ao": ("mask_ao", {"intensity": "ao_intensity", "scan_dist": "ao_scandist", "aperture": "ao_aperture", "rays": "ao_rays"}), "smoothness": ("mask_smooth", {"range": "smooth_range", "falloff": "smooth_falloff"}), "peaks_valleys": ("mask_pv", {"pv_range": "pv_range", "pv_coverage": "pv_coverage"}), "intensity": ("mask_intensity", {}), "hue": ("mask_hue", {}), "saturation": ("mask_saturation", {}), } def resolve(key, required=True): """First existing path for a zb_paint key (or a list of paths), via zb_ops.resolve.""" cands = PATHS.get(key, [key]) if isinstance(key, str) else list(key) return zb_ops.resolve(cands, required) def _z(): return zb_ops._z() def _get(key): p = resolve(key, required=False) return None if p is None else float(_z().get(p)) def _set(key, value, tol=0.51): """set with read-back through zb_ops.set_checked (raises when it did not take).""" return zb_ops.set_checked(PATHS.get(key, [key]), value, tol=tol) def _rgb(c): if len(c) != 3: raise ValueError("colour must be (r, g, b)") out = tuple(int(round(float(v))) for v in c) if any(v < 0 or v > 255 for v in out): raise ValueError(f"colour {c} outside 0..255") return out def has_uvs(uv_bbox): """True for a non-degenerate UV bounding box from zb_ops.stats()["uv_bbox"] (None or all equal values mean no UVs; the exact query_mesh3d(3) layout is [verify]).""" return bool(uv_bbox) and len(uv_bbox) >= 4 and (max(uv_bbox) - min(uv_bbox)) > 1e-9 # ============================================================================================ # Inside ZBrush: paint state and fills # ============================================================================================ def paint_state(): """Draw mode, intensity, main colour, Colorize, texture display, level and UVs. The paint preflight numbers (FlippedNormals: paint at the top level; doc: texture off).""" out = {k: _get(k) for k in ("rgb", "mrgb", "m", "zadd", "zsub", "rgb_intensity", "color_r", "color_g", "color_b", "colorize", "tex_on")} s = zb_ops.stats() for k in ("tool", "subtools", "active", "points", "faces", "sdiv", "sdiv_max", "uv_bbox"): out[k] = s.get(k) sd, mx = s.get("sdiv"), s.get("sdiv_max") out["at_top_level"] = sd is None or mx is None or sd >= mx - 1e-6 out["has_uvs"] = has_uvs(out["uv_bbox"]) out["sculpts_while_painting"] = bool(out["zadd"]) or bool(out["zsub"]) return out def set_paint_mode(mode="rgb", zadd=False, zsub=False, intensity=None): """Rgb on with Zadd and Zsub off paints without sculpting (FlippedNormals 8iAbH3zSQak 00:03:09). mode "rgb" colour only, "mrgb" colour and material, "m" material only (doc FillObject). Returns the previous values for restore_paint_mode().""" if mode not in ("rgb", "mrgb", "m"): raise ValueError("mode must be rgb, mrgb or m") prev = {k: _get(k) for k in ("rgb", "mrgb", "m", "zadd", "zsub", "rgb_intensity")} z = _z() for k in ("rgb", "mrgb", "m"): # treated as a radio group; each is read back p = resolve(k, required=(k == mode)) if p and k != mode and z.get(p) >= 0.5: z.set(p, 0) _set(mode, 1) if zadd is not None: _set("zadd", 1 if zadd else 0) if zsub is not None and resolve("zsub", required=False): _set("zsub", 1 if zsub else 0) if intensity is not None: _set("rgb_intensity", float(intensity), tol=0.51) now = {k: _get(k) for k in ("rgb", "mrgb", "m", "zadd", "zsub", "rgb_intensity")} return {"previous": prev, "now": now} def restore_paint_mode(previous): z = _z() for k in ("rgb", "mrgb", "m", "zadd", "zsub", "rgb_intensity"): v = previous.get(k) p = resolve(k, required=False) if p is not None and v is not None: z.set(p, v) return {k: _get(k) for k in previous} def set_color(rgb): """Main colour with zbc.set_color [doc], read back from Color:R/G/B [macro].""" r, g, b = _rgb(rgb) z = _z() z.set_color(r, g, b) back = tuple(_get(k) for k in ("color_r", "color_g", "color_b")) if None not in back and max(abs(a - c) for a, c in zip(back, (r, g, b))) > 1.01: raise ZBPaintError(f"set_color({r}, {g}, {b}) reads back {back}") return {"set": [r, g, b], "read": list(back)} def colorize(on=True): """Tool > Polypaint > Colorize. On a SubTool without polypaint, turning it on fills white (doc Polypaint reference); later it only toggles the display.""" p = resolve("colorize") z = _z() before = z.get(p) if (before >= 0.5) != bool(on): z.set(p, 1 if on else 0) if (z.get(p) >= 0.5) != bool(on): z.press(p) return {"path": p, "before": before, "after": z.get(p)} def colorize_all(on=False, previous=None): """Colorize on or off for every SubTool, the scripted form of Pavlovich's Shift-click on the Colorize icon (040hAJ3-cTw 00:12:23). Off shows the MatCap instead of the paint, for grey form sheets and generic reflection passes (lEP73nEu3Tc 00:13:49); it only toggles the display and keeps the paint (doc). previous: the list this function returned, to restore each SubTool's own state.""" z = _z() p = resolve("colorize") n = int(z.get_subtool_count()) active0 = int(z.get_active_subtool_index()) states = [] try: for i in range(n): z.select_subtool(i) states.append(z.get(p)) want = (previous[i] >= 0.5) if previous is not None else bool(on) if (z.get(p) >= 0.5) != want: z.set(p, 1 if want else 0) finally: z.select_subtool(active0) return states def texture_off(): """A displayed texture map hides polypaint (doc Spotlight basics, Painting a Head).""" p = resolve("tex_on", required=False) if p is None: return {"path": None} z = _z() before = z.get(p) if before >= 0.5: z.set(p, 0) return {"path": p, "before": before, "after": z.get(p)} def fill(rgb=None, intensity=100, mode="rgb"): """Color > FillObject with the main colour at `intensity` percent. Expert basis: FillObject applies at the current RGB Intensity, 10 percent fills at 10 percent (FlippedNormals 8iAbH3zSQak 00:02:16 to 00:02:51); Pavlovich fills through an inverted AO mask (mW4P0T6tR7k 00:06:30 to 00:07:06) and fills an isolated polygroup (n5_cZK-9peg 01:00:48), and the build's tooltip reads "Fill 3D Object Masked" [strings]. So masked and hidden parts should stay untouched [verify live_p02]. Filling a SubTool turns its Colorize on (Pavlovich 040hAJ3-cTw 00:10:08).""" mode_rep = set_paint_mode(mode, zadd=False, zsub=False, intensity=intensity) col = set_color(rgb) if rgb is not None else None p = resolve("fill_object") z = _z() t = time.time() z.press(p) z.update(redraw_ui=True) return {"path": p, "color": col, "intensity": intensity, "mode": mode, "mode_state": mode_rep["now"], "seconds": round(time.time() - t, 3)} def wash(rgb, intensity=8): """Unify wash: a pale colour over everything at low intensity (doc Painting a Head: RGB Intensity about 10 as a wash; Pablo d03QU-eUaPo 01:31:16 very light colour, low intensity). Realistic targets only; skip for stylized (Pablo keeps saturation).""" return fill(rgb, intensity, "rgb") def fill_subtools(colors, intensity=100, mode="rgb", indices=None, restore=True): """Fill several SubTools, one colour each: the zone fill when zones are parts (eyes, scarf, teeth, clothes). colors: list of (r, g, b) cycled over `indices` (default every SubTool), or a dict {index: (r, g, b)}. Pavlovich fills per SubTool or with SubTool Master Fill (FvpG2tCP8Lw 00:06:50); the Maxon SDK example fills clones in a loop.""" z = _z() n = int(z.get_subtool_count()) active0 = int(z.get_active_subtool_index()) if isinstance(colors, dict): plan = [(int(k), _rgb(v)) for k, v in sorted(colors.items(), key=lambda kv: int(kv[0]))] else: idx = list(range(n)) if indices is None else [int(i) for i in indices] cols = [_rgb(c) for c in colors] if not cols: raise ValueError("no colours") plan = [(i, cols[k % len(cols)]) for k, i in enumerate(idx)] out = [] try: for i, c in plan: if i < 0 or i >= n: raise ZBPaintError(f"SubTool index {i} out of range 0..{n - 1}") z.select_subtool(i) rec = fill(c, intensity, mode) out.append({"index": i, "color": list(c), "seconds": rec["seconds"]}) finally: if restore: z.select_subtool(active0) return {"filled": out, "active_restored": active0 if restore else None} # ============================================================================================ # Inside ZBrush: masks # ============================================================================================ def mask_by(kind, **params): """Run a mask generator with its sliders set first (doc: set Range and Falloff before pressing Mask By Smoothness). kind: cavity, ao, smoothness, peaks_valleys, intensity, hue, saturation. Mask By AO is slow on millions of polygons: prefer ao_plugin(output= "mask") there (Pablo d03QU-eUaPo 01:37:25).""" if kind not in MASKS: raise ValueError(f"kind must be one of {sorted(MASKS)}") button, pmap = MASKS[kind] set_ = {} for name, value in params.items(): if value is None: continue if name not in pmap: raise ValueError(f"{kind} has no parameter {name!r}; known {sorted(pmap)}") set_[name] = _set(pmap[name], float(value), tol=0.51) p = resolve(button) z = _z() t = time.time() z.press(p) z.update(redraw_ui=True) return {"kind": kind, "path": p, "params": set_, "seconds": round(time.time() - t, 3)} def mask_adjust(blur=None): """Mask Adjust > Apply with the Blur slider and the default profile. The doc says the effect is absolute: pressing again changes nothing unless Blur changes (Mask Adjust); Pablo presses it twice (d03QU-eUaPo 01:18:52). Change Blur between presses. The profile curve is a curve widget: keep the default [verify curve scripting].""" if blur is not None: _set("mask_adjust_blur", float(blur), tol=0.51) p = resolve("mask_adjust_apply") _z().press(p) _z().update(redraw_ui=True) return {"path": p, "blur": blur} def mask_pass(kind, rgb, intensity=30, invert=True, adjust_blur=None, clear=True, **params): """Pablo's core loop without a brush: generator, Mask Adjust, Inverse, colour through the mask, Clear (d03QU-eUaPo 01:16:40 to 01:30:43; doc Masking). The big soft brush pass is replaced by FillObject at low RGB Intensity through the inverted mask, the way Pavlovich adds baked AO (mW4P0T6tR7k 00:06:30). kind "ao_plugin" uses Zplugin > Ambient Occlusion (ray traced, faster and more accurate on dense meshes, Pablo 01:37:25). Run it at the highest subdivision level (Pablo 01:16:40).""" st = zb_ops.stats() steps = {"top_level": st.get("sdiv") is None or st.get("sdiv") >= st.get("sdiv_max", 1) - 1e-6} zb_ops.mask("clear") if kind == "ao_plugin": steps["generator"] = ao_plugin(output="mask", **params) else: steps["generator"] = mask_by(kind, **params) if adjust_blur is not None: steps["adjust"] = mask_adjust(adjust_blur) if invert: steps["invert"] = zb_ops.mask("invert") steps["fill"] = fill(rgb, intensity, "rgb") if clear: steps["clear"] = zb_ops.mask("clear") return steps def ao_plugin(output="mask", distance=None, aperture=None, samples=None, resolution=None, volume=None, smooth=None): """Zplugin > Ambient Occlusion > Compute (paths from [strings]). output "mask" writes the AO as a mask (then invert and fill, Pablo 01:37:25; Pavlovich mW4P0T6tR7k 00:06:30); "color" writes it straight into polypaint ("Output ambient occlusion as polypainting" [strings]; overwrite or blend is [verify]: use it on a duplicate SubTool). volume True includes the other SubTools (Pavlovich: Occlusion Volume). A ZScript plugin press may not return control (lead traps): call it with a short timeout and ping after.""" if output not in ("mask", "color"): raise ValueError("output must be mask or color") z = _z() sets = {} for key, val in (("aop_distance", distance), ("aop_aperture", aperture), ("aop_samples", samples), ("aop_resolution", resolution), ("aop_smooth", smooth)): if val is not None: sets[key] = _set(key, float(val), tol=0.51) for key, on in (("aop_mask", output == "mask"), ("aop_color", output == "color")): p = resolve(key, required=False) if p is not None: z.set(p, 1 if on else 0) sets[key] = z.get(p) if volume: z.press(resolve("aop_volume")) t = time.time() z.press(resolve("aop_compute")) z.update(redraw_ui=True) return {"output": output, "sets": sets, "seconds": round(time.time() - t, 3)} # ============================================================================================ # Inside ZBrush: zones # ============================================================================================ def polypaint_from_polygroups(): """Group colours become polypaint (doc Polypaint reference): the zone or chunk map to review before real colours go on (stylized digest P3).""" p = resolve("pp_from_groups") _z().press(p) _z().update(redraw_ui=True) return {"path": p} def zone_fill_by_split(colors, intensity=100, project_back=True, checkpoint=None): """Exact zone fills by polygroup without a click: Duplicate the active SubTool, Groups Split the copy, FillObject each piece, then (project_back) Project All the colour onto the untouched original with its Colorize on, which avoids the "target has no polypaint" popup (AskZBrush _ips3GhWI0s 00:05:57), and hide the pieces. Pavlovich's version isolates a group and fills it (n5_cZK-9peg 01:00:48): isolating needs Ctrl+Shift clicks, which the SDK cannot send. The pieces stay in the tool (deleting a SubTool asks for confirmation); their indices are returned. [verify live_p03: split path, piece order, projection] project_back needs `checkpoint` (an absolute .ztl path, saved versioned first, or False right after a save): the lead's zb_ops.project_all refuses to run without one, since Project All can crash (cgside). No new layer here: a recording layer would also capture every later fill [added]; the morph target is kept (2026-09-24).""" if project_back and checkpoint is None: raise ZBPaintError("project_back needs checkpoint='/abs/name.ztl' (or False right after " "a save): Project All can crash ZBrush") z = _z() zb_ops.ensure_edit() n0 = int(z.get_subtool_count()) i0 = int(z.get_active_subtool_index()) zb_ops.press("duplicate") z.update(redraw_ui=True) n1 = int(z.get_subtool_count()) if n1 != n0 + 1: raise ZBPaintError(f"Duplicate did not add a SubTool ({n0} -> {n1})") z.select_subtool(i0 + 1) z.press(resolve("split_groups")) z.update(redraw_ui=True) n2 = int(z.get_subtool_count()) k = n2 - n0 pieces = list(range(i0 + 1, i0 + 1 + k)) rep = fill_subtools(colors, intensity, "rgb", indices=pieces, restore=False) out = {"original": i0, "pieces": pieces, "groups": k, "fills": rep["filled"]} if project_back: z.select_subtool(i0) colorize(True) keep = set([i0] + pieces) saved = {} for i in range(n2): st = int(z.get_subtool_status(i)) saved[i] = st want = (st | 0x1) if i in keep else (st & ~0x1) if want != st: z.set_subtool_status(i, want) z.select_subtool(i0) out["project"] = zb_ops.project_all(checkpoint=checkpoint, layer=False) for i in pieces: z.set_subtool_status(i, int(z.get_subtool_status(i)) & ~0x1) for i, st in saved.items(): if i not in keep: z.set_subtool_status(i, st) z.select_subtool(i0) return out def polypaint_from_image(image_path, delete_uvs=True, allow_replace_uvs=False): """Stroke-free zone map from a generated image (stylized digest P5): Texture:Import the image [verify dialog-free], Tool > UV Map > Uvp (planar UVs "based on the current orientation as it appears in the Preview window", UV doc), Polypaint From Texture, texture off, Delete UV. Refuses a SubTool that already has UVs unless allow_replace_uvs: those UVs are a deliverable. The image-to-surface convention (flip, aspect) is measured by live_p04 before any real use; zone_image(flip_v=...) follows it. [verify every step]""" image_path = os.path.abspath(image_path) if not os.path.exists(image_path): raise ZBPaintError(f"no image at {image_path}") z = _z() st = zb_ops.stats() if has_uvs(st.get("uv_bbox")) and not allow_replace_uvs: raise ZBPaintError("SubTool has UVs: Uvp would replace them. Paint zones before UVs, " "or run this on a duplicate and Project All the colour back") z.set_next_filename(image_path) z.press(resolve("tex_import")) pending = z.has_next_filename() z.press(resolve("uv_planar")) z.press(resolve("pp_from_texture")) z.update(redraw_ui=True) texture_off() if delete_uvs: z.press(resolve("uv_delete")) return {"image": image_path, "import_preset_pending": pending, "uv_bbox_after": zb_ops.stats().get("uv_bbox")} # ============================================================================================ # Inside ZBrush: polypaint to texture # ============================================================================================ def texture_from_polypaint(out_path, size=None, flip_v=True, overwrite=False): """UV Map Size, Texture Map > New From Polypaint, Clone Txtr, Flip V, Texture:Export (doc Texture Maps; Pavlovich mW4P0T6tR7k 00:07:49 to 00:08:36). UVs are required (FlippedNormals 00:29:59); size defaults to recommend_map_size(points). flip_v follows FlippedNormals' FlipV rule for other apps (00:31:54) [verify orientation live_p04]. The Multi Map Exporter route (Texture from Polypaint, Map Border high) belongs to scenario-zbrush-retopology-export's map batch.""" z = _z() st = zb_ops.stats() if not has_uvs(st.get("uv_bbox")): raise ZBPaintError("no UVs: hand to scenario-zbrush-retopology-export (UV Master Work on Clone " "then Copy/Paste UVs keeps polypaint)") size = int(size or recommend_map_size(st.get("points") or 0)["side"]) _set("uv_size", size, tol=0.51) z.press(resolve("tex_new_from_pp")) z.press(resolve("tex_clone")) flipped = False if flip_v: fp = resolve("tex_flipv", required=False) if fp: z.press(fp) flipped = True res = zb_ops._file_op(PATHS["tex_export"], out_path, overwrite) texture_off() res.update({"size": size, "flipped_v": flipped, "points": st.get("points"), "budget": map_budget(st.get("points") or 0, size)}) return res # ============================================================================================ # Inside ZBrush: render # ============================================================================================ def set_render_mode(mode="preview"): """Render > Flat, Fast, Preview or Best (render modes doc). Flat shows pure colour for colour judgment (Pablo d03QU-eUaPo 01:03:11); set back to Preview afterwards.""" key = "render_" + mode if key not in PATHS: raise ValueError("mode must be flat, fast, preview or best") p = resolve(key) _z().press(p) _z().update(redraw_ui=True) return {"path": p} def render_setup(shadows=True, ao=True, perspective=True, floor=None, light_intensity=None, redshift=False): """BPR scene switches with read-back. Perspective on before rendering, else the render is orthographic (Pavlovich 040hAJ3-cTw 00:02:56). Redshift only when installed (Redshift is a separate application, REND doc; on this Mac mx1 lists it as uninstalled 2026-09-24).""" z = _z() out = {} for key, val in (("rp_shadows", shadows), ("rp_ao", ao), ("persp", perspective), ("floor", floor), ("redshift", redshift)): if val is None: continue p = resolve(key, required=False) if p is None: out[key] = "missing" continue z.set(p, 1 if val else 0) out[key] = z.get(p) if light_intensity is not None: out["light_intensity"] = _set("light_intensity", float(light_intensity), tol=0.02) return out def bpr_render(out_path, overwrite=False): """Press BPR (Shift+R), then export the canvas: the render lives in the document (Document:Export is dialog-free, lead v03). Returns the file and the render seconds.""" z = _z() t = time.time() z.press(resolve("bpr")) z.update(redraw_ui=True) secs = round(time.time() - t, 2) res = zb_ops.export_canvas(out_path, overwrite) res["render_seconds"] = secs return res def export_bpr_pass(name, out_path, overwrite=False): """Save one BPR pass (Render > BPR RenderPass thumbnails; the doc says clicking a pass saves it, which normally opens a file dialog). set_next_filename first [verify live_p05]; if a dialog opens, use configuration passes (clown, mask) with bpr_render instead.""" label = BPR_PASSES.get(name, name) return zb_ops._file_op(["Render:BPR RenderPass:" + label, "Render:" + label], out_path, overwrite) def set_document_size(width, height): """Document Width and Height with Pro off, then Resize (Pavlovich 040hAJ3-cTw 00:27:45). Resizing clears the canvas, so the tool is redrawn and Edit turned back on, and the view is reframed with zb_stroke.frame_transform. Whether Resize asks a question is [verify].""" z = _z() t0 = [float(v) for v in z.get_transform()] pro = resolve("doc_pro", required=False) if pro and z.get(pro) >= 0.5: z.set(pro, 0) z.set(resolve("doc_width"), float(width)) z.set(resolve("doc_height"), float(height)) z.press(resolve("doc_resize")) z.update(redraw_ui=True) w, h = z.get("Document:Width"), z.get("Document:Height") redrawn = False if z.get("Transform:Edit") < 0.5: lc = zb_ops.resolve("layer_clear", required=False) if lc: z.press(lc) z.canvas_click(w * 0.5, h * 0.5, w * 0.5, h * 0.85) z.set("Transform:Edit", 1) redrawn = True bbox = [float(v) for v in z.query_mesh3d(2, 3)] z.set_transform(*zb_stroke.frame_transform(bbox, w, h, tuple(t0[6:9]))) z.update(redraw_ui=True) return {"asked": [width, height], "read": [w, h], "redrawn": redrawn, "edit": z.get("Transform:Edit")} LOOKS = {"flat": ("Flat Color", None), "skin": ("SkinShade4", None), "white": ("MatCap White01", None), "gray": ("MatCap Gray", None), "bpr": (None, "bpr")} def paint_views(out_dir, views=("front", "threequarter", "right", "back"), look="flat", margin=0.75, prefix=None, strict=True): """Canvas renders for colour review through zb_review.capture_views (inside ZBrush). look: "flat" (Flat Color: pure polypaint), "skin" (SkinShade4), "white" (MatCap White01), "gray", "bpr" (keep materials, BPR each view), or "matcap:<name>". Neutral material plus Flat for every colour judgment (FlippedNormals 00:10:51; Pablo 00:44:58, 01:03:11). A SubTool filled in M or MRGB mode keeps its painted material, so a review MatCap does not show on it [added] [verify]. strict: raise when the look's material could not be set (zb_review keeps capturing with the current material and only records the error).""" if look.startswith("matcap:"): matcap, render = look.split(":", 1)[1], None elif look in LOOKS: matcap, render = LOOKS[look] else: raise ValueError(f"look must be one of {sorted(LOOKS)} or matcap:<name>") if zb_review is None: raise ZBPaintError("zb_review not importable") res = zb_review.capture_views(out_dir, list(views), matcap, "math", margin, render, prefix or look.replace(":", "_")) mat = res.get("material") or {} res["look_applied"] = matcap is None or "error" not in mat if strict and not res["look_applied"]: raise ZBPaintError(f"look {look!r} not applied ({mat.get('error')}): the renders in " f"{out_dir} show the current material; check the name with live_p01") return res def subtool_solo_views(out_dir, view="front", look="flat", indices=None, margin=0.75): """One render per SubTool with only that SubTool visible (eye bit 0x1 of the status word, lead cookbook [verify set semantics]), for an ID (clown) pass and a subject mask built on the agent side with id_pass(). Pavlovich's clown pass sets one polygroup per SubTool with Ctrl+W (lEP73nEu3Tc 00:16:50), which rewrites polygroups; this leaves paint, groups and materials untouched [added]. The framing uses the full bbox of all SubTools, so every solo render lines up with the beauty render of the same view.""" z = _z() n = int(z.get_subtool_count()) idx = list(range(n)) if indices is None else [int(i) for i in indices] saved = [int(z.get_subtool_status(i)) for i in range(n)] active0 = int(z.get_active_subtool_index()) out = [] try: for i in idx: for j in range(n): want = (saved[j] | 0x1) if j == i else (saved[j] & ~0x1) z.set_subtool_status(j, want) z.select_subtool(i) res = paint_views(os.path.join(out_dir, f"solo_{i:02d}"), [view], look, margin, prefix=f"solo_{i:02d}") out.append({"index": i, "path": res["views"][0]["path"]}) finally: for j in range(n): z.set_subtool_status(j, saved[j]) z.select_subtool(active0) return {"solo": out, "restored": [int(z.get_subtool_status(j)) for j in range(n)] == saved} def turntable_frames(out_dir, n=36, look="preview", rule="maxon", margin=0.75, first=0, count=None, prefix="tt"): """Scripted turntable: absolute view per frame (never add degrees, lead traps), framed with Maxon's 0.75 rule, BPR per frame when look == "bpr", Document:Export per frame. Call in chunks (first, count) so each bridge call stays short. Movie > Turntable is the GUI route (records viewport quality unless a Redshift render ran first, Pavlovich FvpG2tCP8Lw 00:19:56; it asks to export or replace an existing movie [strings]).""" z = _z() zb_ops.ensure_edit() os.makedirs(out_dir, exist_ok=True) t0 = [float(v) for v in z.get_transform()] w, h = z.get("Document:Width"), z.get("Document:Height") bbox = [float(v) for v in z.query_mesh3d(2, 3)] rots = turntable_rotations(n, rule) last = n if count is None else min(n, first + int(count)) frames = [] t = time.time() try: for k in range(int(first), last): z.set_transform(*zb_stroke.frame_transform(bbox, w, h, rots[k], margin)) z.update(redraw_ui=True) if look == "bpr": z.press(resolve("bpr")) path = os.path.join(out_dir, f"{prefix}_{k:03d}.png") frames.append(zb_ops.export_canvas(path, overwrite=True)["path"]) finally: z.set_transform(*t0) z.update(redraw_ui=True) return {"frames": frames, "rotations": [list(r) for r in rots[int(first):last]], "seconds": round(time.time() - t, 2), "doc": [w, h]} # ============================================================================================ # Agent side: bridge helpers # ============================================================================================ def _prelude_lines(): return [ f"_zb_dirs = [{HERE!r}, {EXPERT_SCRIPTS!r}]", "_zb_before = set(_zb_sys.modules)", "for _zb_d in _zb_dirs:", " _zb_sys.path.insert(0, _zb_d)", "try:", " zb_paint = _zb_il.import_module('zb_paint')", "finally:", " for _zb_d in _zb_dirs:", " _zb_sys.path.remove(_zb_d)", " for _zb_k in set(_zb_sys.modules) - _zb_before:", " if _zb_k.startswith('zb_'):", " _zb_sys.modules.pop(_zb_k, None)", "zb_ops, zb_stroke, zb_review = zb_paint.zb_ops, zb_paint.zb_stroke, zb_paint.zb_review", ] def build_remote_code(func, args=(), kwargs=None): """Source sent to ZBrush: zb_paint and the lead modules imported by path, then removed from sys.path and sys.modules (Maxon's shared-interpreter rule, as zb_launch does), then zb_paint.<func>(*args, **kwargs). zb_launch.run adds `_zb_sys`, `_zb_json`, `_zb_il` and JSON-encodes `result`.""" if not func.isidentifier(): raise ValueError(f"bad function name {func!r}") lines = _prelude_lines() lines.append(f"result = zb_paint.{func}(*_zb_json.loads({json.dumps(list(args))!r}), " f"**_zb_json.loads({json.dumps(kwargs or {})!r}))") return "\n".join(lines) + "\n" def remote(func, *args, port=7788, timeout=120, **kwargs): """zb_paint.<func>(*args, **kwargs) inside ZBrush through the lead bridge (zb_launch.run, main thread). Args must be JSON-able.""" import zb_launch return zb_launch.run(build_remote_code(func, args, kwargs), (), port, timeout) def remote_code(code, port=7788, timeout=120): """Arbitrary code inside ZBrush with zb_paint, zb_ops, zb_stroke, zb_review and zbc bound; assign `result`.""" import zb_launch return zb_launch.run("\n".join(_prelude_lines()) + "\n" + code, (), port, timeout) def paint_review(out_dir, look="flat", views=("front", "threequarter", "right", "back"), regions=None, title=None, port=7788, timeout=240): """Capture paint views in ZBrush, tile a labelled contact sheet (zb_review.contact_sheet) and measure each view: subject value range and saturation, plus zone_report on the front view when `regions` (name -> spec, see region_masks) is given. Open the sheet and judge it with references/critique.md.""" out_dir = os.path.abspath(out_dir) res = remote("paint_views", out_dir, list(views), look, port=port, timeout=timeout) shots = res["views"] sils = [zb_review.silhouette_bbox(s["path"]) for s in shots] labels = [] per_view = [] for s, q in zip(shots, sils): lab = s["view"] + ("" if q else " EMPTY") + (" CLIPPED" if q and q["touches_border"] else "") labels.append(lab) per_view.append({"view": s["view"], "path": s["path"], "value": value_report(s["path"]) if q else None}) sheet = zb_review.contact_sheet([s["path"] for s in shots], labels, os.path.join(out_dir, f"paint_{look.replace(':', '_')}_sheet.png"), title=title or f"paint review | {look}") out = {"sheet": sheet, "views": per_view, "silhouettes": sils, "material": res.get("material")} if regions: front = next((s["path"] for s in shots if s["view"] == "front"), shots[0]["path"]) out["zones"] = zone_report(front, regions) return out # ============================================================================================ # Agent side: colour maths and measurements (numpy) # ============================================================================================ def load_rgb(path): """uint8 H x W x 3 array.""" import numpy as np from PIL import Image with Image.open(path) as im: return np.asarray(im.convert("RGB")) def subject_mask(img, threshold=28, edge=4, fill_holes=True): """Per-pixel model mask against ZBrush's canvas background, with the same rule as zb_review.silhouette_bbox (background of each row = median of its outer `edge` pixels; the canvas is a vertical gradient). fill_holes keeps dark pupils, nostrils and mouth interiors that match the dark background inside the subject (found in the offline tests). img: path or array. Returns a bool array.""" import numpy as np a = load_rgb(img) if isinstance(img, str) else np.asarray(img) a = a.astype(np.int16) ref = np.median(np.concatenate([a[:, :edge], a[:, -edge:]], axis=1), axis=1) mask = np.abs(a - ref[:, None, :]).max(axis=2) > threshold mask[:, :edge] = False mask[:, -edge:] = False return _fill_holes(mask) if fill_holes else mask def _fill_holes(mask): """Everything not connected to the image border through background is subject.""" import numpy as np from PIL import Image, ImageDraw h, w = mask.shape pad = Image.new("L", (w + 2, h + 2), 0) pad.paste(Image.fromarray(np.uint8(mask) * 255, "L"), (1, 1)) ImageDraw.floodfill(pad, (0, 0), 128) return np.asarray(pad)[1:-1, 1:-1] != 128 def _unit(rgb): """Float 0..1 array: integer input (uint8 renders, int lists) is divided by 255.""" import numpy as np raw = np.asarray(rgb) x = raw.astype(np.float64) return x / 255.0 if np.issubdtype(raw.dtype, np.integer) else x def rgb_to_hsv(rgb): """Vectorised HSV: hue in degrees 0..360, s and v in 0..1. rgb: integers 0..255 or floats 0..1.""" import numpy as np x = _unit(rgb) r, g, b = x[..., 0], x[..., 1], x[..., 2] mx, mn = x.max(axis=-1), x.min(axis=-1) c = mx - mn s = np.where(mx > 0, c / np.where(mx > 0, mx, 1), 0.0) safe = np.where(c > 0, c, 1) h = np.where(mx == r, ((g - b) / safe) % 6, np.where(mx == g, (b - r) / safe + 2, (r - g) / safe + 4)) * 60.0 h = np.where(c > 0, h, 0.0) return np.stack([h, s, mx], axis=-1) def rgb_to_lab(rgb): """sRGB (D65) to CIELAB. rgb: integers 0..255 or floats 0..1 (..., 3). Returns L 0..100, a, b.""" import numpy as np x = _unit(rgb) lin = np.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4) m = np.array([[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]]) xyz = lin @ m.T / np.array([0.95047, 1.0, 1.08883]) d = 6.0 / 29.0 f = np.where(xyz > d ** 3, np.cbrt(xyz), xyz / (3 * d * d) + 4.0 / 29.0) L = 116 * f[..., 1] - 16 a = 500 * (f[..., 0] - f[..., 1]) b = 200 * (f[..., 1] - f[..., 2]) return np.stack([L, a, b], axis=-1) def circular_mean_deg(h, weights=None): import numpy as np h = np.radians(np.asarray(h, dtype=np.float64)) w = np.ones_like(h) if weights is None else np.asarray(weights, dtype=np.float64) if w.sum() <= 0: return None ang = math.degrees(math.atan2(float((w * np.sin(h)).sum()), float((w * np.cos(h)).sum()))) return ang % 360.0 def _box_blur(a, r): """Mean filter of radius r (window 2r+1) with edge padding, via an integral image.""" import numpy as np if r <= 0: return a.astype(np.float64) p = np.pad(a.astype(np.float64), r, mode="edge") c = np.cumsum(np.cumsum(p, axis=0), axis=1) c = np.pad(c, ((1, 0), (1, 0))) k = 2 * r + 1 s = c[k:, k:] - c[:-k, k:] - c[k:, :-k] + c[:-k, :-k] return s / (k * k) def region_masks(shape, bbox, spec): """Named bool masks. shape: (H, W). bbox: [x0, y0, x1, y1] of the subject (silhouette). spec: name -> ("box", u0, v0, u1, v1) or ("ellipse", cu, cv, ru, rv) in bbox-normalised coordinates (u right, v down), or ("circle_px", x, y, r) or ("poly_px", [(x, y), ...]) in canvas pixels (for landmarks projected with zb_stroke.Camera).""" import numpy as np H, W = shape[:2] yy, xx = np.mgrid[0:H, 0:W] x0, y0, x1, y1 = bbox bw, bh = max(1.0, x1 - x0), max(1.0, y1 - y0) out = {} for name, s in spec.items(): kind = s[0] if kind == "box": u0, v0, u1, v1 = s[1:5] m = ((xx >= x0 + u0 * bw) & (xx <= x0 + u1 * bw) & (yy >= y0 + v0 * bh) & (yy <= y0 + v1 * bh)) elif kind == "ellipse": cu, cv, ru, rv = s[1:5] cx, cy = x0 + cu * bw, y0 + cv * bh m = ((xx - cx) / max(ru * bw, 1e-6)) ** 2 + ((yy - cy) / max(rv * bh, 1e-6)) ** 2 <= 1.0 elif kind == "circle_px": cx, cy, r = s[1:4] m = (xx - cx) ** 2 + (yy - cy) ** 2 <= r * r elif kind == "poly_px": from PIL import Image, ImageDraw im = Image.new("L", (W, H), 0) ImageDraw.Draw(im).polygon([tuple(p) for p in s[1]], fill=255) m = np.asarray(im) > 0 else: raise ValueError(f"unknown region kind {kind!r}") out[name] = m return out def head_regions_front(split_eyes=True): """Default face regions for a FRONT, head-only, centred render, in silhouette-normalised coordinates. Placement follows the classical head canon (eyes near half the head height) [added: calibrate per model, or replace with landmarks projected from the sculpt]. Zones are the ones the experts name: forehead (yellow), midface cheeks and nose (red), lower third (blue or grey in men), eyes (focal contrast), lips (FlippedNormals 00:00:31, 00:18:44; Pablo 00:47:39).""" r = {"forehead": ("ellipse", 0.50, 0.30, 0.22, 0.07), "nose": ("ellipse", 0.50, 0.58, 0.05, 0.07), "cheek_l": ("ellipse", 0.29, 0.61, 0.09, 0.06), "cheek_r": ("ellipse", 0.71, 0.61, 0.09, 0.06), "lips": ("ellipse", 0.50, 0.74, 0.10, 0.03), "lower": ("ellipse", 0.50, 0.87, 0.20, 0.06)} if split_eyes: r["eye_l"] = ("ellipse", 0.33, 0.48, 0.08, 0.035) r["eye_r"] = ("ellipse", 0.67, 0.48, 0.08, 0.035) else: r["eyes"] = ("box", 0.22, 0.44, 0.78, 0.52) return r def _stats(lab, hsv, m, hp): import numpy as np n = int(m.sum()) if n == 0: return {"pixels": 0} L, a, b = lab[..., 0][m], lab[..., 1][m], lab[..., 2][m] s, v, h = hsv[..., 1][m], hsv[..., 2][m], hsv[..., 0][m] return {"pixels": n, "L": round(float(L.mean()), 2), "a": round(float(a.mean()), 2), "b": round(float(b.mean()), 2), "chroma": round(float(np.hypot(a, b).mean()), 2), "L_p5": round(float(np.percentile(L, 5)), 2), "L_p95": round(float(np.percentile(L, 95)), 2), "L_std": round(float(L.std()), 2), "a_std": round(float(a.std()), 2), "b_std": round(float(b.std()), 2), "sat": round(float(s.mean()), 3), "sat_std": round(float(s.std()), 3), "sat_high_frac": round(float((s > 0.75).mean()), 4), "value": round(float(v.mean()), 3), "hue": circular_mean_deg(h, s), "speckle": round(float(hp[m].std()), 3)} def zone_report(img, regions, bbox=None, subject=None, speckle_radius=3): """Per-region colour statistics on a canvas render (use a Flat Color render for albedo). regions: name -> spec (region_masks). Each region is intersected with the subject mask. speckle = std of L* minus its local mean (radius px): organic breakup versus airbrush smoothness (FlippedNormals 00:05:07) [added metric].""" import numpy as np a = load_rgb(img) if isinstance(img, str) else np.asarray(img) subj = subject_mask(a) if subject is None else subject if bbox is None: ys, xs = np.nonzero(subj) if len(xs) == 0: raise ZBPaintError("no subject in the render") bbox = [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())] lab = rgb_to_lab(a) hsv = rgb_to_hsv(a) hp = lab[..., 0] - _box_blur(lab[..., 0], speckle_radius) masks = region_masks(a.shape, bbox, regions) rep = {"bbox": bbox, "regions": {}} for name, m in masks.items(): rep["regions"][name] = _stats(lab, hsv, m & subj, hp) rep["subject"] = _stats(lab, hsv, subj, hp) return rep JND = 2.3 # one just-noticeable difference in CIELAB (Sharma) [added unit for thresholds] def _mean_of(rep, names, key): vals = [rep["regions"][n][key] for n in names if n in rep["regions"] and rep["regions"][n].get("pixels")] return sum(vals) / len(vals) if vals else None def skin_zone_checks(rep, sex="male", style="realistic", stage="final", min_delta=JND, max_clown=0.05, min_speckle=1.0): """Expert zone rules as pass/fail checks on zone_report output. Thresholds are [added] and meant to be calibrated on live renders; the rules are the experts': forehead yellower (b*) than midface; midface redder (a*) than forehead (Gurney thirds via FlippedNormals 00:00:31; Pablo 00:47:39) male: lower third bluer (b* lower) than midface; female: less blue, more red low (Pablo 00:48:11, 00:54:18) realistic: mean HSV saturation under 0.5, the middle of the picker (Pablo 01:10:30) variation: zones differ by at least one JND and the paint has speckle (FlippedNormals 00:05:07; Pablo 01:02:36); after the cover pass no clown (Pablo 01:05:27) eyes carry the strongest value contrast (FlippedNormals 00:18:44) stage: "zones" (pure hue map), "covered", "final".""" checks = [] def add(name, ok, detail, rule): checks.append({"check": name, "pass": bool(ok) if ok is not None else None, "detail": detail, "rule": rule}) mid = ["cheek_l", "cheek_r", "nose"] eyes = [n for n in ("eye_l", "eye_r", "eyes") if n in rep["regions"]] fb, fa = _mean_of(rep, ["forehead"], "b"), _mean_of(rep, ["forehead"], "a") mb, ma = _mean_of(rep, mid, "b"), _mean_of(rep, mid, "a") lb, la = _mean_of(rep, ["lower"], "b"), _mean_of(rep, ["lower"], "a") if None not in (fb, mb): add("forehead yellower than midface", fb - mb >= min_delta, {"forehead_b": fb, "mid_b": mb}, "FlippedNormals 00:00:31; Pablo 00:47:39") if None not in (fa, ma): add("midface redder than forehead", ma - fa >= min_delta, {"mid_a": ma, "forehead_a": fa}, "FlippedNormals 00:00:31; Pablo 00:50:21") if None not in (lb, mb): if sex == "male": add("lower third bluer than midface (male)", mb - lb >= min_delta, {"lower_b": lb, "mid_b": mb}, "Pablo 00:48:11; FlippedNormals 00:12:46") else: add("lower third not bluer than midface (female)", mb - lb < 2 * min_delta, {"lower_b": lb, "mid_b": mb, "lower_a": la}, "Pablo 00:54:18") subj = rep.get("subject", {}) skin_names = [n for n in ("forehead", "cheek_l", "cheek_r", "nose", "lower") if rep["regions"].get(n, {}).get("pixels")] sat = _mean_of(rep, skin_names, "sat") if sat is not None and stage != "zones": if style == "realistic": add("realistic saturation under the middle of the picker", sat < 0.5, {"mean_sat": sat}, "Pablo 01:10:30") else: add("stylized saturation recorded", True, {"mean_sat": sat}, "Pablo 01:10:30") if stage != "zones": clown = _mean_of(rep, skin_names, "sat_high_frac") if clown is not None: add("no clown after the cover pass", clown <= max_clown, {"sat_above_0.75_frac": clown}, "Pablo 01:05:27") sp = _mean_of(rep, skin_names, "speckle") if sp is not None: add("organic speckle, not airbrush", sp >= min_speckle, {"speckle": sp}, "FlippedNormals 00:05:07; Pablo 01:02:36") if skin_names: bs = [rep["regions"][n]["b"] for n in skin_names] as_ = [rep["regions"][n]["a"] for n in skin_names] spread = max(max(bs) - min(bs), max(as_) - min(as_)) add("not uniform beige", spread >= min_delta, {"zone_spread_ab": round(spread, 2)}, "FlippedNormals 00:00:56") if eyes and skin_names: ec = max(rep["regions"][n]["L_p95"] - rep["regions"][n]["L_p5"] for n in eyes) oc = max(rep["regions"][n]["L_p95"] - rep["regions"][n]["L_p5"] for n in skin_names) add("strongest value contrast at the eyes", ec >= oc, {"eye_contrast": round(ec, 2), "max_other": round(oc, 2)}, "FlippedNormals 00:18:44") add("subject measured", subj.get("pixels", 0) > 0, {"pixels": subj.get("pixels")}, "") return {"checks": checks, "passed": sum(1 for c in checks if c["pass"]), "failed": [c["check"] for c in checks if c["pass"] is False]} def value_report(img, subject=None): """Value range of the subject (L*): percentiles, clipped fractions, saturation mean.""" import numpy as np a = load_rgb(img) if isinstance(img, str) else np.asarray(img) m = subject_mask(a) if subject is None else subject if not m.any(): return {"pixels": 0} L = rgb_to_lab(a)[..., 0][m] s = rgb_to_hsv(a)[..., 1][m] return {"pixels": int(m.sum()), "fill": round(float(m.mean()), 4), "L_p2": round(float(np.percentile(L, 2)), 2), "L_p50": round(float(np.percentile(L, 50)), 2), "L_p98": round(float(np.percentile(L, 98)), 2), "L_span": round(float(np.percentile(L, 98) - np.percentile(L, 2)), 2), "clipped_white": round(float((L > 99).mean()), 5), "clipped_black": round(float((L < 1).mean()), 5), "sat_mean": round(float(s.mean()), 3)} def noise_sigma(img, subject=None): """Gaussian noise estimate on the 0..255 grey image (Immerkaer 1996 fast method), over the subject interior: grain after denoise at 100 percent (Pavlovich 040hAJ3-cTw 00:44:36).""" import numpy as np a = load_rgb(img) if isinstance(img, str) else np.asarray(img) g = a.astype(np.float64) @ np.array([0.299, 0.587, 0.114]) k = (g[:-2, :-2] - 2 * g[:-2, 1:-1] + g[:-2, 2:] - 2 * g[1:-1, :-2] + 4 * g[1:-1, 1:-1] - 2 * g[1:-1, 2:] + g[2:, :-2] - 2 * g[2:, 1:-1] + g[2:, 2:]) m = np.ones_like(k, dtype=bool) if subject is not False: sm = subject_mask(a) if subject is None else subject m = sm[1:-1, 1:-1] & sm[:-2, 1:-1] & sm[2:, 1:-1] & sm[1:-1, :-2] & sm[1:-1, 2:] if not m.any(): return None return round(float(math.sqrt(math.pi / 2.0) / 6.0 * np.abs(k[m]).mean()), 3) def render_checks(img, min_span=50.0, max_clip=0.005, max_noise=2.0, final=True): """Presentation render gates [added thresholds; the criteria are Pavlovich's and the doc's: grain at 100 percent, shadows not cut, subject framed]: subject present and not touching the border, value span, clipped highlights and blacks, noise estimate (finals only).""" sil = zb_review.silhouette_bbox(img) if zb_review else None val = value_report(img) nz = noise_sigma(img) if final else None checks = [ {"check": "subject present", "pass": bool(sil)}, {"check": "subject not clipped by the frame", "pass": bool(sil) and not sil["touches_border"]}, {"check": f"value span L* >= {min_span}", "pass": val.get("L_span", 0) >= min_span, "detail": val.get("L_span")}, {"check": f"clipped whites <= {max_clip}", "pass": val.get("clipped_white", 1) <= max_clip, "detail": val.get("clipped_white")}, {"check": f"clipped blacks <= {max_clip}", "pass": val.get("clipped_black", 1) <= max_clip, "detail": val.get("clipped_black")}, ] if final: checks.append({"check": f"noise sigma <= {max_noise}", "pass": nz is not None and nz <= max_noise, "detail": nz}) return {"silhouette": sil, "value": val, "noise": nz, "checks": checks, "failed": [c["check"] for c in checks if not c["pass"]]} def mask_coverage(masked_img, clear_img, subject=None, drop=0.12): """Share of the subject that a mask darkens: the SDK cannot read mask data, but a ma
-
-
SKILL.md 17.9 KB
--- name: scenario-zbrush-paint-render description: "Use when polypainting in ZBrush (skin color zones, fills, cavity or AO passes, Spotlight), baking polypaint to a texture or vertex color, rendering a ZBrush model with BPR or Redshift, making render passes, a turntable or portfolio shots, or when paint looks like uniform beige or clown makeup, is blurry, is hidden under a texture or in Redshift, or a render is noisy, clipped, orthographic or bruised." license: MIT --- # ZBrush paint and render (texture and presentation artist) Expert paint in ZBrush is observation made systematic: color zones placed by what lies under the skin, pushed enough to survive the shader, judged on neutral and flat views, and baked at a resolution the mesh can hold. For an agent without a tablet most of it is buttons: fills at a chosen intensity through generated masks, zone images projected onto the mesh, and renders that are both measured and looked at. 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) - **Paint last, at the top level.** Polypaint is one color per vertex: a lower level paints blurry, adding levels afterwards breaks it, and the map follows the point count (about 4M points per 2K map; 4K is wasted there) (FlippedNormals). - **Judge on neutral and flat.** SkinShade4 or MatCap White01, plus Flat Color. A tinted MatCap makes you overcompensate and the export looks wrong elsewhere (FlippedNormals, Pablo). - **Structure before beauty.** Start with a pure yellow, red and blue zone map placed by anatomy: bone and fat yellow, thin tissue red, hollows blue, more blue low on a man. Then cover it with skin color at low opacity so the zones shimmer through (Pablo). For an agent this is also the route that can be checked in numbers. - **Masks are "the real trick" (Pablo):** generator, Mask Adjust, Inverse, color through the mask. The agent's big soft brush is FillObject at low RGB Intensity through the inverted mask: it scales with RGB Intensity (FlippedNormals), Pavlovich fills through an inverted AO mask, and the tooltip reads "Fill 3D Object Masked". - **Saturation is the stylization dial (Pablo); paint to be diluted (FlippedNormals).** Shaders wash polypaint out. Pick the final renderer before deciding how far to push, and keep realistic skin under the middle of the picker. - **Contrast directs the eye.** The strongest value or saturation contrast goes to the eyes, and the eyes and mouth get the most time (FlippedNormals). - **Small and fast, then big and slow (Pavlovich).** Test small at a high Error Threshold, compare renders side by side, and rebuild the beauty from passes instead of re-rendering. - **Visibility traps (Maxon docs).** A displayed texture hides polypaint. In Redshift only Polypaint materials show paint (Use Material Color 0, white Base), SSS Weight 1 hides it, and MatCaps show no texture. ## Establish first | Input | Changes | Default when the brief is silent | | ------------------------------------------------------------------------------------ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Purpose: game albedo, film lookdev reference, stills, color print, concept paintover | whether painted shadow is allowed; map size; deliverable | presentation stills, color kept as polypaint | | Renderer | materials, passes, turntable route | BPR. Redshift is a separate application and `mx1 product list` shows it uninstalled on this Mac (2026-09-24); ask before installing it | | Style | saturation target, zone simplification | the upstream skill's style: scenario-zbrush-character-creature is realistic, scenario-zbrush-stylized is stylized | | Subject: sex, age, build, skin tone | zone layout and hues | ask once; if still silent, assume an adult of average build with light skin and say so | | Color deliverable | UVs, map size, Flip V | vertex color; add a texture only when UVs exist | | Images | document size, views, turntable | 1920 x 1080 (Pavlovich); front, three-quarter, profile and back; turntable 36 frames for review, 180 (Movie default) at 30 fps for delivery [added] | ## Workflow Toolkit: [`scripts/zb_paint.py`](scripts/zb_paint.py), ZBrush-side functions through `zp.remote("name", ...)`; full code in [`references/procedures.md`](references/procedures.md). Save a versioned ZTL after every stage: there is no undo control. 1. **Preflight.** `paint_state()` (level, points, UVs, texture display), `recommend_map_size(points)`, `texture_off()`, save a ZTL. GATE: top level, `map_budget(points, side)` passes, v001 on disk. Divide now if needed, never after painting. 2. **Setup and base.** `set_paint_mode("rgb")` (Zadd and Zsub off), `fill(colour, 100)`: a dull skin base (FlippedNormals, doc) or pure yellow for the zone route (Pablo). GATE: the Flat front shows the fill color; Colorize reads on. 3. **Zone map.** Parts (eyes, scarf, teeth, clothes): `fill_subtools`. Polygroups: review with `polypaint_from_polygroups()`, fill exactly with `zone_fill_by_split()` (Duplicate, Groups Split, fill each piece, Project All back with Colorize on). Anatomy on one mesh: `skin_zone_plan` → `zone_image` → `polypaint_from_image` (planar UVs, before real UVs); synthesized Color Spray strokes as the fallback. GATE: `skin_zone_checks(stage="zones")`: forehead yellower than midface, midface redder, a man's lower third bluer, each by at least one JND (2.3 Lab); visually a clown on purpose (Pablo), every zone on its anatomy. 4. **Cover and breakup.** Skin color at 20 to 40 percent over the zones (doc RGB about 30; Pablo's low-opacity cover); breakup from zone-image noise or Color Spray strokes. GATE: `stage="covered"`: no clown (at most 5 percent of skin pixels above saturation 0.75 [added]), speckle present. 5. **Mask passes** with `mask_pass`: cavity darker and redder at 20 to 40 percent; PeaksAndValleys (PVRange 14) or Smoothness with a pale warm fill; `ao_plugin` mask with a purple-red undertone (ping after). Pablo's Adjust Colors is a dialog [verify live_p06]; a low fill through the same mask replaces it. Mask data cannot be read: prove a mask with `mask_coverage(masked, cleared)`. GATE: each pass shifts the region statistics a little, together they read as skin, back of the head included. 6. **Focus and finish.** Eyes: an iris image (dark ring, lighter bottom, darker top, lighter toward the pupil) projected onto the eye SubTool, then slight divergence; lips; selective darkening only where light never reaches unless the renderer adds no AO; a 5 to 10 percent wash for realism. GATE: final `skin_zone_checks` with eye contrast, then the Flat, SkinShade4 and MatCap White01 sheets (`paint_review`) judged with [`references/critique.md`](references/critique.md). 7. **Deliver the color.** `texture_from_polypaint(path, side, flip_v=True)` needs UVs (UV Master on a clone, Paste UVs keeps polypaint); or OBJ vertex color; or Substance Bridge Send PolyPaint. GATE: map budget passes, an asymmetric mark lands where expected, file on disk. 8. **Render.** Save, `set_document_size` (small for tests; Resize clears the canvas, the function redraws), `render_setup` (perspective, shadows, AO), `bpr_render` or `paint_views(..., "bpr")` for matched views. GATE: `render_checks` pass and the renders were looked at at 100 percent. 9. **Passes and turntable.** `export_bpr_pass` (may open a dialog); clown pass by `subtool_solo_views` plus `id_pass` (paint and polygroups untouched); `composite` (shadow and AO Multiply through the mask); `turntable_frames` in short chunks, `assemble_turntable`. GATE: every frame upright with constant framing; the composite beats the beauty toggled against it. 10. **Portfolio and report.** Gray form views (`colorize_all(False)`, `zb_review.review`, restore), albedo and beauty views, ID pass, turntable, one sheet. Report what was measured, what was only looked at, what did not run. ## Numbers | Value | Relative to | Source | | ---------------------------------------------------------------------------------------------- | ------------------------- | ------------------------------------- | | About 4M points per 2K map; 2K = 4M pixels, about 3M usable at 70 percent UV coverage | map side vs vertex count | FlippedNormals 00:30:32; doc | | FillObject at N percent RGB Intensity = N percent blend | the current color | FlippedNormals 00:02:51 | | Zones at RGB about 30, wash about 10; cover Draw Size 150, zones 100 or less | brush strokes | doc Painting a Head | | PVRange 14 | Mask PeaksAndValleys | Pablo 01:26:37 | | Map Border high (frames 4 and 16), FlipV on | Multi Map Exporter | FlippedNormals 00:31:22 | | 1920 x 1080 final; 1280 x 720 or 720 x 405 tests; 3200 x 2133 postcard; inches x 300 ppi print | document | Pavlovich; doc | | Error Threshold 0.01 final, 1 tests (4 s vs 9 s); Progressive 64, 25 with denoise | Redshift | Pavlovich 040h 00:44:03 | | Main light 0.1 (never 0) with an HDRI; EXR gamma 2.2; macOS Redshift gamma 1.8 | Light, Background | Pavlovich 00:23:12; doc | | Crease Level one below SmoothSubdiv (3 and 4) | soft edge highlight | Pavlovich 00:21:26 | | Movie Turntable 180 frames by default | Movie > Modifiers | Pavlovich FvpG 00:19:56 | | Realistic mean HSV saturation < 0.5 | skin regions, Flat render | Pablo 01:10:30 (middle of the picker) | | 1 JND = 2.3 Lab; clown share 5 percent; L* span 50; clipping 0.5 percent; noise sigma 2 | gates | [added], calibrate on live renders | ## Quality gates - **Measurable:** `paint_state` (top level, Rgb on, Zadd off, texture off); `map_budget`; `mask_coverage` above 0 before a masked fill; `skin_zone_checks` on a front Flat render with `head_regions_front()` or regions projected from landmarks; `render_checks` (framing, value span, clipping, noise); `upright_score` on turntable frames with a top marker; files on disk with sizes. - **Visual:** Flat, SkinShade4 and MatCap White01 sheets in five views for color, and the BPR or Redshift beauty for the final read. Check each at 100 percent for breakup and edges, and as a thumbnail for zones and values. Look for beige or clown, lips, eye focus, the back of the head, bruises, cut shadows and grain. The rubric is `references/critique.md`. ## Common mistakes | Mistake | Looks like | Fix | | ---------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------ | | Painting below the top level, or dividing afterwards | blurry, pixelated paint; artifacts | paint last at the top level; divide first | | Judging on a tinted MatCap | colors fine in ZBrush, wrong elsewhere | SkinShade4, MatCap White01, Flat Color | | Mrgb left on while painting | the review MatCap no longer shows; material painted in | Rgb only while painting; M or Mrgb fills only for render materials, on a saved ZTL [added] | | Gray sheet of a painted model | color shows on MatCap Gray | `colorize_all(False)`, then restore | | Texture displayed | new paint invisible | `texture_off()` | | Raw cavity mask | harsh rings | Mask Adjust with Blur; low intensity | | Mask By AO on millions of polygons | slow, inaccurate | the Ambient Occlusion plugin | | UV Master on the painted original | polypaint erased | Work on Clone, Copy UVs, Paste UVs | | 4K map on 4M points | wasted map, soft texels | `recommend_map_size` | | Redshift with a preset material or SSS Weight 1 | paint gone | Polypaint material, Use Material Color 0, white Base | | Smooth Surface on low-poly hard parts | shading bruises | off; creases with Dynamic Subdivision | | Orthographic beauty render | flat, technical look | perspective on | | Movie > Turntable from a script | cannot stop; asks about an existing movie | scripted frames plus `assemble_turntable` | | Adding degrees for each frame | flips past 90 degrees | absolute triples from `turntable_rotations` | ## Handoffs - **Receives:** from scenario-zbrush-sculpting, scenario-zbrush-character-creature or scenario-zbrush-stylized, a final versioned ZTL at its top level with `stats()` and its last review sheet, plus eyes and parts as named SubTools. From scenario-zbrush-hard-surface, parts as SubTools with creases. From scenario-zbrush-pose-print, a posed ZTL (paint with symmetry before posing when possible [added]). - **Delivers to scenario-zbrush-retopology-export:** the painted ZTL, the map side from `recommend_map_size`, and either the exported color map (`texture_from_polypaint`) or a request for UVs on a clone before the bake. Multi Map Exporter's Texture from Polypaint runs in that skill's map batch. - **Delivers to the user:** the beauty renders, gray and albedo sheets, passes and composite, the turntable MP4 or GIF, the portfolio sheet and the ZTL path, with what was verified. - **Sister teams:** scenario-blender-texturing-shading and scenario-maya-expert for lookdev with vertex color or maps; scenario-zbrush-automation for batch renders. ## ZBrush 2026 notes - Redshift needs its separate application; AOVs with OIDN since 2025.1, Cinema 4D-matched materials since 2025.2. BPR is unchanged. - Substance Bridge (2026.2) sends polypaint to Painter as a fill layer; its Force UV Auto-Unwrap strips every SubTool's UVs. - Movies: MP4 (MOV on macOS), resized above 4095 px. Mask by Smoothness fixed in 2024; ZRemesher Keep Polypaint since 2023; Ambient Occlusion plugin since 2021.6. - `zb_paint.PATHS` tags each path; many are [strings] (label exists in this build) or [verify]. Run `live_p01_paint_paths.py` first: `set()` on a missing path is silent. - Nothing in `zb_paint` has run in ZBrush yet; 36 offline tests pass; live tests `tests/code/zbrush-paint-render/live_p01` to `p06`. ## References - `references/procedures.md`: full bridge code for every stage. Load before writing any paint or render code. - `references/critique.md`: the rubric, the Z1 and Z2 finishing test cases, and the two evaluation prompts. Load at every gate. - [`references/expert-notes.md`](references/expert-notes.md): the depth by expert with timestamps, the disagreements and what transfers without a tablet. Load when a decision is not covered here. - [`references/gui-paths.md`](references/gui-paths.md): palettes, hotkeys and settings for a human or computer-use agent. Load for Spotlight, dialogs and GUI-only steps. - [`references/sources.md`](references/sources.md): every source with credentials, URLs and timestamps.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.