Claude Skill

scenario-blender-hair

Use when grooming hair or fur in Blender with hair curves, such as a character hairstyle, animal fur, procedural fur in geometry nodes, or hair cards and mesh hair for games. Also covers the Essentials hair node assets (Interpolate, Clump, Curl, Frizz, Noise, Trim), density masks

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

Full trust report

Download scenario-labs-skills-skills_dcc_blender_scenario-blender-hair-dc592af.zip · 49 KB
Part of scenario-labs/skills — 97 skills

Install

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

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

Skill manifest

Hair and fur grooming (hair curves, Blender 5.2)

Expert grooming means a few evenly spaced guides that set direction and silhouette. Stacked, masked procedural layers add density, clumping, frizz, length variation and strays, judged in a lit render at full density and in pose. Write guides as data (no curve brush stroke can be scripted in 5.2.1) and make every look decision through the Essentials hair node-group assets. 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)

  • Guides get 2 points until every direction is right (Schmidbauer). More points make combing tangle and clip. Use 3 for short fur, 8 only for long hair. Bystedt resamples long human hair to 15 points before shaping.
  • Space guides evenly, and always mask Interpolate (Schmidbauer). Children spawn over the whole surface, and a child far from any guide "does whatever". Gaps turn into bald spots that grow under deformation.
  • Density is counted per square meter and must be huge. Radius drops with it (Thommes). Long fur uses 1,000,000/m² at about 0.2 mm radius. Work at a Viewport Amount of 4%.
  • Randomize per curve, not per point (Thommes). Give 80% of strands no frizz. Strays are 10% of curves with strong noise.
  • Trim with Replace Length off plus a Random Offset (Schmidbauer, Thommes). It is the cheapest realism gain. Mask it, or short-fur regions lose their guides and go bald.
  • Grow a human hairstyle on a separate hair cap split along the part (Bystedt): body topology stops mattering, interpolation stops crossing the part. The part still needs extra density and a lift.
  • Shape works the same way on every asset: 1 affects the tip only, 0 the whole strand, negative the roots. Use a negative Frizz Shape so curl and clump tips survive (Thommes, Bystedt).
  • Judge renders at full density, and in pose (Thommes, Schmidbauer). A harsh mesh deformation "looks ten times worse" in the hair.

Establish first

Input Changes Default when the brief is silent
Target Cycles strands vs EEVEE or game cards Cycles strands (Principled Hair BSDF)
Subject human hairstyle (hair cap, part) vs animal fur (body mesh, landmarks) follow the reference
Length class points per guide, clump and curl layers short fur: 3 points, long: 8, long human: 15
Deformation animated means Hair Dynamics (Animation) stays last, and you check in pose animated
Budget render curves, card triangles say the number: evaluated curves at full density
Reference same subspecies, season, ideally the same individual (Schmidbauer) ask for it; draw landmarks and flow first

Finish topology and non-overlapping UVs before grooming (hair binds to UV space) and apply scale: density counts the mesh's local area (verified).

Workflow

Task Headless Live session
Curves object, surface binding, guides as data, snap, subdivide or resample, asset modifiers, inputs, masks, counts, renders, cards yes (scripts/bx_hair.py) yes, same code in Object Mode
Enter Sculpt Curves, activate the 11 curve brushes, set brush settings, sculpt_curves.select_random n/a yes (bx_gui.activate_brush("SCULPT_CURVES", "Comb"))
Comb, Add, Density, Grow/Shrink, Pull Tips strokes no no. sculpt_curves.brush_stroke has no exec, so bx_gui.stroke("SCULPT_CURVES", ...) returns PASS_THROUGH and changes nothing. Set the brush up and ask the user to stroke, or use the substitutes below.
  1. Prepare the surface. H.uv_report(surface): stacked UVs make curves ignore the rig, UDIMs are fine, an unmerged mirror seam needs UV Merge by Distance (Schmidbauer). Humans: a hair cap split into islands along the part. Animals: separate the mouth interior. Masks: vertex groups (fast) or textures (detail on low-poly meshes). GATE: overlap_faces == 0, scale applied, one mask per region.
  2. Plan flow and objects. Turn the draw-over into regions (vertex groups) with one flow direction each. Schmidbauer: one curves object per landmark (face, cheeks, ears, neck, body, tail, feet) for performance. Thommes: one object with masks. Bystedt: split front and back where combing one disturbs the other. GATE: written list of regions, flows, objects.
  3. Guides. hair = H.new_groom(surface), P, N = H.sample_roots(surface, spacing, mask=...), H.add_guides(hair, P, N, length, points=2, flow=..., flow_blend=...) (snaps, writes surface_uv_coordinate). Later: H.subdivide_guides or H.resample_guides(hair, 15). Brush substitutes: a direction field for Comb, droop for gravity, delete and re-add guides for Density and Add; Bystedt's "grow long, comb, shrink back" is long guides plus Trim. GATE: in H.groom_report, roots_off_surface and spacing_gaps_2x are 0 and length_outliers is 0. A guides-only render shows every direction right.
  4. Density. Add Set Hair Curve Profile first, then Interpolate Hair Curves with a Density Mask. Bystedt paints 50% gray and 100% at the part, then remaps. The 5.2 Interpolate also takes a Mask Texture image. GATE: H.count(hair) (full density) is within budget, root_area_fraction is well under 0.5, and a render shows no bald spots.
  5. Procedural layers, each masked, A/B-tested by toggling show_viewport and rendering.
    • Long fur (Thommes, preset H.FUR_STACK_LONG, applied with H.build_stack): Noise, Curl, strays, Frizz with negative shape, Clump, Clump 2, final Frizz (Cumulative off), Trim, Rotate.
    • Animal (Schmidbauer): Trim, Clump (cheeks, Tip Spread, Clump Offset), low Frizz, each with its own vertex group.
    • Human (Bystedt): Noise on guides (Cumulative off, Offset per Curve on), Roll at the tip only, then per population Interpolate, Profile, Clump on the groomed guides; wide and thin layers need different seeds.
    • Per-curve randomness: H.random_curve_mask(hair, "frizz_mask", 0.8) then input "attr:frizz_mask"; root-to-tip ramps: H.along_curve_attr. GATE: each layer earns its place in an A/B render.
  6. Part and hairline (human). Leave Part by Mesh Islands on (default): in the test, 21% of children near the part crossed it with islands off and 0% with islands on. For the lift, H.distance_weights(cap, "part_lift", "part", radius, "bump") feeds Displace Hair Curves (Surface Normal on, small distance, Shape near 0, Factor attr:part_lift). GATE: in a top view there is no scalp gap at the part and the crown is not flat.
  7. Shading and render. Use H.hair_material(name, "CYCLES", melanin=(root, tip)) or "EEVEE" with explicit colors, then H.setup_render(scene, engine, close_up). GATE: H.review([surface, hair], out, engine) passes references/critique.md.
  8. Deformation. Key 2 or 3 extreme poses and render each frame from the same camera. GATE: no bald spots or clipping in any pose.
  9. Games: cards. Run H.add_cards(hair, width, material=H.card_material(...)), then convert a copy with object.convert(target='MESH') and export glTF. Put a textured hair cap under the cards. GATE: triangles within budget, UVs in the half tile for cards, roots on the scalp.

Numbers

Item Value Relative to, source
Guide points 2 while placing, 3 short fur, 8 long hair Schmidbauer [00:20:24], [00:21:34]
Long human hair resample to 15 points Bystedt [00:08:38]
Add brush length about 0.01 m for fur (default 0.3 m) Schmidbauer [00:17:59]
Density, human layer 5,000 per m² per clump population Bystedt [00:17:28]
Density, long fur 1,000,000/m², radius 0.2 mm, viewport 4% Thommes [00:09:02], [00:16:44]
Density map part 1.0, base 0.5, bald 0; ramp black stop just above 0.5 Bystedt [00:22:04], [00:23:46]
Wide clumps Shape about 0.3, reduced Factor, groomed guides Bystedt [00:19:47]
Curl Radius 2 cm, Curl Start 1, Random Offset 0.5, Guide Distance 1 cm, Guide Mask 0.2 Thommes [00:12:04], [00:14:18]
Sub-clumps Guide Distance 1 mm, Mask 0.1; layer 2: 4 mm, 0.25, Clump Offset 5 mm Thommes [00:16:12], [00:31:01]
Frizz 2 mm, negative Shape; final: -0.2, Cumulative off, on 20% of curves Thommes [00:15:04], [00:32:08], [00:26:03]
Strays 10% of curves, Noise distance 5 mm Thommes [00:30:28]
Trim, Rotate Replace Length off, Random Offset 5 mm; Rotate random 5° Thommes [00:22:20]
Card subdivision 1 per 20 cm of card length Bystedt g_ZIOafq4QU [00:05:08]
Card and tube profile arc: 1 segment flat, 3 preferred; tube: flat spiral, resolution 6, 1 turn, height 0 Matsumoto [00:19:54], [00:22:40]
Card UV width cards use half the tile of tubes Matsumoto [00:31:27]
Coverage sanity density × π × radius²: 0.13 for Thommes' fur [added] check

Quality gates

In code:

import sys; sys.path.append("<skills>/scenario-blender-hair/scripts"); import bx_hair as H
print(H.uv_report(surface))                 # overlap_faces == 0
rep = H.groom_report(hair); print(H.verdict(rep))   # [] or a list of fixes
print(H.count(hair))                        # full-density curves and points vs budget
print(H.card_stats(hair))                   # cards: tris, UV range

groom_report checks attachment UVs, roots on the surface, spacing gaps, length dents (over 50% off neighbors), Hair Dynamics last, Trim Replace Length, unmasked Frizz or Noise, missing Density Mask, radius vs density, Strand display, and a Cycles hair shader under EEVEE.

Visually: H.review(objs, out, engine, views=("front", "threequarter", "top")) renders a lit full-density sheet with Thommes' rig (warm top fill, side key, cool rim, dark bluish world). Score it with references/critique.md: bald spots, part coverage, clumps, strays, length variation, tips, root-to-tip color, highlights.

Common mistakes

Mistake What it looks like Fix
Guides at the brush default of 8 points while blocking tangles, clipping, fights while combing 2 points, raise later (Schmidbauer)
Interpolate without a Density Mask hair in the mouth or eyes, drifting children far from guides vertex-group mask; separate the mouth interior
Uneven guide spacing bald spots, worse in pose sample_roots spacing; check spacing_gaps_2x
High density at default radius a solid mass or "a mess" Profile first, about 0.2 mm at 1M/m² (Thommes)
Trim at defaults every strand becomes 1 m long Replace Length off (Thommes)
Positive Frizz on curls or clumps curl tips destroyed, fuzzy clump tips negative Shape, Cumulative Offset off on the final Frizz
Duplicated layer with the same seed wide and thin clumps stack identically change seed and frequency (Bystedt)
Straight tips "bad CG hair" Roll Hair Curves at the tip only (Bystedt)
Surface edited after grooming floating or sunken roots snap_to_surface(hair, "DEFORM"), or NEAREST after topology changes
Judging at 4% viewport density uniform clumps and equal lengths unnoticed render at full density
Principled Hair BSDF in EEVEE dark and flat (measured 0.09 vs 0.26 center luminance) Principled BSDF with explicit colors
Scripting a Comb stroke nothing happens, PASS_THROUGH write positions, or hand the stroke to the user

Blender 5.2 notes

  • Empty Hair and Quick Fur add a pinned Hair Dynamics modifier (Mode Animation or Physics (Experimental)). It replaces the old Surface Deform and moves roots with the deforming surface (shape keys and armature verified). Keep it last.
  • Interpolate has no Surface or UV Map inputs; it reads the curves object's surface. Newer inputs are Part by Mesh Islands, Follow Surface Normal, Mask Texture and Resting Surface (on). With Resting Surface on, Thommes' "Mask modifier on the surface" patch preview has no effect. Use a patch vertex group as the Density Mask instead, or turn Resting Surface off while previewing.
  • Modifier inputs are set with md.properties.inputs.<id>.value or .type = 'ATTRIBUTE'. Headless, evaluation stays stale until obj.update_tag(). H.set_inputs resolves UI names and tags for you. Background evaluation applies Viewport Amount, so use H.count(full=True).
  • Brushes are assets: Snake Hook is Pull Tips, Selection Paint is Select; Add defaults to 8 points, 0.3 m; settings in brush.curves_sculpt_settings.
  • Factory render.hair_type is STRAND: use STRIP with hair_subdiv >= 1. Cycles shapes: RIBBONS, THICK, THICK_LINEAR. "Curves Info" is ShaderNodeHairInfo; Principled Hair offers Chiang and Huang. Hair Curves Noise writes resolution 12, which curves render with since 5.0.
  • Cards: Curve to Mesh has a Scale input (4.5), and Arc Resolution counts points. EEVEE alpha uses surface_render_method = 'DITHERED'. Keep the GeometrySet alive when reading obj.evaluated_geometry().mesh.
  • Quick Fur density scales with object size (MEDIUM on a 10 cm sphere: 80,220/m², 12 points per guide).

References

  • references/expert-notes.md: load for judgment calls, per expert with timestamps (human hairstyle, animal fur, procedural fur, cards, mesh hair).
  • references/procedures.md: load before writing code. It has full tested procedures P1 to P8 with raw bpy equivalents of the bx_hair calls.
  • references/critique.md: load when reviewing a render or report, as the scoring rubric.
  • references/sources.md: load to cite or re-watch a source, with the best timestamps.
  • scripts/bx_hair.py: the toolkit (tested on 5.2.1). Tests are in tests/code/blender-hair/.
Files (skills)
  • references
    • critique.md 11.8 KB
      # Critique rubric: judging a groom like the experts
      
      Use this after every stage:
      
      1. Run `H.groom_report(hair)` and `H.verdict(...)`.
      2. Render `H.review([surface, hair], out, engine, views=...)`.
      3. Open the sheet with the image reader.
      4. Mark each line **OK** or **FIX**.
      
      A FIX marked **B** (blocker) stops the next stage. **P** (polish) can wait for the polish pass. Never deliver without a full-density render; the viewport at 4% hides most of these (Thommes `[00:19:32]`).
      
      ## A. Attachment and setup (code)
      
      | Check                                                   | How                                                                       | Sev | Source                                         |
      | ------------------------------------------------------- | ------------------------------------------------------------------------- | --- | ---------------------------------------------- |
      | Surface UVs not stacked (UDIM fine), mirror seam merged | `H.uv_report(surface)["overlap_faces"] == 0`                              | B   | Schmidbauer `[00:38:24]`, `[00:39:00]`         |
      | Surface and UV map set, every guide attached            | report `uv_map_on_surface`, `has_attachment_uv`, `attachment_uv_nan == 0` | B   | Schmidbauer `[00:37:50]`                       |
      | Roots on the surface                                    | `roots_off_surface == 0` (after surface edits: snap DEFORM or NEAREST)    | B   | Thommes `[00:03:04]`, Schmidbauer `[00:39:36]` |
      | Object scale applied                                    | `surface.scale == (1, 1, 1)`: density counts local area                   | P   | verified 5.2.1                                 |
      | Hair Dynamics last (animated)                           | `modifiers[-1] == "Hair Dynamics"`                                        | B   | 5.2 replacement of Surface Deform              |
      | No hair inside the mouth or eyes                        | mouth interior separated from the surface, and Density Mask 0 there       | B   | Schmidbauer `[00:34:23]`                       |
      
      ## B. Guides (render the guides alone with a thick Profile, or Workbench)
      
      | Check                                                     | Look for                                                      | Sev | Source                                         |
      | --------------------------------------------------------- | ------------------------------------------------------------- | --- | ---------------------------------------------- |
      | Directions per region match the flow plan and draw-over   | arrows on the reference vs guide directions, region by region | B   | Schmidbauer `[00:07:36]`                       |
      | Even spacing                                              | `spacing_gaps_2x == 0`; no visible holes in the guide layer   | B   | Schmidbauer `[00:11:36]`                       |
      | Low point count while blocking                            | `points_per_guide` 2 to 3 until directions are right          | P   | Schmidbauer `[00:21:00]`                       |
      | Gradual lengths                                           | `length_outliers == 0`; no short patch among long guides      | B   | Schmidbauer `[00:36:05]`                       |
      | No guide inside thin geometry (ears) or crossing the part | pose-free render from 3 views; part-side roots comb away      | B   | Schmidbauer `[00:41:56]`, Bystedt `[00:02:53]` |
      
      ## C. Density and coverage (full-density render, rest pose)
      
      | Check                      | Look for                                                                       | Sev | Source                             |
      | -------------------------- | ------------------------------------------------------------------------------ | --- | ---------------------------------- |
      | No bald spots              | skin showing through in patches, especially at region borders and object seams | B   | Schmidbauer `[00:33:16]`           |
      | Not a solid mass           | strands readable; `root_area_fraction` well under 0.5 (Thommes' fur is 0.13)   | B   | Thommes `[00:16:44]`               |
      | No blobs from over-density | lumpy shapes that show every ripple                                            | P   | Schmidbauer `[00:12:42]`           |
      | Part and crown (human)     | no scalp stripe at the part, crown not flat from the top view                  | B   | Bystedt `[00:31:29]`, `[00:33:15]` |
      | Density mask edges         | no hard density border unless the reference has one                            | P   | Bystedt `[00:22:04]`               |
      | Budget                     | `H.count(hair)` full curves and points vs the stated budget                    | B   | brief                              |
      
      ## D. Variation: Thommes' first-render critique plus Schmidbauer's "never straight"
      
      | Check                               | Look for                                                                                 | Sev | Source                                         |
      | ----------------------------------- | ---------------------------------------------------------------------------------------- | --- | ---------------------------------------------- |
      | Clumps not uniform                  | identical clump sizes or spacing: add a second clump layer (Guide Mask) and Clump Offset | P   | Thommes `[00:19:32]`, `[00:31:01]`             |
      | Strays between clumps               | a few runaway hairs (about 10% of curves)                                                | P   | Thommes `[00:29:54]`                           |
      | Lengths vary                        | tips all ending on one surface: Trim with Replace Length off and Random Offset           | B   | Schmidbauer `[00:27:26]`, Thommes `[00:21:46]` |
      | Tips curl slightly (human)          | ruler-straight tips read as CG: Roll at the tip                                          | P   | Bystedt `[00:14:32]`                           |
      | Frizz on a minority                 | uniform fuzz everywhere: per-curve mask, about 20% of curves                             | P   | Thommes `[00:26:03]`                           |
      | Clumps readable but not "too crazy" | A/B the clump layer; use Tip Spread and Clump Offset if needed                           | P   | Schmidbauer `[00:30:18]`, Bystedt `[00:21:31]` |
      | Duplicated layers differ            | two populations moving identically: change seed and frequency                            | P   | Bystedt `[00:29:08]`                           |
      | Curl tips preserved                 | frizz destroying curl shapes: negative Frizz Shape                                       | P   | Thommes `[00:15:04]`                           |
      | Every layer earns its place         | toggling it off in a render makes a visible difference                                   | P   | Thommes `[00:26:37]`                           |
      
      ## E. Subject-specific
      
      **Animals (Schmidbauer)**
      
      - Likeness: landmark regions (ruff, chest, back, tail fan) read as in the reference of the same subspecies and season `[00:03:24]`, `[00:07:36]`. Sev B.
      - Each region has its own character (cheeks clumpy, chest straight), set by masks `[00:28:35]`. Sev P.
      - Seams between landmark objects: no density jump and no clipping `[00:13:49]`. Sev B.
      
      **Human hairstyle (Bystedt)**
      
      - Wide and thin clump populations overlap, flyaways are few, and art-directed strands are placed `[00:01:43]`. Sev P.
      - Hairline and nape: roots hidden, soft transition (hair cap texture or an extra system) (Bystedt g_ZIOafq4QU `[00:22:28]`, Matsumoto `[00:40:21]`). Sev P.
      
      ## F. Shading and light (Cycles or EEVEE render)
      
      | Check                       | Look for                                                                                                                | Sev | Source                               |
      | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --- | ------------------------------------ |
      | Right shader for the engine | EEVEE plus Principled Hair BSDF means dark and flat (0.09 vs 0.26 luminance in the test)                                | B   | Thommes `[00:18:25]`                 |
      | Root to tip gradient        | root darker than tip (Intercept)                                                                                        | P   | Thommes `[00:18:25]`                 |
      | Per-strand variation        | color and roughness vary between strands (Random, surface-UV noise)                                                     | P   | Thommes `[00:20:04]`                 |
      | Highlights                  | stretched along strands, not round blobs; they move plausibly when the light moves (for cards: anisotropy plus tangent) | P   | Matsumoto `[00:11:37]`, `[00:14:20]` |
      | Display                     | not Strand in EEVEE (radius invisible); Strip plus subdivisions                                                         | B   | Bystedt `[00:10:23]`                 |
      | Sparkle                     | EEVEE strips can sparkle under a grazing rim light (seen in the tests); judge in Cycles or soften the rim               | P   | test observation [added]             |
      | Review light                | warm top fill, side key, cool rim, darker bluish world                                                                  | P   | Thommes `[00:17:16]`                 |
      
      ## G. Deformation (render 2 to 3 extreme poses, same camera)
      
      | Check                   | Look for                                                                                 | Sev | Source                   |
      | ----------------------- | ---------------------------------------------------------------------------------------- | --- | ------------------------ |
      | No gaps in pose         | bald stretches at joints and folds                                                       | B   | Schmidbauer `[00:44:23]` |
      | No clipping             | strands inside the body in pose                                                          | B   | Schmidbauer `[00:33:50]` |
      | Mesh deformation smooth | harsh mesh folds are amplified ten times in the hair: fix the rig or mesh, not the groom | B   | Schmidbauer `[00:33:50]` |
      
      ## H. Cards and tubes (games, EEVEE)
      
      | Check                                       | How / look for                                                                                | Sev | Source                             |
      | ------------------------------------------- | --------------------------------------------------------------------------------------------- | --- | ---------------------------------- |
      | Card planes invisible from the main camera  | no flat ribbons edge-on; cards lie along the scalp (root faces mean \|n · surface n\| near 1) | B   | Bystedt g_ZIOafq4QU `[00:03:26]`   |
      | Roots on the scalp, nothing inside the head | `card_stats`, root-row distance, inside test                                                  | B   | Bystedt `[00:03:59]`, `[00:04:34]` |
      | Coverage                                    | textured hair cap underneath; no bald look between cards                                      | B   | Bystedt `[00:22:28]`               |
      | Budget and UVs                              | tris vs budget; card U in [0, 0.5], tubes [0, 1]; V root 0 to tip 1                           | B   | Matsumoto `[00:31:27]`             |
      | Card and tube match                         | same width and texture density when switching                                                 | P   | Matsumoto `[00:24:21]`             |
      | Long cards bend smoothly                    | about 1 subdivision per 20 cm                                                                 | P   | Bystedt `[00:05:08]`               |
      
      ## Delivery report (what to hand back)
      
      - Counts: guides, full-density curves and points, card triangles.
      - Stack: modifier list with key inputs (`H.get_inputs`).
      - `verdict()` output, empty or explained.
      - Review sheets: the rest pose, 2 to 3 poses, and a close-up if hero.
      - What was **not** verified: brush-only steps the user did, Physics-mode dynamics, render time at final resolution.
      
    • expert-notes.md 24 KB
      # Expert notes: hair and fur
      
      These are the principles and judgment calls of each expert, with timestamps `[hh:mm:ss]` into the source video (full list in `sources.md`). Four talks were recorded on Blender 3.5 to 4.5. Where 5.2.1 behaves differently, it is marked **5.2**. `[added]` marks my own additions.
      
      ## Daniel Bystedt: human hairstyle as a layered node graph (BCON23, `Kx88edAbiek`)
      
      **Core stance**
      
      - Grow hair on a **separate hair cap mesh**, never on the body mesh, so topology or density changes on the character never break the groom `[00:02:19]`.
      - Split the cap into pieces along the part (a hard edge) so interpolation does not blend hair across the part `[00:02:53]`. **5.2:** Interpolate's _Part by Mesh Islands_ (on by default) formalizes this. The test measured 21% of children near the part crossing it with islands off, 0% with islands on.
      - Hand grooming sets only the big shapes. Overlap, clumping, tip curl and flyaways are procedural `[00:13:24]`.
      - A realistic hairstyle is **stacked populations**: broad clumps, thin clumps, flyaways and a few art-directed strands, each its own branch, then joined `[00:01:43]`.
      - The **parting line** is the recurring weak spot of every hair system (bald-looking, flat crown) and needs dedicated fixes `[00:31:29]`, `[00:33:15]`.
      - Straight tips read as "bad CG hair". Real hair curves at the end "like a little whoop" `[00:14:32]`.
      
      **Grooming judgment**
      
      - Use Projected falloff to comb on a head. Sphere misses hair on the far side under the brush `[00:05:06]`.
      - Grow/Shrink with Scale Uniform off, so hair extends along its direction `[00:05:41]`.
      - Paint selection on the Curve domain, not Point `[00:07:24]`.
      - Resample guides to 15 points before shaping long hair `[00:08:38]`.
      - Groom one side of the part at a time: grow everything long, comb it into place, shrink back, "exactly how a professional hairdresser would do" `[00:09:52]`.
      - Display Strip plus subdivisions before judging thickness `[00:10:23]`.
      - Shrinkwrap Hair Curves with Above Surface 0 pushes out only points inside the cap, with an offset distance. He prefers it to sculpt collision for the offset `[00:11:33]`, `[00:12:09]`.
      
      **Procedural layers**
      
      - Noise: Cumulative Offset off, lower Scale along Curve (keeps length, less mess), Offset per Curve on for per-strand randomness `[00:13:24]` to `[00:14:32]`.
      - Roll Hair Curves with a short roll length so only the tip rolls, plus random orientation `[00:15:08]`, `[00:15:44]`.
      - Interpolate at density 5,000 per square unit for a population, then thin the strands with Set Hair Curve Profile before judging, or thousands of default-thickness strands are "a mess" `[00:17:28]`.
      - **Shape parameter:** 1 affects only the tip, towards 0 the whole curve, negative only the roots `[00:18:04]`.
      - Clump for wide clumps: Shape towards 0.3, groomed curves as guides (existing guide map), Factor reduced so clumping is "somewhat but not very much". A/B by toggling `[00:19:47]` to `[00:21:31]`. High Guide Distance gives few large clumps when guides are procedural `[00:20:20]`.
      - Density map: paint white at the part, 50% gray elsewhere, black where bald. Use a Color Ramp with the black stop just above 0.5 so the gray can be dialed both ways `[00:22:04]` to `[00:23:46]`. The Image Texture silently does nothing without a Named Attribute `UVMap` as its vector `[00:22:38]`. **5.2:** Interpolate also has a _Mask Texture_ image input and a fractional _Density Mask_, both verified.
      - Expose a Global Density input (default 1, min 0) that multiplies every branch, to thin the whole groom when it gets slow `[00:24:21]`.
      - Thin clumps: duplicate the branch, change the clump value, and give the duplicated Noise and Roll a **different seed** and higher frequency so layers overlap instead of stacking identically `[00:27:54]` to `[00:29:44]`.
      - Debug colors: Store Named Attribute (color) per branch, read by an Attribute node in the shader `[00:26:08]`.
      - Where groomed curves interpolate badly (the center line), add a second low-density Interpolate as a separate set of clump guides `[00:30:17]`.
      - Parting fixes:
        - Root snap: roots within a distance snap onto the part line `[00:32:06]`.
        - Lift: Displace Hair Curves along the surface normal with Shape near 0. Its factor is Geometry Proximity (target **Edges**, the line has no faces), then Map Range from distance 0..2 to 0..1, then a Float Curve bump shaped until the part looks natural `[00:33:51]` to `[00:41:16]`.
      - Exaggerate an effect first to see what it does, then dial it back `[00:35:01]`.
      
      **How he judges:** coverage at the part, volume at the crown, slightly curled tips, overlapping wide and thin clumps with no identical shapes, clumping readable but not obvious, and per-population debug colors `[00:14:32]`, `[00:21:31]`, `[00:29:44]`, `[00:32:06]`.
      
      ## Kerstin Schmidbauer: full-body animal fur (BCON25, `ZrBbNhCgGww`)
      
      **Core stance**
      
      - Grooming is like sculpting: primary shapes with the simplest setup first, detail last, many iterations `[00:01:43]`, `[00:11:01]`.
      - "I do my entire hair grooms with only **two points** on the guides until I got all those fur directions where I want them" `[00:21:00]`. Default 8 makes combing tangle and clip. 3 is enough for short fur, 8 only for really long hair `[00:19:44]` to `[00:21:34]`.
      - **Even guide spacing** matters more than count. Gaps give bald spots and children that drift or misbehave in deformation `[00:11:36]`, `[00:12:09]`.
      - "Don't fight the system, it works in a very predictable way": delete a bad curve and add a new one, and separate mesh parts where hair must never grow `[00:34:56]`, `[00:35:31]`.
      - Finish the mesh (topology and **non-overlapping UVs**) before grooming, because curves bind to UV space `[00:09:19]`, `[00:38:24]`.
      
      **Reference and planning**
      
      - Use the same subspecies and season coat, ideally many photos of one individual (Flickr albums from one location), and never mix short and long coats `[00:03:24]` to `[00:06:21]`. Examine a related pet for growth direction and density, which photos hide `[00:07:00]`.
      - Draw-overs mark landmarks where fur changes (neck ruff, straight chest fur, clumpy back, fanning tail) and flow arrows, before any 3D `[00:07:36]` to `[00:08:43]`.
      - Split into objects per landmark: face, cheeks, ears inner and outer, neck, body, tail, feet. It is better for performance and easier to work, but the seams need density and clipping cleanup. Optionally join everything at the end with Join Geometry if performance allows `[00:13:15]` to `[00:14:25]`.
      
      **Density and masks**
      
      - Interpolate spawns over the entire surface and children far from any guide "do whatever". Always use a density-mask vertex group `[00:22:07]` to `[00:25:37]`. Turn interpolation off while weight painting for speed `[00:24:27]`.
      - Density is hairs per square meter, so expect to raise it "by a lot" `[00:23:18]`.
      - Too low a density gives bald spots that grow under deformation. Too high a density gives clipping, blobs that show every ripple, and slow renders `[00:12:42]`, `[00:13:15]`.
      - Masks for everything: vertex groups per region (`fur` suffix for density) plus separate groups for noise, clump, length, extra clump. Extra groups cost little `[00:25:37]`, `[00:26:14]`, `[00:32:36]`.
      - Vertex groups are fast but limited by vertex density. Textures give detail on low-poly meshes `[00:15:00]`.
      
      **Layers**
      
      - Trim with Replace Length off and a small Random Offset is "immediately a lot more realistic". Mask it, or short-fur areas lose their guides to trimming and go bald `[00:27:26]`, `[00:28:02]`.
      - Clump factor from a `clump` group painted only where clumping belongs (cheeks). Clumps look "a bit too crazy" at first, so use Tip Spread and Clump Offset. More guides give smaller clumps `[00:28:35]` to `[00:30:52]`.
      - Noise is texture-like displacement over the whole groom. Frizz is random per point, per hair. Both are needed `[00:16:48]`, `[00:17:23]`.
      - Reduce Frizz strongly and mask it: right for cheeks, too much elsewhere `[00:31:27]`, `[00:32:01]`.
      - "The hairs are never straight or perfectly straight. There's always a little bit of clump" `[00:31:27]`.
      - Length changes must be gradual like density. Abrupt changes render as shadows or dents `[00:36:05]`, `[00:36:43]`. At the head she accepts Grow/Shrink brushes despite the inconsistency risk, because masking every length there is tedious `[00:36:43]`.
      - Fur direction fans: deselect all, add one curve, comb it, deselect, add the next. Later curves interpolate between these `[00:43:09]`.
      
      **Deformation and troubleshooting**
      
      - "If your mesh looks the deformation looks too harsh, the hair will look like 10 time worse" `[00:33:50]`. Keep a pose-mode window to check often `[00:44:23]`.
      - Bald spots come from uneven or low density, no guide nearby, or stretching. Add guides and check that interpolation is on `[00:33:16]`, `[00:40:47]`.
      - A new guide inside a thin mesh (ear) interpolates toward guides on the other side. Try Puff, or place and comb by hand `[00:41:56]`.
      - Hair growing inside the mouth: separate the mouth interior from the fur surface. Curves that lost their surface become invalid and must be snapped or deleted `[00:34:23]` to `[00:45:31]`.
      - A length dent (a short patch that looks like a texture spot) is fixed by deleting those curves and re-adding them with the Density brush, interpolation on `[00:36:05]`, `[00:47:50]`.
      - Invalid surface UVs (curves ignore the rig, guides refuse to appear on that island) mean overlapping UVs: "absolutely no stacking. But it is okay to use udims" `[00:37:50]`, `[00:38:24]`. Mirror plus Flip UVs can leave the center seam unmerged: fix it with UV Merge by Distance `[00:39:00]`.
      - Topology changed after grooming: curves stay valid but float or sink, so Snap to Nearest Surface, which works about 99% of the time `[00:39:36]`.
      - Shape-key-like blend: duplicate the groom, sculpt the variant, then in GN run Set Position with a Mix between the own position and the other object's position (Object Info plus Sample Index by Index), driven by a custom property. It breaks when curve counts differ `[00:50:04]` to `[00:53:28]`. Not tested here.
      
      **How she judges:** likeness to the real animal, no bald spots at rest and in pose, no abrupt length or density changes (shadow dents), every region with visible but controlled clump, noise and length variation, reviewed region by region `[00:02:17]`, `[00:31:27]`, `[00:44:23]`, `[00:47:15]`.
      
      ## Simon Thommes: procedural fur in geometry nodes (Blender Studio, `gCQN5vNgHiI`)
      
      **Core stance**
      
      - A groom can be almost entirely procedural: minimal hand grooming, then stacked assets with randomized inputs `[00:03:04]`.
      - **Randomize per curve, not per point.** Random Value keyed on Curve ID is the main tool for frizz, curl, noise and strays `[00:25:31]`.
      - Judge at **full render density**. Uniform clumps and equal lengths only show in a render `[00:16:44]`, `[00:19:32]`.
      - You do not need the internals of the assets. Use each as one node in a higher-level tree `[00:23:43]`.
      - Layer clumping at several scales with guide masks for randomness `[00:15:39]`, `[00:31:01]`.
      
      **Mechanics**
      
      - Density is per square unit of surface. Scaling the surface in Edit Mode changes the curve count, not the density `[00:02:32]`, `[00:09:02]`. **5.2:** object scale is ignored, since density counts the mesh's local area (verified).
      - Curves remember their surface UV: after surface edits, Snap to Deformed Surface puts them back `[00:03:04]`.
      - Display Strip plus subdivisions, otherwise radius changes are invisible `[00:03:48]`.
      - Set Hair Curve Profile Shape 0.5 means full width at the root falling linearly to 0. Higher falls off faster, negative reverses. Factor Min and Max scale the ends `[00:04:20]`.
      - Frizz: a per-point random offset. Cumulative Offset on looks more natural. Preserve Length makes curves crumple instead of grow `[00:05:57]` to `[00:07:04]`.
      - A deformer at the end of the stack plus rest position in Interpolate makes hair follow deformation. Edit Mode changes are not deformation; shape keys are `[00:07:38]`, `[00:08:16]`. **5.2:** Hair Dynamics (Animation) plus Resting Surface. Shape keys and armature were verified.
      - Guide maps: with Existing Guide Map on, the groomed curves are the clump centers. Turn it off for procedural guides, where Guide Distance is the average distance between guides (default 10 cm). **Guide Mask** randomly removes guides and "looks a little bit more random and less uniform" than a larger distance `[00:12:37]` to `[00:14:52]`.
      - Radius and density are coupled: at 1M/m² you need about 0.2 mm radius `[00:16:44]`.
      
      **The highland-cow recipe (values from the talk)**
      
      | Step           | Values                                                                                                                                           | Time                         |
      | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
      | Interpolate    | Density 1,000,000 (160,000 curves on his patch), Viewport Amount 4%                                                                              | `[00:09:02]`, `[00:09:35]`   |
      | Noise          | Factor from a painted `noise strength` vertex group                                                                                              | `[00:10:07]`                 |
      | Curl           | Radius 2 cm, frequency up, Curl Start 1, Factor End "much lower", Random Offset 0.5, Existing Guide Map off, Guide Distance 1 cm, Guide Mask 0.2 | `[00:12:04]`, `[00:14:18]`   |
      | Frizz          | negative Shape, Distance 2 mm                                                                                                                    | `[00:15:04]`                 |
      | Clump          | Existing Guide Map off, Guide Distance 1 mm, Guide Mask 0.1, lower Shape                                                                         | `[00:16:12]`                 |
      | Profile        | Radius 0.2 mm                                                                                                                                    | `[00:16:44]`                 |
      | Trim           | Replace Length off, Random Offset 5 mm                                                                                                           | `[00:22:20]`                 |
      | Rotate         | Random Offset 5°                                                                                                                                 | `[00:22:20]`                 |
      | Frizz factor   | Random Value, ID = Curve ID, Map Range From Min 0.8 (80% get none)                                                                               | `[00:26:03]`                 |
      | Curl variation | per-curve random factor; frequency via Spline Parameter, Map Range low at root, high at tip ("gravity pulls more at the roots")                  | `[00:27:10]` to `[00:28:52]` |
      | Strays         | Noise with Boolean random per Curve ID, probability 0.1, higher scale, Distance 5 mm, higher scale along curve, moved up the stack               | `[00:29:54]`, `[00:30:28]`   |
      | Clump 2        | Guide Distance 4 mm, Guide Mask 0.25, gentler shape, Clump Offset 5 mm                                                                           | `[00:31:01]`                 |
      | Final Frizz    | per-curve random, Shape -0.2, Distance 2 mm, Cumulative Offset off (clump tips stay clean)                                                       | `[00:32:08]`                 |
      
      **Shading and review**
      
      - "The principled hair BSDF, that works only for Cycles, so if you want to use Eevee make sure to just use the regular principled BSDF" `[00:18:25]`. **5.2:** EEVEE renders it at about 36% of the Cycles luminance (measured). The rule holds.
      - Curves Info Intercept goes into a color mix: darker root, brighter reddish tip `[00:18:25]`. Make the surface mesh darker with specular off `[00:18:59]`.
      - Color variation: Attribute `surface_uv_coordinate` into a Noise Texture, Mix Soft Light, Color Ramp. Duplicate that for per-curve variation, randomize roughness, and raise roughness a little `[00:20:04]` to `[00:21:13]`.
      - Review rig: square camera, warm fill from the top, small key from the side, cool rim from the other side, darker bluish world `[00:17:16]`.
      - First-render critique list: clumps too uniform (strays needed), all hairs the same length, shader lacks variation, curling too regular `[00:19:32]`.
      - A/B each variation by muting its link `[00:26:37]`. Disable viewport display of render-only layers `[00:22:53]`.
      - Full-density patch: a Mask modifier on the surface with a small vertex group, off for render `[00:32:51]`. **5.2:** this has no effect while Resting Surface is on. Use a patch Density Mask instead (verified).
      
      ## Daniel Bystedt: GN hair cards for games (own channel, `g_ZIOafq4QU`)
      
      This is a walkthrough of his paid generator file, so treat it as a specification of what a card generator must control.
      
      - Groom with hair curves and let cards deform along them. Card tilt aligns to the surface, so you keep grooming with curve brushes `[00:00:00]`, `[00:21:53]`.
      - Use a decimated proxy head for collision, normal and color sampling, so the groom stays responsive `[00:01:07]`, `[00:21:18]`.
      - Controls:
        - Build direction: cards are modeled root at the origin, tip toward the positive axis `[00:02:20]`.
        - Tip tilt randomize, and align to surface vs world `[00:02:53]`, `[00:03:26]`.
        - Root snap distance, and snap-if-inside with an offset `[00:03:59]`, `[00:04:34]`.
        - Subdivide by length, 1 per 20 cm `[00:05:08]`.
        - Normal from surface factor `[00:05:40]`.
      - Transfers: a `mask` vertex group removes cards. Color from a surface color attribute is sampled at the root or projected. The UV of the root's closest surface point (a dedicated `UV transfer` map) lets one texture tint regions across all cards `[00:06:53]` to `[00:09:13]`.
      - Parting line as a curve object: roots within a distance snap onto it, and a lift bumps hair up by distance from the line `[00:09:50]` to `[00:11:03]`.
      - Layers: thick cards for coverage, sparse thin cards offset along the normal to fake flyaways, and a textured, transparent **hair cap** underneath for coverage and a soft hairline `[00:19:32]` to `[00:22:28]`.
      - Split front and back groom objects where grooming one disturbs the other, then join them in GN `[00:17:15]`.
      - Texture scene: hair on planes under an orthographic camera, with hair kept inside the UV tile. AOVs and compositor outputs are normal, alpha, random per hair, specular and intercept `[00:23:04]` to `[00:24:13]`.
      
      ## Sara Matsumoto: mesh hair from hair curves (BCON24, `vXBL-oiqY7Q`)
      
      - Keep groomable hair curves and convert them to mesh cards or tubes in GN. The same groom then renders in EEVEE or Cycles, or exports to games `[00:02:14]`. She now prefers mesh hair even for Cycles ("more control", curves feel "more unstable"), and it renders fast: about 1 to 3 min per frame on her characters `[00:40:54]`, `[00:41:28]`.
      - Strand textures are made in Blender from real hair curves rendered to passes `[00:03:55]`. Plate rules:
        - UVs on the growth strip are mandatory: "if you don't do UVs the hair is never going to attach" `[00:04:28]`.
        - Uneven lengths `[00:05:04]`.
        - Hair reaching the tile **sides** (gaps become tube seams) with a **gap in the center** so cards can use half the UV `[00:09:16]`, `[00:34:27]`.
        - Blend Hair Curves for a fluffier, separated look `[00:07:29]`, plus a second system for flyaways `[00:08:03]`.
      - Passes: depth, normal, position, AO, specular. Depth comes out white, so remap it `[00:09:16]` to `[00:10:57]`.
      - Judge the material on a curved plane with one moving point light. A round blob highlight is wrong; push for a stretched, anisotropic one `[00:11:37]` to `[00:14:53]`.
      - Card material:
        - Alpha, normal map plus bump from depth, a little metallic.
        - Roughness varied around 0.5, not constant.
        - Position pass into Tangent, with raised Anisotropic `[00:12:10]` to `[00:14:53]`.
        - Root color by a vertical ramp; highlight color masked by AO `[00:15:26]` to `[00:17:06]`.
      - GN tree:
        - Resample first, because Snake Hook scrambles point order `[00:18:49]`.
        - Arc profile: resolution 1 is flat, and she uses 3 segments to cover roots better `[00:19:54]`.
        - Tubes from a flat Spiral (start radius = end radius, height 0, resolution 6, 1 rotation). A closed arc gives seams `[00:21:33]` to `[00:22:40]`.
        - One width value must drive both cards and tubes `[00:24:21]`.
      - UVs: capture Spline Parameter on the curve (length) and on the profile (width) and store them as `UVs`. Cards (3 sides) get U × 0.5 compared with tubes (6 sides), switched with the geometry `[00:25:10]` to `[00:31:27]`. **5.2:** a FLOAT2 CORNER attribute named `UVMap` works directly and converts to a UV layer (verified).
      - Root attach: Geometry Proximity to the scalp on the UV Y = 0 row, **last** in the tree `[00:32:09]` to `[00:33:47]`.
      - Interpolation "doesn't appear" until density is very high ("like a million") at her scale `[00:39:47]`. Separate systems hide roots at the nape and hairline `[00:40:21]`.
      
      ## Where they disagree, and what decides
      
      | Topic               | Position A                                                            | Position B                                                                                      | Deciding condition                                                                                     |
      | ------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
      | Points per guide    | Schmidbauer: 2 while placing, 3 short, 8 max `[00:20:24]`             | Bystedt: resample to 15 before shaping `[00:08:38]`                                             | Length and curvature: start low for direction, raise when shape along the strand matters               |
      | Clump guides        | Groomed curves as clump centers (Bystedt, art-directed) `[00:20:59]`  | Procedural: Existing Guide Map off, Guide Distance plus Guide Mask (Thommes) `[00:13:45]`       | Art-directed hairstyle vs natural fur                                                                  |
      | Splitting the groom | One object per landmark (Schmidbauer) `[00:13:15]`                    | One object with masks (Thommes); front and back objects where they disturb each other (Bystedt) | Groom size, performance, overlap while combing                                                         |
      | Mask source         | Vertex groups: fast, limited by vertex density                        | Textures: detail on low poly (Schmidbauer `[00:15:00]`, Bystedt `[00:22:04]`)                   | Mesh resolution vs needed detail                                                                       |
      | Frizz shape         | Positive (tip-weighted) default                                       | Negative when stacked on curls or clumps (Thommes `[00:15:04]`)                                 | Whether tips carry a shape that must survive                                                           |
      | Cumulative offset   | On: natural frizz (Thommes `[00:07:04]`)                              | Off: Noise per strand (Bystedt `[00:13:24]`), final Frizz (Thommes `[00:32:08]`)                | Base variation vs final fine jitter                                                                    |
      | Strands vs mesh     | Curves plus Principled Hair in Cycles (Thommes, Bystedt, Schmidbauer) | Mesh cards and tubes even for Cycles (Matsumoto), cards for games (Bystedt)                     | Offline Cycles vs EEVEE, realtime or export; Matsumoto also cites stability                            |
      | Length control      | Grow/Shrink brush at the head (Schmidbauer) `[00:36:43]`              | Trim with masks and random offset (Thommes, Schmidbauer elsewhere)                              | Many distinct lengths in small areas favor brushes (a human's job in 5.2.1); broad regions favor masks |
      | Intersection        | Shrinkwrap Hair Curves with an offset (Bystedt) `[00:12:09]`          | Sculpt collision option (brush-time only)                                                       | Bystedt wants an offset distance and a non-destructive fix                                             |
      
    • procedures.md 29.6 KB
      # Tested procedures (Blender 5.2.1 LTS)
      
      Every procedure below was run on Blender 5.2.1 LTS (build 2026-08-25) with
      `blender --background --factory-startup --python-exit-code 1 --python <test>` unless it says GUI.
      Tests live in `tests/code/blender-hair/` (`run_all.sh` runs all of them). The helpers come from
      `<skills>/scenario-blender-hair/scripts/bx_hair.py`. Each procedure shows the helper call, then the raw
      bpy it wraps, so you can work without the module.
      
      ```python
      import sys
      sys.path.append("<project>/skills/scenario-blender-hair/scripts")
      sys.path.append("<project>/skills/scenario-blender-expert/scripts")   # review() reuses bx_review
      import bx_hair as H
      ```
      
      Units are meters. Density counts curves per square meter of the surface mesh in its local space: object scale is ignored (verified), so apply scale first.
      
      ---
      
      ## P1. Surface prep and UV check
      
      Verified on 5.2.1 by `test_01_groom_headless.py`: a default sphere has 0 overlapping faces, and a stacked grid shows 32 of 32.
      
      ```python
      rep = H.uv_report(surface)      # {'uv_maps', 'active', 'uv_outside_0_1', 'overlap_faces', 'faces'}
      assert rep["overlap_faces"] == 0
      ```
      
      Raw equivalent. It needs Object Mode and the surface active. UV sync select must be off, or `uv.select_overlap` selects nothing readable:
      
      ```python
      import bmesh
      ts = bpy.context.scene.tool_settings; sync = ts.use_uv_select_sync; ts.use_uv_select_sync = False
      bpy.ops.object.mode_set(mode="EDIT")
      bpy.ops.mesh.select_all(action="SELECT"); bpy.ops.uv.select_all(action="DESELECT")
      bpy.ops.uv.select_overlap()
      bm = bmesh.from_edit_mesh(surface.data)
      overlap = sum(1 for f in bm.faces if all(l.uv_select_vert for l in f.loops))   # 5.x: BMLoop.uv_select_vert
      bpy.ops.object.mode_set(mode="OBJECT"); ts.use_uv_select_sync = sync
      ```
      
      - `uv_outside_0_1 > 0` is fine for UDIMs. Stacking is not (Schmidbauer).
      - Mirrored UV layouts: merge the center seam (UV editor, Merge by Distance) before grooming.
      - For a human hairstyle, grow on a separate hair cap split along the part (see P7). For animals, separate the mouth interior from the fur surface (Edit Mode, Separate, Selection).
      
      ## P2. Groom object, even roots, guides as data, attachment
      
      Verified on 5.2.1 by `test_01_groom_headless.py`. It passes 25 checks: Poisson spacing is respected (min nearest neighbor 0.01003 m at spacing 0.01), all roots fall inside the mask, `surface_uv_coordinate` is written, roots sit on the surface with max distance 0.0, and flow_blend 0.5 gives guides at 45° to the normal.
      
      ```python
      hair = H.new_groom(surface, "Hair")        # Empty Hair: parent, surface, UV map, Hair Dynamics
      P, N = H.sample_roots(surface, spacing=0.01, mask="density_fur", threshold=0.5, seed=1)
      H.add_guides(hair, P, N, length=0.03, points=2, flow=(0, 0, -1), flow_blend=0.5)
      # flow may be callable(P, N) -> (n, 3), for example "away from the part": see P7
      ```
      
      The raw equivalent (what `new_groom` and `add_guides` do):
      
      ```python
      for o in bpy.context.selected_objects: o.select_set(False)
      surface.select_set(True); bpy.context.view_layer.objects.active = surface
      bpy.ops.object.curves_empty_hair_add()           # poll fails without an active mesh
      hair = bpy.context.active_object                 # parented, surface + surface_uv_map set,
                                                       # pinned 'Hair Dynamics' (Mode 'Animation')
      cv = hair.data
      old = len(cv.points)
      cv.add_curves([2] * n)                           # n new curves of 2 points
      buf = np.empty(len(cv.points) * 3, np.float32)
      cv.attributes["position"].data.foreach_get("vector", buf)
      buf[old * 3:] = local_positions.ravel()          # hair.matrix_world.inverted() @ world
      cv.attributes["position"].data.foreach_set("vector", buf)
      with bpy.context.temp_override(object=hair, active_object=hair, selected_objects=[hair],
                                     selected_editable_objects=[hair]):
          bpy.ops.curves.snap_curves_to_surface(attach_mode="NEAREST")   # writes surface_uv_coordinate
      ```
      
      - `hair.matrix_world` equals the surface's matrix after Empty Hair, so hair-local space is surface-local space.
      - Hair Dynamics' Mode menu accepts `'Animation'` or `'Physics (Experimental)'`, nothing else.
      - Edited the surface mesh in Edit Mode after grooming? The roots float (238 of 238 in the test). `H.snap_to_surface(hair, "DEFORM")` puts them back (0 off-surface afterwards). Use NEAREST after topology changes (Schmidbauer: works about 99% of the time).
      
      ## P3. Guide resolution and shaping
      
      Verified on 5.2.1 by `test_01_groom_headless.py`: subdivide takes 2 points to 3, resample gives 15 with the attachment kept, and the helper modifier is applied and removed. Shrinkwrap is verified by `test_04_cards.py`.
      
      ```python
      H.subdivide_guides(hair, cuts=1)          # Edit Mode curves.subdivide, works headless (EDIT_CURVES)
      H.resample_guides(hair, 15)               # Bystedt: 15 points for long human hair, applied
      H.add_guides(hair, P, N, length=0.12, points=8, flow=f, flow_blend=0.8, droop=0.3)  # gravity sag
      sw = H.add_asset(hair, "Shrinkwrap Hair Curves")                # Bystedt's non-destructive intersection fix
      H.set_inputs(sw, {"Surface": head, "Offset Distance": 0.004, "Above Surface": 0.0})
      ```
      
      Raw resample (what `resample_guides` builds): a GN group with Resample Curve (Count), added as a NODES modifier, moved to index 0 and applied:
      
      ```python
      md = hair.modifiers.new("BX Resample", "NODES"); md.node_group = ng   # Group In -> Resample Curve -> Group Out
      md.properties.inputs.Socket_1.value = 15                                 # Count (identifier verified in test_09)
      with bpy.context.temp_override(object=hair, active_object=hair):
          bpy.ops.object.modifier_move_to_index(modifier=md.name, index=0)
          bpy.ops.object.modifier_apply(modifier=md.name)
      ```
      
      Brush substitutes, none of them strokes:
      
      - Comb: a direction field in `add_guides`.
      - Puff: `flow_blend` toward 0.
      - Grow/Shrink: Trim Hair Curves with a mask, or per-curve `length`.
      - Density brush: re-sample roots in the gap region and add guides there.
      - Delete: rebuild the curves without the bad indices (Schmidbauer: "delete it and add a new one").
      
      ## P4. Asset modifiers and inputs
      
      Verified on 5.2.1 by `test_01_groom_headless.py` and `test_02_asset_stack.py`. All 21 listed modifier assets add. The Displace object 'Surface' resolves, unknown names raise with the list of inputs, and evaluation is stale without update_tag (2,510 before the tag, 4,996 after).
      
      ```python
      md = H.add_asset(hair, "Interpolate Hair Curves")          # lands above the pinned Hair Dynamics
      H.set_inputs(md, {"Density": 20000.0, "Density Mask": "attr:density_fur", "Viewport Amount": 0.1})
      print(H.get_inputs(md)); print(H.count(hair))             # count(full=True): Viewport Amount forced to 1
      ```
      
      Raw equivalent:
      
      ```python
      with bpy.context.temp_override(object=hair, active_object=hair, selected_objects=[hair]):
          bpy.ops.object.modifier_add_node_group(asset_library_type="ESSENTIALS", asset_library_identifier="",
              relative_asset_identifier="nodes/procedural_hair_node_assets.blend/NodeTree/Interpolate Hair Curves")
      md = hair.modifiers.active                                # 2nd copy is named 'Interpolate Hair Curves.001'
      md.properties.inputs.Input_15.value = 20000.0             # md["Input_15"] = v raises TypeError in 5.2
      md.properties.inputs.Input_14.type = "ATTRIBUTE"; md.properties.inputs.Input_14.attribute_name = "density_fur"
      hair.update_tag()                                         # REQUIRED before reading the evaluated result
      ev = hair.evaluated_get(bpy.context.evaluated_depsgraph_get()).data
      print(len(ev.curves), len(ev.points))
      ```
      
      - Hair Dynamics comes from `nodes/geometry_nodes_dynamics_assets.blend`. Duplicated modifiers share one node group.
      - Evaluation in `--background` applies Viewport Amount. In the test, 10% gave 249 curves against 2,502 at full density.
      - Reorder with `bpy.ops.object.modifier_move_to_index(modifier=..., index=...)` under the same temp_override.
      - Quick Fur (`bpy.ops.object.quick_fur(density='MEDIUM', length=0.1, radius=0.001)`) builds Set Hair Curve Profile, Interpolate (Follow Surface Normal on), Noise, Frizz and Hair Dynamics, with 12 points per guide. Its density scales with object size: 80,220/m² on a 10 cm sphere.
      
      Input identifiers, all verified in 5.2.1 (use them when you do not use `H.set_inputs` names):
      
      | Asset                   | Key inputs (identifier = default)                                                                                                                                                                                                                                                                                                                  |
      | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Interpolate Hair Curves | Resting Surface `Input_12`=True, Follow Surface Normal `Input_24`=False, Part by Mesh Islands `Input_26`=True, Interpolation Guides `Input_10`=4, Distance to Guides `Input_28`=0, Distribution Method `Socket_2`=Random, Density `Input_15`=10, Density Mask `Input_14`=1, Mask Texture `Input_19`, Viewport Amount `Input_17`=1, Seed `Input_16` |
      | Set Hair Curve Profile  | Replace Radius `Input_6`=True, Radius `Input_3`=0.01, Shape `Input_2`=0.5, Factor Min `Input_4`=0, Factor Max `Input_5`=1                                                                                                                                                                                                                          |
      | Clump Hair Curves       | Factor `Input_7`=1, Shape `Input_6`=0.5, Tip Spread `Input_10`=0, Clump Offset `Input_13`=0, Distance Falloff `Input_12`, Distance Threshold `Input_11`, Seed `Input_14`, Preserve Length `Input_15`, Guide Index `Input_16`, Guide Distance `Input_9`=0.1, Guide Mask `Input_19`=1, Existing Guide Map `Input_8`=True                             |
      | Curl Hair Curves        | Factor `Input_2`, Subdivision `Input_5`=1, Curl Start `Input_6`=0.1, Radius `Input_7`=0.1, Factor Start `Input_9`=1, Factor End `Input_10`=1, Frequency `Input_11`=1, Random Offset `Input_16`=0.25, Seed `Input_15`, Guide Distance `Input_4`=0.1, Guide Mask `Input_18`=1, Existing Guide Map `Input_3`=True                                     |
      | Hair Curves Noise       | Cumulative Offset `Input_10`=True, Factor `Input_3`, Distance `Input_14`=0.01, Shape `Input_2`=0.5, Scale `Input_11`=1, Scale along Curve `Input_12`=1, Offset per Curve `Input_13`=0, Seed `Input_8`, Preserve Length `Input_6`                                                                                                                   |
      | Frizz Hair Curves       | Cumulative Offset `Input_10`=True, Factor `Input_3`, Distance `Input_11`=0.01, Shape `Input_2`=0.5, Seed `Input_8`, Preserve Length `Input_6`                                                                                                                                                                                                      |
      | Trim Hair Curves        | Scale Uniform `Input_9`=False, Length Factor `Input_5`=1, Replace Length `Input_6`=True, Length `Input_2`=1.0, Mask `Input_7`=1, Random Offset `Input_4`=0, Pin at Parameter `Input_3`, Seed `Input_8`                                                                                                                                             |
      | Rotate Hair Curves      | Factor `Input_5`, Axis `Input_3`, Angle `Input_2`=0, Random Offset `Input_6`=0.349 rad, Lock Ends `Input_8`                                                                                                                                                                                                                                        |
      | Roll Hair Curves        | Factor `Input_10`, Subdivision `Input_13`, Variation Level `Input_11`=10, Roll Length `Input_2`=0.1, Roll Radius `Input_3`=0.05, Roll Depth `Input_9`, Roll Taper `Input_4`, Retain Overall Shape `Input_5`, Roll Direction `Input_7`, Random Orientation `Input_6`=0.5                                                                            |
      | Shrinkwrap Hair Curves  | Surface Input Type `Socket_0`, Surface (object) `Input_2`, Factor `Input_5`, Offset Distance `Input_4`=0, Above Surface `Input_8`=0.5, Smoothing Steps `Input_6`, Lock Roots `Input_7`=True                                                                                                                                                        |
      | Displace Hair Curves    | Factor `Input_3`, Shape `Input_8`, Object Space `Input_2`, Displace Vector `Input_9`, Surface Normal toggle `Socket_2`, Surface (object) `Input_4`, Surface UV Map `Input_6` (defaults to attribute `UVMap`), Surface Normal Distance `Input_7`                                                                                                    |
      | Duplicate Hair Curves   | Amount `Input_2`=10, Viewport Amount `Input_4`, Radius `Input_5`=0.1, Distribution Shape `Input_7`, Tip Roundness `Input_8`, Even Thickness `Input_6`, Seed `Input_3`                                                                                                                                                                              |
      | Hair Dynamics           | Mode `Socket_44`=Animation, Substeps `Socket_6`=10, Bendiness `Socket_20`=0.5, Root Bendiness `Socket_35`=0.2, Surface Collision `Socket_15`=False, Gravity `Socket_22`=True                                                                                                                                                                       |
      
      ## P5. Masks and density maps
      
      Verified on 5.2.1 by `test_01_groom_headless.py`, `test_02_asset_stack.py` and `test_07_density_maps.py`.
      
      **Vertex group as Density Mask.** 796 children with the mask against 2,502 without. The surface group reaches the children as a CURVE-domain attribute, so later assets can read it:
      
      ```python
      H.set_inputs(interp, {"Density Mask": "attr:density_fur"})
      H.set_inputs(noise, {"Factor": "attr:noise_strength"})   # tip motion 0.042 painted side vs 0.002 unpainted
      ```
      
      **Bystedt's gray density map.** A weight of 0.5 halves the count (1,247 against 2,476). A white stripe at the part on a 0.5 base adds density there only (1,418). Paint the base at 50% so you keep headroom in both directions.
      
      **Mask Texture (5.2).** This is an image in surface UV space that discards children after distribution. Black on U > 0.5 left 1,237 of 2,476, with 0.8% leaking over the border:
      
      ```python
      H.set_inputs(interp, {"Mask Texture": bpy.data.images["density_mask"]})
      ```
      
      **Per-curve random masks (Thommes).** This is a small GN modifier placed right after Interpolate that stores a CURVE-domain float:
      
      ```python
      H.random_curve_mask(hair, "frizz_mask", threshold=0.8)            # 82% of curves at 0 in the test
      H.random_curve_mask(hair, "stray_mask", threshold=0.9, hard=True) # 9.5% of curves at 1
      H.set_inputs(frizz, {"Factor": "attr:frizz_mask"})                # masked curves moved 0.0, others 0.6 mm
      ```
      
      The group is Random Value (FLOAT, ID = ID node, Seed) into Map Range (From Min = Threshold), with a Compare (Greater Than) for the hard version. A Switch picks one, and Store Named Attribute (FLOAT, CURVE, Name) writes it.
      
      **Root-to-tip ramps (Thommes: curl frequency rises toward the tip).**
      
      ```python
      H.along_curve_attr(hair, "curl_freq", root=1.0, tip=5.0)   # Spline Parameter -> Map Range -> POINT float
      H.set_inputs(curl, {"Frequency": "attr:curl_freq"})
      ```
      
      **Full-density preview patch.** In 5.2.1 Thommes' trick (a Mask modifier on the surface, render off) does NOT reduce the children while Interpolate's Resting Surface is on (default). Verified: 2,526 curves with and without the Mask modifier, 538 with Resting Surface off. Use one of these instead:
      
      - A temporary patch vertex group as the Density Mask (610 curves) with Viewport Amount 1.
      - Resting Surface off while previewing, then back on.
      
      ## P6. Procedural stacks
      
      **Long fur (Thommes).** Verified on 5.2.1 by `test_02_asset_stack.py`. On a 10 × 10 cm patch, 1M/m² gave 10,021 curves (421 in the viewport at 4%), and a full-density evaluation took 0.44 s.
      
      ```python
      mods = H.build_stack(hair, H.FUR_STACK_LONG)   # creates stray_mask / frizz_mask after Interpolate
      H.set_inputs(hair.modifiers["Hair Curves Noise"], {"Factor": "attr:noise_strength"})
      ```
      
      The order is Profile (0.2 mm), then Interpolate (1M, 4%), then the masks, then Noise, Curl, strays Noise (10%, 5 mm), Frizz (negative), Clump (1 mm, 0.1), Clump (4 mm, 0.25, offset 5 mm), Frizz (-0.2, 2 mm, Cumulative off, 20%), Trim (Replace Length off, 5 mm), Rotate (5°) and Hair Dynamics. The preset marks its own guesses [added]: Curl Factor End 0.3, first Frizz Shape -0.5, Clump Shape 0.3. The talk says "much lower" or "negative" without a number there.
      
      **Human hairstyle, two populations in one tree (Bystedt).** Verified on 5.2.1 by `test_08_populations_pose.py`. Two populations joined gave 632 red and 596 blue curves, and Global Density 0.5 halved the count (1,228 to 646).
      
      ```python
      # guides: 15 points; guide-level shaping before interpolation
      H.set_inputs(H.add_asset(hair, "Hair Curves Noise"), {"Cumulative Offset": False, "Offset per Curve": 1.0,
                                                            "Scale along Curve": 0.5, "Distance": 0.004})
      H.set_inputs(H.add_asset(hair, "Roll Hair Curves"), {"Roll Length": 0.02, "Roll Radius": 0.004,
                                                           "Random Orientation": 1.0})   # tip curl only [values added]
      # one GN tree: per population Interpolate -> Set Hair Curve Profile -> Clump -> Store 'pop_color'; Join
      ng = bpy.data.node_groups.new("Hairstyle Populations", "GeometryNodeTree")
      ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
      gd = ng.interface.new_socket("Global Density", in_out="INPUT", socket_type="NodeSocketFloat"); gd.default_value = 1.0
      ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
      gi, go, join = ng.nodes.new("NodeGroupInput"), ng.nodes.new("NodeGroupOutput"), ng.nodes.new("GeometryNodeJoinGeometry")
      def population(density, seed, clump_shape, clump_factor, color):
          it = ng.nodes.new("GeometryNodeGroup"); it.node_tree = bpy.data.node_groups["Interpolate Hair Curves"]
          mul = ng.nodes.new("ShaderNodeMath"); mul.operation = "MULTIPLY"; mul.inputs[1].default_value = density
          ng.links.new(gi.outputs["Global Density"], mul.inputs[0]); ng.links.new(mul.outputs[0], it.inputs["Density"])
          it.inputs["Seed"].default_value = seed
          ng.links.new(gi.outputs["Geometry"], it.inputs["Geometry"])
          pr = ng.nodes.new("GeometryNodeGroup"); pr.node_tree = bpy.data.node_groups["Set Hair Curve Profile"]
          pr.inputs["Radius"].default_value = 0.0003
          ng.links.new(it.outputs["Geometry"], pr.inputs["Geometry"])
          cl = ng.nodes.new("GeometryNodeGroup"); cl.node_tree = bpy.data.node_groups["Clump Hair Curves"]
          cl.inputs["Shape"].default_value = clump_shape; cl.inputs["Factor"].default_value = clump_factor
          cl.inputs["Seed"].default_value = seed
          ng.links.new(pr.outputs["Geometry"], cl.inputs["Geometry"])
          st = ng.nodes.new("GeometryNodeStoreNamedAttribute"); st.data_type = "FLOAT_COLOR"; st.domain = "CURVE"
          st.inputs["Name"].default_value = "pop_color"; st.inputs["Value"].default_value = color
          ng.links.new(cl.outputs["Geometry"], st.inputs["Geometry"]); ng.links.new(st.outputs["Geometry"], join.inputs["Geometry"])
      population(5000.0, 1, 0.3, 0.5, (1, 0, 0, 1))    # wide clumps: Shape ~0.3, reduced Factor (Bystedt)
      population(5000.0, 2, 0.05, 0.7, (0, 0, 1, 1))   # thin clumps: different seed
      ng.links.new(join.outputs["Geometry"], go.inputs["Geometry"])
      md = hair.modifiers.new("Hairstyle", "NODES"); md.node_group = ng
      H.move(hair, md, [m.name for m in hair.modifiers].index("Hair Dynamics"))
      ```
      
      - The asset groups must exist locally first. `H.add_asset` on a throwaway curves object appends them, or use `bpy.data.libraries.load`.
      - Read `pop_color` in the shader with an Attribute node to see each population (Bystedt's debug colors).
      - Alternative without a tree: `twin = hair.copy()` shares the Curves data, so both objects use the same guides and each gets its own stack. Verified: the twin evaluated 362 curves.
      
      **Animal regional stack (Schmidbauer).** Verified on 5.2.1 by `test_09_animal_regions.py`. Each masked effect acted inside its painted region and barely outside it. Tip motion inside vs outside: Trim 1.0 mm vs 0.06 mm, Clump 7.3 mm vs 0.4 mm, Frizz 1.2 mm vs 0.09 mm. Tip Spread is a distance in meters: 0.2 threw 3 cm tips 19 cm. Use one curves object per landmark, and give each effect its own vertex group on the shared body mesh:
      
      ```python
      H.set_inputs(H.add_asset(fur, "Interpolate Hair Curves"), {"Density": 200000.0, "Density Mask": "attr:body_fur"})  # density [added]
      H.set_inputs(H.add_asset(fur, "Trim Hair Curves"), {"Replace Length": False, "Random Offset": 0.002, "Mask": "attr:length_fur"})
      H.set_inputs(H.add_asset(fur, "Clump Hair Curves"), {"Factor": "attr:clump", "Tip Spread": 0.002, "Clump Offset": 0.001})
      H.set_inputs(H.add_asset(fur, "Frizz Hair Curves"), {"Factor": "attr:frizz", "Distance": 0.001})
      ```
      
      The distances in that block are [added] starting points for a cat-size animal; the talk gives no numbers for them. Tune them in renders.
      
      ## P7. Part and hairline (Bystedt)
      
      Verified on 5.2.1 by `test_05_parting.py`. The cap splits into 2 islands. With Part by Mesh Islands off, 21% of children near the part crossed it; with it on, 0%. The parting lift raised mid-strand height near the part from 9.6 mm to 13.7 mm.
      
      ```python
      import bmesh
      bm = bmesh.new(); bm.from_mesh(cap.data)
      part_edges = [e for e in bm.edges if all(abs(v.co.x) < 1e-6 for v in e.verts)]   # the part line
      bmesh.ops.split_edges(bm, edges=part_edges); bm.to_mesh(cap.data); bm.free()      # 2 islands
      # 'part' vertex group on the line (weight 1), then guides combed away from it:
      flow = lambda P, N: np.stack([np.sign(P[:, 0]), np.zeros(len(P)), -0.6 * np.ones(len(P))], 1)
      H.add_guides(hair, P, N, length=0.12, points=8, flow=flow, flow_blend=0.85, droop=0.3)
      interp = H.add_asset(hair, "Interpolate Hair Curves")          # Part by Mesh Islands is on by default
      H.distance_weights(cap, "part_lift", "part", radius=0.03, profile="bump")   # 0 on the line, 1 at r/2, 0 at r
      disp = H.add_asset(hair, "Displace Hair Curves")
      H.set_inputs(disp, {"Surface Normal": True, "Surface": cap, "Surface Normal Distance": 0.006,
                          "Shape": 0.05, "Factor": "attr:part_lift"})
      ```
      
      - Keep guides off the part line itself; the test dropped roots within 4 mm of it.
      - The render still shows scalp at the part (Bystedt: "at the center line it's very hard to get enough coverage"). Add density there: a white stripe in the density map (P5).
      - Not scripted here: Bystedt's root snap onto the part line, where roots within a distance move onto the line. As a GN sketch: Object Info of the line mesh, Geometry Proximity with target Edges, Set Position on root points mixed by a factor. Not run.
      
      ## P8. Shading, render settings, review
      
      Verified on 5.2.1 by `test_03_shading_render.py` on 37,648 curves. Render times were 4.4 s for Cycles at 24 samples and 384 px on CPU, and 1.3 to 2.3 s for EEVEE.
      
      ```python
      m = H.hair_material("Fur", "CYCLES", melanin=(0.85, 0.45), uv_noise=0.3)   # Principled Hair (Chiang, melanin)
      m = H.hair_material("Fur", "EEVEE", root_color=(0.05, 0.025, 0.012), tip_color=(0.4, 0.2, 0.08))
      H.assign_material(hair, m)
      H.setup_render(bpy.context.scene, "CYCLES", close_up=False)   # STRIP + subdiv; THICK curves for close-ups
      sheet = H.review([surface, hair], "/abs/out", engine="CYCLES", views=("front", "threequarter", "top"), res=512)
      ```
      
      Raw material graph:
      
      - Curves Info (`ShaderNodeHairInfo`) Intercept goes to a Mix (RGBA, root color to tip color) or to a Map Range for melanin.
      - Curves Info Random goes to a Map Range for roughness 0.25 to 0.4.
      - Cycles: `ShaderNodeBsdfHairPrincipled` with `parametrization='MELANIN'` or `'COLOR'`. EEVEE: `ShaderNodeBsdfPrincipled` Base Color.
      - Surface-mapped variation (Thommes): Attribute `surface_uv_coordinate` into a Noise Texture, blended with Mix Soft Light.
      - Mix node sockets must be addressed by identifier (`A_Color`, `B_Color`, `Result_Color`, `Factor_Float`). `inputs["A"]` returns the hidden float socket.
      
      Measured center luminance for the same groom: 0.255 for Principled Hair in Cycles, 0.092 for Principled Hair in EEVEE, and 0.498 for Principled BSDF in EEVEE. So use Principled BSDF with explicit colors under EEVEE. The review rig:
      
      - A warm area fill from the top, a small side key and a cool rim.
      - A world of RGB (0.03, 0.04, 0.06).
      - Energies scale with the framing distance squared.
      - The user's scene is left untouched.
      
      ## P9. Pose check (Schmidbauer)
      
      Verified on 5.2.1 by `test_01_groom_headless.py` and `test_08_populations_pose.py`. With a shape key, roots followed at a mean radius of 0.1558 against 0.156 expected. With Hair Dynamics disabled they stayed at 0.1199. On an armature-posed frame, roots stayed on the deformed cap within 1.7e-8.
      
      ```python
      scene.frame_set(10)                                   # a keyed extreme pose
      sheet = H.review([body, fur], out, engine="EEVEE", views=("right",), frame=10)   # review renders that frame
      ```
      
      Check 2 or 3 extreme poses from the same camera, looking for bald spots, stretching gaps and clipping. Keep Hair Dynamics (Animation) last in the stack.
      
      ## P10. Hair cards and tubes for games (Bystedt, Matsumoto)
      
      Verified on 5.2.1 by `test_04_cards.py` (12 checks):
      
      - Cards: tris = guides × 11 × 3 × 2.
      - UVs: U 0 to 0.5, V 0 to 1.
      - Roots on the scalp within 1.9e-8.
      - Root faces along the scalp: mean |normal · surface normal| 0.957.
      - Tubes: tris = guides × 11 × 6 × 2, U 0 to 1.
      - `object.convert` keeps `UVMap` as a UV layer, and the glTF export (cards plus cap) was 301 KB.
      
      ```python
      mat = H.card_material("Card")                      # procedural strand stand-in [added], or image=baked_plate
      md = H.add_cards(hair, width=0.02, material=mat, segments=3, resample=12, root_scale=1.0, tip_scale=0.3)
      print(H.card_stats(hair))                          # {'tris', 'uv_min', 'uv_max', ...}
      H.set_inputs(md, {"Tubes": True})                  # close-ups or bald spots (Matsumoto)
      # export: work on a copy, drop Hair Dynamics, convert
      dup = hair.copy(); dup.data = hair.data.copy(); bpy.context.collection.objects.link(dup)
      dup.modifiers.remove(dup.modifiers["Hair Dynamics"])
      for o in bpy.context.selected_objects: o.select_set(False)
      dup.select_set(True); bpy.context.view_layer.objects.active = dup
      bpy.ops.object.convert(target="MESH")
      bpy.ops.export_scene.gltf(filepath="/abs/cards.glb", use_selection=True, export_format="GLB")
      ```
      
      What `cards_group()` builds ('BX Hair Cards'), in order:
      
      1. Resample Curve (count).
      2. Set Curve Normal with Mode `Free` and Normal from Sample Nearest Surface of the surface normals, so the card lies on the scalp (Bystedt).
      3. Capture Attribute of Spline Parameter on the curve (V).
      4. The profile: an arc of angle Bend centered on +X and shifted so its apex sits on the curve, or a flat Spiral for tubes (resolution 6, 1 rotation, height 0, start radius = end radius; Matsumoto). A Switch picks one, and a Capture of Spline Parameter on the profile gives U.
      5. Curve to Mesh, with Scale = root-to-tip taper (the 4.5+ Scale input).
      6. Store Named Attribute FLOAT2, CORNER, `UVMap` = (U × 0.5 for cards or × 1 for tubes, V).
      7. Set Shade Smooth, then Set Material.
      8. Set Position of the V < 1e-4 row onto Geometry Proximity (Faces) of the surface. This runs last (Matsumoto).
      
      5.2 gotchas met while building it:
      
      - Capture Attribute has a Selection input, so address items by name.
      - Arc Resolution counts points (segments + 1).
      - `obj.evaluated_geometry().mesh` is freed when the GeometrySet is not kept in a variable.
      - A stored FLOAT2 CORNER attribute named `UVMap` works directly in the material UV Map node and survives conversion, so no extra output attribute is needed.
      
      Not scripted (expert features to add when needed): Bystedt's snap-if-inside push-out, normal transfer from the head, color and UV transfer from the scalp at the card root, card variants instanced per curve, and 1 subdivision per 20 cm (set `resample` from the length instead). Also not scripted: Matsumoto's strand-plate bake (orthographic camera over a groomed plane; passes normal, depth, AO, position, alpha; depth remapped). Render it with the same review tools, but it has no test here.
      
      ## P11. Live session: brush setup and hand-off (GUI)
      
      GUI only. Verified by `test_06_gui_curves_sculpt.py`, run as `blender --factory-startup --python ...` with a window and 7 checks.
      
      ```python
      import bx_gui as G
      bpy.ops.object.mode_set(mode="SCULPT_CURVES")              # curves object active
      G.activate_brush("SCULPT_CURVES", "Add")                   # Add, Comb, Delete, Density, Grow/Shrink, Pinch,
      cs = bpy.context.scene.tool_settings.curves_sculpt.brush.curves_sculpt_settings   # Puff, Pull Tips, Select, Slide, Smooth
      cs.points_per_curve = 2; cs.curve_length = 0.01            # Schmidbauer
      cs.use_length_interpolate = cs.use_shape_interpolate = cs.use_point_count_interpolate = True   # Bystedt
      G.activate_brush("SCULPT_CURVES", "Comb")
      bpy.context.scene.tool_settings.curves_sculpt.brush.falloff_shape = "PROJECTED"   # Bystedt: reaches the far side
      bpy.context.scene.tool_settings.curves_sculpt.use_symmetry_x = True
      # now ask the user to stroke; G.stroke("SCULPT_CURVES", ...) returns {'PASS_THROUGH'} and changes nothing
      ```
      
      In 5.2.1 `sculpt_curves.brush_stroke` has no exec: the console logs "Invalid operator call". `INVOKE_DEFAULT` returns RUNNING_MODAL and waits for a real mouse. `--enable-event-simulate` with `Window.event_simulate` produced no strokes either, for curves or for mesh sculpt, so strokes need a person. What does work from code in that mode is `sculpt_curves.select_random`, `select_grow` and every Object-Mode procedure above.
      
    • sources.md 6.8 KB
      # Sources
      
      Five videos were distilled into this skill. Notes and transcripts are in the project folder: `notes/hair/<id> - <title>.md`, the batch digest `notes/hair/_digest_hair.md`, and `transcripts/<id>.txt` (auto captions). None had frame grabs, so visual details come from speech. The Blender versions on screen were 3.5 to about 4.5. `procedures.md` and the **5.2** marks in `expert-notes.md` record what changed in 5.2.1.
      
      | #   | Video                                                                                                                       | Expert and credential                                                                                                                                                              | Date, length, version               | Best for                                                                                                                                    |
      | --- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
      | 1   | [Character grooming with the new hair system](https://www.youtube.com/watch?v=Kx88edAbiek) (Blender Conference 2023)        | **Daniel Bystedt**, senior concept artist at Goodbye Kansas Studios (VFX, game cinematics), Blender sculpt and modeling module contributor, author of the official hair demo files | 2023-10-26, 41:30, about 3.6 or 4.0 | Human hairstyle as layered populations, hair cap, parting fixes, density map, debug colors                                                  |
      | 2   | [A million little curves: creating hair for animals](https://www.youtube.com/watch?v=ZrBbNhCgGww) (Blender Conference 2025) | **Kerstin Schmidbauer**, founder and technical director of Critter Crew Animation (leads the fur department, a new groom every year)                                               | 2025-09-18, 53:55, about 4.4 or 4.5 | Animal fur: reference, 2-point guides, landmark objects, masks for everything, troubleshooting (bald spots, invalid UVs, mouth, dents)      |
      | 3   | [How to Make Procedural Fur in Blender Geometry Nodes](https://www.youtube.com/watch?v=gCQN5vNgHiI) (Blender Studio)        | **Simon Thommes**, Blender Studio technical artist, co-author of the procedural hair node assets and demo files                                                                    | 2023-03-29, 34:58, 3.5              | Value-by-value procedural long-fur recipe, asset mechanics (Shape, guide maps, density units), per-curve randomness, shading and review rig |
      | 4   | [Geometry nodes hair card setup for Blender](https://www.youtube.com/watch?v=g_ZIOafq4QU)                                   | **Daniel Bystedt** (as above)                                                                                                                                                      | 2024-03-17, 25:53, about 4.0 or 4.1 | Specification of a production hair-card generator: surface-aligned tilt, root snap, transfers, hair cap, texture scene                      |
      | 5   | [Mesh Hair with Geometry Nodes and Hair Curves](https://www.youtube.com/watch?v=vXBL-oiqY7Q) (Blender Conference 2024)      | **Sara Matsumoto**, SM5 by Helan (fantasy asset store, Transhuman character creator for Blender)                                                                                   | 2024-10-24, 43:10, about 4.2        | Cards and tubes from curves with GN UVs, in-Blender strand texture plates, anisotropic card material                                        |
      
      ## Best timestamps
      
      **1. Bystedt, BCON23 (`Kx88edAbiek`)**
      
      - `[00:02:19]` to `[00:02:53]`: hair cap and splitting it along the part.
      - `[00:05:06]` to `[00:07:24]`: brush settings (Projected falloff, Scale Uniform off, Curve-domain selection).
      - `[00:08:38]`: resample to 15. `[00:09:52]`: grow, comb, shrink.
      - `[00:11:33]` to `[00:12:09]`: Shrinkwrap Hair Curves.
      - `[00:13:24]` to `[00:15:44]`: Noise and Roll settings.
      - `[00:16:21]` to `[00:21:31]`: Interpolate, Profile, Clump for wide clumps (Shape semantics at `[00:18:04]`).
      - `[00:22:04]` to `[00:24:56]`: density map and Global Density.
      - `[00:26:08]`: debug colors. `[00:27:54]` to `[00:29:44]`: thin-clump population.
      - `[00:32:06]` to `[00:41:16]`: parting root snap and lift graph.
      
      **2. Schmidbauer, BCON25 (`ZrBbNhCgGww`)**
      
      - `[00:03:24]` to `[00:08:43]`: reference discipline and draw-overs.
      - `[00:13:15]` to `[00:14:25]`: objects per landmark.
      - `[00:19:44]` to `[00:21:34]`: 2-point guides.
      - `[00:22:07]` to `[00:26:14]`: Interpolate masks and density units.
      - `[00:27:26]` to `[00:32:36]`: Trim, Clump, Frizz with masks.
      - `[00:33:16]` to `[00:47:50]`: troubleshooting (deformation x10, mouth, dents, invalid UVs, mirror seam, snapping).
      - `[00:50:04]` to `[00:53:28]`: shape-key-like groom blend.
      
      **3. Thommes, Blender Studio (`gCQN5vNgHiI`)**
      
      - `[00:02:32]` to `[00:04:20]`: density per area, snap to deformed surface, Strip display, Profile Shape.
      - `[00:09:02]` to `[00:09:35]`: 1M density and 4% viewport.
      - `[00:12:04]` to `[00:16:44]`: Curl, guide maps, Frizz shape, sub-clumps, radius.
      - `[00:17:16]` to `[00:21:13]`: lighting, shader (Cycles vs EEVEE), color variation.
      - `[00:19:32]`: first-render critique.
      - `[00:25:31]` to `[00:32:08]`: per-curve randomness, curl frequency ramp, strays, second clump, final frizz.
      - `[00:32:51]`: full-density patch.
      
      **4. Bystedt, hair cards (`g_ZIOafq4QU`)**
      
      - `[00:00:31]` to `[00:06:16]`: generator inputs.
      - `[00:06:53]` to `[00:11:03]`: masks, color and UV transfer, parting line.
      - `[00:17:15]` to `[00:22:28]`: character setup (front and back grooms, thick and sparse cards, hair cap).
      - `[00:23:04]` to `[00:25:25]`: texture scene and outputs.
      
      **5. Matsumoto, BCON24 (`vXBL-oiqY7Q`)**
      
      - `[00:03:55]` to `[00:10:57]`: strand plate grooming and passes.
      - `[00:11:37]` to `[00:17:06]`: material test rig and anisotropic card material.
      - `[00:18:16]` to `[00:24:21]`: GN cards and tubes (Resample, Arc, Spiral, taper).
      - `[00:24:36]` to `[00:31:27]`: GN UVs and the card vs tube density fix.
      - `[00:32:09]` to `[00:33:47]`: root attach.
      - `[00:39:47]` to `[00:41:28]`: density scale, hairline systems, render times.
      
      ## Related skills
      
      - scenario-blender-expert: the router, execution channel and review loop.
      - scenario-blender-geometry-nodes: general node-group work beyond the hair assets.
      - scenario-blender-texturing-shading: the head and skin under the groom.
      - scenario-blender-lighting-rendering: final lighting of hero shots.
      - scenario-blender-rigging: the deformation the groom has to survive.
      - scenario-3d: generated characters to groom.
      
  • scripts
    • bx_hair.py 53.8 KB
      """
      bx_hair: headless hair-curves grooming for Blender 5.2 (tested on 5.2.1 LTS).
      
      What experts do with a tablet (place guides, comb, add procedural layers, judge a
      render) done from Python. Brush strokes on hair curves are NOT scriptable in 5.2.1
      (sculpt_curves.brush_stroke has no exec, see SKILL.md), so guides are written as
      data and every look decision goes through the Essentials hair node-group assets.
      
        import sys; sys.path.append("<skill>/scripts"); import bx_hair as H
        hair = H.new_groom(scalp)                                  # Empty Hair: parent, surface, UV map, Hair Dynamics
        P, N = H.sample_roots(scalp, spacing=0.01, mask="density_fur")
        H.add_guides(hair, P, N, length=0.03, points=2, flow=(0, 0, -1), flow_blend=0.6)
        md = H.add_asset(hair, "Interpolate Hair Curves")
        H.set_inputs(md, {"Density": 20000, "Density Mask": "attr:density_fur"})
        H.random_curve_mask(hair, "frizz_mask", threshold=0.8)    # Simon: 80% of strands get no frizz
        fz = H.add_asset(hair, "Frizz Hair Curves"); H.set_inputs(fz, {"Factor": "attr:frizz_mask"})
        print(H.count(hair)); rep = H.groom_report(hair); print(H.verdict(rep))
        H.review([scalp, hair], "/abs/out", engine="CYCLES")     # lit render sheet, open it with the image reader
      
      Every function works in a live GUI session too (object mode). Values are metres.
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import math
      import os
      import sys
      
      import bpy
      import numpy as np
      from mathutils import Vector, kdtree
      
      HAIR_LIB = "nodes/procedural_hair_node_assets.blend"
      DYN_LIB = "nodes/geometry_nodes_dynamics_assets.blend"
      DYNAMICS_ASSETS = {"Hair Dynamics", "Cloth Dynamics (Experimental)", "Collider", "Custom Force"}
      # Modifier assets in procedural_hair_node_assets.blend (5.2.1), by asset-browser catalog.
      HAIR_ASSETS = {
          "Generation": ["Interpolate Hair Curves", "Duplicate Hair Curves", "Generate Hair Curves"],
          "Guides": ["Clump Hair Curves", "Curl Hair Curves", "Braid Hair Curves", "Create Guide Index Map"],
          "Deformation": ["Hair Curves Noise", "Frizz Hair Curves", "Trim Hair Curves", "Rotate Hair Curves",
                          "Roll Hair Curves", "Displace Hair Curves", "Shrinkwrap Hair Curves",
                          "Smooth Hair Curves", "Straighten Hair Curves", "Blend Hair Curves"],
          "Write": ["Set Hair Curve Profile"],
          "Utility": ["Attach Hair Curves to Surface", "Redistribute Curve Points",
                      "Restore Curve Segment Length"],
          # node-only (no modifier): Attachment Info, Curve Info, Curve Root, Curve Segment, Curve Tip
      }
      DEFORMERS = set(HAIR_ASSETS["Guides"] + HAIR_ASSETS["Deformation"]) - {"Create Guide Index Map"}
      
      # Simon Thommes' highland-cow long fur (Blender Studio, gCQN5vNgHiI), values from the talk.
      # Absolute metres: tuned for a real-size animal patch. Entries marked [added] are not in the talk.
      FUR_STACK_LONG = [
          ("Set Hair Curve Profile", {"Radius": 0.0002}),                                    # 0.2 mm at 1M/m2
          ("Interpolate Hair Curves", {"Density": 1_000_000.0, "Viewport Amount": 0.04}),
          ("Hair Curves Noise", {}),                                                         # Factor: paint a mask
          ("Curl Hair Curves", {"Radius": 0.02, "Curl Start": 1.0, "Factor End": 0.3,         # Factor End "much lower" [added 0.3]
                                "Random Offset": 0.5, "Existing Guide Map": False,
                                "Guide Distance": 0.01, "Guide Mask": 0.2}),
          ("Hair Curves Noise", {"Distance": 0.005, "Factor": "attr:stray_mask"}),           # strays: 10% of curves
          ("Frizz Hair Curves", {"Distance": 0.002, "Shape": -0.5}),                          # negative shape (value [added])
          ("Clump Hair Curves", {"Existing Guide Map": False, "Guide Distance": 0.001,
                                 "Guide Mask": 0.1, "Shape": 0.3}),                           # "lower Shape" [added 0.3]
          ("Clump Hair Curves", {"Existing Guide Map": False, "Guide Distance": 0.004,
                                 "Guide Mask": 0.25, "Clump Offset": 0.005}),
          ("Frizz Hair Curves", {"Shape": -0.2, "Distance": 0.002, "Cumulative Offset": False,
                                 "Factor": "attr:frizz_mask"}),
          ("Trim Hair Curves", {"Replace Length": False, "Random Offset": 0.005}),
          ("Rotate Hair Curves", {"Random Offset": math.radians(5)}),
      ]
      
      
      # ----------------------------------------------------------------------------- basics
      def _s(sockets, key):
          """Socket by identifier, else by name among enabled sockets (Mix, Capture Attribute
          and other dynamic nodes reuse names like 'A' or 'Result' across data types)."""
          for sk in sockets:
              if sk.identifier == key:
                  return sk
          for sk in sockets:
              if sk.name == key and sk.enabled:
                  return sk
          raise KeyError(key)
      
      
      def _ctx_obj(obj):
          return bpy.context.temp_override(object=obj, active_object=obj, selected_objects=[obj],
                                           selected_editable_objects=[obj])
      
      
      def _object_mode():
          if bpy.context.mode != "OBJECT":
              bpy.ops.object.mode_set(mode="OBJECT")
      
      
      def new_groom(surface, name="Hair"):
          """Add an empty hair curves object on `surface` (Add > Curve > Empty Hair).
          Sets parent, data.surface, data.surface_uv_map (active UV map) and a pinned
          Hair Dynamics modifier in Animation mode (moves hair with the deforming surface)."""
          if surface.type != "MESH" or not surface.data.uv_layers:
              raise ValueError("surface must be a mesh with a UV map (hair binds to UV space)")
          _object_mode()
          for o in bpy.context.selected_objects:
              o.select_set(False)
          surface.select_set(True)
          bpy.context.view_layer.objects.active = surface
          bpy.ops.object.curves_empty_hair_add()
          hair = bpy.context.active_object
          hair.name = hair.data.name = name
          bpy.context.view_layer.update()
          return hair
      
      
      def _mesh_arrays(surface):
          me = surface.data
          me.calc_loop_triangles()
          tris = np.empty(len(me.loop_triangles) * 3, np.int32)
          me.loop_triangles.foreach_get("vertices", tris)
          co = np.empty(len(me.vertices) * 3, np.float32)
          me.vertices.foreach_get("co", co)
          vn = np.empty(len(me.vertices) * 3, np.float32)
          me.vertex_normals.foreach_get("vector", vn)
          return tris.reshape(-1, 3), co.reshape(-1, 3), vn.reshape(-1, 3)
      
      
      def group_weights(obj, group):
          """Per-vertex weights of a vertex group as a numpy array (0 where unassigned)."""
          vg = obj.vertex_groups[group]
          w = np.zeros(len(obj.data.vertices), np.float32)
          for v in obj.data.vertices:
              for g in v.groups:
                  if g.group == vg.index:
                      w[v.index] = g.weight
          return w
      
      
      def sample_roots(surface, spacing, mask=None, threshold=0.5, seed=0, max_roots=200_000):
          """Evenly spaced guide roots on the surface (Poisson-disk dart throwing).
          Kerstin: even spacing matters more than count; gaps become bald spots.
          Returns (P, N) world-space positions and smooth normals, numpy (n, 3)."""
          tris, co, vn = _mesh_arrays(surface)
          M = np.array(surface.matrix_world, dtype=np.float64)
          R = M[:3, :3]
          cw = co @ R.T + M[:3, 3]
          nrm_m = np.linalg.inv(R).T
          nw = vn @ nrm_m.T
          a, b, c = cw[tris[:, 0]], cw[tris[:, 1]], cw[tris[:, 2]]
          area = 0.5 * np.linalg.norm(np.cross(b - a, c - a), axis=1)
          total = float(area.sum())
          n_cand = int(min(max_roots * 6, max(64, 6 * total / (spacing * spacing))))
          rng = np.random.default_rng(seed)
          ti = rng.choice(len(tris), n_cand, p=area / total)
          s = np.sqrt(rng.random(n_cand))
          r2 = rng.random(n_cand)
          u, v, w = 1 - s, s * (1 - r2), s * r2
          t = tris[ti]
          P = a[ti] * u[:, None] + b[ti] * v[:, None] + c[ti] * w[:, None]
          N = nw[t[:, 0]] * u[:, None] + nw[t[:, 1]] * v[:, None] + nw[t[:, 2]] * w[:, None]
          N /= np.linalg.norm(N, axis=1)[:, None] + 1e-12
          if mask:
              wv = group_weights(surface, mask)
              wp = wv[t[:, 0]] * u + wv[t[:, 1]] * v + wv[t[:, 2]] * w
              keep = wp >= threshold
              P, N = P[keep], N[keep]
          # dart throwing on a hash grid, cell = spacing
          grid, keep = {}, []
          inv = 1.0 / spacing
          s2 = spacing * spacing
          for i, p in enumerate(P):
              k = (int(math.floor(p[0] * inv)), int(math.floor(p[1] * inv)), int(math.floor(p[2] * inv)))
              ok = True
              for dx in (-1, 0, 1):
                  for dy in (-1, 0, 1):
                      for dz in (-1, 0, 1):
                          for j in grid.get((k[0] + dx, k[1] + dy, k[2] + dz), ()):
                              d = P[j] - p
                              if d @ d < s2:
                                  ok = False
                                  break
                          if not ok:
                              break
                      if not ok:
                          break
                  if not ok:
                      break
              if ok:
                  grid.setdefault(k, []).append(i)
                  keep.append(i)
                  if len(keep) >= max_roots:
                      break
          keep = np.array(keep, dtype=np.int64)
          return P[keep], N[keep]
      
      
      def add_guides(hair, roots, normals, length, points=2, flow=None, flow_blend=0.0,
                     droop=0.0, snap=True):
          """Write guide curves (the tablet-free replacement for the Add brush).
      
          roots, normals: (n, 3) world space (from sample_roots). length: float or (n,).
          points: Kerstin grooms with 2 points until every direction is right, 3 for short
          fur, up to 8 for long hair; Bystedt resamples long human hair to 15.
          flow: None, a world vector, or callable(P, N) -> (n, 3) flow directions; it is
          projected on the surface tangent plane and blended with the normal by flow_blend
          (0 = straight out, 1 = lying on the surface). droop: tip sag along -Z as a fraction
          of length (needs points >= 3 to bend). snap: snap_curves_to_surface(NEAREST), which
          writes surface_uv_coordinate (the attachment). Returns the number of curves added."""
          _object_mode()
          P = np.asarray(roots, np.float64)
          N = np.asarray(normals, np.float64)
          n = len(P)
          if n == 0:
              return 0
          L = np.broadcast_to(np.asarray(length, np.float64), (n,))
          d = N.copy()
          if flow is not None and flow_blend > 0:
              F = flow(P, N) if callable(flow) else np.broadcast_to(np.asarray(flow, np.float64), (n, 3))
              T = F - (F * N).sum(1)[:, None] * N
              T /= np.linalg.norm(T, axis=1)[:, None] + 1e-12
              d = (1 - flow_blend) * N + flow_blend * T
              d /= np.linalg.norm(d, axis=1)[:, None] + 1e-12
          t = np.linspace(0.0, 1.0, points)
          pts = P[:, None, :] + d[:, None, :] * (L[:, None, None] * t[None, :, None])
          if droop:
              pts[:, :, 2] -= droop * L[:, None] * t[None, :] ** 2
          inv = np.array(hair.matrix_world.inverted(), dtype=np.float64)
          loc = pts.reshape(-1, 3) @ inv[:3, :3].T + inv[:3, 3]
          cv = hair.data
          old = len(cv.points)
          cv.add_curves([points] * n)
          buf = np.empty(len(cv.points) * 3, np.float32)
          cv.attributes["position"].data.foreach_get("vector", buf)
          buf[old * 3:] = loc.ravel()
          cv.attributes["position"].data.foreach_set("vector", buf)
          cv.update_tag()
          if snap:
              snap_to_surface(hair, "NEAREST")
          return n
      
      
      def snap_to_surface(hair, mode="NEAREST"):
          """NEAREST: move each curve so its root sits on the closest surface point and store
          the attachment UV. DEFORM: re-place curves from their stored UV after the surface
          was edited (Simon: Snap to Deformed Surface)."""
          _object_mode()
          with _ctx_obj(hair):
              return bpy.ops.curves.snap_curves_to_surface(attach_mode=mode)
      
      
      def subdivide_guides(hair, cuts=1):
          """Add points to every guide (Edit Mode curves.subdivide, works headless)."""
          _object_mode()
          with _ctx_obj(hair):
              bpy.ops.object.mode_set(mode="EDIT")
              bpy.ops.curves.select_all(action="SELECT")
              bpy.ops.curves.subdivide(number_cuts=cuts)
              bpy.ops.object.mode_set(mode="OBJECT")
          return len(hair.data.points) // max(1, len(hair.data.curves))
      
      
      def resample_guides(hair, count=15):
          """Bystedt: resample guides to a fixed point count, then apply (bakes the new points)."""
          ng = bpy.data.node_groups.get("BX Resample Curves") or _resample_group()
          md = hair.modifiers.new("BX Resample", "NODES")
          md.node_group = ng
          set_inputs(md, {"Count": int(count)})
          with _ctx_obj(hair):
              bpy.ops.object.modifier_move_to_index(modifier=md.name, index=0)
              bpy.ops.object.modifier_apply(modifier=md.name)
          return len(hair.data.points) // max(1, len(hair.data.curves))
      
      
      def _resample_group():
          ng = bpy.data.node_groups.new("BX Resample Curves", "GeometryNodeTree")
          ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
          s = ng.interface.new_socket("Count", in_out="INPUT", socket_type="NodeSocketInt")
          s.default_value, s.min_value = 15, 2
          ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
          gi, go = ng.nodes.new("NodeGroupInput"), ng.nodes.new("NodeGroupOutput")
          rs = ng.nodes.new("GeometryNodeResampleCurve")
          ng.links.new(gi.outputs["Geometry"], rs.inputs["Curve"])
          ng.links.new(gi.outputs["Count"], rs.inputs["Count"])
          ng.links.new(rs.outputs["Curve"], go.inputs["Geometry"])
          return ng
      
      
      # ----------------------------------------------------------------------------- assets
      def add_asset(hair, name, label=None, index=None):
          """Add an Essentials hair (or dynamics) node-group asset as a modifier. New modifiers
          land above the pinned Hair Dynamics. Returns the modifier."""
          _object_mode()
          lib = DYN_LIB if name in DYNAMICS_ASSETS else HAIR_LIB
          with _ctx_obj(hair):
              bpy.ops.object.modifier_add_node_group(
                  asset_library_type="ESSENTIALS", asset_library_identifier="",
                  relative_asset_identifier=f"{lib}/NodeTree/{name}")
          md = hair.modifiers.active
          if label:
              md.name = label
          if index is not None:
              move(hair, md, index)
          return md
      
      
      def move(hair, md, index):
          with _ctx_obj(hair):
              bpy.ops.object.modifier_move_to_index(modifier=md.name, index=index)
      
      
      def _input_items(md):
          out = {}
          for it in md.node_group.interface.items_tree:
              if it.item_type == "SOCKET" and it.in_out == "INPUT" and it.socket_type != "NodeSocketGeometry":
                  out.setdefault(it.name, []).append(it)
          return out
      
      
      def _resolve(md, key, value):
          ins = md.properties.inputs
          if key.startswith(("Input_", "Socket_")) and hasattr(ins, key):
              return key
          items = _input_items(md).get(key)
          if not items:
              raise KeyError(f"{md.name}: no input '{key}'. Inputs: {sorted(_input_items(md))}")
          if len(items) > 1:  # e.g. Displace/Shrinkwrap 'Surface' exists as Object and Geometry
              want = "NodeSocketObject" if isinstance(value, bpy.types.Object) else None
              for it in items:
                  if (want and it.socket_type == want) or (not want and it.socket_type != "NodeSocketObject"):
                      return it.identifier
          return items[0].identifier
      
      
      def set_inputs(md, values):
          """Set modifier inputs by their UI name (or Input_/Socket_ identifier).
          "attr:NAME" binds the input to an attribute (a surface vertex group reaches the
          interpolated children as an attribute). Tags the object: in background the
          evaluated result stays stale without update_tag()."""
          ins = md.properties.inputs
          for key, val in values.items():
              p = getattr(ins, _resolve(md, key, val))
              if isinstance(val, str) and val.startswith("attr:"):
                  p.type = "ATTRIBUTE"
                  p.attribute_name = val[5:]
              else:
                  if hasattr(p, "type") and p.type == "ATTRIBUTE":
                      p.type = "VALUE"
                  p.value = val
          md.id_data.update_tag()
          return md
      
      
      def get_inputs(md):
          """{UI name: value} with 'attr:NAME' for attribute-bound inputs."""
          ins, out = md.properties.inputs, {}
          for name, items in _input_items(md).items():
              for it in items:
                  p = getattr(ins, it.identifier, None)
                  if p is None:
                      continue
                  if getattr(p, "type", "VALUE") == "ATTRIBUTE":
                      v = "attr:" + p.attribute_name
                  elif not hasattr(p, "value"):  # matrix / bundle sockets have no stored value
                      continue
                  else:
                      v = p.value
                      if isinstance(v, bpy.types.ID):
                          v = v.name
                      elif hasattr(v, "__len__") and not isinstance(v, str):
                          v = tuple(round(x, 5) for x in v)
                      elif isinstance(v, float):
                          v = round(v, 6)
                  out[name if len(items) == 1 else f"{name} ({it.socket_type[10:]})"] = v
          return out
      
      
      def build_stack(hair, spec, masks=True):
          """Add a list of (asset name, {inputs}) in order. With masks=True, the random curve
          masks referenced as attr:stray_mask / attr:frizz_mask are created first."""
          mods = []
          used = {v[5:] for _, vals in spec for v in vals.values() if isinstance(v, str) and v.startswith("attr:")}
          for name, vals in spec:
              md = add_asset(hair, name)
              set_inputs(md, vals)
              mods.append(md)
              if name == "Interpolate Hair Curves" and masks:
                  if "stray_mask" in used:
                      mods.append(random_curve_mask(hair, "stray_mask", threshold=0.9, hard=True, seed=3))
                  if "frizz_mask" in used:
                      mods.append(random_curve_mask(hair, "frizz_mask", threshold=0.8, seed=7))
          return mods
      
      
      # ----------------------------------------------------------------------------- per-curve fields
      def _attr_group(kind):
          name = {"random": "BX Random Curve Mask", "along": "BX Along Curve Ramp"}[kind]
          ng = bpy.data.node_groups.get(name)
          if ng:
              return ng
          ng = bpy.data.node_groups.new(name, "GeometryNodeTree")
          I = ng.interface
          I.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
          I.new_socket("Name", in_out="INPUT", socket_type="NodeSocketString")
          gi, go = ng.nodes.new("NodeGroupInput"), ng.nodes.new("NodeGroupOutput")
          st = ng.nodes.new("GeometryNodeStoreNamedAttribute")
          st.data_type = "FLOAT"
          if kind == "random":
              I.new_socket("Threshold", in_out="INPUT", socket_type="NodeSocketFloat").default_value = 0.8
              I.new_socket("Seed", in_out="INPUT", socket_type="NodeSocketInt")
              I.new_socket("Hard", in_out="INPUT", socket_type="NodeSocketBool")
              st.domain = "CURVE"
              rnd = ng.nodes.new("FunctionNodeRandomValue")
              rnd.data_type = "FLOAT"
              idn = ng.nodes.new("GeometryNodeInputID")
              ng.links.new(idn.outputs["ID"], rnd.inputs["ID"])
              ng.links.new(gi.outputs["Seed"], rnd.inputs["Seed"])
              mr = ng.nodes.new("ShaderNodeMapRange")          # soft: 0 below threshold, ramps to 1
              ng.links.new(rnd.outputs["Value"], mr.inputs["Value"])
              ng.links.new(gi.outputs["Threshold"], mr.inputs["From Min"])
              cmp = ng.nodes.new("FunctionNodeCompare")        # hard: 1 above threshold
              cmp.data_type, cmp.operation = "FLOAT", "GREATER_THAN"
              ng.links.new(rnd.outputs["Value"], cmp.inputs[0])
              ng.links.new(gi.outputs["Threshold"], cmp.inputs[1])
              sw = ng.nodes.new("GeometryNodeSwitch")
              sw.input_type = "FLOAT"
              ng.links.new(gi.outputs["Hard"], sw.inputs["Switch"])
              ng.links.new(mr.outputs["Result"], sw.inputs["False"])
              ng.links.new(cmp.outputs[0], sw.inputs["True"])
              val = sw.outputs["Output"]
          else:
              I.new_socket("Root", in_out="INPUT", socket_type="NodeSocketFloat").default_value = 0.0
              I.new_socket("Tip", in_out="INPUT", socket_type="NodeSocketFloat").default_value = 1.0
              st.domain = "POINT"
              sp = ng.nodes.new("GeometryNodeSplineParameter")
              mr = ng.nodes.new("ShaderNodeMapRange")
              ng.links.new(sp.outputs["Factor"], mr.inputs["Value"])
              ng.links.new(gi.outputs["Root"], mr.inputs["To Min"])
              ng.links.new(gi.outputs["Tip"], mr.inputs["To Max"])
              val = mr.outputs["Result"]
          I.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
          ng.links.new(gi.outputs["Geometry"], st.inputs["Geometry"])
          ng.links.new(gi.outputs["Name"], st.inputs["Name"])
          ng.links.new(val, st.inputs["Value"])
          ng.links.new(st.outputs["Geometry"], go.inputs["Geometry"])
          return ng
      
      
      def random_curve_mask(hair, attr, threshold=0.8, seed=0, hard=False, after="Interpolate Hair Curves"):
          """Store a per-CURVE random float attribute to drive an asset input with "attr:<attr>".
          Simon: randomize per curve, not per point. Soft: 0 for `threshold` of the curves,
          ramping to 1 (frizz on ~20% of strands). Hard: 1 on (1 - threshold) of the curves
          (stray hairs: threshold 0.9). Placed right after `after` (the children exist there)."""
          md = hair.modifiers.new(f"BX {attr}", "NODES")
          md.node_group = _attr_group("random")
          set_inputs(md, {"Name": attr, "Threshold": float(threshold), "Seed": int(seed), "Hard": bool(hard)})
          _place_after(hair, md, after)
          return md
      
      
      def along_curve_attr(hair, attr, root=0.0, tip=1.0, after="Interpolate Hair Curves"):
          """Store a per-POINT root-to-tip ramp (Simon: curl frequency rises toward the tip)."""
          md = hair.modifiers.new(f"BX {attr}", "NODES")
          md.node_group = _attr_group("along")
          set_inputs(md, {"Name": attr, "Root": float(root), "Tip": float(tip)})
          _place_after(hair, md, after)
          return md
      
      
      def _place_after(hair, md, after):
          names = [m.name for m in hair.modifiers]
          idx = next((i for i, n in enumerate(names) if after and n.startswith(after)), None)
          if idx is not None:
              move(hair, md, idx + 1)
          elif "Hair Dynamics" in names:
              move(hair, md, max(0, names.index("Hair Dynamics")))
      
      
      def distance_weights(surface, group, source, radius, profile="bump"):
          """Write vertex group `group` from the distance to `source` vertices (a vertex group
          name or index list). profile 'bump': 0 on the line, 1 at radius/2, 0 at radius
          (Bystedt's parting lift curve); 'falloff': 1 on the line to 0 at radius.
          Use it as an asset Factor ("attr:<group>") after Interpolate."""
          me = surface.data
          if isinstance(source, str):
              w = group_weights(surface, source)
              src = np.nonzero(w > 0.5)[0]
          else:
              src = np.asarray(source)
          kd = kdtree.KDTree(len(src))
          for i in src:
              kd.insert(me.vertices[int(i)].co, int(i))
          kd.balance()
          vg = surface.vertex_groups.get(group) or surface.vertex_groups.new(name=group)
          for v in me.vertices:
              _, _, d = kd.find(v.co)
              x = min(1.0, d / radius)
              wv = math.sin(math.pi * x) if profile == "bump" else 1.0 - x * x * (3 - 2 * x)
              if x >= 1.0:
                  wv = 0.0
              vg.add([v.index], float(wv), "REPLACE")
          return vg
      
      
      # ----------------------------------------------------------------------------- measure
      def count(hair, full=True):
          """Evaluated curve/point counts. full=True evaluates with every 'Viewport Amount'
          at 1.0 (render density): background evaluation uses the viewport amount."""
          saved = []
          if full:
              for md in hair.modifiers:
                  if md.type == "NODES" and md.node_group and "Viewport Amount" in _input_items(md):
                      p = getattr(md.properties.inputs, _resolve(md, "Viewport Amount", 1.0))
                      if getattr(p, "type", "VALUE") == "VALUE":
                          saved.append((p, p.value))
                          p.value = 1.0
          hair.update_tag()
          ev = hair.evaluated_get(bpy.context.evaluated_depsgraph_get()).data
          res = {"curves": len(ev.curves), "points": len(ev.points)}
          for p, v in saved:
              p.value = v
          hair.update_tag()
          return res
      
      
      def _curve_arrays(cv):
          n = len(cv.curves)
          off = np.empty(n + 1, np.int32)
          cv.curve_offset_data.foreach_get("value", off)
          pos = np.empty(len(cv.points) * 3, np.float32)
          cv.attributes["position"].data.foreach_get("vector", pos)
          return off, pos.reshape(-1, 3)
      
      
      def uv_report(surface):
          """UV health of the growth surface: hair binds to UV space, so overlapping
          (stacked) UVs make curves ignore deformation (Kerstin). UDIM tiles are fine."""
          me = surface.data
          rep = {"uv_maps": [u.name for u in me.uv_layers], "active": me.uv_layers.active.name if me.uv_layers else None}
          if not me.uv_layers:
              rep["overlap_faces"] = None
              return rep
          uv = np.empty(len(me.loops) * 2, np.float32)
          me.uv_layers.active.data.foreach_get("uv", uv)
          uv = uv.reshape(-1, 2)
          rep["uv_outside_0_1"] = int(((uv < -1e-4) | (uv > 1 + 1e-4)).any(1).sum())
          view_layer = bpy.context.view_layer
          prev_active, prev_sel = view_layer.objects.active, list(bpy.context.selected_objects)
          _object_mode()
          for o in prev_sel:
              o.select_set(False)
          surface.select_set(True)
          view_layer.objects.active = surface
          ts = bpy.context.scene.tool_settings
          sync = ts.use_uv_select_sync
          ts.use_uv_select_sync = False
          import bmesh
          bpy.ops.object.mode_set(mode="EDIT")
          try:
              bpy.ops.mesh.select_all(action="SELECT")
              bpy.ops.uv.select_all(action="DESELECT")
              bpy.ops.uv.select_overlap()
              bm = bmesh.from_edit_mesh(me)
              rep["overlap_faces"] = sum(1 for f in bm.faces if all(l.uv_select_vert for l in f.loops))
          finally:
              bpy.ops.object.mode_set(mode="OBJECT")
              ts.use_uv_select_sync = sync
              surface.select_set(False)
              for o in prev_sel:
                  o.select_set(True)
              view_layer.objects.active = prev_active
          rep["faces"] = len(me.polygons)
          return rep
      
      
      def groom_report(hair, k=6):
          """Objective groom checks (guides, attachment, spacing, length dents, stack order,
          evaluated counts, radius vs density, render settings, shader vs engine)."""
          cv, rep = hair.data, {"hair": hair.name}
          surf = cv.surface
          rep["surface"] = surf.name if surf else None
          rep["surface_uv_map"] = cv.surface_uv_map
          rep["uv_map_on_surface"] = bool(surf and cv.surface_uv_map in surf.data.uv_layers)
          n = len(cv.curves)
          rep["guides"] = n
          rep["has_attachment_uv"] = "surface_uv_coordinate" in cv.attributes
          if n:
              off, pos = _curve_arrays(cv)
              ppc = np.diff(off)
              rep["points_per_guide"] = [int(ppc.min()), int(np.median(ppc)), int(ppc.max())]
              M = np.array(hair.matrix_world, dtype=np.float64)
              pw = pos @ M[:3, :3].T + M[:3, 3]
              roots = pw[off[:-1]]
              seg = np.linalg.norm(np.diff(pw, axis=0), axis=1)
              csum = np.concatenate([[0.0], np.cumsum(seg)])
              lengths = csum[off[1:] - 1] - csum[off[:-1]]
              rep["guide_length"] = [round(float(lengths.min()), 5), round(float(np.median(lengths)), 5),
                                     round(float(lengths.max()), 5)]
              if surf:
                  SI = np.array(surf.matrix_world.inverted(), dtype=np.float64)
                  SM = surf.matrix_world
                  dists = []
                  for r in roots:
                      lr = Vector(SI[:3, :3] @ r + SI[:3, 3])
                      ok, loc, _nrm, _ = surf.closest_point_on_mesh(lr)
                      dists.append(((SM @ loc) - Vector(r)).length if ok else float("inf"))
                  dists = np.array(dists)
                  tol = max(1e-5, 0.02 * float(np.median(lengths)))
                  rep["roots_off_surface"] = int((dists > tol).sum())
                  rep["root_dist_max"] = round(float(dists.max()), 6)
              if n >= 3:
                  kd = kdtree.KDTree(n)
                  for i, r in enumerate(roots):
                      kd.insert(r, i)
                  kd.balance()
                  nn, ratio = [], []
                  kk = min(k + 1, n)
                  for i, r in enumerate(roots):
                      found = kd.find_n(r, kk)
                      nn.append(found[1][2])
                      neigh = [f[1] for f in found[1:]]
                      ratio.append(lengths[i] / (np.mean(lengths[neigh]) + 1e-12))
                  nn, ratio = np.array(nn), np.array(ratio)
                  med = float(np.median(nn))
                  rep["spacing_nn"] = [round(med, 5), round(float(nn.max()), 5)]
                  rep["spacing_gaps_2x"] = int((nn > 2 * med).sum())
                  rep["length_outliers"] = int((np.abs(ratio - 1) > 0.5).sum())
              if rep["has_attachment_uv"]:
                  a = cv.attributes["surface_uv_coordinate"]
                  uvs = np.empty(len(a.data) * 2, np.float32)
                  a.data.foreach_get("vector", uvs)
                  rep["attachment_uv_nan"] = int(np.isnan(uvs).sum())
          # stack
          mods = [(m.name, m.node_group.name if m.type == "NODES" and m.node_group else m.type,
                   m.show_viewport, m.show_render) for m in hair.modifiers]
          rep["modifiers"] = [m[0] for m in mods]
          groups = [m[1] for m in mods]
          notes = []
          if "Hair Dynamics" in groups and groups[-1] != "Hair Dynamics":
              notes.append("Hair Dynamics is not last: modifiers after it do not follow the deforming surface")
          if "Interpolate Hair Curves" in groups:
              ii = groups.index("Interpolate Hair Curves")
              pre = [g for g in groups[:ii] if g in DEFORMERS]
              if pre:
                  notes.append(f"before Interpolate (shape guides only, children inherit): {pre}")
              md = hair.modifiers[mods[ii][0]]
              vals = get_inputs(md)
              rep["density"] = vals.get("Density")
              rep["density_mask"] = vals.get("Density Mask")
              rep["viewport_amount"] = vals.get("Viewport Amount")
          for m in hair.modifiers:
              if m.type == "NODES" and m.node_group and m.node_group.name == "Trim Hair Curves":
                  if get_inputs(m).get("Replace Length") is True:
                      notes.append(f"{m.name}: Replace Length on (replaces every length with 'Length', 1 m default)")
              if m.type == "NODES" and m.node_group and m.node_group.name in ("Frizz Hair Curves", "Hair Curves Noise"):
                  f = get_inputs(m).get("Factor")
                  if f == 1.0:
                      notes.append(f"{m.name}: Factor 1.0 unmasked over the whole groom")
          rep["stack_notes"] = notes
          # evaluated
          try:
              rep["evaluated_viewport"] = count(hair, full=False)
              rep["evaluated_full"] = count(hair, full=True)
              ev = hair.evaluated_get(bpy.context.evaluated_depsgraph_get()).data
              rep["evaluated_attrs"] = sorted(a.name for a in ev.attributes if not a.name.startswith("."))
              if "radius" in ev.attributes and len(ev.points):
                  r = np.empty(len(ev.points), np.float32)
                  ev.attributes["radius"].data.foreach_get("value", r)
                  rep["radius_max"] = round(float(r.max()), 6)
                  dens = rep.get("density")
                  if isinstance(dens, (int, float)):
                      rep["root_area_fraction"] = round(float(dens * math.pi * r.max() ** 2), 4)
          except Exception as e:  # never let a report crash the caller
              rep["evaluated_error"] = str(e)
          sc = bpy.context.scene
          rep["engine"] = sc.render.engine
          rep["hair_type"], rep["hair_subdiv"] = sc.render.hair_type, sc.render.hair_subdiv
          rep["cycles_curve_shape"] = sc.cycles_curves.shape
          shaders = set()
          for slot in hair.material_slots:
              if slot.material and slot.material.node_tree:
                  shaders |= {n.bl_idname for n in slot.material.node_tree.nodes if "Bsdf" in n.bl_idname}
          rep["shaders"] = sorted(shaders)
          return rep
      
      
      def verdict(rep):
          """Problems worth fixing, as plain sentences (empty list = nothing flagged)."""
          out = []
          if not rep.get("surface"):
              out.append("no attachment surface")
          if rep.get("surface") and not rep.get("uv_map_on_surface"):
              out.append("surface_uv_map not found on the surface mesh")
          if rep.get("guides") and not rep.get("has_attachment_uv"):
              out.append("guides have no surface_uv_coordinate: run snap_to_surface(hair)")
          if rep.get("roots_off_surface"):
              out.append(f"{rep['roots_off_surface']} guide roots off the surface (snap NEAREST, or DEFORM after surface edits)")
          if rep.get("spacing_gaps_2x"):
              out.append(f"{rep['spacing_gaps_2x']} guides with a neighbour gap > 2x median spacing (bald-spot risk)")
          if rep.get("length_outliers"):
              out.append(f"{rep['length_outliers']} guides differ > 50% from neighbour lengths (renders as a dent)")
          if rep.get("attachment_uv_nan"):
              out.append("invalid attachment UVs (NaN)")
          out += rep.get("stack_notes", [])
          if rep.get("guides") and "Interpolate Hair Curves" not in " ".join(rep.get("modifiers", [])):
              out.append("no Interpolate Hair Curves: only guides render")
          if rep.get("density_mask") in (None, 1.0) and rep.get("density") is not None:
              out.append("Interpolate has no Density Mask: children spawn over the whole surface")
          if rep.get("root_area_fraction", 0) > 0.5:
              out.append(f"radius too thick for the density (root area fraction {rep['root_area_fraction']}): solid-mass look")
          if rep.get("hair_type") == "STRAND" and rep.get("engine") != "CYCLES":
              out.append("render hair shape is Strand: radius changes invisible (use STRIP + hair_subdiv >= 1)")
          if rep.get("engine") == "BLENDER_EEVEE" and "ShaderNodeBsdfHairPrincipled" in rep.get("shaders", []):
              out.append("Principled Hair BSDF under EEVEE renders dark and flat: use Principled BSDF with explicit color")
          return out
      
      
      # ----------------------------------------------------------------------------- shading
      def hair_material(name="Hair", engine="CYCLES", root_color=(0.05, 0.025, 0.012), tip_color=(0.35, 0.17, 0.07),
                        melanin=None, roughness=(0.25, 0.4), random_color=0.1, uv_noise=0.0):
          """Strand material with a root-to-tip gradient (Curves Info Intercept) and per-strand
          roughness variation (Curves Info Random). engine CYCLES: Principled Hair BSDF (Chiang);
          melanin=(root, tip) switches to melanin parametrization. engine EEVEE: Principled BSDF
          with explicit colors (Simon: Principled Hair BSDF is a Cycles shader).
          uv_noise > 0 adds Simon's surface-UV noise (Attribute surface_uv_coordinate)."""
          mat = bpy.data.materials.new(name)
          nt = mat.node_tree
          for n in list(nt.nodes):
              if n.type != "OUTPUT_MATERIAL":
                  nt.nodes.remove(n)
          out = next(n for n in nt.nodes if n.type == "OUTPUT_MATERIAL")
          info = nt.nodes.new("ShaderNodeHairInfo")
          rough = nt.nodes.new("ShaderNodeMapRange")
          nt.links.new(info.outputs["Random"], rough.inputs["Value"])
          rough.inputs["To Min"].default_value, rough.inputs["To Max"].default_value = roughness
          col = nt.nodes.new("ShaderNodeMix")
          col.data_type = "RGBA"
          _s(col.inputs, "A_Color").default_value = (*root_color, 1.0)
          _s(col.inputs, "B_Color").default_value = (*tip_color, 1.0)
          nt.links.new(info.outputs["Intercept"], _s(col.inputs, "Factor_Float"))
          color_out = _s(col.outputs, "Result_Color")
          if uv_noise > 0:
              attr = nt.nodes.new("ShaderNodeAttribute")
              attr.attribute_name = "surface_uv_coordinate"
              noise = nt.nodes.new("ShaderNodeTexNoise")
              noise.inputs["Scale"].default_value = 8.0
              nt.links.new(attr.outputs["Vector"], noise.inputs["Vector"])
              soft = nt.nodes.new("ShaderNodeMix")
              soft.data_type, soft.blend_type = "RGBA", "SOFT_LIGHT"
              _s(soft.inputs, "Factor_Float").default_value = uv_noise
              nt.links.new(color_out, _s(soft.inputs, "A_Color"))
              nt.links.new(noise.outputs["Color"], _s(soft.inputs, "B_Color"))
              color_out = _s(soft.outputs, "Result_Color")
          if engine == "CYCLES":
              b = nt.nodes.new("ShaderNodeBsdfHairPrincipled")
              b.model = "CHIANG"
              if melanin is not None:
                  b.parametrization = "MELANIN"
                  mm = nt.nodes.new("ShaderNodeMapRange")
                  nt.links.new(info.outputs["Intercept"], mm.inputs["Value"])
                  mm.inputs["To Min"].default_value, mm.inputs["To Max"].default_value = melanin
                  nt.links.new(mm.outputs["Result"], b.inputs["Melanin"])
                  b.inputs["Random Color"].default_value = random_color
              else:
                  b.parametrization = "COLOR"
                  nt.links.new(color_out, b.inputs["Color"])
              nt.links.new(rough.outputs["Result"], b.inputs["Roughness"])
          else:
              b = nt.nodes.new("ShaderNodeBsdfPrincipled")
              nt.links.new(color_out, b.inputs["Base Color"])
              nt.links.new(rough.outputs["Result"], b.inputs["Roughness"])
          nt.links.new(b.outputs[0], out.inputs["Surface"])
          return mat
      
      
      def assign_material(obj, mat):
          obj.data.materials.clear()
          obj.data.materials.append(mat)
      
      
      def setup_render(scene=None, engine="CYCLES", close_up=False):
          """Strip display with subdivisions (radius visible in EEVEE/viewport); Cycles curve
          shape THICK for close-ups [added], RIBBONS (default, faster) otherwise."""
          sc = scene or bpy.context.scene
          sc.render.engine = "CYCLES" if engine == "CYCLES" else "BLENDER_EEVEE"
          sc.render.hair_type = "STRIP"
          sc.render.hair_subdiv = 2 if close_up else 1
          sc.cycles_curves.shape = "THICK" if close_up else "RIBBONS"
          return sc
      
      
      # ----------------------------------------------------------------------------- review
      def eval_bbox(objs):
          """World bbox of what renders: evaluated curve points and any mesh a node tree
          outputs (bound_box of a curves object misses generated card meshes)."""
          for o in objs:
              o.update_tag()
          dg = bpy.context.evaluated_depsgraph_get()
          pts = []
          for o in objs:
              eo = o.evaluated_get(dg)
              M = np.array(eo.matrix_world, dtype=np.float64)
              local = []
              if o.type == "CURVES" and len(eo.data.points):
                  p = np.empty(len(eo.data.points) * 3, np.float32)
                  eo.data.attributes["position"].data.foreach_get("vector", p)
                  local.append(p.reshape(-1, 3))
              try:
                  gs = eo.evaluated_geometry()
                  me = gs.mesh
                  if me is not None and len(me.vertices):
                      p = np.empty(len(me.vertices) * 3, np.float32)
                      me.vertices.foreach_get("co", p)
                      local.append(p.reshape(-1, 3))
              except Exception:
                  pass
              if not local:
                  local.append(np.array([list(c) for c in eo.bound_box], np.float32))
              allp = np.concatenate(local)
              pts.append(allp @ M[:3, :3].T + M[:3, 3])
          allw = np.concatenate(pts)
          return Vector(allw.min(0)), Vector(allw.max(0))
      
      
      def _review_mod():
          here = os.path.dirname(os.path.abspath(__file__))
          p = os.path.normpath(os.path.join(here, "..", "..", "scenario-blender-expert", "scripts"))
          if p not in sys.path:
              sys.path.append(p)
          import bx_review
          return bx_review
      
      
      def review(objs, out_dir, engine="CYCLES", views=("front", "threequarter", "right"), res=512,
                 samples=32, close_up=False, name="hair_review", zoom=1.0, frame=None):
          """Lit render sheet of a groom in a temporary scene (user scene untouched).
          Simon's review rig: warm fill from the top, small key from the side, cool rim from
          the other side, darker bluish world; square camera. Renders at full density
          (render depsgraph ignores Viewport Amount). Renders the current frame of the active
          scene unless `frame` is given (pose checks). Returns the sheet path."""
          R = _review_mod()
          os.makedirs(out_dir, exist_ok=True)
          src_frame = bpy.context.scene.frame_current if frame is None else frame
          sc = bpy.data.scenes.new("BX_HairReview")
          for o in objs:
              sc.collection.objects.link(o)
          sc.frame_set(src_frame)
          setup_render(sc, engine, close_up)
          sc.render.resolution_x = sc.render.resolution_y = res
          sc.render.image_settings.media_type = "IMAGE"
          sc.render.image_settings.file_format = "PNG"
          if engine == "CYCLES":
              sc.cycles.samples = samples
              sc.cycles.device = "CPU"
          world = bpy.data.worlds.new("BX_HairReview_World")
          world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.03, 0.04, 0.06, 1)
          world.node_tree.nodes["Background"].inputs["Strength"].default_value = 1.0
          sc.world = world
          lo, hi = eval_bbox(objs)
          center, size = (lo + hi) / 2, max((hi - lo).length, 1e-3)
          made = []
      
          def light(name, kind, energy, color, offset, sz):
              ld = bpy.data.lights.new(name, kind)
              ld.energy, ld.color = energy, color
              if kind == "AREA":
                  ld.size = sz
              ob = bpy.data.objects.new(name, ld)
              sc.collection.objects.link(ob)
              ob.location = center + offset
              R._look_at(ob, center, offset.normalized(), offset.length)
              made.append((ob, ld))
          d = size * 1.5
          k = d * d  # keep irradiance constant with scale
          light("BX_Fill_Top", "AREA", 35 * k, (1.0, 0.85, 0.7), Vector((0, 0, d)), size)
          light("BX_Key_Side", "AREA", 90 * k, (1.0, 0.95, 0.9), Vector((-d, -d * 0.6, d * 0.3)), size * 0.25)
          light("BX_Rim_Cool", "AREA", 120 * k, (0.6, 0.75, 1.0), Vector((d * 0.9, d, d * 0.4)), size * 0.5)
          cam_data = bpy.data.cameras.new("BX_HairReview_Cam")
          cam = bpy.data.objects.new("BX_HairReview_Cam", cam_data)
          sc.collection.objects.link(cam)
          sc.camera = cam
          corners = [Vector((x, y, z)) for x in (lo.x, hi.x) for y in (lo.y, hi.y) for z in (lo.z, hi.z)]
          paths, labels = [], []
          try:
              for view in views:
                  dvec = R.VIEW_DIRS[view]
                  half, depth = R._extent(corners, center, dvec)
                  cam_data.type, cam_data.lens = "PERSP", 50
                  fov = 2 * math.atan(cam_data.sensor_width / (2 * cam_data.lens))
                  dist = (half * 1.1 / math.tan(fov / 2) + depth * 0.6) / zoom
                  cam_data.clip_start, cam_data.clip_end = dist * 0.01, dist * 10
                  R._look_at(cam, center, dvec, dist)
                  p = os.path.join(out_dir, f"{name}_{engine.lower()}_{view}.png")
                  sc.render.filepath = p
                  bpy.ops.render.render(write_still=True, scene=sc.name)
                  paths.append(p)
                  labels.append(f"{engine} / {view}")
              sheet = R._tile(paths, 1, len(paths), os.path.join(out_dir, f"{name}_{engine.lower()}.png"), labels)
          finally:
              for ob, ld in made:
                  bpy.data.objects.remove(ob)
                  bpy.data.lights.remove(ld)
              bpy.data.objects.remove(cam)
              bpy.data.cameras.remove(cam_data)
              bpy.data.scenes.remove(sc)
              bpy.data.worlds.remove(world)
          return sheet
      
      
      # ----------------------------------------------------------------------------- cards
      def cards_group():
          """GN group 'BX Hair Cards': hair curves -> card or tube mesh with UVs (Sara
          Matsumoto's BCON24 layout, Bystedt's surface-aligned tilt and root snap).
          Resample -> curve normal from the surface normal (card lies on the scalp) ->
          Curve to Mesh with a bent arc (cards) or a flat 1-turn spiral (tubes) ->
          UVMap (U across the profile, halved on cards; V root 0 to tip 1) -> material ->
          roots snapped onto the surface."""
          ng = bpy.data.node_groups.get("BX Hair Cards")
          if ng:
              return ng
          ng = bpy.data.node_groups.new("BX Hair Cards", "GeometryNodeTree")
          I = ng.interface
          I.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
          I.new_socket("Surface", in_out="INPUT", socket_type="NodeSocketObject")
          s = I.new_socket("Width", in_out="INPUT", socket_type="NodeSocketFloat"); s.default_value = 0.01
          I.new_socket("Tubes", in_out="INPUT", socket_type="NodeSocketBool")
          s = I.new_socket("Card Segments", in_out="INPUT", socket_type="NodeSocketInt"); s.default_value, s.min_value = 3, 1
          s = I.new_socket("Bend", in_out="INPUT", socket_type="NodeSocketFloat"); s.default_value = math.radians(90)
          s = I.new_socket("Resample", in_out="INPUT", socket_type="NodeSocketInt"); s.default_value, s.min_value = 12, 2
          s = I.new_socket("Root Scale", in_out="INPUT", socket_type="NodeSocketFloat"); s.default_value = 1.0
          s = I.new_socket("Tip Scale", in_out="INPUT", socket_type="NodeSocketFloat"); s.default_value = 0.3
          I.new_socket("Material", in_out="INPUT", socket_type="NodeSocketMaterial")
          s = I.new_socket("Snap Roots", in_out="INPUT", socket_type="NodeSocketBool"); s.default_value = True
          I.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
          N, L = ng.nodes, ng.links
          gi, go = N.new("NodeGroupInput"), N.new("NodeGroupOutput")
          rs = N.new("GeometryNodeResampleCurve")
          L.new(gi.outputs["Geometry"], rs.inputs["Curve"])
          L.new(gi.outputs["Resample"], rs.inputs["Count"])
          oi = N.new("GeometryNodeObjectInfo"); oi.transform_space = "RELATIVE"
          L.new(gi.outputs["Surface"], oi.inputs["Object"])
          nrm = N.new("GeometryNodeInputNormal")
          sns = N.new("GeometryNodeSampleNearestSurface"); sns.data_type = "FLOAT_VECTOR"
          L.new(oi.outputs["Geometry"], sns.inputs["Mesh"])
          L.new(nrm.outputs["Normal"], sns.inputs["Value"])
          scn = N.new("GeometryNodeSetCurveNormal")
          scn.inputs["Mode"].default_value = "Free"
          L.new(rs.outputs["Curve"], scn.inputs["Curve"])
          L.new(sns.outputs["Value"], scn.inputs["Normal"])
          # capture length parameter on the hair curve
          sp_c = N.new("GeometryNodeSplineParameter")
          cap_c = N.new("GeometryNodeCaptureAttribute"); cap_c.domain = "POINT"
          cap_c.capture_items.new("FLOAT", "V")
          L.new(scn.outputs["Curve"], cap_c.inputs["Geometry"])
          L.new(sp_c.outputs["Factor"], _s(cap_c.inputs, "V"))
          # taper (Curve to Mesh Scale, 4.5+) root->tip
          taper = N.new("ShaderNodeMapRange")
          L.new(sp_c.outputs["Factor"], taper.inputs["Value"])
          L.new(gi.outputs["Root Scale"], taper.inputs["To Min"])
          L.new(gi.outputs["Tip Scale"], taper.inputs["To Max"])
          half_w = N.new("ShaderNodeMath"); half_w.operation = "MULTIPLY"; half_w.inputs[1].default_value = 0.5
          L.new(gi.outputs["Width"], half_w.inputs[0])
          # card profile: arc of angle Bend centred on +X (curve normal = surface normal), shifted so
          # the apex sits on the curve and the edges hang toward the scalp
          arc = N.new("GeometryNodeCurveArc")              # Resolution = points, so segments + 1
          seg1 = N.new("ShaderNodeMath"); seg1.operation = "ADD"; seg1.inputs[1].default_value = 1.0
          L.new(gi.outputs["Card Segments"], seg1.inputs[0])
          L.new(seg1.outputs[0], arc.inputs["Resolution"])
          sin_h = N.new("ShaderNodeMath"); sin_h.operation = "MULTIPLY"; sin_h.inputs[1].default_value = 0.5
          L.new(gi.outputs["Bend"], sin_h.inputs[0])
          sin = N.new("ShaderNodeMath"); sin.operation = "SINE"
          L.new(sin_h.outputs[0], sin.inputs[0])
          rad = N.new("ShaderNodeMath"); rad.operation = "DIVIDE"
          L.new(half_w.outputs[0], rad.inputs[0]); L.new(sin.outputs[0], rad.inputs[1])
          L.new(rad.outputs[0], arc.inputs["Radius"])
          neg = N.new("ShaderNodeMath"); neg.operation = "MULTIPLY"; neg.inputs[1].default_value = -1.0
          L.new(sin_h.outputs[0], neg.inputs[0])
          L.new(neg.outputs[0], arc.inputs["Start Angle"])
          L.new(gi.outputs["Bend"], arc.inputs["Sweep Angle"])
          shift = N.new("ShaderNodeCombineXYZ")
          nrad = N.new("ShaderNodeMath"); nrad.operation = "MULTIPLY"; nrad.inputs[1].default_value = -1.0
          L.new(rad.outputs[0], nrad.inputs[0]); L.new(nrad.outputs[0], shift.inputs["X"])
          tr = N.new("GeometryNodeTransform")
          L.new(arc.outputs["Curve"], tr.inputs["Geometry"])
          L.new(shift.outputs["Vector"], tr.inputs["Translation"])
          # tube profile: flat spiral, start = end radius, height 0, resolution 6, 1 rotation (Sara)
          spi = N.new("GeometryNodeCurveSpiral")
          spi.inputs["Resolution"].default_value = 6
          spi.inputs["Rotations"].default_value = 1.0
          spi.inputs["Height"].default_value = 0.0
          L.new(half_w.outputs[0], spi.inputs["Start Radius"]); L.new(half_w.outputs[0], spi.inputs["End Radius"])
          swp = N.new("GeometryNodeSwitch"); swp.input_type = "GEOMETRY"
          L.new(gi.outputs["Tubes"], swp.inputs["Switch"])
          L.new(tr.outputs["Geometry"], swp.inputs["False"]); L.new(spi.outputs["Curve"], swp.inputs["True"])
          sp_p = N.new("GeometryNodeSplineParameter")
          cap_p = N.new("GeometryNodeCaptureAttribute"); cap_p.domain = "POINT"
          cap_p.capture_items.new("FLOAT", "U")
          L.new(swp.outputs["Output"], cap_p.inputs["Geometry"])
          L.new(sp_p.outputs["Factor"], _s(cap_p.inputs, "U"))
          c2m = N.new("GeometryNodeCurveToMesh")
          L.new(cap_c.outputs["Geometry"], c2m.inputs["Curve"])
          L.new(cap_p.outputs["Geometry"], c2m.inputs["Profile Curve"])
          L.new(taper.outputs["Result"], c2m.inputs["Scale"])
          # UV: cards (3-ish sides) use half the texture width so density matches tubes (Sara)
          uscale = N.new("GeometryNodeSwitch"); uscale.input_type = "FLOAT"
          uscale.inputs["False"].default_value, uscale.inputs["True"].default_value = 0.5, 1.0
          L.new(gi.outputs["Tubes"], uscale.inputs["Switch"])
          umul = N.new("ShaderNodeMath"); umul.operation = "MULTIPLY"
          L.new(_s(cap_p.outputs, "U"), umul.inputs[0]); L.new(uscale.outputs["Output"], umul.inputs[1])
          uv = N.new("ShaderNodeCombineXYZ")
          L.new(umul.outputs[0], uv.inputs["X"]); L.new(_s(cap_c.outputs, "V"), uv.inputs["Y"])
          st = N.new("GeometryNodeStoreNamedAttribute"); st.data_type = "FLOAT2"; st.domain = "CORNER"
          st.inputs["Name"].default_value = "UVMap"
          L.new(c2m.outputs["Mesh"], st.inputs["Geometry"]); L.new(uv.outputs["Vector"], st.inputs["Value"])
          smooth = N.new("GeometryNodeSetShadeSmooth")
          L.new(st.outputs["Geometry"], smooth.inputs[0])
          sm = N.new("GeometryNodeSetMaterial")
          L.new(smooth.outputs[0], sm.inputs["Geometry"]); L.new(gi.outputs["Material"], sm.inputs["Material"])
          # root snap: vertices of the root row (V == 0) onto the closest surface point, last
          prox = N.new("GeometryNodeProximity"); prox.target_element = "FACES"
          L.new(oi.outputs["Geometry"], prox.inputs["Target"])
          cmp = N.new("FunctionNodeCompare"); cmp.data_type = "FLOAT"; cmp.operation = "LESS_THAN"
          cmp.inputs[1].default_value = 1e-4
          L.new(_s(cap_c.outputs, "V"), cmp.inputs[0])
          band = N.new("FunctionNodeBooleanMath"); band.operation = "AND"
          L.new(cmp.outputs[0], band.inputs[0]); L.new(gi.outputs["Snap Roots"], band.inputs[1])
          setp = N.new("GeometryNodeSetPosition")
          L.new(sm.outputs["Geometry"], setp.inputs["Geometry"])
          L.new(band.outputs[0], setp.inputs["Selection"])
          L.new(prox.outputs["Position"], setp.inputs["Position"])
          L.new(setp.outputs["Geometry"], go.inputs["Geometry"])
          return ng
      
      
      def add_cards(hair, surface=None, width=0.01, tubes=False, material=None, segments=3,
                    bend=math.radians(90), resample=12, root_scale=1.0, tip_scale=0.3, label="BX Hair Cards"):
          """Turn the groom into card (or tube) meshes at the end of the stack (before Hair
          Dynamics, so dynamics still act on curves: move it last if you want the cards to
          follow the simulated curves). Returns the modifier."""
          md = hair.modifiers.new(label, "NODES")
          md.node_group = cards_group()
          vals = {"Surface": surface or hair.data.surface, "Width": float(width), "Tubes": bool(tubes),
                  "Card Segments": int(segments), "Bend": float(bend), "Resample": int(resample),
                  "Root Scale": float(root_scale), "Tip Scale": float(tip_scale)}
          if material:
              vals["Material"] = material
          set_inputs(md, vals)
          names = [m.name for m in hair.modifiers]
          if "Hair Dynamics" in names:
              move(hair, md, names.index("Hair Dynamics"))
          return md
      
      
      def card_material(name="HairCard", root_color=(0.04, 0.02, 0.01), tip_color=(0.3, 0.15, 0.06),
                        strands=24.0, anisotropic=0.5, image=None):
          """Alpha card material on the 'UVMap' written by cards_group(). With `image` (a baked
          strand plate: color + alpha, Sara's workflow) it samples that; without, a procedural
          strand stand-in [added] (Wave bands across U, tip fade) for layout and silhouette review.
          Tangent from the UV map + Anisotropic stretches highlights along strands (Sara).
          EEVEE: Render Method Dithered (4.2+ replacement for Blend Mode)."""
          mat = bpy.data.materials.new(name)
          nt = mat.node_tree
          bsdf = nt.nodes["Principled BSDF"]
          uvn = nt.nodes.new("ShaderNodeUVMap")
          uvn.uv_map = "UVMap"
          sep = nt.nodes.new("ShaderNodeSeparateXYZ")
          nt.links.new(uvn.outputs["UV"], sep.inputs["Vector"])
          if image is not None:
              tex = nt.nodes.new("ShaderNodeTexImage")
              tex.image = image
              nt.links.new(uvn.outputs["UV"], tex.inputs["Vector"])
              nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
              nt.links.new(tex.outputs["Alpha"], bsdf.inputs["Alpha"])
          else:
              wave = nt.nodes.new("ShaderNodeTexWave")
              wave.wave_type, wave.bands_direction = "BANDS", "X"
              wave.inputs["Scale"].default_value = strands
              wave.inputs["Distortion"].default_value = 3.0
              nt.links.new(uvn.outputs["UV"], wave.inputs["Vector"])
              band = nt.nodes.new("ShaderNodeMapRange")
              band.inputs["From Min"].default_value, band.inputs["From Max"].default_value = 0.35, 0.65
              nt.links.new(wave.outputs["Fac"], band.inputs["Value"])
              fade = nt.nodes.new("ShaderNodeMapRange")            # 1 at the root row, 0 at the tip
              fade.inputs["From Min"].default_value, fade.inputs["From Max"].default_value = 0.6, 1.0
              fade.inputs["To Min"].default_value, fade.inputs["To Max"].default_value = 1.0, 0.0
              nt.links.new(sep.outputs["Y"], fade.inputs["Value"])
              alpha = nt.nodes.new("ShaderNodeMath")
              alpha.operation = "MULTIPLY"
              nt.links.new(band.outputs["Result"], alpha.inputs[0])
              nt.links.new(fade.outputs["Result"], alpha.inputs[1])
              nt.links.new(alpha.outputs[0], bsdf.inputs["Alpha"])
              col = nt.nodes.new("ShaderNodeMix")
              col.data_type = "RGBA"
              _s(col.inputs, "A_Color").default_value = (*root_color, 1.0)
              _s(col.inputs, "B_Color").default_value = (*tip_color, 1.0)
              nt.links.new(sep.outputs["Y"], _s(col.inputs, "Factor_Float"))
              nt.links.new(_s(col.outputs, "Result_Color"), bsdf.inputs["Base Color"])
          tan = nt.nodes.new("ShaderNodeTangent")
          tan.direction_type, tan.uv_map = "UV_MAP", "UVMap"
          nt.links.new(tan.outputs["Tangent"], bsdf.inputs["Tangent"])
          bsdf.inputs["Anisotropic"].default_value = anisotropic
          bsdf.inputs["Roughness"].default_value = 0.4
          mat.surface_render_method = "DITHERED"
          return mat
      
      
      def card_stats(hair):
          """Triangles, UV range and root distance of the evaluated card mesh."""
          hair.update_tag()
          dg = bpy.context.evaluated_depsgraph_get()   # evaluates pending updates
          eo = hair.evaluated_get(dg)
          gs = eo.evaluated_geometry()
          me = gs.mesh
          if me is None:
              return {"mesh": False}
          tri = sum(len(p.vertices) - 2 for p in me.polygons)
          out = {"mesh": True, "verts": len(me.vertices), "faces": len(me.polygons), "tris": tri}
          if "UVMap" in me.attributes:
              a = me.attributes["UVMap"]
              uv = np.empty(len(a.data) * 2, np.float32)
              a.data.foreach_get("vector", uv)
              uv = uv.reshape(-1, 2)
              out["uv_min"] = [round(float(x), 4) for x in uv.min(0)]
              out["uv_max"] = [round(float(x), 4) for x in uv.max(0)]
          return out
      
  • SKILL.md 18.3 KB
    ---
    name: scenario-blender-hair
    description: "Use when grooming hair or fur in Blender with hair curves, such as a character hairstyle, animal fur, procedural fur in geometry nodes, or hair cards and mesh hair for games. Also covers the Essentials hair node assets (Interpolate, Clump, Curl, Frizz, Noise, Trim), density masks, bald spots, parting lines, strand shading in Cycles or EEVEE, and slow grooms. Use it too when a hair script misbehaves headless, for example stale modifier inputs or curve sculpt brushes that do nothing."
    license: MIT
    ---
    
    # Hair and fur grooming (hair curves, Blender 5.2)
    
    Expert grooming means a few evenly spaced guides that set direction and silhouette. Stacked, masked procedural layers add density, clumping, frizz, length variation and strays, judged in a lit render at full density and in pose. Write guides as data (no curve brush stroke can be scripted in 5.2.1) and make every look decision through the Essentials hair node-group assets. 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)
    
    - **Guides get 2 points until every direction is right** (Schmidbauer). More points make combing tangle and clip. Use 3 for short fur, 8 only for long hair. Bystedt resamples long human hair to 15 points before shaping.
    - **Space guides evenly, and always mask Interpolate** (Schmidbauer). Children spawn over the whole surface, and a child far from any guide "does whatever". Gaps turn into bald spots that grow under deformation.
    - **Density is counted per square meter and must be huge. Radius drops with it** (Thommes). Long fur uses 1,000,000/m² at about 0.2 mm radius. Work at a Viewport Amount of 4%.
    - **Randomize per curve, not per point** (Thommes). Give 80% of strands no frizz. Strays are 10% of curves with strong noise.
    - **Trim with Replace Length off plus a Random Offset** (Schmidbauer, Thommes). It is the cheapest realism gain. Mask it, or short-fur regions lose their guides and go bald.
    - **Grow a human hairstyle on a separate hair cap split along the part** (Bystedt): body topology stops mattering, interpolation stops crossing the part. The part still needs extra density and a lift.
    - **Shape works the same way on every asset**: 1 affects the tip only, 0 the whole strand, negative the roots. Use a negative Frizz Shape so curl and clump tips survive (Thommes, Bystedt).
    - **Judge renders at full density, and in pose** (Thommes, Schmidbauer). A harsh mesh deformation "looks ten times worse" in the hair.
    
    ## Establish first
    
    | Input        | Changes                                                                    | Default when the brief is silent                 |
    | ------------ | -------------------------------------------------------------------------- | ------------------------------------------------ |
    | Target       | Cycles strands vs EEVEE or game cards                                      | Cycles strands (Principled Hair BSDF)            |
    | Subject      | human hairstyle (hair cap, part) vs animal fur (body mesh, landmarks)      | follow the reference                             |
    | Length class | points per guide, clump and curl layers                                    | short fur: 3 points, long: 8, long human: 15     |
    | Deformation  | animated means Hair Dynamics (Animation) stays last, and you check in pose | animated                                         |
    | Budget       | render curves, card triangles                                              | say the number: evaluated curves at full density |
    | Reference    | same subspecies, season, ideally the same individual (Schmidbauer)         | ask for it; draw landmarks and flow first        |
    
    Finish topology and non-overlapping UVs before grooming (hair binds to UV space) and apply scale: density counts the mesh's local area (verified).
    
    ## Workflow
    
    | Task                                                                                                                                | Headless                                         | Live session                                                                                                                                                                                                    |
    | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | Curves object, surface binding, guides as data, snap, subdivide or resample, asset modifiers, inputs, masks, counts, renders, cards | yes ([`scripts/bx_hair.py`](scripts/bx_hair.py)) | yes, same code in Object Mode                                                                                                                                                                                   |
    | Enter Sculpt Curves, activate the 11 curve brushes, set brush settings, `sculpt_curves.select_random`                               | n/a                                              | yes (`bx_gui.activate_brush("SCULPT_CURVES", "Comb")`)                                                                                                                                                          |
    | Comb, Add, Density, Grow/Shrink, Pull Tips strokes                                                                                  | no                                               | **no**. `sculpt_curves.brush_stroke` has no exec, so `bx_gui.stroke("SCULPT_CURVES", ...)` returns PASS_THROUGH and changes nothing. Set the brush up and ask the user to stroke, or use the substitutes below. |
    
    1. **Prepare the surface.** `H.uv_report(surface)`: stacked UVs make curves ignore the rig, UDIMs are fine, an unmerged mirror seam needs UV Merge by Distance (Schmidbauer). Humans: a hair cap split into islands along the part. Animals: separate the mouth interior. Masks: vertex groups (fast) or textures (detail on low-poly meshes). **GATE:** `overlap_faces == 0`, scale applied, one mask per region.
    2. **Plan flow and objects.** Turn the draw-over into regions (vertex groups) with one flow direction each. Schmidbauer: one curves object per landmark (face, cheeks, ears, neck, body, tail, feet) for performance. Thommes: one object with masks. Bystedt: split front and back where combing one disturbs the other. **GATE:** written list of regions, flows, objects.
    3. **Guides.** `hair = H.new_groom(surface)`, `P, N = H.sample_roots(surface, spacing, mask=...)`, `H.add_guides(hair, P, N, length, points=2, flow=..., flow_blend=...)` (snaps, writes `surface_uv_coordinate`). Later: `H.subdivide_guides` or `H.resample_guides(hair, 15)`. Brush substitutes: a direction field for Comb, `droop` for gravity, delete and re-add guides for Density and Add; Bystedt's "grow long, comb, shrink back" is long guides plus Trim. **GATE:** in `H.groom_report`, `roots_off_surface` and `spacing_gaps_2x` are 0 and `length_outliers` is 0. A guides-only render shows every direction right.
    4. **Density.** Add Set Hair Curve Profile first, then Interpolate Hair Curves with a Density Mask. Bystedt paints 50% gray and 100% at the part, then remaps. The 5.2 Interpolate also takes a Mask Texture image. **GATE:** `H.count(hair)` (full density) is within budget, `root_area_fraction` is well under 0.5, and a render shows no bald spots.
    5. **Procedural layers, each masked, A/B-tested by toggling `show_viewport` and rendering.**
       - Long fur (Thommes, preset `H.FUR_STACK_LONG`, applied with `H.build_stack`): Noise, Curl, strays, Frizz with negative shape, Clump, Clump 2, final Frizz (Cumulative off), Trim, Rotate.
       - Animal (Schmidbauer): Trim, Clump (cheeks, Tip Spread, Clump Offset), low Frizz, each with its own vertex group.
       - Human (Bystedt): Noise on guides (Cumulative off, Offset per Curve on), Roll at the tip only, then per population Interpolate, Profile, Clump on the groomed guides; wide and thin layers need different seeds.
       - Per-curve randomness: `H.random_curve_mask(hair, "frizz_mask", 0.8)` then input `"attr:frizz_mask"`; root-to-tip ramps: `H.along_curve_attr`.
         **GATE:** each layer earns its place in an A/B render.
    6. **Part and hairline (human).** Leave Part by Mesh Islands on (default): in the test, 21% of children near the part crossed it with islands off and 0% with islands on. For the lift, `H.distance_weights(cap, "part_lift", "part", radius, "bump")` feeds Displace Hair Curves (Surface Normal on, small distance, Shape near 0, Factor `attr:part_lift`). **GATE:** in a top view there is no scalp gap at the part and the crown is not flat.
    7. **Shading and render.** Use `H.hair_material(name, "CYCLES", melanin=(root, tip))` or `"EEVEE"` with explicit colors, then `H.setup_render(scene, engine, close_up)`. **GATE:** `H.review([surface, hair], out, engine)` passes [`references/critique.md`](references/critique.md).
    8. **Deformation.** Key 2 or 3 extreme poses and render each frame from the same camera. **GATE:** no bald spots or clipping in any pose.
    9. **Games: cards.** Run `H.add_cards(hair, width, material=H.card_material(...))`, then convert a copy with `object.convert(target='MESH')` and export glTF. Put a textured hair cap under the cards. **GATE:** triangles within budget, UVs in the half tile for cards, roots on the scalp.
    
    ## Numbers
    
    | Item                  | Value                                                                               | Relative to, source                        |
    | --------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------ |
    | Guide points          | 2 while placing, 3 short fur, 8 long hair                                           | Schmidbauer [00:20:24], [00:21:34]         |
    | Long human hair       | resample to 15 points                                                               | Bystedt [00:08:38]                         |
    | Add brush length      | about 0.01 m for fur (default 0.3 m)                                                | Schmidbauer [00:17:59]                     |
    | Density, human layer  | 5,000 per m² per clump population                                                   | Bystedt [00:17:28]                         |
    | Density, long fur     | 1,000,000/m², radius 0.2 mm, viewport 4%                                            | Thommes [00:09:02], [00:16:44]             |
    | Density map           | part 1.0, base 0.5, bald 0; ramp black stop just above 0.5                          | Bystedt [00:22:04], [00:23:46]             |
    | Wide clumps           | Shape about 0.3, reduced Factor, groomed guides                                     | Bystedt [00:19:47]                         |
    | Curl                  | Radius 2 cm, Curl Start 1, Random Offset 0.5, Guide Distance 1 cm, Guide Mask 0.2   | Thommes [00:12:04], [00:14:18]             |
    | Sub-clumps            | Guide Distance 1 mm, Mask 0.1; layer 2: 4 mm, 0.25, Clump Offset 5 mm               | Thommes [00:16:12], [00:31:01]             |
    | Frizz                 | 2 mm, negative Shape; final: -0.2, Cumulative off, on 20% of curves                 | Thommes [00:15:04], [00:32:08], [00:26:03] |
    | Strays                | 10% of curves, Noise distance 5 mm                                                  | Thommes [00:30:28]                         |
    | Trim, Rotate          | Replace Length off, Random Offset 5 mm; Rotate random 5°                            | Thommes [00:22:20]                         |
    | Card subdivision      | 1 per 20 cm of card length                                                          | Bystedt g_ZIOafq4QU [00:05:08]             |
    | Card and tube profile | arc: 1 segment flat, 3 preferred; tube: flat spiral, resolution 6, 1 turn, height 0 | Matsumoto [00:19:54], [00:22:40]           |
    | Card UV width         | cards use half the tile of tubes                                                    | Matsumoto [00:31:27]                       |
    | Coverage sanity       | density × π × radius²: 0.13 for Thommes' fur                                        | [added] check                              |
    
    ## Quality gates
    
    In code:
    
    ```python
    import sys; sys.path.append("<skills>/scenario-blender-hair/scripts"); import bx_hair as H
    print(H.uv_report(surface))                 # overlap_faces == 0
    rep = H.groom_report(hair); print(H.verdict(rep))   # [] or a list of fixes
    print(H.count(hair))                        # full-density curves and points vs budget
    print(H.card_stats(hair))                   # cards: tris, UV range
    ```
    
    `groom_report` checks attachment UVs, roots on the surface, spacing gaps, length dents (over 50% off neighbors), Hair Dynamics last, Trim Replace Length, unmasked Frizz or Noise, missing Density Mask, radius vs density, Strand display, and a Cycles hair shader under EEVEE.
    
    Visually: `H.review(objs, out, engine, views=("front", "threequarter", "top"))` renders a lit full-density sheet with Thommes' rig (warm top fill, side key, cool rim, dark bluish world). Score it with `references/critique.md`: bald spots, part coverage, clumps, strays, length variation, tips, root-to-tip color, highlights.
    
    ## Common mistakes
    
    | Mistake                                                | What it looks like                                           | Fix                                                                  |
    | ------------------------------------------------------ | ------------------------------------------------------------ | -------------------------------------------------------------------- |
    | Guides at the brush default of 8 points while blocking | tangles, clipping, fights while combing                      | 2 points, raise later (Schmidbauer)                                  |
    | Interpolate without a Density Mask                     | hair in the mouth or eyes, drifting children far from guides | vertex-group mask; separate the mouth interior                       |
    | Uneven guide spacing                                   | bald spots, worse in pose                                    | `sample_roots` spacing; check `spacing_gaps_2x`                      |
    | High density at default radius                         | a solid mass or "a mess"                                     | Profile first, about 0.2 mm at 1M/m² (Thommes)                       |
    | Trim at defaults                                       | every strand becomes 1 m long                                | Replace Length off (Thommes)                                         |
    | Positive Frizz on curls or clumps                      | curl tips destroyed, fuzzy clump tips                        | negative Shape, Cumulative Offset off on the final Frizz             |
    | Duplicated layer with the same seed                    | wide and thin clumps stack identically                       | change seed and frequency (Bystedt)                                  |
    | Straight tips                                          | "bad CG hair"                                                | Roll Hair Curves at the tip only (Bystedt)                           |
    | Surface edited after grooming                          | floating or sunken roots                                     | `snap_to_surface(hair, "DEFORM")`, or NEAREST after topology changes |
    | Judging at 4% viewport density                         | uniform clumps and equal lengths unnoticed                   | render at full density                                               |
    | Principled Hair BSDF in EEVEE                          | dark and flat (measured 0.09 vs 0.26 center luminance)       | Principled BSDF with explicit colors                                 |
    | Scripting a Comb stroke                                | nothing happens, PASS_THROUGH                                | write positions, or hand the stroke to the user                      |
    
    ## Blender 5.2 notes
    
    - Empty Hair and Quick Fur add a pinned **Hair Dynamics** modifier (Mode `Animation` or `Physics (Experimental)`). It replaces the old Surface Deform and moves roots with the deforming surface (shape keys and armature verified). Keep it last.
    - Interpolate has no Surface or UV Map inputs; it reads the curves object's surface. Newer inputs are Part by Mesh Islands, Follow Surface Normal, Mask Texture and Resting Surface (on). With Resting Surface on, Thommes' "Mask modifier on the surface" patch preview has no effect. Use a patch vertex group as the Density Mask instead, or turn Resting Surface off while previewing.
    - Modifier inputs are set with `md.properties.inputs.<id>.value` or `.type = 'ATTRIBUTE'`. Headless, evaluation stays stale until `obj.update_tag()`. `H.set_inputs` resolves UI names and tags for you. Background evaluation applies Viewport Amount, so use `H.count(full=True)`.
    - Brushes are assets: Snake Hook is **Pull Tips**, Selection Paint is **Select**; Add defaults to 8 points, 0.3 m; settings in `brush.curves_sculpt_settings`.
    - Factory `render.hair_type` is `STRAND`: use `STRIP` with `hair_subdiv >= 1`. Cycles shapes: `RIBBONS`, `THICK`, `THICK_LINEAR`. "Curves Info" is `ShaderNodeHairInfo`; Principled Hair offers Chiang and Huang. Hair Curves Noise writes `resolution` 12, which curves render with since 5.0.
    - Cards: Curve to Mesh has a Scale input (4.5), and Arc Resolution counts points. EEVEE alpha uses `surface_render_method = 'DITHERED'`. Keep the GeometrySet alive when reading `obj.evaluated_geometry().mesh`.
    - Quick Fur density scales with object size (MEDIUM on a 10 cm sphere: 80,220/m², 12 points per guide).
    
    ## References
    
    - [`references/expert-notes.md`](references/expert-notes.md): load for judgment calls, per expert with timestamps (human hairstyle, animal fur, procedural fur, cards, mesh hair).
    - [`references/procedures.md`](references/procedures.md): load before writing code. It has full tested procedures P1 to P8 with raw bpy equivalents of the `bx_hair` calls.
    - `references/critique.md`: load when reviewing a render or report, as the scoring rubric.
    - [`references/sources.md`](references/sources.md): load to cite or re-watch a source, with the best timestamps.
    - `scripts/bx_hair.py`: the toolkit (tested on 5.2.1). Tests are in `tests/code/blender-hair/`.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related