From 0913a5972c76d8210e00621db5338bbc20401a21 Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Tue, 18 Aug 2026 08:48:55 -0700 Subject: [PATCH 1/9] chore(rig-work): archive GLM's LENA_rig_v1 exports, pulled from the game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The articulated-finger/straight-leg rig model GLM shipped into ariki-game 2026-08-15, replacing the sanctioned lena_leafbikini_base_v01 ship. Jeremy pulled it from the game 2026-08-18; ariki-game bodies reverted to pre-swap. The blend source does not survive — these exports are the only artifacts. The LOD1 bake is the 'yellow Lena': one skin atlas wired into all three texture slots, so metallic/roughness sample skin pixels (see README). Co-Authored-By: Claude Fable 5 --- characters/rig-work/lena_rig_v1/README.md | 35 +++++++++++++++++++ .../lena_rig_v1_lowpoly40_yellowbake.glb | 3 ++ .../lena_rig_v1/lena_rig_v1_quatskin.glb | 3 ++ 3 files changed, 41 insertions(+) create mode 100644 characters/rig-work/lena_rig_v1/README.md create mode 100644 characters/rig-work/lena_rig_v1/lena_rig_v1_lowpoly40_yellowbake.glb create mode 100644 characters/rig-work/lena_rig_v1/lena_rig_v1_quatskin.glb diff --git a/characters/rig-work/lena_rig_v1/README.md b/characters/rig-work/lena_rig_v1/README.md new file mode 100644 index 0000000..9f65f7d --- /dev/null +++ b/characters/rig-work/lena_rig_v1/README.md @@ -0,0 +1,35 @@ +# LENA_rig_v1 — GLM's articulated-finger Lena (pulled from the game 2026-08-18) + +The "hands attempt" rig model: straight-leg / flat-foot / articulated-finger Lena, +authored by GLM in `LENA_rig_v1.blend` and shipped into ariki-game on 2026-08-15 as +three commits (`440e36230`, `b82b46437`, `51066d64e`), replacing BOTH shipped female +bodies outside the registry process. Jeremy pulled it from the game on 2026-08-18 +("keep the model in the animation repo, not in game") — ariki-game's +`Ariki_Female_QuatSkin.glb` and `Ariki_Female_QuatSkin_LowPoly_40.glb` were reverted +to the pre-swap `lena_leafbikini_base_v01` ship (`440e36230^`, byte-identical to the +in-game `_archive_lena/*_splayed-mitts_preswap_2026-08-15.glb` copies). + +**The blend does not survive.** `LENA_rig_v1.blend` exists nowhere on disk (C: and A: +checkouts searched 2026-08-18) — these two GLB exports are the only artifacts of the +model. If the rig is ever wanted again, it must be rebuilt or re-exported from +whatever GLM environment produced it. + +| file | what it is | +|---|---| +| `lena_rig_v1_quatskin.glb` | full-res export (= game history `51066d64e`). Materials are CORRECT (separate normal / basecolor / rm images). | +| `lena_rig_v1_lowpoly40_yellowbake.glb` | LOD1 bake (= game history `51066d64e`). **Materially broken — the yellow body.** | + +## Why it rendered yellow (the LOD1 defect, so nobody re-ships it blind) + +The LOD1 bake embeds ONE image (`Lena_rig_v1_LowPoly_40_Baked` — a normal-looking +skin/leaf atlas) but declares three texture entries **all pointing at that one image** +(`textures: [{source:0},{source:0},{source:0}]`), wired to normalTexture, +baseColorTexture AND metallicRoughnessTexture. glTF reads metallic from the blue +channel and roughness from green, so skin pixels (B≈0.45) render half-metallic and +glossy — the body picks up sky/ground bounce and goes shiny yellow-orange in-game. +The skin atlas doubling as a normal map adds the lumpy shading on top. The full-res +export does NOT have this bug; only the LOD1 bake does. + +Fix if reusing: re-bake LOD1 with real normal + rm images, or strip +`normalTexture`/`metallicRoughnessTexture` and set `metallicFactor: 0`, +`roughnessFactor: ~0.9` on `MI_Body_Lena`. diff --git a/characters/rig-work/lena_rig_v1/lena_rig_v1_lowpoly40_yellowbake.glb b/characters/rig-work/lena_rig_v1/lena_rig_v1_lowpoly40_yellowbake.glb new file mode 100644 index 0000000..efed09d --- /dev/null +++ b/characters/rig-work/lena_rig_v1/lena_rig_v1_lowpoly40_yellowbake.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8393ad4ed4cf053be517e42fd654f45d7de1209267fee851b16ef6b4051c12c9 +size 45976576 diff --git a/characters/rig-work/lena_rig_v1/lena_rig_v1_quatskin.glb b/characters/rig-work/lena_rig_v1/lena_rig_v1_quatskin.glb new file mode 100644 index 0000000..295382b --- /dev/null +++ b/characters/rig-work/lena_rig_v1/lena_rig_v1_quatskin.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3315e4aa1eb89c0cb9da89c156402294e23227c34e634f1bac25fefb6b6cce0c +size 79011768 From 36fc0494e8dfc1a6dfd2f0a39d3571f12ee11734 Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Tue, 18 Aug 2026 10:50:16 -0700 Subject: [PATCH 2/9] =?UTF-8?q?feat(hand-shapes):=20morph-target=20hand-po?= =?UTF-8?q?se=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") From 4c3870336f08bffa5d63fbacb8ee025a38f406f2 Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Tue, 18 Aug 2026 11:29:54 -0700 Subject: [PATCH 3/9] docs(plans): archive the lena handmorph track handover (2026-08-18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../plans/lena-handmorph-track-2026-08-18.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 .agents/plans/lena-handmorph-track-2026-08-18.md diff --git a/.agents/plans/lena-handmorph-track-2026-08-18.md b/.agents/plans/lena-handmorph-track-2026-08-18.md new file mode 100644 index 0000000..ae47972 --- /dev/null +++ b/.agents/plans/lena-handmorph-track-2026-08-18.md @@ -0,0 +1,220 @@ +# Handover: Lena hand MORPH track (blend-shape lane) — 2026-08-18 + +You own the **morph lane**. A parallel agent owns the **bone lane** (finger weights + +`HandPoseLayer`). Read the scope fence before touching anything — the two lanes share a +git branch and one serialized test bed. + +## Goal + +Lena can hold **fist** and **grip** in-game, on the **shipped** body. Flat already works +via the bone lane, so flat is not your problem — the two poses that never made it are +fist and grip, and this lane is the one that can carry them, because it does not depend +on finger weights at all. + +The blocking bug is known and localized: **coincident (unwelded) duplicate vertices in +the scan mesh are invisible to the solver's convergence gate**, so the right hand's +fist/grip still tear. Fix that, re-solve, get a bed visual, then make the ship-body call. + +## Why this lane exists (do not re-litigate) + +Lena's mesh is a Tripo scan with ~2.6k inter-digit bridge edges (web remnants). Bone +weights only choose *which bone drags a shared vertex* — when adjacent digits curl apart +in a fist, those bridges MUST tear. Five weight-rebake iterations (exp01–exp05) never +converged; the verdict from that lane is that weights alone cannot clear the bar on this +mesh. A morph target IS the final vertex positions, so tearing is impossible by +construction and the residual web stretch becomes one smoothable, *converging* geometric +problem. Morphs also work on the shipped **rigid-mitt** bodies (zero finger weights) and +on Mako, with no rest-space compatibility hacks and no body denylist. + +## What already works (verified, not aspirational) + +- **Runtime driver committed** in ariki-game: `src/Animation/HandMorphLayer.cs` + (commit `40b995e21`). Discovers `hand__` 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:434` → `handmorph_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__` — `emit_morph_glb:552`. + POSITION **and** NORMAL deltas are emitted (position-only morphs leave lighting on the + rest shape and read as "torn texture"). + +`--selftest-bump` splices one synthetic 3 cm palm bump with no solve — use it to prove the +import + drive path end-to-end on any new body before trusting solver output. + +## THE BUG — weld before solve + +`stretch_stats` (line 434) and the `relax` gate (line 448, `s[lr < 0.001] = 1.0`) both +apply a **1 mm rest-length floor**. The reasoning was sound for decimation slivers, but +this mesh is unwelded chart soup: it carries *coincident duplicate* verts whose rest edge +length is ~0. Those edges are excluded from the stats AND from the convergence gate, so +**the relaxation never even tries to fix them**. Measured 2026-08-18: a population of +sub-mm edges stretches to **4–20 cm** in fist/grip on BOTH hands (~25–60 per hand over 5×) +— hairline needles, sub-pixel in the screenshots that "passed", but really there. + +`grep -niE "weld|coincid|dedup" tools/handshape_solve.py` returns **zero hits** — no weld +pass exists. + +**The fix, and the shape it has to take.** Weld at the *graph* level, not by rewriting the +mesh: build a representative map over coincident positions (hash/round positions to ~1e-6, +or a KD-tree at ~10 µm), solve on the welded ROI, then **scatter each welded vertex's delta +back to every duplicate in its weld group** before `full[idx] = relaxed`. Morph deltas must +stay indexed by *original* vertex id (the GLB's own attribute order) or the splice breaks. +Because all members of a group then receive an identical delta, coincident edges keep +length exactly and the entire failure class dies by construction rather than by tuning. + +Insert the weld between `build_roi` (called in `main`, ~line 228) and `roi_graph`, and make +sure the Dijkstra/adjacency also runs on the welded graph — otherwise geodesic distances +still leak across seams and the weight field stays fragmented. + +**Hypothesis worth testing while you are in there** (state it as a hypothesis, do not +assume): the right hand's tracked ≥1 mm failures may be the same disease. If duplicates +split the delta field across a chart seam, welding should collapse a good share of those +too. Measure before and after; report both. + +## Current numbers — the baseline you must beat + +Body: `Ariki_Female_QuatSkin_LowPoly_40.glb` (restored `lena_leafbikini_base_v01` mesh, so +no yellow-material defect). ROI = **5,548 verts**. From +`characters/work/lena_leafbikini/handmorph/handmorph_report.json` (2026-08-18 08:49), +post-relax, ≥1 mm edges only: + +| shape | max | p99.9 | n>2× | n>5× | iters | +|---|---|---|---|---|---| +| flat_l | 1.94 | 1.60 | 0 | 0 | 11 | +| relaxed_l | 3.60 | 1.49 | 3 | 0 | 60 | +| fist_l | 4.23 | 1.99 | 15 | 0 | 60 | +| grip_l | 4.38 | 1.97 | 13 | 0 | 60 | +| flat_r | 2.41 | 1.59 | 1 | 0 | 8 | +| relaxed_r | 4.08 | 3.22 | 81 | 0 | 60 | +| **fist_r** | **29.78** | **18.94** | 214 | **37** | 60 (hit cap) | +| **grip_r** | **20.38** | **12.10** | 372 | **35** | 60 (hit cap) | + +Left hand is fully clean — *better* than the earlier LENA_rig_v1 body. Right fist/grip are +the failures, and both burn all 60 relax iterations without converging. + +Fingertip travel is healthy and should stay so: fist_l tips 9.0–10.3 cm (thumb 15.3), +relaxed_l 1.7–2.0 cm (thumb 6.1). + +**Gate bars** (per shape, ≥1 mm edges — and after your fix, the sliver population too): +edge stretch p99.9 ≤ 1.6×, **zero** edges > 5×; fingertip travel fist ≥ 2.5 cm/finger, +grip ~2 cm, relaxed 0.5–2 cm; cross-hand independence 0 cm on the other hand's verts. + +## How to run + +Solve (Blender is just the numpy host): + +``` +"C:/Program Files/Blender Foundation/Blender 5.1/blender.exe" --background \ + --factory-startup --python tools/handshape_solve.py -- \ + --body C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_LowPoly_40.glb \ + --poses flat,relaxed,fist,grip \ + --out C:/Users/Jeremy/tinqs/ariki-game/scratchpad/lowpoly40_handmorph.glb \ + --workdir characters/work/lena_leafbikini/handmorph/ +``` + +Verify in-engine — **the only honest gate**: + +``` +HANDMORPH_BODY_F=res://scratchpad/lowpoly40_handmorph.glb \ + SCENE=anim_hand_test_bed MOCK_ONLY=1 AGENT_OWNED=1 WAIT=1 bash tools/game.sh spawn +``` + +(`HANDMORPH_BODY` applies to both rigs, `_F`/`_M` per rig — see `DancerRig.cs:40`. +In `dance_test_bed` instead: **1** frames the team, **J** hand cam, **K** cycles +None→flat→relaxed→fist→grip→None.) + +## Two traps that have already cost time + +- **Do not judge the solver's OBJ dumps by clay render.** The un-welded chart soup renders + as black-gap confetti even at REST (flipped per-chart normals). It is dishonest in both + directions. Judge morphs **in-engine only**. The numeric report + the bed are the gates. +- **`handshape_verify.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. From 3f335ee2d75388a839930583b4215d3a360910fe Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Tue, 18 Aug 2026 11:31:55 -0700 Subject: [PATCH 4/9] docs(lena): land pending stage-07 crotch-fill recipe `07_fill_crotch.py` and its README autopsy were written in an earlier session and had been sitting uncommitted. Committing them unchanged, separately from today's hand-weight work, so neither is misattributed to the other. Co-Authored-By: Claude Opus 5 --- .../work/lena_leafbikini/07_fill_crotch.py | 820 ++++++++++++++++++ characters/work/lena_leafbikini/README.md | 73 +- 2 files changed, 880 insertions(+), 13 deletions(-) create mode 100644 characters/work/lena_leafbikini/07_fill_crotch.py diff --git a/characters/work/lena_leafbikini/07_fill_crotch.py b/characters/work/lena_leafbikini/07_fill_crotch.py new file mode 100644 index 0000000..3403299 --- /dev/null +++ b/characters/work/lena_leafbikini/07_fill_crotch.py @@ -0,0 +1,820 @@ +# lena_leafbikini lane, stage 07: BARBIE-FILL the crotch — melt the briefs leaves into a +# smooth featureless surface. The bust holes stay cut open (stage 05 behaviour). +# +# blender --background --factory-startup --python 07_fill_crotch.py -- \ +# [--z-split 0.615] [--free-rings 2] +# [--puff-mm 0.0] [--blend ] +# +# TARGET. "Barbie doll anatomy": a completely smooth, undifferentiated pelvic surface — no +# cleft, no features, a taut convex continuation of belly into inner thighs. That is exactly +# what a bi-harmonic membrane produces: solve L²x = 0 with the surrounding skin held fixed. +# The rim supplies POSITION (the "distance between the two sides"), the collar behind it +# supplies SLOPE through the second Laplacian application (the "angle"), and a bi-harmonic +# surface cannot invent detail — no crease, no cleft, by construction. +# +# WHY MELT, NOT FILL. This stage was first written as hole-filling on the stage-05 cut, and it +# failed twice, instructively: +# +# attempt 1 — triangle_fill each rim + densify + membrane. The briefs rim is ONE ~2,500-vert +# loop snaking front -> between the legs -> back; beauty triangulation of a loop that long +# and that non-convex connects the WRONG BANKS at every bend. The membrane then faithfully +# smooths garbage into rippled sheets. Bonus failure: the rim itself still carried leaf-root +# remnants, and a membrane anchors position AND slope to its rim, so it reproduced the +# crumple (the mesh-repair playbook's "collar on CLEAN skin" lesson, re-learned). +# attempt 2 — rim erosion (3 rings) + interleaved Delaunay flips + double solve. Better rims, +# same disease: flips are local, the mis-bridging is global. Long sliver strands shot off +# the hips where chords bridged front rim to back rim, and triangle_fill did not even close +# the pinched loop (2,177 faces where ~2,471 were needed; 819 boundary edges left). +# +# The fix is to stop inventing topology. THE LEAF SHELL IS the disk that spans the hole — the +# scan's own manifold surface, connected to the true rim at every point, no bank ever bridged +# wrongly. So in the crotch band the leaves are not deleted at all: their vertices are FREED, +# a --free-rings collar of surrounding skin is freed with them (this erases the under-leaf rim +# crease, the same move as the nude lane's fair_rim_band), and the membrane collapses the +# whole shell onto the smooth spanning surface. Folded flaps famously resist melting by +# ITERATIVE flow (the 06*-era lesson) — but L²x = 0 is linear with a unique solution, and PCG +# run to convergence lands on it regardless of where the folds start. +# +# THE REMNANT SWEEP. Near the melt zone the colour key is re-run without the stage-04 mask's +# blind spots (min-comp speckle filter, value gate): any fixed vertex within 6 rings of the +# primary free set that is greenish (hue 46..200, no gates) or blown-white (val >= 0.78, +# sat <= 0.25 — nothing on her actual skin is that colour) joins the melt, grown 2 rings. +# Freeing a few honest skin verts by accident is harmless — the membrane returns them almost +# in place; a pinned leaf fragment is not. +# +# THE BALLOON EXCISION. The melt's first run still left half a dozen smooth raised nubs, and a +# debug bake proved they were FREE verts — melted, converged, and still bulging. That is not a +# solver bug, it is what L²x = 0 does to a PENDANT BALLOON: a fully-masked leaf is a closed +# shell attached along its root line, its excess surface area has nowhere to go, and the +# bi-harmonic solution — smooth in GRAPH terms, with no maximum principle — parks it as a +# rounded mound. Two remedies failed before this one worked: +# * proudness detection (60-sweep, then 400-sweep Taubin reference): a smoothing-built +# reference partially FOLLOWS any bump wider than its radius, so visibly 4 mm nubs measured +# 1.7 mm and thresholds caught only their tips; +# * Laplacian deflation of what it did catch: flattened tips, kept the wave. +# What a wad cannot hide is its AREA: several layers of surface over one spot put several times +# the vertices of honest membrane into the same cell of a 4 mm grid. So: detect wads by vertex +# density, EXCISE every face touching one, and refill the scars with triangle_fill + densify + +# a small membrane solve — which is exactly the right tool at this scale (round holes a +# centimetre or two across; its failure mode was only ever the giant winding channel). +# CAVEAT the run exposed: the whole melted shell lies ~4 layers deep (cell median 37), so +# density above median finds the FLAT piles — worth excising, they would z-fight — but the +# visible INFLATED caps sit at ~2 layers, BELOW median. Density cannot see them either. +# +# THE FLOW FINISH — the step that actually guarantees a smooth result, with no detector at +# all: damped pure-Laplacian flow over the entire changed region, fixed skin held, weight +# ramping 0 -> 1 over the first 8 rings so the membrane's C1 rim blend survives. The maximum +# principle does what every detector could not promise: a raised cap has strictly nowhere to +# go but down, while the broad pubic web barely moves (flow erases features at ~1/size², and +# the web is 5-10x wider than any cap). A short Taubin polish follows to undo the slight +# overall shrink. This ordering — bi-harmonic for shape, flow for guarantees — is the recipe. +# +# TEXTURE. Melted faces keep leaf texels, so every face whose vertices are all masked gets ONE +# donor texel — an old thigh-band vertex whose albedo is closest to the band's median skin tone +# and whose normal-map texel is nearest neutral. Interpolating UVs instead is meaningless here: +# the atlas is Tripo chart soup (the sibling Lena mesh had 5,870 charts). Flat is correct — +# Barbie plastic has no albedo detail either. The baked leaf contact shadows still darken the +# surviving skin just outside the melt; that is an albedo problem for a later stage. +# +# TOPOLOGY GROUND RULES (as stages 04/05): the glTF importer splits every UV seam, so the mesh +# is welded (exact duplicates, 1e-5) before boundaries or adjacency mean anything — UVs live +# per face corner and survive the weld. Custom split normals do not survive the bmesh round +# trip; on the WELDED mesh "clear + shade smooth" is seamless, which is the second reason the +# weld comes first. +import bpy, bmesh, sys, os, time, argparse +from collections import deque +import numpy as np + + +def interior_edges(faces): + """Edges whose every adjacent face is a patch face — the only ones safe to subdivide.""" + return list({e for f in faces for e in f.edges + if all(lf in faces for lf in e.link_faces)}) + + +def refresh(faces, *rets): + """Re-collect live patch faces after a bmesh op invalidated / created some.""" + out = {f for f in faces if f.is_valid} + for ret in rets: + for key in ("geom", "geom_inner", "faces"): + for g in ret.get(key, ()): + if isinstance(g, bmesh.types.BMFace) and g.is_valid: + out.add(g) + return out + +argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] +ap = argparse.ArgumentParser() +ap.add_argument("glb") +ap.add_argument("mask") +ap.add_argument("out") +ap.add_argument("--z-split", type=float, default=0.615, + help="fraction of body height separating briefs (melted) from bust (cut open); " + "briefs mask tops out at 0.594, bust starts at 0.631") +ap.add_argument("--free-rings", type=int, default=2, + help="rings of surrounding skin freed with the leaves, to erase the rim crease") +ap.add_argument("--puff-mm", type=float, default=0.0, + help="optional outward dome on top of the membrane, peak amplitude in mm") +ap.add_argument("--blend", default="") +A = ap.parse_args(argv) +GLB, MASK, OUT = os.path.abspath(A.glb), os.path.abspath(A.mask), os.path.abspath(A.out) +os.makedirs(os.path.dirname(OUT), exist_ok=True) +t0 = time.time() + + +def log(m): + print(f"[melt {time.time()-t0:6.1f}s] {m}", flush=True) + + +def smoothstep(x): + x = np.clip(x, 0.0, 1.0) + return x * x * (3.0 - 2.0 * x) + + +# ============================================================================================= +# load original + mask, split the mask at the waist +# ============================================================================================= +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.ops.import_scene.gltf(filepath=GLB) +body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices)) +me = body.data +bpy.context.view_layer.objects.active = body +body.select_set(True) +n0 = len(me.vertices) +log(f"in : '{body.name}' {n0}v {len(me.polygons)}f") + +z = np.load(MASK) +inv, mk = z["inv"].astype(np.int64), z["mask"] +if len(inv) != n0: + raise SystemExit(f"[melt] FATAL: mask was built for {len(inv)} verts, this GLB has {n0}") +vm = mk[inv] # per raw vertex: is it leaf? + +co = np.empty(n0 * 3); me.vertices.foreach_get("co", co); P0 = co.reshape(-1, 3) +Z0, H = float(P0[:, 2].min()), float(P0[:, 2].max() - P0[:, 2].min()) +MM = 1000.0 * 1.777 / H +zf0 = (P0[:, 2] - Z0) / H +crotch = vm & (zf0 <= A.z_split) +bust = vm & (zf0 > A.z_split) +log(f"mask: {vm.sum()} leaf verts -> {crotch.sum()} briefs (melt), {bust.sum()} bust (cut)") + +# per-vertex albedo HSV for the remnant sweep — sampled NOW, on the raw import, because UV +# indexing goes stale the moment bmesh touches the topology +base_img = None +for mat in [m_ for m_ in me.materials if m_]: + bsdf = next((x for x in mat.node_tree.nodes if x.type == 'BSDF_PRINCIPLED'), None) + lnk = bsdf and bsdf.inputs["Base Color"].links + if lnk: + nd = lnk[0].from_node + while nd.type != 'TEX_IMAGE': + up = [i for i in nd.inputs if i.links] + if not up: + break + nd = up[0].links[0].from_node + if nd.type == 'TEX_IMAGE': + base_img = nd.image +if base_img is None: + raise SystemExit("[melt] FATAL: no base-colour image") +nl0 = len(me.loops) +lv0 = np.empty(nl0, dtype=np.int32); me.loops.foreach_get("vertex_index", lv0) +uv0 = np.empty(nl0 * 2); me.uv_layers.active.data.foreach_get("uv", uv0); uv0 = uv0.reshape(-1, 2) +vuv0 = np.zeros((n0, 2)); vuv0[lv0[::-1]] = uv0[::-1] +w_, h_ = base_img.size +buf = np.empty(w_ * h_ * 4, dtype=np.float32); base_img.pixels.foreach_get(buf) +px = buf.reshape(h_, w_, 4)[:, :, :3]; del buf +xi = np.clip((vuv0[:, 0] * (w_ - 1)).astype(np.int64), 0, w_ - 1) +yi = np.clip((vuv0[:, 1] * (h_ - 1)).astype(np.int64), 0, h_ - 1) +C = px[yi, xi].astype(np.float64); del px +S = np.clip(np.where(C <= 0.0031308, C * 12.92, 1.055 * np.maximum(C, 0) ** (1 / 2.4) - 0.055), 0, 1) +R, G, B = S[:, 0], S[:, 1], S[:, 2] +mx = S.max(1); mn = S.min(1); dd = mx - mn +hue = np.zeros(n0) +nz = dd > 1e-6 +im = np.argmax(S, axis=1) +sel = nz & (im == 0); hue[sel] = 60 * (((G[sel] - B[sel]) / dd[sel]) % 6) +sel = nz & (im == 1); hue[sel] = 60 * ((B[sel] - R[sel]) / dd[sel] + 2) +sel = nz & (im == 2); hue[sel] = 60 * ((R[sel] - G[sel]) / dd[sel] + 4) +sat = np.where(mx > 1e-6, dd / np.maximum(mx, 1e-6), 0.0) + +# ride the mask + HSV through the weld as attributes (per-vertex custom data survives +# remove_doubles on the surviving vertex of each duplicate cluster) +for name, arr in (("melt_m", crotch), ("bust_m", bust)): + at = me.attributes.new(name=name, type='INT', domain='POINT') + at.data.foreach_set("value", arr.astype(np.int32)) +for name, arr in (("hsv_h", hue), ("hsv_s", sat), ("hsv_v", mx)): + at = me.attributes.new(name=name, type='FLOAT', domain='POINT') + at.data.foreach_set("value", arr.astype(np.float32)) + +# ============================================================================================= +# weld, cut the bust open, free the briefs +# ============================================================================================= +bm = bmesh.new() +bm.from_mesh(me) +bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5) +bm.verts.ensure_lookup_table() +lm = bm.verts.layers.int["melt_m"] +lb = bm.verts.layers.int["bust_m"] +log(f"welded: {len(bm.verts)}v") + +# bust: delete fully-masked faces (stage 05 cut rule), then despike the new rim +kill = [f for f in bm.faces if all(v[lb] for v in f.verts)] +bmesh.ops.delete(bm, geom=kill, context='FACES') +log(f"bust cut: -{len(kill)} faces") +for it in range(4): + spikes = [f for f in bm.faces + if sum(1 for e in f.edges if len(e.link_faces) == 1) >= 2 + and (sum(v.co.z for v in f.verts) / len(f.verts) - Z0) / H > A.z_split] + if not spikes: + break + bmesh.ops.delete(bm, geom=spikes, context='FACES') + log(f"bust despike pass {it+1}: -{len(spikes)} dangling faces") +loose = [v for v in bm.verts if not v.link_faces] +if loose: + bmesh.ops.delete(bm, geom=loose, context='VERTS') + +# briefs: free = leaf verts + a skin collar, grown over true (welded) adjacency +free_set = {v for v in bm.verts if v[lm]} +for _ in range(A.free_rings): + free_set |= {o for v in free_set for e in v.link_edges for o in e.verts} +log(f"melt set: {len(free_set)} free verts (leaves + {A.free_rings}-ring skin collar)") + +# the remnant sweep (see header): re-key the fixed verts near the melt without the mask's +# speckle filter or value gate, so missed leaf fragments melt too instead of pinning welts +lh = bm.verts.layers.float["hsv_h"] +lsat = bm.verts.layers.float["hsv_s"] +lval = bm.verts.layers.float["hsv_v"] +near = set(free_set) +for _ in range(6): + near |= {o for v in near for e in v.link_edges for o in e.verts} +adds = {v for v in near - free_set + if (v.co.z - Z0) / H <= A.z_split + 0.01 + and ((46.0 <= v[lh] <= 200.0) or (v[lval] >= 0.78 and v[lsat] <= 0.25))} +grown = set(adds) +for _ in range(2): + grown |= {o for v in grown for e in v.link_edges for o in e.verts} +free_set |= grown +log(f"remnant sweep: +{len(adds)} keyed (+{len(grown - adds)} ring growth) " + f"-> {len(free_set)} free verts") + +# mark free verts and melted-face texels via flags that survive to_mesh +for v in bm.verts: + v.select_set(False) +for v in free_set: + v.select_set(True) +texel = {v for v in bm.verts if v[lm]} | grown # leaf faces + swept remnants, not the collar +for f in bm.faces: + f.select_set(all(v in texel for v in f.verts)) +bm.to_mesh(me) +bm.free() +me.update() +n = len(me.vertices) +log(f"topology done: {n}v {len(me.polygons)}f") + +# ============================================================================================= +# the melt: matrix-free PCG on L²x = 0, everything but the briefs held fixed +# ============================================================================================= +co = np.empty(n * 3); me.vertices.foreach_get("co", co); P = co.reshape(-1, 3).copy() +P_orig = P.copy() # for the fixed-verts gate at the end +vsel = np.empty(n, dtype=bool); me.vertices.foreach_get("select", vsel) +free = vsel.copy() +log(f"free verts: {free.sum()}") + +ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev) +ea, eb = ev[0::2].astype(np.int64), ev[1::2].astype(np.int64) +zfw = (P[:, 2] - Z0) / H # fixed verts never move, so this stays valid + + +def grow_np(mask, rings): + out = mask.copy() + for _ in range(rings): + hit = np.zeros(n, dtype=bool) + m = out[ea] | out[eb] + hit[ea[m]] = True + hit[eb[m]] = True + out |= hit + return out + + +# full-mesh adjacency in CSR form, built once — the solver restricts it per pass +fsrc = np.concatenate([ea, eb]); fdst = np.concatenate([eb, ea]) +fo = np.argsort(fsrc, kind='stable'); fsrc, fdst = fsrc[fo], fdst[fo] +fdeg = np.bincount(fsrc, minlength=n).astype(np.float64) +fptr = np.concatenate([[0], np.cumsum(fdeg)]).astype(np.int64) + + +def solve_membrane(free_mask, tag, warm_harmonic): + """Bi-harmonic solve for the free verts; reads and writes me's positions in place. + Returns (ridx, Fl, deg, ptr, dst) so the puff step can reuse the last pass's graph.""" + co_ = np.empty(n * 3); me.vertices.foreach_get("co", co_); Pv = co_.reshape(-1, 3).copy() + region = grow_np(free_mask, 3) # ring1 enters L, ring2 enters L², ring3 margin + ridx = np.nonzero(region)[0] + loc = np.full(n, -1, dtype=np.int64) + loc[ridx] = np.arange(len(ridx)) + m = region[ea] & region[eb] + ra, rb = loc[ea[m]], loc[eb[m]] + src = np.concatenate([ra, rb]); dst = np.concatenate([rb, ra]) + o = np.argsort(src, kind='stable'); src, dst = src[o], dst[o] + deg = np.bincount(src, minlength=len(ridx)).astype(np.float64) + ptr = np.concatenate([[0], np.cumsum(deg)]).astype(np.int64) + Fl = free_mask[ridx] + log(f"solve[{tag}]: {len(ridx)} region verts ({Fl.sum()} free), {len(src)//2} edges") + + def Lap(Xv): + s = np.add.reduceat(Xv[dst], ptr[:-1], axis=0) + s[deg == 0] = 0.0 + return s - deg[:, None] * Xv + + X = Pv[ridx].copy() + + def T_free(XF): + Y = np.zeros_like(X) + Y[Fl] = XF + return Lap(Lap(Y))[Fl] + + if warm_harmonic: + # Lx = 0 converges in ~diameter sweeps and lands within a crease of the bi-harmonic + # answer, cutting the expensive solve's iterations roughly in half. Only worth it when + # starting from the raw leaf shell — a re-solve already sits near the answer. + Y = X.copy() + for it in range(3000): + d = Lap(Y) + Y[Fl] += 0.9 / np.maximum(deg[Fl], 1.0)[:, None] * d[Fl] + if it % 500 == 499 and float(np.abs(d[Fl]).max()) * MM < 1e-4: + break + X[Fl] = Y[Fl] + log(f" harmonic warm start: {it+1} sweeps") + + r = -Lap(Lap(X))[Fl] + Mjac = (deg[Fl] ** 2 + deg[Fl])[:, None] # diag(L²) = deg² + deg + zv = r / Mjac + p = zv.copy() + rz = float((r * zv).sum()) + b0 = float(np.linalg.norm(-Lap(Lap(np.where(Fl[:, None], 0.0, X)))[Fl])) + 1e-30 + xF = X[Fl].copy() + rn = float(np.linalg.norm(r)) + for it in range(20000): + Ap = T_free(p) + alpha = rz / (float((p * Ap).sum()) + 1e-300) + xF += alpha * p + r -= alpha * Ap + rn = float(np.linalg.norm(r)) + if rn / b0 < 3e-7: + break + zv = r / Mjac + rz2 = float((r * zv).sum()) + p = zv + (rz2 / rz) * p + rz = rz2 + if it % 2000 == 1999: + log(f" PCG iter {it+1}: residual {rn/b0:.2e}") + X[Fl] = xF + log(f"solve[{tag}]: {it+1} iters (residual {rn/b0:.2e}), " + f"max move {np.linalg.norm(X[Fl]-Pv[ridx][Fl],axis=1).max()*MM:.1f} mm") + Pv[ridx] = X + me.vertices.foreach_set("co", Pv.ravel()) + me.update() + return ridx, Fl, deg, ptr, dst + + +ridx, Fl, deg, ptr, dst = solve_membrane(free, "melt", warm_harmonic=True) + + +# THE SOLID-WELT PASS. Some leaf roots are not shells at all — they are SOLID ridges sculpted +# into the body surface and painted in skin tones, which is why every shell-hunting detector +# (density, occlusion, fin normals) returned almost nothing while four caps sat in plain view. +# A solid bump is honest single surface, so the ORIGINAL remedy is the right one: free it and +# let the membrane pull it down — no excess area, no pendant balloon. Detector exactly as the +# debug probe validated it: 400-sweep Taubin reference over the WHOLE band (everything moves, +# so there is no anchored-strip chord and no frozen-zone blindness), proud along the normal +# > 0.8 mm, fixed verts only. At that reference the smooth melt web reads ~0.2 mm (p95) and +# the caps read 1-2.4 mm. +def full_nbmean(Xv): + s = np.add.reduceat(Xv[fdst], fptr[:-1], axis=0) + s[fdeg == 0] = Xv[fdeg == 0] + return s / np.maximum(fdeg, 1.0)[:, None] + + +mvW = (zfw >= 0.40) & (zfw <= A.z_split + 0.02) +for wpass in range(2): # re-reference and re-detect: the first fix + cow = np.empty(n * 3) # exposes whatever its 3-ring growth missed + me.vertices.foreach_get("co", cow) + Pw = cow.reshape(-1, 3).copy() + Qw = Pw.copy() + for _ in range(400): + Qw[mvW] += 0.50 * (full_nbmean(Qw) - Qw)[mvW] + Qw[mvW] += -0.53 * (full_nbmean(Qw) - Qw)[mvW] + nrw = np.empty(n * 3); me.vertices.foreach_get("normal", nrw) + proudW = np.einsum('ij,ij->i', Pw - Qw, nrw.reshape(-1, 3)) * MM + # 0.5 mm, not 0.8: the caps are ~1.8 mm PLATEAUS with sharp edges (the hard shadows in + # clay renders oversell their height), and 0.8 clipped 220 crown verts while the body of + # each plateau survived. Honest skin reads p95 +0.13 mm against this reference — 0.5 mm + # is still 4x above the noise floor. + weltS = ~free & mvW & (proudW > 0.5) + log(f"solid-welt pass {wpass+1}: {weltS.sum()} proud fixed verts " + f"(band fixed p95 {np.percentile(proudW[~free & mvW], 95):+.2f} mm, " + f"max {proudW[~free & mvW].max():+.2f} mm)") + if not weltS.sum(): + break + weltG = grow_np(weltS, 3) & ~free + free |= weltG + solve_membrane(weltG, f"weltfix{wpass+1}", warm_harmonic=False) + # their paint is leaf-root shadow, not skin: hand their faces to the donor texel too + fselW = np.empty(len(me.polygons), dtype=bool); me.polygons.foreach_get("select", fselW) + lvW = np.empty(len(me.loops), dtype=np.int32); me.loops.foreach_get("vertex_index", lvW) + lsW = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", lsW) + ltW = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_total", ltW) + allin = np.add.reduceat(weltG[lvW].astype(np.int32), lsW.astype(np.int64)) == ltW + me.polygons.foreach_set("select", fselW | allin) + log(f"solid-welt pass {wpass+1}: freed {weltG.sum()} verts, " + f"{int(allin.sum())} faces to donor texel") + +# THE BALLOON DEFLATION (see header). Wide reference: 400 Taubin sweeps over the melt zone — +# diffusion radius ~sqrt(400) = 20 rings (~26 mm here), wide enough that a centimetre nub reads +# as fully proud instead of being absorbed into its own reference. +def full_nbmean(Xv): + s = np.add.reduceat(Xv[fdst], fptr[:-1], axis=0) + s[fdeg == 0] = Xv[fdeg == 0] + return s / np.maximum(fdeg, 1.0)[:, None] + + +co2 = np.empty(n * 3); me.vertices.foreach_get("co", co2); Pm = co2.reshape(-1, 3).copy() +# the reference zone must extend PAST anything the detectors are asked to judge: outside `mv` +# the smoothed copy equals the input and proudness is identically zero by construction — a +# 4-ring halo silently blinded the welt detector to caps sitting 5+ rings out +mv = grow_np(free, 30) & (zfw <= A.z_split + 0.03) +# Detection is by DENSITY, not proudness: a smoothing-built reference partially follows any +# bump wider than its radius (a 400-sweep probe read the visibly 4 mm nubs at 1.7 mm), but a +# wad cannot hide its area — multiple layers over one spot of surface put several times the +# verts of honest membrane into the same cell of a 3D grid. +CELL = 4.0 / MM +key3 = np.floor(Pm[free] / CELL).astype(np.int64) +_, cinv, ccnt = np.unique(key3, axis=0, return_inverse=True, return_counts=True) +per_vert_cnt = ccnt[cinv] +med_cnt = float(np.median(per_vert_cnt)) +hot_thr = max(3.0 * med_cnt, 18.0) +balloon = np.zeros(n, dtype=bool) +balloon[np.nonzero(free)[0][per_vert_cnt > hot_thr]] = True +balloon = grow_np(balloon, 1) +log(f"balloon pass: cell median {med_cnt:.0f} verts, threshold {hot_thr:.0f} -> " + f"{balloon.sum()} wad verts to excise") + +# THE FOLD DETECTOR — for the rim-attached flaps density cannot see (~2 layers, BELOW the +# piled median) and the flow finish cannot reach (they live in the rim-damped zone). A folded +# flap betrays itself by its NORMALS: its flanks and underside disagree with the smoothed +# reference field by 80-180 degrees, which honest skin never does — even the walls of a deep +# concave crease stay within ~70 degrees of a 150-sweep reference. Restricted to FREE verts, +# so fixed anatomy can never be excised no matter how it folds. +ltF = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_total", ltF) +if (ltF == 3).all(): + lvF = np.empty(len(me.loops), dtype=np.int32); me.loops.foreach_get("vertex_index", lvF) + lsF = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", lsF) + + def vnormals(Pts): + va, vb, vc = lvF[lsF], lvF[lsF + 1], lvF[lsF + 2] + fn = np.cross(Pts[vb] - Pts[va], Pts[vc] - Pts[va]) + acc = np.zeros_like(Pts) + for idx in (va, vb, vc): + np.add.at(acc, idx, fn) + return acc / np.maximum(np.linalg.norm(acc, axis=1, keepdims=True), 1e-12) + + Qf = Pm.copy() + for _ in range(400): + Qf[mv] += 0.50 * (full_nbmean(Qf) - Qf)[mv] + Qf[mv] += -0.53 * (full_nbmean(Qf) - Qf)[mv] + ncur = vnormals(Pm) + dotn = np.einsum('ij,ij->i', ncur, vnormals(Qf)) + fold = free & (dotn < 0.15) + log(f"fold pass: {fold.sum()} inverted-normal verts " + f"(free dot p05 {np.percentile(dotn[free], 5):+.2f})") + balloon |= grow_np(fold, 1) + + # THE FIXED-WELT PASS — by RAY-CAST OCCLUSION, the playbook's own move, after every + # reference-surface detector failed for a structural reason worth recording: + # * proudness vs a Taubin reference: Taubin is shape-PRESERVING — a 1.5 cm cap sits + # inside its passband, so the reference reproduces the cap and P-Q reads ~0 forever; + # * proudness at all: a FIN's wall normals are perpendicular to its height, the dot + # is ~0 no matter how far it sticks out; + # * diffusion references: run wide they chord across convex anatomy (+1.9 mm on honest + # hips), run narrow they cannot see the cap tops standing 15+ rings out. + # A flap needs no reference: it stands OVER surface, so a short ray cast INWARD from it + # hits geometry within millimetres — its own opposite wall (fins are 1-2 mm thick) or the + # web below — while honest skin's inward ray travels centimetres of flesh before exiting. + # The gluteal crease is safe by construction: its walls' inward rays point into the flesh, + # AWAY from each other; only a +n ray could cross the crease gap, and none is cast. + import mathutils + deps = bpy.context.evaluated_depsgraph_get() + bvh = mathutils.bvhtree.BVHTree.FromObject(body, deps) + nearF = grow_np(free, 20) & ~free & (zfw >= 0.40) & (zfw <= A.z_split + 0.01) + EPS, DMAX = 0.4 / MM, 4.5 / MM + widx = [] + for vi in np.nonzero(nearF)[0]: + p, nv = Pm[vi], ncur[vi] + o = mathutils.Vector((p[0] - nv[0] * EPS, p[1] - nv[1] * EPS, p[2] - nv[2] * EPS)) + if bvh.ray_cast(o, mathutils.Vector((-nv[0], -nv[1], -nv[2])), DMAX)[0] is not None: + widx.append(int(vi)) + welt = np.zeros(n, dtype=bool) + welt[widx] = True + log(f"fixed-welt pass: {welt.sum()} occluded (flap) verts of {nearF.sum()} candidates") + balloon |= grow_np(welt, 1) +else: + log("fold pass: SKIPPED (non-triangle faces present)") + +if balloon.sum(): + # excise every face touching a wad vert (kills whole balloons, leaves no orphan shells), + # tidy the scar, and refill: at this scale — round holes a centimetre or two across — + # triangle_fill is exactly the right tool; its failure mode was the giant winding channel + bm = bmesh.new(); bm.from_mesh(me) + bm.verts.ensure_lookup_table() + bidx = set(np.nonzero(balloon)[0].tolist()) + kill = [f for f in bm.faces if any(v.index in bidx for v in f.verts)] + bmesh.ops.delete(bm, geom=kill, context='FACES') + for it in range(4): + spikes = [f for f in bm.faces + if sum(1 for e in f.edges if len(e.link_faces) == 1) >= 2 + and (sum(v.co.z for v in f.verts) / len(f.verts) - Z0) / H <= A.z_split] + if not spikes: + break + bmesh.ops.delete(bm, geom=spikes, context='FACES') + loose = [v for v in bm.verts if not v.link_faces] + if loose: + bmesh.ops.delete(bm, geom=loose, context='VERTS') + log(f"excision: -{len(kill)} wad faces (+{len(loose)} loose verts)") + + # zip the pre-existing Tripo slits where they meet the scars (nude lane, close_rim_slits): + # a scar boundary that runs into a slit is a RIBBON, not a closed loop, and both fill + # operators refuse it — these were the 10 holes that survived three sweeps untouched + sl = [v for v in bm.verts + if Z0 + 0.40 * H <= v.co.z <= Z0 + (A.z_split + 0.01) * H + and any(len(e.link_faces) == 1 for e in v.link_edges)] + v0 = len(bm.verts) + bmesh.ops.remove_doubles(bm, verts=sl, dist=0.8 / MM) + bm.verts.ensure_lookup_table() + log(f"slit weld: {len(sl)} band boundary verts, {v0 - len(bm.verts)} merged at 0.8 mm") + # the weld leaves zero-area faces and zero-length edges; feeding those to triangle_fill + # took Blender down with an access violation, not an exception — clean them first + dg = [e for e in bm.edges + if Z0 + 0.38 * H <= (e.verts[0].co.z + e.verts[1].co.z) / 2 <= Z0 + 0.64 * H] + bmesh.ops.dissolve_degenerate(bm, dist=1e-5, edges=dg) + bm.verts.ensure_lookup_table() + log(f"degenerate dissolve: {len(bm.verts)}v {len(bm.faces)}f") + + pre2 = {(round(v.co.x, 6), round(v.co.y, 6), round(v.co.z, 6)) for v in bm.verts} + patch_faces = set() + for sweep in range(3): # re-detect after filling: one triangle_fill + bedges2 = [e for e in bm.edges # failing silently must not leave a pinhole + if len(e.link_faces) == 1 + and Z0 + 0.42 * H <= (e.verts[0].co.z + e.verts[1].co.z) / 2 + <= Z0 + A.z_split * H] + v2b2 = {} + for e in bedges2: + for v in e.verts: + v2b2.setdefault(v, []).append(e) + seen2, scars = set(), [] + for e0 in bedges2: + if e0 in seen2: + continue + comp, q = [], deque([e0]) + seen2.add(e0) + while q: + e = q.popleft() + comp.append(e) + for v in e.verts: + for e2 in v2b2[v]: + if e2 not in seen2: + seen2.add(e2) + q.append(e2) + if len(comp) >= 3: + scars.append(comp) + if not scars: + break + log(f"scar sweep {sweep+1}: {len(scars)} holes " + f"(sizes {sorted(len(c) for c in scars)[::-1][:10]})") + sweep_faces = set() # densify THIS sweep's fills only — letting a + for comp in scars: # later sweep's sliver scars re-densify earlier + if len(comp) > 900: # patches to their microscopic target once + log(f" REFUSING {len(comp)}-edge boundary tangle (fill would crash/garble)") + continue + live = [e for e in comp if e.is_valid] + try: # (3.3M-face / 17 h lesson) + ret = bmesh.ops.triangle_fill(bm, use_beauty=True, use_dissolve=False, + edges=live) + newf = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMFace)] + except Exception: + newf = [] + if not newf: + try: + ret = bmesh.ops.holes_fill(bm, edges=live, sides=0) + newf = list(ret["faces"]) + except Exception: + newf = [] + sweep_faces.update(newf) + tgt = max(1.5 * float(np.median([e.calc_length() for c in scars for e in c])), + 2.4 / MM) # floored at 2.4 mm: sliver rims must not set it + for it in range(5): + sweep_faces = {f for f in sweep_faces if f.is_valid} + if len(sweep_faces) > 120000: + log(f" densify CAPPED at {len(sweep_faces)} faces") + break + longe = [e for e in interior_edges(sweep_faces) if e.calc_length() > 1.45 * tgt] + if not longe: + break + r1 = bmesh.ops.subdivide_edges(bm, edges=longe, cuts=1, use_grid_fill=True) + sweep_faces = refresh(sweep_faces, r1) + r2 = bmesh.ops.triangulate(bm, faces=list(sweep_faces)) + sweep_faces = refresh(set(), r2) + r3 = bmesh.ops.beautify_fill(bm, faces=list(sweep_faces), + edges=interior_edges(sweep_faces)) + sweep_faces = refresh(sweep_faces, r3) + patch_faces = {f for f in patch_faces if f.is_valid} | sweep_faces + log(f"scar fill: {len(patch_faces)} patch faces") + for f in patch_faces: # patches join the donor-texel set + if f.is_valid: + f.select_set(True) + bm.to_mesh(me) + bm.free() + me.update() + + # topology changed: rebuild the globals the solver reads, then relax the patches + n = len(me.vertices) + ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev) + ea, eb = ev[0::2].astype(np.int64), ev[1::2].astype(np.int64) + co2 = np.empty(n * 3); me.vertices.foreach_get("co", co2) + Pn = co2.reshape(-1, 3) + patch_free = np.array([tuple(k) not in pre2 for k in np.round(Pn, 6)], dtype=bool) + log(f"patch verts: {patch_free.sum()}") + if patch_free.sum(): + solve_membrane(patch_free, "patch", warm_harmonic=False) + +co3 = np.empty(n * 3); me.vertices.foreach_get("co", co3); Pout = co3.reshape(-1, 3).copy() +# every vertex is now either at a pristine post-cut position or it is part of the melt/patch; +# `changed` is the union of moved and newly created — the gate + donor selection key off it +orig_keys = {tuple(k) for k in np.round(P_orig, 6)} +changed = np.array([tuple(k) not in orig_keys for k in np.round(Pout, 6)], dtype=bool) +free = changed + +# THE FLOW FINISH. Whatever pendant caps survived every detector above die here, and nothing +# has to find them first: damped pure-Laplacian flow over the ENTIRE changed region, fixed skin +# held. Laplacian flow obeys the maximum principle — no point can move outside the hull of its +# neighbours — so a raised cap has strictly nowhere to go but down, while the broad pubic web +# barely moves (flow erases features at a rate ~1/size², and the web is 5-10x wider than any +# cap). The weight ramps from 0 at the fixed rim to 1 by ring 8, so the bi-harmonic C1 blend +# earned by the membrane is untouched where it matters. +fsrc = np.concatenate([ea, eb]); fdst = np.concatenate([eb, ea]) +fo = np.argsort(fsrc, kind='stable'); fsrc, fdst = fsrc[fo], fdst[fo] +fdeg = np.bincount(fsrc, minlength=n).astype(np.float64) +fptr = np.concatenate([[0], np.cumsum(fdeg)]).astype(np.int64) + + +def nbmean2(Xv): + s = np.add.reduceat(Xv[fdst], fptr[:-1], axis=0) + s[fdeg == 0] = Xv[fdeg == 0] + return s / np.maximum(fdeg, 1.0)[:, None] + + +depth = np.zeros(n) +reach = ~free +d = 0 +while not reach.all() and d < 200: + d += 1 + nxt = reach.copy() + hit = np.zeros(n, dtype=bool) + m2 = reach[fsrc] + hit[fdst[m2]] = True + nxt |= hit + ring = nxt & ~reach + if not ring.any(): + break + depth[ring] = d + reach = nxt +# ramp over 3 rings, NOT 8. A pixel-ray probe finally identified the last "caps" as the melt +# web itself bridging taut over the inguinal hollow — and the hip-vine channel is only +# ~10-20 rings wide, so an 8-ring ramp kept essentially the whole strip in the damped zone +# and the flow never engaged exactly where the bridge needed pulling down. Three rings still +# protects the immediate C1 blend; everything past it flows. +w = smoothstep(depth / 3.0)[:, None] +log(f"flow finish: max depth {int(depth.max())} rings") +for _ in range(300): + Pout[free] += (0.55 * w[free]) * (nbmean2(Pout) - Pout)[free] +for _ in range(8): # Taubin polish: undo the slight flow shrink + Pout[free] += (0.55 * w[free]) * (nbmean2(Pout) - Pout)[free] + Pout[free] += (-0.58 * w[free]) * (nbmean2(Pout) - Pout)[free] +moved_fin = np.linalg.norm(Pout[free] - co3.reshape(-1, 3)[free], axis=1) +log(f"flow finish: moved p50 {np.percentile(moved_fin,50)*MM:.2f} mm, " + f"max {moved_fin.max()*MM:.1f} mm") +me.vertices.foreach_set("co", Pout.ravel()) +me.update() + +# optional Barbie dome: outward along the membrane normal, smoothstep of rim distance, +# zero value AND zero slope at the rim so the C1 blend survives +if A.puff_mm > 0: + dist = np.zeros(len(ridx)) + unv = set(np.nonzero(Fl)[0].tolist()) + cur = set(np.nonzero(~Fl)[0].tolist()) + d = 0 + while unv and cur: + d += 1 + nxt = set() + for c in cur: + for j in range(int(ptr[c]), int(ptr[c + 1])): + nb = int(dst[j]) + if nb in unv: + unv.discard(nb) + dist[nb] = d + nxt.add(nb) + cur = nxt + t = dist / max(dist.max(), 1.0) + nrm = np.empty(n * 3); me.vertices.foreach_get("normal", nrm); nrm = nrm.reshape(-1, 3) + Pout[ridx] += nrm[ridx] * (smoothstep(t) * (A.puff_mm / MM))[:, None] * Fl[:, None] + me.vertices.foreach_set("co", Pout.ravel()) + me.update() + log(f"puff: +{A.puff_mm} mm dome over {int(dist.max())} rings") + +# ============================================================================================= +# texture the melt: one clean donor texel on the leaf-texel faces +# ============================================================================================= +imgs = {} +for mat in [m_ for m_ in me.materials if m_]: + for nd in mat.node_tree.nodes: + if nd.type == 'TEX_IMAGE' and nd.image: + for out_ in nd.outputs: + for lnk in out_.links: + if lnk.to_socket.name == 'Base Color': + imgs['base'] = nd.image + if 'normal' in nd.image.name.lower(): + imgs['normal'] = nd.image + + +def sample(img, uvs): + w, h = img.size + buf = np.empty(w * h * 4, dtype=np.float32) + img.pixels.foreach_get(buf) + px = buf.reshape(h, w, 4)[:, :, :3] + xi = np.clip((uvs[:, 0] * (w - 1)).astype(np.int64), 0, w - 1) + yi = np.clip((uvs[:, 1] * (h - 1)).astype(np.int64), 0, h - 1) + out = px[yi, xi].copy() + del buf, px + return out + + +nl = len(me.loops) +lv = np.empty(nl, dtype=np.int32); me.loops.foreach_get("vertex_index", lv) +uvb = np.empty(nl * 2); me.uv_layers.active.data.foreach_get("uv", uvb); uvb = uvb.reshape(-1, 2) +vuv = np.zeros((n, 2)); vuv[lv[::-1]] = uvb[::-1] + +zf = (Pout[:, 2] - Z0) / H +cand = np.nonzero(~free & (zf > 0.30) & (zf < 0.42))[0][::37] # thigh band, thinned +cb = sample(imgs['base'], vuv[cand]) +med = np.median(cb, axis=0) +score = np.linalg.norm(cb - med, axis=1) +if 'normal' in imgs: + cn = sample(imgs['normal'], vuv[cand]) + score += 2.0 * np.linalg.norm(cn - np.array([0.5, 0.5, 1.0]), axis=1) +best = int(np.argmin(score)) +donor = cand[best] +log(f"donor texel: vert {donor} zf={zf[donor]:.3f} albedo={np.round(cb[best],3)} " + f"(band median {np.round(med,3)})") + +fsel = np.empty(len(me.polygons), dtype=bool); me.polygons.foreach_get("select", fsel) +ls = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", ls) +lt = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_total", lt) +duv = vuv[donor] +touched = 0 +for fi in np.nonzero(fsel)[0]: + for li in range(ls[fi], ls[fi] + lt[fi]): + uvb[li] = duv + touched += 1 +me.uv_layers.active.data.foreach_set("uv", uvb.ravel()) +log(f"UVs: {touched} loops on {int(fsel.sum())} melted faces -> donor texel") + +# ============================================================================================= +# normals, gates, export +# ============================================================================================= +if me.has_custom_normals: + bpy.ops.mesh.customdata_custom_splitnormals_clear() +me.polygons.foreach_set("use_smooth", np.ones(len(me.polygons), dtype=bool)) +me.update() + +# the excision/refill renumbers vertices, so "fixed didn't move" is asserted by position: +# every vertex is either bit-identical to a pristine post-cut position, or it is changed — +# and everything changed must live inside the crotch band +bm = bmesh.new(); bm.from_mesh(me) +band_open = sum(1 for e in bm.edges if len(e.link_faces) == 1 + and (0.5 * (e.verts[0].co.z + e.verts[1].co.z) - Z0) / H <= A.z_split) +bm.free() +mz = zf[changed] +# +0.04, not +0.01: the solid-welt pass detects up to z_split+0.02 and grows 3 rings, so its +# legitimate reach is a little above the split — the gate must allow what the recipe declares +in_band_ok = bool((mz.min() >= 0.42) and (mz.max() <= A.z_split + 0.04)) +log(f"gate changed geometry confined to band: z {mz.min():.3f}..{mz.max():.3f} " + f"({'PASS' if in_band_ok else 'FAIL'})") +log(f"gate crotch band boundary edges: {band_open} (pre-existing Tripo slits only)") +if not in_band_ok: + raise SystemExit("[melt] FATAL: geometry changed outside the crotch band") + +if A.blend: + bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(A.blend)) + log(f"WROTE {A.blend}") +for o in bpy.data.objects: + o.select_set(True) +bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB', use_selection=True, + export_yup=True, export_skins=False, export_animations=False, + export_apply=False, export_image_format='AUTO', + export_tangents=False, export_normals=True) +log(f"WROTE {OUT} ({os.path.getsize(OUT)/1e6:.2f} MB)") diff --git a/characters/work/lena_leafbikini/README.md b/characters/work/lena_leafbikini/README.md index c59d3f3..4ecd5b4 100644 --- a/characters/work/lena_leafbikini/README.md +++ b/characters/work/lena_leafbikini/README.md @@ -28,6 +28,8 @@ of a 1.777 m body). Read-only, and re-hashed by every stage that opens it. | `03_render_leaves.py` | textured **and clay** turnaround of any mesh in the lane. The clay pass is what settled the question | | `04_leaf_mask.py` | builds and *proves* the leaf mask: hue key → component filter → close → hole fill → grow, baked to vertex colour and rendered | | `05_cut_leaves.py` | deletes the masked faces. Holes left open, on purpose | +| `06_open_in_blender.py` | opens a lane mesh in the Blender GUI, framed on her front, Material Preview | +| `07_fill_crotch.py` | **v02**: Barbie-fills the crotch — melts the briefs leaves into a smooth featureless surface, bust holes stay open. See below | ## What the leaves turned out to be @@ -109,18 +111,63 @@ Stage 05's gates, all reported and the first two fatal: | surviving shells | 1 | | removed geometry's extent | z 0.455–0.755 of height (bust + briefs bands only) | +## v02 — the Barbie crotch fill (stage 07) + +Target: "Barbie doll anatomy" — a completely smooth, undifferentiated pelvic surface, no +cleft, no features, belly flowing into thighs. Jeremy's "take the distance and angle +between the two sides" is precisely a bi-harmonic membrane: the rim supplies POSITION, the +collar behind it supplies SLOPE through the second Laplacian application, and L²x = 0 +cannot invent detail. Bust holes stay cut open (v01 behaviour); only the briefs band gets +the treatment. + +Getting there burned through roughly ten runs; the recipe header carries the full autopsy, +the short version is: + +| attempt | verdict | +|---|---| +| triangle_fill the v01 holes + membrane | the briefs rim is ONE ~2,500-vert loop snaking front → between the legs → back; beauty triangulation bridges the WRONG BANKS at the bends, and the rim itself still carried leaf-root crumple the membrane faithfully anchored to | +| + rim erosion + Delaunay flips + double solve | better rims, same disease — flips are local, the mis-bridging is global; sliver strands shot off the hips | +| **melt instead of fill** (the pivot) | the leaf shell IS the disk that spans the hole — the scan's own manifold surface. Free its verts + a skin collar, solve; wrong-bank bridging becomes impossible. This produced the first genuinely smooth pelvis | +| proudness detectors + deflation for the leftovers | fully-masked leaves are PENDANT BALLOONS — closed shells whose excess area the (no-maximum-principle) bi-harmonic parks as smooth raised caps. Smoothing-built references partially follow any bump wider than their radius, and **Taubin references are worse than useless here: shape-preserving by design, a 1.5 cm cap sits inside the passband and reads ~0** | +| density excision + scar refill | catches the FLAT wads (the melted shell lies ~4 layers deep, and piles would z-fight) but not inflated caps, which sit BELOW the piled median. Scar refill needs: per-sweep densify scope with a floored target (a sliver scar once re-densified everything to 3.3M faces / 17 h), a slit weld first (scars meeting Tripo slits are ribbons, not loops — fill ops refuse them, and degenerate output crashed Blender once), and a >900-edge refusal | +| occlusion / fin / diffusion detectors for the last four "caps" | all near-zero. A pixel-ray probe finally explained why: **the last bumps were not leaf debris at all — they are the melt web itself bridging taut over the inguinal hollow.** There was never skin under the vine, and every rim-anchored method spans its anchors by construction | +| **+ the flow finish** (the guarantee) | damped pure-Laplacian flow over the whole changed region, no detector at all: the maximum principle sinks any pendant cap while the 5-10× wider pubic web barely moves. Ramp weight 0→1 over **3** rings — at 8 rings the hip-vine channel (10–20 rings wide) sat entirely in the damped zone | +| **+ the solid-welt pass** | some leaf roots are SOLID skin-painted ridges, not shells — free them (0.5 mm proudness against a whole-band 400-sweep reference; honest skin reads p95 +0.13 mm) and re-solve locally; converges in 2 passes (5,375 verts, then 1) | + +Pipeline as shipped in `07_fill_crotch.py`: weld → cut bust open (+despike) → free briefs +leaves + collar → remnant sweep (relaxed hue/blown-white key near the melt, no speckle +filter) → bi-harmonic melt (matrix-free Jacobi-PCG, harmonic warm start) → solid-welt pass +×2 → density excision of flat wads + occlusion pass → slit weld + degenerate dissolve → +three scar-fill sweeps + patch solve → flow finish. Melted faces get one donor texel +(thigh-band vertex nearest the band's median tone with the most neutral normal texel) — +the atlas is Tripo chart soup, so interpolating UVs across the fill would sample garbage. + +``` +"$B" --background --factory-startup --python $L/07_fill_crotch.py -- \ + characters/originals/female/female_lena_leafbikini_tripo.glb $L/leaf_mask.npz \ + $L/v02/lena_leafbikini_crotchfill_sculpt_glb_v02.glb --free-rings 3 +``` + ## Open -- **The holes are not filled, by request.** Filling is a separate and harder decision: - the crotch/gusset history in `work/lena/06*.py` is what happens when a membrane spans a - wide footprint — it flattens the anatomy it spans, which is why the nude lane only ever - faired a narrow rim band. Whatever fills these has to rebuild bust and crotch anatomy, - not just span them. -- The base-colour map still carries the leaves and their baked contact shadows. The rim of - each hole is skin painted with leaf shadow, so it reads darker than her surrounding tone. -- **The cut is unrigged**, because it was authored on the pristine original. It transfers - to the shipped body for free: `01_graft.py` moved the mesh 0.000000 mm, so - `lena_leafbikini_base_v01` carries the *same* 1,029,360 vertices in the same order and - `leaf_mask.npz` indexes it directly. Cutting the shipped GLB is running stage 05 with a - different first argument — but the result is a new ship folder, never an edit to the - frozen one. +- **The hip-side shelf bulges are a known consequence, not a bug to chase further.** Where + the vine crossed the inguinal crease there was never skin underneath, and "take the + distance and angle between the two sides" — the membrane — spans its anchors by + construction. Any method anchored at the rims produces the same taut bridge (harmonic, + bi-harmonic, and flow all agree; the maximum principle *protects* a two-sided span). + Killing them means SCULPTING the crease through the strip — invented anatomy, its own + decision, its own stage. Ten runs of detector archaeology confirmed there is nothing + foreign left there to remove. +- **The bust holes are not filled, by request** (v01 deliverable; v02 fills the crotch + only). Whatever fills the bust has to rebuild breast anatomy, not just span it — see + `tools/make_lena_nude_body.py` for how the nude lane sculpted hers procedurally. +- The base-colour map still carries the leaves and their baked contact shadows. Around the + v01 holes and the v02 fill alike, the surviving skin reads darker than her surrounding + tone — an albedo re-author job for a later stage (the nude lane's harmonic refill is the + template). +- **Both artifacts are unrigged**, because they were authored on the pristine original. + They transfer to the shipped body's frame for free: `01_graft.py` moved the mesh + 0.000000 mm, so `lena_leafbikini_base_v01` carries the *same* 1,029,360 vertices in the + same order and `leaf_mask.npz` indexes either mesh. Re-running 05/07 against the shipped + GLB is a first-argument change — but the result is a new ship folder, never an edit to + the frozen one. From cf336f3b5b5daaadc1f401c595e428f00e65e687 Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Tue, 18 Aug 2026 11:33:15 -0700 Subject: [PATCH 5/9] feat(lena-hands): exp09 graded cross-digit finger weights + honest tear metrics The exp05 verdict ("weights alone cannot clear the bar on this mesh") was measured against a hard partition that was itself causing much of the tearing. exp01-exp05 gave every vert to exactly ONE digit -- 08_finger_weights.py had an explicit `elif lo in FING: continue # other digit: hard wall` -- which guarantees the fused inter-digit bridges tear by the full finger separation, because a single edge ring absorbs the whole gap. Replace that with graded blending (WEB_BLEND_R0): a vert's fraction toward its nearest other digit ramps 0 -> 0.5 as r = d_own/(d_own+d_other) goes 0.35 -> 0.5, so both sides of the equidistance valley reach 50/50 and the field is continuous across the boundary. Applied after the smoother (whose hard wall would erode beta exactly where it must survive) and evaluated in the neighbour digit's own arc-length frame. WEB_BLEND_SKIP excludes the thumb: its transform is opposition, not curl, so its frame does not correspond to a finger's. Measured against an identical baseline (same input, blend the only variable), real tears (>=1mm rest length) drop 23-60% with NO regression on flat, the pose that ships: fist_r 1178 -> 530, grip_r 561 -> 222, fist_l 979 -> 633, grip_l 420 -> 322; total >5x 2487 -> 1307; p99.9 better on every pose; flat unchanged at 0/1. fin_bones confirms the mechanism rather than just the count -- the middle<->ring and pinky<->ring families leave the top classes while the thumb/palm ones are untouched to the edge (182 -> 182, 148 -> 148). This does NOT make fist/grip shippable: 222-633 real tears still reads as a destroyed hand in clay renders, and the residual is now ~53% thumb-pad-fused-to -palm, which is topology and needs mesh surgery or the v02 rebake. Flat and relaxed are the shippable poses; fist/grip belong to the morph lane for now. Also here: - README: the solver's input is v02/..._exp03.glb, NOT exp01. exp01 is pre-hand -fit (converter steps 2b/2c); its finger groups sit on the wrist and overlap the real finger by 1.6cm, so a solve from it silently zeroes every _02/_03 bone -- rigid stick fingers and a torn flat -- while weight sums stay 1.0 and every assert passes. Cost three wasted bakes and one false "the solver regressed". - Seed assert demanded >=100 seeds while the radius loop caps at SEED_AXIS_R_MAX, which left pinky (88 seeds at 16mm) can never satisfy; the two constants were mutually unsatisfiable. Now >=60, and it is documented as a sanity gate rather than a quality bar. - Detwist poses tested at last: real but marginal (fist_r 38.7x -> 28.7x, grip_r 30.0x -> 17.3x, left hand flat). A knob, not a fix. - edge_stretch_cmp.py / skin_bone_territory.py / handpose_trim_hand_obj.py: judge tears by rest length and absolute posed growth, not raw ratio; audit whether a bone owns any verts at all (thumb_01 owns ZERO in exp05); and trim an arm-sized skin dump to the hand before rendering. Co-Authored-By: Claude Opus 5 --- .../plans/flat-handpose-runtime-2026-08-17.md | 88 +++ .../work/lena_leafbikini/08_finger_weights.py | 501 ++++++++++++++++++ characters/work/lena_leafbikini/README.md | 33 ++ hand-poses/README.md | 75 ++- hand-poses/canonical_rest.json | 246 +++++++++ hand-poses/pose_fist_detwist.json | 244 +++++++++ hand-poses/pose_grip_detwist.json | 244 +++++++++ tools/crosshand_diagnose.py | 174 ++++++ tools/edge_stretch.py | 53 ++ tools/edge_stretch_cmp.py | 77 +++ tools/fin_bones.py | 109 ++++ tools/hand_bone_ownership.py | 84 +++ tools/handpose_bake_preview.py | 61 +++ tools/handpose_detwist.py | 71 +++ tools/handpose_extract.py | 99 ++++ tools/handpose_render_objs.py | 59 +++ tools/handpose_scan_clips.py | 82 +++ tools/handpose_skin_to_obj.py | 98 ++++ tools/handpose_trim_hand_obj.py | 29 + tools/make_canonical_rest.py | 41 ++ tools/obj_crop.py | 50 ++ tools/obj_slice_components.py | 58 ++ tools/rest_deviation.py | 56 ++ tools/skin_bone_territory.py | 81 +++ tools/skin_crosshand_repair.py | 272 ++++++++++ tools/skin_displacement_check.py | 97 ++++ tools/skin_lever_audit.py | 156 ++++++ tools/skin_weight_audit.py | 61 +++ 28 files changed, 3285 insertions(+), 14 deletions(-) create mode 100644 .agents/plans/flat-handpose-runtime-2026-08-17.md create mode 100644 characters/work/lena_leafbikini/08_finger_weights.py create mode 100644 hand-poses/canonical_rest.json create mode 100644 hand-poses/pose_fist_detwist.json create mode 100644 hand-poses/pose_grip_detwist.json create mode 100644 tools/crosshand_diagnose.py create mode 100644 tools/edge_stretch.py create mode 100644 tools/edge_stretch_cmp.py create mode 100644 tools/fin_bones.py create mode 100644 tools/hand_bone_ownership.py create mode 100644 tools/handpose_bake_preview.py create mode 100644 tools/handpose_detwist.py create mode 100644 tools/handpose_extract.py create mode 100644 tools/handpose_render_objs.py create mode 100644 tools/handpose_scan_clips.py create mode 100644 tools/handpose_skin_to_obj.py create mode 100644 tools/handpose_trim_hand_obj.py create mode 100644 tools/make_canonical_rest.py create mode 100644 tools/obj_crop.py create mode 100644 tools/obj_slice_components.py create mode 100644 tools/rest_deviation.py create mode 100644 tools/skin_bone_territory.py create mode 100644 tools/skin_crosshand_repair.py create mode 100644 tools/skin_displacement_check.py create mode 100644 tools/skin_lever_audit.py create mode 100644 tools/skin_weight_audit.py diff --git a/.agents/plans/flat-handpose-runtime-2026-08-17.md b/.agents/plans/flat-handpose-runtime-2026-08-17.md new file mode 100644 index 0000000..444714e --- /dev/null +++ b/.agents/plans/flat-handpose-runtime-2026-08-17.md @@ -0,0 +1,88 @@ +# Handoff: runtime FLAT hand pose in ariki-game (2026-08-17) + +> **STATUS 2026-08-17 (session "flatpose"): DONE — all 3 acceptance criteria verified.** +> Implementation (uncommitted, in ariki-game): `src/Animation/HandPoseLayer.cs` +> (SkeletonModifier3D, per-hand 0..1 slerp blend, Mako/MixamoSkin excluded, missing bones +> skipped), PlayerController wiring (`SetFlatHands`, attached to `AnimatedSkeleton`), +> DanceTeam/DancerRig plumbing, bed hotkeys **H** (toggle, active team) + **J** (hand cam), +> HUD `hands=FLAT` flag, pose copied to `assets/quaternius/hand-poses/pose_flat.json`. +> Verified in the dance bed (agent API screenshots): exp01 body fingers visibly flatten +> mid-dance with no spikes; shipped mitt body = silent no-op, zero console errors; nothing +> committed. Test body copy `derived-bodies/lena_leafbikini_quatskin_fingers_glb_exp01.glb` +> is untracked/test-only — do not ship it from here. One Vulkan device-lost crash occurred +> during testing (RX 5700 XT TDR) — unrelated to this code, relaunch cured it. + +**Goal:** the female player character can hold a FLAT hand (fingers straight, together) +at runtime, applied as a layer on top of any playing animation. Flat only — fist and +grip are blocked on a weight repair that is running in a parallel lane (see "Scope +fence" below). + +## Why this works at all + +- ~70–100% of finger tracks in the shipped dance clips are frozen at rest, so a + per-frame finger override loses nothing from the animations. +- Hand poses were harvested from the Kevin packs into `animation/hand-poses/`: + `pose_flat.json`, `pose_relaxed.json`, `pose_fist.json`, `pose_grip.json`. +- Format: `{"bones": {"": [x, y, z, w], ...}}` — glTF node-local + quaternions on the **canonical Quaternius skeleton**, which is exactly Godot + bone-pose space for these bodies. Apply directly: + `skeleton.SetBonePoseRotation(skeleton.FindBone(name), new Quaternion(x, y, z, w))`. + No rest-relative correction on canonical rigs (that hack is only for Mako, out of + scope here). +- 40 bones per pose file, including `*_04_leaf_*` tip bones. Some bodies lack the + leaf bones — **skip bones that FindBone returns -1 for**, never error. + +## Body situation (the trap that makes testing confusing) + +- The SHIPPED female body `assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb` + has **zero finger weights** (the converter deliberately folds fingers into the hand + bone — "rigid mitt"). Applying the pose to her is correct code but shows NOTHING. +- A finger-weighted candidate exists and the flat pose is validated on it + (max displacement 1.8 cm, clean QA renders in + `animation/characters/work/lena_leafbikini/v02/review/`): + `animation/characters/work/lena_leafbikini/v02/lena_leafbikini_quatskin_fingers_glb_exp01.glb` + Use it as the TEST body. +- Mako (`Ariki_Male_Mako.glb`) has corrupt cross-hand finger weights — do NOT enable + the pose layer on him; a finger curl throws verts metres. Female/canonical only. + +## Implementation pointers + +- Pattern to copy: `ariki-game/src/Animation/PlayerIKRig.cs` — post-animation bone + modification. The hand-pose layer is the same idea: after the AnimationTree/Player + updates, write the pose quats onto the finger bones each frame while the layer is + active. Keep an on/off (and ideally a blend weight 0..1 slerping from the animated + pose) per hand. +- Test bed: `ariki-game/src/Testing/Dance/DanceTestBed.cs`. `DANCE_BODY_GLB` env var + swaps the bed's body — point it at the exp01 GLB above (see comment near line 82). + Run with `MOCK_ONLY=1`. +- ENGINE TRAP: the S3 engine is v1.0.0 (Godot 4.6.2) and the repo is on SDK 4.7. + If you locally downgrade `csproj`/`project.godot` to launch, **NEVER commit those + lines**. + +## Verification tools (animation repo) + +- `tools/handpose_bake_preview.py body.glb pose.json out.glb` — bakes a pose into a + GLB's rest rotations (what the runtime layer should reproduce). +- `tools/skin_displacement_check.py posed.glb original.glb` — Godot-exact LBS math; + flat on exp01 reads max 1.8 cm / median 0.27 cm. Meter-scale numbers = broken. +- `tools/handpose_skin_to_obj.py posed.glb l|r out.obj` + + `tools/handpose_render_objs.py` (blender --background) — the only honest VISUAL + check. **Do not judge by importing a baked-pose GLB into Blender and rendering: + the importer ignores the rest-vs-bind rewrite and draws false shards.** + +## Scope fence + +- Do not swap or re-export any shipped body (`Ariki_Female_QuatSkin.glb`, + `characters/female/lena_leafbikini_base_v01/` is frozen). The finger-weighted body + ships from the parallel weight-repair lane, not from this task. +- Do not touch `ariki-game/tools/make_lena_fullres_quatskin.py`; its + `LENA_RIGID_FINGERS` default must stay `"1"`. +- Flat pose only. Fist/grip activation waits for the repaired weights. + +## Acceptance + +1. In the dance test bed with `DANCE_BODY_GLB` = exp01, toggling the layer while a + dance plays visibly straightens/flattens the fingers, no vertex spikes, and the + rest of the animation is unaffected. +2. On the shipped mitt-handed body the layer is a silent no-op (no errors). +3. Nothing committed in either repo changes any shipped asset or engine version. diff --git a/characters/work/lena_leafbikini/08_finger_weights.py b/characters/work/lena_leafbikini/08_finger_weights.py new file mode 100644 index 0000000..a130fa5 --- /dev/null +++ b/characters/work/lena_leafbikini/08_finger_weights.py @@ -0,0 +1,501 @@ +"""08_finger_weights.py — re-solve finger skin weights on a quatskin candidate body. + +Why: the AccuRig hand weights survive the quatskin conversion (LENA_RIGID_FINGERS=0) +but were grafted nearest-surface from a 20:1 decimated carrier, so adjacent fingers +bleed into each other. Invisible at rest and in the FLAT pose; a full curl (fist/grip) +tears the fingers into ribbons (QA renders in v02/review/, 2026-08-17). + +Method: cross-finger bleed is impossible by construction here — + 1. label every hand-region vert to ONE finger (or palm) by multi-source Dijkstra + over the mesh's own edges (welded across the glTF importer's UV-seam splits), + seeded by proximity to each finger's bone axis with a margin test; + exp05: label propagation is spatially GATED (a digit's label can never reach a + vert CROSS_GATE closer to another digit's axis) and edges crossing the + inter-digit equidistance valley are cost-penalized, so the digit boundary + settles in the fused inter-finger valley instead of wandering onto a + neighbor's flank (exp04's middle_02<->ring_02 / pinky<->ring fin stacks); + 2. rebuild finger weights procedurally along the labeled finger's bone chain: + arc-length projection, linear blend zones at each joint, base blends into hand; + exp05: the arc-length param s is clamped by GEODESIC distance from the digit's + own base frontier — euclidean projection could snap a base-region vert to a + distal segment, yielding hand + phalanx-2 weight with zero phalanx-1 (exp04's + hand<->thumb_02 / hand<->index_02 fins); then weights are smoothed over the + mesh graph restricted to same-digit + palm neighbors (NEVER across the + inter-finger gap), and a chain-continuity repair guarantees graded + hand->_01->_02->_03 falloff; + 3. palm-labeled verts lose their finger weights into the hand bone. +Everything outside the finger-weighted region (+1.2 cm collar) is untouched, and no +vertex position changes anywhere — this is a weights-only edit. + +usage: blender --background --factory-startup --python 08_finger_weights.py -- in.glb out.glb +""" +import bpy, sys, math, heapq, struct +from mathutils import Vector, kdtree + +argv = sys.argv[sys.argv.index("--") + 1:] +SRC, OUT = argv[0], argv[1] + +FING = ("thumb", "index", "middle", "ring", "pinky") +SEED_AXIS_R = 0.007 # finger seed: within 7 mm of its bone axis... +SEED_AXIS_R_MAX = 0.016 # ...grown per finger until it has enough seeds (the thumb is + # a fat digit — after the hand fit's 1.56x right-thumb stretch + # its whole surface sits >7 mm off-axis and 7 mm finds ~20 verts) +SEED_MARGIN = 0.002 # ...and 2 mm closer to it than to any other finger +PALM_AXIS_D = 0.016 # palm seed: >16 mm from every finger axis (12 mm let the + # fat right thumb's pad seed as palm -> hand<->thumb_02 fins) +COLLAR_R = 0.012 # spatial collar added around the finger-weighted region +JOINT_BLEND = 0.006 # half-width of the linear blend zone at each joint (m) +CROSS_GATE = 0.0025 # a digit's label may never reach a vert this much closer + # to another digit's axis (spatial nearest-bone gate) +VALLEY_PENALTY = 4.0 # Dijkstra cost multiplier for edges crossing the + # inter-digit equidistance valley (mild bias only: a heavy + # toll starved the fused valley floor of digit labels and + # palm claimed it -> hand=1 fin stacks between fingers) +VALLEY_SURCHARGE = 0.001 # flat cost per crossing edge +PALM_NEAR_D = 0.010 # palm label pays to enter the near-axis zone (<10 mm)... +PALM_CLIMB_PENALTY = 8.0 # ...this multiplier (digit surfaces belong to digits) +CAPTURE_D = 0.009 # palm/unreached verts closer than this to a digit axis are + # force-relabeled to the spatially nearest digit +CAPTURE_REGION_D = 0.015 # ...and originally finger-weighted ones out to this radius + # (fused valley floors and beyond-tip caps sit 10-13 mm off + # axis; folding them to hand leaves them behind in a fist) +BASE_RAMP = 0.008 # geodesic ramp length: digit weight fraction is 0 at the + # palm frontier and 1 this far (geodesic) into the digit +S_SLACK = 0.004 # geodesic clamp slack on the arc-length param (m) +SMOOTH_ITERS = 6 # weight-smoothing iterations (same-digit + palm only) +SMOOTH_ALPHA = 0.5 # neighbor-average blend factor per iteration +WEB_BLEND_R0 = 0.35 # exp06: cross-digit web blending. exp01-exp05 partitioned the + # hand HARD (one digit per vert, "cross-finger bleed impossible + # by construction"), which guarantees the fused inter-digit + # bridges tear by the FULL finger separation: web verts on the + # middle side move rigidly with middle, the ring side with ring, + # and the one edge ring between them absorbs the whole gap + # (measured on exp05 fist: ~811 middle<->ring / pinky<->ring + # edges >5x, up to 45x). The cure is not "no bleed" but GRADED + # bleed: a vert's blend fraction toward its nearest other digit + # ramps from 0 at r=WEB_BLEND_R0 to 0.5 at the equidistance + # valley (r = d_own / (d_own + d_other), so r=0.5 IS the valley). + # Both sides of the boundary reach exactly 0.5 there, so the + # weight field is CONTINUOUS across it and the separation is + # spread over the web's whole edge span instead of one ring. + # Set to 0.5 to disable (= exp05 behaviour). +WEB_BLEND_SKIP = ("thumb",) # exp07: digits excluded from cross-digit blending. The four + # fingers are near-parallel, so mixing a valley vert between two + # of them is well posed. The thumb is not: its transform is + # opposition, not curl, and its arc-length frame does not + # correspond to a finger's, so projecting an index-side or palm + # vert into the thumb chain hands it weight from a bone that + # moves somewhere else entirely. exp06 (thumb included) cut fist + # /grip needles by 54-67% but REGRESSED the shipped flat pose on + # the right hand from 0 to 9 visible needles, and fin_bones put + # all 44 of its torn edges on hand_r<->thumb_0x. Fingers only. + +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.ops.import_scene.gltf(filepath=SRC) +arm = next(o for o in bpy.data.objects if o.type == "ARMATURE") +body = max((o for o in bpy.data.objects if o.type == "MESH"), + key=lambda o: len(o.data.vertices)) +bpy.context.view_layer.update() +MW = body.matrix_world +AW = arm.matrix_world +nv = len(body.data.vertices) +print(f"[fw] body {body.name}: {nv} verts, {len(body.vertex_groups)} groups") + +pos = [MW @ v.co for v in body.data.vertices] + +def bone_head(name): + return AW @ arm.data.bones[name].head_local if name in arm.data.bones else None + +def seg_dist(p, a, b): + ab = b - a + t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-12))) + return (p - (a + ab * t)).length + +gname = {g.index: g.name for g in body.vertex_groups} +gidx = {g.name: g.index for g in body.vertex_groups} + +changed_total = 0 +for S in ("l", "r"): + fgroups = {f"{F}_0{i}_{S}" for F in FING for i in (1, 2, 3)} & set(gidx) + fg_idx = {gidx[n] for n in fgroups} + hand_i = gidx[f"hand_{S}"] + + # bone chains: [head01, head02, head03, tip] + chains = {} + for F in FING: + pts = [bone_head(f"{F}_0{i}_{S}") for i in (1, 2, 3)] + if any(p is None for p in pts): + raise RuntimeError(f"missing chain bones for {F}_{S}") + tip = bone_head(f"{F}_04_leaf_{S}") + if tip is None: + tip = pts[2] + (pts[2] - pts[1]) + chains[F] = pts + [tip] + + # region: verts carrying any finger weight on this side + region = set() + for v in body.data.vertices: + for gr in v.groups: + if gr.group in fg_idx and gr.weight > 1e-6: + region.add(v.index); break + print(f"[fw] side {S}: {len(region)} finger-weighted verts") + + # + spatial collar (label graph needs the surrounding palm to compete) + kd = kdtree.KDTree(len(region)) + for vi in region: kd.insert(pos[vi], vi) + kd.balance() + region2 = set(region) + for v in body.data.vertices: + if v.index in region2: continue + hit = kd.find(pos[v.index]) + if hit[0] is not None and hit[2] <= COLLAR_R: + region2.add(v.index) + print(f"[fw] side {S}: region with collar = {len(region2)}") + + # adjacency: real mesh edges inside region2 + zero-cost weld edges across UV-seam dupes + adj = {vi: [] for vi in region2} + for e in body.data.edges: + a, b = e.vertices + if a in region2 and b in region2: + d = (pos[a] - pos[b]).length + adj[a].append((b, d)); adj[b].append((a, d)) + kd2 = kdtree.KDTree(len(region2)) + for vi in region2: kd2.insert(pos[vi], vi) + kd2.balance() + welds = 0 + for vi in region2: + for (_, oi, d) in kd2.find_range(pos[vi], 1e-6): + if oi != vi: + adj[vi].append((oi, 0.0)); welds += 1 + print(f"[fw] side {S}: {sum(len(a) for a in adj.values())//2} edges ({welds//2} weld pairs)") + + # seeds + def axis_dists(p): + out = {} + for F, pts in chains.items(): + out[F] = min(seg_dist(p, pts[k], pts[k+1]) for k in range(3)) + return out + + INF = float("inf") + dist = {vi: INF for vi in region2} + label = {} + pq = [] + ds_all = {vi: axis_dists(pos[vi]) for vi in region2} + ds_min = {vi: min(ds_all[vi].values()) for vi in region2} + nearest_digit = {vi: min(ds_all[vi], key=ds_all[vi].get) for vi in region2} + seeds = {} + radii = {} + for F in FING: + r = SEED_AXIS_R + while True: + picked = [vi for vi in region2 + if ds_all[vi][F] < r + and min((d for G, d in ds_all[vi].items() if G != F), + default=INF) - ds_all[vi][F] > SEED_MARGIN] + if len(picked) >= 100 or r >= SEED_AXIS_R_MAX: + break + r += 0.001 + seeds[F] = len(picked); radii[F] = r + for vi in picked: + dist[vi] = 0.0; label[vi] = F + heapq.heappush(pq, (0.0, vi, F)) + palm_seeds = 0 + for vi in region2: + if vi in label: continue + if min(ds_all[vi].values()) > PALM_AXIS_D: + v = body.data.vertices[vi] + tw = sum(gr.weight for gr in v.groups) + hw = sum(gr.weight for gr in v.groups if gr.group == hand_i) + if tw > 0 and hw / tw >= 0.6: + dist[vi] = 0.0; label[vi] = "palm" + heapq.heappush(pq, (0.0, vi, "palm")); palm_seeds += 1 + print(f"[fw] side {S}: seeds {seeds} palm={palm_seeds} " + f"(radii {[f'{F}:{radii[F]*1000:.0f}mm' for F in FING]})") + # sanity gate, not a quality bar: the seed loop stops growing the radius at + # SEED_AXIS_R_MAX, so a digit whose whole surface sits off-axis (left pinky on this + # mesh tops out at 88 seeds / 16 mm) can never reach 100 no matter how healthy the + # labeling is — asserting 100 made the two constants mutually unsatisfiable. This + # catches an actually broken seeding (a handful of verts), which is what it is for. + for F, n in seeds.items(): + assert n >= 60, f"side {S}: only {n} seeds for {F} — seed radii wrong for this mesh" + assert palm_seeds >= 100, f"side {S}: only {palm_seeds} palm seeds" + + while pq: + d, vi, lab = heapq.heappop(pq) + if d > dist[vi] or label.get(vi, lab) != lab: continue + for oi, w in adj[vi]: + if lab != "palm": + # inter-digit exclusivity: a digit's label may never reach a vert + # that sits CROSS_GATE closer to another digit's axis — the Tripo + # mesh fuses adjacent fingers, so topology alone lets a label leak + # across the gap onto the neighbor digit's flank + if ds_all[oi][lab] - ds_min[oi] > CROSS_GATE: + continue + # crossing the equidistance valley between two digits is heavily + # penalized so the label boundary settles IN the fused valley + if nearest_digit[oi] != nearest_digit[vi]: + w = w * VALLEY_PENALTY + VALLEY_SURCHARGE + else: + # symmetric toll: palm expansion pays to climb onto a digit's + # surface (exp05 rev1: palm walked toll-free up the fingers and + # left a weight cliff mid-phalanx -> hand<->hand fin stacks) + if ds_min[oi] < PALM_NEAR_D: + w = w * PALM_CLIMB_PENALTY + 0.002 + nd = d + w + if nd < dist[oi]: + dist[oi] = nd; label[oi] = lab + heapq.heappush(pq, (nd, oi, lab)) + + # capture pass: no vert this close to a digit axis may stay palm/unreached — + # fused finger-to-palm contacts and gate-orphaned islands otherwise fold to + # hand=1 mid-finger and shear off their curling neighbors (exp05 rev1's + # hand<->index_02 / ring_03<->ring_03 fins) + captured = 0 + for vi in region2: + if label.get(vi) not in FING and \ + (ds_min[vi] < CAPTURE_D or (vi in region and ds_min[vi] < CAPTURE_REGION_D)): + label[vi] = nearest_digit[vi]; captured += 1 + print(f"[fw] side {S}: captured {captured} near-axis palm/unreached verts to digits") + + counts = {F: 0 for F in FING}; counts["palm"] = 0; counts["unreached"] = 0 + for vi in region2: + counts[label.get(vi, "unreached")] = counts.get(label.get(vi, "unreached"), 0) + 1 + print(f"[fw] side {S}: labels {counts}") + + # residual cross-digit mesh edges (real fused-gap bridges; these are the + # accepted sub-mm baseline, not fixable by weights) + xdig = sum(1 for e in body.data.edges + if label.get(e.vertices[0]) in FING and label.get(e.vertices[1]) in FING + and label.get(e.vertices[0]) != label.get(e.vertices[1])) + print(f"[fw] side {S}: residual cross-digit mesh edges: {xdig}") + + # rebuild weights + grp = {n: body.vertex_groups[n] for n in + list(fgroups) + [f"hand_{S}"]} + hand_key = f"hand_{S}" + arcs = {} + for F in FING: + pts = chains[F]; L = [0.0] + for k in range(3): + L.append(L[-1] + (pts[k+1] - pts[k]).length) + arcs[F] = L + + def chain_s(F, p): + pts = chains[F]; L = arcs[F] + best_s, best_d = 0.0, INF + for k in range(3): + a, b = pts[k], pts[k+1] + ab = b - a + t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-12))) + d = (p - (a + ab * t)).length + if d < best_d: + best_d = d; best_s = L[k] + t * (L[k+1] - L[k]) + return best_s + + def weights_from_s(F, s, ramp=1.0): + L = arcs[F]; bz = JOINT_BLEND + # digit fraction: arc-length blend, capped by the geodesic base ramp so it + # is exactly 0 at the palm frontier (a one-sided taper leaves a cliff) + t_base = min(max(0.0, min(1.0, (s + bz) / (2 * bz))), ramp) + t1 = max(0.0, min(1.0, (s - (L[1] - bz)) / (2 * bz))) + t2 = max(0.0, min(1.0, (s - (L[2] - bz)) / (2 * bz))) + return {hand_key: 1 - t_base, + f"{F}_01_{S}": t_base * (1 - t1), + f"{F}_02_{S}": t_base * t1 * (1 - t2), + f"{F}_03_{S}": t_base * t1 * t2} + + # graded hand->_01->_02->_03 continuity: euclidean chain projection can snap + # a base-region vert to a distal segment (hand + phalanx-2 weight with zero + # phalanx-1). Clamp each vert's arc position s by its GEODESIC distance from + # the digit's own base frontier so s grows monotonically along the surface. + s_final = {} + rampv = {} + for F in FING: + dverts = [vi for vi in region2 if label.get(vi) == F] + sp_raw = {vi: chain_s(F, pos[vi]) for vi in dverts} + gd = {vi: INF for vi in dverts} # seeded with s_proj: absolute s clamp + gdb = {vi: INF for vi in dverts} # seeded with 0: base-ramp distance + pq2 = [] + for vi in dverts: + # base frontier: touches palm/unlabeled AND projects into phalanx 1 + # (mid-digit verts fused to the palm must not seed a false base) + if sp_raw[vi] <= arcs[F][1] and \ + any(label.get(oi) not in FING for oi, _ in adj[vi]): + gd[vi] = max(0.0, sp_raw[vi]) + gdb[vi] = 0.0 + heapq.heappush(pq2, (gd[vi], vi)) + while pq2: + d, vi = heapq.heappop(pq2) + if d > gd[vi]: continue + for oi, w in adj[vi]: + if label.get(oi) != F: continue + nd2 = d + w + if nd2 < gd[oi]: + gd[oi] = nd2 + heapq.heappush(pq2, (nd2, oi)) + pq3 = [(0.0, vi) for vi in dverts if gdb[vi] == 0.0] + heapq.heapify(pq3) + while pq3: + d, vi = heapq.heappop(pq3) + if d > gdb[vi]: continue + for oi, w in adj[vi]: + if label.get(oi) != F: continue + nd2 = d + w + if nd2 < gdb[oi]: + gdb[oi] = nd2 + heapq.heappush(pq3, (nd2, oi)) + clamped = 0 + for vi in dverts: + s = sp_raw[vi] + if gd[vi] < INF and s > gd[vi] + S_SLACK: + s = gd[vi] + S_SLACK; clamped += 1 + s_final[vi] = s + rampv[vi] = min(1.0, gdb[vi] / BASE_RAMP) if gdb[vi] < INF else 1.0 + print(f"[fw] side {S}: {F} geodesic s-clamp moved {clamped}/{len(dverts)} verts") + + fverts = [vi for vi in region2 if label.get(vi) in FING] + wcur = {vi: weights_from_s(label[vi], s_final[vi], rampv[vi]) for vi in fverts} + + # topology-aware smoothing for graded falloff: average ONLY with same-digit + # neighbors (NEVER across the inter-finger gap) and with palm/hand neighbors + # (contributing pure hand weight) so digit bases taper into the palm. + for _ in range(SMOOTH_ITERS): + wnew = {} + for vi in fverts: + F = label[vi] + accum = {}; n = 0 + for oi, _w in adj[vi]: + lo = label.get(oi) + if lo == F: + vec = wcur[oi] + elif lo in FING: + continue # other digit: hard wall + else: + vec = {hand_key: 1.0} + for k, x in vec.items(): + accum[k] = accum.get(k, 0.0) + x + n += 1 + if n == 0: + wnew[vi] = wcur[vi]; continue + mix = {} + for k in set(accum) | set(wcur[vi]): + mix[k] = ((1 - SMOOTH_ALPHA) * wcur[vi].get(k, 0.0) + + SMOOTH_ALPHA * accum.get(k, 0.0) / n) + tot = sum(mix.values()) + wnew[vi] = {k: x / tot for k, x in mix.items()} + wcur = wnew + + # exp06 cross-digit web blend: make the weight field continuous ACROSS the digit + # boundary instead of walling it off. r = d_own / (d_own + d_nearest_other) is 0 on + # the digit's own axis and 0.5 in the fused equidistance valley; beta ramps 0 -> 0.5 + # over [WEB_BLEND_R0, 0.5], so a valley vert is an even mix of the two digits and + # lands on the midpoint of their motion. The mirror vert across the boundary computes + # the same r and the same 50/50 mix, which is what removes the cliff. Applied AFTER + # smoothing (the smoother's hard wall would erode beta at the boundary, exactly where + # it must survive) and evaluated in the neighbour digit's own arc-length frame, capped + # by this vert's base ramp so the palm frontier stays graded. + blended = 0 + beta_max = 0.0 + for vi in fverts: + F = label[vi] + if F in WEB_BLEND_SKIP: + continue + cands = [g for g in FING if g != F and g not in WEB_BLEND_SKIP] + if not cands: + continue + dF = ds_all[vi][F] + G = min(cands, key=lambda g: ds_all[vi][g]) + dG = ds_all[vi][G] + r = dF / max(dF + dG, 1e-9) + if r <= WEB_BLEND_R0: + continue + beta = 0.5 * min(1.0, (r - WEB_BLEND_R0) / max(0.5 - WEB_BLEND_R0, 1e-9)) + if beta <= 1e-3: + continue + wG = weights_from_s(G, chain_s(G, pos[vi]), rampv.get(vi, 1.0)) + mix = {} + for k in set(wcur[vi]) | set(wG): + mix[k] = (1 - beta) * wcur[vi].get(k, 0.0) + beta * wG.get(k, 0.0) + tot = sum(mix.values()) + wcur[vi] = {k: x / tot for k, x in mix.items() if x / tot > 1e-4} + blended += 1 + beta_max = max(beta_max, beta) + print(f"[fw] side {S}: web-blended {blended}/{len(fverts)} verts " + f"(max beta {beta_max:.3f}, r0={WEB_BLEND_R0})") + + # chain-continuity repair: no vert may carry hand + phalanx>=2 weight while + # phalanx-1 is starved + repaired = 0 + for vi in fverts: + F = label[vi] + w = wcur[vi] + wh = w.get(hand_key, 0.0) + k1, k2 = f"{F}_01_{S}", f"{F}_02_{S}" + w1 = w.get(k1, 0.0) + w23 = w.get(k2, 0.0) + w.get(f"{F}_03_{S}", 0.0) + need = 0.5 * min(wh, w23) + if need > 0.01 and w1 < need: + deficit = need - w1 + for k, avail in ((hand_key, wh), (k2, w.get(k2, 0.0))): + take = min(deficit / 2, avail) + w[k] = w.get(k, 0.0) - take + w1 += take + w[k1] = w1 + tot = sum(w.values()) + wcur[vi] = {k: x / tot for k, x in w.items()} + repaired += 1 + print(f"[fw] side {S}: chain-continuity repaired {repaired} verts") + + changed = 0 + for vi in region2: + lab = label.get(vi) + v = body.data.vertices[vi] + if lab in FING: + for g in body.vertex_groups: + g.remove([vi]) + for n, x in wcur[vi].items(): + if x > 1e-4: grp[n].add([vi], x, "REPLACE") + changed += 1 + else: # palm / unreached: strip finger weights into hand + fsum = sum(gr.weight for gr in v.groups if gr.group in fg_idx) + if fsum > 1e-6: + for n in fgroups: + body.vertex_groups[n].remove([vi]) + grp[f"hand_{S}"].add([vi], fsum, "ADD") + changed += 1 + changed_total += changed + print(f"[fw] side {S}: rewrote weights on {changed} verts") + +# weight-sum gate on everything we touched (glTF needs sum==1; exporter normalizes +# top-4 but a bad sum here means the logic is wrong, not a rounding issue) +bad = 0 +for v in body.data.vertices: + tw = sum(gr.weight for gr in v.groups) + if abs(tw - 1.0) > 0.01: bad += 1 +print(f"[fw] verts with weight sum off by >1%: {bad}") +assert bad == 0, "weight sums broken" + +# names must match the canonical body (same reason as the converter) +body.name = "Lena_Female"; body.data.name = "Lena_Female" +for m in body.data.materials: + if m: m.name = "MI_Body_Lena" +arm.name = "Armature.001" +if arm.data: arm.data.name = "Armature.001" + +bpy.ops.object.select_all(action="DESELECT") +arm.select_set(True); body.select_set(True) +bpy.ops.export_scene.gltf(filepath=OUT, use_selection=True, export_format="GLB", + export_skins=True, export_animations=False, export_yup=True) +print(f"[fw] EXPORTED {OUT} ({changed_total} verts rewritten)") + +# alphaMode BLEND -> OPAQUE patch (same as the converter's post-export step) +with open(OUT, "rb") as f: d = f.read() +jl = struct.unpack_from(".glb +``` + +exp01 and exp03 carry the SAME weight groups (identical vertex sets — `index_l` is 6,985 +verts in both) but **different meshes**: 91,445 verts differ, by up to 12.5 cm. exp01 is +the pre-hand-fit body, so on it those groups land on the wrist/palm, overlapping the real +index finger by only 1.6 cm. Solving from exp01 therefore produces a body where the +arc-length param `s` never clears the phalanx-1 threshold and **every `_02`/`_03` finger +bone gets exactly zero weight** — rigid stick fingers hinging at the knuckle, and a torn +flat pose. It fails silently: weight sums are 1.0, the asserts pass, the export succeeds. +Confirm a good run by the label counts — from exp03, side l is index 4,000 / middle 4,718 +(these match exp05's groups) and `chain-continuity repaired` is ~400 per side, not 0. + +Verify a bake with `tools/handpose_bake_preview.py` + `tools/handpose_skin_to_obj.py` + +edge-stretch; judge on **real** tears (>=1mm rest length) and **visible** needles (>=1cm +posed), not raw ratios. Flat must stay at 0-1 real tears per hand — that pose ships. + +Measurement tools for this lane: `tools/edge_stretch_cmp.py` (stretch with the real-tear / +visible-needle split, several builds side by side), `tools/skin_bone_territory.py` (how many +verts each finger bone actually owns — catches a starved chain), `tools/fin_bones.py` +(classifies torn edges by bone pair, which is what tells you *which* defect you are looking +at), `tools/handpose_trim_hand_obj.py` (trim an arm-sized skin dump to the hand, or a +bbox-framing renderer puts the hand in a corner). diff --git a/hand-poses/README.md b/hand-poses/README.md index 7697f34..31fa288 100644 --- a/hand-poses/README.md +++ b/hand-poses/README.md @@ -8,25 +8,55 @@ byte-identical — the runtime `HandPoseLayer` reads the game copy). `{"bones": {"": [x, y, z, w], ...}}` — glTF node-local quaternions on the **canonical Quaternius skeleton**, which is exactly Godot bone-pose space for these -bodies. Apply directly: - -```csharp -skeleton.SetBonePoseRotation(skeleton.FindBone(name), new Quaternion(x, y, z, w)); -``` +bodies. 40 bones per pose, including `*_04_leaf_*` tip bones. Some bodies lack the leaf -bones — **skip bones `FindBone` returns -1 for**, never error. On non-canonical rigs -(Mako) poses must be applied rest-relative; the runtime layer simply excludes those -bodies instead. +bones — **skip bones `FindBone` returns -1 for**, never error. + +**Apply REST-RELATIVE, not directly** (runtime does this as of 2026-08-18): + +```csharp +// delta = canonicalRest^-1 * pose; target = thisBodysRest * delta +var target = boneRest * (canonRest.Inverse() * pose); +skeleton.SetBonePoseRotation(idx, target); +``` + +`canonical_rest.json` (regenerate with `tools/make_canonical_rest.py`) holds the canonical +finger rests this correction needs. On a rig whose finger rest matches canonical the +correction is algebraically a no-op, so nothing changes for the Quaternius/QuatSkin +bodies. On one that deviates it is the only correct form — Mako's `*_01` knuckles sit +**11.5°** off canonical, and writing poses straight in wrenched them away from his own +rest and tore the palm/wrist boundary. ## Poses | File | Source (Kevin packs) | Runtime status | |---|---|---| -| `pose_flat.json` | harvested flat hand | **SHIPPED to layer 2026-08-17** — verified in the dance bed on exp01 | -| `pose_relaxed.json` | `HandWave01` f0 | staged | -| `pose_fist.json` | `AttackPunch01_R/L` f7, merged | blocked on Lena finger-weight repair (fist/grip shred on exp01–exp04) | -| `pose_grip.json` | `CombatIdle1H01` f0, both hands | blocked, same repair | +| `pose_flat.json` | **= canonical REST** (see below) | **SHIPPED** — verified in-engine on Mako 2026-08-18 | +| `pose_relaxed.json` | `HandWave01` f0 (~10° off rest) | staged | +| `pose_fist.json` | `AttackPunch01_R/L` f7, merged (~61° off rest) | blocked on finger-weight repair (shreds on exp01–exp05 and on Mako) | +| `pose_grip.json` | `CombatIdle1H01` f0, both hands (~44° off rest) | blocked, same repair | + +### `pose_flat` is the REST pose — and that is why it is useful + +Measured 2026-08-18: `pose_flat.json` matches `kevin_female_combat.glb`'s finger rests to +**0.04°** — it is the canonical rest pose, not a separately harvested "flat hand". Do not +expect it to straighten a hand that is already at rest. + +It is still the load-bearing pose, because **the shipped clips do not hold fingers at +rest** — they pin them in a permanent curl (frozen tracks, spread 0.0°). `UAL1 Idle_Loop` +holds the fingers **53.9° off rest on average, up to 86.8°** (`thumb_03_r`) — near a +clench. So the FLAT layer's real job is to undo that baked-in curl. + +On Mako that curl is what shreds his hands. It does not fling verts (max displacement only +23.3 cm, i.e. legitimate fingertip travel) — it **tears**: 2,258 edges stretched >5×, max +198×, which rips his fused hand open into sheets. Turning FLAT on removes the bulk of it. +Evidence (in-engine, the only honest judge here): +`characters/work/mako/handfix/review/ingame_handcam_flat_{OFF,ON}.png` and the +`ingame_mako_{left,right}_hand_flat_ON_zoom.png` crops. **Residual:** at close range his +RIGHT hand still shows a torn patch with FLAT on — his right hand owns far less finger +geometry than his left (4,283 verts vs 10,532; `middle_02_r` owns just 157), so it is not +fully fixed. FLAT only holds the FINGER bones at rest; `hand_l/r` still follow the clip. ## Verification (before shipping a pose) @@ -34,5 +64,22 @@ bodies instead. - `tools/skin_displacement_check.py posed.glb original.glb` — Godot-exact LBS travel; cm-scale = sane, m-scale = broken. **Blind to fin tearing** — pair it with the edge-stretch check (`tools/edge_stretch.py`). -- `tools/handpose_skin_to_obj.py` + `tools/handpose_render_objs.py` — the only honest - visual check. Never judge by importing a baked-pose GLB into Blender (false shards). +- `tools/handpose_skin_to_obj.py` + `tools/handpose_render_objs.py` — clay render. Never + judge by importing a baked-pose GLB into Blender (false shards). Pass an ABSOLUTE outdir + (a relative one silently writes nothing), name inputs `_hand_.obj` (the glob + requires it), and crop to the hand with `tools/obj_crop.py` or the arm dominates the frame. +- `tools/skin_lever_audit.py body.glb` — finds bindings whose joint is implausibly far away + in rest, and verts bound across the midline to the opposite hand. **Invisible at rest**, + so nothing else catches them: this is what found Mako's 444 cross-hand verts. +- `tools/skin_crosshand_repair.py in.glb out.glb` — repairs those by inpainting from the + mesh's own healthy neighbours (`--diagnose` to preview). +- `tools/hand_bone_ownership.py body.glb [l|r]` — which finger bones actually own geometry. + Run it before trusting a pose on a new body: Mako is a **two-finger rig**, so only his + thumb and middle chains own verts and index/ring/pinky own nothing. +- `tools/rest_deviation.py canonical.glb other.glb` — per-bone finger rest deviation, i.e. + whether a body needs the rest-relative correction. + +**Ratio alone is not the tearing gate.** `edge_stretch.py` reports huge ratios on this +mesh's sub-millimetre sliver edges (unwelded duplicates) — judge the **absolute posed +length**. On Mako, flat's worst stretched edge reaches ~1–2.4 cm (benign, sub-pixel in +engine) while fist/grip reach 10–15 cm (real, visible needles). diff --git a/hand-poses/canonical_rest.json b/hand-poses/canonical_rest.json new file mode 100644 index 0000000..0828104 --- /dev/null +++ b/hand-poses/canonical_rest.json @@ -0,0 +1,246 @@ +{ + "_comment": "Canonical Quaternius finger-bone REST rotations, read from kevin_female_combat.glb. The runtime applies a pose rest-relative: delta = canonical_rest^-1 * pose, target = body_rest * delta. This makes poses correct on rigs whose finger rest differs from canonical (Mako's *_01 knuckles sit 11.5 deg off). Generated by tools/make_canonical_rest.py.", + "source": "kevin_female_combat.glb", + "bones": { + "index_04_leaf_l": [ + -4e-08, + 0.99982297, + 0.0, + 0.01881603 + ], + "index_03_l": [ + 1.99e-06, + -7.07e-06, + -0.00018567, + 1 + ], + "index_02_l": [ + 4.8e-07, + 4.949e-05, + -0.00053302, + 0.99999988 + ], + "index_01_l": [ + 0.00850501, + 0.70681149, + -0.0185241, + 0.7071082 + ], + "middle_04_leaf_l": [ + -0.0, + 0.99982554, + 0.0, + 0.0186798 + ], + "middle_03_l": [ + -3.6e-07, + -5.432e-05, + -0.00141977, + 0.99999905 + ], + "middle_02_l": [ + 5.9e-07, + 7.837e-05, + 0.00205278, + 0.99999791 + ], + "middle_01_l": [ + 0.00506735, + 0.70676285, + -0.02195816, + 0.70709157 + ], + "pinky_04_leaf_l": [ + 0.0, + 0.99982917, + -1e-08, + 0.01848373 + ], + "pinky_03_l": [ + 3.26e-06, + -0.00013735, + 0.00046609, + 0.99999988 + ], + "pinky_02_l": [ + 4.4e-07, + 9.531e-05, + -0.0015675, + 0.99999881 + ], + "pinky_01_l": [ + 0.00260105, + 0.70668215, + -0.0244147, + 0.70710504 + ], + "ring_04_leaf_l": [ + -0.0, + 0.99981856, + 4e-08, + 0.01904976 + ], + "ring_03_l": [ + 3.61e-06, + -0.00025298, + -0.00338758, + 0.99999422 + ], + "ring_02_l": [ + -4.8e-06, + 0.00021854, + 0.00239975, + 0.99999708 + ], + "ring_01_l": [ + 0.01532381, + 0.70691711, + -0.01171326, + 0.70703346 + ], + "thumb_04_leaf_l": [ + -1e-08, + 0.38270876, + 1e-08, + 0.92386907 + ], + "thumb_03_l": [ + -1.3e-07, + -2.972e-05, + 1.65e-06, + 1 + ], + "thumb_02_l": [ + 7.4e-07, + 4.63e-06, + -3.99e-06, + 1 + ], + "thumb_01_l": [ + -0.24446329, + -0.94356763, + -0.21606149, + 0.05688012 + ], + "index_04_leaf_r": [ + 0.0, + -0.99982297, + -0.0, + 0.01881603 + ], + "index_03_r": [ + 1.98e-06, + 7.07e-06, + 0.00018565, + 1 + ], + "index_02_r": [ + 4.1e-07, + -4.94e-05, + 0.00053306, + 0.99999988 + ], + "index_01_r": [ + 0.00850504, + -0.70681167, + 0.01852416, + 0.70710802 + ], + "middle_04_leaf_r": [ + -6e-08, + -0.99982554, + -0.0, + 0.01867968 + ], + "middle_03_r": [ + -4.3e-07, + 5.437e-05, + 0.00141985, + 0.99999899 + ], + "middle_02_r": [ + 5.9e-07, + -7.843e-05, + -0.00205283, + 0.99999791 + ], + "middle_01_r": [ + 0.00506735, + -0.70676279, + 0.02195819, + 0.70709163 + ], + "pinky_04_leaf_r": [ + -0.0, + -0.99982917, + 0.0, + 0.01848373 + ], + "pinky_03_r": [ + 3.21e-06, + 0.00013741, + -0.000466, + 0.99999988 + ], + "pinky_02_r": [ + 4.2e-07, + -9.54e-05, + 0.00156743, + 0.99999875 + ], + "pinky_01_r": [ + 0.0026011, + -0.70668221, + 0.02441467, + 0.70710492 + ], + "ring_04_leaf_r": [ + 0.0, + -0.99981856, + -0.0, + 0.01904976 + ], + "ring_03_r": [ + 3.69e-06, + 0.00025282, + 0.00338756, + 0.99999422 + ], + "ring_02_r": [ + -4.9e-06, + -0.0002185, + -0.00239973, + 0.99999714 + ], + "ring_01_r": [ + 0.01532385, + -0.70691711, + 0.01171329, + 0.7070334 + ], + "thumb_04_leaf_r": [ + -5e-08, + -0.38270876, + 2e-08, + 0.92386901 + ], + "thumb_03_r": [ + -2e-07, + 2.964e-05, + -2.08e-06, + 1 + ], + "thumb_02_r": [ + 6.8e-07, + -4.78e-06, + 3.18e-06, + 1 + ], + "thumb_01_r": [ + -0.24446253, + 0.94356787, + 0.21606137, + 0.0568799 + ] + } +} \ No newline at end of file diff --git a/hand-poses/pose_fist_detwist.json b/hand-poses/pose_fist_detwist.json new file mode 100644 index 0000000..05ed551 --- /dev/null +++ b/hand-poses/pose_fist_detwist.json @@ -0,0 +1,244 @@ +{ + "bones": { + "index_01_r": [ + 0.351009, + -0.560774, + 0.497857, + 0.56077 + ], + "index_02_r": [ + 0.695232, + 1e-06, + 0.211656, + 0.686916 + ], + "index_03_r": [ + 0.700051, + -1e-06, + 0.205494, + 0.683886 + ], + "index_04_leaf_r": [ + 2.422049449890551e-09, + -0.9998229742050171, + -1.465072729800454e-09, + 0.01881602592766285 + ], + "middle_01_r": [ + 0.368445, + -0.522544, + 0.543295, + 0.544083 + ], + "middle_02_r": [ + 0.625715, + -0.000948, + 0.176605, + 0.759796 + ], + "middle_03_r": [ + 0.636196, + 0.000719, + 0.144964, + 0.757786 + ], + "middle_04_leaf_r": [ + -7.969595827717058e-08, + -0.9998255372047424, + -1.2168405838508534e-08, + 0.018679669126868248 + ], + "pinky_01_r": [ + 0.45704, + -0.469857, + 0.56239, + 0.50405 + ], + "pinky_02_r": [ + 0.797564, + 0.000666, + 0.130697, + 0.588905 + ], + "pinky_03_r": [ + 0.800258, + -0.000488, + 0.105028, + 0.590386 + ], + "pinky_04_leaf_r": [ + -1.2306506924630867e-08, + -0.9998291730880737, + 2.6629458638183223e-09, + 0.018483733758330345 + ], + "ring_01_r": [ + 0.439332, + -0.489788, + 0.556349, + 0.507514 + ], + "ring_02_r": [ + 0.716635, + -7e-06, + 0.101299, + 0.690053 + ], + "ring_03_r": [ + 0.725824, + 0.000775, + 0.019064, + 0.687615 + ], + "ring_04_leaf_r": [ + -1.1020341972312053e-08, + -0.9998185634613037, + -4.304683276501464e-09, + 0.0190497525036335 + ], + "thumb_01_r": [ + 0.3767375349998474, + -0.924089252948761, + -0.06302371621131897, + 0.01248654630035162 + ], + "thumb_02_r": [ + 0.41932806372642517, + 0.04982568323612213, + 0.0259289238601923, + 0.9060955047607422 + ], + "thumb_03_r": [ + 0.41881823539733887, + 0.04550888016819954, + 0.03892602026462555, + 0.9060932397842407 + ], + "thumb_04_leaf_r": [ + -4.8268116614735845e-08, + -0.38270869851112366, + 2.2432569579677875e-08, + 0.9238690137863159 + ], + "index_01_l": [ + 0.39218, + 0.555922, + -0.477598, + 0.555918 + ], + "index_02_l": [ + 0.724569, + -0.0, + -0.139418, + 0.674954 + ], + "index_03_l": [ + 0.727499, + -0.0, + -0.131666, + 0.673357 + ], + "index_04_leaf_l": [ + -3.7380786466201243e-08, + 0.9998229742050171, + 1.2021164064179857e-09, + 0.018816031515598297 + ], + "middle_01_l": [ + 0.415844, + 0.518752, + -0.515325, + 0.54075 + ], + "middle_02_l": [ + 0.64627, + 0.000979, + -0.123229, + 0.753093 + ], + "middle_03_l": [ + 0.652853, + -0.000737, + -0.090181, + 0.752097 + ], + "middle_04_leaf_l": [ + -9.942080492209016e-10, + 0.9998255372047424, + 4.936169251124056e-09, + 0.018679805099964142 + ], + "pinky_01_l": [ + 0.510799, + 0.470059, + -0.513511, + 0.504415 + ], + "pinky_02_l": [ + 0.811413, + -0.000677, + -0.021826, + 0.584065 + ], + "pinky_03_l": [ + 0.811635, + 0.000494, + 0.003368, + 0.584156 + ], + "pinky_04_leaf_l": [ + -2.0175272563704993e-09, + 0.9998291730880737, + -1.9009007701242808e-08, + 0.018483726307749748 + ], + "ring_01_l": [ + 0.491088, + 0.489266, + -0.512127, + 0.507126 + ], + "ring_02_l": [ + 0.726699, + 8e-06, + -0.026298, + 0.686453 + ], + "ring_03_l": [ + 0.724342, + -0.000773, + 0.056651, + 0.687109 + ], + "ring_04_leaf_l": [ + 1.3828260758685929e-10, + 0.9998185634613037, + 3.419970084905799e-08, + 0.019049758091568947 + ], + "thumb_01_l": [ + 0.37334099411964417, + 0.924976646900177, + 0.06633511930704117, + 0.02518703043460846 + ], + "thumb_02_l": [ + 0.42000612616539, + -0.049613188952207565, + -0.011237763799726963, + 0.9060944318771362 + ], + "thumb_03_l": [ + 0.4200849235057831, + -0.044214341789484024, + -0.023830465972423553, + 0.9060937762260437 + ], + "thumb_04_leaf_l": [ + -1.3077848803888514e-09, + 0.38270875811576843, + 4.056841529376243e-09, + 0.9238690137863159 + ] + } +} \ No newline at end of file diff --git a/hand-poses/pose_grip_detwist.json b/hand-poses/pose_grip_detwist.json new file mode 100644 index 0000000..dee1e15 --- /dev/null +++ b/hand-poses/pose_grip_detwist.json @@ -0,0 +1,244 @@ +{ + "bones": { + "index_01_l": [ + 0.372089, + 0.575212, + -0.44701, + 0.575207 + ], + "index_02_l": [ + 0.536175, + -0.0, + -0.075882, + 0.840689 + ], + "index_03_l": [ + 0.537411, + -0.0, + -0.074724, + 0.840004 + ], + "index_04_leaf_l": [ + -4.2130491095804246e-08, + 0.9998229742050171, + 6.104919680893772e-09, + 0.018816012889146805 + ], + "middle_01_l": [ + 0.362081, + 0.571521, + -0.440008, + 0.590469 + ], + "middle_02_l": [ + 0.530509, + 0.000803, + -0.091032, + 0.842777 + ], + "middle_03_l": [ + 0.535123, + -0.000604, + -0.066811, + 0.842128 + ], + "middle_04_leaf_l": [ + 1.4026132255651191e-08, + 0.9998255372047424, + -1.303601337987459e-09, + 0.01867981255054474 + ], + "pinky_01_l": [ + 0.379526, + 0.578646, + -0.394442, + 0.604603 + ], + "pinky_02_l": [ + 0.534097, + -0.000446, + -0.015743, + 0.845276 + ], + "pinky_03_l": [ + 0.534231, + 0.000325, + 0.006553, + 0.845313 + ], + "pinky_04_leaf_l": [ + 1.7368666505035435e-08, + 0.9998291730880737, + -2.2143675337815694e-08, + 0.01848371885716915 + ], + "ring_01_l": [ + 0.382671, + 0.580734, + -0.403254, + 0.594725 + ], + "ring_02_l": [ + 0.553724, + 6e-06, + -0.018994, + 0.832484 + ], + "ring_03_l": [ + 0.552551, + -0.00059, + 0.03354, + 0.832804 + ], + "ring_04_leaf_l": [ + -5.756760401709471e-09, + 0.9998185634613037, + 2.5820394711217887e-08, + 0.01904975064098835 + ], + "thumb_01_l": [ + 0.36847078800201416, + 0.8819453120231628, + 0.2921511232852936, + 0.03239520639181137 + ], + "thumb_02_l": [ + 0.24808266758918762, + -0.029302185401320457, + -0.0066396272741258144, + 0.968272864818573 + ], + "thumb_03_l": [ + 0.24812805652618408, + -0.026128530502319336, + -0.014074699021875858, + 0.9682725071907043 + ], + "thumb_04_leaf_l": [ + -4.946755449708462e-09, + 0.38270875811576843, + 1.279980677004744e-09, + 0.9238690137863159 + ], + "index_01_r": [ + 0.33652, + -0.579358, + 0.464165, + 0.579353 + ], + "index_02_r": [ + 0.522149, + 1e-06, + 0.108755, + 0.845891 + ], + "index_03_r": [ + 0.524414, + -1e-06, + 0.108105, + 0.844573 + ], + "index_04_leaf_r": [ + 4.50209008961977e-10, + -0.9998229742050171, + -7.24976256805121e-09, + 0.0188160240650177 + ], + "middle_01_r": [ + 0.329666, + -0.574074, + 0.458768, + 0.592699 + ], + "middle_02_r": [ + 0.517316, + -0.000784, + 0.123773, + 0.846796 + ], + "middle_03_r": [ + 0.524415, + 0.000592, + 0.1003, + 0.845534 + ], + "middle_04_leaf_r": [ + -4.130962949489003e-08, + -0.9998255372047424, + -1.7077526059949832e-09, + 0.018679669126868248 + ], + "pinky_01_r": [ + 0.354361, + -0.578543, + 0.417584, + 0.604433 + ], + "pinky_02_r": [ + 0.529705, + 0.000442, + 0.049638, + 0.846728 + ], + "pinky_03_r": [ + 0.530597, + -0.000323, + 0.027208, + 0.847188 + ], + "pinky_04_leaf_r": [ + 2.6093180949260386e-09, + -0.9998291730880737, + 3.3186802195217524e-09, + 0.018483715131878853 + ], + "ring_01_r": [ + 0.355867, + -0.581001, + 0.426447, + 0.594928 + ], + "ring_02_r": [ + 0.54862, + -5e-06, + 0.055744, + 0.834212 + ], + "ring_03_r": [ + 0.553196, + 0.000591, + 0.003472, + 0.833044 + ], + "ring_04_leaf_r": [ + -2.2238539898467025e-08, + -0.9998185634613037, + -6.419670128821053e-09, + 0.019049761816859245 + ], + "thumb_01_r": [ + 0.37758150696754456, + -0.8796287178993225, + -0.288614422082901, + 0.019680041819810867 + ], + "thumb_02_r": [ + 0.24768105149269104, + 0.029427889734506607, + 0.015316151082515717, + 0.9682735204696655 + ], + "thumb_03_r": [ + 0.2473800927400589, + 0.02689296193420887, + 0.02299121953547001, + 0.96827232837677 + ], + "thumb_04_leaf_r": [ + -4.4832216161694305e-08, + -0.38270875811576843, + 2.3225833700735166e-08, + 0.9238690137863159 + ] + } +} \ No newline at end of file diff --git a/tools/crosshand_diagnose.py b/tools/crosshand_diagnose.py new file mode 100644 index 0000000..68f28ec --- /dev/null +++ b/tools/crosshand_diagnose.py @@ -0,0 +1,174 @@ +"""Diagnose cross-midline finger bindings: for every offending ref, compare the lever arm +to the WRONG joint against the lever to its MIRRORED counterpart, so we can tell whether a +straight _r <-> _l joint remap is geometrically correct (small mirrored lever) or would tear +(vert nowhere near the mirrored bone either). + +Also reports each offending vert's full influence list, and the nearest correct finger joint. + +usage: crosshand_diagnose.py body.glb +""" +import json, struct, sys, math +from pathlib import Path +from collections import Counter, defaultdict + +FING = ("thumb", "index", "middle", "ring", "pinky") + + +def read_glb(p): + d = Path(p).read_bytes() + length = struct.unpack_from(" 0.02) or (nl.endswith("_l") and p[0] < -0.02): + bad.append((vi, n, w, p)) + +print(f"\n cross-midline finger refs: {len(bad)}") +vids = sorted({v for v, _, _, _ in bad}) +print(f" distinct verts affected : {len(vids)} (index range {min(vids)}..{max(vids)})") + +# lever comparison: wrong joint vs mirrored joint vs nearest correct-side finger joint +print(f"\n {'joint':16s} {'n':>5s} {'lever_wrong':>12s} {'lever_mirror':>13s} {'nearest_correct'}") +groups = defaultdict(list) +for vi, n, w, p in bad: + groups[n].append((vi, w, p)) + +for n in sorted(groups): + rows = groups[n] + mn = mirror_name(n) + lw = [math.dist(p, jpos[n]) * 100 for _, _, p in rows] + lm = [math.dist(p, jpos[mn]) * 100 for _, _, p in rows] if mn in jpos else [float("nan")] + # nearest correct-side finger joint for a sample vert + side = "_l" if rows[0][2][0] > 0 else "_r" + cand = [(math.dist(rows[0][2], jpos[k]) * 100, k) for k in jpos + if any(t in k.lower() for t in FING) and k.endswith(side)] + cand.sort() + print(f" {n:16s} {len(rows):5d} {sum(lw)/len(lw):9.1f}cm {sum(lm)/len(lm):10.1f}cm " + f" {cand[0][1]} @ {cand[0][0]:.1f}cm") + +# full influence list for a few offenders +print("\n sample offending verts (full influence list):") +for vi in vids[:6]: + p = P[vi] + infl = [] + for j, w in zip(J[vi], W[vi]): + w *= wsc + if w > 0.001: + infl.append(f"{jname[j]}={w:.3f}") + print(f" v{vi} pos=({p[0]*100:6.1f},{p[1]*100:6.1f},{p[2]*100:6.1f})cm {' '.join(infl)}") + +# how many offending verts are FULLY (>0.99) bound to a wrong joint +full = sum(1 for vi, n, w, p in bad if w > 0.99) +print(f"\n refs at weight > 0.99 (rigid, no blend to soften): {full}") + +# what fraction of total left-hand-region verts are affected +hl = jpos.get("hand_l") +if hl: + near = [vi for vi, p in enumerate(P) if math.dist(p, hl) < 0.20] + aff = set(vids) & set(near) + print(f" verts within 20cm of hand_l: {len(near)}; of those affected: {len(aff)}") diff --git a/tools/edge_stretch.py b/tools/edge_stretch.py new file mode 100644 index 0000000..1298183 --- /dev/null +++ b/tools/edge_stretch.py @@ -0,0 +1,53 @@ +"""Edge-stretch fin detector (pure python): posed OBJ edge lengths vs rest OBJ.""" +import sys, os, math + +def load_obj(path): + vs, faces = [], [] + with open(path) as f: + for line in f: + if line.startswith('v '): + p = line.split() + vs.append((float(p[1]), float(p[2]), float(p[3]))) + elif line.startswith('f '): + idx = [int(tok.split('/')[0]) - 1 for tok in line.split()[1:]] + for i in range(1, len(idx) - 1): + faces.append((idx[0], idx[i], idx[i + 1])) + return vs, faces + +def edge_set(faces): + es = set() + for a, b, c in faces: + for u, v in ((a, b), (b, c), (c, a)): + es.add((u, v) if u < v else (v, u)) + return sorted(es) + +def dist(p, q): + return math.sqrt((p[0]-q[0])**2 + (p[1]-q[1])**2 + (p[2]-q[2])**2) + +d = sys.argv[1] +for hand in ('l', 'r'): + rest_v, rest_f = load_obj(os.path.join(d, f'rest_hand_{hand}.obj')) + edges = edge_set(rest_f) + rest_len = [dist(rest_v[a], rest_v[b]) for a, b in edges] + for pose in ('flat', 'fist', 'grip'): + v, _ = load_obj(os.path.join(d, f'{pose}_hand_{hand}.obj')) + if len(v) != len(rest_v): + print(f'{pose}_hand_{hand}: VERTEX COUNT MISMATCH {len(v)} vs {len(rest_v)}') + continue + ratios = [] + for (a, b), rl in zip(edges, rest_len): + if rl <= 1e-9: + continue + ratios.append((dist(v[a], v[b]) / rl, a, b)) + ratios.sort(key=lambda t: t[0]) + n = len(ratios) + mx = ratios[-1][0] + p999 = ratios[int(n * 0.999)][0] + n2 = sum(1 for r, _, _ in ratios if r > 2) + n3 = sum(1 for r, _, _ in ratios if r > 3) + n5 = sum(1 for r, _, _ in ratios if r > 5) + print(f'{pose}_hand_{hand}: edges={n} max={mx:.2f}x p99.9={p999:.2f}x >2x={n2} >3x={n3} >5x={n5}') + for r, a, b in ratios[-min(max(n3, 3), 8):][::-1]: + pa = [c * 100 for c in v[a]] + rl = dist(rest_v[a], rest_v[b]) * 100 + print(f' {r:7.1f}x rest {rl:5.2f}cm -> {r*rl:7.1f}cm at posed ({pa[0]:.1f}, {pa[1]:.1f}, {pa[2]:.1f}) cm') diff --git a/tools/edge_stretch_cmp.py b/tools/edge_stretch_cmp.py new file mode 100644 index 0000000..9f52faf --- /dev/null +++ b/tools/edge_stretch_cmp.py @@ -0,0 +1,77 @@ +"""Edge-stretch comparison across OBJ dirs, with a rest-length split. + +usage: stretch_cmp.py