Files
animation/tools/handshape_solve.py
T

1132 lines
50 KiB
Python
Raw Normal View History

"""HAND-SHAPE SOLVER — bakes hand poses as glTF morph targets (blend shapes).
My own method for the ariki-game hand-pose problem (2026-08-17), an alternative to the
bone-rotation + finger-weight-rebake lane that stalled at exp05:
A hand pose is explicit SURFACE DEFORMATION, not skeleton rotation.
Why: the scan mesh has ~2.6k inter-digit bridge edges (web remnants). Weights only
choose which bone drags a shared vertex; when adjacent digits curl apart (fist/grip)
those bridges must tear — five weight-rebake iterations could not and cannot fix that.
Baking the pose offline turns the fight into ONE smoothable stretch; tearing is
impossible because a morph target IS the final vertex positions.
Pipeline (all offline, pure numpy on raw GLB bytes — no bpy, no Blender scene import):
1. parse GLB, rest skeleton (node globals x IBM = skinning space)
2. hand ROI: verts near the finger/hand bone segments, grown by edge rings
2b. WELD the ROI graph: the scan is UV-chart soup, so hundreds of coincident
duplicate verts sit on chart seams in separate graph components. Solve once per
welded node and scatter the delta back to every duplicate (see weld_roi)
3. per-bone geodesic fields: multi-source Dijkstra over the ROI subgraph
4. weights: gaussian kernels on geodesic distance (top-4, renormalized, smoothed)
— chain continuity is implicit: adjacent phalanges seed adjacent segments
5. pose: parametric curl/spread/thumb-opposition per joint, world-axis rotations
pivoted at each joint head, LBS with MY weights (the GLB's own weights are
irrelevant — the shipped body is a rigid mitt and that is FINE)
6. relax: stretch-gated Laplacian smoothing of the DELTA field until edge stretch
bars pass — a converging diffusion, replacing the non-converging weight loop
7. gates: numeric report (JSON) + OBJ dump for clay renders
8. emit: splice morph-target accessors into the GLB (deltas added to base — exactly
what Godot's gltf_document.cpp does: w = target + base) with extras.targetNames
hand_<pose>_<l|r>; runtime driver = ariki-game src/Animation/HandMorphLayer.cs
Run under Blender's python for numpy (Blender is only the interpreter host — no bpy):
"C:/Program Files/Blender Foundation/Blender 5.1/blender.exe" --background \
--factory-startup --python tools/handshape_solve.py -- \
--body <body.glb> --poses relaxed,fist,grip --out <out.glb> --workdir <dir>
--selftest-bump Spike A: splice one synthetic 3cm bump (no solve) to prove the
import+drive path end-to-end before trusting any solver output.
"""
import argparse
import heapq
import json
import math
import struct
import sys
from pathlib import Path
import numpy as np
DIGITS = ("thumb", "index", "middle", "ring", "pinky")
PHALANX = ("01", "02", "03")
# ───────────────────────── glTF container I/O ─────────────────────────
def read_glb(path):
d = Path(path).read_bytes()
ln = struct.unpack_from("<I", d, 8)[0]
off = 12
g = b = None
while off < ln:
clen, ct = struct.unpack_from("<II", d, off)
off += 8
if ct == 0x4E4F534A:
g = json.loads(d[off:off + clen])
elif ct == 0x004E4942:
b = d[off:off + clen]
off += clen
return g, b
def load_accessor(g, bin_data, idx):
"""Numeric accessor -> float64 array (count,) or (count, ncomp)."""
a = g["accessors"][idx]
bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[a["type"]]
dtype = np.dtype({5120: "i1", 5121: "u1", 5122: "<i2", 5123: "<u2",
5125: "<u4", 5126: "<f4"}[a["componentType"]])
size = dtype.itemsize * nc
stride = bv.get("byteStride") or size
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
raw = np.frombuffer(bin_data, dtype=np.uint8, count=a["count"] * stride,
offset=off).reshape(a["count"], stride)
arr = raw[:, :size].copy().view(dtype).reshape(a["count"], nc).astype(np.float64)
if a.get("normalized"):
scale = {5120: 127.0, 5121: 255.0, 5122: 32767.0, 5123: 65535.0}
arr /= scale.get(a["componentType"], 1.0)
return arr
# ───────────────────────── skeleton ─────────────────────────
def quat_to_mat(q):
x, y, z, w = q
n = math.sqrt(x * x + y * y + z * z + w * w)
x, y, z, w = x / n, y / n, z / n, w / n
return np.array([
[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)],
])
def node_local(nd):
M = np.eye(4)
M[:3, :3] = quat_to_mat(nd.get("rotation", [0, 0, 0, 1])) \
* np.array(nd.get("scale", [1, 1, 1]))[None, :]
M[:3, 3] = nd.get("translation", [0, 0, 0])
return M
class Skeleton:
def __init__(self, g, bin_data):
skin = g["skins"][0]
self.joints = skin["joints"]
self.names = [g["nodes"][j].get("name", "") for j in self.joints]
self.idx = {n: i for i, n in enumerate(self.names)}
loc = [node_local(g["nodes"][j]) for j in self.joints]
node_to_joint = {j: i for i, j in enumerate(self.joints)}
parent_of = {}
for i, j in enumerate(self.joints):
for c in g["nodes"][j].get("children", []):
if c in node_to_joint:
parent_of[node_to_joint[c]] = i
self.parent = [parent_of.get(i, -1) for i in range(len(self.joints))]
G = [None] * len(self.joints)
def glob(i):
if G[i] is None:
G[i] = loc[i] if self.parent[i] < 0 else glob(self.parent[i]) @ loc[i]
return G[i]
self.rest = [glob(i) for i in range(len(self.joints))]
ibm = load_accessor(g, bin_data, skin["inverseBindMatrices"]).reshape(-1, 4, 4)
self.ibm = np.array([m.T for m in ibm]) # glTF is column-major
def head(self, i):
return self.rest[i][:3, 3].copy()
def child_head(self, i, fallback_dir=None):
for j, par in enumerate(self.parent):
if par == i:
return self.head(j), True
h = self.head(i)
if fallback_dir is not None:
return h + fallback_dir, False
return h + np.array([0.0, 0.0, 0.02]), False
def finger_bones(skel, side):
sfx = "_l" if side == "l" else "_r"
hand = "hand" + sfx
bones = [f"{d}_{p}{sfx}" for d in DIGITS for p in PHALANX if f"{d}_{p}{sfx}" in skel.idx]
return hand, bones
def refit_fingers(skel, P, side, lat=0.04, pct=99.5, verbose=True):
"""Shrink each digit's joint chain along its own axis until it spans the ACTUAL
flesh, and rebuild that digit's inverse bind matrices to match.
Measured on Ariki_Female_QuatSkin_LowPoly_40 (2026-08-18): Lena's mesh hand ends
14.0cm from the wrist joint, but the Quaternius finger chain runs out to 19.8cm —
the `_02` row sits 1.9-2.8cm outside the mesh and the `_03` row floats 4.7-5.9cm
beyond the fingertips, in empty space. Curling about pivots outside the surface
translates the whole mitt end sideways instead of bending anything, and it starves
the weight solver: no vertex is within the 4mm seed radius of `_02`/`_03`, so the
"200 euclidean-nearest" fallback fires for 10 of 15 bones per hand.
The shipped skeleton is NOT touched — every clip pins all 65 bone positions and this
body has to keep meeting it. This is solver-local scaffolding: a morph target is just
final vertex positions, so the pose only has to be defined by pivots that lie inside
the flesh they bend. Uniform scale about the wrist per digit (so the knuckle row
moves inward too, not just the tips), and ibm := inv(rest) so the refit chain still
reproduces the rest mesh exactly under LBS.
"""
sfx = "_l" if side == "l" else "_r"
hh = skel.head(skel.idx["hand" + sfx])
rel_all = P - hh
near = np.linalg.norm(rel_all, axis=1) < 0.30
out = {}
for d in DIGITS:
chain = [f"{d}_{ph}{sfx}" for ph in PHALANX if f"{d}_{ph}{sfx}" in skel.idx]
if not chain:
continue
tip = skel.head(skel.idx[chain[-1]])
u = tip - hh
L = float(np.linalg.norm(u))
if L < 1e-6:
continue
u /= L
t = rel_all @ u
lat_d = np.linalg.norm(rel_all - t[:, None] * u[None, :], axis=1)
sel = near & (t > 0.3 * L) & (lat_d < lat)
if sel.sum() < 20:
continue
reach = float(np.percentile(t[sel], pct))
k = float(np.clip(reach / L, 0.35, 1.0))
# every joint at or below <digit>_01 scales about the wrist
root = skel.idx[chain[0]]
fam = [root]
for i in range(len(skel.parent)):
j, hops = i, 0
while skel.parent[j] >= 0 and hops < 8:
j = skel.parent[j]
hops += 1
if j == root:
fam.append(i)
break
for i in fam:
R = skel.rest[i].copy()
R[:3, 3] = hh + k * (R[:3, 3] - hh)
skel.rest[i] = R
skel.ibm[i] = np.linalg.inv(R)
out[d] = (k, reach, L)
if verbose and out:
print("[refit] " + " ".join(
f"{d}:{v[0]:.2f}({v[2] * 100:.1f}->{v[1] * 100:.1f}cm)" for d, v in out.items()))
return out
def bone_segments(skel, hand, bones):
"""World (head, tail) per bone; leaf tails extrapolate one phalanx outward."""
sfx = "_l" if hand.endswith("_l") else "_r"
segs = {}
hand_head = skel.head(skel.idx[hand])
for b in bones:
d, p, _ = b.rsplit("_", 2)
i = skel.idx[b]
h = skel.head(i)
t, had_child = skel.child_head(i)
if not had_child:
pn = f"{d}_{int(p) - 1:02d}{sfx}"
if pn in skel.idx:
ph = skel.head(skel.idx[pn])
t = h + (h - ph)
else:
t = h + (hand_head - h) * -0.3
segs[b] = (h, t)
first = bones[0] if bones else hand
segs[hand] = (hand_head, skel.head(skel.idx[first]))
return segs
def point_segment_dist(P, a, b):
ab = b - a
ab2 = ab @ ab
if ab2 < 1e-12:
return np.linalg.norm(P - a, axis=1)
t = np.clip((P - a) @ ab / ab2, 0.0, 1.0)
return np.linalg.norm(P - (a[None, :] + t[:, None] * ab[None, :]), axis=1)
# ───────────────────────── ROI + graph ─────────────────────────
def hand_joints(skel):
"""Wrist + every finger joint, both hands, in skinning space."""
J = []
for side in ("l", "r"):
J.append(skel.head(skel.idx[f"hand_{side}"]))
for d in DIGITS:
for ph in PHALANX:
nm = f"{d}_{ph}_{side}"
if nm in skel.idx:
J.append(skel.head(skel.idx[nm]))
return np.asarray(J)
def build_roi(P, F, skel, grow_rings=2, near=0.040):
"""ROI = flesh around the hand skeleton, as a union of spheres about the wrist and
every (refit) finger joint — NOT a tube about the bone segments.
A segment tube leaves this scan's ROI riddled with interior holes: any patch that
happens to sit farther than `near` from a bone axis drops out, so the "ROI rim"
(verts sharing a face with a non-ROI vert) becomes a fractal of interior chart holes
rather than a wrist ring. Locking that as a boundary froze verts in the middle of the
curling flesh — measured 48x edge stretch and 805 torn edges on fist_r. A union of
joint spheres is blob-like, so its intersection with the surface is one clean band on
the forearm: measured 766 rim verts, 95% of them at radius 4.3-6cm from the wrist,
none among the fingers. That is a rim worth locking, and it is what makes the delta
field fade to zero at the arm instead of cracking there.
"""
mask = np.min(np.linalg.norm(P[:, None, :] - hand_joints(skel)[None, :, :], axis=2),
axis=1) < near
for _ in range(grow_rings):
fmask = mask[F].any(axis=1)
cand = np.unique(F[fmask].reshape(-1))
new = cand[~mask[cand]]
if len(new) == 0:
break
mask[new] = True
idx = np.where(mask)[0]
remap = -np.ones(len(P), dtype=np.int64)
remap[idx] = np.arange(len(idx))
rf = remap[F]
roi_faces = rf[(rf >= 0).all(axis=1)]
# True ROI rim = an ROI vert sharing a face with a vert OUTSIDE the ROI. This has to
# be measured against the FULL mesh: "low degree in the ROI subgraph" is a different
# predicate and, on chart soup, mostly selects INTERIOR chart-corner dangles (verts
# in a single triangle). Locking those froze their delta at zero while their
# neighbours curled 4cm away — the entire remaining >5x needle population on the
# right hand was exactly two such verts.
part = F[(~mask[F]).any(axis=1) & mask[F].any(axis=1)]
rim = np.zeros(len(P), dtype=bool)
if len(part):
rim[part.reshape(-1)] = True
rim &= mask
return idx, remap, roi_faces, rim[idx]
def csr_from_edges(n, e):
"""Undirected adjacency in CSR form (nbr, start)."""
deg = np.zeros(n, dtype=np.int64)
np.add.at(deg, e[:, 0], 1)
np.add.at(deg, e[:, 1], 1)
start = np.zeros(n + 1, dtype=np.int64)
np.cumsum(deg, out=start[1:])
nbr = np.zeros(len(e) * 2, dtype=np.int64)
fill = start[:-1].copy()
nbr[fill[e[:, 0]]] = e[:, 1]
fill[e[:, 0]] += 1
nbr[fill[e[:, 1]]] = e[:, 0]
fill[e[:, 1]] += 1
return nbr, start
def roi_graph(P_roi, roi_faces, extra=None):
e = np.concatenate([roi_faces[:, [0, 1]], roi_faces[:, [1, 2]], roi_faces[:, [2, 0]]])
if extra is not None and len(extra):
e = np.concatenate([e, extra])
e = np.unique(np.sort(e, axis=1), axis=0)
nbr, start = csr_from_edges(len(P_roi), e)
return nbr, start, e
def components(n, e):
par = np.arange(n)
def find(a):
while par[a] != a:
par[a] = par[par[a]]
a = par[a]
return a
for a, b in e:
ra, rb = find(int(a)), find(int(b))
if ra != rb:
par[ra] = rb
root = np.array([find(i) for i in range(n)])
_, comp = np.unique(root, return_inverse=True)
return comp.astype(np.int64).reshape(-1)
def stitch_components(P_w, comp, tol=0.012, verbose=True):
"""Bridge SEPARATE surface sheets that lie within `tol` of each other.
Welding fixes coincident duplicates, but this scan is worse than duplicated: the hand
is built from overlapping sheets with real 1-10mm gaps between them. Measured
2026-08-18 on the refit hand ROI: 6 components, right hand 2111/1558/759 verts with
4.0mm and 1.1mm gaps, left hand 2082/2028 with an 8.9mm gap. Dijkstra cannot cross a
gap, so each bone's geodesic field covers whichever sheet its seeds landed on and
goes blind on the others; diffusion likewise cannot move a delta between sheets. That
is why one hand's fingertips curled 6.7cm while the other's moved 0.2cm from the same
parameters — nothing to do with left/right handedness, just which sheet got seeded.
Only CROSS-component pairs are joined, which is what makes this safe: a stitch can
never short-circuit two points that already have a path through the surface, so it
cannot fake a shortcut inside a sheet. It only restores coupling the scan lost.
"""
n = len(P_w)
out = []
for lo in range(0, n, 512):
hi = min(n, lo + 512)
d = np.linalg.norm(P_w[lo:hi, None, :] - P_w[None, :, :], axis=2)
d[comp[lo:hi, None] == comp[None, :]] = np.inf
j = d.argmin(axis=1)
dm = d[np.arange(hi - lo), j]
keep = dm < tol
if keep.any():
out.append(np.stack([np.arange(lo, hi)[keep], j[keep]], axis=1))
if not out:
if verbose:
print(f"[stitch] no cross-sheet pair within {tol * 1000:.0f}mm")
return np.zeros((0, 2), dtype=np.int64)
ex = np.unique(np.sort(np.concatenate(out), axis=1), axis=0)
if verbose:
L = np.linalg.norm(P_w[ex[:, 0]] - P_w[ex[:, 1]], axis=1)
print(f"[stitch] {len(ex)} cross-sheet edges, len mm "
f"med {np.median(L) * 1000:.2f} max {L.max() * 1000:.2f}")
return ex
WELD_TOL = 1e-5 # 10um: 200x below the 1.96mm median ROI edge, so no real edge collapses
def weld_roi(P, tol=WELD_TOL):
"""Merge coincident ROI vertices into ONE graph node. Returns (wid, P_w, group_size).
The Tripo scan is UV-chart soup. Measured on Ariki_Female_QuatSkin_LowPoly_40
(2026-08-18): 356 groups / 732 of the 5,548 ROI verts are duplicate positions
lying on chart seams, and the raw ROI graph therefore has 16 disconnected
components instead of 2 hands. Three separate failures fall out of that:
* duplicates are solved independently and get DIFFERENT deltas (measured up to
2.55cm apart on fist_r) — the seam physically cracks open;
* seam verts have a truncated one-ring, so `deg < 3` classes them as ROI
BOUNDARY and locks their delta to zero — relaxation was forbidden from
touching the exact verts that were tearing;
* geodesic distance detours around every seam, fragmenting the weight field.
Welding is done at the GRAPH level only — the mesh is never rewritten and morph
deltas stay indexed by original vertex id (required, or the splice breaks). Since
every member of a weld group then receives an identical delta, coincident pairs
keep their length exactly and the whole failure class dies by construction instead
of by tuning. Grid-bucket + 27-cell neighbour union-find rather than plain
round-and-unique, so a pair straddling a cell boundary still merges.
"""
cell = 2.0 * tol
keys = np.floor(P / cell).astype(np.int64)
buckets = {}
for i, k in enumerate(map(tuple, keys)):
buckets.setdefault(k, []).append(i)
par = np.arange(len(P))
def find(a):
while par[a] != a:
par[a] = par[par[a]]
a = par[a]
return a
t2 = tol * tol
offs = [(dx, dy, dz) for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1)]
for k, ids in buckets.items():
cand = []
for dx, dy, dz in offs:
b = buckets.get((k[0] + dx, k[1] + dy, k[2] + dz))
if b:
cand.extend(b)
cand = np.asarray(cand)
for i in ids:
d2 = ((P[cand] - P[i]) ** 2).sum(axis=1)
for j in cand[d2 <= t2]:
ri, rj = find(i), find(int(j))
if ri != rj:
par[ri] = rj
root = np.array([find(i) for i in range(len(P))])
_, wid = np.unique(root, return_inverse=True)
wid = wid.astype(np.int64).reshape(-1)
n_w = int(wid.max()) + 1
grp = np.bincount(wid, minlength=n_w).astype(np.float64)
P_w = np.stack([np.bincount(wid, weights=P[:, c], minlength=n_w) / grp
for c in range(3)], axis=1)
return wid, P_w, grp
def weld_faces(roi_faces, wid):
"""Re-index faces onto welded nodes, dropping the ones that collapse. Winding is
preserved (never sort face index triples)."""
fw = wid[roi_faces]
keep = ((fw[:, 0] != fw[:, 1]) & (fw[:, 1] != fw[:, 2]) & (fw[:, 2] != fw[:, 0]))
return fw[keep]
def dijkstra_multi(P_roi, nbr, start, seeds):
n = len(P_roi)
INF = float("inf")
dist = [INF] * n
heap = [(0.0, int(s)) for s in seeds]
for _, s in heap:
dist[s] = 0.0
heapq.heapify(heap)
while heap:
d, v = heapq.heappop(heap)
if d > dist[v]:
continue
pv = P_roi[v]
for k in range(start[v], start[v + 1]):
u = int(nbr[k])
du = d + float(np.linalg.norm(pv - P_roi[u]))
if du < dist[u]:
dist[u] = du
heapq.heappush(heap, (du, u))
return np.asarray(dist)
# ───────────────────────── weights ─────────────────────────
def digit_chain(skel, side, digit):
"""Polyline [wrist, ph01, ph02, ph03, tip] for one digit, plus per-segment arc."""
sfx = "_l" if side == "l" else "_r"
pts = [skel.head(skel.idx[f"hand{sfx}"])]
last = None
for ph in PHALANX:
nm = f"{digit}_{ph}{sfx}"
if nm in skel.idx:
pts.append(skel.head(skel.idx[nm]))
last = skel.idx[nm]
if last is not None:
tail, had = skel.child_head(last)
if not had and len(pts) >= 3:
tail = pts[-1] + (pts[-1] - pts[-2])
pts.append(tail)
return np.asarray(pts)
def chain_project(P, pts):
"""Per-vertex (arc coordinate along the polyline, lateral distance to it)."""
seg_len = np.linalg.norm(np.diff(pts, axis=0), axis=1)
cum = np.concatenate([[0.0], np.cumsum(seg_len)])
best_d = np.full(len(P), np.inf)
best_s = np.zeros(len(P))
for k in range(len(pts) - 1):
a, b = pts[k], pts[k + 1]
ab = b - a
ab2 = float(ab @ ab)
if ab2 < 1e-12:
continue
t = np.clip((P - a) @ ab / ab2, 0.0, 1.0)
proj = a[None, :] + t[:, None] * ab[None, :]
d = np.linalg.norm(P - proj, axis=1)
upd = d < best_d
best_d[upd] = d[upd]
best_s[upd] = cum[k] + t[upd] * seg_len[k]
return best_s, best_d, cum
def solve_weights(skel, P_roi, nbr, start, edges, side, sig_lat=0.004, support=0.05):
"""Weights as a partition of unity along each digit's CHAIN ARC, not as isotropic
kernels around bone segments.
The kernel version could not produce an owned vertex. Its width (sig_f = 2.5 x median
edge = 8mm) is wider than the surface's distance to the NEIGHBOURING phalanx (~1-1.5cm
on a hand this size), so every vertex came out a 3-way blend: measured 2026-08-18,
ZERO of 17,480 ROI verts had more than 0.85 weight on any phalanx and only 12 had
more than 0.70. Blending three phalanges that rotate by cumulatively different
amounts averages the curl away — which is exactly what the numbers showed: the raw
pose reached only 2.6cm of tip motion on one hand, and relaxation then flattened it
to 0.3cm while happily reporting every stretch bar as passed.
Along-chain arc position is the natural coordinate for a finger: tent functions
centred on each segment's midpoint give 1.0 mid-phalanx, a clean 50/50 at each joint,
and a smooth handover to the rigid hand bone behind the knuckle. Lateral distance to
the chain then picks WHICH digit owns the vertex. Two coordinates, no tuning race.
"""
hand, bones = finger_bones(skel, side)
all_bones = [hand] + bones
col = {b: i for i, b in enumerate(all_bones)}
sfx = "_l" if side == "l" else "_r"
W = np.zeros((len(P_roi), len(all_bones)))
lat_min = np.full(len(P_roi), np.inf)
chains, arcs = {}, {}
for d in DIGITS:
pts = digit_chain(skel, side, d)
if len(pts) < 3:
continue
s_arc, lat, cum = chain_project(P_roi, pts)
chains[d] = (pts, s_arc, lat, cum)
lat_min = np.minimum(lat_min, lat)
for d in DIGITS:
if d not in chains:
continue
pts, s_arc, lat, cum = chains[d]
# segment midpoints in arc space: index 0 is the palm (wrist->knuckle) segment,
# then one per phalanx present
mid = 0.5 * (cum[:-1] + cum[1:])
names = [hand] + [f"{d}_{ph}{sfx}" for ph in PHALANX if f"{d}_{ph}{sfx}" in skel.idx]
names = names[:len(mid)]
# 1D partition of unity along the arc: a narrow handover ramp centred on each
# JOINT, not a tent spanning midpoint-to-midpoint. A midpoint tent is 1.0 only at
# the exact midpoint and decays linearly across the whole phalanx, so the typical
# vertex still ends up a 2-way blend (measured p50 ownership 0.55). Confining the
# blend to +/-25% of a phalanx around each joint gives 1.0 through the middle of
# each segment and a clean 0.5 exactly at the joint, which is what a hand rig
# looks like and what lets a curl actually accumulate.
seg = np.diff(cum)
u = [np.ones(len(P_roi))]
for k in range(1, len(mid)):
w = 0.25 * min(seg[k - 1], seg[k])
u.append(np.clip((s_arc - (cum[k] - w)) / max(2 * w, 1e-9), 0.0, 1.0))
u.append(np.zeros(len(P_roi)))
T = np.stack([u[k] * (1.0 - u[k + 1]) for k in range(len(mid))], axis=1)
T /= np.maximum(T.sum(axis=1, keepdims=True), 1e-12)
# Digit ownership from lateral distance RELATIVE to the nearest chain, not
# absolute. The whole surface sits ~1cm off its own chain (that is just the
# finger's radius), so an absolute kernel suppresses every digit equally and
# renormalization hands the vertex back as a 5-way blend — measured p50 = 0.47
# ownership, which averages the curl away exactly as the isotropic version did.
# Against the nearest chain, a vertex 4mm farther from digit B than from digit A
# scores 0.37 on B, 8mm farther scores 0.02, and A itself gets 1.0. Only genuine
# near-ties (the inter-digit webs) blend, which is what should blend.
aff = np.exp(-((lat - lat_min) / sig_lat) ** 2)
for k, nm in enumerate(names):
if nm in col:
W[:, col[nm]] += aff * T[:, k]
# a floor on the hand bone so verts no digit claims stay rigid instead of
# normalizing a zero row into noise
W[:, 0] += 1e-3
# A sheet Dijkstra never reaches from this hand is detached geometry (see
# stitch_components): give it to the hand bone rather than letting euclidean lateral
# distance hand it a phalanx it has no surface path to.
seeds = np.where(lat_min < 0.012)[0]
if len(seeds) < 20:
seeds = np.argsort(lat_min)[:200]
geo = dijkstra_multi(P_roi, nbr, start, seeds.tolist())
far = (~np.isfinite(geo)) | (lat_min > support)
if far.any():
W[far] = 0.0
W[far, 0] = 1.0
W /= np.maximum(W.sum(axis=1, keepdims=True), 1e-12)
# one light smoothing pass: kills per-facet noise without un-owning anything
S = np.zeros_like(W)
cnt = (start[1:] - start[:-1]).clip(1)
for c in range(W.shape[1]):
S[:, c] = np.add.reduceat(W[nbr, c], start[:-1]) / cnt
W = 0.85 * W + 0.15 * S
W /= np.maximum(W.sum(axis=1, keepdims=True), 1e-12)
return all_bones, W
# ───────────────────────── posing ─────────────────────────
def palm_normal(skel, side):
sfx = "_l" if side == "l" else "_r"
h = skel.head(skel.idx["hand" + sfx])
mid = skel.head(skel.idx["middle_01" + sfx])
ix = skel.head(skel.idx["index_01" + sfx])
pk = skel.head(skel.idx["pinky_01" + sfx])
n = np.cross(mid - h, pk - ix)
n /= np.linalg.norm(n)
# NO artificial sign flip: the cross product already mirrors correctly between
# hands (left/right palms face opposite ways in the bind pose). A z-flip heuristic
# here breaks exactly one side and curls its fingers backward — measured the hard
# way (left raw stretch 2000x vs right 5x). Per-joint curl DIRECTION is instead
# verified against this normal by curl_axis's self-check.
return n
def axis_angle(axis, theta):
x, y, z = axis / max(np.linalg.norm(axis), 1e-12)
c, s = math.cos(theta), math.sin(theta)
C = 1 - c
return np.array([
[c + x * x * C, x * y * C - z * s, x * z * C + y * s, 0],
[y * x * C + z * s, c + y * y * C, y * z * C - x * s, 0],
[z * x * C - y * s, z * y * C + x * s, c + z * z * C, 0],
[0, 0, 0, 1],
])
def curl_axis(skel, side, n, digit, ph, sfx):
"""Axis = normalize(u x n) at the joint; sign-checked so +theta curls INTO the palm."""
name = f"{digit}_{ph}{sfx}"
i = skel.idx[name]
h = skel.head(i)
child, _ = skel.child_head(i, fallback_dir=None)
u = child - h
u /= max(np.linalg.norm(u), 1e-9)
axis = np.cross(u, n)
an = np.linalg.norm(axis)
if an < 1e-6:
return None, None
axis /= an
# self-check: rotating a probe point on the finger by +0.5 rad must move it
# toward the palm (component along -n grows)
moved = axis_angle(axis, 0.5)[:3, :3] @ u
if (moved @ (-n)) <= 0:
axis = -axis
return h, axis
def pose_globals(skel, side, params):
"""{bone: 4x4 posed global}. Each finger joint rotates ONLY its own curl (plus
spread at the MCP); ancestors' rotations enter through the parent recursion:
G_pose(j) = G_pose(p) . G_rest(p)^-1 . Rot_j . G_rest(j)."""
sfx = "_l" if side == "l" else "_r"
n = palm_normal(skel, side)
hand, bones = finger_bones(skel, side)
own = {} # bone -> list[(axis, theta, pivot)]
for d in DIGITS:
p = params["fingers"].get(d)
if p is None:
continue
for ph in PHALANX:
name = f"{d}_{ph}{sfx}"
if name not in skel.idx:
continue
h, axis = curl_axis(skel, side, n, d, ph, sfx)
if axis is None:
continue
own.setdefault(name, [])
if ph == "01" and p.get("spread", 0.0):
own[name].append((n, p["spread"], h))
if axis is not None:
own[name].append((axis, p[f"curl_{ph}"], h))
tp = params.get("thumb", {})
for ph in PHALANX:
name = f"thumb_{ph}{sfx}"
if name not in skel.idx:
continue
h, axis = curl_axis(skel, side, n, "thumb", ph, sfx)
if axis is not None:
own.setdefault(name, []).append((axis, tp.get(f"curl_{ph}", 0.0), h))
if f"thumb_01{sfx}" in own:
palm_c = skel.head(skel.idx[f"hand{sfx}"])
idx_mcp = skel.head(skel.idx[f"index_01{sfx}"])
op = idx_mcp - palm_c
op /= max(np.linalg.norm(op), 1e-9)
own[f"thumb_01{sfx}"].insert(0, (op, tp.get("opposition", 0.0), palm_c))
Gp = {}
def glob(i):
name = skel.names[i]
if name in Gp:
return Gp[name]
G = skel.rest[i].copy()
for axis, theta, pivot in own.get(name, []):
T1 = np.eye(4); T1[:3, 3] = pivot
T2 = np.eye(4); T2[:3, 3] = -pivot
G = T1 @ axis_angle(axis, theta) @ T2 @ G
par = skel.parent[i]
if par >= 0:
G = glob(par) @ np.linalg.inv(skel.rest[par]) @ G
Gp[name] = G
return G
for b in bones:
glob(skel.idx[b])
Gp[hand] = skel.rest[skel.idx[hand]].copy()
return Gp
def lbs(P_roi, skel, all_bones, W, Gp):
out = np.zeros_like(P_roi)
Ph = np.concatenate([P_roi, np.ones((len(P_roi), 1))], axis=1)
for bi, bn in enumerate(all_bones):
w = W[:, bi]
sel = np.where(w > 1e-9)[0]
if len(sel) == 0:
continue
G = Gp.get(bn, skel.rest[skel.idx[bn]])
D = G @ skel.ibm[skel.idx[bn]]
out[sel] += w[sel, None] * (Ph[sel] @ D.T)[:, :3]
# verts with no weight on this hand's bones (other hand, wrist edge of the ROI)
# must KEEP their rest position — a zero fallback flings them to the origin,
# which reads as 1500x edge stretch and poisons the relaxation. A weight row
# that doesn't sum to ~1 (degenerate isolated verts) is garbage too — same rule.
unw = W.sum(axis=1) <= 0.5
out[unw] = P_roi[unw]
return out
# ───────────────────────── relaxation ─────────────────────────
def vertex_normals(P, F):
"""Area-weighted vertex normals, vectorized, over the FULL mesh (so ROI-rim verts
get their complete one-ring — a ROI-local pass would shade the rim differently
than the imported rest normals and leave a discontinuity ring at the wrist)."""
v0, v1, v2 = P[F[:, 0]], P[F[:, 1]], P[F[:, 2]]
fn = np.cross(v1 - v0, v2 - v0) # area-weighted (unnormalized cross)
n = np.zeros_like(P)
for col in range(3):
np.add.at(n, F[:, col], fn)
lens = np.linalg.norm(n, axis=1, keepdims=True)
lens[lens < 1e-12] = 1.0
return n / lens
NEEDLE_MM = 1.0 # absolute growth above which an over-stretched edge is a real spike
def stretch_stats(P, Q, e):
"""Edge stretch with NO rest-length floor, plus the absolute over-stretch a bare
ratio hides.
The old 1mm floor was justified as "a 0.1mm edge doubling is invisible", which is
true — but it also silently excluded a real population: sub-mm edges on chart seams
that grew to 3-4cm in fist/grip, i.e. hairline needles sticking out of the hand,
sub-pixel in the screenshots that "passed" and entirely absent from the numbers. So
ratios are reported over EVERY edge, and a tear is judged by ratio AND absolute
growth together, which is scale-honest for both a 2mm web bridge at 5x and a 0.1mm
decimation sliver at 5x. `max`/`p999`/`n_gt*` stay scoped to >=1mm edges so the
table remains comparable to the pre-weld baseline."""
lr = np.linalg.norm(P[e[:, 0]] - P[e[:, 1]], axis=1)
lq = np.linalg.norm(Q[e[:, 0]] - Q[e[:, 1]], axis=1)
r = lq / np.maximum(lr, 1e-9)
grow_mm = (lq - lr) * 1000.0
slv = lr < 0.001
big = ~slv
return {"max": float(r[big].max()) if big.any() else 1.0,
"p999": float(np.percentile(r[big], 99.9)) if big.any() else 1.0,
"n_gt2": int((big & (r > 2)).sum()), "n_gt5": int((big & (r > 5)).sum()),
# the sliver population the floor used to hide
"slv_n": int(slv.sum()),
"slv_gt5x": int((slv & (r > 5)).sum()),
"slv_grow_gt1mm": int((slv & (grow_mm > NEEDLE_MM)).sum()),
"slv_max_grow_mm": round(float(grow_mm[slv].max()), 2) if slv.any() else 0.0,
# scale-honest tear count over ALL edges: >5x AND >1mm of real growth
"needles": int(((r > 5) & (grow_mm > NEEDLE_MM)).sum()),
"max_grow_mm": round(float(grow_mm.max()), 2)}
def relax(P_roi, delta, e, lr, pin, seam=None, tau=1.35, iters=1500, omega=0.6):
"""Strain-only edge projection: move ONLY the endpoints of edges that exceed `tau`.
This replaces the gated-Laplacian-diffusion relaxation, which was structurally unable
to do the job. Diffusing the DELTA field has a null space — constants — and the
edge-stretch gate is blind to every member of it, because a rigid translation stretches
no edge and neither does a collapse to zero. So the diffusion always found one of those
two exits, and reported perfect bars on the way out. Measured 2026-08-18 on
Ariki_Female_QuatSkin_LowPoly_40: the left hand's delta decayed to 0.3cm of fingertip
travel (max 1.93x, p99.9 1.60x, zero torn edges — a flawless report for a morph that
does nothing), while the right hand converged to a near-constant 6.5cm delta at EVERY
arc position from wrist to fingertip, i.e. the whole hand translated 6.5cm sideways
with its shape intact (max 2.43x, p99.9 1.46x, also "passing"). That is the real
reason five weight-rebake iterations and every gate before this one signed off on
hands that do not make a fist.
Projection has no such exit: a conforming edge contributes no correction, so regions
that are not over-stretched are left exactly as the pose put them, and the pose can
only be modified where it actually tears. Jacobi-style (accumulate, average by
incidence count, under-relax by `omega`) so dense web clusters cannot oscillate.
`seam` = (inside_vert, fixed_outside_position, rest_len) constrains ROI-border edges
against the un-morphed body. Those edges are NOT in `e` — they leave the ROI — so
without them nothing measures or limits the crack at the wrist, and the un-anchored
hand is free to walk away from the arm. Same 70-140mm drift as above, from the other
side of the same blind spot.
"""
d = delta.copy()
n = len(P_roi)
free = (~pin).astype(np.float64)[:, None]
hist = []
for _ in range(iters):
Q = P_roi + d
lq = np.linalg.norm(Q[e[:, 0]] - Q[e[:, 1]], axis=1)
viol = lq > tau * lr
corr = np.zeros_like(d)
cnt = np.zeros(n)
if viol.any():
a, b = e[viol, 0], e[viol, 1]
dv = Q[b] - Q[a]
L = np.linalg.norm(dv, axis=1)
ex = (L - tau * lr[viol])[:, None] * (dv / np.maximum(L, 1e-12)[:, None]) * 0.5
np.add.at(corr, a, ex)
np.add.at(cnt, a, 1)
np.add.at(corr, b, -ex)
np.add.at(cnt, b, 1)
n_seam = 0
if seam is not None:
si, sp, sl = seam
dvs = Q[si] - sp
Ls = np.linalg.norm(dvs, axis=1)
vs = Ls > tau * sl
n_seam = int(vs.sum())
if n_seam:
exs = (Ls[vs] - tau * sl[vs])[:, None] * (dvs[vs] / np.maximum(Ls[vs], 1e-12)[:, None])
np.add.at(corr, si[vs], -exs)
np.add.at(cnt, si[vs], 1)
hist.append((int(viol.sum()), n_seam))
if not viol.any() and n_seam == 0:
break
d += omega * free * corr / np.maximum(cnt, 1)[:, None]
return d, hist
# ───────────────────────── pose parameters ─────────────────────────
DEFAULT_PARAMS = {
"flat": {"fingers": {d: {"curl_01": 0.02, "curl_02": 0.02, "curl_03": 0.01,
"spread": 0.0} for d in DIGITS},
"thumb": {"curl_01": 0.05, "curl_02": 0.05, "curl_03": 0.02,
"opposition": 0.0}},
"relaxed": {"fingers": {d: {"curl_01": 0.15, "curl_02": 0.25, "curl_03": 0.15,
"spread": 0.05} for d in DIGITS},
"thumb": {"curl_01": 0.15, "curl_02": 0.15, "curl_03": 0.1,
"opposition": 0.25}},
"fist": {"fingers": {d: {"curl_01": 1.15, "curl_02": 1.05, "curl_03": 0.75,
"spread": -0.05} for d in DIGITS},
"thumb": {"curl_01": 0.9, "curl_02": 0.9, "curl_03": 0.5,
"opposition": 0.8}},
"grip": {"fingers": {d: {"curl_01": 0.75, "curl_02": 0.85, "curl_03": 0.7,
"spread": 0.1} for d in DIGITS},
"thumb": {"curl_01": 1.0, "curl_02": 0.9, "curl_03": 0.6,
"opposition": 1.2}},
}
# ───────────────────────── morph splice emit ─────────────────────────
def emit_morph_glb(g, bin_data, shapes, out_path):
"""Splice {name: {"POSITION": (N,3) delta, "NORMAL": (N,3) delta|None}} into a NEW
GLB. Godot's gltf importer computes w = target + base for BOTH attributes
(gltf_document.cpp), so deltas are written verbatim; names ride in mesh
extras.targetNames. Existing bufferViews are untouched — new data is appended to
the BIN chunk and buffers[0].byteLength grows. Never writes the source body."""
mesh = g["meshes"][0]
prim = mesh["primitives"][0]
vert_count = g["accessors"][prim["attributes"]["POSITION"]]["count"]
new_bin = bytearray()
targets, names = [], []
for name, attrs in shapes.items():
target_entry = {}
for attr in ("POSITION", "NORMAL"):
delta = attrs.get(attr)
if delta is None:
continue
assert delta.shape == (vert_count, 3), f"{name}.{attr}: {delta.shape} vs {vert_count}"
pad = (4 - len(new_bin) % 4) % 4
new_bin += b"\x00" * pad
f32 = delta.astype("<f4")
bv_idx = len(g["bufferViews"])
g["bufferViews"].append({"buffer": 0, "byteOffset": len(bin_data) + len(new_bin),
"byteLength": f32.nbytes})
acc_idx = len(g["accessors"])
g["accessors"].append({"bufferView": bv_idx, "componentType": 5126,
"count": vert_count, "type": "VEC3"})
new_bin += f32.tobytes()
target_entry[attr] = acc_idx
targets.append(target_entry)
names.append(name)
new_bin += b"\x00" * ((4 - len(new_bin) % 4) % 4)
g["buffers"][0]["byteLength"] = len(bin_data) + len(new_bin)
# G7 bounds self-check: every bufferView must fit the declared buffer
total = g["buffers"][0]["byteLength"]
for bv in g["bufferViews"]:
assert bv["byteOffset"] + bv["byteLength"] <= total, \
f"bufferView out of bounds: {bv} vs {total}"
prim["targets"] = targets
mesh["weights"] = [0.0] * len(targets)
mesh.setdefault("extras", {})["targetNames"] = names
js = json.dumps(g, separators=(",", ":")).encode("utf-8")
js += b" " * ((4 - len(js) % 4) % 4)
bin_total = bytes(bin_data) + bytes(new_bin)
body = struct.pack("<II", len(js), 0x4E4F534A) + js
body += struct.pack("<II", len(bin_total), 0x004E4942) + bin_total
hdr = struct.pack("<III", 0x46546C67, 2, 12 + len(body))
Path(out_path).write_bytes(hdr + body)
return names
def dump_obj(path, verts, faces):
with open(path, "w") as f:
for v in verts:
f.write(f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n")
for a, b, c in faces:
f.write(f"f {a + 1} {b + 1} {c + 1}\n")
# ───────────────────────── main ─────────────────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--body", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--poses", default="flat,relaxed,fist,grip")
ap.add_argument("--workdir", default=None)
ap.add_argument("--selftest-bump", action="store_true")
ap.add_argument("--no-refit", action="store_true",
help="skip the finger-chain refit (see refit_fingers)")
ap.add_argument("--no-stitch", action="store_true",
help="skip cross-sheet stitching (see stitch_components)")
ap.add_argument("--no-weld", action="store_true",
help="solve on the raw (chart-split) ROI graph — reproduces the "
"pre-weld baseline for before/after comparison")
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else argv[1:]
args = ap.parse_args(argv)
g, bin_data = read_glb(args.body)
prim = g["meshes"][0]["primitives"][0]
P = load_accessor(g, bin_data, prim["attributes"]["POSITION"])
N_rest = load_accessor(g, bin_data, prim["attributes"]["NORMAL"]) \
if "NORMAL" in prim["attributes"] else None
F = load_accessor(g, bin_data, prim["indices"]).astype(np.int64).reshape(-1, 3)
skel = Skeleton(g, bin_data)
if args.selftest_bump:
c = skel.head(skel.idx["hand_r"])
delta = np.zeros_like(P)
d = np.linalg.norm(P - c, axis=1)
delta[:, 2] += 0.03 * np.exp(-(d / 0.05) ** 2)
names = emit_morph_glb(g, bin_data,
{"hand_bump_r": {"POSITION": delta}}, args.out)
print(f"[selftest] spliced {names} -> {args.out}; run under BODY_OVERRIDE, the")
print(f"[selftest] layer should log \"found 1 hand pose(s): bump\" and K raises")
print(f"[selftest] a 3cm bump on the right palm — proves import+drive end to end")
return
poses = [p.strip() for p in args.poses.split(",") if p.strip()]
if not args.no_refit:
for side in ("l", "r"):
refit_fingers(skel, P, side)
segs_by_side = {}
for side in ("l", "r"):
hand, bones = finger_bones(skel, side)
segs_by_side[side] = bone_segments(skel, hand, bones)
idx, remap, roi_faces_raw, rim_dup = build_roi(P, F, skel)
P_dup = P[idx]
print(f"[roi] {len(idx)} verts / {len(roi_faces_raw)} faces "
f"({100.0 * len(idx) / len(P):.1f}% of {len(P)})")
# Weld coincident verts into single graph nodes (see weld_roi). Everything from here
# on — graph, geodesics, weights, pose, relax — runs on welded nodes; deltas scatter
# back to every duplicate at emit time.
if args.no_weld:
wid = np.arange(len(P_dup))
P_roi, wgrp, roi_faces = P_dup, np.ones(len(P_dup)), roi_faces_raw
else:
wid, P_roi, wgrp = weld_roi(P_dup)
roi_faces = weld_faces(roi_faces_raw, wid)
nbr, start, e = roi_graph(P_roi, roi_faces)
comp = components(len(P_roi), e)
stitch = (np.zeros((0, 2), dtype=np.int64) if args.no_stitch
else stitch_components(P_roi, comp))
if len(stitch):
nbr, start, e = roi_graph(P_roi, roi_faces, extra=stitch)
comp2 = components(len(P_roi), e)
print(f"[stitch] surface sheets {comp.max() + 1} -> {comp2.max() + 1}")
rim = np.zeros(len(P_roi), dtype=bool)
np.logical_or.at(rim, wid, rim_dup)
print(f"[weld] {len(P_dup)} -> {len(P_roi)} nodes "
f"({int((wgrp > 1).sum())} merged groups, max {int(wgrp.max())}); "
f"rim {int(rim.sum())}; faces {len(roi_faces_raw)} -> {len(roi_faces)}"
+ (" [--no-weld]" if args.no_weld else ""))
lr_roi = np.linalg.norm(P_roi[e[:, 0]] - P_roi[e[:, 1]], axis=1)
# ROI-border constraints. These edges leave the ROI, so they are absent from `e`;
# they are the ONLY thing tying the morph to the un-morphed arm (see relax).
in_roi = np.zeros(len(P), dtype=bool)
in_roi[idx] = True
all_e = np.unique(np.sort(np.concatenate(
[F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]]), axis=1), axis=0)
xe = all_e[in_roi[all_e[:, 0]] != in_roi[all_e[:, 1]]]
x_in = np.where(in_roi[xe[:, 0]], xe[:, 0], xe[:, 1])
x_out = np.where(in_roi[xe[:, 0]], xe[:, 1], xe[:, 0])
w_of = -np.ones(len(P), dtype=np.int64)
w_of[idx] = wid
seam = (w_of[x_in], P[x_out], np.linalg.norm(P[x_in] - P[x_out], axis=1))
print(f"[seam] {len(xe)} ROI-border edges constrained against the un-morphed body")
workdir = Path(args.workdir) if args.workdir else None
if workdir:
workdir.mkdir(parents=True, exist_ok=True)
dump_obj(workdir / "rest_hands.obj", P_roi, roi_faces)
shapes = {}
report = {"body": args.body, "roi_verts": int(len(idx)),
"weld": {"enabled": not args.no_weld, "tol_m": WELD_TOL,
"nodes": int(len(P_roi)), "merged_groups": int((wgrp > 1).sum()),
"max_group": int(wgrp.max()), "rim": int(rim.sum())},
"stitch_edges": int(len(stitch)), "seam_edges": int(len(xe)),
"poses": {}}
# NORMAL deltas: position-only morphs leave lighting on the REST shape — curled
# fingers shade flat/stale and read as "torn texture". Deltas are measured in the
# solver's own recomputed-rest frame (exporter normal conventions cancel), over
# the FULL mesh so ROI-rim verts keep a complete one-ring.
N_mine_rest = vertex_normals(P, F) if N_rest is not None else None
for side in ("l", "r"):
all_bones, W = solve_weights(skel, P_roi, nbr, start, e, side)
hand_i = skel.idx[f"hand_{side}"]
hh = skel.head(hand_i)
side_mask = np.linalg.norm(P_roi - hh, axis=1) < 0.30
W[~side_mask] = 0.0
lock_other = ~side_mask
for pose_name in poses:
Gp = pose_globals(skel, side, DEFAULT_PARAMS[pose_name])
posed = lbs(P_roi, skel, all_bones, W, Gp)
delta0 = posed - P_roi
delta0[lock_other] = 0.0
raw = stretch_stats(P_roi, P_roi + delta0, e)
relaxed, hist = relax(P_roi, delta0, e, lr_roi, lock_other, seam=seam)
fin = stretch_stats(P_roi, P_roi + relaxed, e)
seam_mm = float(np.abs(np.linalg.norm(
(P_roi + relaxed)[seam[0]] - seam[1], axis=1) - seam[2]).max()) * 1000
sfx = "_l" if side == "l" else "_r"
travel = {}
mesh_travel = {}
for d in DIGITS:
tn = f"{d}_03{sfx}"
if tn in Gp:
tip = skel.head(skel.idx[tn])
pv = (Gp[tn] @ skel.ibm[skel.idx[tn]]) @ np.append(tip, 1.0)
travel[d] = float(np.linalg.norm(pv[:3] - tip)) * 100
# MESH travel: what the surface near that joint actually does. The
# bone number above is scaffolding — it says nothing about whether
# any flesh moved, and on this body it read 10cm/finger while the
# `_03` joints floated ~5cm outside the mesh entirely.
sel = np.linalg.norm(P_roi - tip, axis=1) < 0.020
if sel.any():
mesh_travel[d] = float(
np.linalg.norm(relaxed[sel], axis=1).mean()) * 100
key = f"{pose_name}_{side}"
report["poses"][key] = {
"raw": raw, "relaxed": fin,
"tip_bone_cm": {k: round(v, 2) for k, v in travel.items()},
"tip_mesh_cm": {k: round(v, 2) for k, v in mesh_travel.items()},
"relax_iters": len(hist),
"viol_edges_left": hist[-1][0], "viol_seam_left": hist[-1][1],
"seam_max_mm": round(seam_mm, 2),
}
full = np.zeros_like(P)
# scatter: every duplicate of a welded node gets the SAME delta, so
# coincident pairs keep their rest length exactly and chart seams cannot
# crack open. Deltas stay indexed by ORIGINAL vertex id — required, or the
# accessor splice desyncs from the GLB's attribute order.
full[idx] = relaxed[wid]
entry = {"POSITION": full.astype(np.float32)}
if N_mine_rest is not None:
P_full = P.copy()
P_full[idx] = P_dup + relaxed[wid]
n_delta = vertex_normals(P_full, F) - N_mine_rest
entry["NORMAL"] = n_delta.astype(np.float32)
shapes[f"hand_{key}"] = entry
if workdir:
dump_obj(workdir / f"{key}.obj", P_roi + relaxed, roi_faces)
print(f"[{key}] raw max {raw['max']:.2f}x p999 {raw['p999']:.2f}x n>5x {raw['n_gt5']:4d}"
f" -> relaxed max {fin['max']:.2f}x p999 {fin['p999']:.2f}x n>5x {fin['n_gt5']:4d}"
f" needles {fin['needles']:3d}"
f" seam {seam_mm:5.1f}mm"
f" ({len(hist):2d} it) meshtip cm: "
+ " ".join(f"{d[:2]}={mesh_travel[d]:.1f}" for d in mesh_travel))
names = emit_morph_glb(g, bin_data, shapes, args.out)
if workdir:
(workdir / "handmorph_report.json").write_text(json.dumps(report, indent=1))
print(f"[emit] {len(names)} shapes -> {args.out}: {', '.join(names)}")
if __name__ == "__main__":
main()