Files
animation/tools/measure_head_shell.py
T
jeremy 760cf12788 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>
2026-08-12 16:07:34 -07:00

276 lines
11 KiB
Python

# 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)