scenario-blender-sculpting
Use when sculpting in Blender through Python: sculpt a head, face or bust, a stylized or cartoon character, a creature or a realistic portrait; block out from primitives; cut planes, a jaw or gonial angle, cheekbones; fix a jaw or face that looks like a ball or balloon; stylized
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-sculpting
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
git clone https://github.com/scenario-labs/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Sculpting (heads and characters, Blender 5.2)
Expert sculpting is structure first and surface last: masses blocked with stated planes and corners, proportions locked at low resolution, every stage judged by eye through a long lens, and appeal as the final test, not the mesh numbers. The agent builds blockout and primary forms headless (scripts/bx_sculpt.py: smooth-blend Clay, plane cuts, numpy brushes) and does client-quality surface work with Blender's real brushes in a live GUI session (bx_gui). 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-blender-expert (execution channel, review loop, 5.2 API changes).
Stance (the expert delta)
- Planes are stated, not found. Thelen cuts plane borders with Draw Sharp and re-cuts them whenever a face "looks off"; Naydenov simplifies into planes, then smooths; Reinhardt uses Scrape, Trim and Line Project for clean cuts. Executable form: give the plane (point + normal) to
Clayhalf-space clips orplane_cut/corner_cut. Aflattenorscrapedab fits its plane to the ball under it, so it follows the ball. - The jaw is a block with a gonial corner, the cheekbone a mass with its own lower plane. Zarins: the gonial angle is the age and sex dial, steep in adult males; the widest face point is the top of the zygomatic arch. Naydenov: the cheekbone runs from the ear down and forward, only its lower edge shows. Thelen: bone plus tissue, never razor sharp.
- Fix mass upstream; planes only restate. If re-cutting planes does not fix the look, the problem is mass or proportion (Thelen's diagnostic). Measured: plane cuts on a balloon jaw flipped the form metric but rendered as a gem facet; rebuilding the jaw block fixed it.
- Low resolution as long as possible. Abbitt: smoothing is strong only on big polygons; Thelen: lock proportions coarse. Symmetrize after every remesh (Keelan Jon), then a global relax (Kaspar FDscc66fC90 00:11:55).
- Indicate small landmarks early (nostrils, brows, eyes): Yan found a too-wide nose from the nostrils (vB7kPWjBgQI 00:05:11).
- Stylize by consistent exaggeration (Yan: pick it in minute one, make neighbors follow; a long face needs a smaller skull, not a stretch).
- A concept head needs its cues. Thelen: hair makes a male head read better (00:48:58); Naydenov: hair changes the read a lot (00:53:05). An "adventurer" with no hair or costume reads as a bald mannequin.
- Asymmetry and expression live on a shape key; the basis stays neutral and symmetric (Kaspar f-mx-Jfx9lA 00:09:44; Thelen 00:38:27). Expression tests expose proportion errors; fix them in the neutral (Kaspar 00:10:49).
Establish first
| Input | Changes | Default when silent |
|---|---|---|
| Who (sex, age, type, attitude) | gonial angle, brow, jaw width, shape language | adult male, neutral basis, stated [added] |
| Style | stylized: exaggeration table; realistic: canon + reference | stylized, "abstracted enough to not be uncanny" (Kaspar) |
| Character cues | hair, costume, props that sell the type | at least hair plus one costume signifier [added] |
| Purpose | still (vertex paint, decimate) vs game or film (retopology) | still; scenario-blender-retopology if animated |
| Delivery framing | detail frequency, expression size | bust fills a 1080 px frame [added] |
| Scale | nothing if sizes are fractions of head height H | H = 0.24 m [added] |
| Channel | headless numpy / Clay, or live GUI brushes | headless blockout; GUI for surface when the bridge is up |
Workflow
- Design table.
G = S.head_guides(H), a STYLE dict (the one or two exaggerations), the cue list, and the landmark points of the planes: gonion, chin bottom, cheekbone. GATE: written down. - Blockout (voxel H/40). Default for heads:
Clay. Cranium ellipsoid clipped by skull side and front-taper half-spaces; a jawsd_round_boxBEHIND the mouth mound, clipped by side, underside and ramus planes through the gonion; a square chin block; cheekbone ellipsoids along the ear-to-cheek line with a lower plane; mouth mound; brow ridges; nose; neck leaning about 15 degrees; ears; frontal box around the eyes. Blend big masses at 0.08 to 0.15 H, clip edges at 0.02 to 0.03 H. Option:union_remeshof primitives (rounded_box is a Catmull-Clark blob that reads as a ball: never use it for a jaw). GATE: 1 component;lower_face_report(head, H)["ball"]False; silhouette plus 90 mm renders show a jawline and gonial corner in profile and three-quarter; the exaggeration visible. - Primary forms (H/100 to H/110). Rebuild the Clay at the finer voxel with landmarks: sockets (sphere 1.1 x eyeball, smaller than the lids), thin shell lids around separate eyeballs (Keelan Jon's lids from the eyeball, amVAlpxHp8k 00:37:05), nose wings tucked in, ear bowls, neck muscles. Hair and costume as separate Clay objects (
to_object(name, symmetric=False)for anything asymmetric). GATE: profile beats; eyes not marbles or bags; hair frames the face;stage_reportclean. - Secondary (H/130 to H/150,
remesh_stage). Lines withcrease_line(mouth, under-lip, lid rim) on surfaces facing the projection; nostrils from below. Restate planes withcorner_cut/plane_cutonly where the mass is right and moves stay small. GATE: under 100k faces (Abbitt), no scratches, cracks or rims in 90 mm renders. - Surface finish (client quality): live GUI. Real Clay Strips, Scrape, Crease Polish, Smooth 0.2 via
bx_gui(fixed and verified: strokes raycast onto the mesh and pass object-space locations;set_view(frame=obj)first,view_distancea few head heights). Headless numpy strokes suit blockout and primary forms, not polish. Multires only on a settled clean base (Henning Cmi0KoFtc-4 00:16:04). - Expression. Neutral symmetric basis;
sc = S.Sculptor(head, symmetry_x=False),move_featurethe corner, cheek, lower lid, opposite brow;S.write_shape_key(head, "Smirk", sc.co). Size the corner move to the delivery framing (Numbers). Voxel remesh drops keys: last step. - Final render.
S.final_render(objs, path, direction, lens=90, res=1600)(cavity, framed on real vertices) from front, profile, three-quarter, neutral and expression; open every image.
Inside every stage: render, name ONE weakest link, fix it, re-render (Yan). A metric that passes while the render looks wrong means the render wins.
Numbers
| Value | Number | Source |
|---|---|---|
| Voxel ladder | H/40 blockout, H/65 to H/110 primary, H/130 to H/235 secondary, about H/330 detail | Morren, Abbitt; example |
| Faces from voxel | about 1.5 x area / voxel^2 (voxel_for_faces) |
measured, 3 % |
| Example bust faces | H/40 8k, H/110 64k, H/150 103k (with neck) | measured |
| Face gates | shape before 100k; detail about 500k | Abbitt |
| Raw remesh mirror error | max 0.67 to 0.75 voxel, mean 0.05 to 0.07 voxel (nearest vertex) | measured |
| Clay blends (head) | masses 0.08 to 0.15 H, plane clips 0.02 to 0.03 H, lids 0.012 H | example [added] |
| Jaw | bigonial 0.90 to 1.00 x bizygomatic (example 0.93); ramus-to-base angle 124 degrees in the example | Thelen; example [added] |
| Eyes (stylized) | radius 0.064 H, lids 0.012 H thick, upper margin 21 degrees above center | example; 14 degrees read sleepy |
| Hair | clumps 0.065 to 0.115 H radius, rising 0.8 x radius, from the hairline back; base 0.008 to 0.02 H | example [added] |
| Corner move that reads | about 1/8 of mouth width (8 mm on 65 mm, 0.03 H); invisible at 1/30 | measured |
| Pixels per mm | bust fills 1080 px: 2.8; bust 270 px tall: 0.75 | measured (framing) |
| Smooth strength | 0.2 (5.2 default 0.7) | Henning |
| Crease resolution | at least 3 to 4 vertices across the radius | Ryan King |
| Review lens | 85 to 95 mm perspective | Morren, Kaspar, Naydenov, Yan |
| Landmarks | eye line 0.5 H; nose bottom halfway brow to chin; lip parting 1/3 nose to chin; ear nose base to brow; width H/1.5; mouth corners under eye centers; canthal tilt about 8 degrees; neck about 15 degrees forward | Abbitt, Naydenov, Thelen |
Quality gates
- Measurable:
S.stage_report(obj, voxel)(components 1, non_manifold 0, stretch p95/median under 2.5, mirror_max under 1e-4 before asymmetry).S.lower_face_report(head, H):ballTrue means the lower face fits one sphere within 10 % and twice as well as a plane (calibrated: the three E2 balloon jaws 0.057 to 0.082, the baseline 0.18 to 0.20, the v2 example 0.23 to 0.26).S.profile_rhythm(obj): chin out, in, lips out, in, nose out (a nose hanging below the nose base hides the lip beats).sc.planarity(c, r)for a stated plane. - Visual (the gate):
S.long_lens_renderorfinal_renderat 85 to 95 mm from front, profile, three-quarter and below, plusbx_review.review(..., modes=("silhouette", "matcap"))for the ortho silhouette. Order: silhouette, big planes (cavity off, ormode="flat"), below and above, lines, character read at thumbnail size. Rubric:references/critique.md.
Common mistakes
| Mistake | What it looks like | Fix |
|---|---|---|
| rounded_box or ellipsoid jaw, heavy smoothing | balloon lower face: cheek, jaw, jowl one sphere | Clay jaw block with stated planes; lower_face_report gate |
| Flatten or scrape dabs to "make planes" | facets that follow the ball | stated planes: Clay clips, plane_cut, corner_cut |
| Plane cuts on a wrong mass | gem facet with a rim, metric passes | move or rebuild the mass first, then cut |
| Jaw block with its front ahead of the mouth | a mask or slab over the lower face | jaw behind the mound; front belongs to mound and chin |
| Tall jaw block, small blend | a panel edge across the cheek | low block, 0.15 H blend upward |
| Socket wider than the lids | ring groove, eye bags | socket 1.1 x eyeball, lid blend 0.012 H |
| Lids reaching behind the eye equator | sealed pocket, 3 components | clip lids at the eye center plane |
| Uniform hair offset, or clumps on a thick cap | helmet ledge, beanie with worms | thin base, clumps make the volume |
| Clumps starting behind the hairline | bare headband over the forehead | start clumps at the hairline |
| Symmetrizing asymmetric parts | swept hair mirrored, center seam | to_object(..., symmetric=False) |
| Crease projected across a grazing surface | parallel cracks | crease only surfaces facing the view; skip it |
| Smile sculpted into the basis | asymmetry cannot be removed | neutral basis, write_shape_key |
| Corner moved 1 to 2 mm | expression invisible | about 1/8 of mouth width, check pixels |
| No hair or costume | bald mannequin | cue list in stage 0 |
| Detail before proportions | lines on a wrong skull | stay coarse until the gates pass |
| Stroke snapped with nearest from far away | groove on the nose tip | on_surface(p, view) |
| Mask survived the remesh | a region refuses every brush | remesh_stage clears it |
| GUI strokes with the view zoomed out | whole mesh flattened | bx_gui.set_view(frame=obj), check view_distance |
| Hairline as one tilted cut | a garrison cap from the side, vertical wall at the front | hairline as a function of azimuth (sideburn, over the ear, down to the nape), quiff ramp from the hairline over about 0.12 H [eval] |
| Mirrored eyes | no gaze, a staring or blank attitude | two aimed eyeballs; lid margin and brow angle are attitude dials (level brows for a neutral basis) [eval] |
| Feature attached at two points | a nose that floats in profile, see-through gaps | ray-grid silhouette-hole probe per view; give a big nose a side wall to the cheek; bx_audit self-intersections after the last remesh [eval] |
Blender 5.2 notes
- Headless CRASH (segfault):
sculpt.symmetrize,sculpt.mesh_filter,paint.mask_flood_fill,sculpt.mask_filter,sculpt.face_sets_create,sculpt.face_sets_init,object.voxel_remeshin Sculpt Mode. Poll fails:sculpt.brush_stroke,sculpt.set_pivot_position,sculpt.detail_flood_fill, trim gestures. Work:paint_mask_extract,brush.asset_activate, Edit Modemesh.symmetrize,quadriflow_remesh, multires ops. - OpenVDB ships with Blender 5.2 (
import openvdb):FloatGrid.copyFromArray+convertToQuadsmesh the Clay. - Brushes are Essentials assets; Flatten, Fill, Scrape are one Plane type;
sizeis a diameter; Draw Sharp defaults to SUBTRACT.sculpt_toolissculpt_brush_type. - Voxel remesh output is flat shaded (
shade_smooth()), keeps attributes (mask included), drops shape keys. - NumPy 2 in Blender:
np.ptp(a), nota.ptp().
References
references/procedures.md: tested code for every stage (Clay recipe, planes, form check, hair, lids, expression, final render, crash matrix, brush table, GUI recipe); load before writing any sculpt script.references/expert-notes.md: principles by expert with timestamps; load when designing a character or choosing methods.references/critique.md: stage rubric with blockers; load at every gate.references/sources.md: the 27 source videos with best timestamps.- Example:
tests/code/blender-sculpting/stylized_head_blockout.py(13 s, renders instylized_head/stage_N/); comparison:tests/code/blender-sculpting/compare/compare_sheet.png.
Files (skills)
-
references
-
critique.md 14.2 KB
# Critique rubric: judge a sculpt the way the experts do Use after every stage, on the numbers (`S.stage_report`, `S.lower_face_report`, `S.profile_rhythm`) and on the renders. The gate is the long-lens perspective view (`S.long_lens_render` or `S.final_render`, 85 to 95 mm, front, profile, three-quarter, below): experts judge form through a long lens, not orthographic (Morren BPAvvF8py1M 00:13:14; Kaspar uGde7HdmCa8 00:46:31). `bx_review.review` silhouettes serve the silhouette pass and ortho landmark ratios. Open each image before judging. When a number passes and the render looks wrong, the render wins (v2: plane cuts flipped `lower_face_report` on a balloon jaw that still rendered as a gem facet). Then name ONE weakest link, fix only that, re-render (Yan vB7kPWjBgQI 00:54:04). Stop a stage only when every "blocker" line below is clear. ## Order of looking (never skip ahead) 1. **Silhouette** (black on white), front, profile, top, three-quarter: shape and gesture only (Kaspar YaVEJTLDD3Y 00:06:46; f-mx-Jfx9lA 00:26:01). 2. **Big planes under a neutral matcap, cavity off** (Henning Cmi0KoFtc-4 00:25:50): front plane vs side planes of face and skull, chin vs mouth area, cheek vs nasolabial (Morren BPAvvF8py1M 00:12:27). 3. **From below and above**: nose-base triangle, nostril beans, mouth mound curvature, jawline, upper lip emerging from inside (Thelen gC3FG8-4lsU 00:10:36; Naydenov oY9XybQRxzQ 00:44:03); forward taper from the top (Thelen 00:01:11). Ryan King found his too-wide mouth, pointy chin and swollen lip only from a low angle [Km9JSdWTjVY 00:36:38]. 4. **Long lens 85 to 95 mm** for proportions and form (Morren 00:13:14; Kaspar uGde7HdmCa8 00:46:31); ortho front and side only for landmark ratios. A flat-shaded render (`final_render(mode="flat")`) reads planes; matcap with cavity finds bumps (Kaspar f-mx-Jfx9lA 00:05:32). 5. **Lines and edges** (cavity on is fine here): crisp where bone or a fold is, soft into soft tissue. 6. **Zoom out**: the whole head at thumbnail size still reads as the intended character (Morren 00:37:48). ## Stage 1: blockout (voxel about H/40) | Check | Blocker when | Source | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | components, non_manifold_edges | not 1 and 0 after the union | Reinhardt KURuPAVJ6hM 01:07:45 | | faces | outside about 5k to 20k for a head | Morren (0.05 on a 2 m head), courses digest stage map | | scale_applied, mirror_max | scale not (1,1,1); mirror above 1e-4 after `symmetrize` | Reinhardt 00:27:17; Keelan Jon amVAlpxHp8k 00:26:35 | | silhouette, front | egg or balloon, pointed dome, features stuck on a big sphere | Morren 00:16:28; Naydenov oY9XybQRxzQ 00:15:26 | | silhouette, top | head wider at the front than behind the ear | Thelen 00:01:11; Keelan Jon 00:19:10 | | profile | neck missing or vertical under the skull; no forward lean | Thelen 00:02:20; Naydenov 00:16:53 | | matcap | primitives read as separate lumps (stacked sausages, visor brow) | [added] blend them (Clay blend 0.08 to 0.15 H); nine smooth passes widened a seam only 4 % (P2) | | stylization | the defining exaggeration is not visible yet in silhouette | Yan vB7kPWjBgQI 00:01:09 | | lower face form | `lower_face_report(head, H)["ball"]` True, or cheek, jaw and jowl read as one sphere in the 90 mm profile or three-quarter | E2 evaluation; Zarins -3b7hDQUfIg 00:28:11 (gonial angle) | | jaw block | a flat front ahead of the mouth (a mask), or its top edge visible across the cheek (a panel) | v2 iterations 1 and 2 | | cheekbone | a horizontal bar from eye to ear instead of a mass running from the ear down and forward | Naydenov oY9XybQRxzQ 00:25:14; v2 iteration 1 | ## Stage 2: primary forms and landmarks (H/65 to H/100) | Check | Blocker when | Source | | ----------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | faces | above about 100k before the shape review passes | Abbitt K7AJVx0H3Ec 00:28:11 | | landmark ratios | outside about ±5 % of the design table [added tolerance] | SKILL.md Numbers | | profile_rhythm | fewer than chin out, in, lips out, in, nose out | Ryan King Km9JSdWTjVY 00:10:42 | | eyes | marbles on a flat face; no socket between brow and cheek; eyeball poking through lids | Kaspar YaVEJTLDD3Y 01:00:04; Ryan King CbZChQoi46k 00:02:47 | | jaw from below | cheek and jaw one balloon, no jawline | [example iteration 4]; Zarins -3b7hDQUfIg 00:28:11 (gonial angle) | | face vs skull | features too small for the head | Naydenov 00:48:35 | | small landmarks | nostrils, brows, eyes not indicated yet (they expose proportion errors the masses hide) | Yan vB7kPWjBgQI 00:05:11 | | lids and sockets | sleepy (upper margin too low), a ring groove that reads as eye bags, lid cups sticking out in profile | v2 iterations 2 to 5 | | hair | a helmet ledge, a beanie with worms, a bare headband above the forehead, a symmetric crown or flame | v2 iterations 1 to 9; Yan 01:08:06 | | character cues | the type does not read without the face: no hair, costume or prop | Thelen 00:48:58; Naydenov 00:53:05 | | mouth | straight across instead of wrapping the head | Yan 00:08:00; Ryan King 00:16:33 | | ears | glued flat, or flapping out at the bottom; start visible from the front | Naydenov 00:19:35, 00:23:59 | | neighbors | one part stylized alone (face vs neck, long face on a big skull) | Yan 01:13:23, 00:35:38 | | stretch_p95_over_median | above about 2.5 after grabs or moves: remesh | [added, from Reinhardt 00:52:28] | ## Stage 3: secondary forms and lines (H/130 to H/235) | Check | Blocker when | Source | | ----------------- | ----------------------------------------------------------------- | -------------------------------------------------------------- | | lines | jagged, dotted, wobbly, or lost after the last remesh | Abbitt 00:31:59; Reinhardt 01:08:49 | | crease resolution | fewer than 3 to 4 vertices across the crease radius | Ryan King mgHsZZiyd54 00:16:00 | | smoothing | features melted by smoothing across forms | Thelen 00:24:17 | | lids | thick, missing, or the upper lid not covering the top of the iris | Ryan King Km9JSdWTjVY 00:22:34; Abbitt 9N87-yRR5aE 00:26:25 | | nasolabial | over-defined (ages the face) unless age is the brief | Thelen 00:26:31 | | attention | a form that pops "like hey look at me" without a reason | Yan 00:50:35 | | stroke placement | a groove or scratch where no stroke was meant (nose, forehead) | [example iterations 2 and 3: view-ray placement, P4] | | grazing creases | parallel cracks beside the mouth or on the cheek side | v2 iteration 6 (front projection on a grazing surface) | | plane restates | a flat coin or facet with a rim | region smaller than the plane's intersection; wrong mass (P14) | ## Stage 4 and later (detail, asymmetry, hand-off) - Detail density matches the camera: nothing smaller than the game camera or texel density resolves (Naydenov DZ0T4bcchcs 00:03:17, 00:20:39); breakup varies in scale and spacing (Reinhardt 01:43:05). - Asymmetry is intentional and on a shape key; basis neutral and symmetric (Thelen 00:38:27; Kaspar f-mx-Jfx9lA 00:09:44; `procedures.md` P11). - The expression reads at the delivery framing: a mouth corner moved about 1/8 of the mouth width (measured: 8 mm on a 65 mm mouth read at both framings, 2 mm at neither); check pixels with `S.framing`. - Expression tests do not reveal proportion errors (eyes, brows, teeth); fix any in the neutral, not in each key (Kaspar f-mx-Jfx9lA 00:10:49). - Every high-poly part closed; normal-map-only details inside the silhouette; one object per future material color (Naydenov LFjXmRvQpxs 00:11:38, 00:46:38; PkQ2wD8t_f0 00:02:13). ## Character-level questions (after every stage, answer in one line each) 1. Does it read as the brief at thumbnail size (sex, age, type, attitude)? (Kaspar f-mx-Jfx9lA 00:00:33; Zarins 00:29:51) 2. Which single exaggeration defines it, and do the neighbors follow it? (Yan 01:13:23) 3. Is there structure under the skin (skull, tissue over teeth), not a blob? (Zarins 00:00:40; Thelen 00:11:45) 4. Is it harmonious (jaw vs cheekbones, eye size vs face length)? (Thelen 00:44:02) 5. Are the character cues there (hair, costume, prop), and do they frame the face rather than compete with it? (Yan 01:08:06) 6. What is the weakest link now? Fix only that. ## Reporting State per stage: voxel/H, faces, components, mirror error, profile rhythm, the renders looked at (paths), the weakest link found and what was done, and what was NOT verified (for example "lid thickness not judged at 90 mm", "GUI strokes not run"). Never call a sculpt stage done without having opened its renders. ## Lessons from the evaluation runs (2026-09-24) [eval] Three fresh agents sculpted the same brief (stylized adventurer bust) with and without this skill; these are what the renders showed, not expert quotes. - A single tilted hairline half-space reads as a garrison cap from the side; build the hairline as a function of azimuth (sideburn, over the ear, down behind it to the nape), and ramp the quiff up from the hairline over about 0.12 H instead of a z-only thickness ramp (vertical wall). - Gaze carries attitude as much as the mouth: mirrored eyeballs cannot glance; aim two eyes. - Lid margin is an attitude dial that depends on the brow: under a heavy brow shelf 20 to 27 degrees read as staring, about 19 degrees relaxed and confident. - Brow angle is an expression dial: outer ends drooping read worried, inner heads low read villainous; keep them level on a neutral basis. - The 100k stage-3 gate is for a head; state it per head area or as voxel/H when the bust includes shoulders (128k at H/160 was fine). - Silhouette holes: `lower_face_report` and `profile_rhythm` passed while the nose was attached at only two points; probe a ray grid per view before calling a stage done. - Run `bx_audit` self-intersections after the last remesh: thin ear details fold after remesh plus relax; a local volume-preserving smooth fixes them. - Big stylized noses need a side wall to the cheek (nasal base mass); a cone plus a tip sphere floats in profile. - Costume is quick and robust as an offset shell of the head's own SDF grid, cut by half-spaces. - Grooves (`Clay.stroke(op="sub")`): narrow reads as scratches, wide and shallow as lumps, uniform and parallel as stripes; deep at the start and fading along the flow reads as locks. - OpenVDB `to_object` can leave one non-manifold edge at H/110; the next `remesh_stage` clears it. - The skill's structure worked as written in the second run: the jaw-block recipe kept the lower face from reading as a ball (sphere rms 0.15 to 0.26 vs 0.057 to 0.082 for known balloons), and the 1/8-of-mouth-width smile rule read clearly (7.8 mm, 28 px at 1600 px). -
expert-notes.md 32.1 KB
# Expert notes: sculpting (principles and judgment, by expert) Source notes: `notes/sculpting/` (one per video) and the three digests `_digest_sculpt_courses.md`, `_digest_sculpt_stylized.md`, `_digest_sculpt_studio_anatomy.md`. Timestamps are `[video_id hh:mm:ss]`. Items marked [added] are this skill's own synthesis or measurement. Full source list: `sources.md`. ## Julien Kaspar (Blender Studio character artist: Spring, Coffee Run, Sprite Fright) Videos: Rain f-mx-Jfx9lA, Snow BJHede3Oagw, BCON19 YaVEJTLDD3Y, BCON23 FDscc66fC90, Michel uGde7HdmCa8. - **Decide who the character is before geometry**: ethnicity, gender, age, period, occupation, attitude; aim "abstracted enough to not be uncanny but also not too detached from reality" [f-mx-Jfx9lA 00:00:33]. Work from a concept drawing; skipping it for Rain cost time [00:02:55]; for Snow he drew it first [BJHede3Oagw 00:01:08]. - **Blockout of many separate primitives, kept planar and rough "until the overall shapes are set in stone"** [f-mx-Jfx9lA 00:03:22]; block every major volume even inaccurately because 2D-to-3D translation needs compromises only visible in 3D [FDscc66fC90 00:06:21]. - **Voxel remesh replaced Dyntopo** except for Snake Hook pulls and local detail with the Density brush [YaVEJTLDD3Y 00:18:32]. Voxel-only (Snow) forces adding detail step by step and refining "equally throughout the entire character" [BJHede3Oagw 00:02:55]. A 5M-poly voxel remesh takes about 10 s where a Dyntopo flood fill takes minutes [YaVEJTLDD3Y 00:30:36]. - **After remeshing finer, run a global Smooth filter** to remove voxel jaggies instead of hunting with the brush [FDscc66fC90 00:11:55; YaVEJTLDD3Y 00:27:17]. Headless: `remesh_stage(relax=n)`. - **Draw Sharp over Crease for most lines**: Crease pinches and stretches topology; Draw Sharp "just adds a sharp curvature ... saves you some time"; thicken the upper side of the line with Inflate [YaVEJTLDD3Y 00:22:23, 00:22:55]. - **At low resolution a strong Smooth works as a flatten** [YaVEJTLDD3Y 00:45:14]. Normal Radius sets the plane orientation sample; about 4 % of the radius snaps to the local surface for hard forms [00:28:43]. - **Hide, do not only mask, geometry near the brush**: masked geometry is still sampled for brush direction [FDscc66fC90 00:20:19-00:20:52]. - **Face sets by loose parts "95% of the time"** right after joining, by UV seams for two-sided thin parts [FDscc66fC90 00:14:41, 00:21:57]. Pose brush with Rotation Origins = Face Sets is exact; topology origins are loose [00:29:01; uGde7HdmCa8 00:11:04]. - **Grab with topology auto-masking "all the time"**; Grab Silhouette widens one finger without neighbors; Grab 2D moves through the full depth [FDscc66fC90 00:26:04-00:28:16]. On screen: Grab strength 0.4, Pose 0.5 with IK segments 1, smooth iterations 5. - **Linked-data symmetric parts** allow asymmetric posing while sculpting symmetrically; make single user before Apply Scale; negative scale flips normals, and sculpting the negative copy inverts brushes [FDscc66fC90 00:04:06, 00:09:50; YaVEJTLDD3Y 01:01:02]. - **Apply scale and rotation before sculpting**; Snake Hook direction is corrupted by object rotation [YaVEJTLDD3Y 00:03:59, 00:42:04; f-mx-Jfx9lA 00:06:40]. - **Keep symmetry alive as long as possible**: "the longer i can keep the symmetry alive the more work i will save myself" [uGde7HdmCa8 00:47:08]; save the symmetric rest state as a shape key before posing [01:28:08]. "Go in more drastically and then clean up" [00:35:08]. - **Test the design, not just the sculpt**: expression shape keys (on the base level only, never multires levels; eyelids need 3 to 4 loops; 30 frames between expressions) and a full-pose test reveal proportion errors (brows too high, teeth too small) [f-mx-Jfx9lA 00:09:11-00:10:49; BJHede3Oagw 00:06:42]. - **Eyes**: sculpted eyes are never round, insert real spheres; "It's always going to look better with eyelids"; paint the iris early to find appeal [YaVEJTLDD3Y 00:38:25, 01:00:04, 00:51:02]. - **Progress the whole body evenly**, do not finish the head while the body is spheres [YaVEJTLDD3Y 00:15:13]. "It always looks hideous until it doesn't" [00:31:12]. - **Uneven density by region at the production step** (face and hands denser), retopology starts from minimal loops [BJHede3Oagw 00:04:34, 00:08:19]. Clothing: solid volumes first, extract thin cloth last; folds only where fabric compresses or stretches [BJHede3Oagw 00:04:01; f-mx-Jfx9lA 00:07:14]. - **Review**: black silhouette viewport permanently beside the working view [YaVEJTLDD3Y 00:06:46]; turn vertex colors off regularly because color hides form [f-mx-Jfx9lA 00:04:59]; 90 mm lens to compare with the concept [uGde7HdmCa8 00:46:31]; Studio "paint" light for color, not a matcap [FDscc66fC90 00:36:27]. - Numbers on screen: voxel 0.008 m (leg) and 0.004225 m (torso) on a real-scale base mesh, parts 3k to 8k verts, merged figure about 39k verts, paint-ready about 2M verts [FDscc66fC90 00:11:39, 00:16:09, 00:33:58]; Rain head polish 190k to 250k verts, body sculpt 1.1M to 2M; Rain retopo body 16.6k faces, Snow about half [f-mx-Jfx9lA 00:15:51; BJHede3Oagw 00:10:00]. ## Damiaan Thelen (lead character artist, Egosoft) Video: gC3FG8-4lsU (BCON23, realistic male head from a sphere in 50 minutes). - **Plane-first**: sketch the face as sharp simplified planes with Draw Sharp, soften toward realism as resolution grows; **re-cut the primary planes whenever a face "looks off"**, that alone often fixes it [00:05:43, 00:15:08, 00:36:42-00:37:15]. - **Features exist only in context**: judge proportions after several features are blocked, and suspect neighbors when one area looks wrong [00:09:00, 00:43:29]. - **Lock proportions while coarse**: every later adjustment moves more polygons [00:12:52]. "Always use as small a poly count as you can get away with" [00:22:34]. - **Smooth one form at a time, never across forms** [00:24:17]. - **Neck first**: cylinder rotated about 15 degrees forward, shoulders leave at an angle; sternomastoid, trapezius, mass under the jaw [00:01:44-00:06:49]. - **Eyelid mass trick**: a UV sphere with a pulled cornea is joined and remeshed into the face as the lid volume; a copy becomes the real eyeball [00:15:43-00:18:33]. Headless version: `Sculptor.open_eyelids` [added]. - Ratios: head height about 1.5 x width [00:00:35]; top view tapers forward, widest right behind the ear [00:01:11]; ear bottom at nose base, top on eye line [00:04:04]; eye line at the vertical center [00:07:22]; one eye width between the eyes, eyeballs slightly smaller [00:17:24]; equal thirds hairline-brow-nose base-chin [00:20:46]; male jaw 90 to 100 % of cheekbone width [00:20:46]; outer-canthus span about equal to brow-to-mouth [00:21:22]; nose-bottom angle about 90 degrees [00:25:23]; lip corners line up with the eyeballs in profile [00:27:40]; canthal tilt about 8 degrees [00:46:10]; scleral show below the iris ideally none [00:46:42]. - **Appeal**: averageness is pleasing but forgettable; diverge through sexual dimorphism at brow, jaw and nose [00:21:22-00:22:34]; harmony beats single ideals (wide jaw needs matching cheekbones, a longer face carries bigger eyes) [00:44:02]; nasolabial folds defined little because they age the face [00:26:31]; a beak mouth means missing tissue at the corners [00:27:40]; break symmetry with a resting expression (smirk corner), not noise [00:38:27]; eyebrows are needed to read a face [00:45:38]. Beauty standards are culture dependent; state the target [00:30:29]. ## Uldis Zarins (author of "Anatomy for Sculptors") Video: -3b7hDQUfIg (BCON23, construction of the head). - **Blockouts are guidelines to navigate organic chaos, "not the topology"** [00:05:45]; start from Loomis (sphere with chopped sides) or Bammes (egg) [00:07:00-00:09:31]; "if there is no border you have to draw it" (temporal line) [00:33:38]. - **Build the skull first**: "if you build wrong skull you probably will build the wrong soft tissue" [00:14:41]; rely on bony landmarks because they do not move [00:19:31]. - Widest head point: parietal eminence a little behind the ear; widest face point: top of the zygomatic arch [00:21:25, 00:25:18]. Restore the temporalis volume where Loomis chopped the sides [00:27:45]. - Face : brain case about 1 : 1.5 adult, 1 : 3 newborn [00:11:11]. The gonial angle is the age and sex dial: steep in adult males, open in infants and the elderly [00:28:46, 00:32:08]. - Dimorphism: brow ridge with glabella, steep ramus, sharp gonial angle and wide chin read masculine; frontal eminences, rounded jaw angles and narrow chin read feminine; sculptors project their own sex onto characters [00:29:51-00:31:00, 00:35:05]. - Neck attaches where the line from orbit floor through ear canal meets the back of the skull [00:40:24]. The average face is the zero point you push from [00:24:45]. ## Nikolay Naydenov (SpeedChar; character artist at Gameloft, 15 years) Videos: proportions oY9XybQRxzQ (ZBrush, transfers directly), game character episodes 01 a05leXFSf-0, 20 DZ0T4bcchcs, 21 PkQ2wD8t_f0, 22 LFjXmRvQpxs. - **Proportion rules as placement errors**: nose bottom halfway between brow line and chin, not between eye line and chin [oY9XybQRxzQ 00:05:49]; lip parting one third down from the nose, not the midpoint [00:09:06]; eye line at half height, slightly higher for a strong big-chinned male [00:03:23]; head five eyes wide, one eye between [00:07:28]; mouth corners below the middle of the eyes [00:08:33]; ears between nose line and brow line [00:10:37], mid head depth in profile with only about 1.5 ear widths behind [00:17:26], pulled away from the head, never glued flat [00:19:35]; from the front the ear's start is hidden because the skull widens behind it [00:23:59]; front of the neck starts at mid-jaw in profile [00:16:53]. - **"Don't leave the head like a balloon ... it looks more like a square"** [00:15:26]; simplify hard areas into planes, then smooth [00:32:25]. - Mouth last: dig the corners, raise the upper lip rather than pushing it forward, reduce the lower lip to a central trapezoid, upper lip emerges from inside out [00:41:35-00:44:39]. Features too small for the head: mask the face and scale up [00:48:35]. Skull fit test with transparency [00:55:50]. - **Production context**: a game character starts from a brief and a concept sheet; head and body are separate meshes from day one [a05leXFSf-0 00:02:13, 00:03:33]; "don't go with too much resolution too fast" [00:06:48]; about 20+ hours per character [00:41:52]. - **Detail size is set by the game camera**: "we cannot have this kind of very tiny little details" [DZ0T4bcchcs 00:03:17]; big holes "because it will be viewed from around here" [00:20:39]; "too sharp is always a problem" for the bake [00:15:43]; main forms beat details [00:30:56]; know when to stop [00:29:50]. - **High poly for the bake**: every item that gets its own flat color is its own object [PkQ2wD8t_f0 00:02:13]; vertex colors become an ID map in Substance Painter [LFjXmRvQpxs 00:08:43]; high-poly objects closed [00:11:38]; normal-map-only details stay shallow, inside the silhouette [00:46:38]; sign-off by lead and art director before low poly [00:03:46]. Head reprojection: Quad Remesher about 8,000 tris, Multires to about 2M, Shrinkwrap Project with Negative, apply one level down [DZ0T4bcchcs 00:32:35-00:35:21]. Real-scale heads need proportional voxel sizes: a large voxel destroyed a sub-2 m head [00:31:28]. ## Jim Morren (CG Boost; realistic head) Video: BPAvvF8py1M. - **Spend a long time at low resolution**: all landmarks and the skull silhouette before any detail pass [00:17:03]. Landmarks first, "fast and loose", then correct against reference; the head is "basically just a skull with skin on it" [00:05:48, 00:16:28]. - **Landmark pencil**: Crease with pinch reduced, strength raised, Ctrl to raise [00:05:16]. - **Voxel 0.05 on a 2 m head (about H/40) for blocking, 0.0085 (about H/235) for detail** [00:04:45, 00:20:56]. - **Move whole features**: mask, remove the mask where it touches neighbors, smooth it, invert, Set Pivot to Unmasked, Scale or Move [00:23:38, 00:33:29]. - **Eyeball spheres late** for realism: they anchor the lids to a size you may not want [00:23:06]. - **95 mm lens** to match photo reference and remove distortion [00:13:14]. Typical skull errors: egg-shaped or too square; forehead that turns into the side "straight and then BAM" [00:15:56, 00:16:28]. - Build then polish instead of smoothing: Clay Strips "fatty tissue" into crevices, then Flatten with Normal Radius 1 as "a polishing brush" [00:28:35, 00:29:39]. Lower the Smooth strength so blending keeps forms [00:14:21]. - Mouth corners roughly line up with the eyes; "the face from the side should have a very slight triangular shape" [00:35:07]. Nose sits deep in the face [00:10:19]. Not every muscle shows: fat and skin cover them [00:52:07]. Zoom out to fight tunnel vision [00:37:48]. Dotted crease strokes: spacing 5 %, Adjust Strength for Spacing off [00:47:04]. ## Zach Reinhardt (CG Boost; Moai course, 20+ years in 3D) Video: KURuPAVJ6hM. - **Primitives matching the concept's big volumes**, crude; resolution from remesh [00:20:45, 00:24:29]. "Never ever go too high with the resolution when you're just starting" [00:30:32]. - **Unapplied scale is the first suspect** whenever sculpting misbehaves [00:27:17]. - **Protrusions by mask + Move, not strokes**: lasso outline from the side, Smooth Mask 3 iterations, Sharpen, clean with the Mask brush at 1, invert, Move tool, remesh to heal [00:44:15-00:46:29, 00:52:28]. - **Scrape for clean planes**, Flatten "not really giving us what we want" here [00:40:53]; many scrape strokes make carved stone facets [01:32:03]. - **Sharpen pass after every remesh**: Draw Sharp into creases, Scrape to shave the peaks, tiny smooth, Pinch [01:08:49-01:12:40]. "Don't worry if we are losing some sharp details, we will sharpen the whole thing later" [00:55:12]. - **Grab falloff changes the shape**: Sharp for a folded smile corner, Smooth for masses [01:25:31]. Asymmetry pass last, after a ground object shows what the camera sees [01:23:56, 01:28:17]. - **Surface breakup varies in scale and spacing**: pits with Draw, Sphere falloff, spacing 300 %, high jitter, Subtract, Radius Unit Scene, two size passes; damage "not too tiny and not uniformly spread" [01:36:32-01:43:05]. Restore spacing 10 % and jitter 0 afterwards [02:37:29]. - **Cavity auto-masking left on silently disables filters** on the next object [02:20:18, 02:34:08]. Same voxel size on every object seen together [02:23:37]. Angled key light, not frontal, to show surface detail [03:12:14]. ## Grant Abbitt (long-time Blender educator) Videos: 9N87-yRR5aE (3.6), K7AJVx0H3Ec (4.4). - **"Keep as low detail as possible for as long as possible"**: Smooth equalizes vertex spacing, so it moves big polygons a lot (useful early) and fine ones barely (useful late) [9N87-yRR5aE 00:08:26, 00:08:58; K7AJVx0H3Ec 00:09:04]. Distiller measurement: 34 % of a bump removed at voxel 0.08 vs 3 % at 0.02. - **Gate at about 100k faces**: shape correct before, detail at about 500k [K7AJVx0H3Ec 00:28:11, 00:29:49]; about 0.5M ceiling on older machines, 3M on a strong one [9N87-yRR5aE 00:06:46]. Voxel ladder on a 1 m radius sphere: 0.08, 0.07, 0.06, 0.03, about 0.01, 0.006. - Remesh whenever Grab stretches polygons [9N87-yRR5aE 00:07:52]; pull forms from the side, not head-on [00:07:20]; crease, smooth, crease, smooth [K7AJVx0H3Ec 00:35:47]; remesh loses detail, restate after [00:22:13]. - Landmarks: eyes at half height [K7AJVx0H3Ec 00:11:45]; jaw line to about halfway [00:13:15]; ear from nose base to middle of the eye [00:25:28]; half an eyeball between eye and nose bridge [9N87-yRR5aE 00:13:51]; top lip ahead of the bottom lip [00:21:35]; upper lid overlaps the iris, lower lid less [00:26:25]; brows must match the mouth expression [00:28:57]; face wraps like a cylinder [K7AJVx0H3Ec 00:12:17]. Eyeballs early for stylized heads, UV sphere rotated 90 on X so the pole reads as a pupil [9N87-yRR5aE 00:13:19]. ## Henning Sanden (FlippedNormals co-founder, film and games sculptor) Video: Cmi0KoFtc-4. - **Three or four brushes do the vast majority**: Draw, Clay Strips, Grab, Draw Sharp, plus Smooth on Shift [00:03:33]. Clay Strips "90% of the time" with tip roundness about 0.5 and no radius pressure [00:05:44]. - **Default Smooth "nukes the entire shape"**: about 0.2 [00:07:20] (5.2 default 0.7). - **Resolution by phase**: Dyntopo and remesh for concepting (Dyntopo with Snake Hook for new appendages; remesh gives "nice and proper" polygons, Dyntopo "nasty and triangulated"), Multires only on a settled base [00:13:01-00:16:04]; keep Multires viewport level equal to sculpt level [00:17:43]. - **Balanced matcap, cavity off by default**: cavity makes forms look more voluminous than they are [00:25:17, 00:25:50]. ## Ryan King (Blender tutorial author; stylized heads) Videos: Km9JSdWTjVY, CbZChQoi46k, Lxem4yMs5Dg, mgHsZZiyd54 (Blender 5.0). - **Hard stylized male = sharp and angular**: flat skull sides, sharp jaw, thick square chin, straight hard nose built as a triangle from below, slightly pointed skull top; his own verdict: "more sharp, not quite as round" [Km9JSdWTjVY 00:05:50, 00:09:35, 00:27:24; CbZChQoi46k 00:27:40]. - **Profile rhythm**: nose out, in, mouth out, in, chin out; flat faces are the beginner error [Km9JSdWTjVY 00:10:42, 00:16:33]. The mouth wraps around the head. - **Eyeball in first, lids built around it**; move the eyeball, not the skin, when it sits wrong; upper lid three planes (up, across, down), lower lid flatter, both thin; inverted crease sharpens the lid edge [Km9JSdWTjVY 00:18:11-00:24:11]. A deep brow crease reads angry [00:25:47]. - Crease semantics: plain crease cuts a V, Ctrl-crease pinches a knife ridge (lid rim, lip border, ear rim) [Km9JSdWTjVY 00:23:06]. Accumulate and Front Faces Only on, per brush [00:03:01]. - **Cute**: lumpy egg body, stubby masked-and-pulled limbs, big smile, round sockets, bigger pupils; Crease Polish because Crease Sharp is "a little bit too sharp"; crease sharpness is capped by local density, remesh finer first; mask, invert, Elastic Snake Hook for limbs [mgHsZZiyd54 00:10:44, 00:15:28, 00:16:00, 00:21:13, 00:43:11]. "It's better to work with less topology, especially at the starting" [00:14:55]. - Remesh for forming and fusing, Dyntopo only for local detail (freckles, gills) [Lxem4yMs5Dg 00:22:08; mgHsZZiyd54 00:27:29]. Front Faces Only matters on thin fins [Lxem4yMs5Dg 00:29:48]. - **Finish without retopology for stills**: vertex colors on the dense mesh, Decimate 0.4 applied last, expression on a duplicate with symmetry off, fake a head turn by moving the shoulders, catchlight disk light [CbZChQoi46k 00:04:45, 00:18:32, 00:19:04, 00:24:10]. ## Keelan Jon (character artist and teacher; the most scriptable method) Video: amVAlpxHp8k. - **One subdivided primitive per part** (cube + Subdivision 2 applied), apply scale on every part, duplicates moved only along the symmetry plane [00:04:34-00:06:47]. "Force ourselves to not go into too much detail yet" [00:05:07]. - **Join only where a seam would show** (head, jaw, nose); keep neck and ears separate [00:24:19]. - **Remesh in steps and symmetrize after every remesh**: "remeshing can sometimes create inconsistencies between the left and the right" [00:26:35]. Measured [added]: raw voxel remesh mirror error about 0.6 voxel. - Budgets: about 29k faces while defining a head, about 101k for the smooth finish; hair 4k then 22k; clothing 10k to 22k [00:32:40, 00:44:57, 00:52:56, 01:29:50]. - Draw Sharp 0.65 with Sharper falloff for one-stroke cuts, Crease 0.15 to refine, Inflate to close and plump a seam [00:31:34, 00:34:18, 00:35:25]. Smooth then redefine [00:44:57]. - Eyelids as a shell from the eyeball (half sphere duplicated, filled, inset 3 times, tilted) [00:37:05]. Brows and lids must emerge from the skin: Inflate so they stick out [01:00:48]. - Line Project for a flat bust base and cloak hem [00:17:27]; never voxel-remesh an open thin sheet, subdivide it [01:23:40]; Mask Extract for clothing, Mesh Filter smooth in small increments [01:17:26, 01:18:37]; Scrape for flat sharp edges [01:30:23]. - Sculpt under matcap with cavity (ridge 0.5, valley 1.2); paint under the white studio light [01:44:48, 01:45:22]. Camera 90 mm [02:08:33]. ## YanSculpts (character sculptor and educator; stylization judgment) Videos: vB7kPWjBgQI, IG1IEpU5VAw, N4D6F7mhi4I, uoYtqlm6dfY, jZbFYD8JivI. - **Stylization is controlled exaggeration of real anatomy**; appeal is "very very important" [vB7kPWjBgQI 00:07:26]. - **Pick the defining exaggeration in the first minute** and apply it to the whole mass (pull the sphere down for a long face) [00:01:09]. - **Never stylize by stretching**: a long face needs a smaller skull, or it "will just look stretched" [00:35:38]. **Stylize neighbors together**: a stylized face on an unstylized neck fails; a long chin made the neck read short, fixed by masking the face and pulling the neck down; a squished face was fixed by moving ears and jaw back, not by widening [01:13:23, 01:14:34, 01:23:25]. - **Indicate small landmark features early** (nostrils, brows, eyes, ears): nostrils revealed a too-wide nose [00:05:11, 00:19:18]. Brows close to the eyes on a neutral male [00:19:18]. - Ratios: nose base about midway eye line to chin; mouth line one third nose base to chin; ear from eye line to nose base on the widest skull point; neck starts behind the ears; shoulders line up with the ears in profile [00:02:53, 00:08:35, 00:17:29, 01:27:06]. - **Weakest-link loop under a time limit, with breaks**: "find the weakest link, work on that, improve it and then go for the next" [00:54:04]; "if you're working on anything for too long ... you don't really see the mistakes anymore" [01:29:16]. A form "hanging out ... like hey look at me" without a reason gets smoothed back [00:50:35]. Hair frames the face and must not compete; three strands [01:08:06, 01:10:27]. - Brush roles: Draw for big chunks, Clay Strips "the magic brush" for anatomical masses, Crease for separations, Scrape with a flat curve for refinement (most of his last 1.5 h), Pinch after smoothing for clean edges [IG1IEpU5VAw 00:15:47-00:22:29]. Long lens 80 to 100 [00:04:51]; perspective, not ortho, while sculpting [vB7kPWjBgQI 00:16:53]; viewport shadows off while judging [N4D6F7mhi4I 00:08:42]. --- ## Stylization playbook (cross-expert) - **Shape language by type**: hard male = sharp planes, square chin, flat skull sides (Ryan King); cartoony undead = big round nose, wide smile, deep bony sockets, big forehead, flat upper lip (Keelan Jon); cute = egg masses, stubby limbs, big pupils, softer creases (Ryan King); long-faced male = long face, small skull, thick mouth volume at the corners, big ears pushed out, wide neck against a thin face (Yan). Sources: `_digest_sculpt_stylized.md` playbook. - **Exaggeration rules**: one or two defining exaggerations chosen first (Yan 00:01:09); applied to every neighbor (Yan 01:13:23); never uniform stretching (Yan 00:35:38); face-to-brain-case ratio as the age and cuteness lever, 1 : 3 newborn vs 1 : 1.5 adult (Zarins 00:11:11) [added: use it to stylize young or cute characters]; dimorphism as the lever for striking realistic characters (Thelen 00:21:57). - **Appeal cues**: mouth corners slightly up; brow crease too deep reads angry; bigger pupils cuter; catchlights; relaxed tilted lids read "doesn't care" (Ryan King, Keelan Jon); eyelids always (Kaspar); intentional asymmetry at the end (Thelen, Kaspar, Reinhardt). - **Simplify**: flatten the upper lip, keep ears simple, smooth away forms that draw attention for no reason (Keelan Jon 00:36:31, Ryan King 00:44:16, Yan 00:50:35). ## Where experts disagree, and the deciding condition | Topic | Positions | Decide by | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Start geometry | sphere morphed (Thelen, Naydenov, Morren, Abbitt); one primitive per part (Keelan Jon, Kaspar, Reinhardt); existing base mesh (Kaspar 2023, Snow) | single head study: sphere; character with distinct parts or an agent needing deterministic placement: primitives; lineup or time pressure: reuse | | Eyeball timing | early, lids built around it (Ryan King, Keelan Jon, Abbitt, Kaspar); late, size the socket first (Morren) | stylized with a fixed eye size: early; realistic proportion search: late | | Line tool | Crease among the starters (Kaspar Rain, Abbitt); Draw Sharp for most lines, Crease only for late polish (Kaspar 2019, Thelen) | Draw Sharp while topology still changes; pinching crease for final crisp folds | | Clean planes | Scrape (Reinhardt, Yan, Keelan Jon); Flatten with normal radius 1 as polish (Morren) | hard carved planes: Scrape; soft blends between organic masses: wide-normal Flatten | | Smoothing | strong smooth as a flatten at low res (Kaspar); one form at a time (Thelen); strength 0.2 (Henning) | blocky low-res stage: aggressive; once features sit side by side: local and light | | Resolution tool | Dyntopo relative detail (Ryan King heads, Yan, Rain); voxel only (Keelan Jon, Snow, all course videos) | headless: voxel only (Dyntopo needs strokes); GUI: Dyntopo only for Snake Hook pulls or a local detail burst | | Ear top | brow line (Naydenov); eye line (Thelen, Yan); mid-eye (Abbitt) | all agree on the bottom at the nose base; treat the top as a band from eye line to brow line | | Sharpness | sharper is better on a hard male (Ryan King); "too sharp is always a problem" for a game bake (Naydenov); Yan makes neck junctions too sharp | sharp on bony defining edges, soft into soft tissue; a game bake target caps sharpness at what the texel density resolves | | Matcap cavity | off, it inflates volume (Henning); on, it reads creases (Yan, Keelan Jon, Ryan King) | judge big forms without cavity, read lines and creases with it | | Symmetry mechanism | Mirror modifier with a center object (Rain); linked duplicates (Kaspar 2019/2023); mesh symmetry + Symmetrize (Michel, Keelan Jon) | separate posed parts: linked data; one merged mesh: symmetric kernels + `S.symmetrize` after remeshes | ## Planes, jaw and cheek, hair, expressions (cross-expert, added v2 after the E2 evaluation) **Planes.** Thelen sketches the face as sharp simplified planes with Draw Sharp, then softens as resolution grows, and re-cuts the primary planes whenever they get lost: if re-cutting fixes the look the problem was lost form, if not it is proportion (gC3FG8-4lsU 00:05:43, 00:15:08, 00:36:42 to 00:37:15). Naydenov: simplifying a difficult area into planes then smoothing is a good way to start a face; front view reads flat front, angled sides, flat sides (oY9XybQRxzQ 00:32:25, 00:13:26). Reinhardt: Scrape, not Flatten, for clean planes; Lasso Trim for chin and eye recesses; Line Project for perfectly flat ear sides (KURuPAVJ6hM 00:40:53, 00:28:21, 00:47:01). Kaspar draws plane borders and lines with Draw Sharp rather than a pinching Crease (YaVEJTLDD3Y 00:22:23) and reads planes in a flat-shaded render (f-mx-Jfx9lA 00:05:32). Ryan King flattens the jaw side with Ctrl Clay Strips and names sharper edges as the fix for his own round result (Km9JSdWTjVY 00:07:28 to 00:08:01; CbZChQoi46k 00:27:40). **Jaw and cheek.** Zarins: the mandible is placed with its gonial angle, steep in adult males, open in infants and the elderly; the widest face point is the top of the zygomatic arch (-3b7hDQUfIg 00:28:11 to 00:29:19, 00:25:18). Naydenov: straight jaw sides with a triangle under the jaw, the cheekbone line from the ear down and forward, only its lower edge showing, the masseter a disc of mass smoothed except its front edge (oY9XybQRxzQ 00:20:41, 00:25:14, 00:29:30, 00:40:46). Thelen: male jaw 90 to 100 percent of cheekbone width, the jawline bone plus tissue and not razor sharp, the cheek ogee judged in three-quarter (gC3FG8-4lsU 00:20:46, 00:32:17, 00:36:09). **Hair for stylized characters.** Yan: hair mimics the skull (so the skull comes first), three strands is the magic number with a small fourth for interest, and hair is kept off the eyes and forehead so it frames the face (vB7kPWjBgQI 00:59:15, 01:08:06, 01:10:27). Keelan Jon: a subdivided cube grabbed into a slicked-back mass, remeshed at about 4k faces, ridges with Draw Sharp, re-creased after each remesh (amVAlpxHp8k 00:50:44 to 00:56:54). Thelen: hairline from the facial thirds, male square or M, and hair makes a male head read better even as clay strands (gC3FG8-4lsU 00:48:26 to 00:50:38). Kaspar keeps hair as separate objects (f-mx-Jfx9lA 00:05:32). Naydenov: bald versus haired changes the read a lot (oY9XybQRxzQ 00:53:05). **Expressions.** Kaspar sculpts expressions as shape keys on the base level with Grab and Smooth, adds asymmetry to each, and uses the set to find proportion errors that he fixes in the neutral (f-mx-Jfx9lA 00:09:44 to 00:11:22). Thelen breaks symmetry at the end with a resting smirk corner (gC3FG8-4lsU 00:38:27 to 00:39:35). Reinhardt switches Grab to Sharp falloff for a crisp bent smile corner, in a late asymmetry pass (KURuPAVJ6hM 01:25:31, 01:23:56). Ryan King: duplicate, symmetry off, one eye more open, a brow up, mouth corners uneven (CbZChQoi46k 00:18:32 to 00:20:09). **Resolution tools.** Henning: Dyntopo and voxel remesh are concepting tools, Multires is for refining once a base with decent topology exists (Cmi0KoFtc-4 00:16:04). Kaspar smooths globally with a filter after remeshing finer instead of hunting with the brush (FDscc66fC90 00:11:55). Yan marks nostrils early because they expose proportion errors (vB7kPWjBgQI 00:05:11). ## Measurements made while building this skill [added] - faces = 1.5 x area / voxel^2 within 3 % on spheres and ellipsoids (5.2.1); example bust: H/40 6.1k, H/80 23k, H/130 60k faces. - Raw voxel remesh is not X-symmetric: nearest-vertex mirror distance max 0.67 to 0.75 voxel, mean 0.05 to 0.07 voxel (spheres, ellipsoid, head; voxel 0.02 to 0.08; re-measured v2, a grader using another measure found 0 to 0.25 voxel); `mesh.symmetrize(direction='POSITIVE_X')` keeps +X. - `.sculpt_mask` survives voxel remesh (preserve attributes on by default). - Subdivided-box faces sit at 0.84 x scale; primitive facets larger than the voxel survive the remesh. - Headless crash matrix: `procedures.md` P12. - In a GUI, `view3d.view_selected` in Sculpt Mode does not frame; `bx_gui.set_view` handles it (frames in Object Mode and restores the mode) (`procedures.md` P13). - v2 (test_planes_sdf.py): `rounded_box` (Catmull-Clark cube) reads as a ball before smoothing (sphere_rms 0.027, side planarity 0.068); `sd_round_box` side planarity 0.000. Nine Taubin smooth passes at voxel 0.05 widened a primitive seam from R 0.195 to 0.202; a Clay blend of 0.15 gives 0.46. - v2: `plane_cut` on a unit sphere took planarity from 0.034 to 0.000 (a full-strength flatten or scrape dab: 0.005); `corner_cut` made two faces at 90.0 degrees. - v2: `lower_face_report` sphere_rms: E2 balloon jaws 0.057 to 0.082, E2 baseline 0.18 to 0.20, v2 example 0.23 to 0.26. Planes cut on a balloon flipped it to 0.115 while the render showed a gem facet. - v2 (expression_readability.py): 2.83 px per mm with the bust filling 1080 px, 0.75 px per mm at 270 px; an 8 mm corner move read at both, 2 mm at neither. -
procedures.md 27 KB
# Tested sculpting procedures (Blender 5.2.1 LTS) Every block below ran on Blender 5.2.1 LTS on 2026-09-24. Test scripts live in `tests/code/blender-sculpting/`: | Script | What it proves | Result | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | `test_bx_sculpt.py` | the original `bx_sculpt` API (brushes, crease, move, eyelids, masks, remesh ladder, gates) | 39/39 pass | | `test_planes_sdf.py` | v2: plane_cut, corner_cut, planarity, drop_islands, Clay vs union_remesh, form check calibration, planes on a balloon, remesh mirror error, final_render framing, write_shape_key | 39/39 pass | | `stylized_head_blockout.py` | the complete v2 example (P1, P2, P5, P6, P11, P14 to P17) | runs in 13 s, renders reviewed | | `expression_readability.py` | corner move vs pixels at two framings (P11) | renders reviewed | | `compare/make_compare.py` | the three E2 heads rendered identically, lower_face_report for each | sheet reviewed | | `test_procedures.py` | its checks P1 to P6: remesh guard, mask extract (P9 here), face sets (P10), asymmetry key and reprojection (P11), long lens (P8) | 6/6 pass | | `probe_headless_ops.py` | the headless crash list (P12), one child process per operator | re-run v2, unchanged | | `gui_brush_presets.py` | real brushes with the experts' settings in a GUI session on the v2 stage 2 head (P13) | 6/6 strokes FINISHED, local, mirrored (GUI, not headless) | Import pattern (headless or inside a live session): ```python import sys sys.path += ["<skills>/scenario-blender-sculpting/scripts", "<skills>/scenario-blender-expert/scripts"] import bx_sculpt as S, bx_review ``` Conventions: front = -Y, up = +Z, character left = +X. `H` = head height (chin to crown). Every radius, voxel, blend and height is a fraction of H. --- ## P1. Design table before geometry ```python H = 0.24 G = S.head_guides(H, brow=0.60) # eye_z 0.5H, nose_base_z brow/2, mouth_z 1/3 down, head_width H/1.5 ... STYLE = {"nose_tip_r": 0.085, "brow_r": 0.05, "jaw_to_cheek": 0.95, "eye_r": 0.064, "lid_thick": 0.012, "neck_r": 0.205, "gonial_blend": 0.025} # the exaggerations, written first (Yan) CUES = ["swept hair in clumps", "neckerchief"] # what sells "adventurer" [added] ``` `brow=0.60` is an [added] assumption (equal thirds with the hairline at 0.9 H). ## P2. Blockout: smooth-blend Clay (default for heads) Verified in `stylized_head_blockout.py` (`head_clay`, `cranium`, `lid`). `Clay` keeps a signed-distance grid; `add/sub/intersect` take a `blend` (smooth union radius); a primitive clipped by `sd_halfspace` is a plane cut whose edge radius is the clip blend. `to_object` meshes with OpenVDB, symmetrizes (pass `symmetric=False` for asymmetric parts) and drops islands. ```python def h(*v): return tuple(x * H for x in v) clay = S.Clay(h(-0.42, -0.70, -0.44), h(0.42, 0.62, 1.08), voxel=H / 40) # H/40 blockout, H/110 primary cran = S.sd_ellipsoid(h(0, 0.07, 0.60), h(0.335, 0.43, 0.43)) for s in (1, -1): # flat skull sides, front taper from above cran = cran.clip(S.sd_halfspace(h(0.312 * s, 0, 0), (s, 0, 0)), 0.10 * H) cran = cran.clip(S.sd_halfspace(h(0.25 * s, -0.22, 0), (s, -0.55, 0)), 0.12 * H) clay.add(cran) clay.add(S.sd_round_box(h(0, -0.215, 0.50), h(0.25, 0.085, 0.10), 0.06 * H), blend=0.09 * H) # frontal plane round the eyes gon, chin_b, b = (0.27, 0.03, 0.13), (0, -0.34, 0), 0.025 jaw = S.sd_round_box(h(0, -0.09, 0.17), h(0.28, 0.21, 0.13), 0.06 * H, rot=(-6, 0, 0)) # BEHIND the mouth mound for s in (1, -1): g = (gon[0] * s, gon[1], gon[2]) jaw = jaw.clip(S.sd_halfspace(h(*g), (0.92 * s, -0.36, -0.14)), b * H) # side: tapers to the chin jaw = jaw.clip(S.sd_halfspace(h(*g), (0.28 * s, 0.33, -0.90)), b * H) # jaw line rising to the gonion jaw = jaw.clip(S.sd_halfspace(h(*chin_b), (0, 0.34, -0.94)), b * H) # underside jaw = jaw.clip(S.sd_halfspace(h(0, 0.05, 0.13), (0, 0.97, -0.24)), b * H) # ramus back: gonial corner clay.add(jaw, blend=0.15 * H) # wide: fills the cheek clay.add(S.sd_round_box(h(0, -0.32, 0.055), h(0.115, 0.07, 0.055), 0.05 * H), blend=0.10 * H) # square chin clay.add(S.sd_ellipsoid(h(0, -0.31, 0.215), h(0.16, 0.125, 0.10)), blend=0.06 * H) # mouth mound zyg = S.sd_ellipsoid(h(0.235, -0.14, 0.415), h(0.055, 0.14, 0.05), rot=(0, 0, -23)) # ear to cheek line zyg = zyg.clip(S.sd_halfspace(h(0.235, -0.14, 0.395), (0.30, -0.30, -0.90)), 0.02 * H) # its lower plane clay.add(zyg, blend=0.10 * H, mirror=True) # brow ridges, glabella, nose cone + tip, neck cone (15 degrees forward), trapezius, ears: see the script clay.intersect(S.sd_halfspace(h(0, 0, -0.40), (0, 0, -1))) # flat bust base head = clay.to_object("Head") ``` Measured (`test_planes_sdf.py`, `t_sdf_vs_union`): - The v1 recipe's `rounded_box` (a Catmull-Clark subdivided cube) already reads as a ball before any smoothing: `form_report` ball True, sphere_rms 0.027, side-face planarity 0.068, edge radius 0.25 H. `sd_round_box` (rounding 0.03 H): not a ball, side planarity 0.000, 59 % flat, edge radius 0.068 H; rounding 0.08 H gives 0.082 H. - Nine volume-preserving smooth passes at voxel 0.05 on a unit sphere widened a primitive seam only from R 0.195 to 0.202; a Clay blend of 0.15 gives 0.46 and 0.3 gives 0.99. Smoothing is not how seams fuse; the blend is. - Iterations that failed on the way (logged in the script): a jaw block with its flat front ahead of the mouth read as a mask; a tall block left a panel edge across the cheek; a low block with a small blend left a hollow band under the cheekbone (fixed with a 0.15 H blend and the frontal box); a box cheekbone read as a bar (an ellipsoid along the ear-to-cheek line works). Option, primitives + voxel remesh (the v1 path): `S.union_remesh(parts, H / 40, relax=3)`, `S.symmetrize`, big `grab` moves. Use it where round forms are intended (creatures, cute characters); never `rounded_box` for a jaw. Run `lower_face_report` at the gate. ## P3. Resolution ladder ```python rep = S.remesh_stage(head, H / 150, relax=2) # remesh + drop_islands + symmetrize + Taubin relax + clear mask v = S.voxel_for_faces(head, 30000) # faces ~ 1.5 * area / voxel^2 (within 3 % on ellipsoids) ``` Example bust with neck: H/40 8.2k, H/110 64k, H/150 103k faces. `remesh_stage` reports `islands_dropped`: voxel remesh can leave a sealed shell (E2 run: 7 vertices between nose, muzzle and alae); `drop_islands` deletes loose parts under 1 % of the vertices. Raw voxel remesh mirror error (nearest vertex after mirroring): max 0.67 to 0.75 voxel, mean 0.05 to 0.07 voxel (`t_mirror_error`); symmetrize after every remesh. The global relax after a finer remesh is Kaspar's Smooth filter (FDscc66fC90 00:11:55), headless as Taubin passes (`sculpt.mesh_filter` crashes headless). Heavy smoothing belongs before an up-res (Abbitt): the same smooth removed 34 % of a bump at voxel 0.08, 3 % at 0.02. ## P4. Place strokes like a viewport (view rays, not nearest) ```python FRONT, SIDE, BELOW = (0, 1, 0), (-1, 0, 0), (0, 0, 1) front = lambda sc, x, z: sc.on_surface(h(x, -1.0, z), FRONT) # the ray passes through the point below = lambda sc, x, y: sc.on_surface(h(x, y, -1.0), BELOW) sc.crease_line(points_in_front_of_face, r, d, project=FRONT) ``` From a point in front of the mouth the nearest surface is the nose tip. A front-projected crease across a surface seen at a grazing angle (the side of the mouth mound) cut parallel cracks in the v2 example: crease only where the surface faces the projection, or not at all. ## P5. Primary landmarks: sockets, thin lids, wings, ears ```python er, c = STYLE["eye_r"] * H, np.array(h(0.140, -0.285, 0.50)) clay.sub(S.sd_sphere(tuple(c), er * 1.1), blend=0.03 * H, mirror=True) # socket SMALLER than the lid shell clay.add(lid(c, er, up=True), blend=0.012 * H, mirror=True) # shell cap: sphere(r + t) cut by the clay.add(lid(c, er, up=False), blend=0.012 * H, mirror=True) # margin plane, kept in front of c clay.add(S.sd_sphere(h(0.066, -0.425, 0.31), 0.038 * H), blend=0.05 * H, mirror=True) # nose wings, tucked ear = S.sd_ellipsoid(h(0.305, 0.075, 0.45), h(0.04, 0.085, 0.145), rot=(-14, 14, 6)) clay.add(ear.cut(S.sd_ellipsoid(h(0.335, 0.07, 0.455), h(0.03, 0.058, 0.105), rot=(-14, 14, 6)), 0.01 * H), blend=0.02 * H, mirror=True) ``` `lid()`: upper margin 21 degrees above the eye center, lower 26 below, rolled 8 degrees (outer corner up, Thelen 00:46:10), thickness 0.012 H, back clip at the eye center plane. Measured failures: margin 14 degrees read sleepy; a socket 1.2 x the eyeball left a ring groove that read as eye bags; a back clip behind the center let the two margin planes meet behind the eye (at y = +0.061 r) and seal a pocket (3 components). On a union_remesh blockout the alternative is the eyelid mass + `sc.open_eyelids(c, er, ...)` (Thelen's method, restate after every remesh); it gives thicker lids. ## P6. Secondary lines, restated after every remesh ```python S.remesh_stage(head, H / 150, relax=2) sc = S.Sculptor(head, symmetry_x=True, front_faces_only=True) mz = G["mouth_z"] / H sc.crease_line([h(0, -1, mz), h(0.05, -1, mz + .002), h(0.10, -1, mz + .006), h(0.135, -1, mz + .012)], 0.026 * H, 0.010 * H, pinch=0.5, project=FRONT) # mouth, neutral sc.crease_line([h(0, -1, mz + .035), h(0.06, -1, mz + .032), h(0.115, -1, mz + .02)], 0.02 * H, -0.003 * H, pinch=0.6, project=FRONT) # upper lip border ridge sc.crease_line([h(0, -1, 0.12), h(0.05, -1, 0.123), h(0.09, -1, 0.133)], 0.036 * H, 0.013 * H, pinch=0.3, project=FRONT) # under-lip: the chin-in beat sc.dab("draw", sc.on_surface(h(0.045, -0.50, -1), BELOW), 0.022 * H, strength=-1.0, height=-0.009 * H) # nostrils sc.commit() ``` Removed from the example after review: a lid-fold crease on top of the socket rim (doubled into bags), a chin cleft (read as a scratch), a front-projected nasolabial (cracks), Draw Sharp lines on the hair snapped with `nearest` (jagged). `crease_line`: depth > 0 groove, depth < 0 pinched ridge; pinch 0 = Draw Sharp, 0.5 = Crease Polish, 0.8 = Crease Sharp; 3 to 4 vertices across the radius. ## P7. Gate numbers for a stage ```python rep = S.stage_report(head, voxel) # faces, voxel_over_H, components, non_manifold_edges, stretch, mirror_max lf = S.lower_face_report(head, H) # {'profile': {...}, 'threequarter': {...}, 'ball': bool} rh = S.profile_rhythm(head) # [('out', z, y), ('in', ...)] ``` v2 example stage 3: 102,992 faces, voxel 0.0047 H, 1 component, 0 non-manifold, stretch 1.14, mirror 1e-5; lower face sphere_rms 0.259 / 0.235, not a ball; rhythm chin out 0.065 H, in 0.112 H, nose out 0.327 H. The lip and nose-base beats are hidden in profile by a nose that hangs to the nose-base line (the midline profile at z 0.24 H is the nose, y -0.56 H); the under-lip crease had to reach 0.013 H before the chin-in beat passed the 0.007 H filter. ## P8. Review and client renders ```python bx_review.review([head, eyes], out, views=("front", "right", "threequarter", "low", "top"), modes=("silhouette", "matcap")) S.long_lens_render([head, eyes], out + "/lens90.png", lens=90) # 720 px review S.final_render(objs, out + "/final_tq.png", direction=(-0.75, -1.0, 0.12), lens=90, res=1600) # cavity, AA 16 S.final_render(objs, out + "/planes.png", mode="flat", cavity=False) # flat shading reads planes (Kaspar f-mx-Jfx9lA 00:05:32) target, cam_loc, ppu = S.framing(objs, (0, -1, 0), lens=90, margin=1.08) # ppu * res = px per meter ``` `final_render` frames on real evaluated vertices (the sphere test measured 778 px against 791 predicted by `framing`, 1.6 %), uses a temporary scene and leaves nothing behind. The gate is the long-lens perspective view (85 to 95 mm, Morren, Kaspar, Naydenov, Yan); bx_review's ortho views serve the silhouette. ## P9. Mask extract headless (beard, cloth, brows) Verified: `test_procedures.py` P2. ```python sc = S.Sculptor(body, symmetry_x=False) sc.mask_by(lambda co: (co[:, 2] > -0.3) & (co[:, 2] < 0.5)); sc.blur_mask(2); sc.commit() bpy.ops.object.mode_set(mode="SCULPT") bpy.ops.sculpt.paint_mask_extract(mask_threshold=0.5, add_boundary_loop=True, smooth_iterations=4, apply_shrinkwrap=True, add_solidify=True) # new object with Solidify bpy.ops.object.mode_set(mode="OBJECT") ``` Apply the Solidify before any remesh (Naydenov ep01 00:46:29); never voxel-remesh an open thin sheet, subdivide it (Keelan Jon 01:23:40). ## P10. Face sets without sculpt operators Verified: `test_procedures.py` P3 (`sculpt.face_sets_init` CRASHES headless, P12). Flood-fill faces with bmesh (stop at `edge.seam` for seam-bounded sets), then: ```python me.attributes.new(".sculpt_face_set", "INT", "FACE").data.foreach_set("value", ids) ``` The attribute survives voxel remesh with preserve attributes on (distiller, `archive/tests/sculpt_studio_anatomy/probe5.py`). ## P11. Expression on a shape key (neutral basis), readable size, and the hand-off reprojection Verified: `stylized_head_blockout.py` stage 4, `expression_readability.py`, `test_planes_sdf.py` (`t_shape_key`), `test_procedures.py` P4 and P5. ```python sc = S.Sculptor(head, symmetry_x=False) # symmetry OFF for this pass corner = sc.on_surface(h(0.135, -1, 0.212), FRONT) sc.move_feature(corner, 0.07 * H, translate=h(0.006, 0.012, 0.028)) # up, back, out: 0.031 H sc.move_feature(sc.on_surface(h(0.16, -1, 0.31), FRONT), 0.07 * H, translate=h(0.004, -0.004, 0.018)) # cheek bunches sc.move_feature(sc.on_surface(h(0.15, -1, 0.44), FRONT), 0.035 * H, translate=h(0, -0.002, 0.010)) # lower lid squint sc.move_feature(sc.on_surface(h(-0.16, -1, 0.64), FRONT), 0.07 * H, translate=h(0, 0, 0.016)) # other brow up key = S.write_shape_key(head, "Smirk", sc.co) # basis untouched, mirror 1e-5 ``` Readability, measured on the v2 head (mouth 65 mm wide, H 240 mm) through a 90 mm lens with the camera on the smirk side: bust filling a 1080 px frame = 2.83 px per mm, bust 270 px tall (a waist-up shot) = 0.75 px per mm. Judged on native-resolution crops: 1 mm (3 px close) invisible, 2 mm a hint, 4 mm a slight upturn, 8 mm a clear smirk at both framings (25 px close, 6.5 px medium). Rule [added]: move a corner about 1/8 of the mouth width (0.03 H) for a smirk that reads, and check the pixels with `framing` at the delivery framing. Kaspar builds expressions on the base level with Grab and Smooth, adds asymmetry to each, and reads the set for proportion errors (f-mx-Jfx9lA 00:09:44 to 00:11:22); Reinhardt switches Grab to Sharp falloff for a crisp smile corner (KURuPAVJ6hM 01:25:31). Reprojection onto clean quads (Kaspar Rain f-mx-Jfx9lA 00:09:44, Naydenov ep20 00:34:16): ```python bpy.ops.object.quadriflow_remesh(target_faces=3000) # on a duplicate of the sculpt low.modifiers.new("Multires", "MULTIRES") for _ in range(2): bpy.ops.object.multires_subdivide(modifier="Multires", mode="CATMULL_CLARK") sw = low.modifiers.new("Shrinkwrap", "SHRINKWRAP") sw.target, sw.wrap_method = sculpt, "PROJECT" sw.use_negative_direction = sw.use_positive_direction = True bpy.ops.object.modifier_apply(modifier="Shrinkwrap") ``` Multires belongs on a settled clean base like this one, never as a blockout tool (Henning Cmi0KoFtc-4 00:16:04). Production retopology: scenario-blender-retopology. ## P12. Headless operator matrix (Blender 5.2.1, `probe_headless_ops.py`) | Operator | `--background` result | | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `sculpt.symmetrize`, `sculpt.mesh_filter`, `paint.mask_flood_fill`, `sculpt.mask_filter`, `sculpt.face_sets_create`, `sculpt.face_sets_init` | CRASH (segfault, exit -11) | | `object.voxel_remesh` while the object is in Sculpt Mode | CRASH (works in Object Mode; `S.voxel_remesh` switches) | | `sculpt.brush_stroke`, `sculpt.set_pivot_position`, `sculpt.detail_flood_fill` | poll fails (needs a 3D view) | | `sculpt.dynamic_topology_toggle` | works, but dyntopo only refines under strokes | | `sculpt.paint_mask_extract`, `brush.asset_activate`, `mesh.symmetrize` (Edit Mode), `object.quadriflow_remesh`, multires ops | work | Substitutes: `S.symmetrize` for symmetrize, `Sculptor.smooth` / Smooth modifier for mesh filters, `.sculpt_mask` writes plus `blur_mask` / `grow_mask` / `invert_mask` for the mask pie, `move_feature` for Set Pivot + Move/Scale. ## P13. Real brushes in a live GUI session (bx_gui + the experts' settings) Verified in a GUI Blender 5.2.1 window: `gui_brush_presets.py` (Clay Strips, Crease Polish landmark, Draw Sharp, Flatten/Contrast, Smooth, Grab: all FINISHED, 255 to 800 vertices moved per stroke, mirrored in X). ```python import bx_gui as G sc = S.Sculptor(head, symmetry_x=False) # Object Mode: compute stroke points pts = [tuple(sc.on_surface(h(x, -1, z), FRONT)) for x, z in ((0.20, 0.40), (0.24, 0.36), (0.26, 0.31))] G.set_view("FRONT", frame=head) # frames from any mode (see below) bpy.ops.object.mode_set(mode="SCULPT") head.data.use_mirror_x = True G.set_view("FRONT") # axis only, keeps the framing S.expert_brush("Clay Strips") # tip roundness 0.5, frontface, accumulate G.stroke("SCULPT", pts, size=50, strength=0.5) ``` v2 re-run (2026-09-24, GUI window): Clay Strips, Crease Polish landmark, Draw Sharp, Flatten/Contrast, Smooth, Grab all FINISHED on the Clay head, 310 to 1,106 vertices moved each, largest move 0.021 H, mirrored; the Grab points moved to x 0.16 H because the tapered v2 jaw is no longer under x 0.28 H from the front. Trap found on 2026-09-24: `view3d.view_selected` does not frame the object in Sculpt Mode. `set_view(frame=obj)` issued there left `view_distance` at 18.4 m, and a 30 px Crease stroke then covered the whole 0.24 m head and flattened it by 0.17 m in depth, invisible from the front. Fixed in `bx_gui` the same day: set_view() now frames in Object Mode with Smooth View off, forces a redraw and restores the mode; stroke() raycasts each point onto the mesh and passes OBJECT-space locations (the exec path reads `location` in object space: world coordinates on an object away from the origin moved nothing). Verified in a GUI session for Draw, Clay Strips, Crease Polish, Inflate/Deflate, Flatten/Contrast, Smooth. Still check `region_3d.view_distance` is a few head heights before stroking. `S.expert_brush` names: `Smooth` (0.2), `Clay Strips`, `Flatten/Contrast` (normal radius 1.0), `Crease Polish`, `Crease Polish:landmark` (pinch 0.1), `Draw Sharp` (direction explicit, 0.65), `Grab` (topology auto-masking), `Draw:pits`, `Draw` (restores pit settings). Dyntopo in a GUI: use `tool_settings.sculpt.detail_type_method = 'CONSTANT'` for density that does not depend on zoom [added, not run]. ## P14. Explicit planes on a mesh: plane_cut, corner_cut, planarity Verified: `test_planes_sdf.py` (`t_plane_cut`, `t_corner_cut`, `t_planes_on_balloon`). ```python sc = S.Sculptor(head, symmetry_x=True) gon = np.array(h(0.27, 0.03, 0.13)) sc.plane_cut(center, normal, radius, plane_point=gon, hardness=0.6) # everything above the plane onto it sc.corner_cut([(gon, (0.92, -0.36, -0.14)), (gon, (0.28, 0.33, -0.90)), (np.array(h(0, 0.05, 0.13)), (0, 0.97, -0.24))], gon, radius=0.12 * H) # gonial corner flat, normal, n = sc.planarity(point, 0.05 * H) # rms / radius, 0 = flat ``` On a unit sphere: planarity 0.034 before (theory r / 6.9 R = 0.036), 0.005 after a full-strength flatten or scrape dab, 0.000 after `plane_cut`, the core exactly on the stated plane, the soft border 0.0026 proud; `corner_cut` gives two faces at 90.0 degrees and reaches the stated corner within 0.01 (0.14 before). `front=True` kept a neck under a jaw underside still (0.000 moved, 0.46 without). Where it helps: restating planes after a remesh on a form whose mass is right, flat chin fronts, bust bases, carved stone facets. Where it fails: on the v1 balloon jaw, moving the mass, cutting the gonial corner and softening flipped `lower_face_report` (sphere_rms 0.057 to 0.115) but the render showed a gem facet with a rim, not a jaw (`planes_sdf_out/planes_on_balloon_*.png`). Rebuild the jaw block (P2). Keep the region radius larger than the plane's intersection with the form: a partial-weight border stands proud as a lip. ## P15. Shading-level form check Verified: `test_planes_sdf.py` (`t_form_report`), calibration on the E2 heads. ```python lf = S.lower_face_report(head, H, chin_z=0.0) # rays from the side and three-quarter over the lower face P, N = S.surface_samples(obj, view, center, half_u, half_v) # any region, as a viewport shows it rep = S.form_report(obj, P, H) # sphere_R, sphere_rms, plane_rms, R_p10/50/90, flat_frac, edge_frac, ball ``` `ball` = one sphere fits within 10 % of the region size AND fits at least twice as well as a plane. Calibration: E2 with-skill final 0.082 / 0.073, its stage 2 0.070 / 0.064, v1 example stage 3 0.057 / 0.065 (all balls, plane_rms 0.22 to 0.25); E2 baseline 0.180 / 0.197 (not a ball); v2 example 0.255 / 0.231. `flat_frac` did not separate the heads (baseline 0.0): what makes a lower face read is several forms, not flatness. ## P16. Stylized hair as clumps (separate object) Verified in `stylized_head_blockout.py` (`hair_clay`). ```python clay = S.Clay(h(-0.40, -0.52, 0.30), h(0.40, 0.58, 1.18), H / 110) cran = cranium() # the same skull field as the head: hair follows the skull (Yan) mass = S.Prim(lambda x, y, z: cran(x, y, z) - H * (0.008 + 0.012 * np.clip((z / H - 0.62) / 0.36, 0, 1)), np.array(h(-0.40, -0.52, 0.30)), np.array(h(0.40, 0.58, 1.18))) mass = mass.clip(S.sd_halfspace(h(0, -0.31, 0.765), (0, -0.50, -0.87)), 0.04 * H) # hairline clay.add(mass) for pts in clumps: # (azimuth, elevation, radius, rise) from the hairline back guide = [scalp(a, e, 0.02) for a, e, r, k in pts] clay.stroke(guide, [r * H for a, e, r, k in pts], [k * r * H for a, e, r, k in pts], op="add", blend=0.015 * H, n=32) hair = clay.to_object("Hair", symmetric=False) ``` One quiff swept to one side owns the front, three clumps behind it (Yan: three is the magic number, keep hair off the eyes and forehead, vB7kPWjBgQI 01:08:06, 01:10:27; Keelan Jon: a slicked-back mass with ridges, amVAlpxHp8k 00:50:44). Failures on the way: a uniform offset read as a helmet; tubes on a thick cap as a beanie with worms; clumps starting behind the hairline left a bare headband; temple cuts left horn points; symmetrizing the swept hair made a centered flame with a seam. ## P17. Costume cue A neckerchief from `sd_tube` rings round the neck, a knot ellipsoid and a clipped flap (`scarf_clay` in the example). Keep it above the bust cut: the first flap sat below `z = -0.40 H` and was cut away. ## Brush table (5.2 assets and their headless equivalents) | Brush | Surface effect | Headless | Expert use | | ---------------------------------- | ------------------------------------------ | --------------------------------------------- | ---------------------------------------------------------- | | Grab | translates the caught region | `grab`, `move_feature` | all blockout; Sharp falloff for a smile corner (Reinhardt) | | Draw / Draw Sharp | offset along the area normal; Sharp = POW4 | `dab("draw")`, `crease_line(pinch=0)` | plane borders and lines (Thelen, Kaspar) | | Clay Strips / Clay | raise toward a plane above the surface | `dab("clay")`, `Clay.stroke` | masses; Ctrl on the jaw side (Ryan King) | | Crease Polish / Sharp | V groove plus pinch; Ctrl = ridge | `crease_line(depth, pinch)` | lids, lips, ear rims | | Flatten, Scrape, Fill (Plane type) | toward the area plane | `dab(...)`; stated plane: `plane_cut` | Scrape for clean planes (Reinhardt 00:40:53) | | Trim, Line Project | cut to a drawn plane | `plane_cut`, `line_project`, `Clay.intersect` | chin and eye recesses, flat ear sides, bust base | | Inflate, Pinch | along own normal; toward center | `dab("inflate" / "pinch")` | cheeks; tighten edges | | Smooth | Laplacian relax | `smooth(preserve_volume=True)` | 0.2, one form at a time | | Mask + Move/Scale | rigid move about a pivot | `paint_mask`, `move_feature` | proportion fixes (Morren) | -
sources.md 14.4 KB
# Sources: sculpting 27 videos, one note each in `notes/sculpting/` (transcripts in `transcripts/<id>.txt`). Credential as stated in the note. "Best for" says when to open the note; timestamps point at the densest minutes. ## Studio and conference (highest authority) | Video | Expert, credential | Blender | Best for | Best timestamps | | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | [f-mx-Jfx9lA](https://www.youtube.com/watch?v=f-mx-Jfx9lA) Stylized Character Workflow (Rain) | Julien Kaspar, Blender Studio character artist (Spring, Coffee Run, Sprite Fright) | 2.8x | studio order of operations: concept, primitive blockout, planar head, expression and pose tests, reprojection | 00:00:33 character brief; 00:03:22 planar blockout; 00:09:44 multires + shrinkwrap; 00:10:17 expressions on base level; 00:26:01 silhouette pose test | | [FDscc66fC90](https://www.youtube.com/watch?v=FDscc66fC90) Sculpting Live Session BCON23 | Julien Kaspar | 4.1 alpha | face sets, auto-masking, Grab variants, Pose brush, fuse cleanup, remesh smoothing | 00:09:15 cleanup before merge; 00:11:55 smooth after remesh; 00:14:41 face sets; 00:20:19 hide vs mask; 00:26:04 Grab variants | | [YaVEJTLDD3Y](https://www.youtube.com/watch?v=YaVEJTLDD3Y) Speed Sculpting Live BCON19 | Julien Kaspar | 2.81 | voxel vs Dyntopo, Draw Sharp vs Crease, time management, eyes and lids | 00:18:32 Dyntopo only for two cases; 00:22:23 Draw Sharp over Crease; 00:38:25 real eyeballs; 01:00:04 eyelids | | [uGde7HdmCa8](https://www.youtube.com/watch?v=uGde7HdmCa8) Michel the Sheep live | Julien Kaspar | sculpt-dev (pre 3.0) | morphing an existing mesh, keeping symmetry alive, fixing the concept first | 00:03:50 concept drawover; 00:47:08 symmetry alive; 00:46:31 90 mm lens | | [BJHede3Oagw](https://www.youtube.com/watch?v=BJHede3Oagw) Snow recap | Julien Kaspar | 3.0-3.1 | voxel-only discipline, reuse, clothing as volumes, uneven retopo density | 00:02:55 voxel only; 00:04:01 volume clothing; 00:04:34 density by region; 00:08:19 minimal retopo | | [gC3FG8-4lsU](https://www.youtube.com/watch?v=gC3FG8-4lsU) Mirror of the Mind: captivating faces | Damiaan Thelen, lead character artist, Egosoft | 3.6-4.0 | checkable head ratios, plane-first workflow, attractiveness and appeal | 00:00:35 helmet head; 00:05:43 planes; 00:15:43 eyelid mass; 00:20:46 thirds and jaw ratio; 00:24:17 smooth one form; 00:46:10 canthal tilt | | [-3b7hDQUfIg](https://www.youtube.com/watch?v=-3b7hDQUfIg) Construction of the Human Head | Uldis Zarins, author of "Anatomy for Sculptors" | none (talk) | skull-first construction, bony landmarks, dimorphism, age, face : brain case | 00:05:45 blockout is not topology; 00:11:11 face : brain case; 00:21:25 widest points; 00:28:46 gonial angle; 00:40:24 neck attachment | ## Courses by experienced instructors | Video | Expert, credential | Blender | Best for | Best timestamps | | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | [oY9XybQRxzQ](https://www.youtube.com/watch?v=oY9XybQRxzQ) Proportions and shapes of the human head | Nikolay Naydenov (SpeedChar), character artist at Gameloft | ZBrush | proportion rules stated as common placement errors | 00:03:23 eye line; 00:05:49 nose bottom; 00:07:28 five eyes; 00:09:06 lip parting; 00:15:26 not a balloon; 00:16:53 neck front; 00:41:35 mouth | | [a05leXFSf-0](https://www.youtube.com/watch?v=a05leXFSf-0) Game character ep01 | Nikolay Naydenov | 3.0-3.1 | production context: brief, concept sheet, head and body separate | 00:02:13 brief; 00:03:33 separate head; 00:06:48 resolution slowly | | [DZ0T4bcchcs](https://www.youtube.com/watch?v=DZ0T4bcchcs) Game character ep20 | Nikolay Naydenov | 3.1 | detail size set by the game camera, high poly for the bake, head reprojection | 00:03:17 no tiny details; 00:15:43 too sharp; 00:30:56 forms beat details; 00:32:35 reprojection | | [PkQ2wD8t_f0](https://www.youtube.com/watch?v=PkQ2wD8t_f0) Game character ep21 | Nikolay Naydenov | 3.1 | vertex color ID workflow, one object per color | 00:02:13 separate objects; 00:16:07 body reprojection; 00:47:30 distinct colors | | [LFjXmRvQpxs](https://www.youtube.com/watch?v=LFjXmRvQpxs) Game character ep22 | Nikolay Naydenov | 3.1 | closed high poly, shallow normal-map details, sign-off before low poly | 00:08:43 ID map; 00:11:38 closed geometry; 00:46:38 details inside the silhouette | | [BPAvvF8py1M](https://www.youtube.com/watch?v=BPAvvF8py1M) Realistic head | Jim Morren (CG Boost), character and creature instructor | 3.6/4.0 | realistic head order, landmark pencil, feature moves, 95 mm lens, voxel ladder | 00:04:45 voxel 0.05; 00:05:16 landmark pencil; 00:13:14 95 mm; 00:20:56 voxel 0.0085; 00:23:38 move features; 00:29:39 flatten polish | | [KURuPAVJ6hM](https://www.youtube.com/watch?v=KURuPAVJ6hM) Sculpting for beginners, Moai (3h40) | Zach Reinhardt (CG Boost), 20+ years in 3D | 4.0 | reproducible stage order, mask + Move protrusions, sharpen pass, surface breakup | 00:27:17 applied scale; 00:44:15 mask + move; 01:08:49 sharpen pass; 01:25:31 Grab falloff; 01:36:32 pits | | [9N87-yRR5aE](https://www.youtube.com/watch?v=9N87-yRR5aE) Beginner's guide 2023 | Grant Abbitt, long-time Blender educator | 3.6 | why resolution stays low (smooth vs polygon size), voxel ladder, crease-smooth loop | 00:06:46 face budgets; 00:08:26 low detail rule; 00:21:35 top lip; 00:26:25 lids | | [K7AJVx0H3Ec](https://www.youtube.com/watch?v=K7AJVx0H3Ec) Beginner's guide 4.4 | Grant Abbitt | 4.4 | brush-asset UI, 100k gate, Crease Polish vs Sharp, head landmarks | 00:09:04 smooth and spacing; 00:11:45 eye line; 00:25:28 ear height; 00:28:11 100k gate | | [Cmi0KoFtc-4](https://www.youtube.com/watch?v=Cmi0KoFtc-4) Sculpt quick start | Henning Sanden, FlippedNormals co-founder, film/games sculptor | 2.9x | minimal brush set, brush settings, Dyntopo vs remesh vs multires, matcap discipline | 00:05:44 Clay Strips 90 %; 00:07:20 Smooth 0.2; 00:13:01 resolution by phase; 00:25:50 cavity off | | [amVAlpxHp8k](https://www.youtube.com/watch?v=amVAlpxHp8k) Halloween vampire (2h13) | Keelan Jon, character artist and teacher | 4.2 [?] | the most scriptable method: primitives, join where seams show, remesh + symmetrize, budgets, clothing | 00:04:34 primitives; 00:24:19 join rule; 00:26:35 symmetrize; 00:32:40 29k; 00:44:57 101k; 01:17:26 mask extract | | [Km9JSdWTjVY](https://www.youtube.com/watch?v=Km9JSdWTjVY) Stylized male head Pt1 | Ryan King, Blender tutorial author | 4.1 [?] | hard stylized male, order of forms, lids around the eyeball, profile rhythm | 00:05:50 flat skull sides; 00:10:42 profile rhythm; 00:18:11 eyeball first; 00:21:27 three lid planes; 00:36:38 low-angle catch | | [CbZChQoi46k](https://www.youtube.com/watch?v=CbZChQoi46k) Stylized male head Pt2 | Ryan King | 4.1 [?] | finishing without retopo, expression on a duplicate, sharpness verdict | 00:04:45 vertex paint + decimate; 00:18:32 expression; 00:27:40 "more sharp" | | [mgHsZZiyd54](https://www.youtube.com/watch?v=mgHsZZiyd54) Cute character (Blender 5.0) | Ryan King | 5.0 | current brush names, cute recipe, mask + Elastic Snake Hook limbs, crease vs density | 00:14:55 less topology; 00:15:28 Crease Polish; 00:16:00 remesh before sharpening; 00:21:13 limbs | | [Lxem4yMs5Dg](https://www.youtube.com/watch?v=Lxem4yMs5Dg) Sculpting for complete beginners | Ryan King | 4.2 [?] | remesh early, Dyntopo late, Front Faces Only on thin parts | 00:18:10 remesh after stretch; 00:22:08 remesh vs Dyntopo; 00:29:48 thin fins | | [vB7kPWjBgQI](https://www.youtube.com/watch?v=vB7kPWjBgQI) Stylized male face, real time | YanSculpts, character sculptor and educator | 2.79 | stylization judgment: defining exaggeration, consistent neighbors, weakest link | 00:01:09 defining exaggeration; 00:05:11 nostrils early; 00:35:38 small skull; 00:54:04 weakest link; 01:13:23 neck | | [IG1IEpU5VAw](https://www.youtube.com/watch?v=IG1IEpU5VAw) Sculpting for beginners | YanSculpts | 2.79 | brush roles, long lens, Scrape refinement | 00:04:51 long lens; 00:17:30 Clay Strips; 00:18:39 Scrape; 00:21:57 smooth then pinch | | [N4D6F7mhi4I](https://www.youtube.com/watch?v=N4D6F7mhi4I) Sculpting in Blender 2.8 | YanSculpts | 2.8 | review viewport (matcap, cavity, shadows off) | 00:06:31 matcap; 00:08:42 shadows off | | [uoYtqlm6dfY](https://www.youtube.com/watch?v=uoYtqlm6dfY) Course promo (Evie) | YanSculpts | n/a | effort scale: 5+ hours for a stylized bust | 00:02:21 | | [jZbFYD8JivI](https://www.youtube.com/watch?v=jZbFYD8JivI) Anatomy and form trailer | YanSculpts | about 2.92 [?] | primary then secondary forms; hard vs soft surfaces | 00:00:31; 00:01:04 | ## Other inputs - `sources/blender-version-deltas.md` (section 3, sculpting): authoritative 5.2 API and UI facts. - Distiller test code: `archive/tests/sculpt_courses/`, `archive/tests/sculpt_stylized/`, `archive/tests/sculpt_studio_anatomy/` (numpy brush stand-ins reconciled into `scripts/bx_sculpt.py`; probes for defaults, reprojection and renders). - Baseline to beat: `tests/baseline/answers.md` section S2 (textbook plan: SDF/metaball blockout, one voxel ratio, no crash list beyond mesh_filter, no landmark sources, no view-ray placement, no restate-after-remesh).
-
-
scripts
-
bx_sculpt.py 77.2 KB
""" bx_sculpt: sculpt-brush equivalents for an agent with no tablet. Blender's brushes (`bpy.ops.sculpt.brush_stroke`) need a 3D view region, so they fail headless, and several sculpt-mode operators (mask flood fill, mesh filter) crash Blender in background mode. This module reproduces what the brushes do to the surface directly on mesh data with numpy, so it works headless and inside a live GUI session alike. It writes the real `.sculpt_mask` attribute, so masks carry over to Blender's own sculpt mode. import sys; sys.path.append("<skill>/scripts"); import bx_sculpt as S sc = S.Sculptor(obj, symmetry_x=True) p = sc.surface_point(origin=(0, -2, 1.6), direction=(0, 1, 0)) # ray onto the surface sc.dab("draw", p, radius=0.08, strength=0.5) # one brush dab sc.stroke("clay", [p0, p1, p2], radius=0.05, strength=0.4) # dabs along a path sc.grab(p, radius=0.3, delta=(0, -0.05, 0)) # move a mass sc.smooth(p, radius=0.1, strength=0.5, iterations=3) sc.commit() # write back to the mesh Brush semantics (what each does to the surface, radius and heights in world units): draw push along the area normal (negative strength = carve) inflate push each vertex along its own normal (swells or shrinks forms) clay raise the surface toward a plane floating above it; fills dips first, builds flat-topped layers (Clay / Clay Strips feel); negative = dig layer like draw but capped at a fixed height (even plateau) flatten pull toward the local average plane from both sides (planes a form) fill raise only what is below the average plane scrape lower only what is above the average plane (knock down bumps, carve planes) pinch pull vertices toward the dab centre along the surface (sharpens a crease/edge) crease pinch + push inward: sharp valleys (eyelid folds, nasolabial, cloth folds) smooth Laplacian relax (also the tool for polishing, use low strength) grab move a region by a vector with falloff (proportions, masses) mask paint 0..1 protection; every op is scaled by (1 - mask) Falloff: smooth (default, Blender's), smoother (Clay Strips default), sphere, root, sharp, pow4 (Blender "Sharper": Draw Sharp, Crease Sharp), linear, constant. Heights default to `strength * radius * 0.25`, pass `height=` to control exactly. Added 2026-09-24 (tested in tests/code/blender-sculpting/test_bx_sculpt.py): Sculptor(..., front_faces_only=True) Front Faces Only: thin lips, ears, fins safe sc.crease_line(pts, radius, depth, pinch) groove (depth>0) or pinched ridge (depth<0) along a polyline, falloff from the line itself sc.move_feature(c, r, translate, scale) mask + Set Pivot Unmasked + Move/Scale tool sc.open_eyelids(c, r_eye) open an eyelid mass so the eyeball shows (Thelen) sc.on_surface(p, view) land a point like a viewport stroke (ray, not nearest) sc.line_project(plane_co, plane_no) Line Project: flatten everything past a plane sc.blur_mask / grow_mask / invert_mask mask pie substitutes (mask_filter crashes) symmetrize(obj, direction) edit-mode symmetrize (sculpt.symmetrize crashes) remesh_stage(obj, voxel) voxel remesh + symmetrize + volume-kept relax voxel_for_faces(obj, n) faces ~ 1.5 * area / voxel^2 (measured, 3%) stage_report(obj) faces, components, stretch, mirror error, scale profile_rhythm(obj) out/in sequence of the midline front profile head_guides(H) landmark heights and widths from the experts expert_brush(name) real brush + the experts' settings (GUI strokes) Added 2026-09-24, v2 (tested in tests/code/blender-sculpting/test_planes_sdf.py): sc.plane_cut(c, n, r, offset | plane_point=) everything above a STATED plane onto it (Scrape with a fixed plane, Trim, regional Line Project) sc.corner_cut([(p, n), ...], c, r) hard corner where 2 or 3 stated planes meet (gonial angle) sc.planarity(c, r) best-fit plane RMS / radius (0 = flat) drop_islands(obj) sealed shells after a remesh (inside remesh_stage too) fit_plane / fit_sphere / surface_samples / curvature_radius form_report(obj, pts, H), lower_face_report(obj, H) shading-level check: does the lower face read as ONE ball (the E2 failure)? Clay + sd_* primitives smooth-blend SDF blockout meshed with OpenVDB: blends only near seams, half-space clips = plane cuts with a set edge framing(objs, dir, lens), final_render(objs, path, ...) client stills, px per unit write_shape_key(obj, name, co) expression on a key, basis untouched """ __version__ = "0.1" # Blender Expert Skills v0.1 (2026-09-24) import math import bmesh import bpy import numpy as np from mathutils import Vector from mathutils.bvhtree import BVHTree FALLOFF = { "smooth": lambda t: 3 * (1 - t) ** 2 - 2 * (1 - t) ** 3, "sphere": lambda t: np.sqrt(np.clip(1 - t * t, 0, 1)), "root": lambda t: np.sqrt(1 - t), "sharp": lambda t: (1 - t) ** 2, "linear": lambda t: 1 - t, "constant": lambda t: np.ones_like(t), "pow4": lambda t: (1 - t) ** 4, "smoother": lambda t: 1 - (6 * t ** 5 - 15 * t ** 4 + 10 * t ** 3), } # Front Faces Only fades in over normal dot 0..FRONT_RAMP instead of a hard cut: a binary # cut left jagged steps on lips and lids (seen on the test head, 2026-09-24) FRONT_RAMP = 0.25 class Sculptor: def __init__(self, obj, symmetry_x=True, falloff="smooth", front_faces_only=False): if obj.mode != "OBJECT": bpy.ops.object.mode_set(mode="OBJECT") self.obj, self.me = obj, obj.data self.symmetry_x, self.falloff = symmetry_x, falloff self.front_faces_only = front_faces_only self._load() # ---------- data plumbing ---------- def _load(self): me = self.me n = len(me.vertices) self.co = np.empty(n * 3, dtype=np.float64) me.vertices.foreach_get("co", self.co) self.co = self.co.reshape(n, 3) me.calc_loop_triangles() t = np.empty(len(me.loop_triangles) * 3, dtype=np.int64) me.loop_triangles.foreach_get("vertices", t) self.tris = t.reshape(-1, 3) e = np.empty(len(me.edges) * 2, dtype=np.int64) me.edges.foreach_get("vertices", e) self.edges = e.reshape(-1, 2) self.deg = np.bincount(self.edges.ravel(), minlength=n).astype(np.float64) self.deg[self.deg == 0] = 1 attr = me.attributes.get(".sculpt_mask") self.mask = np.zeros(n) if attr is not None: attr.data.foreach_get("value", self.mask) self._normals() self._bvh = None def _normals(self): a, b, c = (self.co[self.tris[:, i]] for i in range(3)) fn = np.cross(b - a, c - a) vn = np.zeros_like(self.co) for i in range(3): np.add.at(vn, self.tris[:, i], fn) ln = np.linalg.norm(vn, axis=1) ln[ln == 0] = 1 self.vn = vn / ln[:, None] self._bvh = None def commit(self, remember_mask=True): """Write coordinates (and mask) back to the mesh.""" self.me.vertices.foreach_set("co", self.co.astype(np.float32).ravel()) if remember_mask: attr = self.me.attributes.get(".sculpt_mask") or \ self.me.attributes.new(".sculpt_mask", "FLOAT", "POINT") attr.data.foreach_set("value", self.mask.astype(np.float32)) self.me.update() def bvh(self): if self._bvh is None: self._bvh = BVHTree.FromPolygons(self.co.tolist(), self.tris.tolist()) return self._bvh # ---------- surface queries (object-local coordinates) ---------- # All coordinates in this class are object-local. Blockouts built with # union_remesh() have an identity transform, so local == world there. def to_local(self, p_world): return np.array(self.obj.matrix_world.inverted() @ Vector(p_world)) def to_world(self, p_local): return np.array(self.obj.matrix_world @ Vector(p_local)) def surface_point(self, origin, direction): """First hit of a ray on the surface, or None. Use to place dabs on landmarks.""" loc, nor, idx, dist = self.bvh().ray_cast(Vector(origin), Vector(direction).normalized()) return None if loc is None else np.array(loc) def nearest(self, p): loc, nor, idx, dist = self.bvh().find_nearest(Vector(p)) return np.array(loc) def on_surface(self, p, view=None): """Put a point on the surface the way a stroke drawn in a viewport lands. view=None: nearest surface point (fine for points already close to the surface). view=(0, 1, 0) etc.: cast a ray along the view direction through p (front view looks along +Y). Use a view for anything placed from outside the head: from a point in front of the mouth, the NEAREST surface is often the nose tip.""" if view is None: return self.nearest(p) v = np.asarray(view, dtype=np.float64) v = v / np.linalg.norm(v) far = float(np.linalg.norm(self.co.max(0) - self.co.min(0))) * 2 hit = self.surface_point(np.asarray(p, dtype=np.float64) - v * far, v) return hit if hit is not None else self.nearest(p) def extremes(self): """Handy landmarks: bbox min/max and the most forward (-Y) midline point.""" lo, hi = self.co.min(0), self.co.max(0) mid = np.abs(self.co[:, 0]) < (hi[0] - lo[0]) * 0.02 front = self.co[mid][np.argmin(self.co[mid][:, 1])] if mid.any() else self.co[np.argmin(self.co[:, 1])] return {"min": lo, "max": hi, "center": (lo + hi) / 2, "front_midline": front} # ---------- core ---------- def _weights(self, center, radius, falloff=None, front=None): d = np.linalg.norm(self.co - center, axis=1) inside = d < radius w = np.zeros(len(self.co)) t = d[inside] / radius w[inside] = FALLOFF[falloff or self.falloff](t) if (self.front_faces_only if front is None else front) and inside.any(): # Front Faces Only: skip vertices facing away from the surface under the # dab centre (the other side of a lip, ear or fin inside the radius) ref = self.vn[np.argmin(d)] w[inside] *= np.clip((self.vn[inside] @ ref) / FRONT_RAMP, 0, 1) return w * (1 - self.mask) def _blur(self, w, iterations): """Average a per-vertex array over the edge graph (Smooth Mask equivalent).""" for _ in range(iterations): s = np.zeros_like(w) np.add.at(s, self.edges[:, 0], w[self.edges[:, 1]]) np.add.at(s, self.edges[:, 1], w[self.edges[:, 0]]) w = 0.5 * w + 0.5 * s / self.deg return w def _area(self, w): s = w.sum() if s <= 0: return None, None p = (self.co * w[:, None]).sum(0) / s n = (self.vn * w[:, None]).sum(0) ln = np.linalg.norm(n) return p, (n / ln if ln > 0 else np.array([0, 0, 1.0])) def _passes(self, center): yield np.asarray(center, dtype=np.float64), np.array([1.0, 1, 1]) if self.symmetry_x: yield np.asarray(center) * np.array([-1.0, 1, 1]), np.array([-1.0, 1, 1]) def dab(self, brush, center, radius, strength=0.5, height=None, falloff=None, front=None): """Apply one brush dab (plus its X mirror). Returns number of vertices moved.""" center = np.asarray(center, dtype=np.float64) h = height if height is not None else strength * radius * 0.25 delta = np.zeros_like(self.co) wmax = np.zeros(len(self.co)) for c, flip in self._passes(center): w = self._weights(c, radius, falloff, front) if not w.any(): continue d = self._brush_delta(brush, c, radius, strength, h, w) take = w > wmax # where passes overlap, the stronger dab wins delta[take] = d[take] wmax = np.maximum(wmax, w) self.co += delta return int((np.abs(delta).sum(1) > 0).sum()) def _brush_delta(self, brush, c, radius, strength, h, w): co, vn = self.co, self.vn p, n = self._area(w) if p is None: return np.zeros_like(co) sgn = 1.0 if strength >= 0 else -1.0 if brush == "draw": return n * (h * w)[:, None] if brush == "inflate": return vn * (h * w)[:, None] if brush == "layer": dist = (co - p) @ n return n * (np.clip(h - dist, 0, None) * w * abs(strength))[:, None] * sgn if brush in ("clay", "flatten", "fill", "scrape"): offset = {"clay": abs(h) * sgn, "flatten": 0.0, "fill": 0.0, "scrape": 0.0}[brush] plane = p + n * offset dist = (co - plane) @ n # >0 above the plane if brush == "clay": move = np.where(dist * sgn < 0, -dist, 0.0) # only toward the plane elif brush == "fill": move = np.where(dist < 0, -dist, 0.0) elif brush == "scrape": move = np.where(dist > 0, -dist, 0.0) else: move = -dist return n * (move * w * min(abs(strength), 1.0))[:, None] if brush in ("pinch", "crease"): to_c = c - co to_c -= np.outer(to_c @ n, n) # keep it on the tangent plane d = to_c * (w * abs(strength) * 0.5)[:, None] if brush == "crease": d += n * (-abs(h) * sgn * w)[:, None] return d raise ValueError(f"unknown brush {brush}") def stroke(self, brush, points, radius, strength=0.5, spacing=0.3, height=None, falloff=None, project=True, front=None): """Dabs every spacing*radius along a polyline. Points snap to the surface: project=True nearest point, project=(x, y, z) ray along that view direction.""" pts = [np.asarray(p, dtype=np.float64) for p in points] path = [pts[0]] for a, b in zip(pts, pts[1:]): L = np.linalg.norm(b - a) k = max(1, int(L / (spacing * radius))) path += [a + (b - a) * (i / k) for i in range(1, k + 1)] moved = 0 for i, q in enumerate(path): if project is not False and project is not None: q = self.on_surface(q, None if project is True else project) moved += self.dab(brush, q, radius, strength, height, falloff, front) if i % 4 == 3: self._normals() self._normals() return moved def grab(self, center, radius, delta, falloff=None, front=None): """Move a region by `delta` (mirrored in X when symmetry is on).""" delta = np.asarray(delta, dtype=np.float64) total = np.zeros_like(self.co) wmax = np.zeros(len(self.co)) for c, flip in self._passes(np.asarray(center)): w = self._weights(c, radius, falloff, front) take = w > wmax total[take] = np.outer(w, delta * flip)[take] wmax = np.maximum(wmax, w) self.co += total self._normals() def smooth(self, center=None, radius=None, strength=0.5, iterations=1, falloff=None, preserve_volume=False): """Laplacian relax. center=None smooths the whole mesh (respecting the mask). preserve_volume=True uses Taubin lambda/mu passes so forms do not shrink (use it for global relax after a voxel remesh; plain Laplacian for polishing).""" if center is None: w = (1 - self.mask) * strength else: w = np.zeros(len(self.co)) for c, _f in self._passes(np.asarray(center)): w = np.maximum(w, self._weights(c, radius, falloff)) w *= strength w = np.clip(w, 0, 1)[:, None] factors = [1.0, -1.06] if preserve_volume else [1.0] for _ in range(iterations): for f in factors: s = np.zeros_like(self.co) np.add.at(s, self.edges[:, 0], self.co[self.edges[:, 1]]) np.add.at(s, self.edges[:, 1], self.co[self.edges[:, 0]]) avg = s / self.deg[:, None] self.co += (avg - self.co) * w * f self._normals() def paint_mask(self, center, radius, value=1.0, falloff=None, mode="max"): for c, _f in self._passes(np.asarray(center)): w = FALLOFF[falloff or "smooth"](np.clip(np.linalg.norm(self.co - c, axis=1) / radius, 0, 1)) w[np.linalg.norm(self.co - c, axis=1) >= radius] = 0 self.mask = np.maximum(self.mask, w * value) if mode == "max" else np.clip(self.mask - w * value, 0, 1) def clear_mask(self): self.mask[:] = 0 def mask_by(self, predicate): """Mask with a numpy predicate on coordinates, e.g. lambda co: co[:, 2] < 1.4.""" self.mask = np.where(predicate(self.co), 1.0, self.mask) # ---------- mask pie substitutes (sculpt.mask_filter crashes headless) ---------- def blur_mask(self, iterations=3): """Smooth Mask (Kaspar/Reinhardt use 3 iterations before moving a feature).""" self.mask = np.clip(self._blur(self.mask, iterations), 0, 1) def grow_mask(self, steps=1): """Grow Mask: dilate the mask over edges `steps` rings.""" for _ in range(steps): m = self.mask.copy() np.maximum.at(m, self.edges[:, 0], self.mask[self.edges[:, 1]]) np.maximum.at(m, self.edges[:, 1], self.mask[self.edges[:, 0]]) self.mask = m def invert_mask(self): self.mask = 1.0 - self.mask # ---------- line tools ---------- def _resample_on_surface(self, points, step, project=True): pts = [np.asarray(p, dtype=np.float64) for p in points] path = [pts[0]] for a, b in zip(pts, pts[1:]): k = max(1, int(np.linalg.norm(b - a) / step)) path += [a + (b - a) * (i / k) for i in range(1, k + 1)] if project is not False and project is not None: view = None if project is True else project path = [self.on_surface(q, view) for q in path] return np.array(path) def _line_field(self, P, radius, falloff, front): """Per vertex: weight from the distance to polyline P, nearest point on P, and the surface normal there (area normal sampled along the line).""" co = self.co ns = [] for s in P: # area normal under each sample near = np.linalg.norm(co - s, axis=1) < radius n = self.vn[near].sum(0) if near.any() else self.vn[np.argmin(np.linalg.norm(co - s, axis=1))] ns.append(n / (np.linalg.norm(n) or 1.0)) ns = np.array(ns) best_d = np.full(len(co), np.inf) best_q = np.zeros_like(co) best_n = np.zeros_like(co) for i in range(max(1, len(P) - 1)): a, b = P[i], P[min(i + 1, len(P) - 1)] ab = b - a L2 = ab @ ab t = np.clip(((co - a) @ ab) / L2, 0, 1) if L2 > 0 else np.zeros(len(co)) q = a + t[:, None] * ab d = np.linalg.norm(co - q, axis=1) m = d < best_d if m.any(): n = ns[i] * (1 - t[m, None]) + ns[min(i + 1, len(P) - 1)] * t[m, None] best_d[m], best_q[m], best_n[m] = d[m], q[m], n best_n /= np.maximum(np.linalg.norm(best_n, axis=1), 1e-12)[:, None] inside = best_d < radius w = np.zeros(len(co)) w[inside] = FALLOFF[falloff](best_d[inside] / radius) if self.front_faces_only if front is None else front: w *= np.clip(np.einsum("ij,ij->i", self.vn, best_n) / FRONT_RAMP, 0, 1) return w * (1 - self.mask), best_q, best_n def crease_line(self, points, radius, depth, pinch=0.5, falloff="pow4", project=True, front=None): """Crease / Draw Sharp along a polyline in one pass. depth > 0 cuts a V groove (mouth line, lid fold, behind the ear); depth < 0 raises a pinched knife ridge (the Ctrl-crease: lid rim, lip border, ear rim). pinch pulls the flanks toward the line: 0 = plain Draw Sharp cut or Kaspar's 'landmark pencil' (Morren: crease with pinch reduced), 0.5 = Crease Polish, 0.8 = Crease Sharp. Falloff is measured from the line itself, so the groove is even along its length. project: True = nearest point, or a view direction such as (0, 1, 0) to drop the line onto the face as seen from the front (safer). A crease only reads with 3 to 4 vertices across `radius`: remesh finer first (Ryan King). Mirrored in X when symmetry is on.""" P = self._resample_on_surface(points, radius * 0.25, project) paths = [P] if self.symmetry_x: paths.append(P * np.array([-1.0, 1, 1])) delta = np.zeros_like(self.co) wmax = np.zeros(len(self.co)) for Q in paths: w, q, n = self._line_field(Q, radius, falloff, front) d = -n * (depth * w)[:, None] to = q - self.co to -= np.einsum("ij,ij->i", to, n)[:, None] * n # tangential part only d += to * (w * pinch * 0.5)[:, None] take = w > wmax delta[take] = d[take] wmax = np.maximum(wmax, w) self.co += delta self._normals() return int((wmax > 0).sum()) def move_feature(self, center, radius, translate=(0, 0, 0), scale=1.0, pivot=None, falloff="smooth", blur=3, front=None): """Move or resize a whole feature instead of re-sculpting it (Morren's mask -> Set Pivot Unmasked -> Move/Scale; Reinhardt's lasso mask -> Smooth Mask x3 -> Move). Weight = falloff around `center` x (1 - mask), blurred `blur` times over the edge graph; pivot = weighted centroid unless given; scale is a float or (sx, sy, sz). Paint mask first on neighbours that must not follow (Morren erases the mask near the cheek before moving the ear). Remesh afterwards if faces stretched.""" s = np.broadcast_to(np.asarray(scale, dtype=np.float64), (3,)) t = np.asarray(translate, dtype=np.float64) total = np.zeros_like(self.co) wmax = np.zeros(len(self.co)) for c, flip in self._passes(np.asarray(center, dtype=np.float64)): w = self._weights(c, radius, falloff, front) if not w.any(): continue w = np.clip(self._blur(w, blur), 0, 1) * (1 - self.mask) pv = (self.co * w[:, None]).sum(0) / w.sum() if pivot is None \ else np.asarray(pivot, dtype=np.float64) * flip target = pv + (self.co - pv) * s + t * flip d = (target - self.co) * w[:, None] take = w > wmax total[take] = d[take] wmax = np.maximum(wmax, w) self.co += total self._normals() return int((wmax > 1e-6).sum()) def open_eyelids(self, center, radius, forward=(0, -1, 0), half_width_deg=40.0, up_deg=16.0, down_deg=22.0, tilt_deg=8.0, recess=0.88, feather=0.35): """Open the eye in an 'eyelid mass' (Thelen gC3FG8-4lsU 00:15:43: a sphere a bit larger than the eyeball is joined into the head and remeshed; a copy becomes the eyeball). Skin inside an almond-shaped opening, measured as angles seen from the eye centre, is pushed back to `recess` x radius so the separate eyeball object shows through; the ring of skin left around it reads as the lids. up_deg < down_deg the upper lid covers more of the iris (Abbitt 9N87-yRR5aE 00:26:25) tilt_deg outer corner higher (Thelen 00:46:10: about 8 degrees) `radius` is the EYEBALL radius. Mirrored in X. Call again after a remesh to restate.""" f0 = np.asarray(forward, dtype=np.float64) f0 = f0 / np.linalg.norm(f0) tt = math.radians(tilt_deg) total = np.zeros_like(self.co) wmax = np.zeros(len(self.co)) for c, flip in self._passes(np.asarray(center, dtype=np.float64)): f = f0 * flip up = np.array([0.0, 0, 1]) - f * f[2] up /= np.linalg.norm(up) out = np.cross(up, f) if out @ np.array([np.sign(c[0]) or 1.0, 0, 0]) < 0: out = -out # +u points away from the midline d = self.co - c dist = np.linalg.norm(d, axis=1) dn = d / np.maximum(dist, 1e-12)[:, None] fz = dn @ f a_h = np.arctan2(dn @ out, fz) a_v = np.arctan2(dn @ up, fz) u = a_h * math.cos(tt) + a_v * math.sin(tt) # opening rotated: outer corner up v = -a_h * math.sin(tt) + a_v * math.cos(tt) vv = np.where(v > 0, math.radians(up_deg), math.radians(down_deg)) e = (u / math.radians(half_width_deg)) ** 2 + (v / vv) ** 2 t = np.clip((1 - e) / feather, 0, 1) w = t * t * (3 - 2 * t) target = radius * recess w *= (fz > 0) & (dist < radius * 1.8) & (dist > target) w *= 1 - self.mask dd = dn * ((target - dist) * w)[:, None] take = w > wmax total[take] = dd[take] wmax = np.maximum(wmax, w) self.co += total self._normals() return int((wmax > 0).sum()) def line_project(self, plane_co, plane_no): """Line Project (Keelan Jon's bust base, Reinhardt's flat ear sides): every vertex on the +normal side of the plane is projected onto it. Respects the mask. Voxel remesh afterwards: the flattened side walls leave degenerate faces.""" n = np.asarray(plane_no, dtype=np.float64) n = n / np.linalg.norm(n) h = (self.co - np.asarray(plane_co, dtype=np.float64)) @ n move = np.where(h > 0, h, 0.0) * (1 - self.mask) self.co -= np.outer(move, n) self._normals() return int((move > 0).sum()) # ---------- explicit planes: the executable form of "planes before lines" ---------- # flatten/scrape dabs fit their plane to the surface under the brush, so on a convex # ball they follow the ball and barely change it (E2 with-skill run, 2026-09-24). Experts # cut planes against a STATED plane: Scrape with a locked plane, Trim / Line Project # (Reinhardt), Ctrl Clay Strips along the jaw side (Ryan King), Draw Sharp plane borders # re-cut whenever the face looks off (Thelen). These two methods take the plane as input. @staticmethod def _ramp(t, hardness): """1 inside hardness, smoothstep to 0 at t = 1: a flat core with a soft border.""" r = np.clip((1 - t) / max(1 - hardness, 1e-6), 0, 1) return r * r * (3 - 2 * r) def plane_cut(self, center, normal, radius, offset=0.0, hardness=0.6, mode="scrape", region="cylinder", front=True, depth=None, max_move=None, strength=1.0, plane_point=None): """Bring everything above an explicit plane down onto it (Scrape with a fixed plane, Trim tools, Line Project limited to a region). The plane passes through center + normal * offset (offset < 0 cuts deeper than the stated point) with the given outward normal, or through plane_point when given (a landmark such as the gonion: then center only places the region). Region: vertices within `radius` of the axis through center along the normal ('cylinder', like a Trim lasso seen along the normal) or of center ('sphere'). Weight 1 inside hardness * radius, smoothstep to 0 at radius. mode: 'scrape' lowers only what is above; 'flatten' also raises what is below, down to `depth` (default 0.3 x radius); 'fill' only raises. front: skip vertices facing away from the plane (the far side of an ear or lip). max_move: leave vertices that would travel further alone (protects a nose inside a wide cheek cut). Mirrored in X with symmetry. Returns (vertices moved, largest move).""" n0 = np.asarray(normal, dtype=np.float64) n0 = n0 / np.linalg.norm(n0) depth = radius * 0.3 if depth is None else depth delta = np.zeros_like(self.co) wmax = np.zeros(len(self.co)) for c, flip in self._passes(np.asarray(center, dtype=np.float64)): n = n0 * flip q = c + n * offset if plane_point is None else np.asarray(plane_point, dtype=np.float64) * flip hgt = (self.co - q) @ n relc = self.co - c # the region is centred on center d = np.linalg.norm(relc - np.outer(relc @ n, n), axis=1) if region == "cylinder" \ else np.linalg.norm(relc, axis=1) w = np.where(d < radius, self._ramp(d / radius, hardness), 0.0) if front: w *= np.clip((self.vn @ n) / FRONT_RAMP, 0, 1) w *= (1 - self.mask) * min(abs(strength), 1.0) if mode == "scrape": move = np.where(hgt > 0, hgt, 0.0) elif mode == "fill": move = np.where((hgt < 0) & (hgt > -depth), hgt, 0.0) elif mode == "flatten": move = np.where(hgt > -depth, hgt, 0.0) else: raise ValueError(f"unknown plane_cut mode {mode}") if max_move is not None: w *= np.clip((max_move - np.abs(move)) / (0.25 * max_move), 0, 1) dd = -n[None, :] * (move * w)[:, None] take = w > wmax delta[take] = dd[take] wmax = np.maximum(wmax, w) self.co += delta self._normals() mv = np.linalg.norm(delta, axis=1) return int((mv > 0).sum()), float(mv.max()) def corner_cut(self, planes, center, radius, hardness=0.5, iterations=8, front=True, max_move=None): """Hard corner where two or three stated planes meet: the gonial angle (jaw side + jaw underside + ramus back), the cheekbone edge (front plane + side plane of the zygoma), a square chin (front + underside). planes = [(point, outward_normal), ...]. Every vertex in the sphere (center, radius) that lies outside the intersection of the half-spaces is projected onto it (alternating projections, exact for two planes), weighted like plane_cut. front: only vertices whose normal agrees with the plane they violate move (the neck under a jaw corner faces sideways or forward and stays; mask it too when in doubt). max_move: optional guard, vertices that would travel further are faded out (it can leave a ridge; prefer front and the mask). The edge is as sharp as the mesh allows: every remesh softens it, restate after each remesh (Reinhardt, Keelan Jon). Mirrored in X. Returns (vertices moved, largest move).""" pl0 = [] for p, n in planes: n = np.asarray(n, dtype=np.float64) pl0.append((np.asarray(p, dtype=np.float64), n / np.linalg.norm(n))) delta = np.zeros_like(self.co) wmax = np.zeros(len(self.co)) for c, flip in self._passes(np.asarray(center, dtype=np.float64)): pl = [(p * flip, n * flip) for p, n in pl0] d = np.linalg.norm(self.co - c, axis=1) w = np.where(d < radius, self._ramp(d / radius, hardness), 0.0) * (1 - self.mask) if front: viol = np.stack([(self.co - p) @ n for p, n in pl], 1) worst = np.argmax(viol, axis=1) nw = np.stack([n for _p, n in pl])[worst] w *= np.clip(np.einsum("ij,ij->i", self.vn, nw) / FRONT_RAMP, 0, 1) P = self.co.copy() for _ in range(iterations): for p, n in pl: P -= np.outer(np.maximum((P - p) @ n, 0.0), n) move = P - self.co if max_move is not None: w *= np.clip((max_move - np.linalg.norm(move, axis=1)) / (0.25 * max_move), 0, 1) dd = move * w[:, None] take = w > wmax delta[take] = dd[take] wmax = np.maximum(wmax, w) self.co += delta self._normals() mv = np.linalg.norm(delta, axis=1) return int((mv > 0).sum()), float(mv.max()) def planarity(self, center, radius, region="sphere", normal=None): """Flatness of the surface around center: best-fit plane RMS residual / radius (0 = a perfect plane; a sphere of radius R sampled over a disc of radius r gives about r / (6.9 R)). region 'cylinder' needs the axis `normal`. Returns (rms_over_radius, fitted normal, vertex count).""" c = np.asarray(center, dtype=np.float64) if region == "cylinder": n = np.asarray(normal, dtype=np.float64) / np.linalg.norm(normal) rel = self.co - c sel = np.linalg.norm(rel - np.outer(rel @ n, n), axis=1) < radius sel &= (self.vn @ n) > 0 else: sel = np.linalg.norm(self.co - c, axis=1) < radius if sel.sum() < 4: return float("nan"), None, int(sel.sum()) _c, nrm, rms = fit_plane(self.co[sel]) return rms / radius, nrm, int(sel.sum()) # ---------- topology stages (object mode, headless-safe) ---------- def voxel_remesh(obj, voxel_size, fix_poles=True, preserve_volume=True): """Blender's voxel remesh (Ctrl+R in sculpt mode). Merges intersecting parts. Headless it must run in Object Mode: object.voxel_remesh CRASHES Blender 5.2.1 in background when the object is in Sculpt Mode (probe_headless_ops.py).""" if bpy.app.background and obj.mode != "OBJECT": bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode="OBJECT") bpy.context.view_layer.objects.active = obj me = obj.data me.remesh_voxel_size = voxel_size me.use_remesh_fix_poles = fix_poles me.use_remesh_preserve_volume = preserve_volume with bpy.context.temp_override(object=obj, active_object=obj, selected_objects=[obj]): bpy.ops.object.voxel_remesh() me.shade_smooth() # remesh output is flat-shaded: faceted banding in every render return len(me.vertices) def union_remesh(objs, voxel_size, name="Blockout", relax=3): """Join primitive masses and voxel-remesh them into one sculptable surface. relax: volume-preserving smoothing passes that soften the voxel seams where the primitives intersect (0 to keep the raw remesh).""" from mathutils import Matrix for o in objs: for m in list(o.modifiers): with bpy.context.temp_override(object=o, active_object=o): bpy.ops.object.modifier_apply(modifier=m.name) # bake location/rotation/scale into the mesh: voxel size is measured in local # units, so an unapplied scale silently changes the resolution # (matrix_world is stale until a depsgraph update; view_layer.update() refreshes it) bpy.context.view_layer.update() o.data.transform(o.matrix_world) o.matrix_world = Matrix.Identity(4) base = objs[0] with bpy.context.temp_override(active_object=base, selected_editable_objects=objs, selected_objects=objs): bpy.ops.object.join() base.name = name voxel_remesh(base, voxel_size) drop_islands(base) # sealed voids where 3+ primitives meet become separate shells if relax: sc = Sculptor(base, symmetry_x=False) sc.smooth(None, None, strength=0.5, iterations=relax, preserve_volume=True) sc.commit(remember_mask=False) return base def ellipsoid(name, location, scale, segments=48, rings=24, collection=None): """UV-sphere mass scaled into an ellipsoid (the basic blockout primitive).""" me = bpy.data.meshes.new(name) bm = bmesh.new() bmesh.ops.create_uvsphere(bm, u_segments=segments, v_segments=rings, radius=1.0) bm.to_mesh(me) bm.free() ob = bpy.data.objects.new(name, me) (collection or bpy.context.scene.collection).objects.link(ob) ob.location, ob.scale = location, scale return ob def subdivide_to(obj, target_verts): """Raise resolution evenly (Catmull-Clark, applied) until roughly target_verts.""" while len(obj.data.vertices) * 4 <= target_verts * 1.5: m = obj.modifiers.new("bx_subd", "SUBSURF") m.levels = 1 with bpy.context.temp_override(object=obj, active_object=obj): bpy.ops.object.modifier_apply(modifier=m.name) return len(obj.data.vertices) def rounded_box(name, location, scale, rotation=(0, 0, 0), levels=3, collection=None): """Keelan Jon's blockout primitive: a cube with Subdivision Surface level 2 (applied by union_remesh). Boxier than an ellipsoid: jaws, chins, square skulls, hair masses. The subdivided faces sit at 0.84 x scale (measured), so divide the half-size you want by 0.84. levels=3 by default: voxel remesh keeps any primitive facet larger than the voxel (a level-2 cranium showed flat patches after remesh at H/40), so primitive faces must be finer than the voxel size.""" me = bpy.data.meshes.new(name) bm = bmesh.new() bmesh.ops.create_cube(bm, size=2.0) bm.to_mesh(me) bm.free() ob = bpy.data.objects.new(name, me) (collection or bpy.context.scene.collection).objects.link(ob) ob.location, ob.scale, ob.rotation_euler = location, scale, rotation ob.modifiers.new("bx_subd", "SUBSURF").levels = levels return ob def symmetrize(obj, direction="POSITIVE_X", threshold=1e-4): """Make the mesh exactly X-symmetric (Keelan Jon symmetrizes after every remesh). Edit-mode mesh.symmetrize, headless-safe; sculpt.symmetrize CRASHES headless. POSITIVE_X keeps the +X half and mirrors it onto -X (verified 5.2.1).""" vl = bpy.context.view_layer # only this object may enter Edit Mode: every other selected mesh would be # symmetrized too (multi-object editing); selection is restored afterwards selected = [o for o in vl.objects if o.select_get()] active = vl.objects.active for o in selected: o.select_set(False) obj.select_set(True) vl.objects.active = obj try: bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") bpy.ops.mesh.symmetrize(direction=direction, threshold=threshold) bpy.ops.object.mode_set(mode="OBJECT") finally: obj.select_set(obj in selected) for o in selected: o.select_set(True) vl.objects.active = active or obj obj.data.shade_smooth() return len(obj.data.vertices) def _co_of(obj): me = obj.data co = np.empty(len(me.vertices) * 3) me.vertices.foreach_get("co", co) return co.reshape(-1, 3) def mirror_error(obj): """Distance from each vertex mirrored in local X to the nearest vertex (max, mean).""" from mathutils.kdtree import KDTree co = _co_of(obj) kd = KDTree(len(co)) for i, v in enumerate(co): kd.insert(v, i) kd.balance() d = np.array([kd.find((-v[0], v[1], v[2]))[2] for v in co]) return {"max": float(d.max()), "mean": float(d.mean())} def surface_area(obj): a = np.empty(len(obj.data.polygons)) obj.data.polygons.foreach_get("area", a) return float(a.sum()) def voxel_for_faces(obj, target_faces): """Voxel size that gives about target_faces on this surface: voxel remesh output has faces ~ 1.5 * area / voxel^2 (measured on spheres and ellipsoids in 5.2.1, within 3%).""" return math.sqrt(1.5 * surface_area(obj) / target_faces) def _labels(n, edges): """Connected-component label per vertex (numpy min-propagation + pointer jumping).""" lab = np.arange(n) if len(edges) == 0: return lab a, b = edges[:, 0], edges[:, 1] while True: m = np.minimum(lab[a], lab[b]) new = lab.copy() np.minimum.at(new, a, m) np.minimum.at(new, b, m) new = new[new] while True: nxt = new[new] if np.array_equal(nxt, new): break new = nxt if np.array_equal(new, lab): return lab lab = new def drop_islands(obj, keep_ratio=0.01): """Delete loose shells smaller than keep_ratio x the vertex count (the largest always stays). Voxel remesh seals tiny internal voids where 3 or more primitives meet (E2 run: a 7-vertex shell between nose, muzzle and alae made stage_report say 2 components). Called by union_remesh and remesh_stage. Returns the number of vertices removed.""" me = obj.data n = len(me.vertices) e = np.empty(len(me.edges) * 2, dtype=np.int64) me.edges.foreach_get("vertices", e) lab = _labels(n, e.reshape(-1, 2)) ids, counts = np.unique(lab, return_counts=True) small = ids[(counts < keep_ratio * n) & (counts < counts.max())] if len(small) == 0: return 0 kill = np.isin(lab, small) bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() bmesh.ops.delete(bm, geom=[bm.verts[i] for i in np.flatnonzero(kill)], context="VERTS") bm.to_mesh(me) bm.free() me.update() return int(kill.sum()) def remesh_stage(obj, voxel_size, relax=2, symmetric=True, direction="POSITIVE_X", clear_mask=True, keep_ratio=0.01): """One resolution step of the ladder: voxel remesh, drop sealed internal shells (drop_islands), symmetrize (remesh is not symmetric: its grid is not centred on x=0), then a volume-preserving relax that removes voxel jaggies (Kaspar runs the Smooth mesh filter after remeshing finer). clear_mask: the mask survives voxel remesh (preserve attributes is on by default) and would silently protect regions in the next stage. The report gains 'islands_dropped'.""" voxel_remesh(obj, voxel_size) dropped = drop_islands(obj, keep_ratio) if keep_ratio else 0 if symmetric: symmetrize(obj, direction) sc = Sculptor(obj, symmetry_x=False) if clear_mask: sc.clear_mask() if relax: sc.smooth(None, None, strength=0.5, iterations=relax, preserve_volume=True) sc.commit() rep = stage_report(obj, voxel_size) rep["islands_dropped"] = dropped return rep def _components(n, edges): parent = np.arange(n) def find(i): root = i while parent[root] != root: root = parent[root] while parent[i] != root: parent[i], i = root, parent[i] return root for a, b in edges: ra, rb = find(a), find(b) if ra != rb: parent[ra] = rb return len({find(i) for i in range(n)}) def stage_report(obj, voxel_size=None): """Numbers to gate a sculpt stage on: faces, height H, voxel as a fraction of H, connected components (1 after a union remesh), non-manifold edges, stretch (p95 / median edge length: above ~2.5 means remesh), X-mirror error, applied scale.""" me = obj.data co = _co_of(obj) e = np.empty(len(me.edges) * 2, dtype=np.int64) me.edges.foreach_get("vertices", e) e = e.reshape(-1, 2) L = np.linalg.norm(co[e[:, 0]] - co[e[:, 1]], axis=1) le = np.empty(len(me.loops), dtype=np.int64) me.loops.foreach_get("edge_index", le) per_edge = np.bincount(le, minlength=len(me.edges)) H = float(co[:, 2].max() - co[:, 2].min()) m = mirror_error(obj) return { "faces": len(me.polygons), "verts": len(me.vertices), "height": round(H, 5), "voxel": voxel_size, "voxel_over_H": round(voxel_size / H, 5) if voxel_size else None, "components": int(len(np.unique(_labels(len(co), e)))), "non_manifold_edges": int((per_edge != 2).sum()), "stretch_p95_over_median": round(float(np.percentile(L, 95) / np.median(L)), 3), "mirror_max": round(m["max"], 6), "mirror_mean": round(m["mean"], 6), "scale_applied": all(abs(s - 1) < 1e-6 for s in obj.scale), } def profile_rhythm(obj, band=None, bins=60, min_prominence=None): """Front profile on the midline (front = -Y): for each height bin the most forward point of the vertices within `band` of x=0, then the alternating extremes from chin up. Ryan King's check: a male head reads 'chin out, in, mouth out, in, nose out'; a flat face shows too few extremes. Returns [(kind, z, y), ...], kind 'out' or 'in'.""" co = _co_of(obj) lo, hi = co.min(0), co.max(0) band = band or (hi[0] - lo[0]) * 0.02 mid = co[np.abs(co[:, 0]) < band] edges = np.linspace(lo[2], hi[2], bins + 1) zs, ys = [], [] for a, b in zip(edges, edges[1:]): sel = mid[(mid[:, 2] >= a) & (mid[:, 2] < b)] if len(sel): zs.append((a + b) / 2) ys.append(sel[:, 1].min()) ys = np.array(ys) fwd = -ys # larger = more forward prom = min_prominence if min_prominence is not None else (hi[2] - lo[2]) * 0.005 out, last_kind, last_val = [], None, None for i in range(1, len(fwd) - 1): kind = "out" if fwd[i] >= fwd[i - 1] and fwd[i] > fwd[i + 1] else \ "in" if fwd[i] <= fwd[i - 1] and fwd[i] < fwd[i + 1] else None if kind is None: continue if last_val is not None and abs(fwd[i] - last_val) < prom: continue if kind == last_kind: # keep the stronger of two same extremes if (kind == "out") == (fwd[i] > last_val): out[-1] = (kind, float(zs[i]), float(ys[i])) last_val = fwd[i] continue out.append((kind, float(zs[i]), float(ys[i]))) last_kind, last_val = kind, fwd[i] return out # ---------- form metrics: what the silhouette and profile_rhythm cannot see ---------- def fit_plane(points): """Least-squares plane: (centroid, unit normal, RMS residual).""" P = np.asarray(points, dtype=np.float64) c = P.mean(0) _u, s, vt = np.linalg.svd(P - c, full_matrices=False) return c, vt[2], float(s[2] / math.sqrt(len(P))) def fit_sphere(points): """Algebraic least-squares sphere: (centre, radius, RMS of |p - c| - r).""" P = np.asarray(points, dtype=np.float64) A = np.c_[2 * P, np.ones(len(P))] b = (P * P).sum(1) sol = np.linalg.lstsq(A, b, rcond=None)[0] c = sol[:3] r = math.sqrt(max(sol[3] + c @ c, 1e-18)) return c, r, float(np.sqrt(np.mean((np.linalg.norm(P - c, axis=1) - r) ** 2))) def surface_samples(obj, view, center, half_u, half_v, grid=28): """Visible surface points of a region, the way a viewport shows it: rays along `view` through a grid over a rectangle (half sizes half_u, half_v) centred on `center` and perpendicular to the view (u = horizontal, v = up). World space. Returns (points, normals) of the first hits.""" from mathutils.bvhtree import BVHTree dg = bpy.context.evaluated_depsgraph_get() tree = BVHTree.FromObject(obj, dg) mw = obj.matrix_world imw = mw.inverted() d = np.asarray(view, dtype=np.float64) d /= np.linalg.norm(d) up = np.array([0, 0, 1.0]) if abs(d[2]) < 0.95 else np.array([0, 1.0, 0]) u = np.cross(d, up) u /= np.linalg.norm(u) v = np.cross(u, d) far = 10 * max(half_u, half_v) + float(max(obj.dimensions)) dl = Vector(imw.to_3x3() @ Vector(d)).normalized() pts, nrs = [], [] for a in np.linspace(-1, 1, grid): for b in np.linspace(-1, 1, grid): o = np.asarray(center) + u * a * half_u + v * b * half_v - d * far loc, nor, _i, _dist = tree.ray_cast(imw @ Vector(o), dl) if loc is not None: pts.append(np.array(mw @ loc)) nrs.append(np.array((mw.to_3x3() @ nor).normalized())) return np.array(pts).reshape(-1, 3), np.array(nrs).reshape(-1, 3) def curvature_radius(obj, points, scale): """Local radius of curvature at each point, measured at `scale`: plane fit to the mesh vertices within `scale`, R = scale^2 / (6.93 x RMS) (exact for a sphere sampled by area). Large R = locally flat; R near `scale` = an edge or a tight form.""" from mathutils.kdtree import KDTree co = _real_points([obj], limit=10 ** 9) # evaluated: shape keys and modifiers kd = KDTree(len(co)) for i, p in enumerate(co): kd.insert(p, i) kd.balance() out = [] for p in points: idx = [i for _c, i, _d in kd.find_range(Vector(p), scale)] if len(idx) < 6: out.append(np.nan) continue rms = fit_plane(co[idx])[2] out.append(scale * scale / (6.93 * max(rms, 1e-12))) return np.array(out) def form_report(obj, points, H, scale=None, flat_R=1.0, edge_R=0.12): """Shading-level form check on visible surface points (from surface_samples). The E2 with-skill head passed the silhouette sheet and profile_rhythm while its cheek, jaw and jowl read as ONE sphere in shading; this measures that. Returns, in units of H: sphere_R, sphere_rms one sphere fitted to the whole region (rms / region size) plane_rms one plane fitted to the region (rms / region size) R_p10, R_p50, R_p90 local radius of curvature at `scale` (default 0.06 H) flat_frac share of points locally flatter than flat_R x H (planes) edge_frac share tighter than edge_R x H (corners, creases, borders) ball True when the region reads as one ball: one sphere fits it within 10 % of its size AND fits at least twice as well as a plane. Calibrated [added] on the E2 heads: the three balloon jaws (with-skill final and stage 2, v1 example) gave sphere_rms 0.057 to 0.082 with plane_rms 0.22 to 0.25; the baseline head, whose lower face reads as several forms, gave 0.18 to 0.20 with plane_rms 0.13 to 0.19 (test_planes_sdf.py).""" P = np.asarray(points) scale = scale or 0.06 * H size = float(np.sqrt(((P - P.mean(0)) ** 2).sum(1).mean())) _c, R, srms = fit_sphere(P) prms = fit_plane(P)[2] Rl = curvature_radius(obj, P, scale) / H Rl = Rl[np.isfinite(Rl)] rep = { "n": int(len(P)), "size_H": round(size / H, 4), "sphere_R": round(R / H, 3), "sphere_rms": round(srms / size, 4), "plane_rms": round(prms / size, 4), "R_p10": round(float(np.percentile(Rl, 10)), 3), "R_p50": round(float(np.percentile(Rl, 50)), 3), "R_p90": round(float(np.percentile(Rl, 90)), 3), "flat_frac": round(float((Rl > flat_R).mean()), 3), "edge_frac": round(float((Rl < edge_R).mean()), 3), } rep["ball"] = bool(rep["sphere_rms"] < 0.10 and rep["sphere_rms"] < 0.5 * rep["plane_rms"]) return rep def lower_face_report(obj, H, chin_z=0.0, face_y=None, grid=28, scale=None): """form_report on the lower face (cheek, jaw, jowl below the cheekbone) seen in profile (from +X) and in three-quarter (from front-left). face_y: y of the most forward chin point, found from the mesh when None (front = -Y). The window spans the jaw from the chin to just in front of the ear, 0.04 H to 0.40 H above the chin.""" co = _co_of(obj) fy = face_y if face_y is not None else float(co[(co[:, 2] > chin_z) & (co[:, 2] < chin_z + 0.2 * H), 1].min()) zc = chin_z + 0.22 * H out = {} views = { "profile": ((-1.0, 0.0, 0.0), np.array([0.0, fy + 0.22 * H, zc]), 0.18 * H, 0.18 * H), "threequarter": ((-0.64, 0.77, 0.0), np.array([0.12 * H, fy + 0.25 * H, zc]), 0.16 * H, 0.18 * H), } for name, (view, c, hu, hv) in views.items(): P, _N = surface_samples(obj, view, c, hu, hv, grid) out[name] = form_report(obj, P, H, scale) out["ball"] = out["profile"]["ball"] or out["threequarter"]["ball"] return out def long_lens_render(objs, path, lens=90, direction=(-0.7, -1.0, 0.15), res=720, mode="matcap"): """Perspective render through a long lens, for proportion judgment: Morren 95 mm (BPAvvF8py1M 00:13:14), Kaspar 90 mm (uGde7HdmCa8 00:46:31), Yan 80 to 100, Naydenov ZBrush 85. bx_review's perspective views use 50 mm. Renders in a temporary scene (user settings untouched). mode: 'matcap' (clay_studio, cavity off) or 'silhouette'.""" from mathutils import Vector sc = bpy.data.scenes.new("BX_LongLens") for o in objs: sc.collection.objects.link(o) sc.render.engine = "BLENDER_WORKBENCH" sc.render.resolution_x = sc.render.resolution_y = res sc.render.image_settings.file_format = "PNG" sc.view_settings.view_transform = "Standard" world = bpy.data.worlds.new("BX_LongLens") sc.world = world sh = sc.display.shading if mode == "silhouette": sh.light, sh.color_type, sh.single_color = "FLAT", "SINGLE", (0, 0, 0) world.color = (1, 1, 1) else: sh.light, sh.studio_light, sh.color_type = "MATCAP", "clay_studio.exr", "SINGLE" sh.single_color, world.color = (0.8, 0.8, 0.8), (0.18, 0.18, 0.18) sh.show_cavity = False cam = bpy.data.objects.new("BX_LongLens", bpy.data.cameras.new("BX_LongLens")) sc.collection.objects.link(cam) cam.data.lens = lens pts = [o.matrix_world @ Vector(c) for o in objs for c in o.bound_box] lo = Vector([min(p[i] for p in pts) for i in range(3)]) hi = Vector([max(p[i] for p in pts) for i in range(3)]) center, radius = (lo + hi) / 2, (hi - lo).length / 2 fov = 2 * math.atan(cam.data.sensor_width / (2 * lens)) d = Vector(direction).normalized() cam.location = center + d * (radius * 1.05 / math.tan(fov / 2)) cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler() cam.data.clip_start, cam.data.clip_end = radius * 0.01, (cam.location - center).length * 4 sc.camera = cam sc.render.filepath = path try: bpy.ops.render.render(write_still=True, scene=sc.name) finally: cd = cam.data bpy.data.objects.remove(cam) bpy.data.cameras.remove(cd) bpy.data.scenes.remove(sc) bpy.data.worlds.remove(world) return path def _real_points(objs, limit=30000): dg = bpy.context.evaluated_depsgraph_get() pts = [] for o in objs: if o.type != "MESH": continue eo = o.evaluated_get(dg) me = eo.to_mesh() co = np.empty(len(me.vertices) * 3) me.vertices.foreach_get("co", co) co = co.reshape(-1, 3)[:: max(1, len(me.vertices) // max(1, limit // len(objs)))] mw = np.array(eo.matrix_world) pts.append(co @ mw[:3, :3].T + mw[:3, 3]) eo.to_mesh_clear() return np.vstack(pts) def framing(objs, direction, lens=90, margin=1.08, sensor=36.0): """Camera placement that fits the REAL vertices (evaluated, modifiers included) of objs seen from `direction` (camera side, pointing from the subject to the camera) through a `lens` mm lens: bbox corners overshoot in diagonal views. Returns (target, camera location, px_per_unit at a 1 px wide image): multiply by the resolution for pixels per metre at the target depth (used to size expression moves, see SKILL.md).""" P = _real_points(objs) center = (P.min(0) + P.max(0)) / 2 d = np.asarray(direction, dtype=np.float64) d /= np.linalg.norm(d) up = np.array([0, 0, 1.0]) if abs(d[2]) < 0.99 else np.array([0, 1.0, 0]) right = np.cross(-d, up) right /= np.linalg.norm(right) up = np.cross(right, -d) tan_half = sensor / (2 * lens) / margin r = P - center toward = r @ d dist = float(max((np.abs(r @ right) / tan_half + toward).max(), (np.abs(r @ up) / tan_half + toward).max())) return center, center + d * dist, 1.0 / (2 * dist * sensor / (2 * lens)) def final_render(objs, path, direction=(-0.7, -1.0, 0.12), lens=90, res=1600, cavity=True, margin=1.08, mode="matcap", color=(0.80, 0.78, 0.76), background=(0.16, 0.16, 0.17)): """Client-quality still: Workbench clay matcap with cavity (ridge 0.6, valley 1.0), a long lens (Kaspar 90 mm, Morren 95), 16x anti-aliasing, framed on real vertices, any resolution (long_lens_render is the 720 px review variant). Temporary scene, user settings untouched. mode 'flat' renders flat-shaded studio light to read planes (Kaspar Rain 00:05:32). direction: from the subject toward the camera; front = (0, -1, 0).""" sc = bpy.data.scenes.new("BX_Final") for o in objs: sc.collection.objects.link(o) sc.render.engine = "BLENDER_WORKBENCH" sc.render.resolution_x = sc.render.resolution_y = res sc.render.image_settings.file_format = "PNG" sc.view_settings.view_transform = "Standard" sc.display.render_aa = "16" world = bpy.data.worlds.new("BX_Final") sc.world = world world.color = background sh = sc.display.shading sh.color_type, sh.single_color = "SINGLE", color if mode == "flat": sh.light, sh.studio_light = "STUDIO", "Default" for o in objs: if o.type == "MESH": o.data.shade_flat() else: sh.light, sh.studio_light = "MATCAP", "clay_studio.exr" sh.show_cavity = cavity if cavity: sh.cavity_type = "BOTH" sh.cavity_ridge_factor, sh.cavity_valley_factor = 0.6, 1.0 sh.curvature_ridge_factor, sh.curvature_valley_factor = 0.5, 0.8 cam = bpy.data.objects.new("BX_Final", bpy.data.cameras.new("BX_Final")) sc.collection.objects.link(cam) cam.data.lens, cam.data.sensor_width = lens, 36.0 target, loc, _ppu = framing(objs, direction, lens, margin) cam.location = Vector(loc) cam.rotation_euler = (Vector(target) - Vector(loc)).to_track_quat("-Z", "Y").to_euler() dist = float(np.linalg.norm(loc - target)) cam.data.clip_start, cam.data.clip_end = dist * 0.01, dist * 10 sc.camera = cam sc.render.filepath = path try: bpy.ops.render.render(write_still=True, scene=sc.name) finally: if mode == "flat": for o in objs: if o.type == "MESH": o.data.shade_smooth() cd = cam.data bpy.data.objects.remove(cam) bpy.data.cameras.remove(cd) bpy.data.scenes.remove(sc) bpy.data.worlds.remove(world) return path def write_shape_key(obj, name, co, value=1.0): """Store sculpted coordinates (e.g. Sculptor.co after an expression pass) in a shape key and leave the basis untouched: the neutral basis stays symmetric and the expression is the whole read of the key (Kaspar f-mx-Jfx9lA 00:09:44: expressions are shape keys on the base level; Thelen gC3FG8-4lsU 00:38:27: asymmetry last, on purpose). Voxel remesh drops shape keys, so this is the last step. Creates 'Basis' when missing.""" if obj.data.shape_keys is None: obj.shape_key_add(name="Basis", from_mix=False) kb = obj.data.shape_keys.key_blocks key = kb.get(name) or obj.shape_key_add(name=name, from_mix=False) key.data.foreach_set("co", np.asarray(co, dtype=np.float32).ravel()) key.value = value obj.data.update() return key # ---------- real brushes (live GUI session): the experts' settings, not 5.2 defaults ---------- # Activation and settings work headless; the stroke itself needs bx_gui.stroke() in a GUI. EXPERT_BRUSHES = { #
-
-
SKILL.md 20.3 KB
--- name: scenario-blender-sculpting description: "Use when sculpting in Blender through Python: sculpt a head, face or bust, a stylized or cartoon character, a creature or a realistic portrait; block out from primitives; cut planes, a jaw or gonial angle, cheekbones; fix a jaw or face that looks like a ball or balloon; stylized hair clumps; eyelids; an expression or smirk; plan remesh or multires resolution; reproduce Clay Strips, Crease, Draw Sharp, Scrape, Grab or mask moves without a tablet; final clay renders; or when a sculpt operator crashes headless." license: MIT --- # Sculpting (heads and characters, Blender 5.2) Expert sculpting is structure first and surface last: masses blocked with stated planes and corners, proportions locked at low resolution, every stage judged by eye through a long lens, and appeal as the final test, not the mesh numbers. The agent builds blockout and primary forms headless ([`scripts/bx_sculpt.py`](scripts/bx_sculpt.py): smooth-blend `Clay`, plane cuts, numpy brushes) and does client-quality surface work with Blender's real brushes in a live GUI session (`bx_gui`). 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-blender-expert (execution channel, review loop, 5.2 API changes). ## Stance (the expert delta) - **Planes are stated, not found.** Thelen cuts plane borders with Draw Sharp and re-cuts them whenever a face "looks off"; Naydenov simplifies into planes, then smooths; Reinhardt uses Scrape, Trim and Line Project for clean cuts. Executable form: give the plane (point + normal) to `Clay` half-space clips or `plane_cut` / `corner_cut`. A `flatten` or `scrape` dab fits its plane to the ball under it, so it follows the ball. - **The jaw is a block with a gonial corner, the cheekbone a mass with its own lower plane.** Zarins: the gonial angle is the age and sex dial, steep in adult males; the widest face point is the top of the zygomatic arch. Naydenov: the cheekbone runs from the ear down and forward, only its lower edge shows. Thelen: bone plus tissue, never razor sharp. - **Fix mass upstream; planes only restate.** If re-cutting planes does not fix the look, the problem is mass or proportion (Thelen's diagnostic). Measured: plane cuts on a balloon jaw flipped the form metric but rendered as a gem facet; rebuilding the jaw block fixed it. - **Low resolution as long as possible.** Abbitt: smoothing is strong only on big polygons; Thelen: lock proportions coarse. Symmetrize after every remesh (Keelan Jon), then a global relax (Kaspar FDscc66fC90 00:11:55). - **Indicate small landmarks early** (nostrils, brows, eyes): Yan found a too-wide nose from the nostrils (vB7kPWjBgQI 00:05:11). - **Stylize by consistent exaggeration** (Yan: pick it in minute one, make neighbors follow; a long face needs a smaller skull, not a stretch). - **A concept head needs its cues.** Thelen: hair makes a male head read better (00:48:58); Naydenov: hair changes the read a lot (00:53:05). An "adventurer" with no hair or costume reads as a bald mannequin. - **Asymmetry and expression live on a shape key; the basis stays neutral and symmetric** (Kaspar f-mx-Jfx9lA 00:09:44; Thelen 00:38:27). Expression tests expose proportion errors; fix them in the neutral (Kaspar 00:10:49). ## Establish first | Input | Changes | Default when silent | | ------------------------------ | ----------------------------------------------------------- | -------------------------------------------------------- | | Who (sex, age, type, attitude) | gonial angle, brow, jaw width, shape language | adult male, neutral basis, stated [added] | | Style | stylized: exaggeration table; realistic: canon + reference | stylized, "abstracted enough to not be uncanny" (Kaspar) | | Character cues | hair, costume, props that sell the type | at least hair plus one costume signifier [added] | | Purpose | still (vertex paint, decimate) vs game or film (retopology) | still; scenario-blender-retopology if animated | | Delivery framing | detail frequency, expression size | bust fills a 1080 px frame [added] | | Scale | nothing if sizes are fractions of head height H | H = 0.24 m [added] | | Channel | headless numpy / Clay, or live GUI brushes | headless blockout; GUI for surface when the bridge is up | ## Workflow 0. **Design table.** `G = S.head_guides(H)`, a STYLE dict (the one or two exaggerations), the cue list, and the landmark points of the planes: gonion, chin bottom, cheekbone. GATE: written down. 1. **Blockout (voxel H/40).** Default for heads: `Clay`. Cranium ellipsoid clipped by skull side and front-taper half-spaces; a jaw `sd_round_box` BEHIND the mouth mound, clipped by side, underside and ramus planes through the gonion; a square chin block; cheekbone ellipsoids along the ear-to-cheek line with a lower plane; mouth mound; brow ridges; nose; neck leaning about 15 degrees; ears; frontal box around the eyes. Blend big masses at 0.08 to 0.15 H, clip edges at 0.02 to 0.03 H. Option: `union_remesh` of primitives (rounded_box is a Catmull-Clark blob that reads as a ball: never use it for a jaw). GATE: 1 component; `lower_face_report(head, H)["ball"]` False; silhouette plus 90 mm renders show a jawline and gonial corner in profile and three-quarter; the exaggeration visible. 2. **Primary forms (H/100 to H/110).** Rebuild the Clay at the finer voxel with landmarks: sockets (sphere 1.1 x eyeball, smaller than the lids), thin shell lids around separate eyeballs (Keelan Jon's lids from the eyeball, amVAlpxHp8k 00:37:05), nose wings tucked in, ear bowls, neck muscles. Hair and costume as separate Clay objects (`to_object(name, symmetric=False)` for anything asymmetric). GATE: profile beats; eyes not marbles or bags; hair frames the face; `stage_report` clean. 3. **Secondary (H/130 to H/150, `remesh_stage`).** Lines with `crease_line` (mouth, under-lip, lid rim) on surfaces facing the projection; nostrils from below. Restate planes with `corner_cut` / `plane_cut` only where the mass is right and moves stay small. GATE: under 100k faces (Abbitt), no scratches, cracks or rims in 90 mm renders. 4. **Surface finish (client quality): live GUI.** Real Clay Strips, Scrape, Crease Polish, Smooth 0.2 via `bx_gui` (fixed and verified: strokes raycast onto the mesh and pass object-space locations; `set_view(frame=obj)` first, `view_distance` a few head heights). Headless numpy strokes suit blockout and primary forms, not polish. Multires only on a settled clean base (Henning Cmi0KoFtc-4 00:16:04). 5. **Expression.** Neutral symmetric basis; `sc = S.Sculptor(head, symmetry_x=False)`, `move_feature` the corner, cheek, lower lid, opposite brow; `S.write_shape_key(head, "Smirk", sc.co)`. Size the corner move to the delivery framing (Numbers). Voxel remesh drops keys: last step. 6. **Final render.** `S.final_render(objs, path, direction, lens=90, res=1600)` (cavity, framed on real vertices) from front, profile, three-quarter, neutral and expression; open every image. Inside every stage: render, name ONE weakest link, fix it, re-render (Yan). A metric that passes while the render looks wrong means the render wins. ## Numbers | Value | Number | Source | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | | Voxel ladder | H/40 blockout, H/65 to H/110 primary, H/130 to H/235 secondary, about H/330 detail | Morren, Abbitt; example | | Faces from voxel | about 1.5 x area / voxel^2 (`voxel_for_faces`) | measured, 3 % | | Example bust faces | H/40 8k, H/110 64k, H/150 103k (with neck) | measured | | Face gates | shape before 100k; detail about 500k | Abbitt | | Raw remesh mirror error | max 0.67 to 0.75 voxel, mean 0.05 to 0.07 voxel (nearest vertex) | measured | | Clay blends (head) | masses 0.08 to 0.15 H, plane clips 0.02 to 0.03 H, lids 0.012 H | example [added] | | Jaw | bigonial 0.90 to 1.00 x bizygomatic (example 0.93); ramus-to-base angle 124 degrees in the example | Thelen; example [added] | | Eyes (stylized) | radius 0.064 H, lids 0.012 H thick, upper margin 21 degrees above center | example; 14 degrees read sleepy | | Hair | clumps 0.065 to 0.115 H radius, rising 0.8 x radius, from the hairline back; base 0.008 to 0.02 H | example [added] | | Corner move that reads | about 1/8 of mouth width (8 mm on 65 mm, 0.03 H); invisible at 1/30 | measured | | Pixels per mm | bust fills 1080 px: 2.8; bust 270 px tall: 0.75 | measured (`framing`) | | Smooth strength | 0.2 (5.2 default 0.7) | Henning | | Crease resolution | at least 3 to 4 vertices across the radius | Ryan King | | Review lens | 85 to 95 mm perspective | Morren, Kaspar, Naydenov, Yan | | Landmarks | eye line 0.5 H; nose bottom halfway brow to chin; lip parting 1/3 nose to chin; ear nose base to brow; width H/1.5; mouth corners under eye centers; canthal tilt about 8 degrees; neck about 15 degrees forward | Abbitt, Naydenov, Thelen | ## Quality gates - **Measurable:** `S.stage_report(obj, voxel)` (components 1, non_manifold 0, stretch p95/median under 2.5, mirror_max under 1e-4 before asymmetry). `S.lower_face_report(head, H)`: `ball` True means the lower face fits one sphere within 10 % and twice as well as a plane (calibrated: the three E2 balloon jaws 0.057 to 0.082, the baseline 0.18 to 0.20, the v2 example 0.23 to 0.26). `S.profile_rhythm(obj)`: chin out, in, lips out, in, nose out (a nose hanging below the nose base hides the lip beats). `sc.planarity(c, r)` for a stated plane. - **Visual (the gate):** `S.long_lens_render` or `final_render` at 85 to 95 mm from front, profile, three-quarter and below, plus `bx_review.review(..., modes=("silhouette", "matcap"))` for the ortho silhouette. Order: silhouette, big planes (cavity off, or `mode="flat"`), below and above, lines, character read at thumbnail size. Rubric: [`references/critique.md`](references/critique.md). ## Common mistakes | Mistake | What it looks like | Fix | | --------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | rounded_box or ellipsoid jaw, heavy smoothing | balloon lower face: cheek, jaw, jowl one sphere | Clay jaw block with stated planes; `lower_face_report` gate | | Flatten or scrape dabs to "make planes" | facets that follow the ball | stated planes: Clay clips, `plane_cut`, `corner_cut` | | Plane cuts on a wrong mass | gem facet with a rim, metric passes | move or rebuild the mass first, then cut | | Jaw block with its front ahead of the mouth | a mask or slab over the lower face | jaw behind the mound; front belongs to mound and chin | | Tall jaw block, small blend | a panel edge across the cheek | low block, 0.15 H blend upward | | Socket wider than the lids | ring groove, eye bags | socket 1.1 x eyeball, lid blend 0.012 H | | Lids reaching behind the eye equator | sealed pocket, 3 components | clip lids at the eye center plane | | Uniform hair offset, or clumps on a thick cap | helmet ledge, beanie with worms | thin base, clumps make the volume | | Clumps starting behind the hairline | bare headband over the forehead | start clumps at the hairline | | Symmetrizing asymmetric parts | swept hair mirrored, center seam | `to_object(..., symmetric=False)` | | Crease projected across a grazing surface | parallel cracks | crease only surfaces facing the view; skip it | | Smile sculpted into the basis | asymmetry cannot be removed | neutral basis, `write_shape_key` | | Corner moved 1 to 2 mm | expression invisible | about 1/8 of mouth width, check pixels | | No hair or costume | bald mannequin | cue list in stage 0 | | Detail before proportions | lines on a wrong skull | stay coarse until the gates pass | | Stroke snapped with nearest from far away | groove on the nose tip | `on_surface(p, view)` | | Mask survived the remesh | a region refuses every brush | `remesh_stage` clears it | | GUI strokes with the view zoomed out | whole mesh flattened | `bx_gui.set_view(frame=obj)`, check `view_distance` | | Hairline as one tilted cut | a garrison cap from the side, vertical wall at the front | hairline as a function of azimuth (sideburn, over the ear, down to the nape), quiff ramp from the hairline over about 0.12 H [eval] | | Mirrored eyes | no gaze, a staring or blank attitude | two aimed eyeballs; lid margin and brow angle are attitude dials (level brows for a neutral basis) [eval] | | Feature attached at two points | a nose that floats in profile, see-through gaps | ray-grid silhouette-hole probe per view; give a big nose a side wall to the cheek; `bx_audit` self-intersections after the last remesh [eval] | ## Blender 5.2 notes - Headless CRASH (segfault): `sculpt.symmetrize`, `sculpt.mesh_filter`, `paint.mask_flood_fill`, `sculpt.mask_filter`, `sculpt.face_sets_create`, `sculpt.face_sets_init`, `object.voxel_remesh` in Sculpt Mode. Poll fails: `sculpt.brush_stroke`, `sculpt.set_pivot_position`, `sculpt.detail_flood_fill`, trim gestures. Work: `paint_mask_extract`, `brush.asset_activate`, Edit Mode `mesh.symmetrize`, `quadriflow_remesh`, multires ops. - OpenVDB ships with Blender 5.2 (`import openvdb`): `FloatGrid.copyFromArray` + `convertToQuads` mesh the Clay. - Brushes are Essentials assets; Flatten, Fill, Scrape are one Plane type; `size` is a diameter; Draw Sharp defaults to SUBTRACT. `sculpt_tool` is `sculpt_brush_type`. - Voxel remesh output is flat shaded (`shade_smooth()`), keeps attributes (mask included), drops shape keys. - NumPy 2 in Blender: `np.ptp(a)`, not `a.ptp()`. ## References - [`references/procedures.md`](references/procedures.md): tested code for every stage (Clay recipe, planes, form check, hair, lids, expression, final render, crash matrix, brush table, GUI recipe); load before writing any sculpt script. - [`references/expert-notes.md`](references/expert-notes.md): principles by expert with timestamps; load when designing a character or choosing methods. - `references/critique.md`: stage rubric with blockers; load at every gate. - [`references/sources.md`](references/sources.md): the 27 source videos with best timestamps. - Example: `tests/code/blender-sculpting/stylized_head_blockout.py` (13 s, renders in `stylized_head/stage_N/`); comparison: `tests/code/blender-sculpting/compare/compare_sheet.png`.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.