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