The plan this track executed against. Its weld diagnosis was real but turned out to be 1 of 6 defects, and its stretch-only acceptance bars were satisfiable by a no-op morph — see hand-shapes/README.md and b57b0f2 for what actually happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
Handover: Lena hand MORPH track (blend-shape lane) — 2026-08-18
You own the morph lane. A parallel agent owns the bone lane (finger weights +
HandPoseLayer). Read the scope fence before touching anything — the two lanes share a
git branch and one serialized test bed.
Goal
Lena can hold fist and grip in-game, on the shipped body. Flat already works via the bone lane, so flat is not your problem — the two poses that never made it are fist and grip, and this lane is the one that can carry them, because it does not depend on finger weights at all.
The blocking bug is known and localized: coincident (unwelded) duplicate vertices in the scan mesh are invisible to the solver's convergence gate, so the right hand's fist/grip still tear. Fix that, re-solve, get a bed visual, then make the ship-body call.
Why this lane exists (do not re-litigate)
Lena's mesh is a Tripo scan with ~2.6k inter-digit bridge edges (web remnants). Bone weights only choose which bone drags a shared vertex — when adjacent digits curl apart in a fist, those bridges MUST tear. Five weight-rebake iterations (exp01–exp05) never converged; the verdict from that lane is that weights alone cannot clear the bar on this mesh. A morph target IS the final vertex positions, so tearing is impossible by construction and the residual web stretch becomes one smoothable, converging geometric problem. Morphs also work on the shipped rigid-mitt bodies (zero finger weights) and on Mako, with no rest-space compatibility hacks and no body denylist.
What already works (verified, not aspirational)
- Runtime driver committed in ariki-game:
src/Animation/HandMorphLayer.cs(commit40b995e21). Discovershand_<pose>_<l|r>blend shapes on the body's meshes, per-hand 0..1 blend,CyclePose(), logs once and goes inert on bodies without shapes.PoseOrder = { flat, relaxed, fist, grip }. - Shape preservation:
src/Character/BodyMeshShaper.Deform()carries the shapes through muscle/fat rebuilds. - Solver + verifier (animation repo, untracked — committing them is your job):
tools/handshape_solve.py(723 lines),tools/handshape_verify.py(109 lines), plushand-shapes/README.md. - In-engine verification happened once (2026-08-17): fist and grip read as real fists/grips mid-dance at hand-cam range on the demo body, no fins/shards/spikes (agent-API screenshots 181806/181808).
- Two beds can drive it:
dance_test_bed(hotkey K cycles poses, J hand cam, 1 frames the team) and the neweranim_hand_test_bed(commitd1e6fb0a5, Lena + Mako side by side as game-model rigs, button panels for both hand lanes + hand-follow cams). Preferanim_hand_test_bed— it is the exact colonist build path the game uses.
Solver pipeline (so you can navigate 723 lines fast)
tools/handshape_solve.py, all offline pure numpy over raw GLB bytes — Blender is
only the interpreter host (numpy), no bpy, no scene import. The
"Blender-importer-draws-false-shards" trap therefore does not apply to the solver, but
see the render warning below.
- parse GLB, rest skeleton (node globals × IBM = skinning space) —
read_glb,Skeleton - hand ROI: verts within 1.8 cm of finger/hand bone segments, grown 3 edge rings —
build_roi:188 - ROI graph (CSR adjacency + unique edge list) —
roi_graph:208 - per-bone geodesic fields: multi-source Dijkstra over the ROI subgraph —
dijkstra_multi:226 - weights: gaussian kernels on geodesic distance, top-4, renormalized, smoothed —
solve_weights:250 - pose: parametric curl/spread/thumb-opposition per joint, pivoted at each joint head,
LBS with the solver's OWN weights (the GLB's rigid-mitt weights are irrelevant and
that is fine) —
pose_globals:336,lbs:398; pose parameters live inDEFAULT_PARAMS - relax: stretch-gated Laplacian diffusion of the DELTA field until the bars pass —
relax:448 - gates:
stretch_stats:434→handmorph_report.json+ per-pose OBJ dumps - emit: splice morph accessors into the GLB (deltas added to base — exactly Godot's
w = target + base) withextras.targetNames = hand_<pose>_<l|r>—emit_morph_glb:552. POSITION and NORMAL deltas are emitted (position-only morphs leave lighting on the rest shape and read as "torn texture").
--selftest-bump splices one synthetic 3 cm palm bump with no solve — use it to prove the
import + drive path end-to-end on any new body before trusting solver output.
THE BUG — weld before solve
stretch_stats (line 434) and the relax gate (line 448, s[lr < 0.001] = 1.0) both
apply a 1 mm rest-length floor. The reasoning was sound for decimation slivers, but
this mesh is unwelded chart soup: it carries coincident duplicate verts whose rest edge
length is ~0. Those edges are excluded from the stats AND from the convergence gate, so
the relaxation never even tries to fix them. Measured 2026-08-18: a population of
sub-mm edges stretches to 4–20 cm in fist/grip on BOTH hands (~25–60 per hand over 5×)
— hairline needles, sub-pixel in the screenshots that "passed", but really there.
grep -niE "weld|coincid|dedup" tools/handshape_solve.py returns zero hits — no weld
pass exists.
The fix, and the shape it has to take. Weld at the graph level, not by rewriting the
mesh: build a representative map over coincident positions (hash/round positions to ~1e-6,
or a KD-tree at ~10 µm), solve on the welded ROI, then scatter each welded vertex's delta
back to every duplicate in its weld group before full[idx] = relaxed. Morph deltas must
stay indexed by original vertex id (the GLB's own attribute order) or the splice breaks.
Because all members of a group then receive an identical delta, coincident edges keep
length exactly and the entire failure class dies by construction rather than by tuning.
Insert the weld between build_roi (called in main, ~line 228) and roi_graph, and make
sure the Dijkstra/adjacency also runs on the welded graph — otherwise geodesic distances
still leak across seams and the weight field stays fragmented.
Hypothesis worth testing while you are in there (state it as a hypothesis, do not assume): the right hand's tracked ≥1 mm failures may be the same disease. If duplicates split the delta field across a chart seam, welding should collapse a good share of those too. Measure before and after; report both.
Current numbers — the baseline you must beat
Body: Ariki_Female_QuatSkin_LowPoly_40.glb (restored lena_leafbikini_base_v01 mesh, so
no yellow-material defect). ROI = 5,548 verts. From
characters/work/lena_leafbikini/handmorph/handmorph_report.json (2026-08-18 08:49),
post-relax, ≥1 mm edges only:
| shape | max | p99.9 | n>2× | n>5× | iters |
|---|---|---|---|---|---|
| flat_l | 1.94 | 1.60 | 0 | 0 | 11 |
| relaxed_l | 3.60 | 1.49 | 3 | 0 | 60 |
| fist_l | 4.23 | 1.99 | 15 | 0 | 60 |
| grip_l | 4.38 | 1.97 | 13 | 0 | 60 |
| flat_r | 2.41 | 1.59 | 1 | 0 | 8 |
| relaxed_r | 4.08 | 3.22 | 81 | 0 | 60 |
| fist_r | 29.78 | 18.94 | 214 | 37 | 60 (hit cap) |
| grip_r | 20.38 | 12.10 | 372 | 35 | 60 (hit cap) |
Left hand is fully clean — better than the earlier LENA_rig_v1 body. Right fist/grip are the failures, and both burn all 60 relax iterations without converging.
Fingertip travel is healthy and should stay so: fist_l tips 9.0–10.3 cm (thumb 15.3), relaxed_l 1.7–2.0 cm (thumb 6.1).
Gate bars (per shape, ≥1 mm edges — and after your fix, the sliver population too): edge stretch p99.9 ≤ 1.6×, zero edges > 5×; fingertip travel fist ≥ 2.5 cm/finger, grip ~2 cm, relaxed 0.5–2 cm; cross-hand independence 0 cm on the other hand's verts.
How to run
Solve (Blender is just the numpy host):
"C:/Program Files/Blender Foundation/Blender 5.1/blender.exe" --background \
--factory-startup --python tools/handshape_solve.py -- \
--body C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_LowPoly_40.glb \
--poses flat,relaxed,fist,grip \
--out C:/Users/Jeremy/tinqs/ariki-game/scratchpad/lowpoly40_handmorph.glb \
--workdir characters/work/lena_leafbikini/handmorph/
Verify in-engine — the only honest gate:
HANDMORPH_BODY_F=res://scratchpad/lowpoly40_handmorph.glb \
SCENE=anim_hand_test_bed MOCK_ONLY=1 AGENT_OWNED=1 WAIT=1 bash tools/game.sh spawn
(HANDMORPH_BODY applies to both rigs, _F/_M per rig — see DancerRig.cs:40.
In dance_test_bed instead: 1 frames the team, J hand cam, K cycles
None→flat→relaxed→fist→grip→None.)
Two traps that have already cost time
- Do not judge the solver's OBJ dumps by clay render. The un-welded chart soup renders as black-gap confetti even at REST (flipped per-chart normals). It is dishonest in both directions. Judge morphs in-engine only. The numeric report + the bed are the gates.
handshape_verify.pyreportsown-delta = -1sentinels on this body — a mesh/bone space mismatch in the verifier, not in the solve. Don't trust it here; either fix the verifier or ignore it and rely on the report + bed.
Open decisions you own
- Ship-body decision — nothing shipped carries the shapes yet, so the layer is a
silent no-op on the real Lena.
LowPoly_40costs ~+41 MB for 8 shapes; full-res ~+98 MB, not shippable as-is. Options: fewer shapes (dropflat/relaxed— the bone lane already does flat on finger-weighted bodies, but the shipped Lena is a rigid mitt, so think it through), hand-region remesh, LOD1-only, or quantized deltas. Bring Jeremy the numbers and a recommendation; do not ship a 128 MB body silently. - Mako male + full-res female via the same one-command solve — untried.
- Pose authoring is currently "edit
DEFAULT_PARAMS". Could become JSON + a tuning scene. Low priority — only if pose tuning becomes the bottleneck. - Commit the tooling.
tools/handshape_solve.py,tools/handshape_verify.py,hand-shapes/README.mdand thehandmorph/workdir are all untracked in the animation repo. Commit the tools and README; keep the demo GLB out of git (128 MB,scratchpad/).
Scope fence — the parallel bone agent
Yours (edit freely): tools/handshape_solve.py, tools/handshape_verify.py,
hand-shapes/, characters/work/lena_leafbikini/handmorph/, ariki-game
src/Animation/HandMorphLayer.cs, scratchpad/lowpoly40_handmorph.glb.
Not yours (the bone agent is actively editing these): tools/handpose_*.py,
tools/edge_stretch.py, tools/fin_bones.py, hand-poses/,
characters/work/lena_leafbikini/08_finger_weights.py and the v02/ weight-experiment
bakes, ariki-game src/Animation/HandPoseLayer.cs.
Shared, coordinate before touching:
src/Testing/AnimHandTestBed.csandsrc/Testing/Dance/DanceTestBed.cs— both lanes' controls live in these files. Announce edits.- Only ONE
--agent-apigame instance may run at a time. The bed is a serialized resource; check whether the other agent is mid-run before you spawn. - ariki-game branch
handpose-flat-runtime, currently 4 local commits ahead and not pushed (40b995e21,518b72ac6,fa5e8e8de,d1e6fb0a5). Both agents commit here. Pull/rebase before committing, and never squash across the other lane's work.
Hard rules (unchanged):
- Do not re-export or swap any shipped body outside the registry process;
characters/female/lena_leafbikini_base_v01/is frozen. - Do not touch
ariki-game/tools/make_lena_fullres_quatskin.py;LENA_RIGID_FINGERSdefault stays"1". - Never commit
csproj/project.godotengine-version lines, and don't sweep the repo-wide.importchurn into your commits — the working tree has hundreds of modified.importsidecars that are not yours. Commit explicit paths only. - Do not enable the bone lane on Mako (corrupt cross-hand finger weights, verts fly
metres). The morph lane on Mako is safe in principle — but prove it with
--selftest-bumpfirst.
Acceptance for this track
- Weld pass lands in
handshape_solve.py; a fresh solve reports zero edges > 5× on all 8 shapes with the sliver floor removed (report the sliver population before/after). - Fingertip travel bars still met (fist ≥ 2.5 cm/finger), cross-hand independence 0 cm.
anim_hand_test_bedscreenshots at hand-cam range: fist and grip read as a real fist and grip on Lena mid-dance, both hands, no needles/shards/spikes.- A written ship-body recommendation with MB costs, for Jeremy's decision.
- Tools + README committed in the animation repo; no shipped asset or engine-version line changed.