feat(characters): hair-fit measurement + head-features contract
Ozan's hair system rebinds Quaternius styles (100% rigid to one Head bone) onto
whichever body loads, corrected by three hand-tuned constants in
CharacterBuildData.HeadFit. Those constants are only valid against the skull they
were tuned on, and nothing re-checks them when a body is rebaked. Both ship bodies
are currently wrong, in opposite directions.
tools/measure_head_shell.py:
--fit solve HeadScale/HairOffset from two cranium landmarks. A scale-about-
pivot plus a translate has exactly two degrees of freedom per axis, so
the crown + widest-cranium slice determine both outright -- no bed pass.
--sweep score every style against the clearance it was AUTHORED with on its own
stock rig (a mohawk stands proud, a beard hangs at the chin -- absolute
crown height is not the test).
default full Head-weighted shell AND the cranium band. The full shell is what
MakoHeadFit quotes; it is inflated several cm by jaw/ear/neck weight
bleed and is not the surface a cap sits on.
Measured against the shipped GLBs:
Lena cranium needs Y 0.960, carries 1.431 (+49%) -- every style floats 4.4-7.3cm
Mako cranium needs Y 1.186, carries 1.346 (+13%) -- every style sinks, buzz cut
2.2cm into his skull, moustache 8.0cm low
Solved constants take Mako to 0.00cm worst error over 10 styles, Lena to 0.29cm mean.
docs/characters/head-features-contract.md records the system, the measurements, and
what a 3-constant fit still cannot reach: Lena needs 1.613 in X but 1.311 in Z, a
23% anisotropy that stretches an authored cap sideways and drags ear cutouts along a
diagonal. That is Ozan's logged residual and wants a per-body geometry bake. Mako's
anisotropy is only 3%, so constants alone should suffice for him.
Also noted: the whole hair set shares just two base textures, so "authored per-colour
hair textures" -- the fix the wiki writes off in favour of the /0.55 swatch hack --
is 16 PNGs.
Crown placement is rest-pose arithmetic; X/Z and the in-bed look are unverified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
# 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_<style>.<bodyid>.glb` as a per-body set.
|
||||
|
||||
**QC gates** (the lane already works this way — gate it or it ships wrong again):
|
||||
|
||||
- **penetration** — no cap-region vert inside the ship scalp.
|
||||
- **clearance** — cap-region verts within the stock clearance distribution (roughly 1–8 mm).
|
||||
- **ear cutout** — for Buzzed/Bob, cutout rim to ear landmark within tolerance. This is the
|
||||
residual Ozan logged, and it becomes a measurable gate rather than an eyeball call.
|
||||
- **crown** — the §2 prediction, run per style per body, must read `ok`.
|
||||
- silhouette renders per style per body, into `preview/` like every other ship artifact.
|
||||
|
||||
**Game-side change is small.** `GetHairPath(hairId, gender)` → `GetHairPath(hairId, bodyId)`
|
||||
with fallback to the stock path when no baked variant exists; `HeadScale` / `HairOffset` /
|
||||
`HairPitchDeg` go to identity for bodies that have a baked set. Slots, catalogue, colour and
|
||||
the builder are untouched. Nothing gets deleted until a baked style is accepted.
|
||||
|
||||
**Cost:** 6 baked styles for Lena, 10 for Mako — 16 GLBs of 466–3,501 verts, a few hundred KB
|
||||
each. Noise next to an 8.5 MB body.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hair colour — the "real fix" is 16 PNGs
|
||||
|
||||
`ApplyHairColor` sets `AlbedoColor`, which Godot multiplies against the card texture. The card
|
||||
is ~0.55 neutral grey, so dark swatches land near-black. `DefaultHairSwatches[7]` stores
|
||||
`target / 0.55` to work around it — the wiki correctly calls that "a hack on one swatch, not a
|
||||
system", and lists authored per-colour textures as the real fix.
|
||||
|
||||
That fix is far cheaper than it sounds. **The entire hair set — all 13 styles, the stock
|
||||
eyebrow meshes and all three facial-hair pieces — uses exactly two base textures**,
|
||||
`T_Hair_1_BaseColor.png` and `T_Hair_2_BaseColor.png`.
|
||||
|
||||
So "authored per-colour hair textures" is **2 textures × 8 swatches = 16 PNGs**, generated
|
||||
once with the luminance-preserving retint this lane already runs on skin
|
||||
(`SkinToneTransform` / the `21_tone.py` texture-space tone engine). Then `ApplyHairColor`
|
||||
swaps a texture instead of multiplying a tint, dark colours land as themselves, and the
|
||||
`/0.55` swatch hack goes away.
|
||||
|
||||
Cheap, ours to produce, Ozan's to accept.
|
||||
|
||||
---
|
||||
|
||||
## 6. Eyebrows and eye colour, in brief
|
||||
|
||||
Both are downstream of the same root cause — our bodies ship as **one mesh, one primitive,
|
||||
one material** (`MI_Body_Lena`, `MI_Body_Mako`), so no facial feature is individually
|
||||
addressable.
|
||||
|
||||
- **Eyebrows.** Lena's ship path deletes painted brows from the albedo and attaches a
|
||||
generated skinned surface built from a *locked UV-space mask*
|
||||
(`tools/generate_lena_brow_surface.py`), coloured by the hair swatch. The mask, the browless
|
||||
albedo and the surface JSON are **atlas-bound** — Lena's v02 re-atlas invalidates all three
|
||||
at once, and they must be regenerated in the same stage that finalizes the atlas.
|
||||
There is also a live-but-superseded mask-tint path (`shaders/character_brow.gdshader`) that
|
||||
recolors a painted brow in place — 72 % replace, not a multiply. That one generalizes to a
|
||||
scanned face; the cover path does not, because you cannot erase raised scan geometry with a
|
||||
texture. Mako has `AttachFacialFeatures = false` and therefore no brow colour at all.
|
||||
- **Eye colour.** `ApplyEyeColorByIndex` swaps the whole albedo of the `OutfitSlot.Eyes` mesh
|
||||
between six baked iris PNGs. It needs a **separate mesh or primitive** — there is no other
|
||||
mechanism. Lena only has one because stock Quaternius eye spheres are laid over her painted
|
||||
face. Mako has none, so the builder's eye swatches are dead for the male default.
|
||||
The pipeline answer is one line of the export stage: emit the iris shells (real geometry on
|
||||
both Mako's scan and Lena's v02 transplanted head) as their own primitive instead of welding
|
||||
them into the body. One extra draw call; retires `quat_eyes.glb`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Suggested order
|
||||
|
||||
| # | Change | Where | Note |
|
||||
|---|---|---|---|
|
||||
| 1 | Land Mako's derived fit `(1.363, 1.000, 1.322)` / `(0, +0.0388, +0.0242)` | game-side C# | His own comment calls the current values "STARTING VALUES for a head-bed pass" — unlocked. He is the **shipped male default with hair sunk into his skull**. Verify in `head_test_bed` before baking |
|
||||
| 2 | Lena's derived fit `(1.613, 1.148, 1.311)` / `(0, −0.0411, −0.0094)` | **Ozan's call** | Do NOT edit unilaterally: `HeadTestBed` carries an explicit hair-fit LOCK, a warning label hardcoding `1.545,1.431,1.426`, and a documented "1.37 incident". Take the §2b table to him |
|
||||
| 3 | `--fit` re-derivation added to the body ship gate | this repo | Makes staleness structurally impossible; both bodies shipped wrong because nothing re-checked |
|
||||
| 4 | `hair_refit.py` + QC gates, baked per body | this repo | The part a 3-constant fit cannot reach: ear cutouts, the 23 % X/Z anisotropy, forehead line |
|
||||
| 5 | 16 per-colour hair PNGs | this repo | Kills the `/0.55` swatch hack |
|
||||
| 6 | Iris primitive split at export | this repo | Gives Mako eye colour |
|
||||
| 7 | Regenerate brow mask/browless/surface at atlas-final | Lena v02 lane | Or v02 ships with brows mismatched to her atlas |
|
||||
|
||||
Note the duplicated literal: `HeadAccessoryBindMath.ArikiHeadScale` carries its own copy of
|
||||
Lena's numbers marked "keep in sync with CharacterBuildData". Any change to hers must touch
|
||||
both, and `MaleCandidateBed` (`ArikiHeadScale * 1.08f`) and `BodyProportionTestBed` read it too.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Skull shell + hair-fit numbers for any 65-joint Quaternius GLB/glTF.
|
||||
#
|
||||
# The game rebinds Quaternius hair (100% rigid to the single `Head` bone) onto whatever body
|
||||
# loaded, correcting it with CharacterBuildData.HeadFit = a diagonal scale about the Head rest
|
||||
# pivot plus a bind translation. Those constants are only valid against the skull they were
|
||||
# tuned on, and they go stale silently every time a body is rebaked. This measures the skull
|
||||
# they SHOULD be tuned against, and predicts where a given hair style's crown actually lands.
|
||||
#
|
||||
# python tools/measure_head_shell.py <body.glb> [<body.glb> ...]
|
||||
# python tools/measure_head_shell.py --hair <hair.gltf> --on <body.glb> --scale X,Y,Z --offset X,Y,Z
|
||||
#
|
||||
# Two shell measures are printed. The FULL Head-weighted shell is what Ozan's MakoHeadFit
|
||||
# comment quotes; it is inflated by jaw/ear/neck weight bleed and is NOT the surface a hair cap
|
||||
# sits on. The CRANIUM band (Head-weighted verts within 8 cm of the skull top) is the honest
|
||||
# one — use it for any scale ratio.
|
||||
#
|
||||
# See docs/characters/head-features-contract.md.
|
||||
import json, struct, sys, os, array
|
||||
|
||||
CT = {5120: ('b', 1), 5121: ('B', 1), 5122: ('h', 2), 5123: ('H', 2), 5125: ('I', 4), 5126: ('f', 4)}
|
||||
NW = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16}
|
||||
|
||||
|
||||
def load(path):
|
||||
if path.endswith('.glb'):
|
||||
d = open(path, 'rb').read()
|
||||
n = struct.unpack_from('<I', d, 12)[0]
|
||||
j = json.loads(d[20:20 + n])
|
||||
off = 20 + n
|
||||
blen, _ = struct.unpack_from('<I4s', d, off)
|
||||
return j, [d[off + 8:off + 8 + blen]]
|
||||
j = json.load(open(path))
|
||||
base = os.path.dirname(path)
|
||||
return j, [open(os.path.join(base, b['uri']), 'rb').read() for b in j['buffers']]
|
||||
|
||||
|
||||
def acc(j, bufs, i):
|
||||
a = j['accessors'][i]
|
||||
bv = j['bufferViews'][a['bufferView']]
|
||||
off = bv.get('byteOffset', 0) + a.get('byteOffset', 0)
|
||||
n = NW[a['type']]
|
||||
fmt, sz = CT[a['componentType']]
|
||||
stride = bv.get('byteStride')
|
||||
buf = bufs[bv['buffer']]
|
||||
if stride and stride != n * sz:
|
||||
out = []
|
||||
for k in range(a['count']):
|
||||
ar = array.array(fmt)
|
||||
ar.frombytes(buf[off + k * stride:off + k * stride + n * sz])
|
||||
out.append(tuple(ar))
|
||||
return out
|
||||
ar = array.array(fmt)
|
||||
ar.frombytes(buf[off:off + a['count'] * n * sz])
|
||||
return [tuple(ar[k * n:(k + 1) * n]) for k in range(a['count'])]
|
||||
|
||||
|
||||
def mat_mul(a, b):
|
||||
"""Column-major 4x4 (glTF order) product a*b."""
|
||||
out = [0.0] * 16
|
||||
for c in range(4):
|
||||
for r in range(4):
|
||||
out[c * 4 + r] = sum(a[k * 4 + r] * b[c * 4 + k] for k in range(4))
|
||||
return out
|
||||
|
||||
|
||||
def node_local(nd):
|
||||
if 'matrix' in nd:
|
||||
return list(nd['matrix'])
|
||||
t = nd.get('translation', [0, 0, 0])
|
||||
q = nd.get('rotation', [0, 0, 0, 1])
|
||||
s = nd.get('scale', [1, 1, 1])
|
||||
x, y, z, w = q
|
||||
r = [
|
||||
1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w),
|
||||
2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w),
|
||||
2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y),
|
||||
]
|
||||
return [
|
||||
r[0] * s[0], r[1] * s[0], r[2] * s[0], 0.0,
|
||||
r[3] * s[1], r[4] * s[1], r[5] * s[1], 0.0,
|
||||
r[6] * s[2], r[7] * s[2], r[8] * s[2], 0.0,
|
||||
t[0], t[1], t[2], 1.0,
|
||||
]
|
||||
|
||||
|
||||
def node_world(j):
|
||||
"""Rest-pose world matrix per node (full TRS chain)."""
|
||||
parent = {}
|
||||
for i, nd in enumerate(j['nodes']):
|
||||
for c in nd.get('children', []):
|
||||
parent[c] = i
|
||||
cache = {}
|
||||
|
||||
def m(i):
|
||||
if i in cache:
|
||||
return cache[i]
|
||||
local = node_local(j['nodes'][i])
|
||||
cache[i] = mat_mul(m(parent[i]), local) if i in parent else local
|
||||
return cache[i]
|
||||
|
||||
def w(i):
|
||||
mm = m(i)
|
||||
return (mm[12], mm[13], mm[14])
|
||||
return w
|
||||
|
||||
|
||||
CRANIUM_BAND_M = 0.08 # verts within this of the skull top = the surface a hair cap sits on
|
||||
|
||||
|
||||
def head_points(path, weight_min=0.5):
|
||||
"""(Head pivot, [Head-weighted verts], total vert count) for a skinned body or hair file."""
|
||||
j, bufs = load(path)
|
||||
skin = j['skins'][0]
|
||||
names = [j['nodes'][n].get('name') for n in skin['joints']]
|
||||
if 'Head' not in names:
|
||||
raise SystemExit(f"{os.path.basename(path)}: no Head joint")
|
||||
hidx = names.index('Head')
|
||||
pivot = node_world(j)(skin['joints'][hidx])
|
||||
pts, total = [], 0
|
||||
for m in j['meshes']:
|
||||
for pr in m['primitives']:
|
||||
at = pr['attributes']
|
||||
if 'JOINTS_0' not in at:
|
||||
continue
|
||||
P = acc(j, bufs, at['POSITION'])
|
||||
J = acc(j, bufs, at['JOINTS_0'])
|
||||
W = acc(j, bufs, at['WEIGHTS_0'])
|
||||
total += len(P)
|
||||
for p, jj, ww in zip(P, J, W):
|
||||
if sum(ww[k] for k in range(4) if jj[k] == hidx) >= weight_min:
|
||||
pts.append(p)
|
||||
if not pts:
|
||||
raise SystemExit(f"{os.path.basename(path)}: no Head-weighted verts")
|
||||
return pivot, pts, total
|
||||
|
||||
|
||||
def _pct(v, p):
|
||||
v = sorted(v)
|
||||
k = (len(v) - 1) * p
|
||||
f = int(k)
|
||||
return v[f] + (v[min(f + 1, len(v) - 1)] - v[f]) * (k - f)
|
||||
|
||||
|
||||
def landmarks(path, slab=0.01):
|
||||
"""Two robust cranium landmarks in Head-pivot-relative metres, plus spans at the wider one.
|
||||
|
||||
`top` = skull crown.
|
||||
`wide` = the height where the cranium is widest (temple/ear line). Two landmarks is exactly
|
||||
what a scale-about-pivot + translate has degrees of freedom for, per axis.
|
||||
"""
|
||||
pivot, pts, _ = head_points(path)
|
||||
ys = [p[1] for p in pts]
|
||||
top = _pct(ys, 0.999)
|
||||
best = None
|
||||
y = top
|
||||
while y > top - 0.16: # search the top 16 cm — cranium, never the jaw
|
||||
slabpts = [p for p in pts if y - slab <= p[1] < y]
|
||||
if len(slabpts) >= 8:
|
||||
xs = [p[0] for p in slabpts]
|
||||
span = _pct(xs, 0.98) - _pct(xs, 0.02)
|
||||
if best is None or span > best[1]:
|
||||
zs = [p[2] for p in slabpts]
|
||||
best = (y - slab / 2, span, _pct(zs, 0.98) - _pct(zs, 0.02),
|
||||
(_pct(zs, 0.98) + _pct(zs, 0.02)) / 2)
|
||||
y -= slab
|
||||
if best is None:
|
||||
raise SystemExit(f"{os.path.basename(path)}: cranium slabs too sparse")
|
||||
wide_y, wide_w, wide_d, wide_zc = best
|
||||
return {
|
||||
'name': os.path.basename(path), 'pivot_y': pivot[1],
|
||||
'top': top - pivot[1], 'wide': wide_y - pivot[1],
|
||||
'wide_w': wide_w, 'wide_d': wide_d, 'wide_zc': wide_zc,
|
||||
}
|
||||
|
||||
|
||||
def fit(stock_path, body_path):
|
||||
"""Derive HeadScale / HairOffset that carry the stock cranium onto this body's cranium.
|
||||
|
||||
Runtime maps an authored vert at Head-local h to shipPivot + h*scale + offset, so two
|
||||
matched landmarks per axis determine both constants exactly — no bed guessing.
|
||||
"""
|
||||
s, t = landmarks(stock_path), landmarks(body_path)
|
||||
sy = (t['top'] - t['wide']) / (s['top'] - s['wide'])
|
||||
oy = t['top'] - s['top'] * sy
|
||||
sx = t['wide_w'] / s['wide_w']
|
||||
sz = t['wide_d'] / s['wide_d']
|
||||
oz = t['wide_zc'] - s['wide_zc'] * sz
|
||||
for d in (s, t):
|
||||
print(f" {d['name']:42s} top {d['top']*100:6.2f} wide-line {d['wide']*100:6.2f}"
|
||||
f" w {d['wide_w']*100:6.2f} d {d['wide_d']*100:6.2f} (cm above pivot)")
|
||||
print(f" HeadScale = new Vector3({sx:.3f}f, {sy:.3f}f, {sz:.3f}f)")
|
||||
print(f" HairOffset = new Vector3(0f, {oy:.4f}f, {oz:.4f}f)")
|
||||
return (sx, sy, sz), (0.0, oy, oz)
|
||||
|
||||
|
||||
def measure(path):
|
||||
pivot, pts, total = head_points(path)
|
||||
ys = [p[1] for p in pts]
|
||||
top, chin = max(ys), min(ys)
|
||||
band = [p for p in pts if p[1] >= top - CRANIUM_BAND_M]
|
||||
def span(v):
|
||||
return (max(v) - min(v)) * 100
|
||||
print(f"{os.path.basename(path):46s} headverts {len(pts):6d}/{total}")
|
||||
print(f" Head pivot Y {pivot[1]:.4f} skull top Y {top:.4f} chin Y {chin:.4f}")
|
||||
print(f" full shell w {span([p[0] for p in pts]):6.2f} d {span([p[2] for p in pts]):6.2f} (cm)")
|
||||
print(f" cranium band w {span([p[0] for p in band]):6.2f} d {span([p[2] for p in band]):6.2f}"
|
||||
f" n={len(band)} <- use THIS for scale ratios")
|
||||
print(f" skull top above pivot {(top - pivot[1]) * 100:6.2f} cm"
|
||||
f" pivot above chin {(pivot[1] - chin) * 100:6.2f} cm")
|
||||
|
||||
|
||||
def predict(hair_path, body_path, stock_path, scale, offset, terse=False):
|
||||
"""Does this style land on this body the way it was authored to land on the stock head?
|
||||
|
||||
Runtime (HeadAccessoryBindMath.RestToWorldStepwise) on a vert rigid to Head:
|
||||
world = bodyHeadRest + ScaleAbout(authoredLocal, 0, headScale) + offset
|
||||
|
||||
The crown's ABSOLUTE height over the skull is not the test — a mohawk stands proud and a
|
||||
beard hangs at the chin by design. The test is whether the style keeps the clearance it
|
||||
was drawn with on its own authoring rig.
|
||||
"""
|
||||
hpivot, hpts, _ = head_points(hair_path)
|
||||
bpivot, bpts, _ = head_points(body_path)
|
||||
spivot, spts, _ = head_points(stock_path)
|
||||
crown_local = max(p[1] for p in hpts) - hpivot[1]
|
||||
authored = crown_local - (_pct([p[1] for p in spts], 0.999) - spivot[1])
|
||||
skull_top = _pct([p[1] for p in bpts], 0.999)
|
||||
landed = bpivot[1] + crown_local * scale[1] + offset[1]
|
||||
got = landed - skull_top
|
||||
err = (got - authored) * 100
|
||||
verdict = "ok" if abs(err) <= 1.0 else ("HIGH" if err > 0 else "LOW")
|
||||
if terse:
|
||||
print(f" {os.path.basename(hair_path):26s} authored {authored*100:+6.2f} "
|
||||
f"got {got*100:+6.2f} err {err:+6.2f} cm {verdict}")
|
||||
return err
|
||||
print(f"{os.path.basename(hair_path)} on {os.path.basename(body_path)}")
|
||||
print(f" authored crown {crown_local*100:6.2f} cm above its pivot"
|
||||
f" = {authored*100:+.2f} cm clearance over the stock skull")
|
||||
print(f" scale {scale} offset {offset}")
|
||||
print(f" lands Y {landed:.4f} skull top Y {skull_top:.4f}"
|
||||
f" = {got*100:+.2f} cm clearance")
|
||||
print(f" error vs authored {err:+6.2f} cm {verdict}")
|
||||
return err
|
||||
|
||||
|
||||
def sweep(stock_path, body_path, hair_dir, scale, offset):
|
||||
print(f" {'style':26s} {'authored':>8s} {'got':>8s} {'err':>10s}")
|
||||
errs = []
|
||||
for f in sorted(os.listdir(hair_dir)):
|
||||
if not f.startswith('Hair_') or not f.endswith('.gltf') or 'Teen' in f:
|
||||
continue
|
||||
errs.append(abs(predict(os.path.join(hair_dir, f), body_path, stock_path,
|
||||
scale, offset, terse=True)))
|
||||
print(f" -> worst {max(errs):.2f} cm, mean {sum(errs)/len(errs):.2f} cm over {len(errs)} styles")
|
||||
|
||||
|
||||
def _vec(s):
|
||||
return tuple(float(v) for v in s.split(','))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
a = sys.argv[1:]
|
||||
sc = _vec(a[a.index('--scale') + 1]) if '--scale' in a else (1, 1, 1)
|
||||
off = _vec(a[a.index('--offset') + 1]) if '--offset' in a else (0, 0, 0)
|
||||
if '--fit' in a:
|
||||
fit(a[a.index('--fit') + 1], a[a.index('--on') + 1])
|
||||
elif '--sweep' in a:
|
||||
sweep(a[a.index('--stock') + 1], a[a.index('--on') + 1],
|
||||
a[a.index('--sweep') + 1], sc, off)
|
||||
elif '--hair' in a:
|
||||
predict(a[a.index('--hair') + 1], a[a.index('--on') + 1],
|
||||
a[a.index('--stock') + 1], sc, off)
|
||||
else:
|
||||
for p in a:
|
||||
measure(p)
|
||||
Reference in New Issue
Block a user