From 36fc0494e8dfc1a6dfd2f0a39d3571f12ee11734 Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Tue, 18 Aug 2026 10:50:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(hand-shapes):=20morph-target=20hand-pose?= =?UTF-8?q?=20solver=20=E2=80=94=20fist/grip=20that=20actually=20deform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../handmorph/handmorph_report.json | 360 ++++++ hand-shapes/README.md | 156 +++ tools/handshape_solve.py | 1131 +++++++++++++++++ tools/handshape_verify.py | 109 ++ 4 files changed, 1756 insertions(+) create mode 100644 characters/work/lena_leafbikini/handmorph/handmorph_report.json create mode 100644 hand-shapes/README.md create mode 100644 tools/handshape_solve.py create mode 100644 tools/handshape_verify.py diff --git a/characters/work/lena_leafbikini/handmorph/handmorph_report.json b/characters/work/lena_leafbikini/handmorph/handmorph_report.json new file mode 100644 index 0000000..9e83519 --- /dev/null +++ b/characters/work/lena_leafbikini/handmorph/handmorph_report.json @@ -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 + } + } +} \ No newline at end of file diff --git a/hand-shapes/README.md b/hand-shapes/README.md new file mode 100644 index 0000000..9e2adf2 --- /dev/null +++ b/hand-shapes/README.md @@ -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__`. + +`--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 --poses flat,relaxed,fist,grip --out \ + --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. diff --git a/tools/handshape_solve.py b/tools/handshape_solve.py new file mode 100644 index 0000000..0db6de4 --- /dev/null +++ b/tools/handshape_solve.py @@ -0,0 +1,1131 @@ +"""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__; 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 --poses relaxed,fist,grip --out --workdir + + --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(" 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: " 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 _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(" {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() diff --git a/tools/handshape_verify.py b/tools/handshape_verify.py new file mode 100644 index 0000000..56c65c8 --- /dev/null +++ b/tools/handshape_verify.py @@ -0,0 +1,109 @@ +"""Verify the emitted hand-morph GLB numerically: +1. CROSS-HAND INDEPENDENCE: each hand__ 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("= 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")