# Hair fit — what Ozan's system needs, why it is wrong on both ship bodies, and the pipeline fix **Written 2026-08-12.** Measurements in §2 were taken today with [`tools/measure_head_shell.py`](../../tools/measure_head_shell.py) against the currently shipped GLBs. Eyebrows and eye colour are covered briefly in §6 — hair is the subject. Game-side sources: `src/Character/OutfitSystem.cs`, `src/Character/HeadAccessoryBindMath.cs`, `src/Data/CharacterBuildData.cs`, `src/Viewer/PlayerController.cs`, `assets/quaternius/derived-bodies/hair_fit_quatskin_female.json`. --- ## 1. How the hair system actually works Sixteen Quaternius pieces — 6 female styles, 7 male styles, 3 facial-hair — live in `assets/quaternius/source-hair/`. Each is a small mesh — **466 to 3,501 verts** — and every one is **100 % rigidly weighted to the single `Head` bone**. Verified across six styles: one joint, weight 1.0, no blending anywhere. That single fact governs everything below. A hairstyle is not a skinned garment. It is a rigid prop parented to one bone, authored in the rest-pose world space of the **stock Quaternius body** — female styles on `Superhero_Female` (Head pivot Y 1.5496), male styles on `Superhero_Male` (Head pivot Y 1.5998). At runtime `PlayerController` attaches the style to whatever body loaded and corrects it with three constants from `CharacterBuildData.HeadFit`: | constant | effect | |---|---| | `HeadScale` | diagonal scale of the binds **about the Head rest pivot** | | `HairOffset` | bind-space translation | | `HairPitchDeg` | pitch about the same pivot | Colour is `ApplyHairColor` → `StandardMaterial3D.AlbedoColor = swatch`, i.e. a **multiply** against the hair card. So the entire per-body fit is a 3-parameter diagonal scale plus a translate, hand-tuned in `head_test_bed`, stored as C# constants. --- ## 2. Measured: both ship bodies are wrong, in opposite directions Cranium band = Head-weighted verts within 8 cm of the skull top — the surface a cap actually sits on. (The *full* Head-weighted shell, which is the measure quoted in `MakoHeadFit`'s comment, is inflated several cm by jaw/ear/neck weight bleed. Use the band.) | body | Head pivot Y | cranium w | cranium d | skull top above pivot | pivot above chin | |---|---|---|---|---|---| | stock `Superhero_Female` *(female hair authoring rig)* | 1.5496 | 14.84 | 20.11 | **21.69** | 1.2 | | stock `Superhero_Male` *(male hair authoring rig)* | 1.5998 | 14.79 | 19.91 | **21.02** | 2.3 | | `Ariki_Female_QuatSkin.glb` (Lena, ship) | 1.5687 | 21.44 | 25.96 | **20.83** | 12.8 | | `Ariki_Male_Mako.glb` (Mako, ship) | 1.5687 | 18.79 | 24.03 | **24.93** | 7.3 | Ratio the cranium against the same gender's authoring rig, and compare to the constant each body actually carries: | | needed | carried | error | |---|---|---|---| | Lena X | 1.445 | 1.545 | +7 % | | Lena Y | **0.960** | **1.431** | **+49 %** | | Lena Z | 1.291 | 1.426 | +10 % | | Mako X | 1.270 | 1.316 | +4 % | | Mako Y | 1.186 | 1.346 | +13 % | | Mako Z | 1.207 | 1.327 | +10 % | Push a scalp-hugging style through the real runtime chain (`HeadAccessoryBindMath.RestToWorldStepwise`) and the consequence is concrete: ``` Hair_BuzzedFemale authored crown 22.13 cm above its pivot on Lena, scale 1.431 / offset -0.060 -> lands Y 1.8254 her skull top 1.7770 crown vs skull +4.84 cm FLOATING Hair_Buzzed (male) authored crown 21.37 cm above its pivot on Mako, scale 1.346 / offset -0.057 -> lands Y 1.7994 his skull top 1.8180 crown vs skull -1.87 cm BURIED ``` **A buzz cut floating ~5 cm over Lena's head, and one sunk ~2 cm into Mako's.** Both from the same three constants. Neither is a mystery. The code says so itself. Lena's constants carry a stale-marker: they were tuned against the pre-swap body, whose skull top was **26.5 cm** above the pivot where the AccuRig body that replaced it on 2026-08-11 measures **20.83 cm**. Her vertical correction should now be ≈ identity; she is carrying a 43 % stretch plus a 6 cm drop that were fitted to a skull 5.7 cm taller. Mako's were never bed-tuned at all — the comment calls them "STARTING VALUES", derived by ratio off the inflated full-shell measure. **Not yet eye-verified in `head_test_bed`.** These are rest-pose numbers from the bind chain; someone should confirm the look before acting on them. But the direction and rough magnitude are arithmetic, not opinion. --- ## 2b. The constants are derivable, not bed-guessable A scale-about-pivot plus a translate has exactly two degrees of freedom per axis, so **two matched cranium landmarks determine both constants outright** — no bed session. Take the crown and the widest cranium slice (the temple/ear line) on the stock authoring rig and on the ship body, solve, done. That is `measure_head_shell.py --fit`. Scored against each style's *authored* clearance on its own stock rig — the correct test, since a mohawk stands proud and a beard hangs at the chin by design: | body | constants | worst error | mean error | |---|---|---|---| | Mako, 10 styles | carried `(1.316,1.346,1.327)` / `−0.057` | 8.04 cm | 3.17 cm | | Mako, 10 styles | **derived `(1.363,1.000,1.322)` / `+0.0388,+0.0242`** | **0.00 cm** | **0.00 cm** | | Lena, 6 styles | carried `(1.545,1.431,1.426)` / `−0.060` | 7.29 cm | 5.09 cm | | Lena, 6 styles | **derived `(1.613,1.148,1.311)` / `−0.0411,−0.0094`** | 1.04 cm | **0.29 cm** | Mako lands *exactly* because his fit comes out as a pure translate (Y scale 1.000), which preserves every relative distance. Lena's residual is `Hair_LongDreads`, whose crown is far from both fit landmarks. This changes the calculus in §3. "Retuning goes stale on the next rebake" stops being an argument once retuning is one command — so **re-derive in the ship gate** and it can never go stale silently again. What the fit still cannot do is the reason §4 survives: it fixes crown height, not shape. Lena's cranium needs **1.613 in X but 1.311 in Z** — a 23 % anisotropy that visibly stretches an authored cap sideways, and moves ear cutouts along a diagonal rather than onto her actual ears. That is Ozan's logged residual, and no 3-constant fit reaches it. **Verification status:** rest-pose arithmetic on the crown only. X/Z placement — the ear cutout, the forehead line — is untested by this metric, and nothing here has been looked at in `head_test_bed`. --- ## 3. Why retuning the constants is not the whole fix Look at the last column of the table in §2: **the Head pivot lands at a different anatomical place on every body.** On the stock rig it sits at the jaw line, 1–2 cm above the chin. On Lena it sits 12.8 cm above her chin — mid-face. On Mako, 7.3 cm. A diagonal scale about that pivot therefore scales about a different landmark per body. It can be made to fit the crown, or the ear cutouts, or the forehead line — not all three. That is exactly the residual Ozan logged in the canonical fit record: > "Buzzed-style ear cutout still slightly off vs her ear position (ear sits further back+lower > than stock). Translation gets close, not perfect — **a true fix is a Blender-authored hair > fit pass**." He named the fix. It is a Blender geometry pass — which is this repo's lane, not the game's. And the tuning loop has a structural flaw: the constants are C# literals validated by eye against one body build. Every rebake invalidates them silently. It has already happened once (Lena, 2026-08-11) and shipped once un-noticed (Mako, 2026-08-12). Lena's v02/v03 rebake will do it a third time. Retuning now buys a few weeks and re-enters the same loop. --- ## 4. The fix: bake a per-body hair set in our lane Because hair is 100 % rigid to one bone, refitting it is a **pure geometry problem with no rigging component**. Deform the verts once, offline, and the runtime needs no constants at all. **Proposed stage — `characters/hair/hair_refit.py`** Inputs: a shipped body GLB, its gender's stock authoring body, that gender's style files. 1. Extract the scalp surface of both heads (Head-weighted, above the brow line). 2. Build a smooth displacement field *stock scalp → ship scalp*, anchored on a handful of landmarks — skull top, ear centres, temples, brow centre, nape — then relaxed. A few hundred correspondences under a thin-plate/RBF fit is ample. **Full NRICP is unnecessary**: hair sits *outside* the surface, so millimetre face accuracy is irrelevant. (The NRICP head-standardization lane proved the harder version of this registration; it was never wired into `tools/`, and we do not need it here.) 3. Per hair vert: closest point on the stock scalp → (surface point, signed normal distance). Reconstruct as *warped surface point + the same normal distance along the ship normal*, so a cap authored 4 mm off the stock scalp stays 4 mm off ours. Verts outside the scalp region — ponytails, dreads, beard fringes — fall off to the rigid part of the field and keep their authored shape while riding the new skull. 4. Recompute normals. **Topology, UVs, materials and the single Head weight are untouched** — that is what makes this safe to ship. 5. Export `Hair_