scenario-zbrush-hard-surface
Use when modeling hard surface in ZBrush 2026 (sci-fi helmet, weapon, prop, armor plates, mechanical parts, kitbash, vents, panel lines, bolts, concept to game asset), when using ZModeler, Dynamic Subdivision and creasing, Live Boolean, Knife, Clip, Slice or Trim curves, IMM, Arr
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/zbrush/scenario-zbrush-hard-surface
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 hard surface
Hard surface is judged as a result: it reads as manufactured when every edge is clearly straight or clearly curved, planes carry an even highlight, edges are wide enough to catch light and every part could be built (Plouffe, Klimer). The agent builds that language from deterministic operations (primitives, Live Boolean stacks, polygroups as the crease plan, Dynamic Subdivision numbers, ArrayMesh, Panel Loops) and uses clicks and strokes only where nothing else exists. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
REQUIRED BACKGROUND: scenario-zbrush-expert (bridge, stroke engine, review loop, 2026 traps). Toolkit: scripts/zb_hardsurface.py (import sys; sys.path.insert(0, "<this skill>/scripts"); import zb_hardsurface as hs, then hs.hs_call("new_part", "cube")), built on the lead's zb_ops, zb_review, zb_audit and zb_stroke. Nothing in it has run in ZBrush yet.
Stance (the expert delta)
- Polygroups are the crease plan. Groups By Normals at 45 degrees (33 catches softer planes, 60 misses edges), Mirror And Weld, UnCreaseAll, Crease PG (Pavlovich; Plouffe). Crease first, a loop only where the falloff must be local. Crease PG after later edits recreases borders you softened on purpose: regroup, or crease by angle.
- The gap SmoothSubdiv minus CreaseLvl sets the edge. 0 reads razor, "very CG"; the closer CreaseLvl sits to SmoothSubdiv, the tighter the edge, so 4/3 is tighter than 4/2 (
hs.edge_width). Pavlovich works at 3/2 and bakes 4/2; Plouffe keeps CreaseLvl 3 over a razor 4 and calls 2 too soft. Deciding condition: render both under metal, keep the widest edge that still looks planned. - QGrid is for boxy blocks (it facets arcs). Curved spans take Smooth Subdiv plus Crease Tolerance, tuned per span: 22 held Pavlovich's arch; too low creases the arc's small angles.
- Booleans need clean inputs (Live Boolean doc; Pavlovich): watertight, no coplanar faces, similar density, low Dynamic levels, nothing partly hidden. DSDiv on, one Start group per separate part, then ZRemesher Detect Edges at Half. Creases, UVs, levels and layers die in the boolean; polygroups survive.
- Keep cuts and repeats live. ArrayMesh the cutter, not the result. Instanced fasteners (NanoMesh, ArrayMesh) make a late "flathead, not Phillips" one Edit Mesh swap; per-seat imports cost one call per seat.
- Straight or curved, never in between (Plouffe). Polish planes, then edges, then corner softness, sharp to smooth; never mix sharp and round edges close together; one cavity along a border sells a separate plate.
- Function and camera set the detail (Klimer): how it is made, where power flows, one fastener family, repeated angles identical. Validate twice in engine: the block (500 to 40k polys) with the rig, then a decimated high under 1M points. Unseen interiors and undersides stay filled or simple, sold by dark AO (Pavlovich).
- Micro detail waits for sign-off, on 3D layers (Klimer): a negative strength carves in, duplicating doubles, and a layer dies with any topology change, so layers come after the last boolean, remesh or Apply [added].
- Mirror And Weld always copies negative X onto positive X. Good half on +X: Deformation Mirror first (Pavlovich, Plouffe). Slice, Trim and BRadius ignore symmetry, so every one-sided edit ends here.
What the agent can and cannot do here
| Deterministic (script it) | Click or stroke [verify] | GUI only, or substitute |
|---|---|---|
Primitives, Unify, placement (new_part, place, import_part); Live Boolean roles (set_roles, roles_from_groups), DSDiv, Make Boolean Mesh, Show Coplanar and Show Issues |
ZModeler: a saved brush or 2026 preset holding the action and crease option, one canvas_action at click_target (a drag the first time; a tap replays) |
Clip, Trim, Slice, Knife (Ctrl+Shift drag; press_key is broken): boolean cutters, fitted_split_groups, DynaMesh Groups on a group outline, a ZScript IKeyPress wrapper (scenario-zbrush-automation) |
| Dynamic Subdiv, CreaseLvl, CTolerance, Crease PG, Crease Bevel, Apply | NanoMesh insert: one click on a face of the target group, or on a front-facing plane (the shipped macro's routine) | Gizmo deformers: Deformation sliders on an axis-aligned part |
Groups By Normals, Mirror And Weld, Panel Loops, GroupsLoops, ArrayMesh, NanoMesh sliders, MeshFromBrush (fastener_from_brush), masks (mask_fillet), ZRemesher, DynaMesh, Decimation |
IMM: a synthesized Dots stroke, or Curve Mode on script curves; imm_ready first |
Retopo brush, Mask By Polypaint dialog, freehand masks, hPolish planing (weak strokes) |
Establish first
| Input | Changes | Default when silent |
|---|---|---|
| Destination | game (bake to a low), film, print collectible, concept read | game asset baked to one 2048 map (Pavlovich's pistol) |
| Camera | first person and ADS, third person, turntable, print framing | third person; detail sized to what reads there |
| Budget | low-poly triangles, map size, high-poly cap | report counts and ask; never invent a budget |
| Function sheet | how it opens, vents, power, how it is made, fastener family | write one before blocking (Klimer) |
| Scale | rigged: real scale in the block; concept: proportion | Unify (about 2 units), Z forward, perspective off (Chervenka); mm briefs via to_tool_units |
| Moving parts | a hinged visor stays a separate part and Start group | separate |
| Painter handoff | Substance Bridge, or OBJ plus an external bake | ask; both are below |
Workflow
- Setup. Proxy or concept as a SubTool; Unify, Z forward, X symmetry; versioned ZTL before every boolean, DynaMesh, ZRemesher and Apply (no undo from Python). GATE: longest bbox side about 2; ZTL on disk.
- Block.
new_partandplace, orimport_partfrom a DCC block; body first, cutters below. GATE: forms sheet (zb_review.review) against the concept; a rigged asset's block OBJ checked in engine with the rig before detailing. - Shell. Deformation sliders on primitives, subtractive boxes for openings, Make Boolean Mesh,
zremesh_recipe("after_boolean"). The sculpted route (DynaMesh 128 to 176, hPolish, TrimDynamic) is concept only. GATE:hygiene(obj)ok (it stands in for Check Mesh, which reports through a note); PolyFrame shows even quads. - Crease plan.
crease_by_groups(45),dynamic_subdiv(intent="working"); arcscrease_by_angle, tuned per span. Loops and insets made with ZModeler take their crease at creation (zmodeler_plan: Insert EdgeLoop "Crease"; Inset "Crease New Edges" crisp, "Crease Inner Poly" rounded; defaults are Do Not Crease). GATE:check_crease; metal sheet (review_sheets) shows soft, light-catching edges, no scalloping, symmetric polygroups. - Cuts and vents. Cutters
new_part(..., op="sub")through the surface, never flush; Smooth 3 / CreaseLvl 2 plus crease by angle;array_meshon the cutter. GATE before:stack_report()["problems"]empty,coplanar_reportclean, Show Coplanar snapshot has no red,array_planwalls ok. Thenmake_boolean_mesh(short timeout). GATE after: UMesh SubTools equal Start groups; Show Issues snapshot clean; recrease (Crease PG unless a border was softened on purpose);hygienebefore ZRemesher. Any one-sided edit:mirror_report,mirror_weld(keep=...), thenmirror_checkon exports before and after. - Panels and fitted parts. Plan panels as polygroups first. Real separations: Panel Loops with Plouffe's recipe (
panel_loops). A visor or hatch that fits its opening: one cutter used twice (fitted_split_groups: Intersect in one Start group, Subtract in the other), or DynaMesh Groups on a group outline. Seams: a thin subtractive cutter fromborder_polylinespluscurve_mesh[verify]. Masks:mask_fillet(Klimer's blur, invert, repeated). GATE:split_copy("groups")counts panels; gaps constant on the sheet. - Hardware. One family. ArrayMesh rows; radial sets as ArrayMesh Rotate 360 then
array_commit("nano")with ZRVar variation (Chervenka); a part from an IMM without a drag:fastener_from_brush; no face where a bolt goes: a helper plane. A port across a seam:seam_port_groups(Pavlovich). Exact runs:spaced_points. GATE: count, orientation and the same fastener everywhere on the sheet. - High-poly commit.
bake_split(): Apply at 4/2 on a copy, Dynamic off on the original. Exact chamfer widths: Crease Bevel width or a boolean chamfer, since Polish only gives the minimum (Polycount). GATE: levels = 1 + Flat + Smooth;edge_languagehas no ambiguous lines;plane_flatnessno wobbly groups;pinch_reportflags nothing. - Micro detail (after sign-off). Layers per region (Tool:Layers:New; procedures P15). GATE:
layer_safe(recorded, zb_ops.stats())before and after each pass. - Low poly and handoff. Dynamic-off cage; ZRemesher per part (
zremesh_recipe("part")); quick static prop:dynamesh_visible(res)then onedecimate_percent; or Maya or Blender. Klimer's ZRemesher plus auto-reduction gave "the nastiest mesh I've ever shipped". Painter routes (scenario-zbrush-retopology-export runs them): Substance Bridge Low & High plus Auto-Bake plus Send PolyPaint (bridge_preflight), or native Unwrap with crease seams (unwrap_creases) then OBJ. GATE:zb_audit.verdict(..., "game"); animated parts separate.
Numbers
| Value | Use | Source |
|---|---|---|
| Groups By Normals 45 (33 softer, 60 misses) | crease plan | Pavlovich; Plouffe |
| Smooth/Crease 3/2 working, 4/2 bake, 4/3 tight, 4/1 soft | edge width | Pavlovich; Plouffe |
| Crease Tolerance 45 (22 on one arch) | crease by angle | Pavlovich |
| Faces x4 per QGrid, Flat or Smooth step; Apply levels 1 + Flat + Smooth | budgets | DSUB doc |
| Cutter cylinders 8, 12, 16, 24 or 32 sides; 36 to 140 before a DynaMesh bake | cutters | Pavlovich; Polycount |
| ArrayMesh Repeat 4 to 5 | vents | Pavlovich |
| Panel Loops: Loops 1, Polish 0, Bevel 0, Elevation -100, Double, Ignore Groups | plates | Plouffe |
| ZRemesher Half + Detect Edges + Adaptive 0 | cut-ready base | Pavlovich |
| Keep Groups, Smooth Groups 0, AdaptiveSize 25 | part retopology | Chervenka |
| About 3 stars on a panel base | pinch budget | Plouffe |
| LazyStep 2 touching (his guess), 2.5 rivets; Curve Step 1 touching | IMM runs | Pavlovich; IMM doc |
| DynaMesh 176 exploration; 1000 game shell | resolution | Pavlovich |
| 25k of 1.671M = 1.4 to 1.5 percent | one decimation pass | Pavlovich |
| 10M polygons per SubTool | high-poly cap | Plouffe |
| Block 500 to 40k polys; review high under 1M points | engine checks | Klimer |
| 2048 bake, 4 px padding | maps | Pavlovich |
Quality gates
- Code:
stack_reportandcoplanar_reportclean;make_boolean_mesh()["ok"];hygiene;check_crease;mirror_check;edge_language(noambiguous);plane_flatness(obj)["wobbly"]empty;pinch_report;layer_safe;bridge_preflight;zb_audit.verdict. - Visual:
review_sheets: MatCap Gray from five views for forms; metal, square-on and 30-degree grazing for planes and edge width; PolyFrame for groups and dotted crease lines. Show Coplanar before and Show Issues after each boolean. Pinches: a low level with dark polypaint and a low-metal MatCap. Judge withreferences/critique.md.
Common mistakes
| Mistake | Looks like | Fix |
|---|---|---|
| CreaseLvl >= SmoothSubdiv | razor CG edges | dynamic_subdiv(intent="bake") |
| QGrid on a curved shell | faceted arcs | QGrid 0, Smooth plus crease_by_angle |
| Cutter flush with the body | coplanar note blocks; holes | cut through; coplanar_report |
| No Start group or DSDiv off | part sewn to the body; faceted UMesh | set_roles(... start=True); dsdiv=True |
| Mirror And Weld, good half on +X | edit erased | mirror_weld(keep="pos"), mirror_check |
| Crease PG after softening borders | softened edges razor again | crease by angle, or merge groups first |
| Debris after cuts | ZRemesher fails | hygiene, hide debris, Del Hidden |
| IMM on a SubTool with levels | insert refused | imm_ready; level-free placeholder SubTool |
| Layer made before a topology change | layer no longer applies | layers after the last boolean or Apply |
| Force UV Auto-Unwrap on | every SubTool loses its UVs | bridge_preflight; off |
Handoffs
- Receives: a concept and brief from the user; an AI mesh from scenario-3d; a DCC block from scenario-maya-modeling or scenario-blender-hard-surface (OBJ, quads and tris); a forms pass from scenario-zbrush-sculpting.
- Delivers: a versioned ZTL (high SubTools, Dynamic-off cages), OBJ per part with matching high and low names,
stack_reportand the last sheets; to scenario-zbrush-retopology-export for UVs (Unwrap with crease seams, UV Master), Substance Bridge or Multi Map Exporter bakes (a polypaint ID travels by Send PolyPaint or an MME Texture From Polypaint map; OBJ vertex color is not established) and the game low poly; the 2026.1 Retopo brush is a GUI step there; to scenario-maya-modeling, scenario-maya-retopology-uv, scenario-blender-retopology or scenario-blender-uv-baking for a hand-built low; polypaint IDs with scenario-zbrush-paint-render; print parts to scenario-zbrush-pose-print.
ZBrush 2026 notes
- Knife (2021.7 to 2022) with Split To Parts (2024) replaces most Slice and Trim routines; Knife cannot cut holes.
- ZModeler 2026.0 creases at creation (Insert EdgeLoop, Inset); 2026.2.1 adds Bevel All, Outer or Inner Edges and fixes the Extrude-instead-of-QMesh default.
- Substance Bridge (2026.2.0): Low & High, Auto-Bake, Send PolyPaint; Force UV Auto-Unwrap is global; each send makes a new Painter project.
- 2026.2.1 fixes Live Boolean rendering and an Apply crash. ZRepeat It is not shipped: loop SubTools in Python.
- Blocking notes (2026.2.1 UI strings): coplanar Make Boolean Mesh; floor Elv on Mirror And Weld; n-gons on import; DynaMesh with levels; the Check Mesh Integrity result [verify]; the Create InsertMesh Append-or-New prompt when the current brush is already an insert brush; the shipped Create Instance Subtool macro ends on a note.
- Prefixed labels:
s.SmoothSubdiv,P.Panel Loops,a.Repeat,m.Width; sections of Tool > Geometry add no path level. Cmd+W quits ZBrush on macOS; Control+W groups.
References
references/expert-notes.md: principles by expert, timestamps, deciding conditions; load when planning.references/procedures.md: full bridge procedures; load before writing code.references/critique.md: the rubric; load at every gate.references/gui-paths.md: palettes, hotkeys, brushes; load when a step is GUI only.references/sources.md: every source, credential, URL and timestamp.
Files (skills)
-
references
-
critique.md 23.4 KB
# Critique: judging a hard-surface model the way the experts do Use at every gate. Inputs: the three sheets from `hs.review_sheets(out_dir, polyframe=True)`: - **forms:** MatCap Gray, front, right, back, three-quarter, top; - **planes:** a metal MatCap (`MatCap Metal01` [verify label]; Pavlovich checks under a green metallic, `Green Metallic` ships), square-on and 30-degree grazing views; - **PolyFrame:** front, right, top. Add the numbers from the calls named in each row. Perspective off for every review (Chervenka). Open each sheet with the image reader and write one line per failed criterion: criterion, where, why, the fix. Severity: **blocker** (fix before the next stage), **fix** (before delivery), **note** (taste, report it). ## A. Does it read as hard surface? (Plouffe's visual language, u75skb32GTo) | # | Criterion | Look where | What fails | Code proxy | Fix | | --- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A1 | Every edge is clearly straight or clearly curved, never in between [00:52:55] | forms sheet, silhouettes in the orthographic views; PolyFrame for group borders | a "straight" edge with a slight bow or kink; a curve too flat to read as a curve | `edge_language(cage_obj)`: any `ambiguous` line (thresholds [added]); `compound` is fine | straighten on the cage (reposition the primitive, regroup, `crease_by_groups`), or push the curve until it reads | | A2 | Planes carry an even highlight; paired edges run parallel or taper consistently [00:59:02]-[01:01:47] | planes sheet, square-on and grazing views | the highlight runs diagonally, dips or breaks across a face: wobbliness | `plane_flatness(obj)["wobbly"]`; `normal_variance` p95 over the plane | fix the edges (the plane's boundary), then polish: Polish Crisp Edges after a morph target, or rebuild the plate on a clean cage (Panel Loops) | | A3 | Edges wide enough to catch light, razor only where planned [01:05:00], [02:19:11] | planes sheet; grazing views show the specular line along each soft edge | no specular line along an edge (razor, "very CG", Pavlovich 8LNjAkqr_lI [00:13:19]) or a mushy roll with no crisp read | `check_crease(smooth, crease_lvl, intent)`; `edge_width` names the pair (gap 0 razor, 1 tight, 2 medium, 3 soft) | CreaseLvl 1 or 2 below SmoothSubdiv; render `intent="tight"` (4/3) and `intent="bake"` (4/2), `soft` (4/1) if rounder is wanted, and keep the widest edge that looks planned. 4/3 is the TIGHTER edge | | A4 | Edge radii look planned; sharp to smooth [01:05:33]-[01:06:09] | planes sheet | random mix of radii on similar edges | none | one crease plan per part family; same Dynamic numbers across similar parts | | A5 | Sharp and round edges are not mixed close together [01:31:40]-[01:32:13] | forms and planes, around cut-outs and mid details | a sharp mask corner or cut next to a soft rolled edge | none | round the cutter (Dynamic on the cutter) or sharpen the neighbor | | A6 | Mid details follow the big shapes' angles and roundness [01:32:13] | forms sheet | a vent or plate with a different angle language than the shell | compare cut angles in the placement values | re-place cutters on the shell's angles | | A7 | Separate plates read as modules [01:50:44]-[01:51:18] | forms sheet at review distance | plates look extruded from the base | Panel Loops present, or a seam cutter along the border | a groove along the border (seam cutter, Panel Loops gap) | | A8 | Blur is intent, not mush [01:33:52]-[01:34:26] | profile views of mask extrusions and bevels | soft blobs where a crisp step was meant | none | fewer blur passes; crease instead | | A9 | Detail sized to the viewing distance [01:10:56]-[01:13:12] | a render at the planned framing (game camera, print scale) | noise that does not read, or pixelation at the closest planned view | faces per SubTool against the budget | remove what does not read; subdivide only until pixelation disappears | | A10 | Chamfers the brief sizes are exact (Polycount: Polish gives only the minimum width; Plouffe digest, Bevel making) | planes sheet, grazing views; bbox of the chamfer faces on an OBJ | uneven or too thin chamfers where a width was specified | `to_tool_units(width_mm, extent_mm, tool_extent)` against the measured band | Crease Bevel with Bevel Width, or a boolean chamfer cutter (procedures P5) | ## B. Construction (Pavlovich, Maxon docs) | # | Criterion | Look where | What fails | Code proxy | Fix | | --- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | B1 | Crease plan symmetric and intentional | PolyFrame sheet: group colors mirror left and right; dotted crease lines where intended (qeFclVta4No [00:05:13]) | groups differ per side; creases on borders meant soft | OBJ export: `load_groups` count; `mirror_weld()["ok"]` | `mirror_weld`, then `crease_by_groups`; merge groups that must not crease | | B2 | No scalloping at caps and ends; no bowing of long slots | planes sheet near cylinder caps and slot ends (qeFclVta4No [00:08:34]; x1gN9-SocYY [00:06:28]) | wavy rims, bulging slots | none | support loop (`groups_loops(1)`), crease the ends (`crease_by_angle`) | | B3 | No QGrid faceting on curved spans | planes sheet on the shell (qeFclVta4No [00:22:59]) | faceted arcs | `dynamic_state()["qgrid"] > 0` on a curved part | QGrid 0, Smooth plus `crease_by_angle(22)` | | B4 | Boolean result clean | Show Coplanar snapshot BEFORE (no red faces) and Show Issues on the UMesh AFTER (no red outlines), both Render > Render Booleans (LB doc Geometry Analysis); PolyFrame | holes, missing parts, long thin triangles, sewn union parts | `coplanar_report` before; `make_boolean_mesh()["ok"]`; `hygiene(obj)` after | move the operand off the coplanar plane; a Start group per part; lower DSDiv levels | | B5 | Clean topology where it continues | PolyFrame after ZRemesher | spirals, star clusters on flat areas, aliased loops along group borders (6PtmKtr1kx0 [00:07:56]) | `zb_audit` poles; Plouffe accepts about 3 stars on a panel base | smooth group borders first; `zremesh_recipe("after_cuts")` or `"part"` | | B6 | Walls between cuts are plausible | forms sheet at the vents (x1gN9-SocYY [00:12:19]) | paper-thin walls | `array_plan()["ok"]` | fewer cuts or narrower cutters | | B7 | Clip left no ring | forms sheet, PolyFrame off (FOdVdgiAHWo [00:03:56]) | a thin residual ring at the widest part | none | boolean or Trim route | | B8 | Pinches do not pop under metal | planes sheet at the top level after Apply; a low level with dark polypaint and a low-metal MatCap (Plouffe [02:25:59]-[02:28:48]) | dimples at star vertices | `pinch_report(cage_obj)`: stars inside groups; more than about 3 on a panel base is flagged (Plouffe [02:04:02]) | fix the cage (regroup, re-ZRemesher, project); a few local ones: smart smooth (GUI) or StoreMT, Polish, Project Morph (Chervenka) | | B9 | Mesh hygiene after cuts, booleans, Panel Loops and imports | PolyFrame; Split To Parts count on a copy | ZRemesher fails; stray shells; black faces | `hygiene(obj)`: no debris shells, non-manifold 0, zero-area 0 (stands in for Check Mesh, REFDOC MeshIntegrity; Pavlovich 8LNjAkqr_lI [00:10:46]) | hide debris, Del Hidden; Fix Mesh; re-export | | B10 | The edited half survived the mirror | forms sheet from both sides | an edit on +X gone after Mirror And Weld | `mirror_check(before_obj, after_obj, keep)`: preserved 1, symmetric | reload the last versioned ZTL; `mirror_weld(keep="pos")` runs Deformation Mirror first (8LNjAkqr_lI [00:06:49]) | | B11 | Soft borders stayed soft after later edits | PolyFrame dotted lines; planes sheet | borders uncreased on purpose are razor again after a boolean or a new Crease PG (qeFclVta4No [00:16:32]) | the list of softened borders in the agent's log | crease by angle or merge the groups, not Crease PG | ## C. Function and consistency (Klimer, 08crkU999Fs) | # | Criterion | Look where | What fails | Fix | | --- | -------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | C1 | It could work and be made | forms sheet with the function sheet | no traceable path for air, power or the hinge; parts with no fixing | add the missing part, or remove the mystery greeble [00:22:18]-[00:23:59] | | C2 | One fastener family, identical repeated angles | forms sheet close-ups | mixed screw heads; slash angles that differ; bolts stamped at one identical rotation | one part everywhere, instanced (NanoMesh or ArrayMesh) so a swap is one Edit Mesh; small ZRVar rotation variation (Chervenka [00:19:39]); copy the placement values [00:29:04], [01:00:37] | | C3 | Reads from the player's camera | a render from the game camera (first person or third person) | stacked small shapes turn into clutter, the part blocks the view | simplify the stack [01:16:23] | | C4 | Large calm areas where shaders and decals go | forms sheet | greebles everywhere | leave canvas areas [00:37:54] | | C5 | Moving parts are separate | `stack_report` groups; OBJ per part | a hinged visor fused to the shell | Start group or a separate SubTool (VfUqkvHymeQ [00:02:06]) | | C6 | Effort follows what is seen | forms sheet from below and inside; the game-camera render | detail inside a helmet, under a prop, in cavities nobody sees | fill them or keep them simple with dark AO (Pavlovich max2JumNDp4 [00:01:06], [00:04:01]) | | C7 | The block was validated before detailing (rigged assets) | the engine check log | detail built on proportions the rig later rejects | block OBJ (500 to 40k polys) in engine with the rig and hands first (Klimer [00:16:59]-[00:17:32]) | | C8 | Micro detail came after sign-off, on layers | layer list; `layer_safe` log | grain or filigree baked into the mesh before approval, or a layer broken by a later topology change | one layer per region, negative strength to carve; `layer_safe(recorded, stats())` (Klimer [00:29:36]-[00:34:30], [00:49:08]) | | C9 | Parts across a seam line up | close-ups of both sides of each seam | a port or boss that stops at the seam or does not match the socket | `seam_port_groups` from one sphere (Pavlovich cw449pmS64g [00:04:02]) | ## D. Budget and delivery | # | Criterion | Code | Pass | | --- | -------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | D1 | High poly per SubTool | `zb_ops.stats()` per part; `subdiv_faces` before Apply | at most 10M per SubTool (Plouffe), unless the brief says otherwise | | D2 | Review high for the engine | total points of the decimated copy | under 1M points (Klimer) | | D3 | Low poly | `zb_audit.verdict(audit, "game", budget_tris=...)` | no n-gons, within budget; UVs are the next skill's job | | D4 | Checkpoints | versioned ZTL before each boolean, Apply, DynaMesh and ZRemesher | files on disk | | D5 | Evidence | the last three sheets, `stack_report`, audit numbers, and the list of steps not verified | all present in the handoff | | D6 | Painter handoff | `bridge_preflight(rows, ...)` for Substance Bridge; UVs present for an OBJ bake | no problems; Force UV Auto-Unwrap off when UVs exist; the polypaint ID travels by Send PolyPaint or an MME map (OBJ vertex color is not established) | ## How to write the verdict Three lines at the top: the stage, blockers (with row ids), and what was not checked (for example "no game-camera render"). Then one line per failed row. Do not advance a stage with a blocker open. Plouffe's efficiency rule applies: effort goes to focus areas; a hidden underside at "decent" is a note, not a fix (u75skb32GTo [00:43:23], [01:13:12]). -
expert-notes.md 36.8 KB
# Expert notes: ZBrush hard surface The depth behind SKILL.md: principles and judgment by expert, each with its source and timestamp. Video ids refer to notes in `notes/hard-surface/` and `notes/kitbash/`; digests are `_digest_hard_surface_tools.md` (tools, with the addendum from the re-transcribed FOdVdgiAHWo and FCAb-CSVCBg, which wins where it conflicts), `_digest_hard_surface_projects.md` (pipeline) and `_digest_hard_surface_masters_plouffe.md` (visual language). Tags: [doc] Maxon documentation, [strings] label or message found in `ZData/ZLang/english/UInterface.zsc` of the installed 2026.2.1, [added] this skill's own inference, [verify] not confirmed. Caption quality: several Pavlovich pistol and skull lessons are auto captions machine-translated back to English (010, 012, 032 to 036, 039); no frame sheets exist for the Pavlovich batch. Plouffe's stream has 35 viewed contact sheets. FOdVdgiAHWo and FCAb-CSVCBg were re-transcribed with Whisper. ## 1. Marco Plouffe: how a surface reads as hard Co-founder of Keos Masons, concept and 3D character artist for games and collectibles (Sideshow Sentinel); ZBrush Masters stream hosted by Paul Gaboury, 2020, ZBrush 2020.1.1 on screen (u75skb32GTo). **Stance.** "Hard surface is not necessarily a technique, it's more like a result" [00:23:20]. Design organically with few constraints, then apply the language (clean surfaces, surgical edges, bolts, separated plates) [00:22:13]-[00:23:52]. Efficiency governs: focus areas get the clean method, a belt piece "nobody notices" stays dirty [00:19:58], [01:13:12]. **The rulebook** (this is the render critique; see `critique.md`): | Rule | Meaning | Source | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | Straight or curved, never ambiguous | "if you're trying to do a straight line do a straight line... but don't be in the middle" | [00:52:55] | | Edges carry the surface | paired edges of a plane run parallel or taper at the same rate; if they diverge the plane twists and the highlight runs diagonally and dips ("wobbliness") | [00:59:02]-[01:01:47] | | Build: mass, edges, planes | volume first, then insist on the edge lines, then flatten the facets | [00:25:02]-[00:28:00] | | Polish: planes, edges, corner softness | "working on the edges is less destructive to the planes than working on the planes is destructive to the edges" | [00:51:13]-[00:52:20] | | Sharp to smooth | let corners get sharp, then widen chosen edges; different radii are fine if they "look planned" | [01:05:00]-[01:06:09] | | Wide edges catch light | the reason he keeps CreaseLvl 3 over a razor edge | [02:19:11] | | Do not mix sharp and round too close | a sharp mask corner next to a round edge "doesn't work" | [01:31:40]-[01:32:13] | | Mid details obey the big shapes | small shapes repeat the angles and roundness of the large ones | [01:32:13] | | Blur is intent, not mush | too much blur looks "like you missed the target" | [01:33:52]-[01:34:26] | | One cavity sells a plate | an Orb_Cracks groove along a border reads as a separate module | [01:50:44]-[01:51:18] | | Function cues at surface level | grilles mean heat removal, so a mainframe behind; "as long as you sell the illusion" | [01:16:02]-[01:18:46] | | Detail from viewing distance | clean at bust framing for a 1/4 statue, far less for 1/12; subdivide until pixelation disappears at the closest planned view | [01:10:56]-[01:13:12], [01:19:51] | **Quick method** (dexterity on DynaMesh) [00:39:16]-[01:10:24]: DynaMesh 500 on an off-scale head, 3000 at standard scale, "as low as possible within the needs" [00:33:45]-[00:34:19]. Commit with a navigation cage: duplicate, ZRemesher target 0.3 (about 300 polygons; defaults on screen Target 4, Adapt on, AdaptiveSize 50, Curves Strength 50), Close Holes, regroup, Divide twice, Project All with Dist raised until everything snaps (0.0625, Mean 25 on screen) [00:44:38]-[00:49:33], [frame 00:51:36]. hPolish without Alt digs, with Alt builds up; use Alt with a plane-sized radius next to other planes [00:29:43]-[00:31:27]. A small radius "records brushstrokes" [00:56:36]. Clay brush with Alt, one large stroke along a corner, gives a flat 45 degree chamfer; a bevel of the bevel plus Smooth gives a round corner [01:06:43]-[01:09:16]. Fix wobbly planes at the edges (Move with AccuCurve) and then re-polish [00:59:34]-[01:01:47]. **Clean method** (ZRemesher, Panel Loops, creases) [01:47:08]-[02:31:33]: clean the contour first (Mask By Feature Border, invert, Deformation Polish, blur, repeat; or GroupsLoops Loops 1 or 0 with some GPolish) [01:59:16]-[01:59:48]; ZRemesher at the lowest target (100 polygons), rerun with Adapt off, accept about three stars [02:04:02]-[02:05:09]; a SliceCurve group line plus KeepGroups keeps groups separate [02:06:59]-[02:08:40]; Panel Loops with everything at the minimum, Loops 1, Thickness the only variable, Ignore Groups on; on screen Double on, Thickness 0.0046, Polish 0, Bevel 0, Elevation -100 [02:09:12]-[02:09:43], [frame 02:14:34]; Dynamic on, Crease PG, Groups By Normals 45 then 33 for a missed edge [02:10:14]-[02:13:01]; Polish Crisp Edges flattens everything not creased: store a morph target, two passes, recover lines with the Morph brush; better, clean by hand and run it once [02:14:41]-[02:16:57]; CreaseLvl default 15, at 4 razor through Dynamic levels 3 and 4, 3 kept, 2 too soft [02:18:03]-[02:19:44]; after Apply remove star pinches at a low level with the Shift-release smart smooth, border hidden, dark polypaint and low-metal MatCap to see them, because pinches pop under metal after the bake [02:25:59]-[02:28:48]. **Symmetry with levels** [02:00:53]-[02:03:39]: delete levels, Mirror (if the good side is wrong), Mirror And Weld, Reconstruct Subdiv back to all levels; impossible if the mirror cut the mesh or layers must survive. **Budgets:** 10M polygons per SubTool, 15M to 20M only for a seamless nude; head 25M, full statue 100M to 150M [01:18:46]-[01:20:58]. Print: push the blocking inside every piece so everything booleans into a watertight solid [02:40:38]-[02:41:45]. Games: same technique, fabric detail to Painter, armor stays clean [01:14:21]-[01:15:28]. **For the agent:** Plouffe avoids masks and morph targets because dexterity is faster [01:03:11]; the agent has no dexterity, so it takes the clean method and checkpoints every time. Perfect half circles come from primitives, not hand strokes [01:21:32]-[01:23:12], which suits the agent. ## 2. Michael Pavlovich: the tools Games sculptor (Halo 4, Call of Duty, Doom), official ZBrushLIVE presenter, CGMA instructor. "Intro to ZBrush" series (2021) and two 2024 updates. **Dynamic Subdivision and creasing** (qeFclVta4No, 2021): - No real levels while box modeling: ZModeler stops on a mesh with levels [00:01:03]. D toggles Dynamic on, Shift+D off; the first time ZBrush asks for confirmation [00:01:34]. - "My general rule is to crease first, and then add a control loop if necessary" [00:08:18]. Loops near a cylinder cap fix scalloping [00:08:34]-[00:09:07]. - Groups By Normals: 45 catches 90 degree edges, 60 misses some, 33 catches smaller plane changes; then Crease PG [00:11:10]-[00:12:15]. - CreaseLvl versus Smooth Subdiv: "If it's equal or greater, it'll always be super sharp"; he works at 2/3; 2/4 "is what I would bake, so you get a nice highlight on that edge"; 1/4 very soft [00:14:11]-[00:16:29]. - Crease PG after more modeling recreases borders you had uncreased; merge unwanted groups first or crease loops manually [00:16:32]-[00:17:49]. - Crease Tolerance 45 equals Group By Normals 45 plus Crease PG [00:18:05]; 22 held an arch without creasing its small angles; too low creases the arc [00:22:59]-[00:23:30]; arch finish 3/4 [00:24:00]. - QGrid packs polygons on every edge, including interior edges that do not shape the silhouette; QGrid-only Apply has no history [00:27:54]-[00:29:26]. - Bake split: duplicate; Shift+D on one for game-res (fix over-curved edges), Apply on the other [00:25:50]-[00:26:20]. Sculpting with Dynamic on does almost nothing until Apply [00:26:51]. **Live Boolean** (HXnKnrhlFpA, 2021): main body on top, cutters below [00:00:33]; cutters inherit Dynamic smoothing and scallop, so crease them by tolerance [00:01:30]-[00:01:42]; DSDiv before Make Boolean Mesh or the result is faceted; output is a new UMesh tool [00:02:31]-[00:03:00]; inputs "nice and crispy with very hard edges" let ZRemesher Detect Edges at Half rebuild clean quads [00:03:00]-[00:03:31]; duplicates revert to Union [00:05:01]; a union part pushed too deep is sewn to the body; Start groups or folders keep it separate [00:06:31]-[00:07:30]; the folder menu has Boolean With DSDiv [00:09:00]-[00:10:00]. **Clip, Trim, Slice** (03BMMabyK8U; FOdVdgiAHWo re-transcribed): - Clip pushes polygons, never cuts topology [03BMM 00:00:16]. - Never Clip past the widest part of the form: it leaves "that residual ring around the object" [FOdV 00:03:56]. Use Trim, or Slice plus Del Hidden plus Close Holes. - "Clip works across an axis. Slice and trim do not"; Mirror And Weld after Slice or Trim, with Deformation Mirror first when the cut is on +X [FOdV 00:06:20]-[00:07:59]. - Drag direction picks the Clip side [FOdV 00:02:51]. - Slice cuts a perfectly straight line with untidy topology: treat it as a group divider, then ZRemesher [FOdV 00:05:46]. - Trim is Slice, isolate, Del Hidden, Close Holes [FOdV 00:08:35]-[00:10:10]. - The manual straight-cap route is "a lot of steps"; he uses Live Boolean instead [03BMM 00:03:37]. The video description now points to Knife. **Knife and Split To Parts** (8LNjAkqr_lI, 2024): prepare the base first (Make PolyMesh3D, ZRemesher Half + Detect Edges + Adaptive Size 0, Divide 4 times, Delete Lower) [00:04:04]-[00:05:09]; Split To Parts leaves no gap, BRadius leaves a gap of Draw Size and does not mirror [00:01:34], [00:05:31]-[00:06:15]; after cuts ZRemesher Keep Groups on, Detect Edges off, Smooth Groups 0 [00:10:14]; invisible floating debris makes ZRemesher fail [00:10:46]-[00:11:58]; Crease PG alone looks "very CG, very sharp", so 2/3 [00:13:19]; Crease Unmasked (2024) creases only the unmasked region [00:16:54]. **ZModeler** (XcR5TzvIaoc, qob31SzC754): QMesh, not Extrude, is the default polygon action (snaps, stays welded, deletes on a negative drag, stitches) [XcR5 00:04:33]-[00:15:05]; X symmetry overrides the PolyGroup All target [00:10:42]; Mirror And Weld makes groups identical on both sides [00:11:14]; Inset Equidistant adds edges on messy topology, Legacy keeps straight lines [00:20:55]-[00:22:34]; Insert EdgeLoop plus Alt deletes loops [qob3 00:00:27]; Insert Multiple EdgeLoops with Interactive Elevation makes fillets [00:02:16]; Optimal Curvature and Resolution match bridges to the surrounding edge length [00:06:04]; temporary Dynamic thickness (Smooth 0, Thickness above 0) makes single-sided edges pickable [00:04:24]. 2026.0 adds creasing on Insert EdgeLoop (Crease or Do Not Crease, default Do Not Crease) and Inset (Crease New Edges for a crisp inset with supported borders, Crease Inner Poly for a rounded one), replacing the alternate-polygroup plus Crease PG pass (Maxon, 6KZiNEO65YY [00:01:06]-[00:02:44]); Plouffe's 2020 trick of inserting loops and inflating them because Inset and Extrude erased creases (u75skb32GTo [02:20:17]-[02:21:25]) is superseded by these options. 2026.2.1 adds Bevel edge modes (All, Outer, Inner Edges [strings]) and fixes ZModeler defaulting to Extrude instead of QMesh (release notes). For the agent the options are Space-menu state: save them in a brush or preset once, then replay with one canvas input (procedures P14) [added]. **ZRemesher on hard surface** (6PtmKtr1kx0): smooth polygroup borders first, because ZRemesher "is going to look at all of these aliased lines and try and build those in" [00:07:56]; Slice-curve group lines place loops better than the guides brush [00:06:48]; Adaptive Size 0 keeps quads even [00:08:59]; detail comes back with Divide plus Project All, never re-sculpted [00:10:04]-[00:10:35]; Remesh By Union welds parts without changing their surface [00:12:12]-[00:12:44]. **Gizmo deformers** (FCAb-CSVCBg, re-transcribed): deformers run along the Gizmo axis, so split an IMM insert into its own SubTool first (Split Unmasked Points) and the Gizmo aligns to the part [00:01:09]-[00:01:45]; Bend Curve: set the curve axis first, then Curve Resolution [00:01:45]-[00:02:20]; commit with W or gear > Accept [00:02:20]. **IMM, NanoMesh, IMM strokes** (U5u3RpI9In4, QGNn1-ey6ME, wnONEaRVhYU): - IMM capture: "how you capture them is important". Orientation comes from the camera, names from SubTools, Unify first [U5u3 00:15:04]-[00:19:08]. - Ctrl while dragging snaps an IMM to the brush size [U5u3 00:12:30]. - Projection Strength 100 conforms a plate; low keeps its shape [U5u3 00:10:56]. - A low-res DynaMesh fuses small parts [U5u3 00:03:58]. - Keep repeated hardware as NanoMesh, so "the art director says... no problem" is one Edit Mesh swap. Fix the swapped scale with Unify [QGNn 00:12:26]-[00:13:32], [00:17:56]. - Levels block IMM insertion: keep a hidden level-free placeholder SubTool at the top [QGNn 00:14:07]-[00:15:46]. - LazyStep is the spacing: 1 overlaps half, 2 touches (his guess: primitives are 2 units), 2.5 for rivets, 0.4 for tubes; 0 breaks the Dots stroke [wnON 00:03:43]-[00:05:25], [00:22:20]. - Ghost transparency lets a run pass over earlier parts [wnON 00:17:30]. Curve Mode handles tapers and swaps [wnON 00:19:37]-[00:21:47]. ## 3. Michael Pavlovich: projects (skull 2020, pistol 2017) - **Exploration resolution:** skull at 128 and 96 too low for hard surface, 176 the "happy medium"; do not re-project while designing [1iJ_sO1U0V4 00:01:03]-[00:02:07]. - **Brushes:** hPolish keeps edges (Preserve Edge in Brush > Samples), TrimDynamic "will absolutely destroy an edge", which is how to bevel; oversize polish brushes; BackfaceMask on for thin parts [qBi-JF23r4Y 00:01:00]-[00:03:34], [00:07:32]. - **Detail where the bake shows it:** fill cavities nobody sees and paint dark AO; at 512 to 2048 maps perfect sub-D does not pay; "if it's gonna save you days of work later to put in an extra hour and a half... do that" [max2JumNDp4 00:01:06], [00:05:14]-[00:05:48]. - **Slice + DynaMesh Groups** makes perfectly fitting embedded panels; regroup and remesh to weld leftovers [Yprguxci8NY 00:01:05]-[00:02:43]. - **Masks:** jagged mask borders become aliased polygroups; clean with Edgeloop Masked Border or Mask By Feature plus Polish By Features [MteQbBFxgqk 00:02:06]-[00:03:12]. A curve IMM along a framed border, set subtractive, is a live cut line [00:03:12]-[00:04:17]. - **Close Holes** on complex openings needs a few Bridge Edges at the major turns on half the model, then Mirror And Weld; Polish By Features near a straight cut damaged it [16AAIFE9CRc 00:00:34]-[00:02:46], [00:11:45]. - **Lightweighting:** plan pockets in Polypaint, mask by Polypaint per color; "in a production setting... I would absolutely boolean those little back notches" [FAFtW_8zB5Q 00:00:00], [00:11:28]. - **Panel seams:** a three-part curve InsertMesh (Tri Parts, Weld Points, creased, Imbed 0) along a framed polygroup border, subtracted live; build the path from a slice or a ZRemeshed strip snapped back with Project All [FM3uQJy1GUY 00:01:03]-[00:05:51]. - **Screw port across a seam:** one sliced sphere, half merged into part A, half subtractive on part B; Crease PG plus two Divides on the halves before remeshing [cw449pmS64g 00:01:06]-[00:04:34]. - **Blocking a weapon:** break the concept into base shapes; model only the base in ZModeler and boolean the cut-ins later [TANNfCLxFx4 00:01:13]. A perfectly round barrel fixes the gun's width [00:04:54]. Cylinders with 8, 12, 16, 24 or 32 sides keep a vertex line on both axes [00:06:44]. - **Clip trap:** ClipCurve plus DynaMesh leaves thin stretched strips; TrimCurve removes material [GrIf_eeq6K0 00:02:07]-[00:02:37]. - **Vents:** ArrayMesh is "my preferred method because less destructive"; instances and the Live Boolean update live; Repeat 4 to 5 with a negative Z offset; a rounded, creased cutter pushed deeper widens its chamfer; watch wall thickness [x1gN9-SocYY 00:08:35]-[00:12:19]. - **Cutter numbers:** QGrid on a cylinder facets; Smooth 3 / CreaseLvl 2 is his cutter default, 4 and 3 or 4 tighter; crease open ends by hiding the rest and pressing Crease; DynaMesh off on cutters [KroRw6dFs10 00:01:00]-[00:02:39], [00:07:15]. For an agent the DynaMesh-off rule guards against nothing (it matters because a human Ctrl+drag remeshes), but it costs nothing [added]. - **Start groups:** any SubTool without its own Start group is unioned into the main part on Make Boolean Mesh; DSDiv on [Ab0ixptmNeA 00:00:00]-[00:01:07]. - **UMesh part cleanup:** Group By Normals, ZRemesher Keep Groups, Smooth Groups 0, Target "Same", Adaptive Size a bit lower [QbEHsPGNnlY 00:00:33]. - **Quick game low poly:** find the lowest DynaMesh resolution that gives a gap-free shell (1000 on the pistol), apply it to every SubTool, then one Decimate All percentage (25,000 of 1.671M; he set 1.4 and got about 23k); keep animated parts separate; he calls it hasty [VfUqkvHymeQ 00:00:32]-[00:02:37]; [h5gsm_-6df4 00:00:31]-[00:01:01]. - **DCC and bake:** ZBrush units make tiny edges zero-length in Maya cleanup, so scale up first; 4 px padding at 2048 [iWq7dFxf55I 00:02:54]-[00:03:56]. Bake by mesh name, Polypaint as the ID map; rebake ID alone if it fails [te7lV_EpGQ0 00:00:00]-[00:00:33]. ## 4. Mike Klimer (Bungie, Destiny 2 weapons, ZBrush Summit 2017) Senior hard-surface artist at Bungie, previously The Division (08crkU999Fs; self-introduced). - **Function and manufacturing:** "anybody can take 50 different parts... dynamesh it into something and then call it a sci-fi weapon"; ask how the round or energy travels, how it is milled or molded, where mold lines and injection points go [00:22:18]-[00:23:59]. - **Player camera:** first person and ADS get the effort; the harsh wide angle turns stacked small shapes into clutter ("six rat butts"), so simplify [00:11:57], [01:16:23]-[01:17:31]. - **Validate twice:** a 500 to 600 up to 30k to 40k polygon block in engine with the rig and hands; then a decimated high (9M points to under 1M) with the animation, before the low poly and textures [00:16:27]-[00:17:32], [00:36:13]-[00:36:45]. - **Micro detail after sign-off, on layers:** negative strength carves in, duplicating doubles strength, layers do not survive added or removed geometry [00:29:36]-[00:34:30], [00:49:08]. Consequence for the agent [added]: layers come after the last boolean, remesh, Panel Loops or Apply, and `layer_safe` compares the point, face and level counts recorded at layer creation. - **Mask refinement by repetition:** blur, invert, blur, invert gives every corner the same fillet radius and removes stray bits; group borders are cleaned with the SmoothGroups brush instead of border loops, because a layer cannot survive added geometry [00:46:21]-[00:49:42]. Scripted as `mask_fillet`; Polish By Groups is the stroke-free stand-in for SmoothGroups [added]. - **Consistency is a rule:** repeated cut angles all the same (22.5 degrees), one fastener type per family, one artist per weapon suite [00:29:04], [01:00:37]-[01:01:10], [00:57:03]. - **Canvas areas:** leave big open areas on base weapons so shaders, decals and camo read [00:37:54]. - **Bevels:** plan them in the low-poly block with ZModeler ("the easier way"); he brute-forces at 6M points [01:08:20]. His own ZRemesher plus auto-reduction low poly was "the nastiest mesh I've ever shipped" [01:22:43]. ## 5. Henry Chervenka (3D Dynamic Studios, ZBrush Summit 2023) CEO of 3D Dynamic Studios, hard-surface instructor (PGX39tnEf3Y). - Two phases: shape and proportion first, "I don't care about topology"; then topology [00:05:05]. - Start every project the same way: Unify (about 2 units at world center, then scale up a little), Z forward, perspective off for Clip, Trim, Knife and Slice [00:08:24]-[00:10:33]. - NanoMesh when instances must follow faces (treads, bolts), with even face sizes; ArrayMesh for patterns, Convert To NanoMesh for rotation variation [00:10:33]-[00:24:18]. Bolts on a wheel: a Fastener IMM turned into a NanoMesh brush, NanoMesh Rotation and Rotation Variation for realistic random angles [00:17:55]-[00:19:39]. No face where a bolt goes: drag a plane, split it off, Dynamic off, draw the NanoMesh on it, Show Placement off [00:20:15]-[00:21:24]. The ArrayMesh route without any drag: duplicate a SubTool, pick the fastener brush, Modify Topology > Mesh From Brush, ArrayMesh (Reset, Lock Position, Lock Size, TransPose, Repeat, Rotate Z Amount 360), then Convert To NanoMesh [00:21:24]-[00:24:18]; scripted as `fastener_from_brush`, `array_mesh(rotate=[0, 0, 360])`, `array_commit("nano")`, `nanomesh_set(ZRVar=...)`. - ZRemesher "the less information we give it as far as geometry the better... the more information you give it as far as instructions the better": delete holes and hidden bottoms, keep bevels tight not round, polygroups, Keep Groups, Smooth Groups 0, Adaptive Size 25 [00:32:14]-[00:37:48]; off-center symmetric parts through the Gizmo's Remesh by ZRemesher [00:36:07]. - Clean topology without volume loss: Store Morph Target, smooth or polish, Project Morph [00:24:52]-[00:27:11]. - Macros are the most underused speed tool; his Mask Border Polish is mask by border, invert, blur, Polish By Features about three times, clear [00:04:00], [00:48:52]. ## 6. Polycount author (ProBoolean plus DynaMesh, 2016) Game hard-surface artist (unverified identity). Keep the model procedural in the DCC; ZBrush only makes the high poly with DynaMesh (256 in his example) plus Polish; the low poly is the same boolean stack with fewer segments; cylinders at 36 minimum, 108 to 140 for large ones; "ZB [provides] the 'minimum' edge width. Any chamfers that need to be specifically larger I'll do with boolean subtractions." His claim that modeling in ZBrush "backfires" on revisions predates Live Boolean with ArrayMesh and Start groups. ## 7. Maxon documentation (rules the experts assume) - **Live Boolean** [doc]: no coplanar faces, watertight volumes, consistent density, low Dynamic Subdivision (DSDiv makes long thin polygons); processed top to bottom, the first SubTool Add or Start, only visible SubTools; unsupported: HD geometry, 3D primitives, render-time objects, partially visible meshes (hidden openings are closed); removed: UVs, textures, levels (cannot be reconstructed), creases, layers, masks; polygroups and polypaint propagate; smooth before the boolean. Show Coplanar before, Show Issues after. 2026.2.1 strings add two blocking notes: a coplanar warning with "Do you really want to launch Boolean whatever?", and "Every input SubTool must be a valid Solid" [strings]. - **Dynamic Subdivision** [doc]: QGrid, then Flat, then Smooth, whatever order you set them; each step x4; Apply: QGrid 1 + Flat 1 + Smooth 3 gives 5 levels; Smooth turns a triangle into three quads on the first level; D and Shift+D step classic levels when they exist. - **Mesh Integrity** [doc reference]: Check Mesh and Fix Mesh after heavy topology operations; the 2026.2.1 strings show the check answers through a note ("completed successfully" or "failed ... Please 'Fix Mesh'"), so the agent measures the same things with `hygiene` on an OBJ export [added]. - **Hard-surface tools** [doc]: Clip moves polygons and never changes topology; Trim caps flat; Slice adds a cut and groups, no symmetry, no levels; Knife cuts and closes with quads, works with symmetry, cannot cut holes through the middle; BevelPro bevels along group borders; Panel Loops with Loops, Double, Append, Inner, Thickness, Polish, Bevel, Elevation, Bevel Profile, Ignore Groups, no levels; ZRemesher on hard surfaces wants a higher target, Keep or Freeze Groups, Frame Mesh curves at Curves Strength 100. - **ZModeler** [doc]: PolyMesh3D only, no levels for topology actions, quads and triangles only, every action replays on a click; QMesh fuses and deletes itself on a negative drag; an EdgeLoop stops at odd-valence vertices, a PolyLoop continues. - **Gizmo deformers** [doc]: only on meshes without levels; parametric primitives replace the SubTool unless it is fully masked; Flatten's Slice Topology adds a creased loop that "drastically" improves Dynamic Subdivision results. - **IMM, ArrayMesh, NanoMesh** [doc]: an IMM stores meshes as seen on screen; save the brush or lose it; Mesh Fusion needs open borders and no DynaMesh mode; ArrayMesh stages, pivot, TransPose mode, Convert To NanoMesh; NanoMesh placement by polygroup, Edit Mesh, several indices. ## 7b. Handoff features added from 2023 to 2026.2 (`sources/zbrush-version-deltas.md`) - **Substance Bridge** (2026.2.0, VD 3.6): Texture > Substance Bridge > Send to Painter with All, Visible or Active; Subdivision Level Current or Low & High; Auto-Bake Maps; Smooth Normals; Send PolyPaint as a fill layer; Texture Sets per SubTool or per PolyGroup. Force UV Auto-Unwrap is global and strips existing UVs from every SubTool; each send creates a new Painter project, with no incremental update; Auto-Bake with all options on did not trigger on Mac before 2026.2.1. It supersedes the manual low and high export of Pavlovich's 2017 bake lesson for quick bakes (te7lV_EpGQ0 Outdated). For hard surface [added]: Low & High reads each SubTool's own lowest and highest level, so the `bake_split` copy (Apply) carries both; after Apply with QGrid 0 level 1 is the Dynamic cage (DSUB Apply rule). Checked by `bridge_preflight`. - **Native Unwrap** (2023, VD 3.5): Tool > UV Map > Create (Unwrap) with Auto Seams, Creased Edges and Symmetry; "Creased PG Unwrap" was fixed in 2026.1.2. On a hard-surface cage the polygroups already are the crease plan, so Crease PG seams fall on the hard edges [added]; scenario-zbrush-retopology-export's `unwrap_creases` runs it. - **Retopo brush** (2026.1, VD 3.5): a manual low poly inside ZBrush; the first click asks Yes or No; a SubTool with levels cannot become the retopo mesh. Stroke-driven: a GUI step. - **Knife and Split To Parts** (2021.7 to 2024, VD 3.4) replace most Slice and Trim panel routines; Knife cannot cut holes. ## 8. Disagreements and deciding conditions | Choice | Option A | Option B | Deciding condition | | --------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Edge hold | crease (Crease PG, tolerance) | control loop | crease by default; loop when the falloff must be local or survive outside ZBrush (Pavlovich) | | CreaseLvl gap | 2 below (Pavlovich bake 2/4, the wider edge) | 1 below (Pavlovich arches 3/4; Plouffe keeps CreaseLvl 3 over 4 and 2, smoothing level not stated; the tighter edge) | render both under metal at the planned distance, keep the widest edge that still reads intended | | Where the base is modeled | DCC booleans, ZBrush for the high (Polycount; Bungie's Lee) | all in ZBrush (Pavlovich, Chervenka) | frequent revisions and a low poly from the same stack: DCC; exploratory or curved: ZBrush. For the agent: ZBrush primitives plus Live Boolean, or a DCC block when ZModeler-style edits dominate | | Commit booleans | DynaMesh high plus Polish (fast, destructive) | Make Boolean Mesh with DSDiv and Start groups | concept or quick bake: DynaMesh; parts that go on to ZRemesher or a low poly: Make Boolean Mesh | | Repeats | ArrayMesh | NanoMesh | linear or radial pattern: ArrayMesh; follows faces or needs variation: NanoMesh (or ArrayMesh then Convert) | | Game low poly | fused DynaMesh shell plus one Decimate All | dedicated low poly with UVs | static prop or deadline: quick; animated parts, LODs, clean shading: dedicated | | Panel lines | Orb_Cracks by hand (Plouffe) | curve IMM seam or boolean, or Painter (Pavlovich, Bungie) | agent: reproducible seams or Panel Loops; fine lines likely to change: texture | | How much function | "sell the illusion" (Plouffe) | ground every part (Klimer) | first-person hero weapons and families: Klimer; collectibles, armor, concept: Plouffe | | Scale | proportion only, fix later (Pavlovich pistol) | physically correct in the block (Klimer) | rigged game asset: correct scale; concept: proportion; always Unify for tool behavior | | ZRemesher role | a cage for projection (Plouffe 0.3, 100) | final topology (Pavlovich, Chervenka) | output subdivided again: lowest workable cage; game low poly: the others' settings | | Mask refinement | blur then sharpen, one-sided blur (Plouffe) | blur, invert, blur, invert (Klimer) | deliberate soft and crisp sides: Plouffe; uniform fillets: Klimer | | Fitted parts (visor, hatch) | Knife with Split To Parts (Pavlovich 2024) | Slice plus DynaMesh Groups (Pavlovich skull 033) | a freehand outline: Knife (GUI); for the agent, one cutter used twice (Intersect and Subtract Start groups, `fitted_split_groups`) or DynaMesh Groups on a group outline [added] | | Chamfer width | Polish or DynaMesh minimum (Polycount) | Crease Bevel, BevelPro or a boolean chamfer | any width the brief fixes or repeats: Crease Bevel or booleans; free sculpted bevels: Plouffe's Clay Alt or TrimDynamic (Plouffe digest, Bevel making) | | Star pinch cleanup | smart smooth at a low level, border hidden (Plouffe) | StoreMT, smooth or polish, Project Morph (Chervenka) | a few local pinches: smart smooth (GUI); whole-surface cleanup without volume loss: the morph route (Plouffe digest) | | Painter handoff | Substance Bridge Low & High plus Auto-Bake (2026.2) | OBJ per part plus an external bake by mesh name (Pavlovich 2017) | a quick bake from ZBrush with polypaint IDs: Bridge; a DCC low poly, custom cages or an engine-specific tangent basis: OBJ and scenario-zbrush-retopology-export [added] | ## 9. Where generic ZBrush advice goes wrong (baseline Z3, `tests/baseline/answers.md`) - QGrid 1 with Coverage on a helmet shell: QGrid facets curved spans (Pavlovich qeFclVta4No [00:22:59]); use Smooth Subdiv with Crease Tolerance. - "CreaseLvl 2 to 3" without its relation to Smooth Subdiv: the gap, not the value, sets the edge (qeFclVta4No [00:15:27]). - Groups By Normals at 30 degrees: the experts start at 45 and drop to 33 only for missed edges. - Scale Master to 1 unit = 1 cm before modeling: tools behave on Unified size (Chervenka [00:08:56]); set export scale at the end (iWq7dFxf55I). - FBX via FBX ExportImport: its options window blocks a script (lead traps); export OBJ per part. - `Tool:NanoMesh:Convert to real mesh` does not exist: the labels are `m.One To Mesh`, `m.All To Brush` or Convert BPR To Geo [strings]. - Mirroring without the negative-to-positive rule loses the edited side (Pavlovich 8LNjAkqr_lI [00:06:49]). - Nothing about Start groups, DSDiv, coplanar faces, debris or ZRemesher after the boolean: the four things that break the pipeline in practice. - UV Master with polygroups on the game low poly: the experts do hard-surface game UVs in a DCC (Klimer [01:02:54]; Chervenka [00:17:21]). - Checking a stroke start with `pixol_pick` depth: depth (component 1) is broken in the SDK stub. - No visual language: nothing checks straight versus curved, plane highlights or edge width. Generic claims found wrong in the blind Z3 grade (`tests/grading/Z3_grade.md`), useful for any hard-surface plan: - "Make Boolean Mesh at a resolution": no such control. DSDiv carries the Dynamic smoothing; without it the faceted cage is booleaned (Ab0ixptmNeA [00:00:00]-[00:00:33]). Resolution belongs to Remesh All. The result is triangulated only where the operation happened (LB doc Data Preservation). - An Intersect operand keeps only the overlap (x1gN9-SocYY [00:13:49]; LB doc operators): on a shell it deletes everything else. Polygroups at a cut come from the operands. - Creases set before a boolean are removed by it and must be restored (LB doc "Removed: creased edges"); a cage taken "before Dynamic" does not contain cuts made later on the UMesh. - Panel Loops Elevation: positive is raised, negative recessed (HS doc Panel Loops; Plouffe -100). - Boolean analysis lives in Render > Render Booleans (Show Coplanar, Show Issues), not in Tool > SubTool. - A NanoMesh brush is made with Brush > Create > Create NanoMesh Brush and is a ZModeler brush, not an IMM mode (QGNn1-ey6ME [00:09:21]-[00:10:28]). - 4/3 is not wider than 4/2: the closer CreaseLvl sits to SmoothSubdiv, the tighter the edge (qeFclVta4No [00:15:27]-[00:16:29]; Plouffe [02:18:03]-[02:19:11]). - The NanoMesh insert click is not GUI-only: the shipped Create Instance Subtool macro applies a Nano with one scripted click at the center of a front-facing plane (QGNn1-ey6ME Agent translation), [verify] from Python. -
gui-paths.md 13.8 KB
# GUI paths: palettes, hotkeys and brushes for hard surface For a computer-use agent (or a human) doing the steps the bridge cannot do, and for mapping a tutorial to item paths. macOS conventions: the docs write "Ctrl" (treat each modifier as [verify]); Control+W groups, and **Cmd+W quits ZBrush** (2023.0.1): never send it. Evidence: [doc] Maxon docs, [strings] label present in `ZData/ZLang/english/UInterface.zsc` (2026.2.1), [macro] shipped macro, otherwise the expert who shows it. ## Internal labels (what item paths use) Duplicate labels in Tool > Geometry are disambiguated by prefixes [strings]; the NanoMesh prefix is proven by the Create Instance Subtool macro (`Tool:NanoMesh:m.Width`) [macro]: - **Dynamic Subdiv:** `s.Dynamic`, `s.Apply`, `s.QGrid`, `s.Coverage`, `s.Constant`, `s.Bevel`, `s.Chamfer`, `s.FlatSubdiv`, `s.SmoothSubdiv`, `s.Thickness`, `s.Segments`, `s.Post SubDiv`, `s.Offset`, `s.Smoothness`, `s.MicroPoly On`. - **Crease:** `Crease` (bubble help "Crease Border"), `CreaseAll`, `CTolerance`, `CreaseLvl`, `UnCrease`, `UnCreaseAll`, `Crease PG`, `UnCrease PG`, `Crease UM`, `UnCrease UM`. - Crease Bevel: `B.Bevel`, `B.PropWidth`, `B.Resolution`, `B.EdgeSharp`, `B.Bevel Width`. - **EdgeLoop:** - Edgeloop Masked Border [macro]; - `GroupsLoops` with `Loops` and `GPolish`; - Panel Loops: `P.Panel Loops`, `P.Loops`, `P.Double`, `P.Append`, `P.Inner`, `P.Thickness`, `P.Polish`, `P.Ignore Groups`, `P.RegroupPanels`, `P.RegroupLoops`, `P.Bevel`, `P.Elevation`, `P.Bevel Profile`; - Delete Loops: `D.Partial`, `D.Groups`. - **ZRemesher:** `Retry`, `Legacy (2018)` (still in the resources), `FreezeBorder`, `FreezeGroups`, `KeepGroups`, `SmoothGroups`, `KeepCreases`, `DetectEdges`, `AdaptiveSize`, `Use Polypaint`, `ColorDensity`, `KeepPolypaint`, `Target Polygons Count`, Half, Same, Double, `Adapt`, `Curves Strength`. - **Polygroups:** `Auto Groups`, `Groups By Normals` with `MaxAngle`, `GroupVisible` (the macro path is `Tool:Polygroups:Group Visible`), `Group Masked`, `Group Masked Clear Mask`, `Group As Dynamesh Sub`, `From Polypaint`. - **Array Mesh:** `a.Array Mesh`, `a.Transpose`, `a.Lock Pos`, `a.Lock Size`, `a.Append New`, `a.Reset`, `a.Repeat`, `a.Chain`, `a.AlignToPath`, `a.Offset`, `a.Scale`, `a.Rotate`, `a.Pivot` (modes), `a.X Amount`, `a.Y Amount`, `a.Z Amount`, `a.X Mirror`, `a.Make Mesh`, `a.Convert To NanoMesh`. - **NanoMesh:** `m.NanoMesh On`, `m.Index`, `m.Edit Mesh`, `m.EditPlacement`, `m.ShowPlacement`, `m.Prop`, `m.Fit`, `m.Fill`, `m.Clip`, `m.Size`, `m.Width`, `m.Length`, `m.Height` (with `WVar`, `LVar`, `HVar`), `m.XOffset` .. `m.ZOffset`, `m.XRotation` .. `m.ZRotation` (with `ZRVar`), `m.H Tile`, `m.V Tile`, `m.Pattern`, `m.Random Distribution`, `m.Align To Normal`, `m.One To Mesh`, `m.All To Brush`, `m.Replace NanoMesh From Brush`. ## Dynamic Subdivision and creasing - **Dynamic Subdiv:** Tool > Geometry > Dynamic Subdiv. D turns Dynamic on and Shift+D off; the first time, ZBrush asks to confirm (Pavlovich). With classic levels, D and Shift+D step the levels instead [doc]. - A 2026.2.1 note string offers "auto-activate Dynamic Subdiv" when a mesh has zero levels; set `s.Dynamic` by path instead. - Apply converts the preview to classic levels. - **Crease:** Tool > Geometry > Crease. - Crease on a partially hidden mesh creases its open borders; Shift+click Crease creases all visible edges [doc]. - CTolerance plus Crease creases by angle. Crease PG creases group borders. Crease UM / UnCrease UM act on the unmasked part (2024). - Bevel (with Bevel Width) adds a real bevel on creased edges. Holding Ctrl while changing the width follows polygroup borders (Pavlovich [verify]). - **Polygroups:** Tool > Polygroups > Groups By Normals with MaxAngle; Control+W groups the visible or masked part. ## Booleans - **Operators:** icons on each SubTool row in Tool > SubTool (Add, Subtract, Intersect), plus the bent arrow for Start. - **Live Boolean** is the switch left of Edit on the top shelf, also Render > Render Booleans > Live Boolean. The same group holds Show Coplanar (Inside, Solo), Show Issues (Inside, Solo, Previous, Next) and the Coplanar threshold. - **Make Boolean Mesh:** Tool > SubTool > Boolean > Make Boolean Mesh, with DSDiv ("Allow Dynamic Subdiv") beside it. The folder menu has Boolean With DSDiv. - **Preferences > Boolean:** Coplanar Threshold, Clean Slivers, Slivers Max Size. Keep the defaults. - **Order of checks:** Show Coplanar before Make Boolean Mesh (red = coplanar, nudge the operand); after it, select the UMesh result and Show Issues (red outlines = holes, edges on more than 2 polygons); Previous and Next jump between SubTools with issues [strings; LB doc Geometry Analysis]. - **Duplicate:** Ctrl+Shift+D. A duplicate reverts to Union (Pavlovich). ## ZModeler (B, Z, M) Hover an element, press Space, then pick Action, Target, Options and Modifiers. The left-most entry in each row is the default. - **Polygon actions:** - QMesh (default): snaps and stays welded; Shift moves along the normal; a negative drag deletes. - Extrude (no snapping); Inset (Equidistant, Legacy; 2026.0 crease modifiers: Do Not Crease is the default, Crease New Edges creases the inner loop and the outer supporting edges for a crisp inset, Crease Inner Poly only the inner loop for a rounded one [strings; 6KZiNEO65YY 00:01:40-00:02:44]); Bevel (2026.2.1 modifiers All Edges, Outer Edges, Inner Edges [strings]); Bridge (Two Polys, Connected Polys with Align to Normal); Delete; PolyGroup (Shift tap copies a group); Crease (Alt after clicking uncreases); Insert NanoMesh; Selection (2025.2). - Targets: A Single Poly, PolyGroup All, PolyGroup Island, Flat Island, Polyloop. - **Edge actions:** Insert Single or Multiple EdgeLoops; Alt deletes; Interactive Elevation makes fillets; 2026.0 adds Crease or Do Not Crease (default Do Not Crease: a loop added with Dynamic on holds nothing until Crease is picked; the double dotted line confirms it); Multiple EdgeLoops can keep or alternate polygroups while creasing; Single EdgeLoop Snap Off, Quarter, Half or Custom, with Ctrl taps to cycle during the drag (easier to see with Dynamic off). Also Bevel, Bridge, Close (Concave = Close Holes, Convex = a domed cap), Collapse, Slide, Crease (EdgeLoop Complete or Partial), Extrude (Snap To Surface for retopology). - **Point actions:** Do Nothing on the other element types stops mis-clicks. - **Tips:** tap to replay the last action at the same value; Alt paints a temporary group (Polygon Selection in 2025.2 does it without Alt); X symmetry overrides the PolyGroup All target. - **ZModeler brushes and presets:** Brush > Clone, then Save As (into the Asset Directory in 2026.1). Presets go in the Space menu (2025.2; preset saving fixed in 2026.2.1). A saved brush or preset holding the action, target and crease option is what the bridge route (procedures P14) replays with one canvas input. - **Build check:** before 2026.2.1 ZModeler could default to Extrude instead of QMesh when a face was selected; check the polygon action before trusting a QMesh step. ## Clip, Trim, Slice, Knife, Crease curves - **Pick:** hold Ctrl+Shift and choose ClipCurve, TrimCurve, SliceCurve, KnifeCurve, KnifeLasso, KnifeRect, KnifeCircle or CreaseCurve in the Brush palette. From then on Ctrl+Shift calls that brush. `Brush:ClipCurve` is bound in `StartupHotkeys.txt`. - **Draw:** Ctrl+Shift drag, then release the keys and keep the pen down. Alt tapped once bends, twice makes a sharp corner; Space moves the line. The drag direction or an Alt on release flips the Clip side. - **Modifiers:** Ctrl+Shift+Space opens the modifier menu (Brush > Clip Brush Modifiers): Brush Radius (BRadius, a gap equal to Draw Size; hold Alt before release), By Polygroup, Split To Parts (2024), UnClip. - **Rules:** - Clip never changes topology; never clip past the widest part. - Slice and Trim ignore symmetry: Mirror And Weld after. - Knife works with symmetry but cannot cut holes through the middle. - Slice, Trim and Knife refuse subdivision levels. - The "Use Legacy TrimCurve" preference exists in 2026.2.1 [strings; location verify]. - **Visibility slot** (Ctrl+Shift, SelectRect or SelectLasso): - click a group to isolate it; drag on empty canvas to invert; tap to show all; - Ctrl+Shift+A grows to the whole connected part; Ctrl+Shift+X and Ctrl+Shift+S grow and shrink. ## Gizmo and deformers - **Gizmo:** W, E, R move, scale and rotate; Y toggles the TransPose line. - **Pivot:** Alt+click the surface to align the Gizmo to it. Unmasked Mesh Center and Reset Mesh Orientation (Alt) are operators; Sticky mode plus 1 repeats the last offset. - **Deformers:** the gear icon opens Bend Arc, Bend Curve (set Curve Axis first), Taper, Twist, Deformer (FFD, Hard, Soft), Extender, Flatten (Slice Topology), Slice, Multi Slice, Bevel, Crease, Project Primitive, and Remesh by DynaMesh, Union, ZRemesher or Decimation. Drag the cones; commit with W or gear > Accept. Deformers refuse meshes with levels. - **Local symmetry:** L.Sym follows the Gizmo center since 2023; turn its Dynamic toggle off for the classic behavior. ## IMM, NanoMesh, ArrayMesh - **IMM brushes:** B, I, then M for the IMM Viewer (tabs since 2026.1). - DragRect by default; all stroke types since 2024. - Ctrl at the start of a drag snaps to the brush size. Alt stretches; Shift aligns to the nearest world plane; Space slides. - Dots with LazyMouse sets spacing through LazyStep (2 touches, 2.5 rivets). Curve Mode uses Curve Step (1 touches). - Automask Mesh Insert masks all but the new part: split it with Split Unmasked Points. - Z Intensity sets height; Strength Multiplier goes beyond it; Projection Strength conforms the part; Brush > Depth > Imbed sets the seating depth. - **Create:** Brush > Create InsertMesh, Create InsertMultiMesh (all SubTools, folders become tabs), Create NanoMesh Brush. Unify and orient in an orthographic view first, name SubTools, then Brush > Save As into the Asset Directory. - **NanoMesh:** ZModeler polygon action Insert NanoMesh (target PolyGroup All; a new NanoMesh brush defaults to A Single Poly); Tool > NanoMesh sliders (ZRotation with ZRVar for variation; Pattern H Tile, V Tile, Border, Corners; Align To Normal; Show Placement off); Shift during the drag adds a second index; Edit Mesh swaps the source, then Deformation > Unify fixes its size. - **No face where a bolt goes:** drag a plane primitive, split it off, turn its Dynamic off, insert the NanoMesh on it, Show Placement off (Chervenka [00:20:15]). - **Macro > Create Instance Subtool** (shipped) turns the selected SubTool into a NanoMesh instance on a 2 x 2 plane; it ends with an info note that must be closed. - **Mesh From Brush:** Tool > Geometry > Modify Topology > MeshFromBrush replaces the SubTool with the current InsertMesh brush's mesh (bubble help "Create Mesh From Brush") [strings]; Chervenka duplicates a SubTool first, then ArrayMesh (Reset, Lock Position, Lock Size, Repeat, Rotate Z Amount 360), then Convert To NanoMesh for rotation variation [00:21:24]-[00:24:18]. - **Create InsertMesh prompt:** with an insert brush already selected, ZBrush asks APPEND or NEW [strings]; select a plain brush first to avoid it. - **Levels block insertion:** keep a hidden, level-free placeholder PolyMesh3D at the top of the list, scaled inside the model, and insert while it is selected (Pavlovich QGNn1-ey6ME [00:14:07]-[00:15:46]). - **ArrayMesh:** Tool > Array Mesh: turn it on, set Repeat, pick a mode (Offset, Scale, Rotate, Pivot) and its X, Y, Z Amount, or use TransPose mode to drag them. Then Make Mesh or Convert To NanoMesh. ## Sculpted hard-surface brushes (concept route) - **hPolish** (B H P): Preserve Edge on; oversize it; Alt builds up, no Alt digs. **TrimDynamic** (B T D): bevels by breaking edges. - **Planar and PlanarCut:** plane from the first contact. **TrimFront and TrimHole:** cut by camera angle. - **Grooves:** Orb_Cracks makes a V groove (Lazy Mouse on); Dam_Standard with Alt raises a ridge that insists on the edge. The Clay brush with Alt makes a 45-degree chamfer (Plouffe). - **Protection:** Brush > Auto Masking > BackfaceMask for thin parts; Brush > Depth > Depth Mask protects neighbors. - **Move with AccuCurve** straightens edges. - LightBox brushes sit in the Maxon tab (`Lightbox/Brushes`) since 2026.1. ## Masks, layers and mesh integrity - **Mask cleanup (Klimer):** Tool > Masking > BlurMask, Inverse, BlurMask, Inverse, repeated; every corner gets the same fillet and stray bits go [00:46:21]-[00:47:32]. - **Mask by Polypaint:** Tool > Masking > Mask By Color > Mask By Polypaint, a color-pick dialog (GUI); Mask by Hue gives graded masks. Plan pockets and panels in polypaint first, one color per region (Pavlovich FAFtW_8zB5Q). - **Layers:** Tool > Layers > New, then REC; the layer slider at a negative value carves in; Duplicate doubles the strength; Bake All commits. Layers break when geometry is added or removed (Klimer [00:49:08]). - **Mesh Integrity:** Tool > Geometry > MeshIntegrity > Check Mesh Integrity reports through a note ("Mesh integrity test completed successfully" or "failed ... Please 'Fix Mesh'"); Fix Mesh repairs [strings; reference doc]. ## Handoff features (2023 to 2026.2) - **Substance Bridge (2026.2.0):** Texture > Substance Bridge: Send to Painter with All, Visible or Active; Subdivision Level Current or Low & High; Auto-Bake Maps; Smooth Normals; Send PolyPaint (a fill layer); Texture Sets Per Subtool or Per PolyGroup; Force UV Auto-Unwrap is global and strips existing UVs from every SubTool. Each send makes a new Painter project (VD 3.6). - **Native UVs (2023):** Tool > UV Map > Create (Unwrap): Unwrap, Auto Seams, Creased Edges (seams on creases), Symmetry; run at the lowest level (VD 3.5). - **Retopo brush (2026.1):** the first click asks Yes (a new retopo SubTool above the reference) or No (edit the current one); it switches on PolyFrame and transparency; a SubTool with levels cannot become the retopo mesh; Alt+drag extrudes edges, Ctrl during extrusion sets loop counts (VD 3.5). -
procedures.md 32.3 KB
# Procedures: hard surface through the bridge Every procedure uses `scripts/zb_hardsurface.py` (inside-ZBrush functions called with `hs.hs_call`) on top of the lead toolkit. **Status of every snippet: not yet run in ZBrush (2026-09-24).** The pure and agent-side parts pass offline tests (`tests/code/zbrush-hard-surface/test_zb_hardsurface.py`, 46 tests, run `python3 run_offline.py`); the ZBrush-side functions were exercised only against a fake (`fake_hs.py`), which is not ZBrush. The live test that will settle each procedure is named; run them all with `tests/code/zbrush-hard-surface/run_live.sh` when a ZBrush session is yours. Agent-side preamble used below: ```python import sys sys.path.insert(0, "<project>/skills/scenario-zbrush-hard-surface/scripts") import zb_hardsurface as hs # puts <skills>/scenario-zbrush-expert/scripts on sys.path too import zb_launch as zl, zb_review, zb_audit zl.start() # lead: ZBrush with the bridge, ping, Home Page closed ``` ## P0. Timeouts mean a modal note Several hard-surface buttons can raise a note (strings in the 2026.2.1 UI resources, `UInterface.zsc`): - **Make Boolean Mesh with coplanar faces:** "Do you really want to launch Boolean whatever?" - **Make Boolean Mesh with invalid inputs:** "Every input SubTool must be a valid Solid". - **Mirror And Weld with the floor Elv not 0:** "Would you like to proceed with the MirrorAndWeld operation?" - **Import with n-gons:** "Would you like to split these polygons...". - **DynaMesh on levels:** "Would you like to freeze subdivision level before entering DynaMesh mode?" - **Check Mesh Integrity:** reports "Mesh integrity test completed successfully" or "failed ... Please 'Fix Mesh'" [modal is verify]. - **Create InsertMesh while the current brush is already an insert brush:** "Would you like to APPEND the active mesh to this brush or create a NEW brush?" The shipped Create Instance Subtool macro answers it with `IKeyPress '1'`, which Python cannot send. - **The shipped Create Instance Subtool macro itself** ends with an info `[Note]` (macro source), so do not press the macro from the bridge: rebuild its routine (P10). The toolkit prevents each one it can: - `stack_report` and `coplanar_report` run before a boolean; - `mirror_weld` sets `Draw:Elv` to 0 first [verify path]; - `import_part` refuses n-gons; - `zb_ops.dynamesh` refuses meshes with levels; - `hygiene(obj)` measures on an OBJ what Check Mesh would report; - select a non-insert brush (Standard) before `Brush:Create:Create InsertMesh` [added, from the prompt string]. When a call still times out: ```python try: r = hs.hs_call("make_boolean_mesh", timeout=90) except zl.ZBTimeout: print(zl.diagnose()) # do NOT send more code: the queued call is still pending # screencapture -x when the screen is unlocked; answer the note with System Events or a human ``` Test: live_hs_03 (bridge alive after Make Boolean Mesh), live_hs_04 (after Mirror And Weld). ## P1. Path discovery (read-only) `HS_PATHS` lists candidates per control, the flat `Tool:Geometry:<label>` form first (the confirmed pattern: sections of Tool > Geometry add no path level). `HS_INFO` holds the bubble help from `UInterface.zsc`, so `resolve_hs` rejects a duplicate label (Bevel, Thickness, Polish, Loops live in several groups). Labels carry prefixes: `s.` Dynamic Subdiv, `P.` Panel Loops, `a.` Array Mesh, `m.` NanoMesh (the NanoMesh prefix is proven by the shipped Create Instance Subtool macro). ```python for key in ("dyn_smooth", "crease_lvl", "crease_pg", "pl_button", "am_repeat", "make_boolean", "show_issues", "mesh_from_brush"): print(key, hs.hs_run(f"result = zb_hardsurface.resolve_hs({key!r}, required=False)")) ``` When a key does not resolve, record a Python macro while clicking the control (ZScript > Python Scripting > New Macro) or read the Activity log (Asset Directory `Logs/Activity`), then add the canonical path to `HS_PATHS`. Test: live_hs_01_paths (writes the whole table). ## P2. Parts without strokes Primitives come from Tool > Initialize on a copy of a level-free SubTool (Pavlovich replaces a duplicate with Initialize > QCube, TANNfCLxFx4 [00:00:00]; the shipped "Append a QCube Subtool" macro sets X/Y/Z Res, presses QCube, then Unify). Build in list order: the body first, each new part lands directly below the active one. ```python zl.call("zb_ops", "new_sphere", 64) # or a Unified body zl.call("zb_ops", "save_ztl", "/abs/out/helmet.ztl") # checkpoint, never overwrites cut = hs.hs_call("new_part", "cube", res=[2, 2, 2], op="sub") # Duplicate + Initialize + Unify hs.hs_call("place", position=[0.8, 0.0, 0.0], xyz_size=0.7) # bbox center, tool units [verify] hs.hs_call("place", size=[0.15, 0.4, 0.4]) # per-axis Size [verify] hs.hs_call("place", rotate=[0, 0, 15]) # Deformation Rotate, z bit [verify] ``` - `new_part(..., method="append")` uses Append + `PopUp:PolyMesh3D` like the macro; whether that popup press returns in Python is [verify], so run it with a short timeout. - A part modeled in Maya or Blender: export OBJ with quads and triangles only (no n-gons), then `hs.hs_call("import_part", "/abs/parts/visor_block.obj", position=[0, 0.3, 0.9], xyz_size=0.9)`. - A brief in millimeters: `hs.to_tool_units(real_mm, real_extent_mm, tool_extent)` converts before any size or width. - A rigged or animated asset: export the block OBJ (500 to 40k polys) for an engine check with the rig before detailing (Klimer 08crkU999Fs [00:16:59]-[00:17:32]). Gate: the new index is the old index + 1; the body's face count is unchanged; `solid` is True for a QCube. Test: live_hs_02_parts_status. ## P3. Live Boolean stack Rules: body on top as Add or Start, cutters below, one Start per separately exported part, cutters crossing the surface (never flush), low Dynamic levels, everything visible and watertight (LB doc; Pavlovich HXnKnrhlFpA, Ab0ixptmNeA). ```python hs.hs_call("set_roles", [{"index": 0, "op": "add", "start": True}, {"index": 1, "op": "sub"}, {"index": 2, "op": "add", "start": True}]) # a visor that stays separate rep = hs.hs_call("stack_report") # rows, Start groups, problems, warnings assert not rep["problems"], rep["problems"] for i, name in ((0, "body"), (1, "cut1")): # OBJ per operand, active SubTool restored hs.hs_run(f"a = zbc.get_active_subtool_index()\nzbc.select_subtool({i})\n" f"result = zb_ops.export_obj('/abs/out/{name}.obj', overwrite=True)\n" "zbc.select_subtool(a)") print(hs.coplanar_report("/abs/out/body.obj", "/abs/out/cut1.obj")) # must be ok hs.hs_call("live_boolean", True, show_coplanar=True) # native check: no red zl.call("zb_review", "snapshot", "/abs/out/show_coplanar.png") # look at it hs.hs_call("live_boolean", True, show_coplanar=False) mb = hs.hs_call("make_boolean_mesh", dsdiv=True, timeout=90) assert mb["ok"], mb # UMesh_ tool selected, SubTools == Start groups hs.hs_call("live_boolean", True, show_issues=True) # red outlines = holes, zl.call("zb_review", "snapshot", "/abs/out/show_issues.png") # edges on 3+ polygons hs.hs_call("live_boolean", True, show_issues=False) obj = zl.call("zb_ops", "export_obj", "/abs/out/umesh.obj", overwrite=True)["path"] assert hs.hygiene(obj)["ok"] # debris or non-manifold edges make ZRemesher fail hs.hs_call("zremesh_recipe", "after_boolean", timeout=300) # Detect Edges + Half hs.hs_call("crease_by_groups", 45) # booleans drop creases; see P4 before recreasing ``` - `set_status` reads each write back. The SDK docstring calls `set_subtool_status` a toggle, while Maxon's example and the shipped macro write absolute values. The function tries absolute, falls back to toggling, and reports which one worked. The status bits: 0x10 add, 0x20 sub, 0x40 clip (Intersect in the UI, `op="intersect"`), 0x80 start [doc]. - `roles_from_groups([[(0, "add"), (1, "sub")], [(2, "add")]])` builds the same roles from a group list and refuses an order ZBrush would group differently (processing is top to bottom, every Start opens a group). - The pre-flight also flags partial hiding: the visible bbox differs from the full bbox [added]. Test: live_hs_02 (roles), live_hs_03 (boolean, both analysis snapshots, hygiene). ## P4. Crease plan and Dynamic preview ```python hs.hs_call("crease_by_groups", 45) # Group Visible, Groups By Normals, UnCreaseAll, Crease PG d = hs.hs_call("dynamic_subdiv", intent="working") # Smooth 3, CreaseLvl 2, Dynamic on, read back print(hs.check_crease(d["state"]["smooth"], d["state"]["crease_lvl"], "working")) hs.hs_call("crease_by_angle", 22) # a curved span instead (Pavlovich's arch) sheets = hs.review_sheets("/abs/out/stage4", polyframe=True) # forms, planes (metal), PolyFrame ``` - Open `sheets["planes"]["sheet"]` and judge it with `critique.md` A1 to A4. - A missed edge: `crease_by_groups(33)`. - Edge width: `hs.edge_width(smooth, crease_lvl)` names it (gap 0 razor, 1 tight, 2 medium, 3 soft). Razor edges: `intent="bake"` (4/2); a narrower but not razor edge: `intent="tight"` (4/3); a softer one: `intent="soft"` (4/1). Render the candidates under metal and keep the widest edge that still looks planned (Plouffe). v1 called 4/3 "wide": that was inverted, and `crease_plan("wide")` now refuses with the explanation. - `crease_by_angle(22)` held one arch for Pavlovich; too low a tolerance creases the small angles along an arc (qeFclVta4No [00:23:30]). Tune per span on the PolyFrame sheet: raise it where dotted crease lines appear along a curve. - **Recreasing pitfall:** Crease PG after later edits (a boolean, new groups) recreases every group border, including borders you uncreased on purpose (qeFclVta4No [00:16:32]). Keep a list of softened borders; when it is not empty, merge those groups first or use `crease_by_angle` with the tuned tolerance instead of `crease_by_groups`. - Scalloped caps: a support loop (`groups_loops(1)`) or regroup. Crease first, a loop only where the falloff must be local (qeFclVta4No [00:08:18]). - Single-sided parts (a visor shell): Dynamic Thickness (`dynamic_subdiv(thickness=...)`) gives visual thickness that toggles with Dynamic (qeFclVta4No [00:02:36]). Test: live_hs_04_crease_dynamic. ## P5. High-poly commit (bake split) and exact chamfers ```python b = hs.hs_call("bake_split", 4, 2, timeout=240) # copy: Apply at 4/2; original: Dynamic off assert b["high"]["ok"], b # levels == 1 + Flat + Smooth ``` - The copy is expected at index + 1 [verify]. - Apply only when the shape is final (Pavlovich qeFclVta4No [00:25:33]). - The 2026.2.1 build fixed an Apply crash. - **Exact chamfer widths.** CreaseLvl sets a relative falloff and Polish or DynaMesh only the minimum edge width (Polycount author). When the brief fixes a width, use one of: - Crease Bevel on the creased edges: `hs.hs_call("crease_bevel", width=hs.to_tool_units(2.0, 300.0, 2.0))` (Crease > Bevel with Bevel Width [doc reference]; Chervenka's Bevel Width fix at poles [00:31:38]) [verify units of the slider]; - a boolean chamfer: a rotated subtractive box along the edge (Polycount: "Any chamfers that need to be specifically larger I'll do with boolean subtractions"); - BevelPro (Zplugin; bevels along polygroup borders, Bevel or Chamfer output, Auto Apply off returns boolean parts) runs in its own app window, so it is a GUI step [verify scriptability]; it needs Metal on macOS. Test: live_hs_04. ## P6. Mirror Mirror And Weld always copies the negative half onto the positive half. `mirror_weld` has no default side: say which half holds the edit. ```python before = zl.call("zb_ops", "export_obj", "/abs/out/before_mirror.obj", overwrite=True)["path"] print(hs.mirror_report(before)) # unmatched vertices per half; suggest is a hint only hs.hs_call("mirror_weld", "x", keep="pos") # Deformation Mirror first, then Mirror And Weld (x bit 1) after = zl.call("zb_ops", "export_obj", "/abs/out/after_mirror.obj", overwrite=True)["path"] assert hs.mirror_check(before, after, keep="pos")["ok"] # the kept half survived, result symmetric ``` - When is the mirror needed: after any Slice, Trim or BRadius cut (they ignore symmetry, FOdVdgiAHWo [00:06:20]; 8LNjAkqr_lI [00:05:31]), after a one-sided cutter or ZModeler edit, after Auto Groups (different groups per side, FAFtW_8zB5Q [00:04:57]), and after Groups By Normals so groups match (8LNjAkqr_lI [00:09:58]). - Which side: you know where you cut (the `place` position of the cutter, the side of the click). `mirror_report` suggests a side only when one half holds all or most unmatched vertices; a removed feature leaves unmatched vertices on both halves, so it returns None and the edit log decides. - The call refuses a mesh with levels. With levels: `zb_ops.del_levels()`, mirror, then `Tool:Geometry:Reconstruct Subdiv` until the level count stops growing. This works only when topology did not change and no layer must survive (Plouffe [02:00:53]-[02:03:39]). Gate: `mirror_weld` `ok` (bbox symmetric) and `mirror_check` `ok`. Test: live_hs_04. ## P7. Panels and fitted parts without strokes ```python hs.hs_call("crease_by_groups", 45) # the panels are the polygroups p = hs.hs_call("panel_loops", thickness_rel=0.01) # Plouffe recipe; thickness_rel [added] print(hs.hs_call("split_copy", "groups")) # panel count on a copy hs.hs_call("groups_loops", 1) # support loops around every group ``` - **Plan first** (Pavlovich FAFtW_8zB5Q [00:00:00]; Klimer): panels, pockets and plates as polygroups or polypaint regions before any cut, so nothing is committed. Polypaint regions per color: `set_color` plus Color > FillObject on a hidden or masked region [verify]; Mask By Polypaint is a dialog (GUI). Prefer polygroups for the agent: Group Masked, Groups By Normals, or the groups a boolean propagates. - **Clean a mask before it becomes geometry:** `hs.hs_call("mask_fillet", 2)` (Klimer's blur, invert, blur, invert, repeated: every corner gets the same fillet, stray bits go). Then Edgeloop Masked Border to cut a group line along it: `hs.hs_run("zbc.press(zb_hardsurface.resolve_hs('edgeloop_masked'))")`. - **Fitted parts (a visor, a hatch, an embedded panel).** Pavlovich slices and runs DynaMesh with Groups on so the pieces fit "perfectly" (Yprguxci8NY [00:01:05]-[00:02:43]); Knife with Split To Parts leaves no gap (8LNjAkqr_lI [00:01:34]). Two scriptable equivalents [added]: - one cutter used twice: duplicate the shell and the cutter, order the SubTools shell copy, cutter, shell, cutter copy, then ```python r = hs.fitted_split_groups(body_a=1, cutter_a=2, body_b=3, cutter_b=4) # indices as placed hs.hs_call("set_roles", r["roles"]) # group 1: shell INTERSECT cutter = the visor piece mb = hs.hs_call("make_boolean_mesh", dsdiv=True, timeout=90) # group 2: shell - cutter ``` Both borders come from identical cutters, so the fit has no gap; scale `cutter_b` up by the wanted gap for a constant one; - a polygroup outline (mask, `mask_fillet`, Group Masked) then DynaMesh with Groups on (`zb_ops.dynamesh(res, groups=True)`): the group border becomes a physical separation; `split_copy("groups")` or Groups Split puts the piece on its own SubTool, then scale it in slightly and push it for the embedded read. - Tight-fitting splits along an arbitrary freehand line still need Knife with Split To Parts (GUI, `gui-paths.md`), or a ZScript `IKeyPress` wrapper [verify] (scenario-zbrush-automation). Test: live_hs_05_panels_curves. ## P8. Vents and slots with ArrayMesh ```python plan = hs.array_plan(count=5, cutter_width=0.15, span=1.35) # pitch 0.3, wall 0.15: ok hs.hs_call("new_part", "cube", res=[2, 2, 2], op="sub") hs.hs_call("place", position=[-0.6, 0.2, 0.95], size=[0.15, 0.45, 0.4]) hs.hs_call("dynamic_subdiv", intent="cutter") # Smooth 3 / Crease 2 on the cutter hs.hs_call("crease_by_angle", 45) # sharpen the cutter hs.hs_call("array_mesh", plan["count"], offset=[plan["pitch"], 0, 0]) ``` - Whether the ArrayMesh Offset is per copy or the total span is [verify]; live_hs_03 measures the bbox growth. If it is the total span, pass `offset=[plan["total"] - 0.15, 0, 0]`. - Keep the array live while designing. Use `array_commit("mesh")` before Make Boolean Mesh only if the boolean ignores the array; the Live Boolean doc lists ArrayMesh as a supported, converted input. Test: live_hs_03. ## P9. Seam cutter from curves [verify] A thin subtractive seam along a polygroup border, built from script curves instead of a curve-IMM stroke (Pavlovich's FM3uQJy1GUY route): ```python obj = zl.call("zb_ops", "export_obj", "/abs/out/shell.obj", overwrite=True)["path"] # active = shell lines = hs.border_polylines(obj) # OBJ coordinates = tool space [verify] seam = hs.hs_call("curve_mesh", lines[0], "seam01", 0.01) hs.hs_call("set_status", seam["subtools_after"] - 1, op="sub") ``` - `create_mesh_from_curves(name, 1, thickness)` adds a new SubTool (SDK stub). What it builds is [verify]: a tube or a strip. - Maxon's lightning example notes that a curve brush only applies when a curve is touched, so the brush route (CurveTube, a Tri Parts IMM) still needs a click. Test: live_hs_05. ## P10. Hardware and kitbash Keep one fastener family for the whole asset (Klimer [01:00:37]). Instanced hardware (NanoMesh, ArrayMesh) makes a late change one swap; seats placed one by one with `import_part` need one call per seat. **A part from an IMM without a drag** (Chervenka PGX39tnEf3Y [00:21:24]: Mesh From Brush, then ArrayMesh, then Convert To NanoMesh): ```python f = hs.hs_call("fastener_from_brush", "IMM Industrial Parts") # duplicate + MeshFromBrush + Unify hs.hs_call("place", position=[0.0, 0.0, 1.2], xyz_size=0.08) hs.hs_call("array_mesh", 6, rotate=[0, 0, 360]) # radial set [verify semantics] hs.hs_call("array_commit", "nano") # Convert To NanoMesh hs.hs_call("nanomesh_set", ZRVar=10) # rotation variation (Chervenka) ``` Which mesh of a multi-mesh IMM MeshFromBrush takes is [verify] (live_hs_07). **NanoMesh insert by one click** [verify], two routes: - on an existing polygroup (Pavlovich QGNn1-ey6ME [00:10:36]-[00:11:40]: Bolt Head, Insert NanoMesh / PolyGroup All, one per face): a NanoMesh brush (Brush > Create > Create NanoMesh Brush from an IMM; its default face action is Insert NanoMesh / A Single Poly, so switch the target to PolyGroup All and save the brush once in the GUI), then ```python assert hs.hs_call("imm_ready")["ok"] # levels block insertion obj = zl.call("zb_ops", "export_obj", "/abs/out/rim.obj", overwrite=True)["path"] cam = hs.hs_run("b = zbc.query_mesh3d(2, 3)\nresult = {'t': zbc.get_transform(), " "'p': [(b[0]+b[3])/2, (b[1]+b[4])/2, (b[2]+b[5])/2]}") tgt = hs.click_target(obj, cam["t"], group="Group12", pivot=cam["p"]) # a visible rim face hs.hs_call("canvas_action", tgt["x"], tgt["y"], method="click") hs.hs_run("result = zbc.is_enabled(zb_hardsurface.resolve_hs('nm_edit_mesh'))") # evidence ``` Pattern sliders (H and V Tile 3, Border, Corners) and ZRotation 0 with Align To Normal follow as `nanomesh_set` values [verify labels]. - where no face exists (Chervenka [00:20:15]-[00:21:24]): a helper placement plane. The shipped Create Instance Subtool macro builds one: `Tool:Plane3D`, `Tool:Initialize:HDivide` and `VDivide` 2, Make PolyMesh3D, the view turned front, then `[IClick,1004, width/2, height/2]` at the canvas center. From Python rebuild that routine with `set_transform` and `canvas_action(w/2, h/2)`, not by pressing the macro (it ends on a note, P0). **The late swap** (Pavlovich QGNn1-ey6ME [00:12:26]-[00:13:32], [00:17:56]): `nanomesh_set(**{"Edit Mesh": True})` opens the source; replace its mesh (the new part's brush selected, then `hs.hs_run("zbc.press(zb_hardsurface.resolve_hs('mesh_from_brush'))")`, [verify] inside Edit Mesh; Pavlovich swaps it with the Gizmo IMM swap); Unify the source (swapped parts arrive tiny); `nanomesh_set(**{"Edit Mesh": False})`: every instance follows. **Recessed port across a seam** (Pavlovich cw449pmS64g [00:01:06]-[00:04:34]: one sphere sliced, one half merged into part A, the other subtracted from part B, so a boss and a socket line up across the seam). Live Boolean form [added]: 1. Parts A and B on their own SubTools, in that order. 2. A QSphere placed straddling the seam (`new_part("sphere")`, `place`), Dynamic Smooth on (DSDiv carries it, as Pavlovich's Crease PG plus two Divides keep his halves from faceting). 3. SubTool order: part A, ball, a trim box on B's side just off the seam plane (not coplanar with A's seam face: check with `coplanar_report`), part B, a duplicate of the ball. 4. Roles and commit: ```python r = hs.seam_port_groups(part_a=0, ball_a=1, trim_a=2, part_b=3, ball_b=4) hs.hs_call("set_roles", r["roles"]) # A + ball - trim | B - ball mb = hs.hs_call("make_boolean_mesh", dsdiv=True, timeout=90) ``` 5. A screw hole in the socket: a small subtractive cylinder (Pavlovich stamps Alpha 06 with DragDot; the cylinder is the agent substitute). Mirror the finished side (P6). **Exact runs** of bolts or rivets: `hs.spaced_points(polyline, count=8)` (or `pitch=`) gives seats and tangents along a border (`border_polylines`) for `import_part`, helper planes, or a check of an ArrayMesh run. **IMM runs** [verify]. Since 2024 every stroke type works with IMM brushes (wnONEaRVhYU): - Dots stroke with LazyMouse on and LazyStep as spacing (1 overlaps half, 2 touching by his guess, 2.5 his rivets, 0 breaks the stroke): select the IMM brush, set the stroke, then one synthesized line with `zb_ops.sculpt_stroke(zb_stroke.line(p0, p1))`; - Curve Mode with Curve Step (1 = parts just touch, IMM doc Curve Strokes) on script curves (`new_curves`, `add_curve_point`, `curves_to_ui`), then a tap on the curve to commit (lightning example); - insertion is refused on a SubTool with levels: `imm_ready()`, and keep Pavlovich's hidden, level-free placeholder PolyMesh3D at the top of the list (QGNn1-ey6ME [00:14:07]-[00:15:46]); Split Unmasked Points moves the insert to its own SubTool. Test: live_hs_07_kitbash_clicks (MeshFromBrush, the radial NanoMesh set, imm_ready). ## P11. Low poly and the Painter handoff | Route | Calls | When | | --------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | | Cage | the Dynamic-off original after `bake_split` | a ZBrush-built base with clean groups | | ZRemesher per part | `zremesh_recipe("part", target_k=...)` after `crease_by_groups` | boolean or knife results | | Quick shell | `dynamesh_visible(res, skip_indices=[animated])`, then per SubTool `zb_ops.decimate(hs.decimate_percent(target, total))` | a static prop or a deadline (Pavlovich calls it hasty) | | DCC | export OBJ per part; scenario-maya-modeling or scenario-blender-retopology | LODs, animated parts, clean shading | | Retopo brush (2026.1) | GUI: first click asks Yes (new retopo SubTool above) or No; refuses a SubTool with levels (VD 3.5) | a hand-built low inside ZBrush, by a computer-use agent | ```python dm = hs.hs_call("dynamesh_visible", 1000, skip_indices=[2], timeout=600) pct = hs.decimate_percent(25000, dm["total_faces"]) ``` - Decimation Master is a plugin press, so run it last. The lead's live_09 covers whether control returns. - Gate: `zb_audit.verdict(zb_audit.audit(obj), "game", budget_tris=...)`. **Painter routes** (VD 3.5, 3.6; run by scenario-zbrush-retopology-export): - **Substance Bridge** (Texture > Substance Bridge, 2026.2.0): Send to Painter with Low & High (each SubTool's lowest and highest level, so the `bake_split` copy carries both), Auto-Bake Maps, Smooth Normals, Send PolyPaint (a fill layer: the ID colors from FillObject per part), Texture Sets per SubTool or per PolyGroup. Force UV Auto-Unwrap is global and strips existing UVs from every SubTool; each send makes a new Painter project. Before sending: ```python rows = [{"name": "shell", "levels": 5, "has_uvs": True, "polypaint": True}] print(hs.bridge_preflight(rows, low_high=True, force_unwrap=False, send_polypaint=True)) ``` - **Native Unwrap with crease seams** (Tool > UV Map > Create (Unwrap), 2023): scenario-zbrush-retopology-export's `unwrap_creases` runs UnCrease All, Crease PG, Creased Edges on, Unwrap at the lowest level. On a hard-surface cage the polygroups are the crease plan, so the seams follow the hard edges [added]; run it on the cage copy, since it rewrites the creases. - **OBJ plus an external bake**: OBJ per part with matching high and low names (PBAKE [00:00:00]); the polypaint ID travels as an MME Texture From Polypaint map, because OBJ vertex color is not established (PBAKE Agent translation assumes FBX [verify], and FBX raises a dialog). Test: live_hs_06_lowpoly. ## P12. Numbers for the critique ```python el = hs.edge_language("/abs/out/cage.obj") # straight / arc / compound / ambiguous pf = hs.plane_flatness("/abs/out/cage.obj") # per polygroup: meant flat, wobbly pr = hs.pinch_report("/abs/out/cage.obj", max_stars=3) # stars inside groups (Plouffe) nv = hs.hs_run(""" w, h = zbc.get('Document:Width'), zbc.get('Document:Height') result = zb_hardsurface.normal_variance(w/2-60, h/2-60, w/2+60, h/2+60, 8) """) ``` - Run `edge_language` on the cage or the UMesh, not on a soft-beveled high; raise `noise_rel` on DynaMesh input. - `pinch_report` lists every star vertex inside a group; each one is a candidate dimple under metal after the bake (Plouffe [02:25:59]-[02:28:48]). Look at them at a low level with dark polypaint and a low-metal MatCap; fix on the cage (regroup, re-ZRemesher, project), and a few local ones with Plouffe's smart smooth (GUI) or Chervenka's StoreMT, Polish, Project Morph. - `normal_variance` needs an orthographic view square to one plane, with the rectangle inside that plane [verify pixol normals]. Offline tests cover all four on synthetic meshes. Live: live_hs_05. ## P13. Stroke fallback (concept only, weak) The sculpted route (hPolish planes, TrimDynamic bevels, Orb_Cracks grooves) is a set of synthesized strokes: ```python zl.call("zb_ops", "set_symmetry", False) zl.call("zb_ops", "sculpt_stroke", [[420, 300], [700, 300], [700, 330], [420, 330]], brush="hPolish", size=120, z_intensity=20) # large radius: a small one records strokes ``` - Not yet run in ZBrush. Brush selection is covered by the lead's live_05, and there is no dedicated test. - There is no Alt, so hPolish always digs. Zsub in place of Alt is [verify]. - Judge each pass on the metal sheet. Prefer the clean method (P4, P7). ## P14. ZModeler by click, with the 2026 crease options [verify] ZModeler options live in the Space pop-up (no item path). The agent route is a brush that already holds the action and option, then one canvas input on a picked element. 1. Once, in the GUI (a computer-use agent or a human): set the action, target and crease option, then Brush > Save As (Asset Directory) or save a ZModeler preset (2025.2 My Presets). The option comes from `zmodeler_plan`: ```python print(hs.zmodeler_plan("inset", "crisp")) # Crease New Edges; +4 faces per quad print(hs.zmodeler_plan("inset", "rounded")) # Crease Inner Poly: a rounded inset print(hs.zmodeler_plan("insert_edgeloop", "hold", ring_quads=16)) # Crease; +16 faces print(hs.zmodeler_plan("bevel", "outer")) # 2026.2.1: All, Outer or Inner Edges ``` Defaults are Do Not Crease: a loop added with Dynamic on holds nothing until Crease is picked (Maxon 6KZiNEO65YY [00:01:06]). The crease at creation replaces the alternate-polygroup plus Crease PG pass of older tutorials. 2. Each use: `zb_ops.select_brush("<saved brush>")`, export the SubTool, `click_target(obj, transform, group=..., face=... or edge=...)`, then `canvas_action(x, y, method="drag", expect_delta=plan["face_delta"])`. The first use needs a drag (inset amount, QMesh height); a later tap replays the last action at the same value. 3. Gate: the face delta matches the plan; PolyFrame shows the dotted crease lines. If the click does not register (the README found `canvas_click` drags in Edit mode did not sculpt), fall back to palette equivalents: GroupsLoops then Crease PG for creased support loops; Panel Loops for insets; Crease Bevel for bevels; booleans for cut-ins; or a DCC block through `import_part`. Test: live_hs_07 (a synthesized drag on a QCube face with the default QMesh action). ## P15. Micro detail on layers (after sign-off) Klimer (08crkU999Fs [00:31:47]-[00:34:30], [00:49:08]): one layer per region and pattern (grain, filigree, hex, mold lines); a negative layer strength carves in instead of out; duplicating a layer doubles its strength; layers do not survive added or removed geometry. ```python rec = zl.call("zb_ops", "stats") # at layer creation hs.hs_run("result = zb_ops.press(['Tool:Layers:New'])") # [macro] path; zb_character.layer_new does the same # ... detail on the layer (alpha stamps, masks plus Inflate) ... assert hs.layer_safe(rec, zl.call("zb_ops", "stats"))["ok"] # topology unchanged since ``` - Make layers after the last boolean, remesh, Panel Loops or Apply [added]; before any later topology change bake them (`Tool:Layers:Bake All`) or plan to rebuild them. - Clean group borders with Polish By Groups rather than new border loops, so the layer stays valid (Klimer uses the SmoothGroups brush for the same reason, [00:48:35]-[00:49:42]). - Fine lines likely to change can go to the texture instead (Pavlovich FAFtW_8zB5Q [00:00:32]). ## P16. Worked brief: sci-fi helmet for a game character (scenario Z3) 1. **Setup and function sheet.** - Visor: hinged, a separate part. Vents: two cheek intakes. Shell: two halves with one hex-screw family. Camera: third person. - The interior is unseen: fill it or keep it simple and let dark AO sell it (Pavlovich max2JumNDp4 [00:01:06]). - Append or import the head proxy, Unify, then `save_ztl`. If the character is rigged, the block goes to the engine with the rig before detailing. 2. **Shell.** `new_part("sphere")`, `place(size=[...])` over the head with clearance; face and neck openings as subtractive cubes through the shell; `make_boolean_mesh()`, Show Issues, `hygiene`, `zremesh_recipe("after_boolean")`. 3. **Crease plan.** `crease_by_groups(45)`, then `crease_by_angle` tuned per span on the crown; `dynamic_subdiv(intent="working")`, then the metal sheet. 4. **Visor.** The fitted split (P7): the shell duplicate intersected with a visor-shaped cutter is the visor, the shell minus the same cutter is the opening; the visor keeps its own Start group. Or a Maya or Blender block through `import_part`. 5. **Vents.** A creased rounded cutter with `array_mesh(5, offset=...)` on one side, placed by `array_plan`, then `mirror_weld(keep=...)` and `mirror_check` (P6), or symmetric cutter positions on both sides. 6. **Panels.** Groups plus `panel_loops` on the shell copy for real separations; seams from `border_polylines` plus `curve_mesh` [verify]. 7. **Bolts and ports.** Recessed ports across the halves' seam with `seam_port_groups` (P10); the hex screws as one NanoMesh or ArrayMesh family seated in them, or `import_part` per seat. 8. **Commit.** `bake_split()` per part; `edge_language`, `plane_flatness` and `pinch_report` on the cages; a decimated review copy under 1M points for the engine (Klimer). 9. **Micro detail** only after sign-off, on layers (P15). 10. **Handoff.** OBJ per part (matching high and low names), the last sheets and `stack_report`; Substance Bridge after `bridge_preflight`, or UVs and bakes by scenario-zbrush-retopology-export; a hand-built low by scenario-maya-modeling or scenario-blender-retopology. Deterministic: every step above except Knife lines, the one-time ZModeler brush setup, and the clicks of P10 and P14, which are [verify] until live_hs_07 runs. -
sources.md 12.2 KB
# Sources of scenario-zbrush-hard-surface One line per source: who, credential, where, what it is best for, best timestamps. ## Revisions - **v1 2026-09-24:** first build (copy in `skills/_versions/zbrush-hard-surface v1 2026-09-24/`, with its tests under `_tests-code/`). - **v2 2026-09-24 (refactor after the blind grade `tests/grading/Z3_grade.md`):** fixed the inverted crease label (4/3 is the tight pair, `intent="wide"` refused); the NanoMesh insert and ZModeler actions moved from "not scriptable" to click routes marked [verify] (`click_target`, `canvas_action`); `mirror_weld` now requires the kept side, with `mirror_report` and `mirror_check`; added the 2026 ZModeler crease options and Bevel modes (`zmodeler_plan`), the 2023 to 2026.2 handoff routes (Substance Bridge with `bridge_preflight`, Unwrap with crease seams, Retopo brush), micro detail on layers (`layer_safe`), the seam port and fitted-split boolean groups (`seam_port_groups`, `fitted_split_groups`, `roles_from_groups`), NanoMesh fastener routes (`fastener_from_brush`, Rotate 360 then Convert, ZRVar, helper plane, Edit Mesh swap), IMM mechanics (`imm_ready`, LazyStep, Curve Step, `spaced_points`), mesh hygiene (`hygiene`), star pinches (`pinch_report`), native Show Coplanar and Show Issues, hidden-area and first-engine-check rules, exact-width chamfers (`to_tool_units`, Crease Bevel), panel planning and `mask_fillet`. Offline tests 32 to 46, all passing; live test live_hs_07 added, not yet run. Notes are in `notes/hard-surface/` and `notes/kitbash/` of the project; digests: `_digest_hard_surface_tools.md` (with its addendum from the Whisper re-transcriptions), `_digest_hard_surface_projects.md`, `_digest_hard_surface_masters_plouffe.md`. Version facts: `sources/zbrush-version-deltas.md`. ## Evidence from this install (2026-09-24) - ZBrush 2026.2.1 UI string resources, `/Applications/Maxon ZBrush 2026/ZData/ZLang/english/UInterface.zsc` (read with `strings`). **Best for:** the exact labels, bubble help and prefixes: - labels and prefixes: `s.`, `P.`, `a.`, `m.`, `B.`; `Groups By Normals` with `MaxAngle`; `DetectEdges`, `SmoothGroups`; `Crease UM`; `Unweld Groups Border`; `Check Mesh Integrity`; - the blocking notes: coplanar Boolean, invalid Boolean inputs, Mirror And Weld floor Elv, n-gon import, DynaMesh on levels, auto-activate Dynamic Subdiv; - `Render:Render Booleans` as a path string; Show Issues with its bubble help ("Show issues that preventing Boolean computation like holes and edges sharing more than 2 polygons"). - v2 additions: the ZModeler modifiers Do Not Crease, Crease New Edges, Crease Inner Poly and All Edges, Outer Edges, Inner Edges; `MeshFromBrush` ("Create Mesh From Brush"); `LazyStep`, `CurveStep`, `Curve Mode`; the Check Mesh Integrity result notes; the Create InsertMesh prompt "Would you like to APPEND the active mesh to this brush or create a NEW brush?". - Shipped macros, `/Applications/Maxon ZBrush 2026/ZData/Macros/`. **Best for:** known-good 2026 paths. - Append a QCube Subtool: Append, `PopUp:PolyMesh3D`, Initialize X/Y/Z Res, QCube, Unify. - Create Instance Subtool: `Brush:Create:Create InsertMesh` with `IKeyPress '1'`, `Tool:NanoMesh:m.*`, the canvas click on a front-facing plane (`[IClick,1004, width/2, height/2]`), absolute `SubToolSetStatus`, and a final info `[Note]` (source text in `sources/docs/automation__zscript-shipped-macros-2026.md`). - Append Eyes: Mirror And Weld modifier values 1/2/4; X Position and XYZ Size. - Snap To Ground: Y Size and Y Position semantics. - GrpUnMasked: `Tool:Polygroups:Group Visible`. - Shipped MatCaps, `ZData/Materials/MatCap/`: `MatCap Metal01`, `MatCap Metal02`, `Green Metallic`, `MatCap Gray`. **Best for:** the metal plane test. - SDK stub and examples, `/Applications/Maxon ZBrush 2026/Documentation/python-api/` (Maxon SDK team, 2025-10). **Best for:** - `get_subtool_status` and `set_subtool_status` bits, where the docstring says "toggle logic" and the example writes absolute values; - the curves API (`new_curves`, `add_new_curve`, `add_curve_point`, `curves_to_ui`, `create_mesh_from_curves`); - `get_info` (bubble help), `get_tool_count`, `get_tool_path`, `select_tool`, `pixol_pick` normals, `query_mesh3d` bbox modes; - `ex_mod_curve_lightning.py`: curves are in tool space, and a curve brush applies only after a curve is touched. - The v03 OBJ export (`tests/code/zbrush-expert/fixtures/v03_sphere.obj`). **Best for:** ZBrush writing polygroups as `g Group<id>` lines. - The lead skill scenario-zbrush-expert: bridge, `zb_ops`, `zb_review`, `zb_audit`, `zb_stroke`, the 2026 traps and the SDK cookbook. ## Maxon documentation (help.maxon.net, retrieved 2026-09-24) - Live Boolean, Boolean remesh, Remesh SubTools (14 pages; models by Joseph Drust), https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/modeling-basics/creating-meshes/live-boolean/live-boolean.html. **Best for:** the four input rules, processing order and Start groups, what survives, Show Coplanar and Show Issues. - Dynamic Subdivision (9 pages), https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/modeling-basics/dynamic-subdivision/dynamic-subdivision.html. **Best for:** the order QGrid, Flat, Smooth; x4 per step; Apply levels; triangles under Smooth; MicroPoly. - Hard Surface tools (19 pages; Christopher Brändström, Daniel Bystedt, Paul Gaboury, Joseph Drust), https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/hard-surface/hard-surface.html. **Best for:** Clip, Trim, Slice, Knife and Crease brush behavior; Panel Loops; BevelPro; Polish by Features; ZRemesher on hard surfaces. - ZModeler (20 pages; Joseph Drust, Daisuke Narukawa), https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/modeling-basics/creating-meshes/zmodeler/zmodeler.html. **Best for:** action, target and modifier names; restrictions; 2025.2 to 2026 additions. - Gizmo 3D deformers and operators (7 pages), https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/modeling-basics/gizmo-3d/deformers/list-of-deformers/list-of-deformers.html. **Best for:** which deformers act without levels; Slice Topology; Remesh by Union. - Tool palette reference: Geometry, Deformation, SubTool, https://help.maxon.net/zbr/en-us/Content/html/reference-guide/tool/polymesh/geometry/geometry.html. **Best for:** button-level names and options. - Insert Mesh, IMM creation, Curve brushes, Curve Bridge, Mesh Fusion, ArrayMesh, NanoMesh (20 pages; Ken Toney, Geert Melis, Joseph Drust), https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/sculpting/sculpting-brushes/insert-mesh/insert-mesh.html. **Best for:** capture rules, TriParts, Curve Step, ArrayMesh stages and pivot, NanoMesh placement and Edit Mesh. - Maxon ZBrush channel, "ZBrush 2026: ZModeler Updates" (2025-12-03), https://www.youtube.com/watch?v=6KZiNEO65YY. **Best for:** creasing at creation on Insert EdgeLoop and Inset [00:01:06]-[00:02:44]; Insert snap with Ctrl taps [00:02:44]. ## Experts - **Marco Plouffe,** co-founder of Keos Masons, concept and 3D artist (Sideshow Sentinel). ZBrush Masters: Hard Surface Modeling, Pixologic, hosted by Paul Gaboury, 2020-05-26, https://www.youtube.com/watch?v=u75skb32GTo. **Best for:** the visual language and the quick and clean methods. - Rulebook: [00:23:20], [00:52:55], [00:59:02]-[01:01:47], [01:05:00]-[01:06:09], [01:31:40], [01:50:44]. - Quick commit: [00:44:38]-[00:49:33]. - Clean plate: [01:57:33]-[02:25:26], Panel Loops [02:09:12], CreaseLvl [02:18:03]-[02:19:44]. - Pinches: [02:25:59]. - **Michael Pavlovich,** games sculptor (Halo 4, Call of Duty, Doom), official ZBrushLIVE presenter, CGMA instructor. - "Intro to ZBrush" (2021): Dynamic Subdivisions and Creasing https://www.youtube.com/watch?v=qeFclVta4No (crease first [00:08:18], CreaseLvl rule [00:14:11]-[00:16:29], QGrid on arcs [00:22:59], bake split [00:25:50]). - Live Boolean https://www.youtube.com/watch?v=HXnKnrhlFpA (DSDiv [00:02:31], ZRemesher on the UMesh [00:03:31], Start groups [00:07:01]). - Sharp clip results https://www.youtube.com/watch?v=03BMMabyK8U. Clip, Trim, Slice https://www.youtube.com/watch?v=FOdVdgiAHWo (never past the widest part [00:03:56], symmetry per modifier [00:06:20]). - ZModeler Polygon Actions https://www.youtube.com/watch?v=XcR5TzvIaoc. ZModeler Edge Actions https://www.youtube.com/watch?v=qob31SzC754. - ZRemesher https://www.youtube.com/watch?v=6PtmKtr1kx0 (smooth group borders [00:07:56]). Gizmo Deformers https://www.youtube.com/watch?v=FCAb-CSVCBg. - IMM kitbash https://www.youtube.com/watch?v=U5u3RpI9In4 (capture [00:15:04]). MicroMesh and NanoMesh https://www.youtube.com/watch?v=QGNn1-ey6ME (Edit Mesh swap [00:12:26], placeholder SubTool [00:14:07]). - 2024 updates: Knife Split To Parts https://www.youtube.com/watch?v=8LNjAkqr_lI (base prep [00:04:04], debris [00:10:46]); IMM Strokes https://www.youtube.com/watch?v=wnONEaRVhYU (LazyStep [00:03:43]). - **Michael Pavlovich, Mechanical Skull (2020):** - 030 https://www.youtube.com/watch?v=1iJ_sO1U0V4; 031 https://www.youtube.com/watch?v=qBi-JF23r4Y; 032 https://www.youtube.com/watch?v=max2JumNDp4; 033 https://www.youtube.com/watch?v=Yprguxci8NY; - 034 https://www.youtube.com/watch?v=MteQbBFxgqk; 036 https://www.youtube.com/watch?v=ejuEogNKNoo; 037 https://www.youtube.com/watch?v=16AAIFE9CRc; 038 https://www.youtube.com/watch?v=FAFtW_8zB5Q; - 039 https://www.youtube.com/watch?v=H_ETE4qJduU; 042 https://www.youtube.com/watch?v=FM3uQJy1GUY; 044 https://www.youtube.com/watch?v=cw449pmS64g. - **Best for:** exploration resolution, sculpted hard-surface brushes, mask and slice separations, seams, screw ports. - **Michael Pavlovich, Sci Fi Pistol (2017, ZBrush 4R8):** - 007 https://www.youtube.com/watch?v=TANNfCLxFx4; 010 https://www.youtube.com/watch?v=GrIf_eeq6K0; 012 https://www.youtube.com/watch?v=KOmR4vepB04; 013 https://www.youtube.com/watch?v=x1gN9-SocYY; - 015 https://www.youtube.com/watch?v=Ab0ixptmNeA; 019 https://www.youtube.com/watch?v=KroRw6dFs10; 022 https://www.youtube.com/watch?v=QbEHsPGNnlY; 032 https://www.youtube.com/watch?v=VfUqkvHymeQ; - 033 https://www.youtube.com/watch?v=h5gsm_-6df4; 034 https://www.youtube.com/watch?v=iWq7dFxf55I; 035 https://www.youtube.com/watch?v=te7lV_EpGQ0. - **Best for:** blocking from a concept, ArrayMesh cutters, cutter crease numbers, Start groups, the quick game low poly, DCC and bake hand-off. - **Mike Klimer,** senior hard-surface artist at Bungie (Destiny 2), previously The Division. Weapons of Destiny 2, ZBrush Summit 2017, https://www.youtube.com/watch?v=08crkU999Fs. **Best for:** function and manufacturing [00:22:18], first-person readability [01:16:23], validating in engine twice [00:17:32], [00:36:13], consistency [00:29:04], [01:00:37], micro detail on layers [00:29:36]. - **Henry Chervenka,** CEO of 3D Dynamic Studios, hard-surface instructor. Pushing the Boundaries of ZBrush, ZBrush Summit 2023, https://www.youtube.com/watch?v=PGX39tnEf3Y. **Best for:** project setup [00:08:24]-[00:10:33], NanoMesh versus ArrayMesh [00:10:33]-[00:24:18], ZRemesher preparation [00:32:14]-[00:37:48], Project Morph [00:24:52]. - **Polycount Technical Talk,** "3ds Max ProBoolean plus DynaMesh hard-surface workflow" (2016; author "Amsterdam Hilton Hotel", identity unverified), https://polycount.com/discussion/168610/3ds-max-zbrush-proboolean-dynamesh-hardsurface-workflow-tutorial. **Best for:** the DCC-booleans school, cylinder segment counts, Polish as the minimum edge width. ## Version deltas used (`sources/zbrush-version-deltas.md`) - 3.4 Hard surface and ZModeler: crease options on Insert Edge Loop, Insert Multiple Edge Loop and Inset (2026.0); Bevel inner or outer edge modes and the QMesh default fix (2026.2.1); Knife (2021.7 to 2022) and Split To Parts (2024); BevelPro. - 3.5 Retopology and UVs: the Retopo brush (2026.1); Tool > UV Map > Create (Unwrap) with Crease seams (2023). - 3.6 Maps and export: Substance Bridge (2026.2.0) with Low & High, Auto-Bake, Send PolyPaint and the global Force UV Auto-Unwrap; the FBX options window. - 3.12 Scripting: `press_key` does not work; no undo control. ## Baseline, scenario and grade - `tests/grading/Z3_grade.md` (blind grade, 2026-09-24). **Best for:** the missing-from-both list and the factual errors this v2 fixes. - `tests/baseline/answers.md` Z3 and `tests/scenarios.md` Z3 (sci-fi helmet). **Best for:** what generic practice gets wrong; corrected in `expert-notes.md` section 9.
-
-
scripts
-
zb_hardsurface.py 106.1 KB
""" zb_hardsurface: hard-surface building blocks and checks for ZBrush 2026.2.1 (skill scenario-zbrush-hard-surface). Built on the lead toolkit (<skills>/scenario-zbrush-expert/scripts: zb_ops, zb_launch, zb_review, zb_audit, zb_stroke); nothing here re-implements those. Three kinds of functions, like zb_review: pure Python, both sides (tested offline): crease_plan, check_crease, edge_width, subdiv_faces, apply_levels, array_plan, decimate_percent, decode_status, desired_status, boolean_groups, stack_problems, roles_from_groups, seam_port_groups, fitted_split_groups, zmodeler_plan, bridge_preflight, layer_safe, spaced_points, to_tool_units, normal_stats, obj_ngons inside ZBrush (run them from the agent with hs_call("name", ...)): resolve_hs, set_status, set_roles, stack_report, live_boolean, make_boolean_mesh, dynamic_subdiv, dynamic_state, crease_by_groups, crease_by_angle, crease_bevel, apply_dynamic, bake_split, mirror_weld, panel_loops, groups_loops, array_mesh, array_commit, nanomesh_set, new_part, place, import_part, fastener_from_brush, imm_ready, mask_fillet, canvas_action, split_copy, zremesh_recipe, dynamesh_visible, set_polyframe, normal_variance, curve_mesh agent side (system python3, numpy for the mesh checks): hs_call, hs_run, build_call, build_code, review_sheets, load_groups, coplanar_report, edge_language, plane_flatness, border_polylines, mirror_report, mirror_check, hygiene, pinch_report, click_target import sys; sys.path.insert(0, "<skills>/scenario-zbrush-hard-surface/scripts") import zb_hardsurface as hs # also puts the lead scripts on sys.path hs.hs_call("new_part", "cube", res=[2, 2, 2]) hs.hs_call("set_roles", [{"index": 0, "op": "add", "start": True}, {"index": 1, "op": "sub"}]) print(hs.hs_call("stack_report")["problems"]) Evidence tags used in HS_PATHS and in docstrings: [macro] used by a shipped 2026.2.1 macro (ZData/Macros) [strings] the label is in ZData/ZLang/english/UInterface.zsc of 2026.2.1, with the bubble help quoted in HS_INFO; this proves the label, not the full item path [doc] Maxon docs or SDK stub; [bridge] proven through the bridge (lead tests v01..v03) [added] this toolkit's own rule or threshold; [verify] not confirmed Status 2026-09-24: NOT YET RUN IN ZBRUSH. The live checks are tests/code/zbrush-hard-surface/live_hs_*.py; offline tests in the same folder pass. """ __version__ = "0.1" # ZBrush Expert Skills v0.1 (2026-09-24) import json import math import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) LEAD_SCRIPTS = os.path.abspath(os.path.join(HERE, "..", "..", "scenario-zbrush-expert", "scripts")) try: from zbrush import commands as _zbc_probe # noqa: F401 INSIDE_ZBRUSH = True except ImportError: INSIDE_ZBRUSH = False try: # inside ZBrush, hs_call's prelude has put the lead folder on sys.path already import zb_ops except ImportError: # Agent side: keep the lead folder importable (zb_launch, zb_review, zb_audit). # Inside ZBrush (imported without the prelude): import, then leave sys.path as found. sys.path.insert(0, LEAD_SCRIPTS) try: import zb_ops finally: if INSIDE_ZBRUSH and LEAD_SCRIPTS in sys.path: sys.path.remove(LEAD_SCRIPTS) ZBOpError = zb_ops.ZBOpError # -------------------------------------------------------------------------------------------- # Item paths. Candidates are tried in order with exists(); when HS_INFO has an expected # bubble-help substring, a candidate whose get_info() text does not contain it is rejected. # That guards the duplicate labels of Tool:Geometry (Bevel, Thickness, Polish, Loops, # Resolution, Offset appear in several groups: tools digest P0). # -------------------------------------------------------------------------------------------- def _grouped(group, label, extra=()): # Flat path first: the confirmed pattern (Tool:Geometry:Mirror And Weld, Tool:Geometry: # Edgeloop Masked Border [macro]) shows that the collapsible sections of Tool > Geometry do # not add a path level (tools digest header); the sectioned form stays as a fallback. base = [f"Tool:Geometry:{label}", f"Tool:Geometry:{group}:{label}"] return base + list(extra) HS_PATHS = { # Dynamic Subdiv: internal labels carry an "s." prefix [strings]; the NanoMesh macro shows # that prefixed labels are what item paths use ("Tool:NanoMesh:m.Width") [macro] "dyn_on": _grouped("Dynamic Subdiv", "s.Dynamic", ["Tool:Geometry:Dynamic Subdiv:Dynamic"]), "dyn_apply": _grouped("Dynamic Subdiv", "s.Apply", ["Tool:Geometry:Dynamic Subdiv:Apply"]), "dyn_qgrid": _grouped("Dynamic Subdiv", "s.QGrid", ["Tool:Geometry:QGrid"]), "dyn_coverage": _grouped("Dynamic Subdiv", "s.Coverage", ["Tool:Geometry:Coverage"]), "dyn_constant": _grouped("Dynamic Subdiv", "s.Constant"), "dyn_bevel": _grouped("Dynamic Subdiv", "s.Bevel"), "dyn_chamfer": _grouped("Dynamic Subdiv", "s.Chamfer"), "dyn_flat": _grouped("Dynamic Subdiv", "s.FlatSubdiv", ["Tool:Geometry:FlatSubdiv"]), "dyn_smooth": _grouped("Dynamic Subdiv", "s.SmoothSubdiv", ["Tool:Geometry:SmoothSubdiv"]), "dyn_thickness": _grouped("Dynamic Subdiv", "s.Thickness"), "dyn_micropoly": _grouped("Dynamic Subdiv", "s.MicroPoly On"), # Crease group [strings] "crease": _grouped("Crease", "Crease"), "crease_all": _grouped("Crease", "CreaseAll"), "ctolerance": _grouped("Crease", "CTolerance"), "crease_lvl": _grouped("Crease", "CreaseLvl"), "uncrease": _grouped("Crease", "UnCrease"), "uncrease_all": _grouped("Crease", "UnCreaseAll"), "crease_pg": _grouped("Crease", "Crease PG"), "uncrease_pg": _grouped("Crease", "UnCrease PG"), "crease_um": _grouped("Crease", "Crease UM"), "crease_bevel": _grouped("Crease", "B.Bevel"), "crease_bevel_width": _grouped("Crease", "B.Bevel Width"), "crease_bevel_res": _grouped("Crease", "B.Resolution"), # Polygroups [strings]; Group Visible [macro GrpUnMasked] "groups_by_normals": ["Tool:Polygroups:Groups By Normals"], "max_angle": ["Tool:Polygroups:MaxAngle", "Tool:Polygroups:Groups By Normals:MaxAngle"], "group_visible": ["Tool:Polygroups:Group Visible", "Tool:Polygroups:GroupVisible"], "auto_groups": ["Tool:Polygroups:Auto Groups"], # EdgeLoop group [strings]; Edgeloop Masked Border [macro] "edgeloop_masked": ["Tool:Geometry:Edgeloop Masked Border", "Tool:Geometry:EdgeLoop:Edgeloop Masked Border"], "groups_loops": _grouped("EdgeLoop", "GroupsLoops"), "gl_loops": _grouped("EdgeLoop", "Loops"), "gl_gpolish": _grouped("EdgeLoop", "GPolish"), "pl_button": _grouped("EdgeLoop", "P.Panel Loops", ["Tool:Geometry:Panel Loops"]), "pl_loops": _grouped("EdgeLoop", "P.Loops"), "pl_double": _grouped("EdgeLoop", "P.Double"), "pl_append": _grouped("EdgeLoop", "P.Append"), "pl_inner": _grouped("EdgeLoop", "P.Inner"), "pl_thickness": _grouped("EdgeLoop", "P.Thickness"), "pl_polish": _grouped("EdgeLoop", "P.Polish"), "pl_ignore_groups": _grouped("EdgeLoop", "P.Ignore Groups"), "pl_bevel": _grouped("EdgeLoop", "P.Bevel"), "pl_elevation": _grouped("EdgeLoop", "P.Elevation"), # Modify Topology [strings]; Mirror And Weld [macro] "close_holes": _grouped("Modify Topology", "Close Holes"), "del_hidden": _grouped("Modify Topology", "Del Hidden"), "mirror_weld": ["Tool:Geometry:Mirror And Weld"], "delete_by_symmetry": _grouped("Modify Topology", "Delete By Symmetry"), "unweld_groups": _grouped("Modify Topology", "Unweld Groups Border"), "reconstruct": ["Tool:Geometry:Reconstruct Subdiv"], # label "MeshFromBrush", bubble help "Create Mesh From Brush" [strings]; item paths ignore # spaces [doc SDK Item Paths]; Chervenka's route to a kitbash part without a drag "mesh_from_brush": _grouped("Modify Topology", "MeshFromBrush"), "check_mesh": _grouped("MeshIntegrity", "Check Mesh Integrity", ["Tool:Geometry:Check Mesh"]), "fix_mesh": _grouped("MeshIntegrity", "Fix Mesh"), # Deformation [strings]; Unify [macro] "deform_mirror": ["Tool:Deformation:Mirror"], "polish_features": ["Tool:Deformation:Polish By Features"], "polish_groups": ["Tool:Deformation:Polish By Groups"], "polish_crisp": ["Tool:Deformation:Polish Crisp Edges"], "unify": ["Tool:Deformation:Unify"], # SubTool [strings]; Duplicate [bridge] "duplicate": ["Tool:SubTool:Duplicate"], "append": ["Tool:SubTool:Append"], "split_groups": ["Tool:SubTool:Groups Split", "Tool:SubTool:Split:Groups Split"], "split_parts": ["Tool:SubTool:Split To Parts", "Tool:SubTool:Split:Split To Parts"], "split_unmasked": ["Tool:SubTool:Split Unmasked Points", "Tool:SubTool:Split:Split Unmasked Points"], "merge_down": ["Tool:SubTool:MergeDown", "Tool:SubTool:Merge:MergeDown"], "merge_visible": ["Tool:SubTool:MergeVisible", "Tool:SubTool:Merge:MergeVisible"], # Booleans [strings]: "Render:Render Booleans" and "Tool:[SubTool]:{Boolean" appear in the # resources; Live Boolean doc names Render > Render Booleans > Live Boolean "live_boolean": ["Render:Render Booleans:Live Boolean", "Render:Live Boolean"], "show_coplanar": ["Render:Render Booleans:Show Coplanar"], "show_issues": ["Render:Render Booleans:Show Issues"], "make_boolean": ["Tool:SubTool:Boolean:Make Boolean Mesh", "Tool:SubTool:Make Boolean Mesh"], "dsdiv": ["Tool:SubTool:Boolean:DSDiv", "Tool:SubTool:DSDiv"], # ZRemesher extras not wrapped by zb_ops.zremesher [strings] "zr_smooth_groups": ["Tool:Geometry:ZRemesher:SmoothGroups", "Tool:Geometry:SmoothGroups"], "zr_freeze_groups": ["Tool:Geometry:ZRemesher:FreezeGroups", "Tool:Geometry:FreezeGroups"], "zr_curves_strength": ["Tool:Geometry:ZRemesher:Curves Strength", "Tool:Geometry:Curves Strength"], # ArrayMesh: "a." labels [strings]; the sub-palette title is "Array Mesh" [strings] "am_on": ["Tool:Array Mesh:a.Array Mesh", "Tool:ArrayMesh:a.Array Mesh"], "am_repeat": ["Tool:Array Mesh:a.Repeat", "Tool:ArrayMesh:a.Repeat"], "am_offset_mode": ["Tool:Array Mesh:a.Offset", "Tool:ArrayMesh:a.Offset"], "am_scale_mode": ["Tool:Array Mesh:a.Scale", "Tool:ArrayMesh:a.Scale"], "am_rotate_mode": ["Tool:Array Mesh:a.Rotate", "Tool:ArrayMesh:a.Rotate"], "am_pivot_mode": ["Tool:Array Mesh:a.Pivot", "Tool:ArrayMesh:a.Pivot"], "am_x": ["Tool:Array Mesh:a.X Amount", "Tool:ArrayMesh:a.X Amount"], "am_y": ["Tool:Array Mesh:a.Y Amount", "Tool:ArrayMesh:a.Y Amount"], "am_z": ["Tool:Array Mesh:a.Z Amount", "Tool:ArrayMesh:a.Z Amount"], "am_make_mesh": ["Tool:Array Mesh:a.Make Mesh", "Tool:ArrayMesh:a.Make Mesh"], "am_to_nano": ["Tool:Array Mesh:a.Convert To NanoMesh", "Tool:ArrayMesh:a.Convert To NanoMesh"], # Initialize [macro "Append a QCube Subtool"; strings for the others] "init_xres": ["Tool:Initialize:X Res"], "init_yres": ["Tool:Initialize:Y Res"], "init_zres": ["Tool:Initialize:Z Res"], "init_qcube": ["Tool:Initialize:QCube"], "init_qsphere": ["Tool:Initialize:QSphere"], "init_qgrid": ["Tool:Initialize:QGrid"], "init_qcyl_x": ["Tool:Initialize:QCyl X"], "init_qcyl_y": ["Tool:Initialize:QCyl Y"], "init_qcyl_z": ["Tool:Initialize:QCyl Z"], # placement [macro Append Eyes, Snap To Ground] "x_pos": ["Tool:Geometry:X Position"], "y_pos": ["Tool:Geometry:Y Position"], "z_pos": ["Tool:Geometry:Z Position"], "xyz_size": ["Tool:Geometry:XYZ Size"], "x_size": ["Tool:Geometry:X Size"], "y_size": ["Tool:Geometry:Y Size"], "z_size": ["Tool:Geometry:Z Size"], # misc "import": ["Tool:Import"], # [bridge] exists "nm_edit_mesh": ["Tool:NanoMesh:m.Edit Mesh"], # [macro] the late swap "floor_elv": ["Draw:Elv"], # [verify] "polyframe": ["Transform:Pf", "Transform:PolyF"], # [cmdxml] id "Transform: Pf" "current_tool": ["Tool:Current Tool"], # [doc ZScript manual] } # Bubble help substrings from UInterface.zsc (2026.2.1) [strings] HS_INFO = { "dyn_on": "Activates Dynamic Subdiv", "dyn_apply": "Apply All To Mesh", "dyn_qgrid": "Quick Grid-Based Subdiv", "dyn_coverage": "QGrid Coverage", "dyn_constant": "QGrid Constant", "dyn_bevel": "QGrid Bevel", "dyn_chamfer": "QGrid Chamfer", "dyn_flat": "Flat Subdivision Levels", "dyn_smooth": "Smooth Subdivision Levels", "dyn_thickness": "Dynamic Thickness", "dyn_micropoly": "MicroPoly", "crease": "Crease Border", "crease_all": "Crease All", "ctolerance": "Crease Edges Tolerance", "crease_lvl": "Crease Levels", "uncrease": "Uncrease Border", "uncrease_all": "Uncrease All", "crease_pg": "Crease Polygroups Border", "uncrease_pg": "Uncrease Polygroups Border", "crease_um": "Crease UnMasked", "crease_bevel": "Bevel Creased Edges", "crease_bevel_width": "Bevel Width", "crease_bevel_res": "Bevel Resolution", "groups_by_normals": "Similar Face Normals", "max_angle": "Maximum Angle", "group_visible": "Group Visible", "auto_groups": "Topology Continuity", "edgeloop_masked": "Masked Border", "groups_loops": "Edgeloops Around Polygroups", "gl_loops": "GroupsLoops Count", "gl_gpolish": "GroupsLoops Polish", "pl_button": "Panels Around Polygroups", "pl_loops": "Panel Loops Count", "pl_double": "Double Sided", "pl_append": "Append Panel Loops", "pl_inner": "Inner Panel", "pl_thickness": "Panel Loops Thickness", "pl_polish": "Panel Loops Polish", "pl_ignore_groups": "Ignore Polygrouping", "pl_bevel": "Panel Loops Bevel", "pl_elevation": "Panel Loops Elevation", "close_holes": "Close Holes", "del_hidden": "Delete Hidden", "mirror_weld": "Mirror And Weld", "delete_by_symmetry": "Delete By Symmetry", "unweld_groups": "Unweld Groups Border", "reconstruct": "Reconstruct Subdiv", "check_mesh": "Mesh Integrity", "fix_mesh": "Fix Mesh", "mesh_from_brush": "Mesh From Brush", "deform_mirror": "Flip Object", "polish_features": "Polish By Mesh Features", "polish_groups": "Polish By Groups", "polish_crisp": "Polish Crisp Edges", "unify": "Auto Resize", "duplicate": "Duplicate", "append": "Append", "split_groups": "Groups Split", "split_parts": "Split To Parts", "split_unmasked": "Split Unmasked", "merge_down": "Merge Down Two Subtools", "merge_visible": "Merge Visible", "live_boolean": "Live Boolean", "show_coplanar": "coplanar", "show_issues": "issues", "make_boolean": "Make Boolean Mesh", "dsdiv": "Allow Dynamic Subdiv", "zr_smooth_groups": "Smooth Groups Border", "zr_freeze_groups": "Freeze Groups", "zr_curves_strength": "Curves Strength", "am_on": "Array Mesh On", "am_offset_mode": "Offset", "am_pivot_mode": "Pivot", "am_x": "X Amount", "am_y": "Y Amount", "am_z": "Z Amount", "init_xres": "X Resolution", "init_yres": "Y Resolution", "init_zres": "Z Resolution", "x_pos": "X Position", "y_pos": "Y Position", "z_pos": "Z Position", "xyz_size": "XYZ Size", } # -------------------------------------------------------------------------------------------- # Pure helpers (both sides) # -------------------------------------------------------------------------------------------- # (smooth_subdiv, crease_lvl, source). The rule: CreaseLvl >= SmoothSubdiv is razor sharp, # each level below adds falloff (Pavlovich qeFclVta4No 00:15:27; Plouffe u75skb32GTo 02:18:03). # The CLOSER CreaseLvl sits to SmoothSubdiv, the TIGHTER the edge: 4/3 is tighter than 4/2. # v1 of this file called 4/3 "wide"; that label was inverted (Z3 grade) and is refused now. CREASE_PRESETS = { "working": (3, 2, "Pavlovich working preview (qeFclVta4No 00:16:29; HXnKnrhlFpA 00:04:30)"), "bake": (4, 2, "Pavlovich bake highlight (qeFclVta4No 00:14:56)"), "tight": (4, 3, "one level of falloff: a narrower highlight than bake 4/2. Pavlovich arches " "3/4 (qeFclVta4No 00:24:00); Plouffe keeps CreaseLvl 3 because 4 stays razor " "through Dynamic level 4 and 2 is too soft on his plate; his smoothing level " "is not stated (u75skb32GTo 02:18:03 to 02:19:44)"), "soft": (4, 1, "very soft falloff (qeFclVta4No 00:15:27)"), "cutter": (3, 2, "Pavlovich cutter default Smooth 3 / Crease 2 (KroRw6dFs10 00:02:39)"), "razor": (3, 3, "equal or higher is always razor, reads CG (qeFclVta4No 00:15:27; " "8LNjAkqr_lI 00:13:19)"), } _WIDE_MSG = ("'wide' was an inverted label: 4/3 is the TIGHT pair. For a wider edge than 4/3 use " "'bake' (4/2) or 'soft' (4/1); compare renders under metal and keep the widest edge " "that still looks planned (Plouffe)") def edge_width(smooth, crease_lvl): """How wide the highlight along a creased edge reads under Dynamic Subdivision. The gap SmoothSubdiv - CreaseLvl sets it, not either value alone: 0 or less razor ('very CG'), 1 tight, 2 medium (Pavlovich's bake pair 4/2), 3 or more soft (qeFclVta4No 00:14:11 to 00:16:29). rank grows with the width, so sort candidate pairs by it.""" gap = int(smooth) - int(crease_lvl) label = "razor" if gap <= 0 else "tight" if gap == 1 else "medium" if gap == 2 else "soft" return {"smooth": int(smooth), "crease_lvl": int(crease_lvl), "gap": gap, "width": label, "rank": max(gap, 0)} def crease_plan(intent="working"): """SmoothSubdiv and CreaseLvl for an intent: working, bake, tight, soft, cutter, razor.""" if intent == "wide": raise ValueError(_WIDE_MSG) if intent not in CREASE_PRESETS: raise ValueError(f"intent must be one of {sorted(CREASE_PRESETS)}") s, c, src = CREASE_PRESETS[intent] return {"intent": intent, "smooth": s, "crease_lvl": c, "gap": s - c, "width": edge_width(s, c)["width"], "source": src} def check_crease(smooth, crease_lvl, intent="bake"): """Problems for a SmoothSubdiv / CreaseLvl pair. bake, working, tight and cutter need a gap of 1 or 2 (a highlight that bakes); razor needs crease_lvl >= smooth; soft allows a gap of 3. The result carries edge_width's label.""" if intent == "wide": raise ValueError(_WIDE_MSG) gap = int(smooth) - int(crease_lvl) out = {"smooth": smooth, "crease_lvl": crease_lvl, "gap": gap, "width": edge_width(smooth, crease_lvl)["width"], "problems": []} if intent == "razor": if gap > 0: out["problems"].append("razor edges need CreaseLvl >= SmoothSubdiv") elif intent in ("bake", "working", "tight", "cutter"): if gap <= 0: out["problems"].append("CreaseLvl >= SmoothSubdiv reads razor sharp, 'very CG': " "lower CreaseLvl by 1 or 2 (Pavlovich, Plouffe)") elif gap > 2: out["problems"].append("more than 2 levels of falloff rounds the edge away; " "Plouffe found 2 below already too soft on his plate") elif intent == "soft": if gap < 2: out["problems"].append("a soft edge needs CreaseLvl 2 or 3 below SmoothSubdiv") else: raise ValueError("intent must be bake, working, tight, cutter, soft or razor") if int(smooth) < 1 and int(crease_lvl) > 0: out["problems"].append("SmoothSubdiv 0 shows no creasing at all") out["ok"] = not out["problems"] return out def subdiv_faces(quads, tris=0, qgrid=0, flat=0, smooth=0): """Displayed faces under Dynamic Subdivision. Order is always QGrid, Flat, Smooth; each step multiplies by 4 (DSUB doc Workflow); on the first Smooth level a triangle becomes three quads (DSUB doc Smooth Subdivision). Triangles under QGrid and Flat are counted as 4 each [added].""" q, t = float(quads), float(tris) for _ in range(int(qgrid) + int(flat)): q, t = q * 4, t * 4 for i in range(int(smooth)): if i == 0: q, t = q * 4 + t * 3, 0.0 else: q *= 4 return int(round(q + t)) def apply_levels(qgrid=0, flat=0, smooth=0): """Subdivision levels after Dynamic Subdiv > Apply: the base (or the QGrid geometry) is level 1, then one level per Flat and per Smooth step. The doc example QGrid 1 + Flat 1 + Smooth 3 gives 5 levels (DSUB Apply); the general form is [added, derived] and [verify] in live_hs_04.""" return 1 + int(flat) + int(smooth) def array_plan(count, cutter_width, span=None, pitch=None, min_wall=None, wall_rel=0.5): """Spacing for a row of `count` cutters of width `cutter_width` along one axis. Give either the span covered by the whole row or the pitch (centre to centre). The wall left between two cuts must stay plausible: "doesn't look very structurally sound" (Pavlovich x1gN9-SocYY 00:12:19); the default minimum wall of half a cutter width is [added].""" count = int(count) if count < 1 or cutter_width <= 0: raise ValueError("count >= 1 and cutter_width > 0") if pitch is None: if span is None: raise ValueError("give span or pitch") pitch = 0.0 if count == 1 else (float(span) - cutter_width) / (count - 1) wall = pitch - cutter_width if count > 1 else float("inf") mw = float(min_wall) if min_wall is not None else wall_rel * cutter_width return {"count": count, "pitch": pitch, "wall": wall, "min_wall": mw, "total": (count - 1) * pitch + cutter_width, "ok": count == 1 or wall >= mw, "first_offset": 0.0} def decimate_percent(target, total): """Decimation Master '% of decimation' for one uniform pass: 100 x target / total (Pavlovich h5gsm_-6df4 00:00:31: 25,000 of 1.671M points; he set 1.4 and got about 23k).""" if total <= 0: raise ValueError("total must be > 0") return round(100.0 * float(target) / float(total), 3) # SubTool status bits (SDK get_subtool_status) [doc] EYE, FOLDER_EYE, ADD, SUB, CLIP, START = 0x1, 0x2, 0x10, 0x20, 0x40, 0x80 OPS = ADD | SUB | CLIP MASK = EYE | FOLDER_EYE | OPS | START OP_BITS = {"add": ADD, "sub": SUB, "clip": CLIP, "intersect": CLIP, "none": 0} def decode_status(s): s = int(s) op = "sub" if s & SUB else "clip" if s & CLIP else "add" if s & ADD else "none" return {"eye": bool(s & EYE), "folder_eye": bool(s & FOLDER_EYE), "op": op, "start": bool(s & START)} def desired_status(current, op=None, start=None, visible=None): """Status value with the operator, Start flag and eye changed, other bits kept.""" s = int(current) if op is not None: if op not in OP_BITS: raise ValueError(f"op must be one of {sorted(OP_BITS)}") s = (s & ~OPS) | OP_BITS[op] if start is not None: s = (s | START) if start else (s & ~START) if visible is not None: s = (s | EYE) if visible else (s & ~EYE) return s def boolean_groups(rows): """Start groups as ZBrush processes them: visible SubTools top to bottom, a new group at the first one and at every Start flag (LB doc Boolean Process, Linearity, Start groups).""" groups = [] for r in rows: if not r.get("visible", r.get("eye", True)): continue if not groups or r.get("start"): groups.append({"start_index": r["index"], "members": []}) groups[-1]["members"].append({"index": r["index"], "op": r.get("op", "none")}) return groups def stack_problems(rows, density_factor=16.0, qgrid_max=1): """Pre-flight of a Live Boolean stack from stack_report rows. Problems block Make Boolean Mesh; warnings need a look. Sources: LB doc (Important Information, Linearity, Data Preservation); the density factor and QGrid limit are [added] numbers.""" problems, warnings, notes = [], [], [] vis = [r for r in rows if r.get("visible", r.get("eye", True))] if len(vis) < 2: problems.append("fewer than two visible SubTools: nothing to boolean") if vis and vis[0].get("op") in ("sub", "clip") and not vis[0].get("start"): problems.append(f"first visible SubTool {vis[0]['index']} is {vis[0]['op']}: it must be " "Add or Start (processed top to bottom)") for r in vis: if r.get("solid") is False: problems.append(f"SubTool {r['index']} is not watertight; every input must be a valid " "solid or ZBrush reports invalid SubTools") if r.get("partly_hidden"): problems.append(f"SubTool {r['index']} is partially hidden: hidden openings get " "closed with Close Hole; show all first") dyn = r.get("dynamic") or {} if dyn.get("on") and (dyn.get("qgrid") or 0) > qgrid_max: warnings.append(f"SubTool {r['index']} QGrid {dyn.get('qgrid')}: DSDiv turns it into " "long thin polygons; lower it") if (r.get("levels") or 1) > 1: notes.append(f"SubTool {r['index']} has {r['levels']} levels: they collapse to the " "current level in the result") if vis and not any(r.get("op") in ("sub", "clip") for r in vis): warnings.append("no Subtract or Intersect operand: the result is a plain union") for g in boolean_groups(rows): if g["members"] and g["members"][0]["op"] in ("sub", "clip"): warnings.append(f"Start group at {g['start_index']} begins with a cutter") dens = [r["faces"] / r["area"] for r in vis if r.get("faces") and r.get("area")] if len(dens) >= 2 and min(dens) > 0 and max(dens) / min(dens) > density_factor: warnings.append(f"density differs {max(dens) / min(dens):.1f}x between operands; keep " "it similar or expect long thin triangles") return {"problems": problems, "warnings": warnings, "notes": notes, "ok": not problems} def roles_from_groups(groups): """set_roles() rows for Live Boolean Start groups given as lists of (index, op), in SubTool order. Each group's first member becomes Add + Start. ZBrush processes visible SubTools top to bottom and every Start opens a new group (LB doc Boolean Process, Linearity), so the groups must be consecutive runs; the function checks that with boolean_groups.""" rows, roles = [], [] last = -1 for g in groups: if not g: raise ValueError("empty Start group") for k, (idx, op) in enumerate(g): idx = int(idx) if idx <= last: raise ValueError(f"SubTool {idx} out of order: groups must follow the SubTool " "list top to bottom") if k == 0 and op not in ("add", "none"): raise ValueError(f"group starting at {idx} begins with {op}: a Start group " "begins with its body (Add)") if op not in OP_BITS: raise ValueError(f"op must be one of {sorted(OP_BITS)}") opn = "clip" if op == "intersect" else op roles.append({"index": idx, "op": "add" if k == 0 else opn, "start": k == 0}) rows.append({"index": idx, "op": "add" if k == 0 else opn, "start": k == 0, "visible": True}) last = idx got = [[m["index"] for m in grp["members"]] for grp in boolean_groups(rows)] want = [[int(i) for i, _ in g] for g in groups] if got != want: raise ValueError(f"Start groups would be {got}, not {want}") return {"roles": roles, "groups": want} def seam_port_groups(part_a, ball_a, trim_a, part_b, ball_b): """Pavlovich's recessed port across a seam (cw449pmS64g 00:01:06 to 00:04:34): one sphere, the half on part A's side added to A (a boss), the half on B's side subtracted from B (the socket), so both line up across the seam. He slices the sphere; this Live Boolean form needs no stroke [added]: group A = part_a + ball_a - trim_a (a box on B's side, just off the seam plane, removes ball_a's B half); group B = part_b - ball_b (only the half inside B cuts). ball_a and ball_b are copies of the same placed sphere (Dynamic Smooth on, which DSDiv carries into the result: Pavlovich creases and divides his halves so they do not facet).""" return roles_from_groups([[(part_a, "add"), (ball_a, "add"), (trim_a, "sub")], [(part_b, "add"), (ball_b, "sub")]]) def fitted_split_groups(body_a, cutter_a, body_b, cutter_b): """Two parts that fit with no gap (a visor, hatch or panel embedded in a shell), from one cutter used twice [added]: group 1 = body_a INTERSECT cutter_a (the piece: Intersect keeps only the overlap, LB doc operators), group 2 = body_b - cutter_b (the shell with its opening). body_b is a duplicate of body_a and cutter_b a duplicate of cutter_a, so the borders match: the boolean stand-in for Knife + Split To Parts (8LNjAkqr_lI 00:01:34) or Slice + DynaMesh Groups (Yprguxci8NY 00:01:05). For a constant gap, scale cutter_b up by the gap.""" return roles_from_groups([[(body_a, "add"), (cutter_a, "intersect")], [(body_b, "add"), (cutter_b, "sub")]]) # ZModeler 2026: options live in the Space pop-up (no item path). Labels are in the 2026.2.1 # UI strings as ZModeler modifiers [strings]; behaviour from Maxon's 2026 update video. ZMODELER_2026 = { "insert_edgeloop": { "element": "edge", "menu": "Insert > Single EdgeLoop or Multiple EdgeLoops", "options": {"hold": "Crease", "free": "Do Not Crease"}, "default": "Do Not Crease", "since": (2026, 0), "source": "Maxon 6KZiNEO65YY 00:01:06 to 00:01:40"}, "inset": { "element": "polygon", "menu": "Inset", "options": {"crisp": "Crease New Edges", "rounded": "Crease Inner Poly", "free": "Do Not Crease"}, "default": "Do Not Crease", "since": (2026, 0), "source": "6KZiNEO65YY 00:01:40 to 00:02:44 (Crease New Edges creases the inner loop and " "the outer supporting edges; Crease Inner Poly only the inner loop: rounded)"}, "bevel": { "element": "edge", "menu": "Bevel", "options": {"all": "All Edges", "outer": "Outer Edges", "inner": "Inner Edges"}, "default": None, "since": (2026, 2, 1), "source": "release notes 2026.2.1 (inner or outer edge modes); labels [strings]"}, "qmesh": { "element": "polygon", "menu": "QMesh", "options": {}, "default": "QMesh", "since": (2021,), "source": "XcR5TzvIaoc 00:04:33; 2026.2.1 fixed ZModeler defaulting to " "Extrude instead of QMesh (release notes)"}, } def zmodeler_plan(action, intent=None, ring_quads=None, region_border=None, build=(2026, 2, 1)): """What to set in the ZModeler Space menu for one action, and the face delta that proves the click ran (the SDK cannot read the menu state). 2026.0 creases at creation: Insert EdgeLoop (Crease) and Inset (Crease New Edges for a crisp inset with supported borders, Crease Inner Poly for a rounded one) replace the alternate-polygroup plus Crease PG pass; the defaults are Do Not Crease, so a loop added with Dynamic on holds nothing until Crease is picked (Maxon 6KZiNEO65YY). Face deltas are [added]: a full loop across a ring of n quads adds n faces; Inset of one quad adds 4; Inset Region with b border edges adds b.""" if action not in ZMODELER_2026: raise ValueError(f"action must be one of {sorted(ZMODELER_2026)}") a = ZMODELER_2026[action] out = {"action": action, "element": a["element"], "menu": a["menu"], "default": a["default"], "source": a["source"], "warnings": []} if a["options"]: if intent is None: raise ValueError(f"{action}: say the intent, one of {sorted(a['options'])}") if intent not in a["options"]: raise ValueError(f"{action}: intent must be one of {sorted(a['options'])}") out["option"] = a["options"][intent] out["changes_default"] = out["option"] != a["default"] if tuple(build) < tuple(a["since"]): out["warnings"].append(f"{action} options need ZBrush {'.'.join(map(str, a['since']))}; " "on this build use GroupsLoops or regroup, then Crease PG") if action == "qmesh" and tuple(build) < (2026, 2, 1): out["warnings"].append("before 2026.2.1 ZModeler could default to Extrude: check the " "polygon action before trusting a QMesh step") if action == "insert_edgeloop" and ring_quads: out["face_delta"] = int(ring_quads) elif action == "inset": if region_border: out["face_delta"] = int(region_border) elif intent is not None: out["face_delta"] = 4 return out def bridge_preflight(rows, low_high=True, force_unwrap=False, send_polypaint=False, auto_bake=True): """Checks before Texture > Substance Bridge > Send to Painter (2026.2.0; VD 3.6). rows, one per SubTool sent: {"name", "levels", "has_uvs", "qgrid_applied" (optional), "polypaint" (optional bool)}. Low & High sends each SubTool's lowest and highest level, so the bake_split copy (Apply) carries both; after Apply with QGrid 0 level 1 is the Dynamic cage, with QGrid it is the QGrid mesh [added, from the DSUB Apply rule]. Force UV Auto-Unwrap is global and strips existing UVs from every SubTool. Each send makes a new Painter project.""" problems, warnings = [], [] names = [r.get("name") for r in rows] dup = sorted({str(n) for n in names if names.count(n) > 1}) if dup: warnings.append(f"duplicate SubTool names {dup}: keep names unique so parts pair by name " "[added]") for r in rows: nm = r.get("name") if low_high and (r.get("levels") or 1) < 2: problems.append(f"{nm}: Low & High needs subdivision levels (level 1 = low, top = " "high): run bake_split, or send Current") if low_high and (r.get("qgrid_applied") or 0) > 0: warnings.append(f"{nm}: QGrid was applied, so level 1 is the QGrid mesh, not the cage") if not force_unwrap and not r.get("has_uvs"): problems.append(f"{nm}: no UVs; unwrap level 1 first (scenario-zbrush-retopology-export " "unwrap_creases) or allow Force UV Auto-Unwrap") if send_polypaint and r.get("polypaint") is False: warnings.append(f"{nm}: Send PolyPaint is on but the part has no polypaint " "(FillObject per part gives the ID colours)") if force_unwrap and any(r.get("has_uvs") for r in rows): problems.append("Force UV Auto-Unwrap is global: it strips existing UVs from every " "SubTool; turn it off to keep them") notes = ["each send creates a new Painter project; there is no incremental update"] if auto_bake: notes.append("Auto-Bake with every option on did not trigger on Mac before 2026.2.1") return {"problems": problems, "warnings": warnings, "notes": notes, "ok": not problems} def layer_safe(recorded, current): """3D layers store per-point offsets and 'cannot survive added or removed geometry' (Klimer 08crkU999Fs 00:49:08). recorded: zb_ops.stats() when the layer was made; current: the same now. Any change in points, faces or level count means the layer no longer applies: make detail layers after the last boolean, remesh, Apply or Panel Loops, or bake them first.""" keys = [k for k in ("points", "faces", "sdiv_max", "levels") if k in recorded and k in current] if not keys: raise ValueError("no comparable keys (points, faces, sdiv_max, levels)") changed = {k: (recorded[k], current[k]) for k in keys if recorded[k] != current[k]} return {"ok": not changed, "changed": changed, "compared": keys} def spaced_points(polyline, count=None, pitch=None, start=0.0): """Evenly spaced seats along a 3D polyline (arc length), with unit tangents: exact bolt or rivet runs for import_part, ArrayMesh checks or NanoMesh helper planes. count puts the first and last seat on the ends; pitch steps from `start`. The 2024 IMM Dots stroke spaces by LazyStep and a Curve Mode run by Curve Step (1 = parts touch) (wnONEaRVhYU 00:03:43; IMM doc Curve Strokes); this gives the same spacing as numbers [added].""" pts = [tuple(float(c) for c in p) for p in polyline] if len(pts) < 2: raise ValueError("need at least two points") seg = [math.dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1)] total = sum(seg) if total <= 0: raise ValueError("zero-length polyline") if count is not None: count = int(count) if count < 1: raise ValueError("count >= 1") ds = [total / 2] if count == 1 else [total * i / (count - 1) for i in range(count)] elif pitch is not None: if pitch <= 0: raise ValueError("pitch > 0") ds, d = [], float(start) while d <= total + 1e-9: ds.append(d) d += float(pitch) else: raise ValueError("give count or pitch") out, i, acc = [], 0, 0.0 for d in ds: while i < len(seg) - 1 and acc + seg[i] < d - 1e-12: acc += seg[i] i += 1 t = 0.0 if seg[i] == 0 else min(max((d - acc) / seg[i], 0.0), 1.0) a, b = pts[i], pts[i + 1] p = tuple(a[k] + t * (b[k] - a[k]) for k in range(3)) tan = tuple((b[k] - a[k]) / (seg[i] or 1.0) for k in range(3)) out.append({"point": [round(c, 9) for c in p], "tangent": [round(c, 9) for c in tan], "distance": round(d, 9)}) return {"seats": out, "length": round(total, 9), "count": len(out)} def to_tool_units(real_size, real_extent, tool_extent): """A real dimension (a 2 mm chamfer, a 6 mm bolt) in ZBrush tool units, given the part's real extent and its extent in the tool (bbox). The Unified tool is about 2 units long and export scale is set at the end (Chervenka; iWq7dFxf55I), so briefs in mm need this before Crease Bevel widths or cutter sizes [added].""" if real_extent <= 0 or tool_extent <= 0: raise ValueError("extents must be > 0") return float(real_size) * float(tool_extent) / float(real_extent) def normal_stats(normals): """Spread of canvas normals (pixol_pick 6..8) over one plane: the numeric proxy for Plouffe's 'wobbliness' check [added]. Zero-length samples (background) are skipped.""" pts = [n for n in normals if math.sqrt(sum(c * c for c in n)) > 0.5] if not pts: return {"samples": 0} unit = [tuple(c / math.sqrt(sum(k * k for k in n)) for c in n) for n in pts] m = [sum(u[i] for u in unit) / len(unit) for i in range(3)] ml = math.sqrt(sum(c * c for c in m)) or 1.0 m = [c / ml for c in m] ang = sorted(math.degrees(math.acos(max(-1.0, min(1.0, sum(a * b for a, b in zip(u, m)))))) for u in unit) p95 = ang[min(len(ang) - 1, int(round(0.95 * (len(ang) - 1))))] return {"samples": len(unit), "skipped": len(normals) - len(unit), "mean_normal": [round(c, 5) for c in m], "mean_deg": round(sum(ang) / len(ang), 4), "p95_deg": round(p95, 4), "max_deg": round(ang[-1], 4)} def obj_ngons(path, limit=5): """Faces with more than four corners in an OBJ (pure Python). ZBrush asks in a modal note how to split them on import ('This mesh contains nonstandard polygons', UInterface.zsc).""" n, first = 0, [] with open(path, "r", errors="ignore") as fh: for i, line in enumerate(fh): if line.startswith("f "): k = len(line.split()) - 1 if k > 4: n += 1 if len(first) < limit: first.append((i + 1, k)) return {"ngons": n, "first": first} def obj_face_count(path): with open(path, "r", errors="ignore") as fh: return sum(1 for line in fh if line.startswith("f ")) # -------------------------------------------------------------------------------------------- # Inside ZBrush # -------------------------------------------------------------------------------------------- def _z(): return zb_ops._z() def resolve_hs(key, required=True): """First existing candidate for `key` whose bubble help matches HS_INFO (when known). A label that exists with the wrong bubble help is a duplicate-label collision.""" cands = HS_PATHS.get(key, [key]) if isinstance(key, str) else list(key) want = HS_INFO.get(key) if isinstance(key, str) else None z = _z() rejected = [] for p in cands: if not z.exists(p): continue info = "" if want: try: info = z.get_info(p) or "" except Exception: info = "" if want and info and want.lower() not in info.lower(): rejected.append((p, info[:80])) continue return p if not required: return None if rejected: raise ZBOpError(f"{key}: {rejected} exist but their bubble help does not contain " f"{want!r} (duplicate label)") raise ZBOpError(f"{key}: none of {cands} exists (label changed, wrong mode, or the item " "lives elsewhere: record a macro and read the Activity log)") def _set(key, value, tol=0.51): return zb_ops.set_checked([resolve_hs(key)], value, tol=tol) def _switch(key, on): return zb_ops.set_checked([resolve_hs(key)], 1 if on else 0, tol=0.5) def _press(key, check_enabled=True): p = resolve_hs(key) zb_ops.press([p], check_enabled=check_enabled) _z().update(redraw_ui=True) return p def _press_mod(key, bits): """Press an axis button (Mirror And Weld, Deformation Mirror) with modifier bits x=1, y=2, z=4 (shipped Append Eyes macro: IModSet 1/2/4) and restore the previous modifiers.""" z = _z() p = resolve_hs(key) old = z.get_mod(p) z.set_mod(p, bits) try: z.press(p) z.update(redraw_ui=True) finally: z.set_mod(p, old) return p def _levels(): p = zb_ops.resolve("sdiv", required=False) return int(round(_z().get_max(p))) if p else 1 def _faces(): return int(_z().query_mesh3d(1)[0]) def _bbox(mode=1): return [float(v) for v in _z().query_mesh3d(2, mode)] def _subtool_name(): z = _z() p = resolve_hs("current_tool", required=False) if p: try: t = z.get_title(p) if t: return t.strip().rstrip(".").strip() except Exception: pass return z.get_active_tool_path() _SEMANTICS = [None] # "absolute" or "toggle" once a write has been read back def set_status(index, op=None, start=None, visible=None, semantics=None): """Set a SubTool's operator / Start / eye with read-back. The SDK docstring says set_subtool_status "realizes a toggle logic", while Maxon's own example and the shipped Create Instance Subtool macro write absolute values: the function tries absolute first, reads back, falls back to toggling the difference, and remembers what worked.""" z = _z() index = int(index) cur = int(z.get_subtool_status(index)) want = desired_status(cur, op, start, visible) res = {"index": index, "before": cur, "want": want, "tried": []} if (cur & MASK) == (want & MASK): res.update(after=cur, changed=False, semantics=_SEMANTICS[0]) return res order = [semantics] if semantics else ([_SEMANTICS[0]] if _SEMANTICS[0] else ["absolute", "toggle"]) for sem in order: now = int(z.get_subtool_status(index)) val = want if sem == "absolute" else (now ^ want) & MASK z.set_subtool_status(index, val) got = int(z.get_subtool_status(index)) res["tried"].append({"semantics": sem, "wrote": val, "read": got}) if (got & MASK) == (want & MASK): _SEMANTICS[0] = sem res.update(after=got, changed=True, semantics=sem) return res raise ZBOpError(f"set_subtool_status({index}) did not reach {want:#x}: {res['tried']}") def set_roles(roles, semantics=None): """roles: [{"index": i, "op": "add"|"sub"|"clip"|"none", "start": bool, "visible": bool}]. Pavlovich's stack: main body on top (Add or Start), cutters below, one Start per part that must stay separate (HXnKnrhlFpA 00:00:33, 00:07:01; Ab0ixptmNeA 00:01:07).""" out = [] for r in roles: out.append(set_status(r["index"], r.get("op"), r.get("start"), r.get("visible"), semantics or _SEMANTICS[0])) return {"results": out, "semantics": _SEMANTICS[0]} def dynamic_state(required=False): z = _z() out = {} for name, key in (("on", "dyn_on"), ("qgrid", "dyn_qgrid"), ("flat", "dyn_flat"), ("smooth", "dyn_smooth"), ("crease_lvl", "crease_lvl")): p = resolve_hs(key, required=required) if p: v = z.get(p) out[name] = bool(v >= 0.5) if name == "on" else int(round(v)) return out def stack_report(read_dynamic=True, density_factor=16.0): """Every SubTool: status bits, folder-aware visibility (zb_ops.subtools), faces, area, watertight, levels, partial hiding (visible bbox differs from the full bbox [added]) and the Dynamic Subdiv state; then the Start groups and the pre-flight verdict.""" z = _z() n = int(z.get_subtool_count()) active = int(z.get_active_subtool_index()) vis = {s["index"]: s["visible"] for s in zb_ops.subtools()} rows = [] try: for i in range(n): st = int(z.get_subtool_status(i)) row = {"index": i, "status": st, **decode_status(st), "visible": vis.get(i, True)} if z.select_subtool(i) != 0: row["error"] = "select_subtool failed" rows.append(row) continue try: row["name"] = _subtool_name() row["faces"] = _faces() row["points"] = int(z.query_mesh3d(0)[0]) full, shown = _bbox(1), _bbox(0) row["bbox"] = [round(v, 6) for v in full] ext = max(full[3] - full[0], full[4] - full[1], full[5] - full[2]) or 1.0 row["partly_hidden"] = max(abs(a - b) for a, b in zip(full, shown)) > 1e-4 * ext row["area"] = float(z.get_polymesh3d_area()) row["solid"] = bool(z.is_polymesh3d_solid()) row["levels"] = _levels() except Exception as e: row["mesh_error"] = repr(e) if read_dynamic: try: row["dynamic"] = dynamic_state(required=False) except Exception as e: row["dynamic_error"] = repr(e) rows.append(row) finally: z.select_subtool(active) pre = stack_problems(rows, density_factor) return {"subtools": rows, "active": active, "groups": boolean_groups(rows), **pre} def live_boolean(on=True, show_coplanar=None, show_issues=None): """Render > Render Booleans: Live Boolean, plus the native analysis (LB doc Geometry Analysis): Show Coplanar BEFORE Make Boolean Mesh (coplanar faces in red), Show Issues on the result AFTER it (red outlines on holes and edges shared by more than 2 polygons). Take a zb_review.snapshot with each on; coplanar_report is the numeric twin of Show Coplanar.""" out = {"live_boolean": _switch("live_boolean", on)} if show_coplanar is not None: out["show_coplanar"] = _switch("show_coplanar", show_coplanar) if show_issues is not None: out["show_issues"] = _switch("show_issues", show_issues) return out def _tool_paths(): z = _z() return [z.get_tool_path(i) for i in range(int(z.get_tool_count()))] def make_boolean_mesh(dsdiv=True, select=True, require_clean=True): """Tool > SubTool > Boolean > Make Boolean Mesh with the pre-flight first. Needs Live Boolean on and more than one SubTool (LB doc Basic Process). The result is a NEW tool whose name starts with UMesh_, one SubTool per Start group (LB doc Advanced Process). Blocking risk: coplanar faces raise a Yes/No note ('The Boolean contains coplanar faces... Do you really want to launch Boolean whatever?', UInterface.zsc); run coplanar_report on OBJ exports first, and call this with a short bridge timeout.""" rep = stack_report() if require_clean and rep["problems"]: raise ZBOpError(f"boolean pre-flight failed: {rep['problems']}") z = _z() live_boolean(True) if dsdiv is not None: _switch("dsdiv", dsdiv) before = _tool_paths() _press("make_boolean", check_enabled=True) after = _tool_paths() seen = set(before) new = [(i, p) for i, p in enumerate(after) if p not in seen] umesh = [(i, p) for i, p in new if "umesh" in os.path.basename(str(p)).lower()] out = {"new_tools": new, "umesh": umesh, "expected_subtools": len(rep["groups"]), "warnings": rep["warnings"]} if select and umesh: z.select_tool(umesh[-1][0]) out["umesh_subtools"] = int(z.get_subtool_count()) out["ok"] = out["umesh_subtools"] == out["expected_subtools"] else: out["ok"] = bool(umesh) return out def dynamic_subdiv(smooth=None, crease_lvl=None, qgrid=None, flat=None, coverage=None, thickness=None, on=True, intent=None): """Tool > Geometry > Dynamic Subdiv values with read-back. intent (working, bake, tight, soft, cutter, razor) fills smooth and crease_lvl from crease_plan. QGrid is for boxy blocks: it facets curves and packs polygons on every edge (Pavlovich qeFclVta4No 00:22:59, 00:28:24).""" if intent: plan = crease_plan(intent) smooth = plan["smooth"] if smooth is None else smooth crease_lvl = plan["crease_lvl"] if crease_lvl is None else crease_lvl out = {} for name, key, val in (("qgrid", "dyn_qgrid", qgrid), ("flat", "dyn_flat", flat), ("smooth", "dyn_smooth", smooth), ("coverage", "dyn_coverage", coverage), ("thickness", "dyn_thickness", thickness), ("crease_lvl", "crease_lvl", crease_lvl)): if val is not None: out[name] = _set(key, val, tol=0.51 if name not in ("coverage", "thickness") else 1e-3) if on is not None: out["on"] = _switch("dyn_on", on) st = dynamic_state(required=False) base = _faces() lv = _levels() out.update(state=st, base_faces=base, levels=lv, display_faces=subdiv_faces(base, 0, st.get("qgrid", 0), st.get("flat", 0), st.get("smooth", 0)) if st.get("on") else base) if "smooth" in st and "crease_lvl" in st: out["crease_gap"] = st["smooth"] - st["crease_lvl"] if lv > 1: out["warning"] = "classic levels present: Dynamic and classic mix only at low levels (DSUB)" return out def crease_by_groups(max_angle=45.0, one_group_first=True, uncrease_first=True): """Polygroups as the crease plan: (show all, Group Visible = Ctrl+W), Groups By Normals at MaxAngle (45 default catches 90 degree edges; 33 catches softer planes; 60 misses edges), UnCreaseAll, Crease PG (Pavlovich qeFclVta4No 00:11:42 to 00:12:15; Plouffe 02:11:55). Pitfall: Crease PG after later edits recreases borders you had uncreased (00:16:32).""" pressed = [] if one_group_first: zb_ops.visibility("show") pressed.append(_press("group_visible")) out = {"max_angle": _set("max_angle", max_angle, tol=0.51)} pressed.append(_press("groups_by_normals")) if uncrease_first: pressed.append(_press("uncrease_all")) pressed.append(_press("crease_pg")) out.update(pressed=pressed, faces=_faces()) return out def crease_by_angle(tolerance=45.0, uncrease_first=True): """UnCreaseAll, CTolerance, Crease: every edge sharper than the tolerance (45 equals Group By Normals 45 + Crease PG; 22 held an arch without creasing its small angles) (qeFclVta4No 00:18:05, 00:23:30). Also the way to sharpen cutters that inherit Dynamic smoothing (HXnKnrhlFpA 00:01:42).""" pressed = [] if uncrease_first: pressed.append(_press("uncrease_all")) out = {"ctolerance": _set("ctolerance", tolerance, tol=0.51)} pressed.append(_press("crease")) out["pressed"] = pressed return out def crease_bevel(width=None, resolution=None, press=True): """Crease > Bevel: real bevel geometry along creased edges (Pavlovich qeFclVta4No 00:19:02; Chervenka's Bevel Width fix for crease pinching at poles, PGX39tnEf3Y 00:31:38) [verify].""" out = {} if width is not None: out["width"] = _set("crease_bevel_width", width, tol=1e-3) if resolution is not None: out["resolution"] = _set("crease_bevel_res", resolution) if press: f0 = _faces() _press("crease_bevel") out.update(faces_before=f0, faces_after=_faces()) return out def apply_dynamic(): """Dynamic Subdiv > Apply: real levels. Expect 1 + Flat + Smooth levels (apply_levels) and base x 4^(QGrid+Flat+Smooth) faces on an all-quad mesh (DSUB). The Apply crash is fixed in 2026.2.1 (release notes). Stay in preview until the shape is final (qeFclVta4No 00:25:33).""" st = dynamic_state(required=True) if not st.get("on"): raise ZBOpError("Dynamic Subdiv is off: nothing to apply") f0 = _faces() _press("dyn_apply") lv = _levels() exp = apply_levels(st.get("qgrid", 0), st.get("flat", 0), st.get("smooth", 0)) return {"state": st, "faces_before": f0, "levels": lv, "expected_levels": exp, "ok": lv == exp} def bake_split(bake_smooth=4, crease_lvl=2): """Pavlovich's bake split (qeFclVta4No 00:25:50): duplicate the SubTool; Apply at Smooth 4 / CreaseLvl 2 on the copy (the high to bake); Dynamic off on the original (the game-res mesh, check it for over-curved edges). The copy is expected directly below [verify].""" z = _z() zb_ops.ensure_edit() i = int(z.get_active_subtool_index()) n0 = int(z.get_subtool_count()) zb_ops.press("duplicate") z.update(redraw_ui=True) if int(z.get_subtool_count()) != n0 + 1: raise ZBOpError("Duplicate did not add a SubTool") copy = i + 1 z.select_subtool(copy) dyn = dynamic_subdiv(smooth=bake_smooth, crease_lvl=crease_lvl, on=True) app = apply_dynamic() z.select_subtool(i) _switch("dyn_on", False) return {"low_index": i, "high_index": copy, "high": app, "high_dynamic": dyn, "low_faces": _faces()} def mirror_weld(axis="x", keep=None, check_floor=True, allow_levels=False): """Mirror And Weld ALWAYS copies the NEGATIVE half onto the positive half; when the good half is on +X, Deformation > Mirror runs first so the good half becomes the negative one (Pavlovich 8LNjAkqr_lI 00:06:49, FOdVdgiAHWo 00:06:52; Plouffe 02:01:25). keep has no default on purpose: say which half holds the edit ("neg" or "pos"), from where you cut (Slice, Trim and BRadius ignore symmetry; a one-sided boolean or ZModeler edit too), or from mirror_report on an OBJ export; confirm with mirror_check afterwards. Refuses a mesh with levels (Plouffe: delete levels, mirror, Reconstruct Subdiv when topology did not change). A floor 'Elv' not at 0 raises a Yes/No note (UInterface.zsc), so Draw:Elv is set to 0 first [verify path].""" z = _z() if keep not in ("neg", "pos"): raise ZBOpError("mirror_weld: pass keep='neg' or keep='pos' (the half that holds the edit); " "Mirror And Weld overwrites the positive half with the negative one") bits = {"x": 1, "y": 2, "z": 4}[axis] if _levels() > 1 and not allow_levels: raise ZBOpError("SubTool has subdivision levels: delete them (zb_ops.del_levels) or pass " "allow_levels=True") out = {"axis": axis, "keep": keep} if check_floor: p = resolve_hs("floor_elv", required=False) if p is not None and abs(float(z.get(p))) > 1e-9: out["floor_elv_was"] = float(z.get(p)) z.set(p, 0) b0 = _bbox(1) if keep == "pos": out["mirror"] = _press_mod("deform_mirror", bits) out["mirror_weld"] = _press_mod("mirror_weld", bits) b1 = _bbox(1) k = "xyz".index(axis) span = (b1[k + 3] - b1[k]) or 1.0 out.update(bbox_before=b0, bbox_after=b1, symmetry_error=abs(b1[k] + b1[k + 3]) / span, faces=_faces()) out["ok"] = out["symmetry_error"] < 1e-3 return out def panel_loops(loops=1, thickness=None, thickness_rel=None, polish=0, bevel=0, elevation=-100, double=True, ignore_groups=True, append=None, inner=None): """Panel Loops on the polygroups. Defaults are Plouffe's plate recipe: Loops 1, Polish 0, Bevel 0, Elevation -100, Double on, Ignore Groups on, only Thickness tuned (0.0046 on his plate) (u75skb32GTo 02:09:12, frame 02:14:34). thickness_rel x bbox diagonal is [added]. No subdivision levels allowed (HS doc).""" if _levels() > 1: raise ZBOpError("Panel Loops needs a mesh without subdivision levels") if thickness is None and thickness_rel is not None: b = _bbox(1) thickness = thickness_rel * math.dist(b[:3], b[3:]) out = {} for name, key, val, tol in (("loops", "pl_loops", loops, 0.51), ("thickness", "pl_thickness", thickness, 1e-4), ("polish", "pl_polish", polish, 0.51), ("bevel", "pl_bevel", bevel, 0.51), ("elevation", "pl_elevation", elevation, 0.51)): if val is not None: out[name] = _set(key, val, tol=tol) for name, key, val in (("double", "pl_double", double), ("ignore_groups", "pl_ignore_groups", ignore_groups), ("append", "pl_append", append), ("inner", "pl_inner", inner)): if val is not None: out[name] = _switch(key, val) f0 = _faces() _press("pl_button") out.update(faces_before=f0, faces_after=_faces(), solid=bool(_z().is_polymesh3d_solid())) return out def groups_loops(loops=1, gpolish=None): """GroupsLoops: loops around every polygroup, the scripted stand-in for support loops (qob31SzC754; Plouffe contour cleanup with Loops 1 or 0 and some GPolish, 01:59:16).""" if _levels() > 1: raise ZBOpError("GroupsLoops needs a mesh without subdivision levels") out = {"loops": _set("gl_loops", loops)} if gpolish is not None: out["gpolish"] = _set("gl_gpolish", gpolish) f0 = _faces() _press("groups_loops") out.update(faces_before=f0, faces_after=_faces()) return out def array_mesh(repeat, offset=None, scale=None, rotate=None, pivot=None, on=True): """Tool > Array Mesh on the active SubTool. Each of offset / scale / rotate / pivot is an (x, y, z) triple set through its mode button then X/Y/Z Amount [strings; mode semantics verify]. ArrayMesh the CUTTER, not the result: editing the source updates every instance and the Live Boolean (Pavlovich x1gN9-SocYY 00:09:05, 00:10:13).""" out = {} if on is not None: out["on"] = _switch("am_on", on) out["repeat"] = _set("am_repeat", repeat) for name, mode, vals in (("
-
-
SKILL.md 18.9 KB
--- name: scenario-zbrush-hard-surface description: "Use when modeling hard surface in ZBrush 2026 (sci-fi helmet, weapon, prop, armor plates, mechanical parts, kitbash, vents, panel lines, bolts, concept to game asset), when using ZModeler, Dynamic Subdivision and creasing, Live Boolean, Knife, Clip, Slice or Trim curves, IMM, ArrayMesh, NanoMesh or Panel Loops, or when edges look razor sharp, a Make Boolean Mesh fails or leaves holes, planes look wobbly, Mirror And Weld erased an edit, or ZRemesher ruins a hard-surface part." license: MIT --- # ZBrush hard surface Hard surface is judged as a result: it reads as manufactured when every edge is clearly straight or clearly curved, planes carry an even highlight, edges are wide enough to catch light and every part could be built (Plouffe, Klimer). The agent builds that language from deterministic operations (primitives, Live Boolean stacks, polygroups as the crease plan, Dynamic Subdivision numbers, ArrayMesh, Panel Loops) and uses clicks and strokes only where nothing else exists. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap. **REQUIRED BACKGROUND:** scenario-zbrush-expert (bridge, stroke engine, review loop, 2026 traps). Toolkit: [`scripts/zb_hardsurface.py`](scripts/zb_hardsurface.py) (`import sys; sys.path.insert(0, "<this skill>/scripts"); import zb_hardsurface as hs`, then `hs.hs_call("new_part", "cube")`), built on the lead's `zb_ops`, `zb_review`, `zb_audit` and `zb_stroke`. Nothing in it has run in ZBrush yet. ## Stance (the expert delta) - **Polygroups are the crease plan.** Groups By Normals at 45 degrees (33 catches softer planes, 60 misses edges), Mirror And Weld, UnCreaseAll, Crease PG (Pavlovich; Plouffe). Crease first, a loop only where the falloff must be local. Crease PG after later edits recreases borders you softened on purpose: regroup, or crease by angle. - **The gap SmoothSubdiv minus CreaseLvl sets the edge.** 0 reads razor, "very CG"; the closer CreaseLvl sits to SmoothSubdiv, the tighter the edge, so 4/3 is tighter than 4/2 (`hs.edge_width`). Pavlovich works at 3/2 and bakes 4/2; Plouffe keeps CreaseLvl 3 over a razor 4 and calls 2 too soft. Deciding condition: render both under metal, keep the widest edge that still looks planned. - **QGrid is for boxy blocks** (it facets arcs). Curved spans take Smooth Subdiv plus Crease Tolerance, tuned per span: 22 held Pavlovich's arch; too low creases the arc's small angles. - **Booleans need clean inputs** (Live Boolean doc; Pavlovich): watertight, no coplanar faces, similar density, low Dynamic levels, nothing partly hidden. DSDiv on, one Start group per separate part, then ZRemesher Detect Edges at Half. Creases, UVs, levels and layers die in the boolean; polygroups survive. - **Keep cuts and repeats live.** ArrayMesh the cutter, not the result. Instanced fasteners (NanoMesh, ArrayMesh) make a late "flathead, not Phillips" one Edit Mesh swap; per-seat imports cost one call per seat. - **Straight or curved, never in between** (Plouffe). Polish planes, then edges, then corner softness, sharp to smooth; never mix sharp and round edges close together; one cavity along a border sells a separate plate. - **Function and camera set the detail** (Klimer): how it is made, where power flows, one fastener family, repeated angles identical. Validate twice in engine: the block (500 to 40k polys) with the rig, then a decimated high under 1M points. Unseen interiors and undersides stay filled or simple, sold by dark AO (Pavlovich). - **Micro detail waits for sign-off, on 3D layers** (Klimer): a negative strength carves in, duplicating doubles, and a layer dies with any topology change, so layers come after the last boolean, remesh or Apply [added]. - **Mirror And Weld always copies negative X onto positive X.** Good half on +X: Deformation Mirror first (Pavlovich, Plouffe). Slice, Trim and BRadius ignore symmetry, so every one-sided edit ends here. ## What the agent can and cannot do here | Deterministic (script it) | Click or stroke [verify] | GUI only, or substitute | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Primitives, Unify, placement (`new_part`, `place`, `import_part`); Live Boolean roles (`set_roles`, `roles_from_groups`), DSDiv, Make Boolean Mesh, Show Coplanar and Show Issues | ZModeler: a saved brush or 2026 preset holding the action and crease option, one `canvas_action` at `click_target` (a drag the first time; a tap replays) | Clip, Trim, Slice, Knife (Ctrl+Shift drag; `press_key` is broken): boolean cutters, `fitted_split_groups`, DynaMesh Groups on a group outline, a ZScript `IKeyPress` wrapper (scenario-zbrush-automation) | | Dynamic Subdiv, CreaseLvl, CTolerance, Crease PG, Crease Bevel, Apply | NanoMesh insert: one click on a face of the target group, or on a front-facing plane (the shipped macro's routine) | Gizmo deformers: Deformation sliders on an axis-aligned part | | Groups By Normals, Mirror And Weld, Panel Loops, GroupsLoops, ArrayMesh, NanoMesh sliders, MeshFromBrush (`fastener_from_brush`), masks (`mask_fillet`), ZRemesher, DynaMesh, Decimation | IMM: a synthesized Dots stroke, or Curve Mode on script curves; `imm_ready` first | Retopo brush, Mask By Polypaint dialog, freehand masks, hPolish planing (weak strokes) | ## Establish first | Input | Changes | Default when silent | | --------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | Destination | game (bake to a low), film, print collectible, concept read | game asset baked to one 2048 map (Pavlovich's pistol) | | Camera | first person and ADS, third person, turntable, print framing | third person; detail sized to what reads there | | Budget | low-poly triangles, map size, high-poly cap | report counts and ask; never invent a budget | | Function sheet | how it opens, vents, power, how it is made, fastener family | write one before blocking (Klimer) | | Scale | rigged: real scale in the block; concept: proportion | Unify (about 2 units), Z forward, perspective off (Chervenka); mm briefs via `to_tool_units` | | Moving parts | a hinged visor stays a separate part and Start group | separate | | Painter handoff | Substance Bridge, or OBJ plus an external bake | ask; both are below | ## Workflow 1. **Setup.** Proxy or concept as a SubTool; Unify, Z forward, X symmetry; versioned ZTL before every boolean, DynaMesh, ZRemesher and Apply (no undo from Python). GATE: longest bbox side about 2; ZTL on disk. 2. **Block.** `new_part` and `place`, or `import_part` from a DCC block; body first, cutters below. GATE: forms sheet (`zb_review.review`) against the concept; a rigged asset's block OBJ checked in engine with the rig before detailing. 3. **Shell.** Deformation sliders on primitives, subtractive boxes for openings, Make Boolean Mesh, `zremesh_recipe("after_boolean")`. The sculpted route (DynaMesh 128 to 176, hPolish, TrimDynamic) is concept only. GATE: `hygiene(obj)` ok (it stands in for Check Mesh, which reports through a note); PolyFrame shows even quads. 4. **Crease plan.** `crease_by_groups(45)`, `dynamic_subdiv(intent="working")`; arcs `crease_by_angle`, tuned per span. Loops and insets made with ZModeler take their crease at creation (`zmodeler_plan`: Insert EdgeLoop "Crease"; Inset "Crease New Edges" crisp, "Crease Inner Poly" rounded; defaults are Do Not Crease). GATE: `check_crease`; metal sheet (`review_sheets`) shows soft, light-catching edges, no scalloping, symmetric polygroups. 5. **Cuts and vents.** Cutters `new_part(..., op="sub")` through the surface, never flush; Smooth 3 / CreaseLvl 2 plus crease by angle; `array_mesh` on the cutter. GATE before: `stack_report()["problems"]` empty, `coplanar_report` clean, Show Coplanar snapshot has no red, `array_plan` walls ok. Then `make_boolean_mesh` (short timeout). GATE after: UMesh SubTools equal Start groups; Show Issues snapshot clean; recrease (Crease PG unless a border was softened on purpose); `hygiene` before ZRemesher. Any one-sided edit: `mirror_report`, `mirror_weld(keep=...)`, then `mirror_check` on exports before and after. 6. **Panels and fitted parts.** Plan panels as polygroups first. Real separations: Panel Loops with Plouffe's recipe (`panel_loops`). A visor or hatch that fits its opening: one cutter used twice (`fitted_split_groups`: Intersect in one Start group, Subtract in the other), or DynaMesh Groups on a group outline. Seams: a thin subtractive cutter from `border_polylines` plus `curve_mesh` [verify]. Masks: `mask_fillet` (Klimer's blur, invert, repeated). GATE: `split_copy("groups")` counts panels; gaps constant on the sheet. 7. **Hardware.** One family. ArrayMesh rows; radial sets as ArrayMesh Rotate 360 then `array_commit("nano")` with ZRVar variation (Chervenka); a part from an IMM without a drag: `fastener_from_brush`; no face where a bolt goes: a helper plane. A port across a seam: `seam_port_groups` (Pavlovich). Exact runs: `spaced_points`. GATE: count, orientation and the same fastener everywhere on the sheet. 8. **High-poly commit.** `bake_split()`: Apply at 4/2 on a copy, Dynamic off on the original. Exact chamfer widths: Crease Bevel width or a boolean chamfer, since Polish only gives the minimum (Polycount). GATE: levels = 1 + Flat + Smooth; `edge_language` has no ambiguous lines; `plane_flatness` no wobbly groups; `pinch_report` flags nothing. 9. **Micro detail** (after sign-off). Layers per region (Tool:Layers:New; procedures P15). GATE: `layer_safe(recorded, zb_ops.stats())` before and after each pass. 10. **Low poly and handoff.** Dynamic-off cage; ZRemesher per part (`zremesh_recipe("part")`); quick static prop: `dynamesh_visible(res)` then one `decimate_percent`; or Maya or Blender. Klimer's ZRemesher plus auto-reduction gave "the nastiest mesh I've ever shipped". Painter routes (scenario-zbrush-retopology-export runs them): Substance Bridge Low & High plus Auto-Bake plus Send PolyPaint (`bridge_preflight`), or native Unwrap with crease seams (`unwrap_creases`) then OBJ. GATE: `zb_audit.verdict(..., "game")`; animated parts separate. ## Numbers | Value | Use | Source | | ------------------------------------------------------------------------------ | ------------------- | -------------------- | | Groups By Normals 45 (33 softer, 60 misses) | crease plan | Pavlovich; Plouffe | | Smooth/Crease 3/2 working, 4/2 bake, 4/3 tight, 4/1 soft | edge width | Pavlovich; Plouffe | | Crease Tolerance 45 (22 on one arch) | crease by angle | Pavlovich | | Faces x4 per QGrid, Flat or Smooth step; Apply levels 1 + Flat + Smooth | budgets | DSUB doc | | Cutter cylinders 8, 12, 16, 24 or 32 sides; 36 to 140 before a DynaMesh bake | cutters | Pavlovich; Polycount | | ArrayMesh Repeat 4 to 5 | vents | Pavlovich | | Panel Loops: Loops 1, Polish 0, Bevel 0, Elevation -100, Double, Ignore Groups | plates | Plouffe | | ZRemesher Half + Detect Edges + Adaptive 0 | cut-ready base | Pavlovich | | Keep Groups, Smooth Groups 0, AdaptiveSize 25 | part retopology | Chervenka | | About 3 stars on a panel base | pinch budget | Plouffe | | LazyStep 2 touching (his guess), 2.5 rivets; Curve Step 1 touching | IMM runs | Pavlovich; IMM doc | | DynaMesh 176 exploration; 1000 game shell | resolution | Pavlovich | | 25k of 1.671M = 1.4 to 1.5 percent | one decimation pass | Pavlovich | | 10M polygons per SubTool | high-poly cap | Plouffe | | Block 500 to 40k polys; review high under 1M points | engine checks | Klimer | | 2048 bake, 4 px padding | maps | Pavlovich | ## Quality gates - **Code:** `stack_report` and `coplanar_report` clean; `make_boolean_mesh()["ok"]`; `hygiene`; `check_crease`; `mirror_check`; `edge_language` (no `ambiguous`); `plane_flatness(obj)["wobbly"]` empty; `pinch_report`; `layer_safe`; `bridge_preflight`; `zb_audit.verdict`. - **Visual:** `review_sheets`: MatCap Gray from five views for forms; metal, square-on and 30-degree grazing for planes and edge width; PolyFrame for groups and dotted crease lines. Show Coplanar before and Show Issues after each boolean. Pinches: a low level with dark polypaint and a low-metal MatCap. Judge with [`references/critique.md`](references/critique.md). ## Common mistakes | Mistake | Looks like | Fix | | ----------------------------------- | ------------------------------------ | ------------------------------------------- | | CreaseLvl >= SmoothSubdiv | razor CG edges | `dynamic_subdiv(intent="bake")` | | QGrid on a curved shell | faceted arcs | QGrid 0, Smooth plus `crease_by_angle` | | Cutter flush with the body | coplanar note blocks; holes | cut through; `coplanar_report` | | No Start group or DSDiv off | part sewn to the body; faceted UMesh | `set_roles(... start=True)`; `dsdiv=True` | | Mirror And Weld, good half on +X | edit erased | `mirror_weld(keep="pos")`, `mirror_check` | | Crease PG after softening borders | softened edges razor again | crease by angle, or merge groups first | | Debris after cuts | ZRemesher fails | `hygiene`, hide debris, Del Hidden | | IMM on a SubTool with levels | insert refused | `imm_ready`; level-free placeholder SubTool | | Layer made before a topology change | layer no longer applies | layers after the last boolean or Apply | | Force UV Auto-Unwrap on | every SubTool loses its UVs | `bridge_preflight`; off | ## Handoffs - **Receives:** a concept and brief from the user; an AI mesh from scenario-3d; a DCC block from scenario-maya-modeling or scenario-blender-hard-surface (OBJ, quads and tris); a forms pass from scenario-zbrush-sculpting. - **Delivers:** a versioned ZTL (high SubTools, Dynamic-off cages), OBJ per part with matching high and low names, `stack_report` and the last sheets; to scenario-zbrush-retopology-export for UVs (Unwrap with crease seams, UV Master), Substance Bridge or Multi Map Exporter bakes (a polypaint ID travels by Send PolyPaint or an MME Texture From Polypaint map; OBJ vertex color is not established) and the game low poly; the 2026.1 Retopo brush is a GUI step there; to scenario-maya-modeling, scenario-maya-retopology-uv, scenario-blender-retopology or scenario-blender-uv-baking for a hand-built low; polypaint IDs with scenario-zbrush-paint-render; print parts to scenario-zbrush-pose-print. ## ZBrush 2026 notes - Knife (2021.7 to 2022) with Split To Parts (2024) replaces most Slice and Trim routines; Knife cannot cut holes. - ZModeler 2026.0 creases at creation (Insert EdgeLoop, Inset); 2026.2.1 adds Bevel All, Outer or Inner Edges and fixes the Extrude-instead-of-QMesh default. - Substance Bridge (2026.2.0): Low & High, Auto-Bake, Send PolyPaint; Force UV Auto-Unwrap is global; each send makes a new Painter project. - 2026.2.1 fixes Live Boolean rendering and an Apply crash. ZRepeat It is not shipped: loop SubTools in Python. - Blocking notes (2026.2.1 UI strings): coplanar Make Boolean Mesh; floor Elv on Mirror And Weld; n-gons on import; DynaMesh with levels; the Check Mesh Integrity result [verify]; the Create InsertMesh Append-or-New prompt when the current brush is already an insert brush; the shipped Create Instance Subtool macro ends on a note. - Prefixed labels: `s.SmoothSubdiv`, `P.Panel Loops`, `a.Repeat`, `m.Width`; sections of Tool > Geometry add no path level. Cmd+W quits ZBrush on macOS; Control+W groups. ## References - [`references/expert-notes.md`](references/expert-notes.md): principles by expert, timestamps, deciding conditions; load when planning. - [`references/procedures.md`](references/procedures.md): full bridge procedures; load before writing code. - `references/critique.md`: the rubric; load at every gate. - [`references/gui-paths.md`](references/gui-paths.md): palettes, hotkeys, brushes; load when a step is GUI only. - [`references/sources.md`](references/sources.md): every source, credential, URL and timestamp.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.