From cf336f3b5b5daaadc1f401c595e428f00e65e687 Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Tue, 18 Aug 2026 11:33:15 -0700 Subject: [PATCH] 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