feat(hand-shapes): morph-target hand-pose solver — fist/grip that actually deform
The morph lane's blocking bug was believed to be coincident duplicate verts hidden
from the solver's convergence gate. Welding was necessary but nowhere near
sufficient: the previous relaxation could satisfy every stretch bar without posing
anything, so the numbers it reported were not measuring the thing they claimed.
Measured on Ariki_Female_QuatSkin_LowPoly_40, the tracked "left hand is fully
clean" baseline was a morph that moved fingertips 0.3cm, and the right hand was a
rigid 6.5cm translation of the whole hand with its shape intact. Gated Laplacian
diffusion of a delta field has a null space — constants — and edge stretch cannot
see any of it: a rigid translation stretches no edge and neither does a collapse to
zero. Both exits report perfect bars.
Six defects fixed, each with its measurement in the code comments:
weld_roi 356 duplicate groups solved twice, deltas up to 2.55cm apart
stitch_components hand is 6 overlapping sheets with 1-10mm gaps; Dijkstra
cannot cross one, so each bone saw only its own sheet
refit_fingers skeleton finger chain ran to 19.8cm; the flesh ends at 14.0cm,
so curl_02/curl_03 drove almost no weight (shipped skeleton
untouched — this is solver-local scaffolding)
solve_weights chain-arc partition of unity; the old 8mm isotropic kernels
left ZERO of 17,480 verts owned above 0.85, and a 3-way blend
averages the curl away. Plus a support cutoff: outside every
kernel, renormalized 1e-81 noise had handed a mid-palm vertex
index_02_r=0.50 and flung it 29cm
build_roi joint-sphere ROI, so the rim is a wrist band and not a fractal
of interior chart holes
relax strain-only edge projection + ROI-border seam constraints,
replacing the diffusion described above
All 8 shapes now pass every bar with real deformation: p99.9 <= 1.58x, zero edges
over 5x, zero needles with the sliver floor removed, seam <= 3.2mm, cross-hand
independence exactly 0.0000cm, mesh fingertip travel 4.6-6.2cm on fist and
3.5-4.7cm on grip (both hands). Gates now report mesh travel and seam alongside
stretch, because stretch alone cannot gate this lane.
Verified in anim_hand_test_bed: fist and grip read as a real curl on both hands,
static and mid-dance, no fins/shards/needles. It is a loose fist rather than a
clenched one — her fingers are ~4-5cm past the knuckles.
Demo GLB stays out of git (122.6 MB, ariki-game/scratchpad/). Ship decision, the
thumb-axis refit, and the TDR crash from 8 dense targets are open — see README.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_LowPoly_40.glb",
|
||||
"roi_verts": 19159,
|
||||
"weld": {
|
||||
"enabled": true,
|
||||
"tol_m": 1e-05,
|
||||
"nodes": 17480,
|
||||
"merged_groups": 1605,
|
||||
"max_group": 4,
|
||||
"rim": 749
|
||||
},
|
||||
"stitch_edges": 980,
|
||||
"seam_edges": 1613,
|
||||
"poses": {
|
||||
"flat_l": {
|
||||
"raw": {
|
||||
"max": 2.5649668819269853,
|
||||
"p999": 1.4997998775715617,
|
||||
"n_gt2": 6,
|
||||
"n_gt5": 0,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 0,
|
||||
"slv_grow_gt1mm": 0,
|
||||
"slv_max_grow_mm": 0.92,
|
||||
"needles": 0,
|
||||
"max_grow_mm": 2.14
|
||||
},
|
||||
"relaxed": {
|
||||
"max": 1.35,
|
||||
"p999": 1.3499999999999406,
|
||||
"n_gt2": 0,
|
||||
"n_gt5": 0,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 0,
|
||||
"slv_grow_gt1mm": 0,
|
||||
"slv_max_grow_mm": 0.34,
|
||||
"needles": 0,
|
||||
"max_grow_mm": 1.44
|
||||
},
|
||||
"tip_bone_cm": {
|
||||
"thumb": 0.63,
|
||||
"index": 0.15,
|
||||
"middle": 0.15,
|
||||
"ring": 0.15,
|
||||
"pinky": 0.14
|
||||
},
|
||||
"tip_mesh_cm": {
|
||||
"index": 0.11,
|
||||
"middle": 0.11,
|
||||
"ring": 0.11,
|
||||
"pinky": 0.11
|
||||
},
|
||||
"relax_iters": 236,
|
||||
"viol_edges_left": 0,
|
||||
"viol_seam_left": 0,
|
||||
"seam_max_mm": 1.23
|
||||
},
|
||||
"relaxed_l": {
|
||||
"raw": {
|
||||
"max": 12.689021649045726,
|
||||
"p999": 8.077834064456647,
|
||||
"n_gt2": 1020,
|
||||
"n_gt5": 248,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 43,
|
||||
"slv_grow_gt1mm": 96,
|
||||
"slv_max_grow_mm": 9.97,
|
||||
"needles": 291,
|
||||
"max_grow_mm": 17.03
|
||||
},
|
||||
"relaxed": {
|
||||
"max": 1.3589166079445092,
|
||||
"p999": 1.3501469074679688,
|
||||
"n_gt2": 0,
|
||||
"n_gt5": 0,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 0,
|
||||
"slv_grow_gt1mm": 0,
|
||||
"slv_max_grow_mm": 0.35,
|
||||
"needles": 0,
|
||||
"max_grow_mm": 3.77
|
||||
},
|
||||
"tip_bone_cm": {
|
||||
"thumb": 3.86,
|
||||
"index": 1.42,
|
||||
"middle": 1.39,
|
||||
"ring": 1.33,
|
||||
"pinky": 1.3
|
||||
},
|
||||
"tip_mesh_cm": {
|
||||
"index": 0.96,
|
||||
"middle": 0.99,
|
||||
"ring": 0.99,
|
||||
"pinky": 0.96
|
||||
},
|
||||
"relax_iters": 1500,
|
||||
"viol_edges_left": 304,
|
||||
"viol_seam_left": 12,
|
||||
"seam_max_mm": 1.99
|
||||
},
|
||||
"fist_l": {
|
||||
"raw": {
|
||||
"max": 52.899800203384736,
|
||||
"p999": 34.724530961525744,
|
||||
"n_gt2": 2213,
|
||||
"n_gt5": 1455,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 103,
|
||||
"slv_grow_gt1mm": 158,
|
||||
"slv_max_grow_mm": 51.07,
|
||||
"needles": 1558,
|
||||
"max_grow_mm": 65.54
|
||||
},
|
||||
"relaxed": {
|
||||
"max": 1.634544698690311,
|
||||
"p999": 1.3844521052494323,
|
||||
"n_gt2": 0,
|
||||
"n_gt5": 0,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 0,
|
||||
"slv_grow_gt1mm": 0,
|
||||
"slv_max_grow_mm": 0.44,
|
||||
"needles": 0,
|
||||
"max_grow_mm": 4.11
|
||||
},
|
||||
"tip_bone_cm": {
|
||||
"thumb": 9.73,
|
||||
"index": 7.19,
|
||||
"middle": 7.1,
|
||||
"ring": 6.82,
|
||||
"pinky": 6.75
|
||||
},
|
||||
"tip_mesh_cm": {
|
||||
"index": 4.91,
|
||||
"middle": 4.9,
|
||||
"ring": 4.76,
|
||||
"pinky": 4.62
|
||||
},
|
||||
"relax_iters": 1500,
|
||||
"viol_edges_left": 1939,
|
||||
"viol_seam_left": 48,
|
||||
"seam_max_mm": 1.32
|
||||
},
|
||||
"grip_l": {
|
||||
"raw": {
|
||||
"max": 49.79406421161323,
|
||||
"p999": 30.545310726816965,
|
||||
"n_gt2": 2150,
|
||||
"n_gt5": 1353,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 101,
|
||||
"slv_grow_gt1mm": 140,
|
||||
"slv_max_grow_mm": 40.91,
|
||||
"needles": 1454,
|
||||
"max_grow_mm": 73.2
|
||||
},
|
||||
"relaxed": {
|
||||
"max": 1.8239868532277623,
|
||||
"p999": 1.4628749431140193,
|
||||
"n_gt2": 0,
|
||||
"n_gt5": 0,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 0,
|
||||
"slv_grow_gt1mm": 0,
|
||||
"slv_max_grow_mm": 0.57,
|
||||
"needles": 0,
|
||||
"max_grow_mm": 3.73
|
||||
},
|
||||
"tip_bone_cm": {
|
||||
"thumb": 10.03,
|
||||
"index": 5.47,
|
||||
"middle": 5.39,
|
||||
"ring": 5.18,
|
||||
"pinky": 5.09
|
||||
},
|
||||
"tip_mesh_cm": {
|
||||
"index": 3.71,
|
||||
"middle": 3.72,
|
||||
"ring": 3.63,
|
||||
"pinky": 3.52
|
||||
},
|
||||
"relax_iters": 1500,
|
||||
"viol_edges_left": 1984,
|
||||
"viol_seam_left": 53,
|
||||
"seam_max_mm": 1.32
|
||||
},
|
||||
"flat_r": {
|
||||
"raw": {
|
||||
"max": 7.3444863267240175,
|
||||
"p999": 3.087924956802205,
|
||||
"n_gt2": 103,
|
||||
"n_gt5": 6,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 2,
|
||||
"slv_grow_gt1mm": 4,
|
||||
"slv_max_grow_mm": 5.93,
|
||||
"needles": 8,
|
||||
"max_grow_mm": 6.94
|
||||
},
|
||||
"relaxed": {
|
||||
"max": 1.3508407237549374,
|
||||
"p999": 1.3500148699201415,
|
||||
"n_gt2": 0,
|
||||
"n_gt5": 0,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 0,
|
||||
"slv_grow_gt1mm": 0,
|
||||
"slv_max_grow_mm": 0.35,
|
||||
"needles": 0,
|
||||
"max_grow_mm": 2.11
|
||||
},
|
||||
"tip_bone_cm": {
|
||||
"thumb": 0.77,
|
||||
"index": 0.15,
|
||||
"middle": 0.16,
|
||||
"ring": 0.15,
|
||||
"pinky": 0.15
|
||||
},
|
||||
"tip_mesh_cm": {
|
||||
"index": 0.13,
|
||||
"middle": 0.13,
|
||||
"ring": 0.13,
|
||||
"pinky": 0.12
|
||||
},
|
||||
"relax_iters": 1500,
|
||||
"viol_edges_left": 141,
|
||||
"viol_seam_left": 17,
|
||||
"seam_max_mm": 1.47
|
||||
},
|
||||
"relaxed_r": {
|
||||
"raw": {
|
||||
"max": 45.958092227798915,
|
||||
"p999": 19.548705590243003,
|
||||
"n_gt2": 736,
|
||||
"n_gt5": 315,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 24,
|
||||
"slv_grow_gt1mm": 60,
|
||||
"slv_max_grow_mm": 40.74,
|
||||
"needles": 339,
|
||||
"max_grow_mm": 50.09
|
||||
},
|
||||
"relaxed": {
|
||||
"max": 1.6604900471831592,
|
||||
"p999": 1.4451486205473596,
|
||||
"n_gt2": 0,
|
||||
"n_gt5": 0,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 0,
|
||||
"slv_grow_gt1mm": 0,
|
||||
"slv_max_grow_mm": 0.68,
|
||||
"needles": 0,
|
||||
"max_grow_mm": 2.78
|
||||
},
|
||||
"tip_bone_cm": {
|
||||
"thumb": 4.59,
|
||||
"index": 1.42,
|
||||
"middle": 1.42,
|
||||
"ring": 1.36,
|
||||
"pinky": 1.32
|
||||
},
|
||||
"tip_mesh_cm": {
|
||||
"index": 1.13,
|
||||
"middle": 1.15,
|
||||
"ring": 1.13,
|
||||
"pinky": 1.11
|
||||
},
|
||||
"relax_iters": 1500,
|
||||
"viol_edges_left": 686,
|
||||
"viol_seam_left": 60,
|
||||
"seam_max_mm": 2.63
|
||||
},
|
||||
"fist_r": {
|
||||
"raw": {
|
||||
"max": 118.26346663296857,
|
||||
"p999": 58.38873513669817,
|
||||
"n_gt2": 3820,
|
||||
"n_gt5": 1139,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 106,
|
||||
"slv_grow_gt1mm": 299,
|
||||
"slv_max_grow_mm": 116.16,
|
||||
"needles": 1245,
|
||||
"max_grow_mm": 132.62
|
||||
},
|
||||
"relaxed": {
|
||||
"max": 1.7741351039193665,
|
||||
"p999": 1.535993990998224,
|
||||
"n_gt2": 0,
|
||||
"n_gt5": 0,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 0,
|
||||
"slv_grow_gt1mm": 0,
|
||||
"slv_max_grow_mm": 0.63,
|
||||
"needles": 0,
|
||||
"max_grow_mm": 4.39
|
||||
},
|
||||
"tip_bone_cm": {
|
||||
"thumb": 12.24,
|
||||
"index": 7.22,
|
||||
"middle": 7.28,
|
||||
"ring": 6.97,
|
||||
"pinky": 6.87
|
||||
},
|
||||
"tip_mesh_cm": {
|
||||
"index": 6.01,
|
||||
"middle": 6.24,
|
||||
"ring": 6.2,
|
||||
"pinky": 6.16
|
||||
},
|
||||
"relax_iters": 1500,
|
||||
"viol_edges_left": 6109,
|
||||
"viol_seam_left": 247,
|
||||
"seam_max_mm": 3.23
|
||||
},
|
||||
"grip_r": {
|
||||
"raw": {
|
||||
"max": 134.1470205961321,
|
||||
"p999": 64.71684078507103,
|
||||
"n_gt2": 3189,
|
||||
"n_gt5": 1040,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 89,
|
||||
"slv_grow_gt1mm": 253,
|
||||
"slv_max_grow_mm": 129.98,
|
||||
"needles": 1129,
|
||||
"max_grow_mm": 150.49
|
||||
},
|
||||
"relaxed": {
|
||||
"max": 2.226320726002906,
|
||||
"p999": 1.5799820313724093,
|
||||
"n_gt2": 1,
|
||||
"n_gt5": 0,
|
||||
"slv_n": 2125,
|
||||
"slv_gt5x": 0,
|
||||
"slv_grow_gt1mm": 0,
|
||||
"slv_max_grow_mm": 0.75,
|
||||
"needles": 0,
|
||||
"max_grow_mm": 4.04
|
||||
},
|
||||
"tip_bone_cm": {
|
||||
"thumb": 13.09,
|
||||
"index": 5.5,
|
||||
"middle": 5.53,
|
||||
"ring": 5.29,
|
||||
"pinky": 5.18
|
||||
},
|
||||
"tip_mesh_cm": {
|
||||
"index": 4.5,
|
||||
"middle": 4.66,
|
||||
"ring": 4.6,
|
||||
"pinky": 4.54
|
||||
},
|
||||
"relax_iters": 1500,
|
||||
"viol_edges_left": 5005,
|
||||
"viol_seam_left": 197,
|
||||
"seam_max_mm": 3.07
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
# Hand shapes — morph-target hand poses for ariki-game (the "shape" lane)
|
||||
|
||||
Where `../hand-poses/` stores poses as **bone rotations** (blocked on a finger-weight
|
||||
repair that stalled at exp05), this lane bakes poses as **surface deformation** — glTF
|
||||
morph targets spliced directly into a body GLB. The pose library ships *inside the mesh*.
|
||||
|
||||
## Why this exists (the one-paragraph case)
|
||||
|
||||
The scan mesh carries ~2.6k inter-digit bridge edges. Weights only choose *which bone
|
||||
drags a shared vertex* — when adjacent fingers curl apart in a fist, those bridges must
|
||||
tear, which is exactly the fin-stack failure of exp01–exp05 (torn-edge counts 800–3300
|
||||
per hand, five iterations, no convergence; the mesh topology is the problem, not the
|
||||
weights). A morph target IS the final vertex positions: tearing is impossible by
|
||||
construction, and the web stretch becomes one geometric fix.
|
||||
|
||||
## What this mesh actually is (measured 2026-08-18, `LowPoly_40`)
|
||||
|
||||
Every one of these was silently breaking the solve. Read before tuning anything.
|
||||
|
||||
| Fact | Number | Consequence |
|
||||
|---|---|---|
|
||||
| Coincident duplicate verts on chart seams | 356 groups / 732 verts | solved twice, deltas disagreed by up to 2.55cm → seam cracks |
|
||||
| Hand is built from separate overlapping sheets | 6 components, gaps 1.1–10mm | Dijkstra cannot cross a gap: each bone's field covers only the sheet its seeds landed on |
|
||||
| Skeleton finger chain overshoots the flesh | mesh ends 14.0cm from the wrist, `_03` joints sit at 19.8cm | `_02`/`_03` carried almost no weight, so `curl_02`/`curl_03` did nothing; the 4mm seed radius found no verts for 10 of 15 bones |
|
||||
| Detached fragment near the right wrist | 134 verts | took `index_02_r` through a seed leak and flew **31cm** on fist_r |
|
||||
| Verts owned by any single phalanx | **zero** of 17,480 above 0.85 | 8mm kernels are wider than the gap to the next phalanx; a 3-way blend averages the curl away |
|
||||
|
||||
## Pipeline (`tools/handshape_solve.py`, pure numpy on raw GLB bytes)
|
||||
|
||||
1. **refit** the finger chain into the flesh (`refit_fingers`) — solver-local scaffolding
|
||||
only; the shipped skeleton is never touched, because every clip pins all 65 bone
|
||||
positions. A morph is just final vertex positions, so the pose only needs pivots that
|
||||
lie inside the flesh they bend.
|
||||
2. **ROI** = union of spheres about the wrist + refit joints (`build_roi`). NOT a tube
|
||||
about bone segments — a tube leaves the ROI riddled with interior chart holes, so its
|
||||
"rim" is a fractal inside the hand rather than a wrist band.
|
||||
3. **weld** coincident verts into single graph nodes (`weld_roi`); deltas scatter back to
|
||||
every duplicate, so seams cannot crack by construction.
|
||||
4. **stitch** separate sheets within 12mm (`stitch_components`) — cross-component pairs
|
||||
only, so a stitch can never fake a shortcut inside a sheet.
|
||||
5. **weights** as a partition of unity along each digit's chain arc (`solve_weights`):
|
||||
narrow handover ramps at each joint (1.0 mid-phalanx, 0.5 at the joint), times digit
|
||||
ownership from lateral distance *relative to the nearest chain*.
|
||||
6. **pose** parametric curl/spread/thumb-opposition, LBS with the solver's own weights
|
||||
(`pose_globals`, `lbs`); parameters in `DEFAULT_PARAMS`.
|
||||
7. **relax** by strain-only edge projection (`relax`) plus ROI-border seam constraints.
|
||||
8. **emit** POSITION *and* NORMAL deltas as morph accessors (`emit_morph_glb`), names in
|
||||
`extras.targetNames` as `hand_<pose>_<l|r>`.
|
||||
|
||||
`--selftest-bump` splices one synthetic 3cm palm bump with no solve — proves the
|
||||
import + drive path on a new body. `--no-weld` / `--no-stitch` / `--no-refit` reproduce
|
||||
the older behaviour for comparison.
|
||||
|
||||
### The trap that invalidated every earlier gate
|
||||
|
||||
The previous relaxation was **gated Laplacian diffusion of the delta field**. Diffusion
|
||||
has a null space — constants — and edge stretch is blind to every member of it: 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
|
||||
on this body: the left hand 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), and the
|
||||
right hand converged to a near-constant **6.5cm delta at every arc position from wrist to
|
||||
fingertip** — the whole hand translated sideways with its shape intact (max 2.43x,
|
||||
p99.9 1.46x, also "passing"). Strain-only projection has no such exit: a conforming edge
|
||||
contributes no correction, so the pose survives wherever it does not tear.
|
||||
|
||||
Corollary: **edge stretch alone can never gate this lane.** Always read mesh fingertip
|
||||
travel (`tip_mesh_cm`) and `seam_max_mm` beside it. `tip_bone_cm` is scaffolding — it
|
||||
read 10cm/finger while the `_03` joints floated 5cm outside the mesh.
|
||||
|
||||
## Bake a body
|
||||
|
||||
```
|
||||
"C:/Program Files/Blender Foundation/Blender 5.1/blender.exe" --background \
|
||||
--factory-startup --python tools/handshape_solve.py -- \
|
||||
--body <body.glb> --poses flat,relaxed,fist,grip --out <out.glb> \
|
||||
--workdir characters/work/lena_leafbikini/handmorph/
|
||||
```
|
||||
|
||||
Blender is only the numpy host — no bpy, no scene import (so the
|
||||
importer-draws-false-shards trap does not apply to the solver). ~1 min for 8 shapes.
|
||||
Numbers land in `handmorph/handmorph_report.json`, OBJ dumps beside it.
|
||||
|
||||
## Gate bars (per shape)
|
||||
|
||||
- edge stretch over **all** edges, no rest-length floor: p99.9 <= 1.6x, zero > 5x, zero
|
||||
"needles" (>5x *and* >1mm of real growth). The old 1mm floor hid a population of sub-mm
|
||||
seam edges that grew to 3–4cm — hairline spikes, sub-pixel in screenshots.
|
||||
- **mesh** fingertip travel: fist >= 2.5 cm/finger, grip ~2 cm, relaxed 0.5–2 cm
|
||||
- ROI-border seam: <= ~5mm
|
||||
- cross-hand independence: 0 cm on the other hand's verts
|
||||
|
||||
Current (2026-08-18, `Ariki_Female_QuatSkin_LowPoly_40.glb`, ROI 19,159 → 17,480 nodes):
|
||||
|
||||
| shape | max | p99.9 | n>5x | needles | seam | mesh tip cm |
|
||||
|---|---|---|---|---|---|---|
|
||||
| flat_l | 1.35 | 1.35 | 0 | 0 | 1.2mm | 0.1 |
|
||||
| relaxed_l | 1.36 | 1.35 | 0 | 0 | 2.0mm | 1.0 |
|
||||
| fist_l | 1.63 | 1.38 | 0 | 0 | 1.3mm | 4.6–4.9 |
|
||||
| grip_l | 1.82 | 1.46 | 0 | 0 | 1.3mm | 3.5–3.7 |
|
||||
| flat_r | 1.35 | 1.35 | 0 | 0 | 1.5mm | 0.1 |
|
||||
| relaxed_r | 1.66 | 1.45 | 0 | 0 | 2.6mm | 1.1–1.2 |
|
||||
| fist_r | 1.77 | 1.54 | 0 | 0 | 3.2mm | 6.0–6.2 |
|
||||
| grip_r | 2.23 | 1.58 | 0 | 0 | 3.1mm | 4.5–4.7 |
|
||||
|
||||
Cross-hand independence is exactly 0.0000 cm on all 8 shapes. `viol_edges_left` in the
|
||||
report counts edges still above the *soft* 1.35x projection target (~6k on fist/grip after
|
||||
1500 iterations) — not a bar, but the reason `max` sits near 1.8x rather than 1.35x.
|
||||
|
||||
## 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
|
||||
# then: game.sh click 'fist' / 'grip' / 'flat' / 'morph OFF' / 'Cam: Lena hands'
|
||||
```
|
||||
|
||||
Verified 2026-08-18: `[HandMorphLayer] found 4 hand pose(s)`; fist and grip both read as
|
||||
a real curl on both hands, static and mid-`dance_soul`, at hand-cam range — no fins,
|
||||
shards, needles or stray geometry. It reads as a **loose fist / cupped hand**, not a
|
||||
clenched one: her fingers are only ~4–5cm long past the knuckles, so ~6cm of tip travel
|
||||
is most of the range available.
|
||||
|
||||
Two environment notes: this demo body carries the **yellow LowPoly-bake defect** (yellow
|
||||
with the morph on *and* off — it is the body, not the lane), and 8 dense morph targets on
|
||||
a 430k-vert mesh **crashed the GPU driver** (`Vulkan device was lost`, TDR) after ~1–2
|
||||
minutes of bed time. Take screenshots promptly, and treat runtime cost as an open risk.
|
||||
|
||||
## Relationship to the bone lane (`../hand-poses/`)
|
||||
|
||||
Independent and composable: the bone lane overrides finger-bone rotations (needs
|
||||
finger-weighted bodies); this lane deforms the surface (works on ANY body carrying the
|
||||
shapes, including the shipped rigid-mitt bodies). Hotkeys **H** bone lane, **K** shape
|
||||
lane in the dance bed; button panels in `anim_hand_test_bed`.
|
||||
|
||||
## Open items
|
||||
|
||||
- **Ship decision.** Nothing shipped carries the shapes, so the layer is a silent no-op
|
||||
on the real Lena. Dense float32 POSITION+NORMAL deltas over all 430,551 verts cost
|
||||
**~9.9 MB per shape**: 43.8 MB → 122.6 MB for 8 (**+78.8 MB**, not the +41 MB the
|
||||
handover estimated). Only 5.5k–9.7k verts per shape are non-zero (2.2%), so glTF
|
||||
**sparse accessors** are a ~30x lever (~2.5 MB for all 8) — but the engine's glTF
|
||||
module appears to *write* sparse accessors without reading them, so test one shape
|
||||
before betting on it. Fallbacks: drop `flat` (max delta 0.33–0.66cm — nearly a no-op)
|
||||
and `relaxed`, keeping fist+grip = 4 shapes at ~+39 MB; or LOD1-only; or a hand-region
|
||||
remesh. The TDR crash above says runtime cost needs measuring too, not just bytes.
|
||||
- **Thumb chain refit is unreliable.** Its reach is measured along a wrist→tip axis that
|
||||
passes through the palm, so palm/wrist flesh gets claimed by the thumb (a vertex 5cm
|
||||
from the wrist came out `thumb_01_r`=0.88 and swung 4.8cm). The right hand still shows
|
||||
1.4cm of wrist motion on fist; the left shows none. Needs a thumb-specific axis.
|
||||
- Projection does not fully converge to 1.35x within 1500 iterations (see above).
|
||||
- `tools/handshape_verify.py` reports `own-delta = -1` sentinels on this body — a
|
||||
mesh/bone space mismatch in the verifier, not in the solve. Superseded in practice by
|
||||
the report plus the bed; fix it or retire it.
|
||||
- Mako male and full-res female via the same one-command solve — untried.
|
||||
- Pose authoring is editing `DEFAULT_PARAMS` — could become JSON plus a tuning scene.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
"""Verify the emitted hand-morph GLB numerically:
|
||||
1. CROSS-HAND INDEPENDENCE: each hand_<pose>_<side> shape must move ONLY that side's verts.
|
||||
2. CURL DIRECTION: per shape, left-hand fingertip-region deltas must point toward the palm
|
||||
(dot with palm normal < 0) — catches a flipped normal making fingers bend backward.
|
||||
"""
|
||||
import json, struct, sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
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 acc(g, b, i):
|
||||
a = g["accessors"][i]; bv = g["bufferViews"][a["bufferView"]]
|
||||
dt = np.dtype({5126: "<f4", 5123: "<u2", 5121: "u1", 5125: "<u4", 5122: "<i2"}[a["componentType"]])
|
||||
nc = {"VEC3": 3, "SCALAR": 1, "VEC4": 4}[a["type"]]
|
||||
size = dt.itemsize * nc
|
||||
stride = bv.get("byteStride") or size
|
||||
off2 = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
|
||||
raw = np.frombuffer(b, np.uint8, a["count"] * stride, off2).reshape(a["count"], stride)
|
||||
return raw[:, :size].copy().view(dt).reshape(a["count"], nc).astype(np.float64)
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else argv[1:]
|
||||
g, b = read_glb(argv[0])
|
||||
prim = g["meshes"][0]["primitives"][0]
|
||||
P = acc(g, b, prim["attributes"]["POSITION"])
|
||||
names = [g["nodes"][j].get("name", "") for j in g["skins"][0]["joints"]]
|
||||
idx = {n: i for i, n in enumerate(names)}
|
||||
|
||||
# bone heads in world (mesh) space: node global translations
|
||||
def node_local(nd):
|
||||
t = np.array(nd.get("translation", [0, 0, 0])); s = np.array(nd.get("scale", [1, 1, 1]))
|
||||
x, y, z, w = nd.get("rotation", [0, 0, 0, 1])
|
||||
n = np.sqrt(x*x+y*y+z*z+w*w); x, y, z, w = x/n, y/n, z/n, w/n
|
||||
R = 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)]])
|
||||
M = np.eye(4); M[:3, :3] = R * s[None, :]; M[:3, 3] = t
|
||||
return M
|
||||
|
||||
node_to_joint = {j: i for i, j in enumerate(g["skins"][0]["joints"])}
|
||||
parent = {}
|
||||
for i, j in enumerate(g["skins"][0]["joints"]):
|
||||
for c in g["nodes"][j].get("children", []):
|
||||
if c in node_to_joint: parent[node_to_joint[c]] = i
|
||||
G = {}
|
||||
def glob(i):
|
||||
if i in G: return G[i]
|
||||
j = g["skins"][0]["joints"][i]
|
||||
M = node_local(g["nodes"][j])
|
||||
G[i] = M if parent.get(i, -1) < 0 else glob(parent[i]) @ M
|
||||
return G[i]
|
||||
|
||||
def head(n): return glob(idx[n])[:3, 3]
|
||||
|
||||
# palm plane per side: normal via middle/pinky/index MCP + hand
|
||||
for side in ("l", "r"):
|
||||
h = head(f"hand_{side}")
|
||||
mid, ix, pk = head(f"middle_01_{side}"), head(f"index_01_{side}"), head(f"pinky_01_{side}")
|
||||
n = np.cross(mid - h, pk - ix); n /= np.linalg.norm(n)
|
||||
print(f"side {side}: palm normal (world) = {np.round(n, 3)}")
|
||||
|
||||
# nearest-bone segmentation for vert labeling (euclidean, fingers only — verification only)
|
||||
finger_bones = [f"{d}_{p}{s}" for s in ("l", "r") for d in ("thumb","index","middle","ring","pinky")
|
||||
for p in ("01","02","03") if f"{d}_{p}{s}" in idx]
|
||||
heads = {bn: head(bn) for bn in finger_bones}
|
||||
dmin = np.full(len(P), 1e9); label = np.full(len(P), -1, dtype=int)
|
||||
for bi, bn in enumerate(finger_bones):
|
||||
d = np.linalg.norm(P - heads[bn], axis=1)
|
||||
closer = d < dmin
|
||||
dmin[closer] = d[closer]; label[closer] = bi
|
||||
# keep verts within 8cm of their nearest finger bone (fingertip region <= 3.5cm for direction)
|
||||
targets = prim["targets"]
|
||||
tnames = g["meshes"][0]["extras"]["targetNames"]
|
||||
for ti, tname in enumerate(tnames):
|
||||
delta = acc(g, b, targets[ti]["POSITION"])
|
||||
mag = np.linalg.norm(delta, axis=1)
|
||||
side = tname[-1]
|
||||
# 1) cross-hand: verts whose nearest bone is the OTHER side must not move
|
||||
other = np.array([finger_bones[l][-1] != side if l >= 0 else False for l in label])
|
||||
other &= dmin < 0.08
|
||||
cross = float(mag[other].max()) if other.any() else 0.0
|
||||
# 2) curl direction: fingertip verts of THIS side (non-thumb, nearest <= 3.5cm of _03 bone)
|
||||
tip = np.zeros(len(P), dtype=bool)
|
||||
for d in ("index", "middle", "ring", "pinky"):
|
||||
bn = f"{d}_03_{side}"
|
||||
if bn in heads:
|
||||
tip |= (np.linalg.norm(P - heads[bn], axis=1) < 0.035)
|
||||
if tip.any():
|
||||
h = head(f"hand_{side}")
|
||||
mid, ix, pk = head(f"middle_01_{side}"), head(f"index_01_{side}"), head(f"pinky_01_{side}")
|
||||
n = np.cross(mid - h, pk - ix); n /= np.linalg.norm(n)
|
||||
dots = delta[tip] @ n
|
||||
toward = float((dots < 0).mean())
|
||||
else:
|
||||
toward = -1
|
||||
own = ~other & (dmin < 0.08)
|
||||
ownmax = float(mag[own].max()) * 100 if own.any() else -1.0
|
||||
print(f"{tname}: max|delta| other-hand={cross*100:.2f}cm tip-toward-palm={toward*100:.0f}% "
|
||||
f"max|delta| own={ownmax:.1f}cm")
|
||||
Reference in New Issue
Block a user