Claude Skill

scenario-zbrush-pose-print

Use when posing a sculpt or preparing it for 3D printing in ZBrush: "pose this character", Transpose Master, TPoseMesh, Gizmo posing with masks, ZSphere rig, Proxy Pose, weapon in the hand, stretching after posing, "3D print this", 1/6 scale statue, miniature, resin or FDM, hollo

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

Full trust report

Download scenario-labs-skills-skills_dcc_zbrush_scenario-zbrush-pose-print-dc592af.zip · 82 KB
Part of scenario-labs/skills — 97 skills

Install

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

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

Skill manifest

ZBrush posing and 3D print

Expert level means a pose that stands and reads, transferred onto every SubTool without losing a level, and print parts whose size, walls, fits and cavities were measured in millimeters on the exported files. The stance: turn every decision into a number the agent can check (scale, point order, joint angles, walls, gaps), keep the pose reversible, use strokes only for cleanup. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.

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

Stance (the expert delta)

  • Gaboury: scale is the first decision. SubTool number one is a box at the client's size; nothing may clip out of it, and the whole tool resizes in one step because sizes change late (6 in with 15 points of articulation became 4 in with 8). Agent form: brief_box, zb_print.envelope, set_export_scale(target_mm).
  • Munoz Gomez: pose at the lowest level, several SubTools through Transpose Master, the pose on a 3D Layer. Plates and carried props (weapon, shield, staff) stay IN the TPose mesh as rigid pieces (Auto Groups, one group per loose piece, masked whole); Move Topological clears intersections. Plouffe forbids deforming primitives, not moving them rigidly: never pull a carried prop out and re-place it with one rotation.
  • Maxon doc: two things destroy a transfer: a partially hidden SubTool (Vertex Mismatch) and Gizmo 3D deformers on the TPose mesh (they reorder points; SubTools die on TPose>SubT). Pavlovich: "The order of the vertices is very important." Start from a clean project, save the ZPR while a TPose mesh exists (the Transpose Master data lives there).
  • Plouffe: parts that bend together need the same polygon size at level 1 (mask blur spreads per polygon). Re-place bolts and discs after bending. Spend detail only where the final size and viewing distance show it.
  • Pavlovich: DynaMesh without levels = Proxy Pose plus a ZSphere rig. Location beats size, helpers hold volume, rotate only.
  • Gaboury: never hollow the 9M sculpt. Shell a light DynaMesh copy (128 was enough for a 9 in car), keep its inner wall, Polish it, subtract it from the untouched detailed part in Live Boolean. Create Shell Thickness is not millimeters: measure.
  • Bennett (Hasbro): 1 mm minimum wall (0.85 mm was flagged), 0.5 mm edges, 0.12 mm fit offset for Formlabs parts; thin walls are thickened from the inside so the outside does not change.
  • Gaboury: decimate by eye (about 1M per mesh is plenty; 10k to 20k triangles can still print); masks on the detail that matters (face, hands) steer Decimation Master; pre-process again after any edit; vent every cup on SLA.

Establish first

Input Why it changes the plan Default when the brief is silent
Process: resin (SLA, MSLA), FDM, molded or cast walls, clearance, hollowing, shrink (Hasbro sculpts PVC at 104 %); molds add Hasbro's rules: touching fingers lock the mold, Group Front shows the parting line, Draw Draft Analysis shows draft resin, direct print
Size: ratio and real height, envelope W x H x D, base included or not export scale, brief box, feature floor 1/6 = standing height / 6 (1,800 mm gives 300 mm), measured on the rest pose
Printer volume, slicer caps splits (Gaboury split a car for the Form 2 bed); polycount or file caps (his example: 50 MB) ask; report each part's size
Parts: one piece or kit, glued or pegged cuts and keys (Carratala plans cuts while sculpting) natural breaks (collar, belt, cuffs, weapon at the hand), two keys per glued joint [added]
Pose source; alternate poses route and layers one layer per pose
Deliverable STL has no units; 3MF declares them STL and 3MF per part in mm, JSON report

Posing without a mouse

The artists' default (Ctrl+click a polygroup, drag a Gizmo ring) is a canvas gesture. The SDK cannot read mask data, and press_key is documented but marked "Does not work, hidden for now" in the installed 2026 stub (with canvas_click it is reported unreliable, Maxon forum). Routes by determinism:

Route When How Status
A. Numeric external rig (Munoz Gomez's Blender route, made exact) humanoids, extreme or many poses TPoseMesh, export_tpose, loose_pieces weighted rigidly to one bone each, scenario-blender-rigging poses by angle and returns the same order, import_pose, TPose>SubT deterministic; guards tested offline
B. ZSphere rig by code in ZBrush, organic, or DynaMesh via Proxy Pose joints from polygroup borders, zs_build, forward kinematics zs_pose FK tested; bound mesh following API edits [verify live_p02]
C. Whole-SubTool rigid moves a base, a stand, a prop added after posing rotate_subtool_matrix (X, Y, Z) plus translate_subtool, the carrier's whole transform, corrected from exports math tested; signs [verify live_p03, p08]
D. Mask plus Gizmo or action line cleanup by a computer-use agent or a human Ctrl+click group, Alt pivot, drag weak for this agent

Weak without a human: gesture design, Plouffe's hand-painted gradient masks, large fold redesign. Seam smoothing and Move Topological have agent forms in procedures.md P6, still [verify].

Workflow

zb_pose.call("name", ...) runs inside ZBrush; zb_print runs on exported OBJs; full code in references/procedures.md.

  1. Intake. Versioned ZTL, inventory(), target and envelope in mm. GATE: every SubTool named, unique, with points and levels.
  2. Scale. set_export_scale, export_parts, check_scale, then brief_box (hidden while exporting). GATE: height within 0.5 % [added], one export scale on every part (a part appended from another ZTool arrives at another scale), envelope ok, named dimensions checked with caliper.
  3. Pose prep. clean_project (ZTL saved, default project, Load Tool); polygroup every segment, plates and props left as loose pieces; preflight_tpose. GATE: preflight ok, density spread under 2x [added]. No levels: scenario-zbrush-retopology-export or Proxy Pose.
  4. Pose. tpose_mesh(layer=True, groups=True) with props and plates visible, export_tpose (the order reference), save_project; route A or B, center first, parents before children, rotations only; no Gizmo deformers. GATE: rigid_pieces ok, follows puts each carried prop where its carrier took it (0.5 mm [added]), piece_interference empty or listed for P6, tpose_to_subtools(expected, rest_obj) passes its order guard and point counts, rigid_delta within 2 degrees of the plan [added], balance stands or a base is planned; review sheet: silhouette and weight read (Plouffe: an unstable pose "destroys the entire work").
  5. Fix deformation. New layer at the top level. Seam first while the rest is protected (Munoz Gomez smooths and moves before clearing the mask); then volumes and folds re-sculpted for the new pose (tension from the stretched joint, compression on the inner bend [added]); re-place primitives; Move Topological on pieces; pose-specific detail on a pose-only layer, because layers blend linearly. GATE: stretch_report near the rest pose, piece_interference ok, Polyframe tiles.
  6. Breakdown. Parts at natural breaks; merge what prints as one (Live Boolean union or DynaMesh on light copies: Hasbro engineers on decimated meshes). Molded: loops, parting line, draft. GATE: each part closed, fits the build volume.
  7. Hollow when asked. hollow_copy (insert inside, DynaMesh about 128, Create Shell), keep_inner_shell, Live Boolean: detailed part start, inner shell, drain and vents subtract; thin ends stay solid. Too thin: move the cutter inward, never the outside. GATE: thickness p01 at the wall target, no sealed cavity for resin, outer_drift ok, vents at cups.
  8. Cut and key. Cutter QCube in Live Boolean or a polygroup split then Close Holes [added]; plan_keys; socket = peg grown by 2 x clearance. GATE: clearance median at target, no interference; one key pair test-printed before the kit (Gaboury).
  9. Decimate. Unique names; per-SubTool budgets, masks before Pre-process (mask="cavity" [added]), pre-process after any edit. GATE: faces within budget, deviation p99 under the printer's XY resolution [added], close-ups before and after.
  10. Deliver. STL and 3MF per part in mm, print_report(..., envelope_mm=...), a review tile at real size (detail under the process floor is backfilled, fused or left to paint: Hasbro). GATE: print_report["ok"], every "to look at" item looked at, unit stated.

Numbers

Item Value Source
Minimum wall 1 mm; 0.85 flagged Hasbro, PVC and Formlabs copies
Edges after trimming 0.5 mm; about 0.2 mm floats away Hasbro
Fit offset 0.12 mm (Formlabs); Inflate 2 gave it in his ZTool Hasbro, calibrate per ZTool
Shell copy DynaMesh 64 too low, 128 enough (9 in car); Thickness 4 then 6 Gaboury
From Thickness Min 1, Max 5 once scaled to mm Maxon doc
Decimation about 1M per mesh; 2.7M to 545k at 20 % unchanged; 8k faceted Gaboury
Decimation quality 40 to 100 % near identical, 2 to 40 % visible Maxon doc
Scale with shrink height / 12 + 4 % for a 6 in PVC figure Hasbro
Proxy Pose skin Adaptive Skin Density 1, DynaMesh Resolution 0 Pavlovich

Quality gates

  • Measured (zb_print on OBJs in mm): watertight, volume, thin-wall share (0.2 % of samples [added]), sealed cavities, floating shells, height, envelope, one export scale, build volume, clearance, balance, faces, outer drift. Pose: point order, rigid_pieces, follows, piece_interference, rigid_delta, stretch_report.
  • Visual (scenario-zbrush-expert zb_review.review, MatCap Gray, five views): gesture and balance; joints with Polyframe; primitives round; the grip closed around the handle; the brief box shown once; From Thickness on a copy; every listed spot; decimated close-ups. Judge with references/critique.md.

Common mistakes

Mistake What it looks like Fix
Top-level pose, rough mask, mask cleared too early stretched, overlapping polygons lowest level, blurred mask, Smooth and Move the seam before clearing (Munoz Gomez)
Carried prop pulled out of the TPose mesh, turned by one angle afterwards a sword beside a raised, bent hand; interference 0 still passes keep it in the TPose mesh as a rigid piece; else the carrier's whole transform, gate follows
Partly hidden SubTool at TPoseMesh Vertex Mismatch preflight_tpose (ShowPt on all)
Gizmo deformer on the TPose mesh destroyed SubTools; a point count check misses a reorder rotations or rigs; the order guard refuses the transfer
Session closed with a TPose mesh, no ZPR no way back to the SubTools save_project right after TPoseMesh
Neutral-pose detail shown on the pose soft, stretched detail at bends refine on a pose-only layer (linear blend)
Create Shell numbers read as mm; insert touching the surface wrong walls; one joined piece measure thickness; insert inside, drain as a cutter
Wall thickened from outside the sculpt drifts move the cutter inward, check outer_drift
Mask or edit after Pre-process decimation ignores it pre-process again (the wrapper always does)
No clearance on keys "20 discs to sand before noon" (Hasbro) 0.12 mm offset, measured, test print
STL without a unit 150 mm printed as 150 in say mm, or ship 3MF

Handoffs

  • Receives from scenario-zbrush-sculpting, scenario-zbrush-character-creature, scenario-zbrush-stylized or scenario-zbrush-hard-surface: a versioned ZTL with named SubTools, levels (or a DynaMesh for Proxy Pose), polygroups per part, the last review sheet; from scenario-zbrush-retopology-export, a projected stack when a sculpt had no levels.
  • Sends to scenario-blender-rigging or scenario-maya-expert: the TPose OBJ and a plan (joints, angles, rigid pieces per bone), expecting the same vertex order back.
  • Delivers posed sculpts (layers, ZTL, ZPR, sheets) to scenario-zbrush-paint-render; print packages (STL and 3MF in mm, report, sheets, part and key list) to the user or the printer.

ZBrush 2026 notes

  • Proxy Pose (2023.1) is in Tool > Geometry. Transpose Master still ships ("inactive after a file save" fixed in 2025.2); its data lives in the ZPR. The doc's clean project, DefaultCube.ZPR, is not in the 2026.2.1 install: Lightbox/Projects/DefaultProject.ZPR is used [verify].
  • 3D Print Hub has Export to 3MF (errors fixed in 2026.2.1). Update Size Ratios and Scale Master Set Scene Scale open dialogs: use the export scale route.
  • From Thickness (2026.2.1 labels Min Thickness, Max Thickness, Ball or Ray) asks "Too many polygons... proceed?" above Max Thickness Polygons; that note stalls the bridge, so from_thickness refuses first.
  • Live Boolean drops UVs, creases, 3D layers and masks on the result: boolean the print copy. Decimation Master and Transpose Master are ZScript plugins: check that control returns (live_p01, p07). Cmd+W quits ZBrush on macOS.

References

  • references/procedures.md: bridge procedures with full code (scale and brief box, clean project, Transpose Master, three rig routes, fixes, hollow chain, keys, masked decimation, delivery). Load before driving ZBrush.
  • references/expert-notes.md: principles by expert with timestamps and deciding conditions. Load when planning a pose or a kit.
  • references/critique.md: the rubric for pose, deformation and print parts. Load at every gate.
  • references/gui-paths.md: palettes, buttons, hotkeys for a computer-use agent or a human.
  • references/sources.md: sources, credentials, timestamps, revisions.
  • scripts/zb_pose.py (inside ZBrush) and scripts/zb_print.py (agent side, numpy): offline tests in tests/code/zbrush-pose-print/; live tests live_p01 to live_p08 not yet run in ZBrush.
Files (skills)
  • references
    • critique.md 15.1 KB
      # Critique rubric: pose and print parts
      
      Use at every gate. Score each line pass, fix, or not applicable, and write the evidence next to it (a number from `zb_print` or `zb_pose`, or the review sheet and the tile you judged). A line without evidence is not a pass. Views: scenario-zbrush-expert `zb_review.review` (front, right, back, three-quarter, top) with MatCap Gray; add Polyframe for joints and flat color for silhouettes.
      
      ## 1. The pose (what Plouffe, Pavlovich and Munoz Gomez look at)
      
      | Check                                                                                                         | Evidence                                                                                                                              | Fix                                                                                                             |
      | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
      | Stands steady: weight over the support, nothing reads as falling                                              | `zb_print.balance`: `stands` true, margin in mm; front and side tiles                                                                 | shift the pelvis over the support foot, widen the stance, or plan a base (doc: no floating characters)          |
      | Gesture reads before detail: a clear line of action, silhouette readable from the main view and three-quarter | review sheet, silhouettes                                                                                                             | rotate the torso and pelvis against each other; open limbs away from the body in the main view                  |
      | Pose built from the center outward, parents before children                                                   | the pose plan order, `rigid_delta` of the pelvis and chest                                                                            | re-pose the central mass first (Plouffe, Pavlovich)                                                             |
      | Joint angles match the plan                                                                                   | `rigid_delta` per segment within 2 degrees [added]                                                                                    | re-run the rig step for that chain                                                                              |
      | Nothing unintended moved (planted feet, base)                                                                 | `rigid_delta(...)["max_disp"]` on those vertices near 0                                                                               | mask or exclude them from the rig                                                                               |
      | Plates, weapon, bolts moved as one body each                                                                  | `rigid_pieces`: `ok`, no piece `bent` (rms under 0.05 mm [added])                                                                     | rigid weights to one bone (route A), or keep the piece its own SubTool and carry it (P5)                        |
      | The weapon sits where the hand took it                                                                        | `follows`: residual p95 under 0.5 mm [added], `part_rigid`; `interference` alone is not evidence (a prop left far away also scores 0) | keep it in the TPose mesh; else the carrier's whole transform (`rotate_subtool_matrix`, `placement_correction`) |
      | Grip closed around the handle, nothing passing through (Hasbro re-checks the grip)                            | close-up tile; `interference` 0 when printed separately                                                                               | adjust the fingers; for a mold, fingers must not touch each other or the thumb                                  |
      | The transfer was safe                                                                                         | `tpose_to_subtools` `order_check.same`, point counts; ZPR saved after TPoseMesh; `clean_project` ok                                   | reload the saved ZTL, pose again without Gizmo deformers                                                        |
      | Lineup of a series reads as a family (heights, masses)                                                        | side by side sheet                                                                                                                    | balance heights; big scale gaps suit animation more than statues (Plouffe)                                      |
      
      ## 2. Deformation after posing (Munoz Gomez, Plouffe)
      
      | Check                                                                 | Evidence                                                                                                                | Fix                                                                                                       |
      | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
      | No stretched or overlapping polygons at joints                        | `stretch_report`: stretched share near the rest pose, spots listed; Polyframe tile                                      | blur the transition, Smooth and Move the seam, or re-pose at a lower level                                |
      | The seam was evened while the rest was protected                      | the fix order in the log: seam smoothing (Corrective Smooth band, or Polish under the mask) before the mask was cleared | redo the seam from the pose layer (Munoz Gomez)                                                           |
      | Volumes follow the pose: bent limbs bulge, stretched ones flatten     | compressed and stretched spots against the anatomy; three-quarter tile                                                  | re-volume on a new top-level layer (aimed strokes, Inflate on a built mask)                               |
      | Primitives still perfect (discs, bolts, perfect cylinders, IMM parts) | close-ups                                                                                                               | re-place them on the bent surface instead of bending them                                                 |
      | Cloth, body and armor do not interpenetrate where it shows            | `piece_interference` on the TPose export, interference spots; Polyframe fill colors (Hasbro)                            | Intersection Masker plus masked Inflate [verify], Move Topological stroke on the piece or in the rig plan |
      | Folds follow the new pose                                             | three-quarter tile: tension from the stretched joint, compression on the inner bend [added]                             | re-sculpt on the fix layer, or hand the spot list over                                                    |
      | Detail reads on the pose, not only in neutral                         | close-up at the bends with the pose layer on                                                                            | refine on a pose-only layer: layers blend linearly (Munoz Gomez)                                          |
      | Detail survived the transfer on every level                           | point counts from `tpose_to_subtools`; top-level close-up                                                               | redo the transfer from the saved ZTL; check the layer interpolation (Munoz Gomez)                         |
      | Hidden or shadowed areas got only the time they need                  | where the hours went                                                                                                    | stop polishing what the final view hides (Plouffe)                                                        |
      
      ## 3. Print parts (Gaboury, Hasbro, Carratala, Maxon docs)
      
      | Check                                                            | Evidence                                                                                                                     | Fix                                                                                                                |
      | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
      | Size in millimeters matches the brief                            | `check_scale`: height error within 0.5 % [added]; one export scale on every part; `caliper` on any dimension the brief names | `set_export_scale` again; a part appended from another ZTool is suspect                                            |
      | Nothing clips out of the brief box                               | `envelope` ok (overflow 0 per axis); one tile with the box shown (Gaboury)                                                   | re-pose the limb that pokes out, or ask for the size change and rescale in one call                                |
      | Every part is closed, oriented outward, manifold                 | `print_report` watertight and volume gates; `is_polymesh3d_solid`                                                            | Close Holes, DynaMesh the part, or Live Boolean union; fix flipped faces                                           |
      | One shell per part unless a loose piece is intended              | `shells`: floating solids 0                                                                                                  | merge it or make it its own part                                                                                   |
      | Walls at or above the process minimum                            | `thickness`: thin share, p01, thin spots; From Thickness render on a copy                                                    | move the cutter inward (higher Thickness, or shrink the inner shell), or keep that end solid (Gaboury)             |
      | Hollowing and wall fixes left the outside alone                  | `outer_drift` p99 within 0.02 mm [added] against the solid export                                                            | the fix went outside: redo it on the cutter (Hasbro: "don't change it from the outside")                           |
      | Hollowed the Gaboury way                                         | a light copy shelled, inner wall kept and polished, subtracted from the untouched detailed part; drain and vents as cutters  | never shell the heavy sculpt; never print the 128 copy when detail matters                                         |
      | Smallest features survive: edges, blade tips, fingers, strands   | thin spots at tips; close-ups at real size                                                                                   | edges at least 0.5 mm, strands fused or bunched (Hasbro); tips may taper where the design allows                   |
      | Hollow parts drain and vent                                      | `shells`: sealed cavities 0 for resin; the slicer's layer preview for cups                                                   | drain at the base (insert touching the surface), vents at cup tops (Gaboury)                                       |
      | Cuts sit at natural breaks and hide the seam                     | part list against costume seams, joints, the weapon at the hand                                                              | move the cut to a seam, collar, belt or cuff (Carratala, Hasbro)                                                   |
      | Keys fit, align and hold                                         | `clearance`: median at target, no interference; `plan_keys` ok; two keys or a non-round key where spin matters               | resize the socket from the measured gap; add the second key; mushroom head or slot for retention (Hasbro, Gaboury) |
      | Parts fit the printer                                            | `fits_volume` per part                                                                                                       | split again at another break                                                                                       |
      | Polycount fits the slicer, no visible faceting                   | faces per part; `deviation` p99 within the printer's XY resolution [added]; close-ups before and after                       | raise that part's target; decimate SubTools with detail separately                                                 |
      | Decimation kept density where detail matters                     | per-SubTool budgets or a mask (face, hands) set before Pre-process; pre-processed after the last edit                        | mask, pre-process again, decimate again (doc: later edits are ignored)                                             |
      | Detail sized to the print                                        | a tile at the figure's real size on screen: nothing under the process floor that is meant to show                            | backfill, fuse or leave it to paint (Hasbro); stop detailing what the final view hides (Plouffe, Carratala)        |
      | One key pair test-printed before the kit                         | the physical fit noted in the report                                                                                         | adjust the clearance from the print (Gaboury)                                                                      |
      | Undercuts only matter for molds: parting line clean when molding | Group Front from the mold view, Draw Draft Analysis (Hasbro, doc)                                                            | add draft, split the part, separate touching fingers                                                               |
      
      ## 4. Delivery
      
      | Check                                                                | Evidence                                                    | Fix                                               |
      | -------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------- |
      | Unit stated in the files and the note                                | STL header, 3MF `unit="millimeter"`, delivery note          | write it; STL itself carries none (doc)           |
      | Each file named after its part, one file per part                    | `export_parts` names, report part list                      | re-export with names                              |
      | The report says what was measured and what was only looked at        | `print_report` JSON (`failed`, `to_look_at`), review sheets | complete the missing check or say it was not done |
      | Sources kept: posed ZTL with layers, print ZTL with booleans unbaked | versioned ZTL paths                                         | save them; never deliver from the only copy       |
      
    • expert-notes.md 22.3 KB
      # Expert notes: posing and 3D print
      
      Principles and judgment by expert, with source and timestamp (`[hh:mm:ss]` in the video; doc sections by page). Source notes live in `notes/posing/` and `notes/3d-print/` of the project; the batch digest is `notes/posing/_digest_print_pose_render.md`. `[added]` marks this skill's own translation for an agent without a mouse.
      
      ## Paul Gaboury (Pixologic, then Maxon, product lead; freelance toy artist for Hasbro, Disney Consumer Products, Topps), "Using ZBrush for 3D Printing", Gnomon 2020 (W9P5YmLGBj0)
      
      - **Bounding box first.** "The number one thing if you're going to make something physical... I personally like to always have a bounding box" [00:21:43]. The box at the client's size stays SubTool number one; toggle it to see if anything clips out of the product or package box [02:04:45] [02:08:31].
      - **Resize everything in one step.** Existing sculpt: Set Scene Scale reads the size, declare the unit, New Bounding Box SubTool, R on, type the new height, Resize SubTool with All [00:38:39] [00:41:05]. Clients shrink products late for cost: 6 in with 15 points of articulation became 4 in with 8 [00:39:26]; one penny per unit is real money at 500k to 1M units [02:09:04].
      - **Default world is about millimeters**: the default cube reads 0.99 per side in Set Scene Scale [00:30:59].
      - **Transpose line as a caliper** after Scale Master: major and minor ticks in the chosen unit, set in Preferences > Transpose Units; the line snaps to points [00:34:29] [00:35:13]. (This install logs Minor Ticks 10, Major 1, Calibration Distance 0.25, Units Scale 1.)
      - **DynaMesh resolution is per side of the bounding box**, so it is relative to physical size [00:58:07] [01:01:53].
      - **Hollow a light copy.** "Even for my hollowing out when I have a mesh that is 9 million, I don't try to hollow out that 9 million polygon mesh" [00:57:33]. Duplicate, DynaMesh 64 (too low) then 128 (enough for a 9 in car) [00:56:58], one polygroup, a negative IMM cylinder pulled away from the surface so it leaves no hole, Create Shell [01:03:10] [01:05:29]; Thickness 4, then 6 for a thicker wall [01:10:32]. Auto Groups separates outer and inner shells, Split Groups, keep the inner, Polish it [01:12:44] [01:14:58], and use it as a Live Boolean subtract so the outer car stays untouched [01:13:48]. Do not shell thin ends: delete the inner shell there [01:15:30].
      - **Cups and vents.** Slide a cutting cube through the Live Boolean like the slicer's layer slider; any cup is a vacuum risk on SLA: add air holes [01:34:49] [01:35:21].
      - **Split for the bed**: the car body did not fit the Form 2, so two pieces with snap joints; glue only guarantees a clean seam [00:46:40] [00:49:27]. Snap joints double as alignment keys even if you glue [00:46:40].
      - **Snap joint build**: IMM cylinder at the cut plane, Split Unmasked Points, Dynamic Subdivision with Crease tolerance 45 degrees, ZModeler loops to taper; SmoothSubdiv 4 with Crease Level 3 gives a small fillet, equal values give razor edges [01:41:41] [01:44:55]. A slot cut in the male peg gives flex (deeper = more give) [01:47:38]; the socket gets play with Inflate 10, then a second Inflate [01:51:32]; test print in the target resin before the full job [01:53:10].
      - **Stay non-destructive**: sources in folders, cut with Live Boolean start groups, Boolean Folder only at the end; "all the individual meshes before the booleans come together are still all separate" [02:05:48].
      - **Decimation by eye**: "Usually a million is more than enough per mesh... I bring stuff all the way down to like 20,000 triangles, 10,000 triangles and it holds up really well" [01:11:40]; "If there's nothing changing on the model visually then it's not going to print out any differently" [01:12:12]. Example: 2.7M to 545k at 20 percent, no visible change [01:22:53]; 8k on a detailed sphere is faceted [01:28:19]. Mask regions before Decimation Master or Tessimate to keep density there [01:30:30] [01:32:07]. Some slicers cap polycount or file size (example 50 MB) [01:11:40]. Decimation Master caches grow to many GB; Delete Cache at Startup is on by default [01:22:20].
      - **Only ZBrush and PreForm** for any print job [02:07:25].
      - Agent translation [added]: the height becomes an export scale (`set_export_scale`, the state Scale Master's Resize with All plus Unify leaves: real size in the Export Scale, doc); the bounding box stays a SubTool (`brief_box`) gated by `zb_print.envelope`; the caliper becomes `zb_print.caliper` (ends snap to vertices, as the line snaps to points), `check_scale` and `thickness`; the shell chain becomes `hollow_copy` (copy moved to the end so MergeDown takes the insert), `keep_inner_shell`, a Live Boolean subtract and `outer_drift`; the joint test print stays physical; the cup preview stays a slicer check.
      
      ## Paul Bennett (Hasbro sculpture department, Star Wars Black Series), ZBrush Summit 2019 (P08kuTIRziE)
      
      - **Every sculpt is a product for injection molding**: undercuts, walls, tooling and plastic shape it from the start [00:01:12]. About $10,000 per tool, about $100,000 of tooling for one 6 in figure [00:02:13].
      - **Walls**: "You really don't want to go any lower than a millimeter" [00:10:57]; 0.85 mm at the shoulder was flagged and fixed by inflating inward, taking volume from the hidden shoulder ball: "Fix it, but don't change it from the outside" [00:16:46]. Edges at least 0.5 mm after trimming [00:48:28]; about 0.2 mm floats away on the Formlabs tray [00:11:31]. Short shots: features too small for plastic to fill [00:03:21].
      - **Offsets**: "For something like Formlabs we generally look at it like a 0.12 millimeter offset on most things" [00:37:55]. He duplicates the part, Inflates it, measures with the Transpose line until it reads 0.12 mm (Inflate 2 in his ZTool, 1 gave about half) and uses it as the cutter [00:53:04] [00:56:20]. Appending into a ZTool with another unit space breaks the calibrated value [00:56:51]. Without offsets, "20 discs to sand" [00:37:55].
      - **Scale**: actor height / 12 plus 4 percent for a 6 in figure (PVC shrink), / 18 for 3.75 in [00:12:54] [00:17:51] [00:57:52]; say which scale a file is at when trading files [00:17:51]. Height read with 3D Print Hub Update Size Ratio: 151.6 mm [00:17:17].
      - **Articulation is spheres and cylinders**: "All articulation, all of it, boils down to either a sphere or a cylinder" [00:07:36]. Primitives at the dead center of the sphere so the Gizmo rotates about the right axis [00:40:08]; pegs into "the meat", mushroom heads so they cannot pull out [00:40:42]; a backstop against hyperextension [00:45:09]; elbow over 90 degrees minimum, 115 target [00:08:40] [01:06:02]; double joints give range but can look "run over by a truck" [01:06:33]; pilot holes for prototype pins [01:18:03]. Factory ball joints trap the ball nearly to the stem: thinner stems keep range [01:03:51].
      - **Decimate high-res parts almost immediately** once happy, then engineer on the decimated mesh with Live Boolean or polygroups [00:25:28]. Keep Live Boolean hierarchies to one or two operations [00:47:15].
      - **Molding checks that also help prints**: touching fingers make a closed loop the mold cannot release [00:19:34]; hair as a separate keyed part for undercut detail, flyaway strands fused because of minimum wall [00:20:41]; Group Front from the view of one mold half shows the parting line [01:24:32].
      - **Sculpt the standing pose the toy ships in**, likeness first, joints cut after [00:19:34] [00:33:06]. Camouflage joints: pull the costume over, project the cloth detail onto the disc [00:45:42] [01:12:39]. Collisions show with Polyframe fill colors or flipped normals [01:09:16]. Axis memory: switch to the Transpose line before changing SubTool, the Gizmo then adopts the line's position [01:10:26].
      - **Detail at real size**: viewed at the figure's size, detail that cannot be captured at 6 in (eyelashes) is backfilled or left to deco (tampo or inkjet) [00:09:47] [00:36:15]; the Move brush with Backface Masking thickens an edge without disturbing the inside surface [00:51:53].
      - Agent translation [added]: primitive pegs are sized exactly with Geometry Size sliders (clearance = 2 x 0.12 mm on diameter), and the offset is measured with `zb_print.clearance` instead of the Transpose line; Inflate calibration stays for sculpted cutters; "don't change it from the outside" becomes the `outer_drift` gate on every wall fix.
      
      ## Alex Carratala (sculptor of about 90 mm collectibles, Kimera Models), 80.lv interview 2023
      
      - Pose at the very start on a mannequin with clay chunks; "A good silhouette is the result of proper posing, not something you can work on upfront" (Tips on Silhouettes); zero symmetry except mech parts and props (step 3).
      - Keep shapes sharp and raw, smooth at the very end (step 4); sculpt with the cuts in mind (step 5): "I cut the piece, decimate every part resulting, I scale the model into real scale, and send them in STL to the client" (step 6). Kits range from 7 to more than 32 parts; price the engineering stage separately (Rules and Intricacies).
      - Over-sculpting for the scale is the usual failure: remind yourself of the final size daily (Rules and Intricacies).
      
      ## Pablo Munoz Gomez (concept and character artist, Pablander Academy, ZBrushLIVE), "A very cool trick to pose characters in and outside ZBrush" 2021 (oxyKKr4aHPY)
      
      - "The proper method is to go to the lowest subdivision level" [00:07:44]; "What you want to achieve in the posing stage is as little distortion as possible" [00:08:49]. Wrong way at level 5 with a careless mask: stretched and overlapping polygons, seen with Polyframe, fill off, Skin material [00:06:40].
      - Mask, BlurMask several times [00:04:26], unlock the Gizmo pivot with Alt, place it at the joint [00:05:00], rotate, then Smooth and Move the transition before clearing the mask, flip the mask and do the other side [00:07:44] [00:08:16]: the order matters, the mask protects the rest of the limb while the seam evens out. Bent arms bulge, stretched ones do not: re-volume after [00:09:55].
      - Beyond a few SubTools, masking each is "very boring": Transpose Master [00:13:26] [00:14:00]. In the TPose mesh, Auto Groups gives one group per disconnected piece, so hard-surface plates are isolated, inverted and masked whole, then the rest is shown and refined [00:15:37] [00:16:14] [00:16:46]: the plates move rigidly with their segment instead of being cut by the bend. Move Topological (B, M) moves only the connected piece under the cursor and clears intersections between pieces [00:17:24] [00:17:56]; missing groups are made by hiding edge loops then Auto Groups [00:18:30]. TPose>SubT transfers "in two seconds" and every level keeps its detail [00:21:51].
      - **External pose** [00:25:28] to [00:44:44]: export level 1 as OBJ, rig in Mixamo or pose bones in Blender, export only the mesh with modifiers applied, then on a clone at the highest level: new layer (records), level 1, Import. "You must use the same mesh or the same object you exported" [00:39:55]; importing at the wrong level replaces the SubTool and kills its levels [00:40:59]. Layers only record at the highest level [00:45:23]; layers interpolate linearly, so refine detail on a pose-only layer [00:50:20]; one layer per pose lets a studio compare poses in one file [00:50:54].
      - Agent translation [added]: this is route A of the skill, with `same_order` and the point guard in `import_pose` replacing "hope it matches", and `rigid_delta` checking each bone angle. Auto Groups becomes `zb_print.loose_pieces` on the export (the Grps segment groups survive), rigid weights to one bone per piece, and the gates `rigid_pieces` and `follows`; Move Topological becomes `piece_interference` plus a nudge in the rig plan or a synthesized Move Topological stroke; the seam cleanup under the mask becomes a Corrective Smooth on the joint bands in Blender, or Polish while a mask is still on.
      
      ## Michael Pavlovich (Director of Character, Weapon and Vehicle Art, Certain Affinity; ZBrushLIVE), "Proxy Pose ZBrush 2023.1 with ZSphere Rigging" (irnuxmVuF1Y)
      
      - For organic meshes a ZSphere rig behaves like smooth bind and saves most masking [00:00:31]. Proxy Pose with defaults on the heavy DynaMesh [00:00:00]; Make PolyMesh3D gives an exact copy with the same vertex order [00:01:03] [00:08:00].
      - Rig: root at the hips, a very small draw size so one ZSphere does not grab its neighbors [00:02:01] [00:02:32]; "The location of the ZSpheres is more important, because that is where their active influence is" [00:03:36]. Separate skull and jaw, an extra neck sphere, stop at the wrist for a character and use masks for fingers [00:04:38] [00:05:41].
      - Select Mesh picks the copy, Bind Mesh [00:08:00] [00:09:02]. Test every chain: lats pulled by the arm (add chest ZSpheres off the spine), tail stretching with the legs (add a tail chain), missing wrist [00:09:33] to [00:12:50].
      - Pose from the center, parents first, balance and weight, then wrists, fingers, jaw [00:13:26] [00:14:29]; "Overall, I just stick to the rotations" (moving or scaling bones stretches non-uniformly) [00:13:58].
      - Adaptive Skin Density 1 and DynaMesh Resolution 0 keep the bound vertex order; Overwrite Pose picks the only mesh with a matching order; exiting Proxy Pose puts the pose on the high DynaMesh [00:15:50] to [00:16:53]. "The order of the vertices is very important. You must not violate it" [00:08:31].
      - Agent translation [added]: the SDK's `add_zsphere` / `set_zsphere` inside `edit_zsphere` place joints by numbers (joint centers from polygroup borders) and forward kinematics rotates chains; whether a bound mesh follows is live_p02.
      
      ## Marco Plouffe (co-founder and art director of Keos Masons; sculptor for Sideshow, Prime 1, XM Studios), Outgang excerpt 2022 (lakCNFUOQlI)
      
      - Pose the blockout early (1 to 2 hours), polish in the symmetrical A pose, final pose last (1 to 3 days with fixes) [00:14:44] [00:26:18]: "I see a lot of people posing characters and the pose destroys the entire work" [00:25:45]. Pose the connected central section first, then everything on top of it [00:26:51].
      - Blockout on one SubTool, polygroups per section (even the foot), three polypaint tones for layers of the design [00:09:14] [00:15:59]. In Transpose Master, group all armor plates of a segment together, the anatomy under it in its own group, subgroups for thigh and tibia [00:27:24].
      - **Equal lowest-level resolution**: press All Low, compare the square sizes of parts that move together, delete a lower level or go up one to match [00:28:30] [00:29:36], because "The spread of the blur depends on the size of your polygon" [00:30:08]. Better: paint the mask by hand with light pressure (falloff independent of density) [00:30:41].
      - Plan mechanical joint axes with helper "calculation meshes" before posing [00:31:44]. "Just keep your primitive shapes unstretched... a lot can actually be bent and stretched and you're not going to see it" [00:33:55]; re-place IMM bolts on the bent surface [00:33:23].
      - Final viewing distance decides subdivision [00:18:51]; polished focus areas next to rough ones [00:23:15]; hidden areas get first-try design [00:23:48]; a series of statues must read as a lineup [00:07:34].
      - Agent translation [added]: `preflight_tpose` reports the level-1 edge-length spread; hand-painted gradient masks are the one expert technique here the agent cannot reproduce, so equalizing density matters more for it than for Plouffe.
      
      ## Maxon documentation (ZBrush 2026 online help, pages from 4R6 to 2023)
      
      - **Posing** (`sources/docs/paint-pose-render-print__posing-transpose-master-zsphere-rig.md`): Gizmo topology masking with Ctrl-drag (auto-blurred, best on clean topology); in Gizmo mode Ctrl+click a polygroup masks everything else; Transform All Selected SubTools with Ctrl+Shift+click selection; action line with Shift for 15 degree steps; TPoseMesh warnings: "Make sure that no SubTools are partially hidden" (Vertex Mismatch) and "It is best to avoid using the Gizmo3D modifiers to pose your model as the point order can be changed" (SubTools destroyed on transfer); Transpose Master options Grps, Layer, ZSphere Rig, StoreTM and PasteTM Rig; data saved in the ZPR since 4R6, so save the project to continue posing next session (Saving/Loading); clean project tip: save the tool as ZTL, load DefaultCube.ZPR, Load Tool, then pose, which avoids transferring onto the wrong model when parts of it also exist as separate tools (Tips; the 2026.2.1 install has DefaultProject.ZPR and Cube.ZPR, not DefaultCube.ZPR, local check); Expert Tip: for dense meshes without levels, ZRemesher, levels and projection first. Proxy Pose: Reduction Amount, Keep Details (set before entering), Polish, Freeze Border, Keep Current, Overwrite Pose, Proxy Groups.
      - **3D print** (`sources/docs/paint-pose-render-print__3d-print-hub-scale-decimation.md`): "ZBrush does not natively work with units"; "The STL file format maintains scale but doesn't internally specify units"; preparing the model (watertight, outward normals, polycount, supported, no fragile ropes, hollow and cut to save cost); Scale Master (Set Scene Scale converts to mm, Unify to XYZ Size 2 with the real size in the Export Scale, Resize with All, New Bounding Box Subtool, Export to Unit Scale); 3D Print Hub (Update Size Ratios, size basis visible SubTools, Use Size of Selected Subtool, Move Bounding Axis to Origin, STL binary default, VRML carries color, OBJ textures only, Export to Separate Files); PolyPaint From Thickness (Min 1, Max 5 once scaled; red under the minimum; fix with Inflate or ClayBuildup with Backface Masking); Draw Draft Analysis and MaskByDraft; Decimation Master (unique names, pre-process then decimate, 40 to 100 percent near identical, masks move density not count, polygroups lost, caches, per-SubTool quality).
      
      ## Where they disagree, and what decides
      
      | Choice                 | Option A                                                             | Option B                                                                                                            | Deciding condition                                                                                         |
      | ---------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
      | When to pose           | first, on a mannequin, no symmetry (Carratala)                       | A pose polish, blockout posed early, final pose last (Plouffe); standing pose forever for articulated toys (Hasbro) | static miniature where the pose is the design, versus a many-part statue, versus a toy that ships standing |
      | Keep symmetric editing | pose on a 3D Layer, detail on a neutral layer (Munoz Gomez)          | pose baked, asymmetric finishing in the pose (Plouffe)                                                              | alternate poses or late symmetric edits wanted                                                             |
      | Posing engine          | Transpose Master on SubTools with levels (Munoz Gomez, Plouffe, doc) | Proxy Pose plus ZSphere rig on DynaMesh (Pavlovich)                                                                 | does the sculpt have levels and several SubTools?                                                          |
      | Rig                    | in ZBrush masks                                                      | external auto-rig and bones, imported on a layer (Munoz Gomez)                                                      | extreme or many poses, numeric control; for this agent, route A by default [added]                         |
      | Mask softness          | BlurMask passes (Munoz Gomez)                                        | hand-painted gradient (Plouffe)                                                                                     | mixed level-1 densities favor painted masks; the agent equalizes densities instead [added]                 |
      | Decimation depth       | about 1M per mesh, down to 10k to 20k if unchanged (Gaboury)         | a fixed point budget per SubTool (a ZBrushCentral user: 75k)                                                        | slicer caps, print scale, FDM versus resin                                                                 |
      | Hollowing              | Create Shell on a 128 copy, inner shell subtracted (Gaboury)         | in the slicer (Gaboury concedes Cura works [01:19:03])                                                              | designed drain and vent placement, thin ends solid, client wants hollow files                              |
      | Wall number            | 1 mm floor (Hasbro, doc example)                                     | Create Shell Thickness 4 to 6 (Gaboury)                                                                             | Gaboury's number is DynaMesh-relative: convert by measuring                                                |
      | Joint                  | single elbow, about 115 degrees                                      | double elbow or knee, more range                                                                                    | looks versus range and pin space (Hasbro)                                                                  |
      
      ## Agent additions used in this skill [added]
      
      - Export scale as the dialog-free scale route, gated by the OBJ height; `#Auto scale` header equality across parts.
      - Wall and feature thickness by ray casting on the OBJ (the Ray method; ZBrush's own From Thickness offers Ray and Ball in 2026.2.1) with thin spots clustered for the review.
      - Clearance measured between peg and socket exports; Inflate calibration as a least-squares slope with a linearity check.
      - Keys at the section's pole of inaccessibility; two keys on elongated sections; peg length bounded by the socket side's depth minus the wall.
      - Balance as center of mass over the footprint's convex hull (a proxy for Plouffe's "steady on its feet").
      - Joint centers from polygroup borders; per-segment rigid fits (Kabsch) to check angles; edge-length ratios to find stretch.
      - Sealed-cavity detection from shell orientation; for resin a failure until a drain exists.
      - Refactor pass 2026-09-24, after the Z5 blind grade: a point-order guard before TPose>SubT (face-list hash of the rest and posed TPose exports), `clean_project` and `save_project`, loose pieces and rigid-piece checks, `follows` (a carried part against its carrier's transform), a full rigid placement (three one-axis rotations plus a measured translation), `piece_interference`, `brief_box` and `envelope`, `caliper`, `hollow_copy` and `keep_inner_shell`, `outer_drift`, masks before Pre-process.
      
    • gui-paths.md 24.8 KB
      # GUI paths, hotkeys and settings: posing and print
      
      For a computer-use agent or a human doing the same procedures by hand. Palette paths as ZBrush shows them; the item path an API call takes is in `scripts/zb_pose.py` `PATHS`. Evidence: [doc] Maxon help, [strings] label present in the 2026.2.1 UI resources, [log] ZBrush Activity log 2026-09-24 on this Mac, [macro] shipped macro, [expert] shown in a source video. On macOS, doc "Ctrl" can mean Command or Control depending on the shortcut [verify each]; Cmd+W quits ZBrush, Control+W groups.
      
      ## Posing
      
      | Task                                             | Path or gesture                                                                                                                  | Notes                                                                 |
      | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
      | Gizmo move, scale, rotate                        | W, E, R                                                                                                                          | Gizmo 3D is the default handle [doc]                                  |
      | Toggle Gizmo and Transpose action line           | Y                                                                                                                                | [doc]                                                                 |
      | Move the Gizmo pivot without moving the mesh     | hold Alt, or the lock icon                                                                                                       | [expert Munoz Gomez] [strings "Alt to Unlock"]                        |
      | Gizmo topology mask                              | Ctrl+drag the Gizmo                                                                                                              | follows topology, auto-blur; reworked in 2024 [doc]                   |
      | Mask all but one polygroup                       | Ctrl+click the polygroup in Gizmo or Transpose mode                                                                              | [doc]                                                                 |
      | Isolate a polygroup                              | Ctrl+Shift+click it; Ctrl+Shift+click empty canvas shows all                                                                     | [expert]                                                              |
      | Move several SubTools as one                     | Gizmo icon "Transpose All Selected Subtools"; Ctrl+Shift+click SubTools to (de)select                                            | [doc] [strings]                                                       |
      | Action line snap                                 | hold Shift: 15 degree steps                                                                                                      | [doc]                                                                 |
      | Bones mode                                       | line as the bone above the bend, Alt+drag its end                                                                                | no mask needed [doc]                                                  |
      | Mask by lasso, invert, blur                      | Ctrl+drag lasso; Ctrl+click empty canvas inverts; Ctrl+click on the mesh blurs                                                   | [expert]                                                              |
      | Mask buttons                                     | Tool > Masking > BlurMask, Inverse, Clear, MaskAll (visible part only)                                                           | [doc] [macro]                                                         |
      | Unhide everything                                | Tool > Visibility > ShowPt                                                                                                       | the GrpUnMasked macro uses it after HidePt [macro]                    |
      | Groups for posing                                | Tool > Polygroups > Auto Groups (one per loose piece), Group Visible; Control+W                                                  | [doc]                                                                 |
      | Clear intersections between pieces               | Move Topological brush (B, M, then pick it): moves only the connected piece under the cursor                                     | [expert Munoz Gomez]; `ZData/BrushPresets/Move Topological.ZBP` ships |
      | Keep plates and a weapon rigid in the TPose mesh | Tool > Polygroups > Auto Groups (one group per loose piece), Ctrl+Shift click to isolate them, invert, mask them whole, show all | [expert Munoz Gomez]                                                  |
      | Brush confined to one polygroup                  | Brush > Auto Masking > Mask By Polygroups (the shipped toggle sets it to 100)                                                    | [macro Toggle Mask By Polygroups]; exact falloff [verify]             |
      | Gizmo deformers                                  | the Gizmo's deformer list (Bend Arc, Bend Curve, Twist, Taper and the rest): not on a TPose mesh                                 | point order changes, SubTools destroyed on transfer [doc]             |
      | Mask where SubTools cross                        | Zplugin > Intersection Masker > Create Intersection Mask                                                                         | [strings] [verify behavior]                                           |
      | Stretch check                                    | Transform > PolyF (Polyframe), fill off, a Skin material                                                                         | [expert Munoz Gomez]                                                  |
      
      ## Transpose Master (Zplugin > Transpose Master)
      
      | Button                                                                                                                                                                                                                                                                                                                                                                                                                                                                | Effect                                                                                        |
      | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
      | TPoseMesh                                                                                                                                                                                                                                                                                                                                                                                                                                                             | combined low mesh of all visible SubTools at their lowest level, new tool TPose1_<name> [doc] |
      | TPose>SubT (resource label "TPose\|SubT")                                                                                                                                                                                                                                                                                                                                                                                                                             | transfers the pose back [doc] [strings]                                                       |
      | ZSphere Rig                                                                                                                                                                                                                                                                                                                                                                                                                                                           | pose the combined mesh with a ZSphere rig [doc]                                               |
      | Grps                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | keep existing polygroups; off gives one group per SubTool [doc]                               |
      | Layer                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | a new 3D Layer on every SubTool for the transferred pose [doc]                                |
      | StoreTM Rig, PasteTM Rig                                                                                                                                                                                                                                                                                                                                                                                                                                              | reuse a saved rig [doc]                                                                       |
      | Before TPoseMesh: every SubTool fully visible, no Gizmo deformers on the TPose mesh [doc]. Clean project: Tool > Save As, open a default project (the doc's DefaultCube.ZPR is not in 2026.2.1; LightBox > Project has DefaultProject.ZPR and Cube.ZPR, local check), Tool > Load Tool [doc]. Opening a project deletes every loaded tool, so save first. Save the project (File > Save As, .ZPR) while a TPose mesh exists: Transpose Master data lives in it [doc]. |
      
      ## Proxy Pose and ZSphere rigs
      
      | Task            | Path or key                                                                                                                                                                      |
      | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Proxy Pose      | Tool > Geometry > Proxy Pose: Proxy Pose, Reduction Amount, Keep Details (set before entering), Polish, Freeze Border, ProxyGroups, Keep Current, Overwrite Pose [doc] [strings] |
      | Same-order copy | Tool > Make PolyMesh3D while in Proxy Pose [expert Pavlovich]                                                                                                                    |
      | ZSphere editing | Q draw, W move, E scale, R rotate, S draw size (keep it tiny), X symmetry, A adaptive preview [expert Pavlovich]                                                                 |
      | Handles         | R on a joint rotates it and its children; drag the link to twist about the bone; E on the bone scales children, on the joint only that joint [expert Pavlovich]                  |
      | Bind            | Tool > Rigging > Select Mesh (tool popup), Bind Mesh; Use Classic Skinning for magnet spheres [doc] [strings]                                                                    |
      | Overwrite skin  | Tool > Adaptive Skin > Density 1, DynaMesh Resolution 0, Preview (A) [expert Pavlovich]                                                                                          |
      | Equal sizes     | Stroke > Curves > Scale ZSpheres to Draw Size [expert Pavlovich]                                                                                                                 |
      
      ## Layers and checkpoints
      
      | Task                | Path                                                                                                |
      | ------------------- | --------------------------------------------------------------------------------------------------- |
      | New recording layer | Tool > Layers > New (at the highest level: layers only record there) [strings] [expert Munoz Gomez] |
      | Layer slider, bake  | Layer Intensity; Bake All [strings]                                                                 |
      | Morph checkpoint    | Tool > Morph Target > StoreMT [macro]                                                               |
      | Posed OBJ back in   | highest level, Layers > New, lowest level, Tool > Import (same mesh only) [expert Munoz Gomez]      |
      
      ## Scale and measuring
      
      | Task              | Path                                                                                                                     | Notes                                                                                                         |
      | ----------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
      | Scene scale       | Zplugin > Scale Master > Set Scene Scale (dialog), unit buttons mm, cm, in, ft                                           | converts the tool to mm [doc]                                                                                 |
      | Resize            | Sliders to Subtool Size, X / Y / Z with R (ratio lock), Resize Subtool, All                                              | Alt+click Resize skips Unify [doc]                                                                            |
      | Helpers           | New Subtool, New Bounding Box Subtool, 1 Unit Helper, ZBrush Scale Unify, Export to Unit Scale, Center Subtools to World | [doc] [strings]                                                                                               |
      | Export scale      | Tool > Export > Scale, X / Y / Z Offset                                                                                  | what Scale Master stores after Unify [doc] [strings]                                                          |
      | Size of a SubTool | Tool > Geometry > X / Y / Z Size, XYZ Size; X / Y / Z Position                                                           | the Snap To Ground macro sets Y Position = -(Y Size / 2) [macro]                                              |
      | Caliper           | Transpose line after Scale Master; Preferences > Transpose Units                                                         | this Mac: Minor Ticks 10, Major Ticks 1, Calibration Distance 0.25, Units Scale 1 [log]; Gaboury used Minor 4 |
      | Rotation steps    | Preferences > Transpose > Rotation Steps                                                                                 | 18 on this Mac [log]; Mask Blur Strength 24 [log]                                                             |
      
      ## Hollowing, walls, draft
      
      | Task                 | Path                                                                                                                                                                                  | Notes                                                                                                                                    |
      | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
      | Shell                | Tool > Geometry > DynaMesh > Thickness, Create Shell (needs a negative insert)                                                                                                        | Thickness relative to the resolution [doc]                                                                                               |
      | Negative insert      | B, I, IMM Primitives, hold Alt while drawing; Gizmo it inside for a closed shell, touching for a hole                                                                                 | [expert Gaboury]                                                                                                                         |
      | Keep the inner wall  | Tool > Polygroups > Auto Groups, Tool > SubTool > Split > Groups Split, keep the inner piece, Tool > Deformation > Polish, set it to Subtract under the detailed part in Live Boolean | [expert Gaboury]                                                                                                                         |
      | Thin ends stay solid | isolate that polygroup of the inner shell (Ctrl+Shift click), Delete Hidden                                                                                                           | [expert Gaboury]                                                                                                                         |
      | Wall colors          | Tool > Polypaint > From Thickness; 2026.2.1 labels Min Thickness, Max Thickness, Thickness Range, Ball or Ray                                                                         | doc 2020: Min Range 1, Max Range 5 after scaling; colors in Preferences > Analysis; Max Thickness Polygons 8 on this Mac [log] [strings] |
      | Thicken from inside  | Inflate or ClayBuildup with Brush > Auto Masking > BackfaceMask                                                                                                                       | [doc] [expert Hasbro]                                                                                                                    |
      | Draft for molds      | Transform > Draw Draft Analysis; Tool > Masking > MaskByDraft; Tool > Polygroups > Group Front (parting line)                                                                         | orthographic view [doc] [expert Hasbro]                                                                                                  |
      | Close holes          | Tool > Geometry > Modify Topology > Close Holes                                                                                                                                       | [strings]                                                                                                                                |
      | Check integrity      | Tool > Geometry > MeshIntegrity > Check Mesh Integrity, Fix Mesh                                                                                                                      | answers in a note [strings]                                                                                                              |
      
      ## Cutting, keys, booleans
      
      | Task                    | Path                                                                                                                            | Notes                      |
      | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
      | Live Boolean            | Render > Render Booleans > Live Boolean; SubTool icons Add, Subtract, Intersect, Start                                          | [strings]                  |
      | Bake booleans           | Make Boolean Mesh (new tool); folder gear > Boolean Folder (new SubTools, sources kept)                                         | [strings] [expert Gaboury] |
      | Cutter box              | Tool > Initialize > QCube, or Macro > Misc > Append a QCube Subtool; the Gizmo gear swaps a mesh for a parametric shape         | [macro] [expert Gaboury]   |
      | Peg                     | Tool > Initialize > QCyl X / Y / Z with X / Y / Z Res                                                                           | [strings]                  |
      | Split                   | Tool > SubTool > Split > Split Unmasked Points, Groups Split, Split To Parts, Split Hidden                                      | [strings]                  |
      | Order SubTools          | Tool > SubTool MoveUp / MoveDown (Shift: to first or last); MergeDown merges with the SubTool below                             | [strings]                  |
      | Duplicate, move folders | Ctrl+drag the Gizmo duplicates; Ctrl+F puts selected SubTools in a folder; Zplugin > SubTool Master > Copy Folder, Paste Folder | [expert Gaboury]           |
      | Snap joint fillet       | Dynamic Subdiv with Crease Level one below SmoothSubdiv (3 and 4)                                                               | [expert Gaboury]           |
      | Place on a surface      | Tool > Contact (C1, C2, C3, Apply), 2023.2                                                                                      | [doc via version deltas]   |
      
      ## Decimation and export
      
      | Task                  | Path                                                                                                                                                                                                                                                                                                                                                     | Notes                                                                  |
      | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
      | Decimation Master     | Zplugin > Decimation Master: Options (Keep UVs, Freeze borders, Use and Keep Polypaint, Polypaint weight), Pre-process Current / All, % of decimation, k Polys, k Points, Decimate Current / All, Presets 20 k to 250 k, Delete Caches, Export All SubTools                                                                                              | unique SubTool names; pre-process again after any edit [doc] [strings] |
      | Tessimate             | Tool > Geometry > Tessimate (respects masks)                                                                                                                                                                                                                                                                                                             | [expert Gaboury]                                                       |
      | Masks without a brush | Tool > Masking > Mask By Cavity, Mask PeaksAndValleys (set before Pre-process)                                                                                                                                                                                                                                                                           | [strings]; as decimation masks [added]                                 |
      | 3D Print Hub          | Zplugin > 3D Print Hub: Update Size Ratios (dialog), mm or inch, X / Y / Z (mm), Use Size of Selected Subtool, Move Bounding Axis to Origin (For All Tools, Per SubTool), Check Mesh Volume, All / Visible / Selected, Export to Separate Files, Binary or ASCII, Export to STL / 3MF / OBJ / VRML, Send to Preform, Import STL File with Weld threshold | 3MF export exists in 2026 [strings]; STL carries no unit [doc]         |
      | OBJ                   | Tool > Export (Qud, Tri, Txr, Flp, Mrg, Grp options)                                                                                                                                                                                                                                                                                                     | Grp writes polygroups [strings]                                        |
      | Snap to ground        | Macro > Snap To Ground                                                                                                                                                                                                                                                                                                                                   | [macro]                                                                |
      
    • procedures.md 34.9 KB
      # Bridge procedures: posing and print prep
      
      Agent-side Python. Every block below is **not yet run in ZBrush**: no ZBrush session was assigned to this skill while it was written, and at 06:11 to 06:15 on 2026-09-24 three `zb_launch.start()` attempts by another session stalled at startup (`tests/code/zbrush-expert/live_logs/*/00_start.json`). What IS verified:
      
      - the math and file logic, offline: `tests/code/zbrush-pose-print/test_zb_print.py` (synthetic meshes with known walls, gaps, sections, angles, plus the real v03 ZBrush export) and `test_zb_pose.py` (FK, pivot correction, guards, against a fake `zbrush.commands`); 57 tests pass (`offline_results.json`, 42 in v1, 15 added by the 2026-09-24 refactor);
      - from the lead skill, through the bridge: DynaMesh, Divide, strokes, dialog-free `Tool:Export` OBJ and `Document:Export` PNG (v01 to v03).
      
      Revised 2026-09-24 after the Z5 blind grade: props and plates stay in the TPose mesh, the transfer has a point-order guard, the clean project and ZPR save are code, the hollow chain is complete, the brief box exists, decimation can be masked. New offline tests pass (see `offline_results.json`); the new live checks are in `live_p05`, `live_p07`, `live_p08`, not yet run in ZBrush.
      
      Each procedure names the live test that will settle it. Item paths carry the tags of `scripts/zb_pose.py` (`[strings]` = the label exists in the 2026.2.1 UI resources, `[macro]` = used by a shipped macro, `[log]` = written by ZBrush's Activity log).
      
      ```python
      # common header for every procedure
      import sys
      SKILLS = "/abs/path/to/skills"  # the folder holding the scenario-zbrush-* skills
      sys.path[:0] = [f"{SKILLS}/scenario-zbrush-expert/scripts", f"{SKILLS}/scenario-zbrush-pose-print/scripts"]
      import zb_launch as zl          # lead: start, run, call, stop
      import zb_review                # lead: review sheets
      import zb_pose                  # this skill, runs inside ZBrush through zb_pose.call
      import zb_print as zp           # this skill, agent side, numpy
      JOB = "/abs/path/to/job"        # absolute paths only: relative ones land in the app folder
      ```
      
      ## P0. Session and intake
      
      ```python
      zl.start()                                            # lead: bridge up, Home Page closed
      inv = zb_pose.call("inventory")                       # name, visible, points, faces, levels, bboxes, solid
      names = [r["name"] for r in inv["subtools"]]
      assert len(set(names)) == len(names), "rename at creation: Decimation Master and GoZ need unique names"
      zl.call("zb_ops", "save_ztl", f"{JOB}/hero_rest.ztl")  # -> hero_rest_v001.ztl, never overwrites
      ```
      
      Gate: the inventory is saved with the job; every later check compares against it. Status: not yet run in ZBrush. Test: `live_p01` (inventory runs inside `preflight_tpose`).
      
      ## P1. Real-world scale without dialogs
      
      Scale Master Set Scene Scale and 3D Print Hub Update Size Ratios open choice dialogs (doc). The dialog-free route sets what Scale Master stores anyway, the Export Scale (doc: after Unify the real size lives in `Tool:Export:Scale`), so the internal mesh keeps its well-behaved size and every OBJ comes out in millimeters.
      
      ```python
      target = zp.scale_for(1800, "1/6")                    # 300.0 mm; Hasbro PVC: scale_for(h, 12, shrink_pct=4)
      # hide what must not count in the height (a display base) before this call
      sc = zb_pose.call("set_export_scale", target)         # Export Scale = target / visible internal height
      parts = zb_pose.call("export_parts", f"{JOB}/scale_check")
      chk = zp.check_scale([p["path"] for p in parts["parts"]], target)
      assert chk["ok"] and chk["scales_consistent"], chk     # 0.5 % [added]; one '#Auto scale' on all parts
      ```
      
      Gate: assembled height within 0.5 %, one export scale on every part. If `scales_consistent` fails, Export Scale is per SubTool: call `set_export_scale` per SubTool with the same factor [verify]. Human route: Zplugin > Scale Master > Set Scene Scale (mm), New Bounding Box Subtool, R on, type the height, Resize Subtool with All (Gaboury). Status: not yet run in ZBrush. Test: `live_p04_scale_export.py`.
      
      **The brief box, SubTool number one** (Gaboury [00:21:43] [02:04:45]). The height sets the scale; the box is the envelope the client or the package allows (his car: 8 x 6 x 4 in). Every later part is checked against it, and a late size change is one call (the export scale route is what Scale Master's Resize with All plus Unify leaves behind: real size in `Tool:Export:Scale`, doc).
      
      ```python
      env = zp.envelope([p["path"] for p in parts["parts"]], (220.0, 300.0, 160.0))   # W x H x D from the brief
      box = zb_pose.call("brief_box", (220.0, 300.0, 160.0), sc["export_scale"], env["floor_center_mm"])
      zb_pose.call("set_visible", box["index"], False)       # hidden while scaling and exporting parts
      # ... later, at every print gate:
      rep = zp.print_report(paths, 300.0, envelope_mm=(220.0, 300.0, 160.0))   # "inside brief box" is a must
      # brief changes late (Gaboury: 6 in became 4 in): one call rescales every part at export; the box
      # scales with them, so size it to the new brief (geometry_set, mm / new export scale), re-run every gate
      sc = zb_pose.call("set_export_scale", 200.0)
      ```
      
      Show the box (`set_visible(..., True)`) in one review sheet to see what clips out. A distance the brief names (head height, blade length) is measured like Gaboury's Transpose-line caliper, which snaps to points: `zp.caliper(obj, p, q)["distance_mm"]`. For a human, the caliper is the action line after Scale Master, units in Preferences > Transpose Units (`gui-paths.md`). Status: not yet run in ZBrush. Test: `live_p08` (box size in mm, floor, hide).
      
      ## P2. Transpose Master preflight and round trip
      
      ```python
      cp = zb_pose.call("clean_project", f"{JOB}/hero_rest.ztl", timeout=300)   # doc Tips, see below
      assert cp["ok"], cp
      pre = zb_pose.call("preflight_tpose")                  # ShowPt on every SubTool, levels, density, names
      if not pre["ok"]:
          raise SystemExit(pre["problems"])                  # e.g. partial hiding = Vertex Mismatch
      print(pre["warnings"], pre["density_spread"])          # spread > 2x [added]: equalize level 1 (Plouffe)
      tp = zb_pose.call("tpose_mesh", True, True, False, timeout=300)   # Layer, Grps, no ZSphere rig
      assert zl.ping()["ok"]                                 # the ZScript plugin handed control back
      assert tp["combined"]["faces"] == tp["visible_l1_faces"], tp
      rest = zb_pose.call("export_tpose", f"{JOB}/tpose_rest.obj")          # the order reference
      zb_pose.call("save_project", f"{JOB}/pose_session")                  # TM data lives in the ZPR
      # ... pose the combined mesh here: P3 (external rig) or P4 (ZSphere rig) ...
      back = zb_pose.call("tpose_to_subtools", tp["preflight"], rest["path"], timeout=300)
      assert back["ok"] and back["order_check"]["same"], back   # order, source tool, every point count
      ```
      
      Rules:
      
      - **Clean project first** (doc Tips): `clean_project` saves a versioned ZTL, opens a default project and loads the tool back, so the pose cannot land on another model when parts of it also exist as separate tools. Opening a ZPR deletes every loaded tool (scenario-zbrush-expert traps), which is why the ZTL is checked on disk before the project opens. The doc names DefaultCube.ZPR; 2026.2.1 ships `Lightbox/Projects/DefaultProject.ZPR` and `Cube.ZPR` instead (local check), `DEFAULT_PROJECT` points at the first [verify equivalent].
      - **Save the ZPR** while the TPose mesh exists (doc Saving/Loading: TM data is saved with the project since 4R6). A ZTL alone does not carry it: a session that ends between TPoseMesh and TPose>SubT without a ZPR loses the way back.
      - **No Gizmo 3D deformers on the TPose mesh** (Bend Arc, Bend Curve, Twist, Taper, any modifier from the Gizmo's deformer list): "the point order can be changed" and SubTools get destroyed on transfer (doc); a reorder keeps the point count, so counting alone does not catch it [added]. Plain rotations, rigs, Deformation sliders are fine [verify in live_p01]. `tpose_to_subtools(..., rest_obj)` compares the face lists of the rest and posed TPose exports and refuses the press when they differ.
        Status: not yet run in ZBrush. Tests: `live_p01_transpose_master.py`, `live_p08` (clean project, ZPR, order guard); offline: the guard, the save order and the versioning.
      
      ## P3. Route A: numeric external rig (Blender), then back onto the TPose mesh
      
      Munoz Gomez's round trip (Mixamo or Blender, import on a layer at level 1) made exact: bones get numeric angles, the vertex order is checked, not hoped for.
      
      ```python
      tp = zb_pose.call("tpose_mesh", True, True, False, timeout=300)
      rest = zb_pose.call("export_tpose", f"{JOB}/tpose_rest.obj")                  # no groups: no importer split
      grp = zb_pose.call("export_tpose", f"{JOB}/tpose_groups.obj", True)           # polygroups as OBJ groups [verify]
      joints = zp.joint_centres(grp["path"])                 # pivots between adjacent polygroups, in OBJ space
      # Auto Groups on the export: one row per loose piece; the body is pieces[0], then plates, weapon, bolts
      pieces = zp.loose_pieces(rest["path"])
      ids, names = zp.face_groups(grp["path"])
      sword = pieces[1]                                      # identify each piece from its bbox and the inventory
      carrier = {sword["piece"]: "hand_r"}                   # the bone of the segment each piece rides on
      rigid = [{"vertices": p["vertices"].tolist(), "bone": carrier.get(p["piece"], "chest")} for p in pieces[1:]]
      plan = {
          "mesh": rest["path"], "axes": "OBJ as ZBrush writes it: Y up, +Z toward the front view",
          "joints": joints,
          "rotations": [{"joint": ["pelvis", "thigh_l"], "axis": [1, 0, 0], "deg": -35},
                        {"joint": ["upperarm_r", "forearm_r"], "axis": [0, 0, 1], "deg": 70}],
          "rigid": rigid,                                    # weight 1.0 to that one bone: never skinned
          "seam_smooth": "Corrective Smooth on a vertex group of the joint bands only [added]",
          "return": "OBJ with the same vertex count and order, no triangulation or merge, armature "
                    "applied, forward -Z, up Y, scale 1, one object",
      }
      # hand `plan` to scenario-blender-rigging (automatic weights, pose bones by angle, apply, export);
      # it returns f"{JOB}/tpose_posed.obj"
      posed = f"{JOB}/tpose_posed.obj"
      assert zp.same_order(rest["path"], posed)["ok"]        # vertex order sacred (Pavlovich, Munoz Gomez)
      d = zp.rigid_delta(rest["path"], posed, zp.group_vertices(grp["path"], "forearm_r", (ids, names)))
      print(d["angle_deg"], d["pivot"])                      # within 2 degrees of the plan [added]
      assert zp.rigid_pieces(rest["path"], posed)["ok"]      # every plate and the weapon moved as one body
      palm = zp.group_vertices(grp["path"], "hand_r", (ids, names))
      f = zp.follows(rest["path"], posed, rest["path"], posed, palm, sword["vertices"])
      assert f["ok"], f["residual_mm"]                      # the sword is where the hand took it
      hit = zp.piece_interference(posed)                    # plates into the body: Move Topological targets
      print(hit["pairs"][:5])
      imp = zb_pose.call("import_pose", posed)               # refuses another point count
      back = zb_pose.call("tpose_to_subtools", tp["preflight"], rest["path"], timeout=300)   # order guard
      assert imp["ok"] and back["ok"]
      ```
      
      Why this route first: it is the only one where every angle is a number and every step is checked. **Props and plates stay in the TPose mesh.** Munoz Gomez keeps them rigid inside it with Auto Groups (one group per loose piece), masks each piece whole and poses the rest [00:15:37] [00:16:14]; the agent's form is `loose_pieces` plus rigid weights to one bone, so the sword follows the whole chain (shoulder, elbow, wrist) and the plates follow their segment (Plouffe groups all plates of a segment with it [00:27:24]). Plouffe's rule forbids deforming primitives, not moving them rigidly [00:33:23]. Pulling a carried prop out and turning it afterwards by one angle about one axis cannot reproduce a raised, bent arm, and `interference == 0` still passes when the prop floats away from the hand; `follows` does not. Intersections between pieces after posing are Munoz Gomez's Move Topological job [00:17:24] (the brush moves only the connected piece under it): in route A, nudge that piece's transform in the plan and re-run; in ZBrush, a synthesized Move Topological stroke from the piece's projected center (P6) [verify], or a human. What stays manual: the pose idea itself (line of action, weight) and the cleanup of P6. Status: not yet run in ZBrush. Tests: the import guard and `same_order` are offline-tested; the round trip is `live_p01` plus a Blender-side run [verify].
      
      ## P4. Route B: ZSphere rig built by code
      
      **B1, Transpose Master with ZSphere Rig on** (doc: pose the combined mesh with a ZSphere rig).
      
      ```python
      # joints from the body SubTool at level 1, exported with polygroups (active SubTool)
      zl.run("zb_ops.set_checked('sdiv', 1, 0.5); result = zbc.get_active_tool_path()", ("zb_ops",))
      body = zb_pose.call("export_tpose", f"{JOB}/body_l1_groups.obj", True)
      J = {tuple(j["names"]): j["center"] for j in zp.joint_centres(body["path"])}
      tp = zb_pose.call("tpose_mesh", True, True, True, timeout=300)            # ZSphere Rig on
      k = 1.0                                                                    # export scale of that OBJ
      to = lambda p: zb_pose.obj_to_internal(p, k)                               # signs [verify live_p03]
      nodes = [{"pos": to(J[("chest", "pelvis")]), "radius": 0.05, "parent": -1},   # root at the hips
               {"pos": to(J[("pelvis", "thigh_l")]), "radius": 0.03, "parent": 0},
               {"pos": to(J[("shin_l", "thigh_l")]), "radius": 0.03, "parent": 1},
               {"pos": to(J[("foot_l", "shin_l")]), "radius": 0.03, "parent": 2}]   # ... every chain
      zb_pose.call("zs_build", nodes)
      zl.run("p = zb_ops.resolve(['Tool:Rigging:Bind Mesh'])\n"
             "if zbc.get(p) < 0.5:\n    zbc.press(p)\nresult = zbc.get(p)", ("zb_ops",))
      zb_pose.call("zs_pose", [{"joint": 1, "axis": [1, 0, 0], "angle": -30},     # parents first
                               {"joint": 2, "axis": [1, 0, 0], "angle": 45}])
      back = zb_pose.call("tpose_to_subtools", tp["preflight"], timeout=300)
      ```
      
      Pavlovich's rig rules: root at the hips, a very small draw size while placing (API placement makes this moot), a wrist ZSphere or the hand cannot bend, helper ZSpheres off the spine to hold the lats, a tail chain, test every chain before posing, rotations only. Unknown until live_p02: whether the bound mesh follows `set_zsphere` edits, and whether the rig tool shares the mesh's internal space.
      
      **B2, Proxy Pose for a DynaMesh without levels** (Pavlovich):
      
      ```python
      zb_pose.call("proxy_pose")                                   # Tool > Geometry > Proxy Pose, defaults
      zl.run("zb_ops.press('make_polymesh'); result = zbc.get_active_tool_path()", ("zb_ops",))
      # the Make PolyMesh3D copy has the proxy's vertex order: note its tool name, e.g. PM3D_Hero
      # a ZSphere tool: zb_pose.zs_build(nodes) as in B1, then
      zb_pose.call("rig_bind", "PM3D_Hero")                        # Select Mesh + PopUp:<name> + Bind Mesh [verify]
      zb_pose.call("zs_pose", plan)
      zb_pose.call("adaptive_skin", 1, 0, True)                    # Density 1, DynaMesh 0: same vertex order
      # select the Proxy Pose SubTool again, then:
      zb_pose.call("proxy_overwrite")                              # picks the only mesh with that order, exits
      ```
      
      Gate for both: export the posed SubTool, `rigid_delta` per segment against the plan, `rigid_pieces` (a ZSphere rig skins every piece it binds, so a plate across a joint can bend: then route A with rigid weights, or keep that plate its own SubTool and carry it with P5), review sheet. Status: not yet run in ZBrush. Test: `live_p02_zsphere_rig.py` (B1); B2 [verify], no test yet.
      
      ## P5. Route C: rigid moves of whole SubTools
      
      For what nothing carries (a base, a display stand, a prop added after posing, a kit part posed on its own). A carried prop or plate belongs in the TPose mesh (P3). When a separate SubTool must follow a posed carrier, it gets the carrier's WHOLE transform, rotation plus translation, measured, never one angle about one axis (Z5 grade error).
      
      ```python
      # the carrier's transform: the palm group of the body at level 1, before and after the pose
      palm = zp.group_vertices(f"{JOB}/body_l1_groups.obj", "hand_r")
      R, t, rms = zp.rigid_fit(zp.load(f"{JOB}/body_l1_rest.obj").verts[palm],
                               zp.load(f"{JOB}/body_l1_posed.obj").verts[palm])   # OBJ mm; rms: how rigid the palm was
      R, t = R.tolist(), t.tolist()
      w = 2                                                         # the weapon SubTool, still at rest
      zl.run(f"zbc.select_subtool({w}); result = 1")
      a = zl.call("zb_ops", "export_obj", f"{JOB}/sword_rest.obj", overwrite=True)
      zb_pose.call("rotate_subtool_matrix", R)                     # X, then Y, then Z Deformation Rotate
      b = zl.call("zb_ops", "export_obj", f"{JOB}/sword_rot.obj", overwrite=True)
      corr = zp.placement_correction(a["path"], b["path"], R, t)   # measured: the rotation center is [verify]
      assert corr["rotation_error_deg"] < 0.5, corr               # signs and ROTATE_SIGN (live_p03, live_p08)
      zb_pose.call("translate_subtool", corr["translate_mm"], sc["export_scale"])
      c = zl.call("zb_ops", "export_obj", f"{JOB}/sword_placed.obj", overwrite=True)
      f = zp.follows(f"{JOB}/body_l1_rest.obj", f"{JOB}/body_l1_posed.obj", a["path"], c["path"], palm)
      assert f["ok"], f["residual_mm"]                             # where the hand took it, within 0.5 mm [added]
      assert zp.interference(c["path"], f"{JOB}/body_l1_posed.obj")["ok"]   # and not through the hand
      ```
      
      Grip gate (Hasbro [00:19:34]): the fingers close around the handle with no pass-through, checked on a close-up tile; if the kit will be molded, fingers must not touch each other or the thumb in a closed loop. `rotate_subtool(axis, deg, pivot)` stays for one-axis turns about a known pivot (a base, a hinge). Tool > Contact (C1 to C3, 2023.2) can snap a prop to a surface [verify]. Status: not yet run in ZBrush. Tests: `live_p03_rigid_moves.py` (signs, rotation center, pivot, Offset units), `live_p08` (full placement); offline: Euler order, signs, correction.
      
      ## P6. Fix deformation after posing
      
      Measure first, then fix where the numbers point (Munoz Gomez: bent limbs bulge, stretched ones flatten; accept breakage while posing, then re-volume). Order of the fixes:
      
      1. **The seam, while the rest is still protected.** Munoz Gomez smooths and moves the transition BEFORE clearing the mask, then flips the mask and does the other side [00:07:44] [00:08:16]: the rest of the limb is protected while the joint evens out. Agent forms: route A, a Corrective Smooth limited to a vertex group of the joint bands before export (vertex order kept) [added]; in ZBrush with a mask still on (a human's Gizmo pose, or `intersection_mask`), Deformation Smooth or Polish respect the mask, so press them before `mask("clear")`. Gate: `stretch_report` share back near the rest pose at that joint.
      2. **Volumes and folds.** Bulge the compressed side, flatten the stretched one; re-sculpt cloth folds for the new pose (digest P3 step 2): tension lines from the stretched joint, compression folds on the inner bend [added]. Strokes aimed at measured spots, below, for small fixes; big fold redesign goes to a human or scenario-zbrush-character-creature with the spot list.
      3. **Pieces.** Re-place bolts and discs on bent armor instead of bending them (Plouffe); push intersecting pieces apart (Move Topological, below).
      4. **Detail on the pose.** 3D layers blend linearly from neutral to posed, so detail sculpted in the neutral pose can look soft or stretched on the bend: refine it on an extra pose-only layer, symmetry off (Munoz Gomez [00:49:48] [00:50:20]). Every layer is created at the top level (a layer only records there, [00:45:23]).
      
      ```python
      # level 1 of the same SubTool before TPoseMesh (rest) and after the transfer (posed)
      st = zp.stretch_report(f"{JOB}/body_l1_rest.obj", f"{JOB}/body_l1_posed.obj")
      print(st["stretched_share"], st["stretched_spots"][:5], st["compressed_spots"][:5])
      # fixes go on a new layer made at the top level (a layer only records there)
      zl.run("p = zb_ops.resolve('sdiv')\nzb_ops.set_checked('sdiv', zbc.get_max(p), 0.5)\n"
             "zbc.press('Tool:Layers:New')\nresult = zbc.get(p)", ("zb_ops",))
      # a stroke aimed at a measured spot: the lead's camera maps OBJ points (export scale 1:
      # divide mm by sc["export_scale"]) to canvas pixels; set a fixed view first (zb_review views)
      code = """
      cam = zb_stroke.Camera.from_zbrush(zbc)
      x, y, depth = cam.project(__P__)
      result = zb_ops.sculpt_stroke(zb_stroke.line((x - 20, y), (x + 20, y)), brush="Inflat", size=30,
                                    z_intensity=8)
      """
      spot = [c / sc["export_scale"] for c in st["compressed_spots"][0]["center"]]
      zl.run(code.replace("__P__", repr(spot)), ("zb_ops", "zb_stroke"))
      # cloth through the body, deterministic mask [verify]:
      zb_pose.call("intersection_mask")
      zl.call("zb_ops", "mask", "invert")                           # which side ends masked: [verify]
      zl.call("zb_ops", "deform", "Inflate", 2)
      zl.call("zb_ops", "deform", "Polish", 5)                      # even the seam while still masked
      zl.call("zb_ops", "mask", "clear")
      # Move Topological on one piece: only the connected piece under the brush moves [verify]
      posed_obj = f"{JOB}/tpose_posed.obj"
      pcs = {p["piece"]: p for p in zp.loose_pieces(posed_obj)}
      hit = zp.piece_interference(posed_obj, list(pcs.values()))["pairs"][0]
      into = pcs[hit["into"]]["bbox"]
      k = sc["export_scale"]
      a_pt = [c / k for c in hit["center"]]                        # where the piece went in
      b_pt = [(lo + hi) / 2 / k for lo, hi in zip(*into)]           # center of what it went into
      mt = '''
      cam = zb_stroke.Camera.from_zbrush(zbc)
      x0, y0, _ = cam.project(__A__)
      x1, y1, _ = cam.project(__B__)
      dx, dy = x0 - x1, y0 - y1
      n = max((dx * dx + dy * dy) ** 0.5, 1e-6)
      result = zb_ops.sculpt_stroke(zb_stroke.line((x0, y0), (x0 + 15 * dx / n, y0 + 15 * dy / n)),
                                    brush="Move Topological", size=20, z_intensity=40)   # values [added]
      '''
      zl.run(mt.replace("__A__", repr(a_pt)).replace("__B__", repr(b_pt)), ("zb_ops", "zb_stroke"))
      ```
      
      The stroke starts on the piece that went in and pulls it away from the one it entered, in screen space of a fixed view; re-export and re-run `piece_interference` after each nudge. A screen drag moves the piece in the view plane only: pick the view that shows the overlap edge-on.
      The camera projection uses the lead's `zb_stroke.Camera` (front view measured; other views via `fit_convention`). Re-place bolts and discs on bent armor with `append_primitive` and `geometry_set` instead of bending them (Plouffe). Gate: `stretch_report` share near the rest pose, `piece_interference` ok on the TPose export, `rigid_pieces` ok, `interference` 0 for separately printed parts, review sheet with Polyframe on (`Transform:PolyF` [verify]). Status: not yet run in ZBrush. Test: the stroke half is proven by the lead (v02); the layer, Intersection Masker and Move Topological presses are [verify], no test yet.
      
      ## P7. Print breakdown and merge
      
      ```python
      # light copies first (Hasbro decimates right after the design sculpt, then engineers)
      zl.call("zb_ops", "save_ztl", f"{JOB}/hero_print.ztl")
      # union of the SubTools that print as one part, non-destructive: Live Boolean
      for i in torso_group:                                         # e.g. body, belt, vest
          zb_pose.call("set_boolean_mode", i, "add", start=(i == torso_group[0]))
      res = zb_pose.call("make_boolean_mesh", timeout=600)          # new tool, sources stay [verify]
      # alternative that welds inserted parts into one watertight mesh (Gaboury): DynaMesh a merged copy
      # zl.call("zb_ops", "dynamesh", 512)   # resolution relative to the bbox: detail loss, check deviation
      ```
      
      Gate per part: `is_polymesh3d_solid` in `inventory()`, then the OBJ audit (`zp.print_report` watertight gate), `fits_volume` against the printer. Status: not yet run in ZBrush. Test: `live_p06` covers Make Boolean Mesh.
      
      ## P8. Hollow and walls
      
      Gaboury's whole chain [00:57:33] to [01:15:30]: the heavy sculpt is never shelled; a light DynaMesh copy is, its inner wall becomes the cutter, and the detailed part is hollowed by a Live Boolean subtract, so its outside does not move. Drains and vents are cutters too. Hollow only when asked or when the part is big enough to cost resin (doc: hollowing and cutting make a bigger print for the same price); otherwise print solid or hollow in the slicer (Gaboury concedes Cura works [01:19:03]).
      
      ```python
      part = 0                                                              # the detailed part, untouched
      solid = zl.call("zb_ops", "export_obj", f"{JOB}/torso_solid.obj", overwrite=True)
      k = sc["export_scale"]
      # 1-3. copy, insert INSIDE (no hole, so two walls), DynaMesh about 128, Create Shell (Thickness is not mm)
      hc = zb_pose.call("hollow_copy", part, [0.0, 150.0, 0.0], 60.0, k, 128, 4, timeout=900)
      shell = zl.call("zb_ops", "export_obj", f"{JOB}/torso_shell.obj", overwrite=True)
      th = zp.thickness(shell["path"], min_wall_mm=2.0, samples=4000)      # too thin: Thickness 6 (Gaboury), again
      # 4-5. keep the inner wall: Auto Groups, Groups Split, the smaller piece, Polish off the DynaMesh steps
      ks = zb_pose.call("keep_inner_shell", 10)
      # 6. thin ends stay solid (Gaboury [01:15:30]): trim the inner shell where the part is thin, e.g. a QCube
      #    set to subtract under the inner shell and baked first, or a human's isolate + Delete Hidden
      # 7. the stack: detailed part = start, inner shell = subtract, drain and vents = subtract
      zb_pose.call("set_boolean_mode", part, "add", start=True)
      zb_pose.call("set_boolean_mode", ks["inner"], "sub")
      drain = zb_pose.call("append_primitive", "qcyl_y", [24, 4, 24])
      zb_pose.call("geometry_set", zb_pose.obj_to_internal([0.0, 20.0, 0.0], k), [6.0 / k, 40.0 / k, 6.0 / k])
      zb_pose.call("set_boolean_mode", drain["index"], "sub")           # from under the base into the cavity
      res = zb_pose.call("make_boolean_mesh", timeout=900)              # a new tool; sources stay (Gaboury)
      out = zl.call("zb_ops", "export_obj", f"{JOB}/torso_hollow.obj", overwrite=True)
      s = zp.shells(out["path"])
      assert s["sealed_cavities"] == 0, s                                  # the drain opened the cavity
      w = zp.thickness(out["path"], min_wall_mm=2.0, samples=4000)
      dr = zp.outer_drift(solid["path"], out["path"])                     # the sculpted outside did not move
      zb_pose.call("from_thickness", 2.0, 10.0)                            # the visual, on a copy
      zl.call("zb_ops", "export_canvas", f"{JOB}/torso_thickness.png", overwrite=True)
      ```
      
      **Thicken from the inside** (Hasbro: "Fix it, but don't change it from the outside... inflate it inward" [00:16:46]): a wall under the target is fixed on the cutter, never on the sculpt. Move the inner wall inward: shell the copy again with a higher Thickness (Gaboury went from 4 to 6 [01:10:32]), or shrink the cutter with a negative Deformation Inflate [verify sign], and re-make the boolean; gate `outer_drift` p99 within 0.02 mm [added] and `thickness` p01 at the target. On a solid part with a thin feature (a cape edge, a blade), take the material from where it does not show, the back or the underside (Hasbro takes it from the hidden shoulder ball), and check the front with `outer_drift` on that region.
      
      The shortcut Gaboury also shows, an insert touching the surface so Create Shell opens a hole [01:05:29], applies when the shell copy itself is printed (a smooth part, no detail to keep); it joins the two walls into one piece, so `keep_inner_shell` refuses it. Live Boolean drops UVs, textures, creases, 3D layers and masks on the result (version deltas, Live Boolean doc): hollow the print copy, never the posed master. Gate: `th["walls"]["p01"]` and `w["walls"]["p01"]` at or above the wall target, `s["sealed_cavities"] == 0` for resin, `dr["ok"]`, vents at every cup that faces the build plate (Gaboury slides a cutter through the Live Boolean like the slicer's layer slider [01:34:49]; the slicer's layer preview is the check). Status: not yet run in ZBrush. Test: `live_p05_shell_thickness.py` (copy, shell, inner shell, subtract, drift); offline: the order of the presses, the split, `outer_drift`.
      
      ## P9. Cut and keys
      
      ```python
      low, up = f"{JOB}/torso_low.obj", f"{JOB}/torso_up.obj"            # the two halves, mm
      plan = zp.plan_keys(low, (0, 150.0, 0), (0, 1, 0), clearance_mm=0.12, min_wall_mm=1.0, part_b=up)
      assert all(key["ok"] for key in plan["keys"]), plan["warnings"]
      k = sc["export_scale"]
      for key in plan["keys"]:
          peg = zb_pose.call("append_primitive", "qcyl_y", [32, 4, 32])    # smooth enough to fit
          c = list(key["center"])
          c[1] += key["peg_length_mm"] / 2                                  # half above the cut plane
          r, L = key["peg_radius_mm"] / k, key["peg_length_mm"] / k
          zb_pose.call("geometry_set", zb_pose.obj_to_internal(c, k), [2 * r, L, 2 * r])
          pe = zl.call("zb_ops", "export_obj", f"{JOB}/peg_{peg['index']}.obj", overwrite=True)
          zl.run("zb_ops.press('duplicate'); result = zbc.get_active_subtool_index()", ("zb_ops",))
          g = 0.12 / k                                                     # clearance, internal units
          zb_pose.call("geometry_set", None, [2 * r + 2 * g, L + 2 * g, 2 * r + 2 * g])
          so = zl.call("zb_ops", "export_obj", f"{JOB}/socket_{peg['index']}.obj", overwrite=True)
          cl = zp.clearance(pe["path"], so["path"], target_mm=0.12)
          assert cl["ok"], cl["gap"]
      # Live Boolean: [lower half (start), peg (add)] and [upper half (start), socket (sub)], then
      # make_boolean_mesh(); one or two operations per hierarchy (Hasbro)
      ```
      
      Cutting: a QCube cutter larger than the model (Gaboury: PolyCube, clearance on every side) in Live Boolean, subtract for one half and intersect for the other; or, when the cut follows a polygroup border, `Groups Split` then `close_holes()` on each part [added]. Retention: Hasbro gives pegs a mushroom head so they cannot pull out; Gaboury slots a snap-joint peg for flex; for glued statues two keys stop a round peg from spinning [added]. Sculpted cutters (a disc carrying projected cloth detail) cannot be sized with sliders: calibrate Inflate per ZTool, `zp.inflate_calibration([(1, gap1), (2, gap2)])`, `zp.inflate_for(0.12, cal)`, where each gap is `zp.clearance(original, inflated)["gap"]["p50"]` (Hasbro measured 0.12 mm at Inflate 2 in his ZTool). Status: not yet run in ZBrush. Test: `live_p06_keys_boolean.py`.
      
      **If the parts will be molded** (a cast resin kit, a production toy), Hasbro's rules apply on top [00:19:34] [01:24:32]: fingers touching each other or the thumb make a closed loop the mold cannot release; `Tool:Polygroups:Group Front` from the view that faces one mold half shows the parting line (one clean line wanted); `Transform:Draw Draft Analysis` with the camera along the pull shows draft (doc: green and yellow good, red bad; `MaskByDraft` masks the bad faces). Both are presses plus a canvas render (`PATHS["group_front"]`, `PATHS["draft_analysis"]`, [verify]); a direct print needs none of it.
      
      ## P10. Decimate per part
      
      ```python
      budget = {0: 400_000, 1: 150_000, 2: 60_000}                  # per SubTool: faces and hands get more
      masks = {0: "cavity"}                                         # a head SubTool: keep density in its detail
      for i, target in budget.items():
          zl.run(f"zbc.select_subtool({i}); result = 1")
          a = zl.call("zb_ops", "export_obj", f"{JOB}/dec/{i}_before.obj", overwrite=True)
          r = zb_pose.call("decimate_to_faces", target, True, masks.get(i), timeout=900)  # mask, Pre-process, Decimate
          assert zl.ping()["ok"]                                      # the plugin handed control back
          b = zl.call("zb_ops", "export_obj", f"{JOB}/dec/{i}_after.obj", overwrite=True)
          dv = zp.deviation(a["path"], b["path"], tol_mm=0.05)        # tol: printer XY resolution [added]
          print(i, r["faces_after"], dv["deviation"]["p99"], dv["ok"])
      ```
      
      **Masks steer Decimation Master** (Gaboury masks the regions whose detail matters, face and hands, before decimating [01:30:30]; doc: masked areas keep density, the final count is the same, a partial mask protects partly). The agent cannot paint that mask, so in order of preference: (1) face and hands as their own SubTools with their own budget (doc: decimate SubTools one by one when detail differs); (2) a mask made without strokes before Pre-process, `mask="cavity"` (Mask By Cavity) or `"peaks"` (Mask PeaksAndValleys) [added] [verify that it lands on the detail]; (3) a mask a human or `intersection_mask` left on, `mask="keep"`; (4) a human mask. **Pre-process again after ANY edit**, a mask, a level change, a rename included: Decimation Master ignores what changed after Pre-process and can reuse a stale cache for a same-named tool (doc Troubleshooting), so `decimate_to_faces` always pre-processes and refuses a mask without it. Polygroups are lost after decimation (doc), so group before. Gate: faces within the slicer's budget, `deviation` p99 within tolerance, close-up render against the original (Gaboury: stop one step before faceting; FDM hides more than resin). Status: not yet run in ZBrush. Test: `live_p07_decimate_printhub.py`.
      
      ## P11. Export, package and report
      
      ```python
      sc = zb_pose.call("set_export_scale", 300.0)
      parts = zb_pose.call("export_parts", f"{JOB}/print/obj")
      paths = {p["name"]: p["path"] for p in parts["parts"]}
      rep = zp.print_report(paths, 300.0, min_wall_mm=1.0, build_volume_mm=(218, 123, 250),
                            keys=[(f"{JOB}/peg_3.obj", f"{JOB}/socket_4.obj", 0.12)],
                            envelope_mm=(220.0, 300.0, 160.0), out_json=f"{JOB}/print/report.json")
      print(rep["ok"], rep["failed"], rep["to_look_at"])
      for name, p in paths.items():
          zp.write_stl(p, f"{JOB}/print/stl/{name}.stl")             # binary, mm, Z up
      zp.write_3mf(paths, f"{JOB}/print/hero_1-6.3mf")               # units declared: millimeter
      sheet = zb_review.review(f"{JOB}/print/review")["sheet"]       # look at it before delivering
      zl.call("zb_ops", "save_ztl", f"{JOB}/hero_print.ztl")
      ```
      
      **Test print first** (Gaboury prints a joint in the target resin before the full job [01:53:10]): send one key pair (peg and socket, or a cut-down coupon of the same section; digest P5 gate) and, when the detail floor is in doubt, the most detailed part such as the head [added], before the kit; adjust the clearance from the physical fit. The detail pass happens here too: a review tile rendered at the figure's real size on screen, anything under the process floor (edges 0.5 mm, about 0.2 mm floats away: Hasbro) backfilled, fused or left to paint (Hasbro [00:09:47]; Plouffe: the final viewing distance decides the detail [00:18:51]; Carratala: over-sculpting for the scale is the usual failure).
      
      The delivery note states: units mm, scale and real height, process assumed, wall and clearance targets, part list with keys, what was measured (`report.json`) and what was only looked at. The 3D Print Hub buttons (`Export to STL`, `Export to 3MF`) are the human route; whether a preset file name suppresses their dialog is live_p07. Status: not yet run in ZBrush. Test: `live_p04` (writers on live exports), `live_p07` (Print Hub press).
      
      ## P12. When a human or a computer-use agent takes over
      
      Gizmo drags with masks, Ctrl-drag topology masks, hand-painted gradient masks, Move Topological and seam smoothing are canvas gestures (the SDK's `press_key` is marked "Does not work, hidden for now" in the installed 2026 stub, and modifier presses with `canvas_click` are reported unreliable, Maxon forum): hand them over with the pose plan, the stretch spots (`stretch_report`) and the review sheet, then re-run the gates of P3 or P6 on the result. Menu paths and hotkeys: `gui-paths.md`.
      
    • sources.md 9.9 KB
      # Sources of scenario-zbrush-pose-print
      
      Each line: source, who, credential, where, what it is best for, best timestamps. Notes are in the project's `notes/posing/` and `notes/3d-print/`; the batch digest is `notes/posing/_digest_print_pose_render.md`; version facts come from `sources/zbrush-version-deltas.md`.
      
      ## Revisions
      
      - 2026-09-24, v1: first build (snapshot in `skills/_versions/zbrush-pose-print v1 2026-09-24/`, tests in its `tests-code/`).
      - 2026-09-24, v2, refactor after the Z5 blind grade (`tests/grading/Z5_grade.md`): taught the items missing from both answers (Auto Groups and Move Topological in the TPose mesh, the Gizmo deformer warning with a point-order guard, the clean project and ZPR save, Gaboury's full hollow chain, the brief box with one-call resize, masks and re-pre-process for Decimation Master, thickening from the inside, the seam evened under the mask, linear layer blending, detail sized to the print, the Transpose caliper, mold rules) and fixed the graded errors (a carried weapon re-placed by one rotation, now kept in the TPose mesh or given its carrier's whole transform and gated by `follows`; "the SDK has no modifier clicks", now: `press_key` is documented but marked "Does not work, hidden for now" in the installed stub and reported unreliable with `canvas_click`; From Thickness asks, the wrapper refuses). New code in `zb_pose.py` and `zb_print.py` with offline tests; live checks added to `live_p05`, `live_p07` and the new `live_p08`, not yet run in ZBrush. Also fixed: the v1 hollowing snippet and `live_p05` pressed MergeDown with the insert active, the last SubTool, which has nothing below it to merge with.
      
      ## Evidence produced by this project
      
      - Offline tests `tests/code/zbrush-pose-print/test_zb_print.py` and `test_zb_pose.py`, results in `offline_results.json` (2026-09-24: 42 pass in v1, 57 in v2, Python 3.14 with numpy; synthetic meshes, the fake `zbrush.commands`, and the real v03 ZBrush export `tests/code/zbrush-expert/fixtures/v03_sphere.obj`). Best for: what the measuring and planning code is known to do.
      - Live tests `tests/code/zbrush-pose-print/live_p01..p08` with `run_live.sh`: written, not yet run in ZBrush (p08 and the p05, p07 additions from the v2 refactor). Best for: the exact questions still open (plugin control return, bound rigs, signs, dialogs).
      - ZBrush 2026.2.1 UI string resources, read-only: `/Applications/Maxon ZBrush 2026/ZData/ZLang/english/UInterface.zsc` (read 2026-09-24). Best for: labels that exist in this build (TPose|SubT, Proxy Pose buttons, From Thickness with Min Thickness, Max Thickness, Ball, Ray, Max Thickness Polygons, 3D Print Hub Export to 3MF, Scale Master and Decimation Master item paths, Intersection Masker, Tool:Layers:New, Tool:Export:Grp; added in v2: Load Tool, Load Tools From Project, MoveUp and MoveDown, Groups Split, Mask By Cavity, Mask PeaksAndValleys, Group Front, Draw Draft Analysis, MaskByDraft, Bend Arc, Bend Curve, Twist, Taper).
      - Install listing, read-only (2026-09-24): `Lightbox/Projects/` has `DefaultProject.ZPR` and `Cube.ZPR` but no `DefaultCube.ZPR` (the doc's clean project); `ZData/BrushPresets/Move Topological.ZBP`; `ZData/Macros/Misc/Toggle Mask By Polygroups.TXT` (sets `Brush:Auto Masking:Mask By Polygroups` to 100). Best for: the clean-project default and stroke-free masking options.
      - ZBrush Activity logs `~/Library/Preferences/Maxon/ZBrush_03C27D49/Logs/Activity/Activity 2026-09-24-05-20-14.txt` and later. Best for: preference values on this Mac (Rotation Steps 18, Mask Blur Strength 24, Transpose Units, Max Thickness Polygons 8) and canonical paths such as `Zplugin:3D Print Hub:Y(mm)`.
      - Shipped macros `/Applications/Maxon ZBrush 2026/ZData/Macros/` (Maxon): `GrpUnMasked.TXT` (HidePt, ShowPt, Group Visible), `Snap To Ground.TXT` (Y Size, Y Position), `Misc/Append a QCube Subtool.TXT` (Append, PopUp:PolyMesh3D, Initialize), `Misc/Center Mesh to World.TXT`. Best for: known-good 2026 item paths.
      - ZBrush Python SDK 2026 stub and examples, `/Applications/Maxon ZBrush 2026/Documentation/python-api/` (Maxon SDK team, Ferdinand Hoppe): `add_zsphere`, `set_zsphere`, `get_zsphere`, `edit_zsphere`, `delete_zsphere`, `get_transpose`, `set_transpose` (origin plus direction, not two points), `set_subtool_status` (toggle logic), `query_mesh3d`, `is_polymesh3d_solid`, `press_key` (its docstring: "Does not work, hidden for now"); `examples/modeling/_ex_mod_zsphere_biped.py` (WIP rig builder, delete children in reverse), `ex_mod_subtool_export.py` (SubTool name from the tool path). Best for: rigs by code and checks.
      - Lead skill scenario-zbrush-expert: `zb_launch`, `zb_ops`, `zb_audit`, `zb_review`, `zb_stroke` and the v01 to v03 bridge evidence. Best for: everything that talks to ZBrush.
      
      ## Experts
      
      - **Paul Gaboury**, Pixologic Lead Product Development Manager (now Maxon), freelance toy artist for Hasbro, Disney Consumer Products and Topps; "Using ZBrush for 3D Printing: The Creative Process", Gnomon live event hosted by Josh Herman, 2020-05-05, https://www.youtube.com/watch?v=W9P5YmLGBj0. Best for: scale first, Scale Master, hollowing a light copy, cups and vents, snap joints, Boolean folders, decimation by eye. Timestamps: bounding box [00:21:43], Scale Master [00:29:45] to [00:41:05], DynaMesh for the shell copy [00:56:58], "never hollow the 9 million" [00:57:33], Create Shell [01:03:10] to [01:10:32], inner shell as subtract [01:13:48], thin ends [01:15:30], decimation [01:11:40] [01:22:53], masks and Tessimate [01:30:30], cups [01:35:21], snap joints [01:40:38] to [01:53:44], folders [02:05:48].
      - **Paul Bennett**, digital product designer, Hasbro sculpture department (Star Wars Black Series); "Hasbro Toys: Star Wars Rey Skywalker Toy and More with ZBrush", ZBrush Summit 2019, https://www.youtube.com/watch?v=P08kuTIRziE. Best for: production constraints, minimum walls, the 0.12 mm offset and its calibration, scale with shrink, articulation from spheres and cylinders, molding checks. Timestamps: walls [00:10:57] [00:16:46], offset [00:37:55] [00:53:04] to [00:56:51], scale [00:12:54] [00:17:51], articulation [00:07:36] [00:40:08], elbow range [01:06:02], decimate early [00:25:28], parting line [01:24:32].
      - **Alex Carratala**, sculptor of about 90 mm collectibles (Kimera Models; credentials beyond the article unverified); "The Rules and Intricacies of Sculpting Miniatures in ZBrush", 80.lv, 2023-08-21, https://80.lv/articles/the-rules-intricacies-of-sculpting-miniatures-in-zbrush. Best for: the miniature order of operations (pose first, cuts in mind, cut, decimate, scale, STL), scale awareness.
      - **Pablo Munoz Gomez**, 3D concept and character artist, founder of Pablander Academy, ZBrushLIVE streamer; "A very cool trick to pose characters in and outside ZBrush", 2021-05-24, https://www.youtube.com/watch?v=oxyKKr4aHPY. Best for: lowest-level posing, Transpose Master with Auto Groups, the external rig round trip onto a layer. Timestamps: lowest level [00:07:44], seam cleanup [00:08:16], Transpose Master [00:14:00] to [00:22:24], same mesh rule [00:39:55], layer import [00:42:34] to [00:44:44], layers record at the top [00:45:23], linear interpolation [00:50:20].
      - **Michael Pavlovich**, Director of Character, Weapon and Vehicle Art at Certain Affinity (Halo, Call of Duty, DOOM), ZBrushLIVE streamer; "Proxy Pose ZBrush 2023.1 with ZSphere Rigging", 2023-04-02, https://www.youtube.com/watch?v=irnuxmVuF1Y. Best for: Proxy Pose plus ZSphere rig on a DynaMesh, rig placement rules, Adaptive Skin settings for Overwrite Pose. Timestamps: copy with the same order [00:01:03], placement [00:02:01] to [00:03:36], bind [00:08:00], rig fixes [00:09:33] to [00:12:50], rotations only [00:13:58], Adaptive Skin and Overwrite [00:15:50] to [00:16:53].
      - **Marco Plouffe**, co-founder and art director of Keos Masons, sculptor for Sideshow Collectibles, Prime 1 Studio and XM Studios; "How a Master ZBrush Artist Sculpts and Poses a Character", Outgang excerpt, 2022-12-05, https://www.youtube.com/watch?v=lakCNFUOQlI. Best for: collectible pose order and time budget, equal level-1 density, primitives unstretched, economy by viewing distance. Timestamps: blockout pose [00:15:25], final pose [00:25:45] [00:26:18], plate groups [00:27:24], density [00:28:30] to [00:30:41], primitives [00:32:49] [00:33:55], economy [00:18:51] [00:23:15].
      
      ## Maxon documentation
      
      - Posing pages of the ZBrush 2026 help (Gizmo topology masking, one-touch polygroup masking, Gizmo with multiple SubTools, Transpose, Transpose Master, Proxy Pose, rigging), saved as `sources/docs/paint-pose-render-print__posing-transpose-master-zsphere-rig.md`, e.g. https://help.maxon.net/zbr/en-us/Content/html/user-guide/zbrush-plugins/transpose-master/transpose-master.html and https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/posing-your-model/proxy-pose/proxy-pose.html. Best for: button names and the two TPoseMesh warnings.
      - 3D printing pages (3D Print Hub, Scale Master, Decimation Master, Ready for Print, PolyPaint From Thickness, Draw Draft Analysis, Mask by Draft), saved as `sources/docs/paint-pose-render-print__3d-print-hub-scale-decimation.md`, e.g. https://help.maxon.net/zbr/en-us/Content/html/user-guide/3d-modeling/ready-for-print/polypaint-from-thickness/polypaint-from-thickness.html. Best for: units, export formats, From Thickness ranges, decimation rules.
      - Tool palette reference for Deformation (Offset in percent of the tool's unit radius, Rotate, Size), saved as `sources/docs/hard-surface__zbrush-ref-tool-palette-geometry-deformation-subtool.md`. Best for: rigid moves by slider.
      - Release notes and What's New (via `sources/zbrush-version-deltas.md` sections 2, 3.8, 3.11). Best for: Proxy Pose 2023.1, Transpose Classic 2024, Transpose Master fix 2025.2, 3MF fixes 2026.0.1 and 2026.2.1, Scale Master fixes, Decimation Master 100M.
      
      ## Sister skills named in handoffs
      
      - scenario-blender-rigging (Blender Expert Skills team): numeric bone posing with automatic weights for route A.
      - scenario-maya-expert: the same round trip through Maya if the studio pipeline is Maya.
      
  • scripts
    • zb_pose.py 55.4 KB
      """
      zb_pose: the in-ZBrush half of scenario-zbrush-pose-print. Posing (clean project and ZPR save,
      Transpose Master with a point-order guard before TPose>SubT, ZSphere rigs built by code,
      Proxy Pose, rigid moves of whole SubTools with a full rotation plus translation, pose import
      onto a layer) and print prep (brief box, export scale in millimetres, per-part OBJ export,
      DynaMesh shell and inner-shell cutter, From Thickness, primitives for keys and cutters,
      boolean flags, masked decimation), plus the pure-Python rotation math both need. The
      measurements on exported meshes live in zb_print (agent side, numpy).
      
      Runs INSIDE ZBrush 2026 (CPython 3.11.9, no numpy). From the agent side:
          import sys; sys.path.insert(0, "<skills/scenario-zbrush-pose-print/scripts>")
          import zb_pose
          zb_pose.call("preflight_tpose")                  # through the proven bridge (zb_launch)
          zb_pose.call("set_export_scale", 300.0)
          zb_pose.call("export_parts", "/abs/out/parts")
      Inside ZBrush it imports zb_ops (<skills>/scenario-zbrush-expert/scripts) and uses its checked
      wrappers; it never re-implements them.
      
      Evidence tags on paths (PATHS):
        [strings] label or full item path present in the 2026.2.1 UI string resources
                  (ZData/ZLang/english/UInterface.zsc, read 2026-09-24): the item exists in the
                  build; the exact path is still [verify]
        [log]     written by ZBrush in Logs/Activity (2026-09-24 sessions)
        [macro]   used by a shipped 2026.2.1 macro (ZData/Macros)
        [doc]     Maxon docs or SDK;  [verify] inferred, the wrapper tries it and fails loudly
      NOTHING in this module has run in ZBrush yet ("not yet run in ZBrush"); the live checks are
      tests/code/zbrush-pose-print/live_p0*.py.
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import json
      import math
      import os
      import sys
      import time
      
      _HERE = os.path.dirname(os.path.abspath(__file__))
      EXPERT_SCRIPTS = os.path.normpath(os.path.join(_HERE, "..", "..", "scenario-zbrush-expert", "scripts"))
      _added = EXPERT_SCRIPTS not in sys.path
      if _added:
          sys.path.insert(0, EXPERT_SCRIPTS)
      try:
          import zb_ops  # noqa: E402  (lead toolkit: resolve, set_checked, press, stats, exports)
      finally:
          if _added and EXPERT_SCRIPTS in sys.path:
              sys.path.remove(EXPERT_SCRIPTS)
      
      try:
          from zbrush import commands as zbc
      except ImportError:  # agent side and offline tests: use(fake)
          zbc = None
      
      
      class ZBPoseError(RuntimeError):
          pass
      
      
      def use(z):
          """Point this module and zb_ops at the same zbrush.commands (a fake in offline tests)."""
          global zbc
          zbc = z
          zb_ops.zbc = z
      
      
      def _z():
          if zbc is None:
              raise ZBPoseError("zbrush.commands not available: run inside ZBrush (zb_pose.call)")
          if zb_ops.zbc is not zbc:
              zb_ops.zbc = zbc
          return zbc
      
      
      PATHS = {
          # Transpose Master (ZPlugin). The shipped label of the transfer button is "TPose|SubT"
          # in the resources; the UI draws "TPose>SubT".
          "tm_tposemesh": ["Zplugin:Transpose Master:TPoseMesh"],                           # [strings]
          "tm_to_subt": ["Zplugin:Transpose Master:TPose|SubT", "Zplugin:Transpose Master:TPose>SubT",
                         "Zplugin:Transpose Master:TPoseSubT"],                             # [strings]
          "tm_zsphere_rig": ["Zplugin:Transpose Master:ZSphere Rig"],                       # [strings]
          "tm_grps": ["Zplugin:Transpose Master:Grps"],                                     # [strings]
          "tm_layer": ["Zplugin:Transpose Master:Layer"],                                   # [strings]
          "tm_store_rig": ["Zplugin:Transpose Master:StoreTM Rig"],                         # [strings]
          "tm_paste_rig": ["Zplugin:Transpose Master:PasteTM Rig"],                         # [strings]
          # Proxy Pose (Tool > Geometry sub-palette, 2023.1)
          "proxy": ["Tool:Geometry:Proxy Pose:Proxy Pose", "Tool:Geometry:Proxy Pose"],    # [strings]
          "proxy_reduction": ["Tool:Geometry:Proxy Pose:Reduction Amount", "Tool:Geometry:Reduction Amount"],
          "proxy_keep_details": ["Tool:Geometry:Proxy Pose:Keep Details", "Tool:Geometry:Keep Details"],
          "proxy_keep_current": ["Tool:Geometry:Proxy Pose:Keep Current", "Tool:Geometry:Keep Current"],
          "proxy_overwrite": ["Tool:Geometry:Proxy Pose:Overwrite Pose", "Tool:Geometry:Overwrite Pose"],
          "proxy_groups": ["Tool:Geometry:Proxy Pose:ProxyGroups", "Tool:Geometry:ProxyGroups",
                           "Tool:Geometry:Proxy Pose:Proxy Groups"],                       # [strings]
          # ZSphere rigging and Adaptive Skin
          "rig_select": ["Tool:Rigging:Select Mesh"],                                       # [strings]
          "rig_bind": ["Tool:Rigging:Bind Mesh"],                                           # [strings]
          "askin_density": ["Tool:Adaptive Skin:Density"],                                  # [strings]
          "askin_dynamesh": ["Tool:Adaptive Skin:DynaMesh Resolution", "Tool:Adaptive Skin:Dynamesh Resolution"],
          "askin_make": ["Tool:Adaptive Skin:Make Adaptive Skin"],                          # [strings]
          "askin_preview": ["Tool:Adaptive Skin:Preview"],                                  # [verify]
          # 3D layers (records by default when created: Munoz Gomez oxyK [00:42:34])
          "layer_new": ["Tool:Layers:New"],                                                 # [strings]
          # geometry of the active SubTool (internal units)
          "x_pos": ["Tool:Geometry:X Position"], "y_pos": ["Tool:Geometry:Y Position"],   # [macro]
          "z_pos": ["Tool:Geometry:Z Position"],                                            # [macro]
          "x_size": ["Tool:Geometry:X Size"], "y_size": ["Tool:Geometry:Y Size"],         # [macro] Y Size
          "z_size": ["Tool:Geometry:Z Size"], "xyz_size": ["Tool:Geometry:XYZ Size"],     # [macro]
          "show_all": ["Tool:Visibility:ShowPt"],                                           # [macro] unhide all
          "close_holes": ["Tool:Geometry:Modify Topology:Close Holes", "Tool:Geometry:Close Holes"],
          # export scale: OBJ coordinates = internal x Scale (+ offsets) [verify order]
          "export_scale": ["Tool:Export:Scale"],                                            # [doc] "Scale Factor" [strings]
          "export_x_off": ["Tool:Export:X Offset"], "export_y_off": ["Tool:Export:Y Offset"],
          "export_z_off": ["Tool:Export:Z Offset"],                                         # [strings]
          "export_grp": ["Tool:Export:Grp"],                                                # [strings] polygroups as OBJ groups
          "import": ["Tool:Import"],                                                        # [bridge v03 exists]
          # DynaMesh shell (Create Shell needs a negative insert in the DynaMesh)
          "dyn_thickness": ["Tool:Geometry:DynaMesh:Thickness", "Tool:Geometry:Thickness"],  # [strings] Shell Thickness
          "dyn_create_shell": ["Tool:Geometry:DynaMesh:Create Shell", "Tool:Geometry:Create Shell"],
          # PolyPaint From Thickness. 2026.2.1 resources carry Min Thickness / Max Thickness /
          # Thickness Range and the Ball or Ray algorithm; the 2020 doc says Min Range / Max Range.
          "from_thickness": ["Tool:Polypaint:From Thickness"],                              # [strings]
          "ft_min": ["Tool:Polypaint:Min Thickness", "Tool:Polypaint:Min Range",
                     "Preferences:Analysis:Min Thickness"],                                 # [strings] [verify place]
          "ft_max": ["Tool:Polypaint:Max Thickness", "Tool:Polypaint:Max Range",
                     "Preferences:Analysis:Max Thickness"],
          "ft_max_polys": ["Preferences:Analysis:Max Thickness Polygons"],                  # [log] 8 on this Mac
          # primitives for keys and cutters (Append a QCube macro: Append, PopUp:PolyMesh3D, Initialize)
          "append": ["Tool:SubTool:Append"],                                                # [bridge]
          "duplicate": ["Tool:SubTool:Duplicate"],                                          # [bridge]
          "init_x_res": ["Tool:Initialize:X Res"], "init_y_res": ["Tool:Initialize:Y Res"],  # [macro]
          "init_z_res": ["Tool:Initialize:Z Res"],                                          # [macro]
          "init_qcube": ["Tool:Initialize:QCube"],                                          # [macro]
          "init_qsphere": ["Tool:Initialize:QSphere"],                                      # [strings]
          "init_qcyl_x": ["Tool:Initialize:QCyl X"], "init_qcyl_y": ["Tool:Initialize:QCyl Y"],
          "init_qcyl_z": ["Tool:Initialize:QCyl Z"],                                        # [strings]
          "unify": ["Tool:Deformation:Unify"],                                              # [macro]
          # booleans and splits
          "live_boolean": ["Render:Render Booleans:Live Boolean", "Render:Live Boolean"],  # [strings] [verify]
          "make_boolean": ["Tool:SubTool:Boolean:Make Boolean Mesh", "Render:Render Booleans:Make Boolean Mesh",
                           "Tool:SubTool:Make Boolean Mesh"],                               # [strings] [verify]
          "split_groups": ["Tool:SubTool:Split:Groups Split", "Tool:SubTool:Groups Split"],  # [strings]
          "split_parts": ["Tool:SubTool:Split:Split To Parts", "Tool:SubTool:Split To Parts"],
          "split_unmasked": ["Tool:SubTool:Split:Split Unmasked Points", "Tool:SubTool:Split Unmasked Points"],
          "merge_down": ["Tool:SubTool:Merge:MergeDown", "Tool:SubTool:MergeDown", "Tool:SubTool:Merge Down"],
          "move_down": ["Tool:SubTool:MoveDown", "Tool:SubTool:Arrange:MoveDown"],        # [strings] label
          "intersection_mask": ["Zplugin:Intersection Masker:Create Intersection Mask"],  # [strings]
          # deformation (applies once per set, respects masks)
          "deform_rotate": ["Tool:Deformation:Rotate"],                                     # [doc palette ref]
          "deform_offset": ["Tool:Deformation:Offset"],                                     # [doc] % of unit radius
          "deform_inflate": ["Tool:Deformation:Inflate", "Tool:Deformation:Inflat"],        # [bridge]
          # 3D Print Hub (Update Size Ratios opens a dialog [doc]; export buttons [verify])
          "ph_update": ["Zplugin:3D Print Hub:Update Size Ratios"],                         # [strings]
          "ph_stl": ["Zplugin:3D Print Hub:Export to STL"],                                 # [strings]
          "ph_3mf": ["Zplugin:3D Print Hub:Export to 3MF"],                                 # [strings]
          "ph_obj": ["Zplugin:3D Print Hub:Export to OBJ"],                                 # [strings]
          "ph_y_mm": ["Zplugin:3D Print Hub:Y(mm)"],                                        # [log]
          # Scale Master (Set Scene Scale opens a choice dialog [doc])
          "sm_set_scene": ["Zplugin:Scale Master:Set Scene Scale"],                         # [strings]
          "sm_resize": ["Zplugin:Scale Master:Resize Subtool"],                             # [strings]
          "sm_new_bbox": ["Zplugin:Scale Master:New Bounding Box Subtool"],                 # [strings]
          "sm_all": ["Zplugin:Scale Master:All"],                                           # [strings]
          # projects and tools: the Transpose Master clean-project tip (doc Tips)
          "file_open": ["File:Open"],                                                       # [verify]
          "tool_load": ["Tool:Load Tool"],                                                  # [strings] label
          # masks that need no canvas gesture (stand-ins for painted masks, [added])
          "mask_cavity": ["Tool:Masking:Mask By Cavity"],                                   # [strings]
          "mask_peaks": ["Tool:Masking:Mask PeaksAndValleys"],                              # [strings]
          "mask_by_polygroups": ["Brush:Auto Masking:Mask By Polygroups"],                  # [macro]
          # mould checks (Hasbro, doc): only when the part will be moulded or cast
          "group_front": ["Tool:Polygroups:Group Front"],                                   # [strings] [verify place]
          "draft_analysis": ["Transform:Draw Draft Analysis"],                              # [strings] [doc]
      }
      
      # The doc's clean project is DefaultCube.ZPR; the 2026.2.1 install ships no such file, only
      # Lightbox/Projects/DefaultProject.ZPR and Cube.ZPR (local 2026-09-24) [verify equivalent].
      DEFAULT_PROJECT = "/Applications/Maxon ZBrush 2026/Lightbox/Projects/DefaultProject.ZPR"
      # Sign of a positive Deformation:Rotate value about its axis: 1 = counter-clockwise seen
      # from +axis (right-handed) [verify] (live_p03).
      ROTATE_SIGN = 1.0
      
      # Deformation axis bits for set_mod (zb_ops.deform: x=1, y=2, z=4 in GUI order [doc])
      AXIS_BITS = {"x": 1, "y": 2, "z": 4}
      # Geometry slider axes against OBJ export axes. The shipped Snap To Ground macro sets
      # Y Position = -(Y Size / 2) to put a SubTool on the ground, which suggests internal +Y
      # points down while the OBJ export is Y up (v03 analysis). (1, -1, -1) is [verify] (live_p03).
      GEOM_SIGNS = (1.0, -1.0, -1.0)
      
      
      def _p(key, required=True):
          return zb_ops.resolve(PATHS[key] if key in PATHS else key, required)
      
      
      def _set(key, value, tol=1e-3):
          return zb_ops.set_checked(PATHS[key], value, tol)
      
      
      def _press(key, check_enabled=True):
          return zb_ops.press(PATHS[key], check_enabled)
      
      
      # ============================================================================================
      # Pure math (both sides, no numpy)
      # ============================================================================================
      
      def _unit(v):
          n = math.sqrt(sum(c * c for c in v))
          if n == 0:
              raise ValueError("zero vector")
          return [c / n for c in v]
      
      
      def rot_matrix(axis, deg):
          """Rodrigues rotation matrix (right-handed, degrees)."""
          x, y, z = _unit(axis)
          a = math.radians(deg)
          c, s, t = math.cos(a), math.sin(a), 1 - math.cos(a)
          return [[t * x * x + c, t * x * y - s * z, t * x * z + s * y],
                  [t * x * y + s * z, t * y * y + c, t * y * z - s * x],
                  [t * x * z - s * y, t * y * z + s * x, t * z * z + c]]
      
      
      def _mv(m, v):
          return [sum(m[r][k] * v[k] for k in range(3)) for r in range(3)]
      
      
      def rotate_about(p, pivot, axis, deg):
          r = rot_matrix(axis, deg)
          d = [p[k] - pivot[k] for k in range(3)]
          q = _mv(r, d)
          return [q[k] + pivot[k] for k in range(3)]
      
      
      def descendants(parents, i):
          """Indices below node i in a parent-index tree (root parent = -1)."""
          kids = {}
          for k, p in enumerate(parents):
              kids.setdefault(int(p), []).append(k)
          out, todo = [], list(kids.get(int(i), []))
          while todo:
              k = todo.pop()
              out.append(k)
              todo.extend(kids.get(k, []))
          return sorted(out)
      
      
      def fk_rotate(positions, parents, joint, axis, deg):
          """Rotate every descendant of `joint` about the joint position: a ZSphere rotation with R
          on the joint (Pavlovich irnu [00:06:45]). Bone lengths stay; the joint itself stays."""
          pos = [list(map(float, p)) for p in positions]
          piv = pos[int(joint)]
          for k in descendants(parents, joint):
              pos[k] = rotate_about(pos[k], piv, axis, deg)
          return pos
      
      
      def fk_pose(positions, parents, plan):
          """Apply a pose plan [{"joint": i, "axis": [x,y,z], "angle": deg}, ...] in order.
          Pose from the centre outward, parents first (Pavlovich irnu [00:13:26]); rotations only,
          moving or scaling bones stretches the mesh (irnu [00:13:58])."""
          pos = [list(map(float, p)) for p in positions]
          for step in plan:
              pos = fk_rotate(pos, parents, step["joint"], step["axis"], step["angle"])
          return pos
      
      
      def pivot_correction(center, pivot, axis, deg):
          """Translation t so that 'rotate about center, then move by t' equals 'rotate about
          pivot': t = (j - c) - R (j - c). Deformation:Rotate turns a SubTool about its own centre
          [verify]; this moves it back onto the joint."""
          d = [pivot[k] - center[k] for k in range(3)]
          rd = _mv(rot_matrix(axis, deg), d)
          return [d[k] - rd[k] for k in range(3)]
      
      
      def obj_to_internal(p, export_scale=1.0, signs=GEOM_SIGNS):
          """OBJ export coordinates (mm after set_export_scale) to Geometry slider and ZSphere
          coordinates: divide by the export scale, apply the axis signs [verify live_p03]."""
          return [float(p[k]) / float(export_scale) * signs[k] for k in range(3)]
      
      
      def internal_to_obj(p, export_scale=1.0, signs=GEOM_SIGNS):
          return [float(p[k]) * signs[k] * float(export_scale) for k in range(3)]
      
      
      def euler_xyz(R):
          """Angles (ax, ay, az) in degrees with R = Rz(az) Ry(ay) Rx(ax): the order in which three
          one-axis Deformation Rotate calls about X, then Y, then Z compose. R: 3x3 nested list."""
          r20 = max(-1.0, min(1.0, float(R[2][0])))
          ay = math.degrees(math.asin(-r20))
          if abs(r20) < 1.0 - 1e-9:
              ax = math.degrees(math.atan2(R[2][1], R[2][2]))
              az = math.degrees(math.atan2(R[1][0], R[0][0]))
          else:  # gimbal lock: fold Z into X
              ax = math.degrees(math.atan2(-R[1][2], R[1][1]))
              az = 0.0
          return [ax, ay, az]
      
      
      def from_euler_xyz(ax, ay, az):
          """Rz(az) Ry(ay) Rx(ax), the inverse of euler_xyz."""
          rz, ry, rx = rot_matrix((0, 0, 1), az), rot_matrix((0, 1, 0), ay), rot_matrix((1, 0, 0), ax)
      
          def mm(a, b):
              return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
          return mm(rz, mm(ry, rx))
      
      
      def obj_topology(path):
          """Point count, face count and a hash of the face index lists of an OBJ (vertex indices
          only): the same hash means the same point order, which a TPose transfer needs."""
          import hashlib
          h = hashlib.sha1()
          nv = nf = 0
          with open(path, "r", errors="ignore") as fh:
              for line in fh:
                  if line.startswith("v "):
                      nv += 1
                  elif line.startswith("f "):
                      nf += 1
                      h.update((" ".join(t.split("/")[0] for t in line.split()[1:]) + "\n").encode())
          return {"points": nv, "faces": nf, "sha1": h.hexdigest()}
      
      
      def decimate_percent(faces_now, target_faces):
          """Decimation Master '% of decimation' for about target_faces (100 none, 0.01 max)."""
          return round(min(100.0, max(0.01, 100.0 * float(target_faces) / float(faces_now))), 2)
      
      
      def obj_vertex_count(path):
          n = 0
          with open(path, "r", errors="ignore") as fh:
              for line in fh:
                  if line.startswith("v "):
                      n += 1
          return n
      
      
      # ============================================================================================
      # Inventory and Transpose Master
      # ============================================================================================
      
      def subtool_name():
          """Active SubTool name: last segment of get_active_tool_path (Maxon export example),
          else the title of Tool:SubTool:ItemInfo without its trailing dot (ZScript idiom)."""
          z = _z()
          try:
              p = z.get_active_tool_path()
              if p:
                  name = p.replace("\\", "/").rsplit("/", 1)[-1].strip()
                  if name:
                      return name
          except Exception:
              pass
          for path in ("Tool:SubTool:ItemInfo", "Tool:ItemInfo"):
              if z.exists(path):
                  return z.get_title(path).strip().rstrip(".").strip()
          return None
      
      
      def _bbox_differs(a, b, rel=1e-4):
          span = max(abs(b[3] - b[0]), abs(b[4] - b[1]), abs(b[5] - b[2]), 1e-12)
          return any(abs(x - y) > rel * span for x, y in zip(a, b))
      
      
      def inventory():
          """Per SubTool: name, visibility, counts, level, full and visible bboxes, solid flag.
          Restores the active SubTool."""
          z = _z()
          n = int(z.get_subtool_count())
          cur = int(z.get_active_subtool_index())
          vis = {s["index"]: s for s in zb_ops.subtools()}
          rows = []
          try:
              for i in range(n):
                  z.select_subtool(i)
                  st = zb_ops.stats(bbox_mode=1)
                  try:
                      vb = [float(v) for v in z.query_mesh3d(2, 0)]
                  except Exception:
                      vb = st.get("bbox")
                  row = {"index": i, "name": subtool_name(), "visible": vis.get(i, {}).get("visible"),
                         "status": vis.get(i, {}).get("status"), "points": st.get("points"),
                         "faces": st.get("faces"), "sdiv": st.get("sdiv"), "sdiv_max": st.get("sdiv_max"),
                         "bbox": st.get("bbox"), "bbox_visible": vb, "solid": st.get("solid"),
                         "volume": st.get("volume"), "area": st.get("area")}
                  row["partially_hidden"] = bool(vb and st.get("bbox") and _bbox_differs(vb, st["bbox"]))
                  rows.append(row)
          finally:
              z.select_subtool(cur)
          return {"tool": z.get_active_tool_path(), "count": n, "active": cur, "subtools": rows}
      
      
      def show_all(index=None):
          """Unhide every polygon of one SubTool (index) or of all: Tool:Visibility:ShowPt, as the
          shipped GrpUnMasked macro uses it after HidePt [macro]."""
          z = _z()
          cur = int(z.get_active_subtool_index())
          targets = range(int(z.get_subtool_count())) if index is None else [int(index)]
          done = []
          try:
              for i in targets:
                  z.select_subtool(i)
                  _press("show_all", check_enabled=False)
                  done.append(i)
          finally:
              z.select_subtool(cur)
          return {"shown": done}
      
      
      def set_visible(index, visible=True):
          """Eye bit (0x1) of one SubTool. set_subtool_status toggles the bits it is given (SDK),
          so the bit is sent only when the state differs, then read back. Used to hide the brief
          box before set_export_scale and the outer shell copy after keep_inner_shell."""
          z = _z()
          cur = int(z.get_subtool_status(index))
          if bool(cur & 0x1) != bool(visible):
              z.set_subtool_status(index, 0x1)
          got = int(z.get_subtool_status(index))
          if bool(got & 0x1) != bool(visible):
              raise ZBPoseError(f"SubTool {index}: eye bit still {bool(got & 0x1)} (status {got:#x})")
          return {"index": int(index), "visible": bool(got & 0x1)}
      
      
      def clean_project(ztl_path, project=DEFAULT_PROJECT):
          """Transpose Master from a clean project (doc Tips): save the tool as a versioned ZTL,
          open a default project, Load Tool, so the pose cannot land on another model when parts
          also exist as separate tools. Opening a ZPR deletes every loaded tool (lead traps): the
          ZTL is checked on disk before the project opens. File:Open and Tool:Load Tool with a
          preset file name are [verify] (live_p08)."""
          z = _z()
          before = inventory()
          saved = zb_ops.save_ztl(ztl_path)
          if not os.path.exists(saved["path"]) or os.path.getsize(saved["path"]) == 0:
              raise ZBPoseError(f"{saved['path']} not written: the project was NOT opened")
          if not os.path.exists(project):
              raise ZBPoseError(f"{project} not found: pass a clean .ZPR (the doc's DefaultCube.ZPR "
                                "is not in the 2026 install)")
          z.set_next_filename(os.path.abspath(project))
          _press("file_open", check_enabled=False)
          z.update(redraw_ui=True)
          z.set_next_filename(saved["path"])
          _press("tool_load", check_enabled=False)
          z.update(redraw_ui=True)
          after = inventory()
          same = [r["points"] for r in after["subtools"]] == [r["points"] for r in before["subtools"]]
          return {"ztl": saved["path"], "project": project, "tool": after["tool"],
                  "subtools": after["count"], "ok": bool(same and after["count"] == before["count"])}
      
      
      def save_project(path):
          """File:Save As to a versioned .ZPR (never overwrites). Transpose Master data lives in
          the project since 4R6 (doc Saving/Loading): save one before a session ends with a TPose
          mesh open, or TPose>SubT has nothing to return to. Dialog-free is [verify] (live_p08)."""
          path = os.path.abspath(os.path.expanduser(path))
          if not path.lower().endswith(".zpr"):
              path += ".zpr"
          return zb_ops._file_op("file_save_as", zb_ops.next_version(path), overwrite=False)
      
      
      def level1_density(indices=None):
          """Edge length estimate sqrt(area / faces) at the lowest level of each SubTool: parts that
          bend together need similar values, because mask blur spreads per polygon (Plouffe lakC
          [00:29:36] [00:30:08]). Moves each SubTool to SDiv 1 and back."""
          z = _z()
          cur = int(z.get_active_subtool_index())
          out = []
          try:
              for i in (indices if indices is not None else range(int(z.get_subtool_count()))):
                  z.select_subtool(i)
                  sd = zb_ops.resolve("sdiv", required=False)
                  was = z.get(sd) if sd else 1.0
                  if sd and was > 1:
                      zb_ops.set_checked("sdiv", 1, tol=0.5)
                  faces = int(z.query_mesh3d(1)[0])
                  area = float(z.get_polymesh3d_area())
                  if sd and was > 1:
                      zb_ops.set_checked("sdiv", was, tol=0.5)
                  out.append({"index": i, "name": subtool_name(), "faces_l1": faces, "area": area,
                              "edge_l1": math.sqrt(area / faces) if faces else None})
          finally:
              z.select_subtool(cur)
          return out
      
      
      def preflight_tpose(density_ratio=2.0, fix_hidden=True, heavy_faces=500000):
          """Checks before TPoseMesh. Problems block; warnings go to the report.
          - partially hidden SubTools give a Vertex Mismatch on transfer (TPoseMesh warning, doc):
            fixed by ShowPt when fix_hidden, else a problem (the bbox test cannot see hidden
            polygons inside the hull: ShowPt is the real guarantee, so it runs on every SubTool)
          - hidden SubTools are left out of the combined mesh
          - duplicate names (Decimation Master and GoZ need unique ones later)
          - no lower levels on a heavy SubTool: ZRemesher + Project first, or Proxy Pose (doc
            Expert Tip); heavy_faces 500k is [added]
          - level-1 density spread above density_ratio ([added] threshold for Plouffe's rule)"""
          problems, warnings = [], []
          if fix_hidden:
              try:
                  show_all()
              except zb_ops.ZBOpError as e:
                  warnings.append(f"could not unhide ({e}); relying on the bbox test only")
          inv = inventory()
          rows = inv["subtools"]
          for r in rows:
              if r["partially_hidden"]:
                  problems.append(f"SubTool {r['index']} {r['name']!r} is partially hidden: Vertex Mismatch "
                                  "on TPose>SubT (doc)")
              if r["visible"] is False:
                  warnings.append(f"SubTool {r['index']} {r['name']!r} is hidden: it will not be posed")
              if (r.get("sdiv_max") or 1) <= 1 and (r.get("faces") or 0) > heavy_faces:
                  warnings.append(f"SubTool {r['index']} {r['name']!r} has {r['faces']} faces and no lower "
                                  "level: ZRemesher + Project (scenario-zbrush-retopology-export) or Proxy Pose")
          names = [r["name"] for r in rows]
          dups = sorted({n for n in names if names.count(n) > 1})
          if dups:
              warnings.append(f"duplicate SubTool names {dups}: rename at creation (Decimation Master, GoZ)")
          dens = level1_density([r["index"] for r in rows if r["visible"] is not False])
          edges = [d["edge_l1"] for d in dens if d["edge_l1"]]
          spread = (max(edges) / min(edges)) if edges else 1.0
          if spread > density_ratio:
              warnings.append(f"level-1 edge length varies {spread:.1f}x across visible SubTools: equalise "
                              "the lowest levels of parts that bend together, or plan hand masks (Plouffe)")
          return {"ok": not problems, "problems": problems, "warnings": warnings, "tool": inv["tool"],
                  "subtools": rows, "density": dens, "density_spread": spread}
      
      
      def tpose_mesh(layer=True, groups=True, zsphere_rig=False, force=False):
          """ZPlugin > Transpose Master > TPoseMesh with the Layer, Grps and ZSphere Rig switches.
          A ZScript plugin press: whether control returns to Python is [verify] (live_p01).
          Returns the source inventory (needed by tpose_to_subtools) and the combined mesh."""
          z = _z()
          pre = preflight_tpose()
          if not pre["ok"] and not force:
              raise ZBPoseError("preflight failed: " + "; ".join(pre["problems"]))
          for key, val in (("tm_layer", layer), ("tm_grps", groups), ("tm_zsphere_rig", zsphere_rig)):
              _set(key, 1 if val else 0, tol=0.5)
          source = z.get_active_tool_path()
          t = time.time()
          _press("tm_tposemesh", check_enabled=False)
          z.update(redraw_ui=True)
          after = zb_ops.stats()
          visible_l1 = sum(d["faces_l1"] for d in pre["density"])
          return {"source_tool": source, "tpose_tool": z.get_active_tool_path(), "seconds": round(time.time() - t, 2),
                  "combined": after, "visible_l1_faces": visible_l1, "preflight": pre,
                  "note": "TPose mesh faces should equal the visible level-1 faces [added check]"}
      
      
      def tpose_to_subtools(expected=None, rest_obj=None, force=False):
          """TPose>SubT: transfer the pose back. expected: the 'preflight' part of tpose_mesh()
          (point counts per SubTool) to prove nothing was destroyed afterwards. rest_obj: the
          export_tpose OBJ written right after TPoseMesh; the posed TPose mesh is exported again and
          its face lists compared BEFORE the press, because a Gizmo 3D deformer (Bend Arc, Bend
          Curve, Twist, Taper, any Gizmo modifier) can reorder points and destroy SubTools on
          transfer (doc TPoseMesh warning); a reorder keeps the point count, so counts alone miss
          it. A changed order refuses the transfer unless force."""
          z = _z()
          order = None
          if rest_obj:
              now = zb_ops.next_version(os.path.splitext(os.path.abspath(rest_obj))[0] + "_pretransfer.obj")
              export_tpose(now)
              a, b = obj_topology(rest_obj), obj_topology(now)
              order = {"rest": a, "now": b, "path": now, "same": a == b}
              if not order["same"] and not force:
                  raise ZBPoseError("TPose mesh point order changed since TPoseMesh (a Gizmo deformer?): "
                                    "TPose>SubT refused, it can destroy SubTools (doc). Reload the saved "
                                    f"ZTL and pose with rotations or a rig only. {a} vs {b}")
          t = time.time()
          _press("tm_to_subt", check_enabled=False)
          z.update(redraw_ui=True)
          inv = inventory()
          out = {"tool": inv["tool"], "seconds": round(time.time() - t, 2), "subtools": inv["subtools"],
                 "order_check": order}
          if expected:
              # the transfer should land back on the source tool [verify]; if the TPose tool is
              # still active, select the source tool and call inventory() before judging
              out["tool_is_source"] = inv["tool"] == expected.get("tool")
              before = {r["index"]: r["points"] for r in expected["subtools"]}
              changed = [r["index"] for r in inv["subtools"] if before.get(r["index"]) != r["points"]]
              out["points_changed"] = changed
              out["ok"] = out["tool_is_source"] and not changed and inv["count"] == len(before)
          return out
      
      
      def export_tpose(path, groups=False, overwrite=False):
          """OBJ of the TPose mesh for an external rig (scenario-blender-rigging) or for joint_centres.
          groups=True writes polygroups as OBJ groups (Tool:Export:Grp [verify]); keep it False for
          the round trip so no importer splits the mesh."""
          p = _p("export_grp", required=False)
          old = None
          if p:
              old = _z().get(p)
              zb_ops.set_checked(PATHS["export_grp"], 1 if groups else 0, tol=0.5)
          try:
              return zb_ops.export_obj(path, overwrite=overwrite)
          finally:
              if p is not None and old is not None:
                  _z().set(p, old)
      
      
      def import_pose(obj_path, level=1, new_layer=False):
          """Bring a posed OBJ back (Munoz Gomez oxyK [00:43:39]): optionally a new recording layer
          at the highest level, then the lowest level, then Tool:Import. Refuses when the OBJ point
          count differs from the current level: a different mesh replaces the SubTool and kills
          its levels (oxyK [00:40:59])."""
          z = _z()
          sd = zb_ops.resolve("sdiv", required=False)
          out = {}
          if new_layer and sd:
              zb_ops.set_checked("sdiv", z.get_max(sd), tol=0.5)
              _press("layer_new", check_enabled=False)
              out["layer"] = True
          if sd and z.get(sd) != level:
              zb_ops.set_checked("sdiv", level, tol=0.5)
          here = int(z.query_mesh3d(0)[0])
          n = obj_vertex_count(obj_path)
          if n != here:
              raise ZBPoseError(f"{obj_path} has {n} points, the current level has {here}: not the same mesh")
          v0 = float(z.get_polymesh3d_volume())
          z.set_next_filename(os.path.abspath(obj_path))
          _press("import", check_enabled=False)
          z.update(redraw_ui=True)
          out.update(points=int(z.query_mesh3d(0)[0]), volume_before=v0,
                     volume_after=float(z.get_polymesh3d_volume()),
                     preset_consumed=not z.has_next_filename())
          out["ok"] = out["points"] == here and out["preset_consumed"]
          return out
      
      
      # ============================================================================================
      # ZSphere rigs by code
      # ============================================================================================
      
      ZS_X, ZS_Y, ZS_Z, ZS_R, ZS_PARENT = 1, 2, 3, 4, 7
      
      
      def zs_read():
          """[{"pos", "radius", "parent"}] of the active ZSphere tool (get_zsphere, SDK)."""
          z = _z()
          n = int(z.get_zsphere(0, 0, 0))
          return [{"pos": [float(z.get_zsphere(k, i, 0)) for k in (ZS_X, ZS_Y, ZS_Z)],
                   "radius": float(z.get_zsphere(ZS_R, i, 0)), "parent": int(z.get_zsphere(ZS_PARENT, i, 0))}
                  for i in range(n)]
      
      
      def zs_build(nodes):
          """Replace the active ZSphere tool's tree by `nodes` (index order, parents first, node 0
          is the root with parent -1). Children are deleted in reverse order first, as Maxon's
          biped example does (a deleted parent blocks its children)."""
          z = _z()
          for i, nd in enumerate(nodes):
              if i and not 0 <= int(nd["parent"]) < i:
                  raise ValueError(f"node {i}: parent {nd['parent']} must come before it")
      
          def edit():
              count = int(z.get_zsphere(0, 0, 0))
              for i in reversed(range(1, count)):
                  z.delete_zsphere(i)
              root = nodes[0]
              for k, v in zip((ZS_X, ZS_Y, ZS_Z), root["pos"]):
                  z.set_zsphere(k, 0, float(v))
              z.set_zsphere(ZS_R, 0, float(root.get("radius", 0.05)))
              for nd in nodes[1:]:
                  x, y, zz = (float(v) for v in nd["pos"])
                  z.add_zsphere(x, y, zz, float(nd.get("radius", 0.05)), int(nd["parent"]))
          z.edit_zsphere(edit)
          return zs_read()
      
      
      def zs_set_positions(positions):
          z = _z()
      
          def edit():
              for i, p in enumerate(positions):
                  for k, v in zip((ZS_X, ZS_Y, ZS_Z), p):
                      z.set_zsphere(k, i, float(v))
          z.edit_zsphere(edit)
          return zs_read()
      
      
      def zs_pose(plan):
          """Forward kinematics on the active ZSphere rig: new positions from fk_pose, written with
          set_zsphere. Whether a bound mesh follows API edits is [verify] (live_p02)."""
          nodes = zs_read()
          pos = fk_pose([n["pos"] for n in nodes], [n["parent"] for n in nodes], plan)
          return {"before": [n["pos"] for n in nodes], "after": zs_set_positions(pos)}
      
      
      def rig_bind(mesh_tool_name):
          """Tool > Rigging > Select Mesh picks the mesh in a tool popup; the shipped macros answer
          popups with PopUp:<item> [macro], then Bind Mesh. [verify] (live_p02)."""
          z = _z()
          _press("rig_select", check_enabled=False)
          pop = f"PopUp:{mesh_tool_name}"
          if not z.exists(pop):
              raise ZBPoseError(f"{pop} not found: the Select Mesh popup lists tools by name")
          z.press(pop)
          _press("rig_bind", check_enabled=False)
          return {"bound": z.get(_p("rig_bind")) >= 0.5}
      
      
      def proxy_pose(reduction=None, keep_details=None):
          """Enter Proxy Pose (Tool > Geometry, 2023.1) for a dense mesh without lower levels.
          Keep Details must be set before entering (doc). Pavlovich: defaults work (irnu
          [00:00:00])."""
          if keep_details is not None:
              _set("proxy_keep_details", keep_details, tol=0.5)
          if reduction is not None:
              _set("proxy_reduction", reduction, tol=0.5)
          before = zb_ops.stats()
          _press("proxy", check_enabled=False)
          _z().update(redraw_ui=True)
          return {"before": before, "after": zb_ops.stats()}
      
      
      def proxy_overwrite():
          """Overwrite Pose: takes the only mesh with the proxy's vertex order (the Adaptive Skin
          of the bound ZSphere rig) and exits to the high mesh (Pavlovich irnu [00:16:21])."""
          _press("proxy_overwrite", check_enabled=False)
          _z().update(redraw_ui=True)
          return zb_ops.stats()
      
      
      def adaptive_skin(density=1, dynamesh_resolution=0, make=False):
          """Density 1 and DynaMesh Resolution 0 keep the bound vertex order (Pavlovich irnu
          [00:15:50]); higher values subdivide or remesh and break Overwrite Pose."""
          _set("askin_density", density, tol=0.5)
          _set("askin_dynamesh", dynamesh_resolution, tol=0.5)
          if make:
              _press("askin_make", check_enabled=False)
          return {"density": density, "dynamesh_resolution": dynamesh_resolution, "made": make}
      
      
      # ============================================================================================
      # Rigid moves of whole SubTools (weapon to the hand, armour plates, bases)
      # ============================================================================================
      
      def geometry_get():
          z = _z()
          out = {}
          for key in ("x_pos", "y_pos", "z_pos", "x_size", "y_size", "z_size", "xyz_size"):
              p = _p(key, required=False)
              out[key] = z.get(p) if p else None
          return out
      
      
      def geometry_set(position=None, size=None, xyz_size=None):
          """Tool > Geometry X/Y/Z Position and X/Y/Z Size in internal units (the Center Mesh to
          World and Snap To Ground macros set these [macro])."""
          if position is not None:
              for key, v in zip(("x_pos", "y_pos", "z_pos"), position):
                  if v is not None:
                      _set(key, v, tol=1e-4 * max(1.0, abs(v)))
          if size is not None:
              for key, v in zip(("x_size", "y_size", "z_size"), size):
                  if v is not None:
                      _set(key, v, tol=1e-4 * max(1.0, abs(v)))
          if xyz_size is not None:
              _set("xyz_size", xyz_size, tol=1e-4 * max(1.0, abs(xyz_size)))
          return geometry_get()
      
      
      def rotate_subtool(axis, deg, pivot_obj=None, export_scale=None, signs=GEOM_SIGNS):
          """Rotate the whole active SubTool with Tool:Deformation:Rotate on one axis ('x', 'y',
          'z'), then, when pivot_obj (OBJ export coordinates) is given, move it so the rotation
          happened about that pivot (pivot_correction). The rotation centre of Deformation:Rotate
          and the slider signs are [verify] (live_p03): check the result with
          zb_print.rigid_delta on exports before and after."""
          z = _z()
          g0 = geometry_get()
          if pivot_obj is not None and None in (g0["x_pos"], g0["y_pos"], g0["z_pos"]):
              raise ZBPoseError(f"Geometry position sliders not found: {g0}")
          zb_ops.deform("Rotate", deg, axes=AXIS_BITS[axis])
          out = {"before": g0, "after_rotate": geometry_get()}
          if pivot_obj is not None:
              k = float(export_scale or z.get(_p("export_scale")) or 1.0)
              c_int = [g0["x_pos"], g0["y_pos"], g0["z_pos"]]
              c_obj = [c_int[i] * signs[i] * k for i in range(3)]
              ax = [1.0 if a == axis else 0.0 for a in "xyz"]
              ax_obj = [ax[i] * signs[i] for i in range(3)]
              t_obj = pivot_correction(c_obj, pivot_obj, ax_obj, deg)
              new = [c_int[i] + t_obj[i] / (signs[i] * k) for i in range(3)]
              out["after_move"] = geometry_set(position=new)
              out["translation_obj"] = t_obj
          return out
      
      
      def rotate_subtool_matrix(R_obj, signs=GEOM_SIGNS):
          """Turn the active SubTool by a full rotation given in OBJ axes, as three one-axis
          Deformation Rotate calls (X, then Y, then Z: euler_xyz). A prop re-placed after posing
          needs its carrier's whole transform, rotation plus translation (zb_print.follows,
          placement_correction), never one angle about one axis. Internal angle = axis sign x OBJ
          angle (GEOM_SIGNS is a proper rotation); ROTATE_SIGN and the rotation centre are [verify]
          (live_p03), which is why the translation is measured and corrected afterwards."""
          angles = euler_xyz(R_obj)
          calls = []
          for k, axis in enumerate("xyz"):
              if abs(angles[k]) > 1e-6:
                  deg = angles[k] * signs[k] * ROTATE_SIGN
                  zb_ops.deform("Rotate", deg, axes=AXIS_BITS[axis])
                  calls.append({"axis": axis, "internal_deg": deg})
          return {"angles_obj_deg": angles, "calls": calls, "geometry": geometry_get()}
      
      
      def translate_subtool(delta_mm, export_scale, signs=GEOM_SIGNS):
          """Move the active SubTool by delta_mm (OBJ axes, mm after set_export_scale) with the
          Geometry Position sliders (internal units = mm / export scale, axis signs [verify])."""
          g = geometry_get()
          keys = ("x_pos", "y_pos", "z_pos")
          if None in (g[k] for k in keys):
              raise ZBPoseError(f"Geometry position sliders not found: {g}")
          k = float(export_scale)
          new = [g[a] + float(delta_mm[i]) / k * signs[i] for i, a in enumerate(keys)]
          return geometry_set(position=new)
      
      
      # ============================================================================================
      # Print prep inside ZBrush
      # ============================================================================================
      
      def set_export_scale(target_mm, up_axis=1, zero_offsets=True):
          """Make Tool:Export write millimetres at the target height without the Scale Master
          dialogs: Export Scale = target / internal height of all visible SubTools
          (query_mesh3d(2, 2)). This is what Scale Master stores after Unify (doc: real size in the
          Export Scale). Whether the scale is per tool or per SubTool is [verify] (live_p04):
          check every exported header with zb_print.check_scale."""
          z = _z()
          bb = [float(v) for v in z.query_mesh3d(2, 2)]
          h = bb[3 + up_axis] - bb[up_axis]
          if h <= 0:
              raise ZBPoseError(f"visible bbox has no height on axis {up_axis}: {bb}")
          k = float(target_mm) / h
          got = _set("export_scale", k, tol=1e-3 * max(1.0, k))   # the OBJ height is the real gate
          if zero_offsets:
              for key in ("export_x_off", "export_y_off", "export_z_off"):
                  p = _p(key, required=False)
                  if p:
                      z.set(p, 0.0)
          return {"export_scale": got, "internal_height": h, "target_mm": target_mm, "bbox_internal": bb}
      
      
      def export_parts(out_dir, only_visible=True, overwrite=False, prefix=""):
          """One OBJ per SubTool (Maxon's batch-export pattern, dialog-free Tool:Export) with the
          counts ZBrush reports, including is_polymesh3d_solid()."""
          z = _z()
          out_dir = os.path.abspath(out_dir)
          os.makedirs(out_dir, exist_ok=True)
          cur = int(z.get_active_subtool_index())
          vis = {s["index"]: s["visible"] for s in zb_ops.subtools()}
          rows, used = [], set()
          try:
              for i in range(int(z.get_subtool_count())):
                  if only_visible and not vis.get(i, True):
                      continue
                  z.select_subtool(i)
                  name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (subtool_name() or f"st{i}"))
                  base = f"{prefix}{i:02d}_{name}"
                  while base in used:
                      base += "_"
                  used.add(base)
                  st = zb_ops.stats()
                  ex = zb_ops.export_obj(os.path.join(out_dir, base + ".obj"), overwrite=overwrite)
                  rows.append({"index": i, "name": name, "path": ex["path"], "bytes": ex["bytes"],
                               "points": st.get("points"), "faces": st.get("faces"), "solid": st.get("solid"),
                               "volume_internal": st.get("volume")})
          finally:
              z.select_subtool(cur)
          return {"dir": out_dir, "parts": rows}
      
      
      def dynamesh_shell(thickness, resolution=None):
          """Hollow a LIGHT DynaMesh copy, never the heavy sculpt (Gaboury W9P5 [00:57:33]): set
          DynaMesh Thickness (relative to the DynaMesh resolution, not mm: doc, W9P5 [01:10:32])
          and press Create Shell. Create Shell needs a negative insert in the DynaMesh; touching
          the surface it opens a hole (a drain), pulled inside it leaves the shell closed (W9P5
          [01:05:29]). Measure the wall afterwards with zb_print.thickness."""
          if resolution is not None:
              zb_ops.dynamesh(resolution)
          _set("dyn_thickness", thickness, tol=0.5)
          before = zb_ops.stats()
          _press("dyn_create_shell", check_enabled=False)
          _z().update(redraw_ui=True)
          after = zb_ops.stats()
          return {"thickness": thickness, "before": before, "after": after,
                  "volume_saved_internal": (before.get("volume") or 0) - (after.get("volume") or 0)}
      
      
      def hollow_copy(source_index, insert_center_mm, insert_size_mm, export_scale, resolution=128,
                      thickness=4, max_moves=500):
          """Gaboury's light shell copy, never the heavy sculpt (W9P5 [00:57:33]): duplicate the part,
          move the copy to the end of the SubTool list, drop its levels (top level kept), append a
          QSphere insert at insert_center_mm (OBJ mm) with XYZ Size insert_size_mm, set it to
          Subtract, MergeDown into the copy, DynaMesh at `resolution` (per bbox side, so relative to
          size: 64 too low and 128 enough for his 9 in car, W9P5 [00:56:58]) and Create Shell at
          `thickness` (not mm, W9P5 [01:10:32]). Keep the insert INSIDE: then outer and inner walls
          stay two pieces for keep_inner_shell, and the drain is cut later in the Live Boolean stack.
          The list order matters because MergeDown merges with the SubTool BELOW. MoveDown, the
          subtract flag surviving MergeDown and XYZ Size semantics are [verify] (live_p05)."""
          z = _z()
          k = float(export_scale)
          z.select_subtool(int(source_index))
          zb_ops.ensure_edit()
          n0 = int(z.get_subtool_count())
          _press("duplicate", check_enabled=False)
          z.update(redraw_ui=True)
          if int(z.get_subtool_count()) != n0 + 1:
              raise ZBPoseError("Duplicate did not add a SubTool")
          copy = int(z.get_active_subtool_index())
          for _ in range(max_moves):
              if copy >= int(z.get_subtool_count()) - 1:
                  break
              _press("move_down", check_enabled=False)
              z.update(redraw_ui=True)
              nxt = int(z.get_active_subtool_index())
              if nxt != copy + 1:
                  raise ZBPoseError(f"MoveDown left the copy at {nxt}, expected {copy + 1}")
              copy = nxt
          st = zb_ops.stats()
          if (st.get("sdiv_max") or 1) > 1:
              zb_ops.set_checked("sdiv", st["sdiv_max"], tol=0.5)
              zb_ops.del_levels()
          ins = append_primitive("qsphere", (16, 16, 16))
          if ins["index"] != copy + 1:
              raise ZBPoseError(f"insert at {ins['index']}, not right below the copy ({copy})")
          geometry_set(position=obj_to_internal(insert_center_mm, k), xyz_size=float(insert_size_mm) / k)
          set_boolean_mode(ins["index"], "sub")
          z.select_subtool(copy)
          n1 = int(z.get_subtool_count())
          _press("merge_down", check_enabled=False)
          z.update(redraw_ui=True)
          if int(z.get_subtool_count()) != n1 - 1:
              raise ZBPoseError("MergeDown did not merge the insert into the copy")
          z.select_subtool(copy)
          sh = dynamesh_shell(thickness, resolution)
          return {"copy": copy, "source": int(source_index), "shell": sh}
      
      
      def keep_inner_shell(polish=10, hide_outer=True):
          """Gaboury W9P5 [01:12:44] to [01:14:58], on the active shell copy made by dynamesh_shell
          with the insert pulled INSIDE (outer and inner walls are then two loose pieces; a touching
          insert opens them into one): Auto Groups (one group per piece), Groups Split, keep the
          inner piece (the smaller bbox), Polish it to take the DynaMesh steps off, hide the outer
          copy. The inner piece then goes into the Live Boolean stack as a Subtract under the
          untouched detailed part (set_boolean_mode, make_boolean_mesh). Split order [verify]."""
          z = _z()
          n0 = int(z.get_subtool_count())
          cur = int(z.get_active_subtool_index())
          zb_ops.polygroups("auto")
          _press("split_groups", check_enabled=False)
          z.update(redraw_ui=True)
          n1 = int(z.get_subtool_count())
          if n1 != n0 + 1:
              raise ZBPoseError(f"Groups Split gave {n1 - n0 + 1} pieces, expected 2 (outer, inner): an "
                                "insert touching the surface joins them; pull it inside and shell again")
          size = {}
          for i in (cur, cur + 1):
              z.select_subtool(i)
              bb = zb_ops.stats()["bbox"]
              size[i] = math.dist(bb[:3], bb[3:])
          inner = min(size, key=size.get)
          outer = cur + 1 if inner == cur else cur
          z.select_subtool(inner)
          pol = zb_ops.polish(polish) if polish else None
          hidden = set_visible(outer, False) if hide_outer else None
          z.select_subtool(inner)
          return {"inner": inner, "outer": outer, "outer_hidden": bool(hidden), "polish": pol,
                  "bbox_diag": size}
      
      
      def brief_box(size_mm, export_scale, floor_center_mm=(0.0, 0.0, 0.0), res=1):
          """Gaboury's SubTool number one (W9P5 [00:21:43] [02:04:45]): a box at the brief's size,
          width X, height Y, depth Z in mm, standing on floor_center_mm (OBJ coordinates, e.g. the
          bottom centre of the assembled bbox from zb_print.check_scale). A QCube at Initialize
          resolution 1 (6 faces, W9P5 [00:22:47]) sized with the Geometry sliders in internal units
          (mm / export scale). Hide it (set_visible) before set_export_scale and export_parts;
          show it in the review sheet to see what clips out; zb_print.envelope is the number.
          Whether X, Y and Z Size stay independent (no ratio lock) is [verify] (live_p08)."""
          k = float(export_scale)
          w, h, d = (float(s) for s in size_mm)
          c = [float(floor_center_mm[0]), float(floor_center_mm[1]) + h / 2.0, float(floor_center_mm[2])]
          prim = append_primitive("qcube", (res, res, res))
          geo = geometry_set(position=obj_to_internal(c, k), size=[w / k, h / k, d / k])
          return {"index": prim["index"], "size_mm": [w, h, d], "center_mm": c, "geometry": geo}
      
      
      def from_thickness(min_value, max_value=None, force=False):
          """PolyPaint From Thickness for the visual check (red under the minimum). The values are
          in scene units: mm only after scaling (doc); whether set_export_scale alone makes them mm
          is [verify] (live_p05). Above Preferences > Analysis > Max Thickness Polygons ZBrush does
          not refuse: it asks "Too many polygons ... Are you sure that you wish to proceed?" in a
          note (strings), and that note would stall the bridge, so THIS wrapper refuses unless
          force. The pref reads 8 on this Mac [log]; that it means millions is [verify]. This
          paints over polypaint: run it on a copy."""
          z = _z()
          faces = int(z.query_mesh3d(1)[0])
          p = _p("ft_max_polys", required=False)
          limit = z.get(p) * 1e6 if p else None
          if limit and faces > limit and not force:
              raise ZBPoseError(f"{faces} faces > Max Thickness Polygons ({limit:.0f}): a confirmation "
                                "note would block; decimate a copy first or pass force=True")
          _set("ft_min", min_value, tol=1e-3 * max(1.0, abs(min_value)))
          if max_value is not None:
              _set("ft_max", max_value, tol=1e-3 * max(1.0, abs(max_value)))
          t = time.time()
          _press("from_thickness", check_enabled=False)
          z.update(redraw_ui=True)
          return {"faces": faces, "seconds": round(time.time() - t, 2), "min": min_value, "max": max_value}
      
      
      def append_primitive(kind="qcyl_y", res=(8, 8, 8)):
          """Append a PolyMesh3D SubTool and initialise it as a quick primitive (the shipped
          'Append a QCube Subtool' macro: Append, PopUp:PolyMesh3D, Initialize). kind: qcube,
          qsphere, qcyl_x, qcyl_y, qcyl_z. Returns the new SubTool index (the last one [verify])."""
          z = _z()
          zb_ops.ensure_edit()
          _press("append", check_enabled=False)
          if not z.exists("PopUp:PolyMesh3D"):
              raise ZBPoseError("PopUp:PolyMesh3D not found after Append")
          z.press("PopUp:PolyMesh3D")
          idx = int(z.get_subtool_count()) - 1
          z.select_subtool(idx)
          for key, v in zip(("init_x_res", "init_y_res", "init_z_res"), res):
              _set(key, v, tol=0.5)
          _press("init_" + kind, check_enabled=False)
          z.update(redraw_ui=True)
          return {"index": idx, "name": subtool_name(), "stats": zb_ops.stats(), "geometry": geometry_get()}
      
      
      BOOL_BITS = {"add": 0x10, "sub": 0x20, "intersect": 0x40}
      
      
      def set_boolean_mode(index, mode=None, start=None):
          """Live Boolean role of a SubTool through its status bits (add 0x10, subtract 0x20,
          intersect 0x40, start 0x80). set_subtool_status TOGGLES the bits it is given (SDK), so
          only the bits that differ are sent; the result is read back."""
          z = _z()
          cur = int(z.get_subtool_status(index))
          want = cur & ~(0x10 | 0x20 | 0x40)
          if mode:
              want |= BOOL_BITS[mode]
          if start is not None:
              want = (want | 0x80) if start else (want & ~0x80)
          else:
              want |= cur & 0x80
          diff = (cur ^ want) & (0x10 | 0x20 | 0x40 | 0x80)
          if diff:
              z.set_subtool_status(index, diff)
          got = int(z.get_subtool_status(index))
          if (got & 0xF0) != (want & 0xF0):
              raise ZBPoseError(f"SubTool {index}: status {got:#x}, wanted {want:#x}")
          return {"index": index, "status": got}
      
      
      def make_boolean_mesh():
          """Live Boolean on, then Make Boolean Mesh: a new tool with the results; the sources
          stay (Gaboury W9P5 [02:05:48]). Keep one or two operations per hierarchy (Hasbro P08k
          [00:47:15]). Paths [verify] (live_p06)."""
          z = _z()
          zb_ops.set_checked(PATHS["live_boolean"], 1, tol=0.5)
          before = z.get_active_tool_path()
          _press("make_boolean", check_enabled=False)
          z.update(redraw_ui=True)
          return {"source_tool": before, "result_tool": z.get_active_tool_path(), "stats": zb_ops.stats()}
      
      
      MASK_KEYS = {"cavity": "mask_cavity", "peaks": "mask_peaks"}
      
      
      def decimate_to_faces(target_faces, preprocess=True, mask=None, keep_mask=False):
          """Decimation Master on the active SubTool to about target_faces (zb_ops.decimate does
          the presses and times them). Masks steer it: masked areas keep density, the count does
          not change (doc Masking; Gaboury W9P5 [01:30:30] masks the face and hands). mask:
          'cavity' or 'peaks' presses Mask By Cavity or Mask PeaksAndValleys first, a stroke-free
          stand-in for a painted mask [added] [verify]; 'keep' uses the mask already on the mesh.
          Anything changed after Pre-process, a mask included, is ignored (doc Troubleshooting),
          so a mask without preprocess is refused. Unique SubTool names first."""
          if mask and not preprocess:
              raise ZBPoseError("a mask added after Pre-process is ignored (doc): pre-process again")
          if mask and mask != "keep":
              if mask not in MASK_KEYS:
                  raise ValueError(f"mask must be one of {sorted(MASK_KEYS) + ['keep']}")
              _press(MASK_KEYS[mask], check_enabled=False)
          faces = int(_z().query_mesh3d(1)[0])
          pct = decimate_percent(faces, target_faces)
          res = zb_ops.decimate(pct, preprocess=preprocess)
          if mask and not keep_mask:
              zb_ops.mask("clear")
          res.update(target_faces=target_faces, mask=mask)
          return res
      
      
      def close_holes():
          before = zb_ops.stats()
          _press("close_holes", check_enabled=False)
          _z().update(redraw_ui=True)
          after = zb_ops.stats()
          return {"solid_before": before.get("solid"), "solid_after": after.get("solid"),
                  "faces_before": before.get("faces"), "faces_after": after.get("faces")}
      
      
      def intersection_mask():
          """ZPlugin > Intersection Masker > Create Intersection Mask on the active SubTool
          [strings] [verify]: a deterministic mask of where it crosses other SubTools, to push
          cloth out with a masked Inflate instead of Move Topological strokes."""
          _press("intersection_mask", check_enabled=False)
          _z().update(redraw_ui=True)
          return {"pressed": True}
      
      
      # ============================================================================================
      # Agent side: call a function of this module inside ZBrush through the proven bridge
      # ============================================================================================
      
      def bridge_code(func, args=(), kwargs=None):
          """Source that imports zb_pose (and through it zb_ops, zb_stroke) by path inside ZBrush,
          removes the paths and zb_* modules again (shared interpreter rule), then calls func."""
          if not func.isidentifier():
              raise ValueError(f"bad function name {func!r}")
          return (
              "import sys as _s, importlib as _il, json as _j\n"
              f"_d = {_HERE!r}\n"
              "_before = set(_s.modules)\n"
              "_s.path.insert(0, _d)\n"
              "try:\n"
              "    zb_pose = _il.import_module('zb_pose')\n"
              "finally:\n"
              "    if _d in _s.path:\n"
              "        _s.path.remove(_d)\n"
              "    for _k in set(_s.modules) - _before:\n"
              "        if _k.startswith('zb_'):\n"
              "            _s.modules.pop(_k, None)\n"
              f"result = zb_pose.{func}(*_j.loads({json.dumps(list(args))!r}), "
              f"**_j.loads({json.dumps(kwargs or {})!r}))\n")
      
      
      def call(func, *args, port=7788, timeout=120, **kwargs):
          """zb_pose.func(*args, **kwargs) inside ZBrush via zb_launch.run (main thread)."""
          added = EXPERT_SCRIPTS not in sys.path
          if added:
              sys.path.insert(0, EXPERT_SCRIPTS)
          import zb_launch
          return zb_launch.run(bridge_code(func, args, kwargs), (), port, timeout)
      
    • zb_print.py 65.6 KB
      #!/usr/bin/env python3
      """
      zb_print: print and pose gates measured on meshes exported from ZBrush (agent side, numpy).
      
      The ZBrush SDK only reports counts, bounding boxes, area, volume and is_polymesh3d_solid() for
      the active SubTool. Anything in millimetres, per region, or between two parts needs the
      exported mesh. Export each print part with zb_ops.export_obj (dialog-free, proven v03), after
      zb_pose.set_export_scale(target_mm) so the OBJ is written in millimetres, then:
      
          import sys; sys.path.insert(0, "<skills/scenario-zbrush-pose-print/scripts>")
          import zb_print as zp
          rep = zp.print_report({"torso": "/abs/torso.obj", "arm_l": "/abs/arm_l.obj"},
                                target_height_mm=300, min_wall_mm=1.0, out_json="/abs/report.json")
          zp.write_stl("/abs/torso.obj", "/abs/torso.stl")                 # binary, mm, Z up
          zp.write_3mf({"torso": "/abs/torso.obj"}, "/abs/figure.3mf")     # unit="millimeter"
      
          python3 zb_print.py report torso.obj arm_l.obj --target-height-mm 300 --json out.json
      
      OBJ loading and the base audit reuse zb_audit (<skills>/scenario-zbrush-expert/scripts). Numbers in
      the gates come from the experts named in GATE_NOTES; [added] marks this toolkit's own
      defaults, meant to be overridden by the brief. Offline tests: tests/code/zbrush-pose-print/.
      Nothing here needs ZBrush; the meshes it reads have not yet come from a live ZBrush session.
      
      Sections
        loading      load, obj_header, face_groups
        geometry     tri_index, sample_surface, RayIndex (uniform grid + Moller-Trumbore)
        print gates  shells, thickness, clearance, interference, check_scale, fits_volume,
                     center_of_mass, balance, deviation, outer_drift, envelope, caliper
        planning     scale_for, inflate_calibration, inflate_for, decimation_percent, section,
                     plan_keys
        pose checks  same_order, rigid_fit, rigid_delta, stretch_report, group_vertices,
                     joint_centres, loose_pieces, rigid_pieces, follows, placement_correction,
                     piece_interference
        delivery     write_stl, read_stl, write_3mf, read_3mf, print_report
      """
      
      __version__ = "0.1"  # ZBrush Expert Skills v0.1 (2026-09-24)
      import argparse
      import json
      import math
      import os
      import re
      import struct
      import sys
      import time
      import xml.etree.ElementTree as ET
      import zipfile
      
      import numpy as np
      
      HERE = os.path.dirname(os.path.abspath(__file__))
      EXPERT_SCRIPTS = os.path.normpath(os.path.join(HERE, "..", "..", "scenario-zbrush-expert", "scripts"))
      if EXPERT_SCRIPTS not in sys.path:
          sys.path.insert(0, EXPERT_SCRIPTS)
      import zb_audit  # noqa: E402  (lead toolkit: OBJ reader, audit, verdict)
      
      GATE_NOTES = {
          "scale": "ZBrush and STL carry no units: the size is set up front and checked in mm "
                   "(3D Print Hub doc; Gaboury W9P5 [00:21:43]); tolerance 0.5 % [added]",
          "watertight": "closed, outward normals, no non-manifold edges (3D Print Hub doc, "
                        "Preparing the Model)",
          "wall": "1 mm minimum wall, 0.85 mm was flagged (Bennett/Hasbro P08k [00:10:57] "
                  "[00:16:46]); set min_wall_mm from the process",
          "edge": "cut edges at least 0.5 mm; about 0.2 mm floats away on the tray (Hasbro P08k "
                  "[00:48:28] [00:11:31])",
          "clearance": "0.12 mm fit offset on most parts for Formlabs prototypes (Hasbro P08k "
                       "[00:37:55]); measured between the peg and the socket cutter",
          "faces": "about 1M polygons per mesh is more than enough; 10k to 20k triangles can "
                   "still print (Gaboury W9P5 [01:11:40])",
          "cavity": "a sealed hollow traps resin and makes suction cups: vent or drain it "
                    "(Gaboury W9P5 [01:35:21] on cups; trapped resin [added])",
          "floating": "no floating parts: every piece supported (3D Print Hub doc)",
          "balance": "the pose must stand steady (Plouffe lakC [00:25:45]); centre of mass over "
                     "the footprint as the measurable proxy [added]",
          "build": "each part must fit the printer volume; the car body was split for the Form 2 "
                   "bed (Gaboury W9P5 [00:49:27])",
          "units_stated": "STL maintains scale but not units: state mm in the delivery "
                          "(3D Print Hub doc, Set the Unit and Size)",
      }
      
      
      # ============================================================================================
      # Loading
      # ============================================================================================
      
      def load(src):
          """zb_audit.Mesh from an OBJ path (header attached as .header) or a Mesh."""
          if isinstance(src, zb_audit.Mesh):
              if not hasattr(src, "header"):
                  src.header = {}
              return src
          m = zb_audit.load_obj(src)
          m.header = obj_header(src)
          return m
      
      
      _NUM = r"([-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)"
      
      
      def obj_header(path, max_lines=60):
          """ZBrush OBJ header: '#Auto scale x= y= z=' and '#Auto offset ...' (written by
          Tool:Export, v03 fixture), vertex and face counts. scale is the Export Scale applied
          on export [verify that it follows Tool:Export:Scale]."""
          out = {"zbrush": False, "scale": None, "offset": None, "vertex_count": None,
                 "face_count": None}
          with open(path, "r", errors="ignore") as fh:
              for i, line in enumerate(fh):
                  if i >= max_lines or line.startswith("v "):
                      break
                  s = line.strip()
                  if "zbrush" in s.lower():
                      out["zbrush"] = True
                  for key, tag in (("scale", "scale"), ("offset", "offset")):
                      m = re.match(r"#\s*Auto " + tag + r"\s+x=" + _NUM + r"\s+y=" + _NUM +
                                   r"\s+z=" + _NUM, s)
                      if m:
                          out[key] = [float(m.group(k)) for k in (1, 2, 3)]
                  m = re.match(r"#\s*Vertex Count\s+(\d+)", s)
                  if m:
                      out["vertex_count"] = int(m.group(1))
                  m = re.match(r"#\s*Face Count\s+(\d+)", s)
                  if m:
                      out["face_count"] = int(m.group(1))
          return out
      
      
      def face_groups(path):
          """Group index per face, in the face order of zb_audit.load_obj, from 'g' lines (ZBrush
          writes polygroups as groups when Tool:Export:Grp is on [verify]) or 'usemtl' lines.
          Returns (array of int per face, list of names)."""
          g_cur = m_cur = None
          g_ids, m_ids = [], []
          g_idx, m_idx = {}, {}
          with open(path, "r", errors="ignore") as fh:
              for line in fh:
                  if line.startswith("g "):
                      g_cur = line[2:].strip() or None
                  elif line.startswith("usemtl "):
                      m_cur = line[7:].strip() or None
                  elif line.startswith("f "):
                      g_ids.append(g_idx.setdefault(g_cur, len(g_idx)))
                      m_ids.append(m_idx.setdefault(m_cur, len(m_idx)))
          use_g = len(g_idx) > 1 or len(m_idx) <= 1
          ids, idx = (g_ids, g_idx) if use_g else (m_ids, m_idx)
          names = [k if k is not None else "" for k, _ in sorted(idx.items(), key=lambda kv: kv[1])]
          return np.asarray(ids, dtype=np.int64), names
      
      
      # ============================================================================================
      # Geometry
      # ============================================================================================
      
      def tri_index(mesh):
          """Fan triangulation: (T,3) vertex indices and the face of each triangle."""
          sizes = mesh.sizes
          ntri = np.clip(sizes - 2, 0, None)
          face = np.repeat(np.arange(len(sizes)), ntri)
          if not len(face):
              return np.zeros((0, 3), dtype=np.int64), face
          start = np.concatenate([[0], np.cumsum(ntri)[:-1]])
          local = np.arange(len(face)) - np.repeat(start, ntri) + 1
          st = mesh.starts[face]
          tri = np.stack([mesh.flat[st], mesh.flat[st + local], mesh.flat[st + local + 1]], axis=1)
          return tri, face
      
      
      def _tris(mesh):
          tri, face = tri_index(mesh)
          v = mesh.verts
          return tri, face, v[tri[:, 0]], v[tri[:, 1]], v[tri[:, 2]]
      
      
      def _signed_volume(p0, p1, p2):
          return float((p0 * np.cross(p1, p2)).sum() / 6.0)
      
      
      def sample_surface(p0, p1, p2, n, seed=0):
          """n area-weighted random points on the triangles: (points, triangle ids, unit normals)."""
          cr = np.cross(p1 - p0, p2 - p0)
          area = 0.5 * np.linalg.norm(cr, axis=1)
          tot = float(area.sum())
          if tot <= 0:
              raise ValueError("mesh has no area")
          rng = np.random.default_rng(seed)
          tid = rng.choice(len(area), size=int(n), p=area / tot)
          r1 = np.sqrt(rng.random(int(n)))
          r2 = rng.random(int(n))
          a, b, c = 1 - r1, r1 * (1 - r2), r1 * r2
          pts = a[:, None] * p0[tid] + b[:, None] * p1[tid] + c[:, None] * p2[tid]
          nrm = cr[tid] / np.maximum(np.linalg.norm(cr[tid], axis=1), 1e-300)[:, None]
          return pts, tid, nrm
      
      
      _CORNERS = np.array([[i, j, k] for i in (0, 1) for j in (0, 1) for k in (0, 1)], dtype=np.int64)
      
      
      class RayIndex:
          """Uniform grid over triangle bounding boxes plus vectorised Moller-Trumbore. Good for
          short rays (walls, gaps, deviations) on meshes up to a few million triangles."""
      
          def __init__(self, p0, p1, p2, cell=None):
              self.p0 = np.asarray(p0, dtype=np.float64)
              self.e1 = np.asarray(p1, dtype=np.float64) - self.p0
              self.e2 = np.asarray(p2, dtype=np.float64) - self.p0
              n = np.cross(self.e1, self.e2)
              self.nrm = n / np.maximum(np.linalg.norm(n, axis=1), 1e-300)[:, None]
              lo = np.minimum(np.minimum(self.p0, p1), p2)
              hi = np.maximum(np.maximum(self.p0, p1), p2)
              self.lo, self.hi = lo.min(axis=0), hi.max(axis=0)
              self.diag = float(np.linalg.norm(self.hi - self.lo)) or 1.0
              ext = (hi - lo).max(axis=1)
              if cell is None:
                  cell = max(2.0 * float(np.median(ext)) if len(ext) else self.diag, self.diag / 256.0)
              self.cell = float(cell)
              self.origin = self.lo - 1e-9 * self.diag
              imin = np.floor((lo - self.origin) / self.cell).astype(np.int64)
              imax = np.floor((hi - self.origin) / self.cell).astype(np.int64)
              self.dims = imax.max(axis=0) + 1 if len(imax) else np.ones(3, dtype=np.int64)
              span = imax - imin + 1
              cnt = span.prod(axis=1)
              tid = np.repeat(np.arange(len(self.p0)), cnt)
              k = np.arange(len(tid)) - np.repeat(np.cumsum(cnt) - cnt, cnt)
              sy, sz = span[tid, 1], span[tid, 2]
              cells = imin[tid] + np.stack([k // (sy * sz), (k // sz) % sy, k % sz], axis=1)
              keys = self._pack(cells)
              order = np.argsort(keys, kind="stable")
              self.keys, self.tids = keys[order], tid[order]
      
          def _pack(self, c):
              return (c[:, 0] * self.dims[1] + c[:, 1]) * self.dims[2] + c[:, 2]
      
          def candidates(self, o, d, t0, t1):
              """Triangles whose grid cells the segment o + t d, t in [t0, t1], passes through."""
              t0, t1 = max(float(t0), -self.diag), min(float(t1), 3.0 * self.diag)
              m = max(1, int(math.ceil((t1 - t0) / (0.5 * self.cell))))
              ts = np.linspace(t0, t1, m + 1)
              c = np.floor((o[None, :] + ts[:, None] * d[None, :] - self.origin) / self.cell).astype(np.int64)
              a, b = c[:-1], c[1:]
              allc = (np.minimum(a, b)[:, None, :] + _CORNERS[None, :, :] * np.abs(b - a)[:, None, :]).reshape(-1, 3)
              allc = allc[np.all((allc >= 0) & (allc < self.dims), axis=1)]
              if not len(allc):
                  return np.zeros(0, dtype=np.int64)
              keys = np.unique(self._pack(allc))
              left = np.searchsorted(self.keys, keys, "left")
              right = np.searchsorted(self.keys, keys, "right")
              ln = right - left
              if not ln.sum():
                  return np.zeros(0, dtype=np.int64)
              idx = np.repeat(left, ln) + (np.arange(ln.sum()) - np.repeat(np.cumsum(ln) - ln, ln))
              return np.unique(self.tids[idx])
      
          def hits(self, o, d, t0, t1, cand=None):
              """All hits with t0 < t <= t1: (t array, triangle ids)."""
              o = np.asarray(o, dtype=np.float64)
              d = np.asarray(d, dtype=np.float64)
              cand = self.candidates(o, d, t0, t1) if cand is None else cand
              if not len(cand):
                  return np.zeros(0), cand
              e1, e2, p0 = self.e1[cand], self.e2[cand], self.p0[cand]
              pvec = np.cross(d[None, :], e2)
              det = np.einsum("ij,ij->i", e1, pvec)
              ok = np.abs(det) > 1e-14 * np.einsum("ij,ij->i", e1, e1) * np.sqrt(np.einsum("ij,ij->i", e2, e2)) + 1e-300
              inv = np.where(ok, 1.0 / np.where(ok, det, 1.0), 0.0)
              tv = o[None, :] - p0
              u = np.einsum("ij,ij->i", tv, pvec) * inv
              q = np.cross(tv, e1)
              v = (q @ d) * inv
              t = np.einsum("ij,ij->i", e2, q) * inv
              tol = 1e-9
              hit = ok & (u >= -tol) & (v >= -tol) & (u + v <= 1 + tol) & (t > t0) & (t <= t1)
              return t[hit], cand[hit]
      
          def nearest(self, o, d, t0, t1, exclude=None):
              t, ids = self.hits(o, d, t0, t1)
              if exclude is not None and len(ids):
                  keep = ids != exclude
                  t, ids = t[keep], ids[keep]
              if not len(t):
                  return math.inf, -1
              k = int(np.argmin(t))
              return float(t[k]), int(ids[k])
      
          def first_exit(self, o, d, t0, t1, flip=False):
              """Nearest hit on a face whose outward normal points along d: where a ray leaves
              the solid (flip for an inside-out mesh)."""
              t, ids = self.hits(o, d, t0, t1)
              if not len(t):
                  return math.inf, -1
              facing = (self.nrm[ids] @ np.asarray(d, dtype=np.float64)) * (-1.0 if flip else 1.0)
              t, ids = t[facing > 0], ids[facing > 0]
              if not len(t):
                  return math.inf, -1
              k = int(np.argmin(t))
              return float(t[k]), int(ids[k])
      
          def inside(self, points, directions=None):
              """Even-odd test with three skewed rays per point, majority vote."""
              dirs = directions or ((0.5773, 0.5774, 0.5775), (-0.6, 0.64, 0.48), (0.28, -0.96, 0.02))
              dirs = [np.asarray(v) / np.linalg.norm(v) for v in dirs]
              out = []
              for p in np.asarray(points, dtype=np.float64):
                  votes = 0
                  for d in dirs:
                      t, _ = self.hits(p, d, 0.0, 2.5 * self.diag)
                      t = np.unique(np.round(t / (1e-9 * self.diag)))  # an edge hit counts once
                      votes += len(t) % 2
                  out.append(votes >= 2)
              return np.asarray(out, dtype=bool)
      
      
      def _as_parts(parts):
          if isinstance(parts, dict):
              return dict(parts)
          if isinstance(parts, (str, zb_audit.Mesh)):
              parts = [parts]
          out = {}
          for i, p in enumerate(parts):
              name = os.path.splitext(os.path.basename(p))[0] if isinstance(p, str) else f"part{i}"
              out[name if name not in out else f"{name}_{i}"] = p
          return out
      
      
      def _stats(x):
          x = np.asarray(x, dtype=np.float64)
          if not len(x):
              return {"n": 0}
          return {"n": int(len(x)), "min": round(float(x.min()), 4),
                  "p01": round(float(np.percentile(x, 1)), 4),
                  "p05": round(float(np.percentile(x, 5)), 4),
                  "p50": round(float(np.percentile(x, 50)), 4),
                  "p95": round(float(np.percentile(x, 95)), 4),
                  "p99": round(float(np.percentile(x, 99)), 4),
                  "max": round(float(x.max()), 4), "mean": round(float(x.mean()), 4)}
      
      
      def _clusters(points, values, cell, top=12):
          """Group flagged sample points into grid cells: centroid, count, worst value, bbox."""
          if not len(points):
              return []
          key = np.floor(points / cell).astype(np.int64)
          _, inv = np.unique(key, axis=0, return_inverse=True)
          inv = inv.reshape(-1)
          out = []
          for g in range(int(inv.max()) + 1):
              sel = inv == g
              p = points[sel]
              out.append({"center": p.mean(axis=0).round(3).tolist(), "count": int(sel.sum()),
                          "worst": round(float(values[sel].min()), 4),
                          "bbox": [p.min(axis=0).round(3).tolist(), p.max(axis=0).round(3).tolist()]})
          out.sort(key=lambda c: (c["worst"], -c["count"]))
          return out[:top]
      
      
      # ============================================================================================
      # Print gates
      # ============================================================================================
      
      def shells(mesh):
          """Connected shells with signed volume: 'solid' (> 0), 'cavity' (< 0, an inner wall
          facing inward, as Create Shell or a boolean hollow makes), 'open' (has boundary edges).
          A cavity inside a solid with no opening is sealed."""
          m = load(mesh)
          a, b = zb_audit._edges(m)
          nv = len(m.verts)
          lab = zb_audit._shells(nv, np.minimum(a, b), np.maximum(a, b))
          tri, face, p0, p1, p2 = _tris(m)
          face_lab = lab[m.flat[m.starts]] if len(m.sizes) else np.zeros(0, dtype=np.int64)
          tri_lab = face_lab[face]
          n = max(nv, 1)
          und = np.minimum(a, b) * n + np.maximum(a, b)
          ukeys, counts = np.unique(und, return_counts=True)
          bverts = np.unique(np.concatenate([ukeys[counts == 1] // n, ukeys[counts == 1] % n])) \
              if (counts == 1).any() else np.zeros(0, dtype=np.int64)
          open_labels = set(lab[bverts].tolist())
          rows = []
          for s in np.unique(face_lab):
              sel = tri_lab == s
              vol = _signed_volume(p0[sel], p1[sel], p2[sel])
              vs = m.verts[np.unique(tri[sel].reshape(-1))]
              kind = "open" if int(s) in open_labels else ("solid" if vol > 0 else "cavity")
              rows.append({"label": int(s), "faces": int((face_lab == s).sum()), "kind": kind,
                           "volume": round(vol, 4), "bbox": [vs.min(axis=0).round(4).tolist(),
                                                             vs.max(axis=0).round(4).tolist()]})
          rows.sort(key=lambda r: -abs(r["volume"]))
          solids = [r for r in rows if r["kind"] == "solid"]
          for r in rows:
              if r["kind"] != "cavity":
                  continue
              lo, hi = np.array(r["bbox"][0]), np.array(r["bbox"][1])
              r["inside_of"] = next((s["label"] for s in solids
                                     if np.all(np.array(s["bbox"][0]) <= lo + 1e-9)
                                     and np.all(np.array(s["bbox"][1]) >= hi - 1e-9)), None)
          sealed = [r for r in rows if r["kind"] == "cavity" and r.get("inside_of") is not None]
          return {"shells": rows, "solids": len(solids), "cavities": sum(r["kind"] == "cavity" for r in rows),
                  "open": sum(r["kind"] == "open" for r in rows), "sealed_cavities": len(sealed),
                  "floating_solids": max(0, len(solids) - 1), "expected_shells": len(rows),
                  "net_volume": round(sum(r["volume"] for r in rows), 4)}
      
      
      def thickness(mesh, min_wall_mm=1.0, samples=4000, max_mm=None, seed=0, max_thin_share=0.002,
                    index=None):
          """Wall and feature thickness by ray casting: from area-weighted surface samples, along
          the inward normal, to the first opposite wall (the Ray method; ZBrush's From Thickness
          also offers Ray and a rolling Ball in 2026.2.1 [strings]). Units are the mesh units: mm
          once exported with the export scale set. A hit on a front face means internal geometry
          (overlapping shells that were never merged). Tips of tapering features read thin by
          nature (Hasbro allows knife-edge branch tips): judge the listed spots on a render.
          max_thin_share 0.2 % of samples is [added]."""
          m = load(mesh)
          tri, face, p0, p1, p2 = _tris(m)
          flip = _signed_volume(p0, p1, p2) < 0
          idx = index or RayIndex(p0, p1, p2)
          L = float(max_mm or 5.0 * min_wall_mm)
          pts, tid, nrm = sample_surface(p0, p1, p2, samples, seed)
          sgn = -1.0 if flip else 1.0
          nrm = nrm * sgn
          eps = 1e-7 * idx.diag
          th = np.full(len(pts), np.inf)
          facing = np.zeros(len(pts), dtype=np.int8)
          for i in range(len(pts)):
              d = -nrm[i]
              t, j = idx.nearest(pts[i] - eps * nrm[i], d, 0.0, L, exclude=tid[i])
              if j >= 0:
                  th[i] = t + eps
                  facing[i] = 1 if float(idx.nrm[j] @ d) * sgn > 0 else -1
          wall = facing == 1
          thin = wall & (th < min_wall_mm)
          vals = np.where(np.isfinite(th), th, L)
          rep = {"units": "mesh units (mm after set_export_scale)", "samples": int(len(pts)),
                 "ray_max": L, "min_wall": min_wall_mm, "flipped_normals": bool(flip),
                 "walls": _stats(vals[wall]), "thin_share": round(float(thin.mean()), 5),
                 "thin_count": int(thin.sum()),
                 "internal_share": round(float((facing == -1).mean()), 5),
                 "no_hit_share": round(float((facing == 0).mean()), 5),
                 "spots": _clusters(pts[thin], th[thin], max(3.0 * min_wall_mm, idx.diag / 60.0)),
                 "source": GATE_NOTES["wall"]}
          rep["ok"] = rep["thin_share"] <= max_thin_share
          return rep
      
      
      def clearance(peg, socket, target_mm=None, samples=3000, max_mm=None, seed=0, tol_frac=0.3):
          """Signed gap from the peg surface to the socket (cutter or cavity) along the peg's
          normal: positive = clearance, negative = interference. Only peg samples that see the
          socket within max_mm count. Gate with target_mm: no interference, median within
          tol_frac of the target and p05 at least half of it ([added] tolerances)."""
          A, B = load(peg), load(socket)
          _, _, a0, a1, a2 = _tris(A)
          _, _, b0, b1, b2 = _tris(B)
          idx = RayIndex(b0, b1, b2)
          L = float(max_mm or (4.0 * target_mm if target_mm else 0.01 * idx.diag))
          pts, _, nrm = sample_surface(a0, a1, a2, samples, seed)
          if _signed_volume(a0, a1, a2) < 0:
              nrm = -nrm
          t0 = -1e-9 * idx.diag
          gaps, where = [], []
          for i in range(len(pts)):
              t_out, _ = idx.nearest(pts[i], nrm[i], t0, L)
              t_in, _ = idx.nearest(pts[i], -nrm[i], t0, L)
              if math.isinf(t_out) and math.isinf(t_in):
                  continue
              gaps.append(t_out if t_out <= t_in else -t_in)
              where.append(pts[i])
          g = np.asarray(gaps)
          rep = {"considered": int(len(g)), "samples": int(len(pts)), "ray_max": L,
                 "gap": _stats(g), "interference_share": round(float((g < 0).mean()), 5) if len(g) else None,
                 "target": target_mm, "source": GATE_NOTES["clearance"]}
          if len(g):
              bad = g < 0
              rep["interference_spots"] = _clusters(np.asarray(where)[bad], g[bad], max(L, idx.diag / 40.0), 6)
          if target_mm is not None and len(g):
              rep["ok"] = bool(rep["interference_share"] == 0
                               and abs(rep["gap"]["p50"] - target_mm) <= tol_frac * target_mm
                               and rep["gap"]["p05"] >= 0.5 * target_mm)
          else:
              rep["ok"] = bool(len(g)) and rep["interference_share"] == 0
          return rep
      
      
      def interference(a, b, samples=400, seed=0):
          """Share of a's vertices inside b (even-odd, three rays): body against a weapon or a
          cape that will print separately must be 0."""
          A, B = load(a), load(b)
          _, _, b0, b1, b2 = _tris(B)
          idx = RayIndex(b0, b1, b2)
          rng = np.random.default_rng(seed)
          pick = rng.choice(len(A.verts), size=min(int(samples), len(A.verts)), replace=False)
          ins = idx.inside(A.verts[pick])
          pts = A.verts[pick][ins]
          return {"tested": int(len(pick)), "inside_share": round(float(ins.mean()), 5),
                  "spots": _clusters(pts, np.zeros(len(pts)), idx.diag / 30.0, 6), "ok": not ins.any()}
      
      
      def check_scale(parts, target_height_mm, up_axis=1, tol_pct=0.5):
          """Assembled height (union bbox of all parts) against the target, plus one export scale
          shared by every part (a part appended from another ZTool arrives at a different scale:
          Gaboury W9P5 [02:04:12], Hasbro P08k [00:56:51])."""
          ps = _as_parts(parts)
          los, his, scales = [], [], {}
          for name, src in ps.items():
              m = load(src)
              los.append(m.verts.min(axis=0))
              his.append(m.verts.max(axis=0))
              scales[name] = (m.header or {}).get("scale")
          lo, hi = np.min(los, axis=0), np.max(his, axis=0)
          size = hi - lo
          h = float(size[up_axis])
          err = abs(h - target_height_mm) / target_height_mm * 100.0
          known = [tuple(round(x, 6) for x in s) for s in scales.values() if s]
          rep = {"height_mm": round(h, 4), "target_mm": target_height_mm, "error_pct": round(err, 3),
                 "size_mm": size.round(4).tolist(), "bbox": [lo.round(4).tolist(), hi.round(4).tolist()],
                 "export_scales": scales, "scales_consistent": len(set(known)) <= 1,
                 "ok": err <= tol_pct, "source": GATE_NOTES["scale"]}
          if abs(float(size.max()) - 2.0) < 0.05:
              rep["warning"] = "longest side is about 2: ZBrush units (Scale Unify), not mm; set the export scale"
          return rep
      
      
      def scale_for(real_height_mm, ratio, shrink_pct=0.0):
          """Target height for a scale ratio ('1/6', 6 or 0.1667) with optional shrink
          compensation (Hasbro sculpts PVC at 104 %: height / 12 * 1.04 for 6 in, P08k [00:17:51])."""
          if isinstance(ratio, str):
              num, den = (float(x) for x in ratio.split("/"))
              f = num / den
          else:
              f = float(ratio)
              f = 1.0 / f if f > 1 else f
          return real_height_mm * f * (1.0 + shrink_pct / 100.0)
      
      
      def fits_volume(size_mm, build_mm, margin_mm=0.0):
          """Axis-permutation fit of a part's bbox into the build volume [added]: returns the
          permutation (part axis per printer axis) or None."""
          import itertools
          size = [float(s) for s in size_mm]
          lim = [float(b) - margin_mm for b in build_mm]
          for perm in itertools.permutations(range(3)):
              if all(size[perm[k]] <= lim[k] for k in range(3)):
                  return {"fits": True, "part_axis_per_printer_axis": list(perm),
                          "upright": perm[2] == 1, "size_mm": size, "build_mm": list(build_mm)}
          return {"fits": False, "size_mm": size, "build_mm": list(build_mm)}
      
      
      def center_of_mass(meshes):
          """Volume-weighted centre of mass of closed meshes (signed tetrahedra; cavities count
          negatively) and the total volume."""
          tot, acc = 0.0, np.zeros(3)
          for src in (meshes if isinstance(meshes, (list, tuple)) else [meshes]):
              _, _, p0, p1, p2 = _tris(load(src))
              vol = (p0 * np.cross(p1, p2)).sum(axis=1) / 6.0
              tot += float(vol.sum())
              acc += ((p0 + p1 + p2) / 4.0 * vol[:, None]).sum(axis=0)
          if abs(tot) < 1e-300:
              raise ValueError("zero volume: open meshes?")
          return acc / tot, tot
      
      
      def _hull2d(pts):
          p = sorted(set(map(tuple, np.round(pts, 6).tolist())))
          if len(p) < 3:
              return p
      
          def cross(o, a, b):
              return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
          lower, upper = [], []
          for q in p:
              while len(lower) >= 2 and cross(lower[-2], lower[-1], q) <= 0:
                  lower.pop()
              lower.append(q)
          for q in reversed(p):
              while len(upper) >= 2 and cross(upper[-2], upper[-1], q) <= 0:
                  upper.pop()
              upper.append(q)
          return lower[:-1] + upper[:-1]
      
      
      def _margin_in_hull(c, hull):
          """Signed distance from c to the convex hull boundary (positive inside)."""
          if len(hull) < 3:
              return -math.inf
          best = math.inf
          for i in range(len(hull)):
              a, b = np.array(hull[i]), np.array(hull[(i + 1) % len(hull)])
              e = b - a
              ln = float(np.linalg.norm(e)) or 1e-300
              best = min(best, float((e[0] * (c[1] - a[1]) - e[1] * (c[0] - a[0])) / ln))
          return best
      
      
      def balance(meshes, up_axis=1, band_mm=1.0):
          """Does the posed figure stand? Centre of mass projected on the ground plane against the
          convex hull of the contact points (vertices within band_mm of the lowest point)."""
          ms = [load(s) for s in (meshes if isinstance(meshes, (list, tuple)) else [meshes])]
          com, vol = center_of_mass(ms)
          allv = np.concatenate([m.verts for m in ms])
          ground = float(allv[:, up_axis].min())
          contact = allv[allv[:, up_axis] <= ground + band_mm]
          plane = [k for k in range(3) if k != up_axis]
          hull = _hull2d(contact[:, plane])
          margin = _margin_in_hull(com[plane], hull)
          return {"com": com.round(4).tolist(), "volume": round(vol, 4), "ground": round(ground, 4),
                  "contact_points": int(len(contact)), "hull": [list(h) for h in hull],
                  "margin_mm": round(margin, 4) if math.isfinite(margin) else None,
                  "stands": bool(math.isfinite(margin) and margin > 0), "source": GATE_NOTES["balance"]}
      
      
      def deviation(original, decimated, samples=3000, max_mm=None, tol_mm=None, seed=0):
          """Distance from the decimated surface to the original (rays both ways along the
          decimated normal): the numeric partner of Gaboury's 'stop one step before faceting'.
          tol_mm: set it from the printer's XY resolution [added]."""
          O, D = load(original), load(decimated)
          _, _, o0, o1, o2 = _tris(O)
          _, _, d0, d1, d2 = _tris(D)
          idx = RayIndex(o0, o1, o2)
          L = float(max_mm or 0.02 * idx.diag)
          pts, _, nrm = sample_surface(d0, d1, d2, samples, seed)
          t0 = -1e-9 * idx.diag
          dev, miss = [], 0
          for i in range(len(pts)):
              a, _ = idx.nearest(pts[i], nrm[i], t0, L)
              b, _ = idx.nearest(pts[i], -nrm[i], t0, L)
              t = min(a, b)
              if math.isinf(t):
                  miss += 1
              else:
                  dev.append(abs(t))
          rep = {"deviation": _stats(dev), "missed_share": round(miss / len(pts), 5), "ray_max": L,
                 "tol_mm": tol_mm}
          rep["ok"] = None if tol_mm is None else bool(dev and rep["deviation"]["p99"] <= tol_mm and miss == 0)
          return rep
      
      
      def outer_drift(before, after, tol_mm=0.02, samples=3000, seed=0):
          """How far the outside moved between a part before and after a wall fix or a hollowing:
          rays from the BEFORE surface to the AFTER one. Hasbro fixes thin walls from the inside so
          the outside does not change (P08k [00:16:46]); with the inner shell as the cutter the
          outside of the detailed part should not move at all. tol_mm 0.02 is [added]."""
          rep = deviation(after, before, samples=samples, tol_mm=tol_mm, seed=seed)
          rep["note"] = "samples on the before surface; p99 above tol means the outside was changed"
          return rep
      
      
      def envelope(parts, size_mm, up_axis=1):
          """Assembled size against the brief box (Gaboury's first SubTool: width X, height Y,
          depth Z in mm, W9P5 [00:21:43] [02:08:31]): per-axis overflow in mm, bottom centre of
          the assembly (the floor point for zb_pose.brief_box)."""
          ps = _as_parts(parts)
          los, his = [], []
          for src in ps.values():
              m = load(src)
              los.append(m.verts.min(axis=0))
              his.append(m.verts.max(axis=0))
          lo, hi = np.min(los, axis=0), np.max(his, axis=0)
          size = hi - lo
          box = np.asarray([float(s) for s in size_mm])
          over = np.maximum(size - box, 0.0)
          floor = (lo + hi) / 2.0
          floor[up_axis] = lo[up_axis]
          return {"size_mm": size.round(4).tolist(), "box_mm": box.tolist(), "overflow_mm": over.round(4).tolist(),
                  "floor_center_mm": floor.round(4).tolist(), "ok": bool(not over.any()),
                  "source": "nothing may clip out of the brief box (Gaboury W9P5 [02:08:31])"}
      
      
      def caliper(mesh, a, b):
          """Gaboury's Transpose-line caliper (W9P5 [00:34:29] [00:36:20]) on the export: both ends
          snap to the nearest vertex, as the action line snaps to points; distance in mm."""
          m = load(mesh)
          ends = []
          for p in (a, b):
              p = np.asarray(p, dtype=np.float64)
              k = int(np.argmin(np.linalg.norm(m.verts - p, axis=1)))
              ends.append({"vertex": k, "point": m.verts[k].round(4).tolist(),
                           "snap_mm": round(float(np.linalg.norm(m.verts[k] - p)), 4)})
          d = float(np.linalg.norm(m.verts[ends[0]["vertex"]] - m.verts[ends[1]["vertex"]]))
          return {"distance_mm": round(d, 4), "ends": ends}
      
      
      # ============================================================================================
      # Planning
      # ============================================================================================
      
      def decimation_percent(faces_now, target_faces):
          """Decimation Master '% of decimation' that keeps about target_faces (100 = none,
          0.01 = maximum, doc)."""
          return round(min(100.0, max(0.01, 100.0 * float(target_faces) / float(faces_now))), 2)
      
      
      def inflate_calibration(points):
          """points: [(inflate_value, measured_offset_mm), ...], the offset measured with
          clearance() between a part and its inflated duplicate. Least squares through the origin;
          Inflate is not in mm and depends on the ZTool's unit space (Hasbro P08k [00:56:20]
          [00:56:51]). 'linear' when the worst residual is under 10 % of the largest offset
          [added]."""
          v = np.array([p[0] for p in points], dtype=np.float64)
          o = np.array([p[1] for p in points], dtype=np.float64)
          slope = float((v * o).sum() / (v * v).sum())
          res = np.abs(o - slope * v)
          return {"mm_per_unit": slope, "points": [list(map(float, p)) for p in points],
                  "max_residual_mm": round(float(res.max()), 5),
                  "linear": bool(res.max() <= 0.1 * np.abs(o).max())}
      
      
      def inflate_for(offset_mm, calibration):
          slope = calibration["mm_per_unit"] if isinstance(calibration, dict) else float(calibration)
          return offset_mm / slope
      
      
      def _basis(n):
          n = np.asarray(n, dtype=np.float64)
          n = n / np.linalg.norm(n)
          helper = np.array([1.0, 0, 0]) if abs(n[0]) < 0.9 else np.array([0, 1.0, 0])
          u = np.cross(n, helper)
          u /= np.linalg.norm(u)
          return n, u, np.cross(n, u)
      
      
      def section(mesh, point, normal):
          """Cross-section of a closed mesh by a plane: oriented 2D segments (outer boundary
          counter-clockwise seen from +normal, holes clockwise), net area and 2D bbox."""
          m = load(mesh)
          tri, _, p0, p1, p2 = _tris(m)
          n, u, v = _basis(normal)
          point = np.asarray(point, dtype=np.float64)
          sd = (m.verts - point) @ n
          scale = float(np.abs(sd).max()) or 1.0
          sd = np.where(np.abs(sd) < 1e-12 * scale, 1e-12 * scale, sd)
          s = sd[tri]
          cross = (s.min(axis=1) < 0) & (s.max(axis=1) > 0)
          if not cross.any():
              return {"segments": np.zeros((0, 2, 2)), "area": 0.0, "bbox": None, "point": point.tolist(),
                      "normal": n.tolist(), "u": u.tolist(), "v": v.tolist()}
          P = np.stack([p0, p1, p2], axis=1)[cross]
          S = s[cross]
          tn = np.cross(P[:, 1] - P[:, 0], P[:, 2] - P[:, 0])
          ends = []
          for i, j in ((0, 1), (1, 2), (2, 0)):
              si, sj = S[:, i], S[:, j]
              hit = si * sj < 0
              f = np.where(hit, si / np.where(hit, si - sj, 1.0), 0.0)
              ends.append((hit, P[:, i] + (P[:, j] - P[:, i]) * f[:, None]))
          seg = []
          for k in range(len(P)):
              e = [pt[k] for h, pt in ends if h[k]]
              if len(e) == 2:
                  seg.append(e)
          seg = np.asarray(seg)
          tn = tn[[k for k in range(len(P)) if sum(bool(h[k]) for h, _ in ends) == 2]]
          a2 = np.stack([(seg[:, 0] - point) @ u, (seg[:, 0] - point) @ v], axis=1)
          b2 = np.stack([(seg[:, 1] - point) @ u, (seg[:, 1] - point) @ v], axis=1)
          m2 = np.stack([tn @ u, tn @ v], axis=1)
          dd = b2 - a2
          right = np.stack([dd[:, 1], -dd[:, 0]], axis=1)
          swap = (right * m2).sum(axis=1) < 0
          a2[swap], b2[swap] = b2[swap].copy(), a2[swap].copy()
          segs = np.stack([a2, b2], axis=1)
          area = 0.5 * float((a2[:, 0] * b2[:, 1] - b2[:, 0] * a2[:, 1]).sum())
          allp = segs.reshape(-1, 2)
          return {"segments": segs, "area": round(area, 5),
                  "bbox": [*allp.min(axis=0).round(5).tolist(), *allp.max(axis=0).round(5).tolist()],
                  "point": point.tolist(), "normal": n.tolist(), "u": u.tolist(), "v": v.tolist()}
      
      
      def _inside2d(q, segs, block=256):
          """Even-odd test of 2D points against oriented segments, in blocks to bound memory."""
          out = np.zeros(len(q), dtype=bool)
          ax, ay, bx, by = segs[:, 0, 0][None], segs[:, 0, 1][None], segs[:, 1, 0][None], segs[:, 1, 1][None]
          for s0 in range(0, len(q), block):
              x, y = q[s0:s0 + block, 0][:, None], q[s0:s0 + block, 1][:, None]
              cond = (ay > y) != (by > y)
              with np.errstate(divide="ignore", invalid="ignore"):
                  xc = ax + (y - ay) * (bx - ax) / (by - ay)
              out[s0:s0 + block] = ((cond & (x < xc)).sum(axis=1) % 2) == 1
          return out
      
      
      def _dist2d(q, segs, block=256):
          """Distance from 2D points to the nearest segment, in blocks to bound memory."""
          a, b = segs[:, 0][None], segs[:, 1][None]
          ab = b - a
          ab2 = np.maximum((ab * ab).sum(axis=2), 1e-300)
          out = np.empty(len(q))
          for s0 in range(0, len(q), block):
              qq = q[s0:s0 + block, None, :]
              t = np.clip(((qq - a) * ab).sum(axis=2) / ab2, 0, 1)
              d = qq - (a + t[:, :, None] * ab)
              out[s0:s0 + block] = np.sqrt((d * d).sum(axis=2)).min(axis=1)
          return out
      
      
      def plan_keys(part_a, point, normal, clearance_mm=0.12, min_wall_mm=1.0, part_b=None, n_keys=None,
                    radius_mm=None, grid=40, min_radius_mm=0.5):
          """Alignment keys for a planar cut. part_a carries the pegs, part_b (on the +normal
          side) the sockets. Pegs sit at the points of the section farthest from its outline
          (pole of inaccessibility on a grid). Sizing rules are [added]: radius = half of
          (room - min_wall - clearance), two keys when the section is elongated (aspect >= 2) so
          a round key cannot spin, peg length = min(3 radius, socket side depth - min wall -
          clearance). Clearance and wall come from the process (Hasbro: 0.12 mm, 1 mm)."""
          A = load(part_a)
          sec = section(A, point, normal)
          segs = sec["segments"]
          if not len(segs):
              raise ValueError("the plane does not cut part_a")
          x0, y0, x1, y1 = sec["bbox"]
          step = max(x1 - x0, y1 - y0) / float(grid)
          gx, gy = np.meshgrid(np.arange(x0 + step / 2, x1, step), np.arange(y0 + step / 2, y1, step))
          q = np.stack([gx.ravel(), gy.ravel()], axis=1)
          q = q[_inside2d(q, segs)]
          if not len(q):
              raise ValueError("no interior points on the section grid: raise grid")
          dist = _dist2d(q, segs)
          warnings = []
          if n_keys is None:
              ev, evec = np.linalg.eigh(np.cov(q.T)) if len(q) > 2 else (np.array([1.0, 1.0]), np.eye(2))
              n_keys = 2 if ev[-1] / max(ev[0], 1e-300) >= 4.0 else 1          # aspect >= 2 [added]
          else:
              evec = np.linalg.eigh(np.cov(q.T))[1] if len(q) > 2 else np.eye(2)
          if n_keys == 1:
              picks = [int(np.argmax(dist))]
              warnings.append("one round key can spin: glue it, or use two keys or a non-round key [added]")
          else:
              axis = evec[:, -1]
              along = (q - q.mean(axis=0)) @ axis
              side = along > 0
              dist = np.minimum(dist, np.abs(along))          # each key stays in its own half
              picks = [int(np.flatnonzero(s)[np.argmax(dist[s])]) for s in (side, ~side) if s.any()]
          n, u, v = (np.asarray(sec[k]) for k in ("normal", "u", "v"))
          origin = np.asarray(sec["point"])
          B = load(part_b) if part_b is not None else None
          idx_b = None
          if B is not None:
              _, _, b0, b1, b2 = _tris(B)
              idx_b = RayIndex(b0, b1, b2)
          else:
              _, _, a0, a1, a2 = _tris(A)
              idx_b = RayIndex(a0, a1, a2)
              warnings.append("no part_b: socket depth estimated from part_a along -normal")
          keys = []
          for k in picks:
              room = float(dist[k])
              r = radius_mm if radius_mm else 0.5 * (room - min_wall_mm - clearance_mm)
              c3 = origin + q[k, 0] * u + q[k, 1] * v
              d = n if B is not None else -n
              depth, _ = idx_b.first_exit(c3, d, -1e-9 * idx_b.diag, 10.0 * max(room, 1.0))
              avail = depth - min_wall_mm - clearance_mm
              length = min(3.0 * r, avail) if math.isfinite(avail) else 3.0 * r
              ok = r >= min_radius_mm and length >= r
              if not ok:
                  warnings.append(f"key at {c3.round(3).tolist()}: room {room:.3f} mm, depth {depth:.3f} mm "
                                  "is too small: thicken the cut zone or move the cut [added]")
              keys.append({"center": c3.round(4).tolist(), "room_mm": round(room, 4),
                           "peg_radius_mm": round(r, 4), "socket_radius_mm": round(r + clearance_mm, 4),
                           "peg_length_mm": round(length, 4),
                           "socket_depth_mm": round(length + clearance_mm, 4),
                           "socket_side_depth_mm": round(depth, 4) if math.isfinite(depth) else None,
                           "ok": bool(ok)})
          return {"section_area_mm2": sec["area"], "section_bbox": sec["bbox"], "normal": n.tolist(),
                  "keys": keys, "clearance_mm": clearance_mm, "min_wall_mm": min_wall_mm,
                  "warnings": warnings, "source": GATE_NOTES["clearance"]}
      
      
      # ============================================================================================
      # Pose checks
      # ============================================================================================
      
      def same_order(a, b):
          """Vertex order sacred (Pavlovich irnu [00:08:31]; Munoz Gomez oxyK [00:39:55]): same
          point count and identical face lists, so a posed mesh can go back onto a layer or a
          TPose mesh."""
          A, B = load(a), load(b)
          same_n = len(A.verts) == len(B.verts)
          same_f = same_n and np.array_equal(A.sizes, B.sizes) and np.array_equal(A.flat, B.flat)
          return {"points": [len(A.verts), len(B.verts)], "faces": [len(A.sizes), len(B.sizes)],
                  "same_count": bool(same_n), "same_faces": bool(same_f), "ok": bool(same_f)}
      
      
      def rigid_fit(P, Q):
          """Kabsch: R, t with Q ~ R P + t, and the RMS residual."""
          P, Q = np.asarray(P, dtype=np.float64), np.asarray(Q, dtype=np.float64)
          cp, cq = P.mean(axis=0), Q.mean(axis=0)
          H = (P - cp).T @ (Q - cq)
          U, _, Vt = np.linalg.svd(H)
          dd = np.sign(np.linalg.det(Vt.T @ U.T)) or 1.0
          R = Vt.T @ np.diag([1.0, 1.0, dd]) @ U.T
          t = cq - R @ cp
          rms = float(np.sqrt(((P @ R.T + t - Q) ** 2).sum(axis=1).mean()))
          return R, t, rms
      
      
      def axis_angle(R):
          ang = math.degrees(math.acos(max(-1.0, min(1.0, (np.trace(R) - 1.0) / 2.0))))
          if ang < 1e-6:
              return None, 0.0
          if ang > 179.999:
              w, vecs = np.linalg.eig(R)
              axis = np.real(vecs[:, int(np.argmin(np.abs(w - 1.0)))])
          else:
              axis = np.array([R[2, 1] - R[1, 2], R[0, 2] - R[2, 0], R[1, 0] - R[0, 1]])
          return axis / np.linalg.norm(axis), ang
      
      
      def rigid_delta(before, after, vertices=None):
          """How a region moved between two exports of the same topology: angle (deg), axis,
          pivot (closest point of the rotation axis to the region), screw translation along the
          axis and the RMS of the rigid fit (small = moved rigidly). vertices: indices, a boolean
          mask, or None for all."""
          A, B = load(before), load(after)
          if len(A.verts) != len(B.verts):
              raise ValueError(f"vertex count changed {len(A.verts)} -> {len(B.verts)}")
          sel = np.arange(len(A.verts)) if vertices is None else np.asarray(vertices)
          P, Q = A.verts[sel], B.verts[sel]
          R, t, rms = rigid_fit(P, Q)
          axis, ang = axis_angle(R)
          out = {"count": int(len(P)), "angle_deg": round(ang, 4), "rms": round(rms, 6),
                 "translation": t.round(6).tolist(), "max_disp": round(float(np.linalg.norm(Q - P, axis=1).max()), 6)}
          if axis is not None:
              c, *_ = np.linalg.lstsq(np.eye(3) - R, t - axis * float(t @ axis), rcond=None)
              cen = P.mean(axis=0)
              c = c + axis * float((cen - c) @ axis)
              out.update(axis=axis.round(6).tolist(), pivot=c.round(6).tolist(),
                         screw=round(float(t @ axis), 6))
          return out
      
      
      def stretch_report(before, after, warn_ratio=1.5, top=10):
          """Edge-length change between two exports of the same topology: the numeric partner of
          Munoz Gomez's Polyframe look at stretched polygons (oxyK [00:06:40]). warn_ratio 1.5 is
          [added]."""
          A, B = load(before), load(after)
          if len(A.verts) != len(B.verts):
              raise ValueError("vertex count changed: not the same topology")
          a, b = zb_audit._edges(A)
          n = len(A.verts)
          key = np.unique(np.minimum(a, b) * n + np.maximum(a, b))
          i, j = key // n, key % n
          la = np.linalg.norm(A.verts[i] - A.verts[j], axis=1)
          lb = np.linalg.norm(B.verts[i] - B.verts[j], axis=1)
          ok = la > 0
          ratio = lb[ok] / la[ok]
          mid = ((B.verts[i] + B.verts[j]) / 2.0)[ok]
          st = ratio > warn_ratio
          sq = ratio < 1.0 / warn_ratio
          diag = float(np.linalg.norm(B.verts.max(axis=0) - B.verts.min(axis=0))) or 1.0
          return {"edges": int(ok.sum()), "ratio": _stats(ratio), "stretched_share": round(float(st.mean()), 5),
                  "compressed_share": round(float(sq.mean()), 5), "warn_ratio": warn_ratio,
                  "stretched_spots": _clusters(mid[st], -ratio[st], diag / 25.0, top),
                  "compressed_spots": _clusters(mid[sq], ratio[sq], diag / 25.0, top)}
      
      
      def group_vertices(mesh, group, groups=None):
          """Vertex indices of one polygroup (name or id) of an OBJ exported with groups, for
          rigid_delta per limb segment. groups: (ids, names) from face_groups, else read from the
          path."""
          m = load(mesh)
          if groups is None:
              if not isinstance(mesh, str):
                  raise ValueError("pass groups when mesh is not an OBJ path")
              groups = face_groups(mesh)
          ids, names = groups
          gid = names.index(group) if isinstance(group, str) else int(group)
          faces = np.flatnonzero(np.asarray(ids) == gid)
          if not len(faces):
              return np.zeros(0, dtype=np.int64)
          sel = np.concatenate([m.flat[m.starts[f]:m.starts[f] + m.sizes[f]] for f in faces])
          return np.unique(sel)
      
      
      def joint_centres(mesh, groups=None):
          """Pivot candidates from polygroup borders: for each pair of adjacent groups, the
          centroid of their shared border vertices, the border's plane normal (the bone
          direction, sign arbitrary) and its mean radius. groups: per-face ids (face_groups) or
          None to read them from the OBJ path. Plouffe and Munoz Gomez pose by polygroup, pivot
          on the joint (lakC [00:16:33]; oxyK [00:05:00])."""
          m = load(mesh)
          if groups is None:
              if not isinstance(mesh, str):
                  raise ValueError("pass groups when mesh is not an OBJ path")
              groups, names = face_groups(mesh)
          else:
              names = None
          groups = np.asarray(groups)
          a, b = zb_audit._edges(m)
          fid = np.repeat(np.arange(len(m.sizes)), m.sizes)
          n = len(m.verts)
          key = np.minimum(a, b) * n + np.maximum(a, b)
          order = np.argsort(key, kind="stable")
          ks, fs = key[order], fid[order]
          same = ks[1:] == ks[:-1]
          first = np.flatnonzero(same)
          ga, gb = groups[fs[first]], groups[fs[first + 1]]
          border = ga != gb
          out = []
          pairs = {}
          for e, g1, g2 in zip(ks[first][border], ga[border], gb[border]):
              pairs.setdefault((int(min(g1, g2)), int(max(g1, g2))), []).append(int(e))
          for (g1, g2), edges in sorted(pairs.items()):
              e = np.asarray(edges)
              vs = np.unique(np.concatenate([e // n, e % n]))
              p = m.verts[vs]
              c = p.mean(axis=0)
              w, vec = np.linalg.eigh(np.cov((p - c).T)) if len(p) > 3 else (None, np.eye(3))
              out.append({"groups": [g1, g2], "names": [names[g1], names[g2]] if names else None,
                          "center": c.round(6).tolist(), "normal": vec[:, 0].round(6).tolist(),
                          "radius": round(float(np.linalg.norm(p - c, axis=1).mean()), 6),
                          "border_vertices": int(len(vs))})
          return out
      
      
      def _face_pieces(m):
          """Connected-piece label of every face (vertex label propagation of zb_audit)."""
          a, b = zb_audit._edges(m)
          lab = zb_audit._shells(len(m.verts), np.minimum(a, b), np.maximum(a, b))
          return lab[m.flat[m.starts]]
      
      
      def loose_pieces(mesh, min_faces=1):
          """Connected pieces of one OBJ, largest first: what Auto Groups makes in ZBrush (one group
          per disconnected piece: plates, a weapon, bolts; Munoz Gomez oxyK [00:15:37]), computed on
          the export so the Grps segment groups of the TPose mesh stay intact. Each row: piece,
          vertices (indices), faces, bbox, volume (signed, mesh units)."""
          m = load(mesh)
          if not len(m.sizes):
              return []
          face_lab = _face_pieces(m)
          tri, face, p0, p1, p2 = _tris(m)
          tri_lab = face_lab[face]
          rows = []
          for s in np.unique(face_lab):
              nf = int((face_lab == s).sum())
              if nf < min_faces:
                  continue
              sel = tri_lab == s
              vs = np.unique(tri[sel].reshape(-1))
              p = m.verts[vs]
              rows.append({"label": int(s), "vertices": vs, "faces": nf,
                           "bbox": [p.min(axis=0).round(4).tolist(), p.max(axis=0).round(4).tolist()],
                           "volume": round(_signed_volume(p0[sel], p1[sel], p2[sel]), 6)})
          rows.sort(key=lambda r: (-r["faces"], r["label"]))
          for k, r in enumerate(rows):
              r["piece"] = k
          return rows
      
      
      def rigid_pieces(rest, posed, pieces=None, tol_mm=0.05, skip_largest=True):
          """Did every rigid piece (plate, weapon, bolt: a loose piece of the TPose mesh) move as
          one body between two exports of the same topology? Kabsch fit per piece; rms above
          tol_mm means the piece bent (Munoz Gomez keeps plates whole with Auto Groups, oxyK
          [00:15:37] [00:16:14]; Plouffe keeps primitives unstretched, lakC [00:33:23]).
          skip_largest leaves out the body, which is meant to bend. tol_mm 0.05 is [added]."""
          A, B = load(rest), load(posed)
          if len(A.verts) != len(B.verts):
              raise ValueError(f"vertex count changed {len(A.verts)} -> {len(B.verts)}")
          rows = pieces if pieces is not None else loose_pieces(A)
          if skip_largest and pieces is None:
              rows = rows[1:]
          out = []
          for r in rows:
              vs = np.asarray(r["vertices"])
              if len(vs) < 3:
                  continue
              R, t, rms = rigid_fit(A.verts[vs], B.verts[vs])
              _, ang = axis_angle(R)
              out.append({"piece": r.get("piece"), "count": int(len(vs)), "rms": round(rms, 6),
                          "angle_deg": round(ang, 4), "moved_mm": round(float(np.linalg.norm(
                              B.verts[vs] - A.verts[vs], axis=1).max()), 4), "bent": bool(rms > tol_mm)})
          return {"pieces": out, "bent": [r["piece"] for r in out if r["bent"]], "tol_mm": tol_mm,
                  "ok": not any(r["bent"] for r in out)}
      
      
      def follows(carrier_rest, carrier_posed, part_rest, part_posed, carrier_vertices=None,
                  part_vertices=None, tol_mm=0.5, rigid_tol_mm=0.05):
          """Does a carried part (a sword in the hand, a plate on its segment) sit where its carrier
          took it? The carrier's rigid transform between rest and posed (fit on carrier_vertices,
          e.g. the palm group) is applied to the part's rest points and compared with its posed
          points. interference() == 0 alone passes a sword left far from the hand; this does not.
          Works on one TPose export (same paths, two vertex sets) or on separate SubTool exports.
          tol_mm 0.5 and rigid_tol_mm 0.05 are [added]."""
          C0, C1, P0, P1 = (load(x) for x in (carrier_rest, carrier_posed, part_rest, part_posed))
          cs = np.arange(len(C0.verts)) if carrier_vertices is None else np.asarray(carrier_vertices)
          ps = np.arange(len(P0.verts)) if part_vertices is None else np.asarray(part_vertices)
          R, t, crms = rigid_fit(C0.verts[cs], C1.verts[cs])
          pred = P0.verts[ps] @ R.T + t
          res = np.linalg.norm(P1.verts[ps] - pred, axis=1)
          _, _, prms = rigid_fit(P0.verts[ps], P1.verts[ps])
          st = _stats(res)
          return {"residual_mm": st, "carrier_rms": round(crms, 6), "part_rms": round(prms, 6),
                  "carrier_R": R.round(9).tolist(), "carrier_t": t.round(6).tolist(),
                  "part_rigid": bool(prms <= rigid_tol_mm), "tol_mm": tol_mm,
                  "ok": bool(st["p95"] <= tol_mm and prms <= rigid_tol_mm)}
      
      
      def placement_correction(rest_part, moved_part, R, t):
          """After zb_pose.rotate_subtool_matrix(R): the translation (mm, OBJ axes) still needed so
          the part lands at R p + t (pass it to zb_pose.translate_subtool), and how far the
          rotation that ZBrush applied is from R (degrees). Measured, because the rotation centre
          of Deformation Rotate is [verify]."""
          A, B = load(rest_part), load(moved_part)
          R, t = np.asarray(R, dtype=np.float64), np.asarray(t, dtype=np.float64)
          Ra, _, rms = rigid_fit(A.verts, B.verts)
          delta = (A.verts @ R.T + t - B.verts).mean(axis=0)
          return {"translate_mm": delta.round(6).tolist(), "rotation_error_deg": round(axis_angle(Ra.T @ R)[1], 4),
                  "rms": round(rms, 6)}
      
      
      def piece_interference(mesh, pieces=None, samples=200, seed=0, min_share=0.0):
          """Loose pieces of one mesh that pass into each other after posing (a plate into the
          body, a sword through the thigh): the targets of Munoz Gomez's Move Topological, which
          moves only the connected piece under the brush (oxyK [00:17:24]). Only pairs whose
          bboxes overlap are tested; share = sampled vertices of piece i inside piece j."""
          m = load(mesh)
          rows = pieces if pieces is not None else loose_pieces(m)
          tri, face, p0, p1, p2 = _tris(m)
          tri_lab = _face_pieces(m)[face]
          rng = np.random.default_rng(seed)
          idx = {}
          out = []
          for i in rows:
              for j in rows:
                  if i is j:
                      continue
                  lo_i, hi_i = np.array(i["bbox"][0]), np.array(i["bbox"][1])
                  lo_j, hi_j = np.array(j["bbox"][0]), np.array(j["bbox"][1])
                  if np.any(hi_i < lo_j) or np.any(hi_j < lo_i):
                      continue
                  if j["label"] not in idx:
                      sel = tri_lab == j["label"]
                      idx[j["label"]] = RayIndex(p0[sel], p1[sel], p2[sel])
                  vs = np.asarray(i["vertices"])
                  pick = vs[rng.choice(len(vs), size=min(int(samples), len(vs)), replace=False)]
                  ins = idx[j["label"]].inside(m.verts[pick])
                  share = float(ins.mean()) if len(ins) else 0.0
                  if share > min_share:
                      pts = m.verts[pick][ins]
                      out.append({"piece": i["piece"], "into": j["piece"], "inside_share": round(share, 5),
                                  "center": pts.mean(axis=0).round(4).tolist()})
          out.sort(key=lambda r: -r["inside_share"])
          return {"pairs": out, "ok": not out}
      
      
      # ============================================================================================
      # Delivery
      # ============================================================================================
      
      def _y_to_z(P):
          """ZBrush OBJ is Y up (+Z toward the front camera, v03 analysis); printers are Z up.
          Rotation +90 deg about X: (x, y, z) -> (x, -z, y), handedness and winding kept."""
          out = np.empty_like(P)
          out[..., 0], out[..., 1], out[..., 2] = P[..., 0], -P[..., 2], P[..., 1]
          return out
      
      
      def _target(path, overwrite):
          """Never overwrite silently: an existing file is kept as .bak (project rule)."""
          path = os.path.abspath(os.path.expanduser(path))
          os.makedirs(os.path.dirname(path), exist_ok=True)
          if os.path.exists(path):
              if not overwrite:
                  raise FileExistsError(f"{path} exists: pass overwrite=True (the old file is kept as .bak)")
              os.replace(path, path + time.strftime(".%Y%m%d-%H%M%S.bak"))
          return path
      
      
      def write_stl(mesh, path, scale=1.0, up="z", overwrite=False):
          """Binary STL of one part (mesh units times scale, mm expected). STL stores no units:
          the header says mm, and the delivery note must too (3D Print Hub doc)."""
          m = load(mesh)
          _, _, p0, p1, p2 = _tris(m)
          P = np.stack([p0, p1, p2], axis=1) * float(scale)
          if up == "z":
              P = _y_to_z(P)
          nrm = np.cross(P[:, 1] - P[:, 0], P[:, 2] - P[:, 0])
          nrm /= np.maximum(np.linalg.norm(nrm, axis=1), 1e-300)[:, None]
          rec = np.zeros(len(P), dtype=np.dtype([("n", "<f4", (3,)), ("v", "<f4", (3, 3)), ("a", "<u2")]))
          rec["n"], rec["v"] = nrm, P
          path = _target(path, overwrite)
          head = f"zb_print binary STL | units mm | up {up.upper()}".encode("ascii").ljust(80, b" ")
          with open(path, "wb") as fh:
              fh.write(head)
              fh.write(struct.pack("<I", len(P)))
              fh.write(rec.tobytes())
          flat = P.reshape(-1, 3)
          return {"path": path, "triangles": int(len(P)), "bytes": os.path.getsize(path),
                  "bbox_mm": [flat.min(axis=0).round(4).tolist(), flat.max(axis=0).round(4).tolist()],
                  "units": "mm (stated here: STL itself carries none)"}
      
      
      def read_stl(path):
          """Binary STL -> (T,3,3) float64 triangles and the header text."""
          with open(path, "rb") as fh:
              head = fh.read(80)
              (n,) = struct.unpack("<I", fh.read(4))
              rec = np.frombuffer(fh.read(50 * n), dtype=np.dtype([("n", "<f4", (3,)), ("v", "<f4", (3, 3)), ("a", "<u2")]))
          return rec["v"].astype(np.float64), head.decode("ascii", "ignore").strip()
      
      
      _CT = ('<?xml version="1.0" encoding="UTF-8"?>\n<Types xmlns="http://schemas.openxmlformats.org/'
             'package/2006/content-types"><Default Extension="rels" ContentType="application/'
             'vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType='
             '"application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/></Types>')
      _RELS = ('<?xml version="1.0" encoding="UTF-8"?>\n<Relationships xmlns="http://schemas.'
               'openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model"'
               ' Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>'
               '</Relationships>')
      _NS3MF = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
      
      
      def write_3mf(parts, path, unit="millimeter", scale=1.0, up="z", overwrite=False, title=None):
          """3MF package (core spec), one object per part, units declared in the file: the unit
          problem of STL disappears. 3D Print Hub also has 'Export to 3MF' in 2026.2.1 [strings]
          [verify]; this writer is the dialog-free route."""
          ps = _as_parts(parts)
          objs, items = [], []
          tri_total = 0
          for oid, (name, src) in enumerate(ps.items(), start=1):
              m = load(src)
              tri, _ = tri_index(m)
              V = m.verts * float(scale)
              if up == "z":
                  V = _y_to_z(V)
              vx = "".join(f'<vertex x="{x:.6g}" y="{y:.6g}" z="{z:.6g}"/>' for x, y, z in V.tolist())
              tx = "".join(f'<triangle v1="{a}" v2="{b}" v3="{c}"/>' for a, b, c in tri.tolist())
              safe = re.sub(r'[<>&"]', "_", str(name))
              objs.append(f'<object id="{oid}" type="model" name="{safe}"><mesh><vertices>{vx}</vertices>'
                          f'<triangles>{tx}</triangles></mesh></object>')
              items.append(f'<item objectid="{oid}"/>')
              tri_total += len(tri)
          model = (f'<?xml version="1.0" encoding="UTF-8"?>\n<model unit="{unit}" xml:lang="en-US" '
                   f'xmlns="{_NS3MF}"><metadata name="Title">{re.sub(r"[<>&]", "_", title or "zb_print")}'
                   f'</metadata><resources>{"".join(objs)}</resources><build>{"".join(items)}</build></model>')
          path = _target(path, overwrite)
          with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
              z.writestr("[Content_Types].xml", _CT)
              z.writestr("_rels/.rels", _RELS)
              z.writestr("3D/3dmodel.model", model)
          return {"path": path, "objects": len(objs), "triangles": tri_total, "unit": unit,
                  "bytes": os.path.getsize(path)}
      
      
      def read_3mf(path):
          """{name: (verts (N,3), tris (T,3))} and the declared unit, for checking a package."""
          with zipfile.ZipFile(path) as z:
              root = ET.fromstring(z.read("3D/3dmodel.model"))
          ns = {"m": _NS3MF}
          out = {}
          for obj in root.findall("m:resources/m:object", ns):
              vs = np.array([[float(v.get(k)) for k in "xyz"] for v in obj.findall("m:mesh/m:vertices/m:vertex", ns)])
              ts = np.array([[int(t.get(k)) for k in ("v1", "v2", "v3")] for t in obj.findall("m:mesh/m:triangles/m:triangle", ns)])
              out[obj.get("name")] = (vs, ts)
          return out, root.get("unit")
      
      
      def _gate(rows, gate, part, ok, value=None, limit=None, severity="must", source=None):
          rows.append({"gate": gate, "part": part, "pass": bool(ok), "value": value, "limit": limit,
                       "severity": severity, "source": source})
      
      
      def print_report(parts, target_height_mm=None, up_axis=1, min_wall_mm=1.0, max_faces=1_000_000,
                       build_volume_mm=None, thickness_samples=3000, keys=None, check_balance=True,
                       balance_band_mm=1.0, process="resin", out_json=None, seed=0, overwrite=False,
                       envelope_mm=None):
          """Every measurable gate for a set of print parts (OBJ in mm), in one dict.
          keys: [(peg_obj, socket_obj, target_clearance_mm), ...] measured with clear
  • SKILL.md 19.6 KB
    ---
    name: scenario-zbrush-pose-print
    description: 'Use when posing a sculpt or preparing it for 3D printing in ZBrush: "pose this character", Transpose Master, TPoseMesh, Gizmo posing with masks, ZSphere rig, Proxy Pose, weapon in the hand, stretching after posing, "3D print this", 1/6 scale statue, miniature, resin or FDM, hollowing, wall thickness, drain holes, keys and pins, cutting into parts, 3D Print Hub, Scale Master, Decimation Master, STL or 3MF, size in millimeters.'
    license: MIT
    ---
    
    # ZBrush posing and 3D print
    
    Expert level means a pose that stands and reads, transferred onto every SubTool without losing a level, and print parts whose size, walls, fits and cavities were measured in millimeters on the exported files. The stance: turn every decision into a number the agent can check (scale, point order, joint angles, walls, gaps), keep the pose reversible, use strokes only for cleanup. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap.
    
    **REQUIRED BACKGROUND:** scenario-zbrush-expert (bridge, stroke engine, review loop, 2026 traps).
    
    ## Stance (the expert delta)
    
    - **Gaboury: scale is the first decision.** SubTool number one is a box at the client's size; nothing may clip out of it, and the whole tool resizes in one step because sizes change late (6 in with 15 points of articulation became 4 in with 8). Agent form: `brief_box`, `zb_print.envelope`, `set_export_scale(target_mm)`.
    - **Munoz Gomez: pose at the lowest level, several SubTools through Transpose Master, the pose on a 3D Layer.** Plates and carried props (weapon, shield, staff) stay IN the TPose mesh as rigid pieces (Auto Groups, one group per loose piece, masked whole); Move Topological clears intersections. Plouffe forbids deforming primitives, not moving them rigidly: never pull a carried prop out and re-place it with one rotation.
    - **Maxon doc: two things destroy a transfer:** a partially hidden SubTool (Vertex Mismatch) and Gizmo 3D deformers on the TPose mesh (they reorder points; SubTools die on TPose>SubT). Pavlovich: "The order of the vertices is very important." Start from a clean project, save the ZPR while a TPose mesh exists (the Transpose Master data lives there).
    - **Plouffe: parts that bend together need the same polygon size at level 1** (mask blur spreads per polygon). Re-place bolts and discs after bending. Spend detail only where the final size and viewing distance show it.
    - **Pavlovich: DynaMesh without levels = Proxy Pose plus a ZSphere rig.** Location beats size, helpers hold volume, rotate only.
    - **Gaboury: never hollow the 9M sculpt.** Shell a light DynaMesh copy (128 was enough for a 9 in car), keep its inner wall, Polish it, subtract it from the untouched detailed part in Live Boolean. Create Shell Thickness is not millimeters: measure.
    - **Bennett (Hasbro): 1 mm minimum wall (0.85 mm was flagged), 0.5 mm edges, 0.12 mm fit offset for Formlabs parts;** thin walls are thickened from the inside so the outside does not change.
    - **Gaboury: decimate by eye** (about 1M per mesh is plenty; 10k to 20k triangles can still print); masks on the detail that matters (face, hands) steer Decimation Master; pre-process again after any edit; vent every cup on SLA.
    
    ## Establish first
    
    | Input                                                                 | Why it changes the plan                                                                                                                                                                          | Default when the brief is silent                                                           |
    | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
    | Process: resin (SLA, MSLA), FDM, molded or cast                       | walls, clearance, hollowing, shrink (Hasbro sculpts PVC at 104 %); molds add Hasbro's rules: touching fingers lock the mold, Group Front shows the parting line, Draw Draft Analysis shows draft | resin, direct print                                                                        |
    | Size: ratio and real height, envelope W x H x D, base included or not | export scale, brief box, feature floor                                                                                                                                                           | 1/6 = standing height / 6 (1,800 mm gives 300 mm), measured on the rest pose               |
    | Printer volume, slicer caps                                           | splits (Gaboury split a car for the Form 2 bed); polycount or file caps (his example: 50 MB)                                                                                                     | ask; report each part's size                                                               |
    | Parts: one piece or kit, glued or pegged                              | cuts and keys (Carratala plans cuts while sculpting)                                                                                                                                             | natural breaks (collar, belt, cuffs, weapon at the hand), two keys per glued joint [added] |
    | Pose source; alternate poses                                          | route and layers                                                                                                                                                                                 | one layer per pose                                                                         |
    | Deliverable                                                           | STL has no units; 3MF declares them                                                                                                                                                              | STL and 3MF per part in mm, JSON report                                                    |
    
    ## Posing without a mouse
    
    The artists' default (Ctrl+click a polygroup, drag a Gizmo ring) is a canvas gesture. The SDK cannot read mask data, and `press_key` is documented but marked "Does not work, hidden for now" in the installed 2026 stub (with `canvas_click` it is reported unreliable, Maxon forum). Routes by determinism:
    
    | Route                                                             | When                                           | How                                                                                                                                                                        | Status                                                      |
    | ----------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
    | A. Numeric external rig (Munoz Gomez's Blender route, made exact) | humanoids, extreme or many poses               | TPoseMesh, `export_tpose`, `loose_pieces` weighted rigidly to one bone each, scenario-blender-rigging poses by angle and returns the same order, `import_pose`, TPose>SubT | deterministic; guards tested offline                        |
    | B. ZSphere rig by code                                            | in ZBrush, organic, or DynaMesh via Proxy Pose | joints from polygroup borders, `zs_build`, forward kinematics `zs_pose`                                                                                                    | FK tested; bound mesh following API edits [verify live_p02] |
    | C. Whole-SubTool rigid moves                                      | a base, a stand, a prop added after posing     | `rotate_subtool_matrix` (X, Y, Z) plus `translate_subtool`, the carrier's whole transform, corrected from exports                                                          | math tested; signs [verify live_p03, p08]                   |
    | D. Mask plus Gizmo or action line                                 | cleanup by a computer-use agent or a human     | Ctrl+click group, Alt pivot, drag                                                                                                                                          | weak for this agent                                         |
    
    Weak without a human: gesture design, Plouffe's hand-painted gradient masks, large fold redesign. Seam smoothing and Move Topological have agent forms in `procedures.md` P6, still [verify].
    
    ## Workflow
    
    `zb_pose.call("name", ...)` runs inside ZBrush; `zb_print` runs on exported OBJs; full code in [`references/procedures.md`](references/procedures.md).
    
    0. **Intake.** Versioned ZTL, `inventory()`, target and envelope in mm. GATE: every SubTool named, unique, with points and levels.
    1. **Scale.** `set_export_scale`, `export_parts`, `check_scale`, then `brief_box` (hidden while exporting). GATE: height within 0.5 % [added], one export scale on every part (a part appended from another ZTool arrives at another scale), `envelope` ok, named dimensions checked with `caliper`.
    2. **Pose prep.** `clean_project` (ZTL saved, default project, Load Tool); polygroup every segment, plates and props left as loose pieces; `preflight_tpose`. GATE: preflight ok, density spread under 2x [added]. No levels: scenario-zbrush-retopology-export or Proxy Pose.
    3. **Pose.** `tpose_mesh(layer=True, groups=True)` with props and plates visible, `export_tpose` (the order reference), `save_project`; route A or B, center first, parents before children, rotations only; no Gizmo deformers. GATE: `rigid_pieces` ok, `follows` puts each carried prop where its carrier took it (0.5 mm [added]), `piece_interference` empty or listed for P6, `tpose_to_subtools(expected, rest_obj)` passes its order guard and point counts, `rigid_delta` within 2 degrees of the plan [added], `balance` stands or a base is planned; review sheet: silhouette and weight read (Plouffe: an unstable pose "destroys the entire work").
    4. **Fix deformation.** New layer at the top level. Seam first while the rest is protected (Munoz Gomez smooths and moves before clearing the mask); then volumes and folds re-sculpted for the new pose (tension from the stretched joint, compression on the inner bend [added]); re-place primitives; Move Topological on pieces; pose-specific detail on a pose-only layer, because layers blend linearly. GATE: `stretch_report` near the rest pose, `piece_interference` ok, Polyframe tiles.
    5. **Breakdown.** Parts at natural breaks; merge what prints as one (Live Boolean union or DynaMesh on light copies: Hasbro engineers on decimated meshes). Molded: loops, parting line, draft. GATE: each part closed, fits the build volume.
    6. **Hollow when asked.** `hollow_copy` (insert inside, DynaMesh about 128, Create Shell), `keep_inner_shell`, Live Boolean: detailed part start, inner shell, drain and vents subtract; thin ends stay solid. Too thin: move the cutter inward, never the outside. GATE: `thickness` p01 at the wall target, no sealed cavity for resin, `outer_drift` ok, vents at cups.
    7. **Cut and key.** Cutter QCube in Live Boolean or a polygroup split then Close Holes [added]; `plan_keys`; socket = peg grown by 2 x clearance. GATE: `clearance` median at target, no interference; one key pair test-printed before the kit (Gaboury).
    8. **Decimate.** Unique names; per-SubTool budgets, masks before Pre-process (`mask="cavity"` [added]), pre-process after any edit. GATE: faces within budget, `deviation` p99 under the printer's XY resolution [added], close-ups before and after.
    9. **Deliver.** STL and 3MF per part in mm, `print_report(..., envelope_mm=...)`, a review tile at real size (detail under the process floor is backfilled, fused or left to paint: Hasbro). GATE: `print_report["ok"]`, every "to look at" item looked at, unit stated.
    
    ## Numbers
    
    | Item                 | Value                                                          | Source                          |
    | -------------------- | -------------------------------------------------------------- | ------------------------------- |
    | Minimum wall         | 1 mm; 0.85 flagged                                             | Hasbro, PVC and Formlabs copies |
    | Edges after trimming | 0.5 mm; about 0.2 mm floats away                               | Hasbro                          |
    | Fit offset           | 0.12 mm (Formlabs); Inflate 2 gave it in his ZTool             | Hasbro, calibrate per ZTool     |
    | Shell copy           | DynaMesh 64 too low, 128 enough (9 in car); Thickness 4 then 6 | Gaboury                         |
    | From Thickness       | Min 1, Max 5 once scaled to mm                                 | Maxon doc                       |
    | Decimation           | about 1M per mesh; 2.7M to 545k at 20 % unchanged; 8k faceted  | Gaboury                         |
    | Decimation quality   | 40 to 100 % near identical, 2 to 40 % visible                  | Maxon doc                       |
    | Scale with shrink    | height / 12 + 4 % for a 6 in PVC figure                        | Hasbro                          |
    | Proxy Pose skin      | Adaptive Skin Density 1, DynaMesh Resolution 0                 | Pavlovich                       |
    
    ## Quality gates
    
    - **Measured** (`zb_print` on OBJs in mm): watertight, volume, thin-wall share (0.2 % of samples [added]), sealed cavities, floating shells, height, envelope, one export scale, build volume, clearance, balance, faces, outer drift. Pose: point order, `rigid_pieces`, `follows`, `piece_interference`, `rigid_delta`, `stretch_report`.
    - **Visual** (scenario-zbrush-expert `zb_review.review`, MatCap Gray, five views): gesture and balance; joints with Polyframe; primitives round; the grip closed around the handle; the brief box shown once; From Thickness on a copy; every listed spot; decimated close-ups. Judge with [`references/critique.md`](references/critique.md).
    
    ## Common mistakes
    
    | Mistake                                                                   | What it looks like                                                | Fix                                                                                            |
    | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
    | Top-level pose, rough mask, mask cleared too early                        | stretched, overlapping polygons                                   | lowest level, blurred mask, Smooth and Move the seam before clearing (Munoz Gomez)             |
    | Carried prop pulled out of the TPose mesh, turned by one angle afterwards | a sword beside a raised, bent hand; `interference` 0 still passes | keep it in the TPose mesh as a rigid piece; else the carrier's whole transform, gate `follows` |
    | Partly hidden SubTool at TPoseMesh                                        | Vertex Mismatch                                                   | `preflight_tpose` (ShowPt on all)                                                              |
    | Gizmo deformer on the TPose mesh                                          | destroyed SubTools; a point count check misses a reorder          | rotations or rigs; the order guard refuses the transfer                                        |
    | Session closed with a TPose mesh, no ZPR                                  | no way back to the SubTools                                       | `save_project` right after TPoseMesh                                                           |
    | Neutral-pose detail shown on the pose                                     | soft, stretched detail at bends                                   | refine on a pose-only layer (linear blend)                                                     |
    | Create Shell numbers read as mm; insert touching the surface              | wrong walls; one joined piece                                     | measure `thickness`; insert inside, drain as a cutter                                          |
    | Wall thickened from outside                                               | the sculpt drifts                                                 | move the cutter inward, check `outer_drift`                                                    |
    | Mask or edit after Pre-process                                            | decimation ignores it                                             | pre-process again (the wrapper always does)                                                    |
    | No clearance on keys                                                      | "20 discs to sand before noon" (Hasbro)                           | 0.12 mm offset, measured, test print                                                           |
    | STL without a unit                                                        | 150 mm printed as 150 in                                          | say mm, or ship 3MF                                                                            |
    
    ## Handoffs
    
    - **Receives** from scenario-zbrush-sculpting, scenario-zbrush-character-creature, scenario-zbrush-stylized or scenario-zbrush-hard-surface: a versioned ZTL with named SubTools, levels (or a DynaMesh for Proxy Pose), polygroups per part, the last review sheet; from scenario-zbrush-retopology-export, a projected stack when a sculpt had no levels.
    - **Sends** to scenario-blender-rigging or scenario-maya-expert: the TPose OBJ and a plan (joints, angles, rigid pieces per bone), expecting the same vertex order back.
    - **Delivers** posed sculpts (layers, ZTL, ZPR, sheets) to scenario-zbrush-paint-render; print packages (STL and 3MF in mm, report, sheets, part and key list) to the user or the printer.
    
    ## ZBrush 2026 notes
    
    - Proxy Pose (2023.1) is in Tool > Geometry. Transpose Master still ships ("inactive after a file save" fixed in 2025.2); its data lives in the ZPR. The doc's clean project, DefaultCube.ZPR, is not in the 2026.2.1 install: `Lightbox/Projects/DefaultProject.ZPR` is used [verify].
    - 3D Print Hub has Export to 3MF (errors fixed in 2026.2.1). Update Size Ratios and Scale Master Set Scene Scale open dialogs: use the export scale route.
    - From Thickness (2026.2.1 labels Min Thickness, Max Thickness, Ball or Ray) asks "Too many polygons... proceed?" above Max Thickness Polygons; that note stalls the bridge, so `from_thickness` refuses first.
    - Live Boolean drops UVs, creases, 3D layers and masks on the result: boolean the print copy. Decimation Master and Transpose Master are ZScript plugins: check that control returns (live_p01, p07). Cmd+W quits ZBrush on macOS.
    
    ## References
    
    - `references/procedures.md`: bridge procedures with full code (scale and brief box, clean project, Transpose Master, three rig routes, fixes, hollow chain, keys, masked decimation, delivery). Load before driving ZBrush.
    - [`references/expert-notes.md`](references/expert-notes.md): principles by expert with timestamps and deciding conditions. Load when planning a pose or a kit.
    - `references/critique.md`: the rubric for pose, deformation and print parts. Load at every gate.
    - [`references/gui-paths.md`](references/gui-paths.md): palettes, buttons, hotkeys for a computer-use agent or a human.
    - [`references/sources.md`](references/sources.md): sources, credentials, timestamps, revisions.
    - [`scripts/zb_pose.py`](scripts/zb_pose.py) (inside ZBrush) and [`scripts/zb_print.py`](scripts/zb_print.py) (agent side, numpy): offline tests in `tests/code/zbrush-pose-print/`; live tests `live_p01` to `live_p08` not yet run in ZBrush.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related