Files
animation/.agents/plans/lena-handmorph-track-2026-08-18.md
T
jeremy 4c3870336f docs(plans): archive the lena handmorph track handover (2026-08-18)
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>
2026-08-18 11:31:12 -07:00

12 KiB
Raw Blame History

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 (exp01exp05) 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 (commit 40b995e21). Discovers hand_<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), plus hand-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 newer anim_hand_test_bed (commit d1e6fb0a5, Lena + Mako side by side as game-model rigs, button panels for both hand lanes + hand-follow cams). Prefer anim_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.

  1. parse GLB, rest skeleton (node globals × IBM = skinning space) — read_glb, Skeleton
  2. hand ROI: verts within 1.8 cm of finger/hand bone segments, grown 3 edge rings — build_roi:188
  3. ROI graph (CSR adjacency + unique edge list) — roi_graph:208
  4. per-bone geodesic fields: multi-source Dijkstra over the ROI subgraph — dijkstra_multi:226
  5. weights: gaussian kernels on geodesic distance, top-4, renormalized, smoothed — solve_weights:250
  6. 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 in DEFAULT_PARAMS
  7. relax: stretch-gated Laplacian diffusion of the DELTA field until the bars pass — relax:448
  8. gates: stretch_stats:434handmorph_report.json + per-pose OBJ dumps
  9. emit: splice morph accessors into the GLB (deltas added to base — exactly Godot's w = target + base) with extras.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 420 cm in fist/grip on BOTH hands (~2560 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.010.3 cm (thumb 15.3), relaxed_l 1.72.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.52 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.py reports own-delta = -1 sentinels 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

  1. Ship-body decision — nothing shipped carries the shapes yet, so the layer is a silent no-op on the real Lena. LowPoly_40 costs ~+41 MB for 8 shapes; full-res ~+98 MB, not shippable as-is. Options: fewer shapes (drop flat/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.
  2. Mako male + full-res female via the same one-command solve — untried.
  3. Pose authoring is currently "edit DEFAULT_PARAMS". Could become JSON + a tuning scene. Low priority — only if pose tuning becomes the bottleneck.
  4. Commit the tooling. tools/handshape_solve.py, tools/handshape_verify.py, hand-shapes/README.md and the handmorph/ 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.cs and src/Testing/Dance/DanceTestBed.cs — both lanes' controls live in these files. Announce edits.
  • Only ONE --agent-api game 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_FINGERS default stays "1".
  • Never commit csproj / project.godot engine-version lines, and don't sweep the repo-wide .import churn into your commits — the working tree has hundreds of modified .import sidecars 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-bump first.

Acceptance for this track

  1. 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).
  2. Fingertip travel bars still met (fist ≥ 2.5 cm/finger), cross-hand independence 0 cm.
  3. anim_hand_test_bed screenshots at hand-cam range: fist and grip read as a real fist and grip on Lena mid-dance, both hands, no needles/shards/spikes.
  4. A written ship-body recommendation with MB costs, for Jeremy's decision.
  5. Tools + README committed in the animation repo; no shipped asset or engine-version line changed.