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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 11:33:15 -07:00
parent 3f335ee2d7
commit cf336f3b5b
28 changed files with 3285 additions and 14 deletions
@@ -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
- ~70100% 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": {"<bone_name>": [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.
@@ -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("<I", d, 12)[0]
js = d[20:20+jl].decode("utf-8")
j2 = js.replace('"alphaMode":"BLEND"', '"alphaMode":"OPAQUE"').replace('"alphaMode": "BLEND"', '"alphaMode": "OPAQUE"')
if j2 != js:
b = j2.encode("utf-8"); b += b" " * ((4 - len(b) % 4) % 4)
o = d[:12] + struct.pack("<I", len(b)) + d[16:20] + b + d[20+jl:]
o = o[:8] + struct.pack("<I", len(o)) + o[12:]
with open(OUT, "wb") as f: f.write(o)
print("[fw] alphaMode patched OPAQUE")
print("[fw] DONE")
+33
View File
@@ -171,3 +171,36 @@ the atlas is Tripo chart soup, so interpolating UVs across the fill would sample
same order and `leaf_mask.npz` indexes either mesh. Re-running 05/07 against the shipped same order and `leaf_mask.npz` indexes either mesh. Re-running 05/07 against the shipped
GLB is a first-argument change — but the result is a new ship folder, never an edit to GLB is a first-argument change — but the result is a new ship folder, never an edit to
the frozen one. the frozen one.
## Finger-weight re-solve (`08_finger_weights.py`) — how to run it
**The input is `v02/lena_leafbikini_quatskin_fingers_glb_exp03.glb`, not exp01.** This is
the one thing to get right; guessing it cost three wasted bakes on 2026-08-18.
```
"C:/Program Files/Blender Foundation/Blender 5.1/blender.exe" --background \
--factory-startup --python characters/work/lena_leafbikini/08_finger_weights.py -- \
characters/work/lena_leafbikini/v02/lena_leafbikini_quatskin_fingers_glb_exp03.glb \
characters/work/lena_leafbikini/v02/lena_leafbikini_quatskin_fingers_glb_<tag>.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).
+61 -14
View File
@@ -8,25 +8,55 @@ byte-identical — the runtime `HandPoseLayer` reads the game copy).
`{"bones": {"<bone_name>": [x, y, z, w], ...}}` — glTF node-local quaternions on the `{"bones": {"<bone_name>": [x, y, z, w], ...}}` — glTF node-local quaternions on the
**canonical Quaternius skeleton**, which is exactly Godot bone-pose space for these **canonical Quaternius skeleton**, which is exactly Godot bone-pose space for these
bodies. Apply directly: bodies.
```csharp
skeleton.SetBonePoseRotation(skeleton.FindBone(name), new Quaternion(x, y, z, w));
```
40 bones per pose, including `*_04_leaf_*` tip bones. Some bodies lack the leaf 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 bones — **skip bones `FindBone` returns -1 for**, never error.
(Mako) poses must be applied rest-relative; the runtime layer simply excludes those
bodies instead. **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 ## Poses
| File | Source (Kevin packs) | Runtime status | | 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_flat.json` | **= canonical REST** (see below) | **SHIPPED** — verified in-engine on Mako 2026-08-18 |
| `pose_relaxed.json` | `HandWave01` f0 | staged | | `pose_relaxed.json` | `HandWave01` f0 (~10° off rest) | staged |
| `pose_fist.json` | `AttackPunch01_R/L` f7, merged | blocked on Lena finger-weight repair (fist/grip shred on exp01exp04) | | `pose_fist.json` | `AttackPunch01_R/L` f7, merged (~61° off rest) | blocked on finger-weight repair (shreds on exp01exp05 and on Mako) |
| `pose_grip.json` | `CombatIdle1H01` f0, both hands | blocked, same repair | | `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) ## Verification (before shipping a pose)
@@ -34,5 +64,22 @@ bodies instead.
- `tools/skin_displacement_check.py posed.glb original.glb` — Godot-exact LBS travel; - `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 cm-scale = sane, m-scale = broken. **Blind to fin tearing** — pair it with the
edge-stretch check (`tools/edge_stretch.py`). edge-stretch check (`tools/edge_stretch.py`).
- `tools/handpose_skin_to_obj.py` + `tools/handpose_render_objs.py`the only honest - `tools/handpose_skin_to_obj.py` + `tools/handpose_render_objs.py`clay render. Never
visual check. Never judge by importing a baked-pose GLB into Blender (false shards). judge by importing a baked-pose GLB into Blender (false shards). Pass an ABSOLUTE outdir
(a relative one silently writes nothing), name inputs `<pose>_hand_<l|r>.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 ~12.4 cm (benign, sub-pixel in
engine) while fist/grip reach 1015 cm (real, visible needles).
+246
View File
@@ -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
]
}
}
+244
View File
@@ -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
]
}
}
+244
View File
@@ -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
]
}
}
+174
View File
@@ -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("<I", d, 8)[0]
off = 12
g = b_ = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off)
off += 8
if ct == 0x4E4F534A:
g = json.loads(d[off:off + clen])
else:
b_ = d[off:off + clen]
off += clen
return g, b_
def acc(g, b, i):
a = g["accessors"][i]
bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[a["type"]]
fmt = {5121: "B", 5123: "H", 5125: "I", 5126: "f"}[a["componentType"]]
size = struct.calcsize(fmt) * nc
stride = bv.get("byteStride") or size
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
return [struct.unpack_from("<%d%s" % (nc, fmt), b, off + k * stride)
for k in range(a["count"])], a["componentType"]
def quat_mat(q):
x, y, z, w = q
return [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
def node_local(nd):
t = nd.get("translation", [0, 0, 0])
r = nd.get("rotation", [0, 0, 0, 1])
s = nd.get("scale", [1, 1, 1])
R = quat_mat(r)
return [[R[i][j] * s[j] for j in range(3)] + [t[i]] for i in range(3)] + [[0, 0, 0, 1]]
def matmul(A, B):
return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
def global_mats(g):
loc = [node_local(nd) for nd in g["nodes"]]
parent = {}
for i, nd in enumerate(g["nodes"]):
for c in nd.get("children", []):
parent[c] = i
memo = {}
def gm(i):
if i in memo:
return memo[i]
m = loc[i]
p = parent.get(i)
if p is not None:
m = matmul(gm(p), m)
memo[i] = m
return m
return [gm(i) for i in range(len(g["nodes"]))]
def mirror_name(n):
if n.endswith("_r"):
return n[:-2] + "_l"
if n.endswith("_l"):
return n[:-2] + "_r"
return None
path = sys.argv[1]
g, b = read_glb(path)
names = [nd.get("name", "") for nd in g["nodes"]]
GM = global_mats(g)
mesh = g["meshes"][0]
prim = mesh["primitives"][0]
att = prim["attributes"]
skin_idx = next(nd.get("skin") for nd in g["nodes"] if nd.get("mesh") == 0 and "skin" in nd)
joints = g["skins"][skin_idx]["joints"]
jname = [names[j] for j in joints]
jpos = {jname[k]: (GM[j][0][3], GM[j][1][3], GM[j][2][3]) for k, j in enumerate(joints)}
P, _ = acc(g, b, att["POSITION"])
J, _ = acc(g, b, att["JOINTS_0"])
W, wt = acc(g, b, att["WEIGHTS_0"])
wsc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
# hand-bone anchors, to describe where verts sit
print(f"== {Path(path).name} ==")
for hb in ("hand_l", "hand_r", "middle_01_l", "middle_01_r", "middle_03_l", "middle_03_r"):
if hb in jpos:
p = jpos[hb]
print(f" {hb:14s} rest pos = ({p[0]*100:7.1f}, {p[1]*100:7.1f}, {p[2]*100:7.1f}) cm")
bad = []
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
for j, w in zip(jrow, wrow):
w *= wsc
if w <= 0.001:
continue
n = jname[j]
nl = n.lower()
if not any(t in nl for t in FING):
continue
if (nl.endswith("_r") and p[0] > 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)}")
+53
View File
@@ -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')
+77
View File
@@ -0,0 +1,77 @@
"""Edge-stretch comparison across OBJ dirs, with a rest-length split.
usage: stretch_cmp.py <rest_dir> <label>=<dir> [<label>=<dir> ...]
Rest OBJs (rest_hand_l/r.obj) come from <rest_dir>; every compared dir must share the
body's vertex order. Reports, per pose and hand: max / p99.9 / >2x / >5x over ALL edges,
then the subset that is real geometry (>=1mm rest length) and the subset that is
VISIBLE (posed length >=1cm) — the count that decides whether a render shows a needle.
"""
import math
import os
import sys
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(t.split("/")[0]) - 1 for t 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(sum((p[i] - q[i]) ** 2 for i in range(3)))
rest_dir = sys.argv[1]
cols = [a.split("=", 1) for a in sys.argv[2:]]
for hand in ("l", "r"):
rest_v, rest_f = load_obj(os.path.join(rest_dir, f"rest_hand_{hand}.obj"))
edges = edge_set(rest_f)
rest_len = [dist(rest_v[a], rest_v[b]) for a, b in edges]
print(f"\n=== hand_{hand} ({len(rest_v)} verts, {len(edges)} edges) ===")
print(f"{'pose / build':22s} {'max':>8s} {'p99.9':>7s} {'>2x':>6s} {'>5x':>6s}"
f" {'>5x real':>9s} {'>=1cm':>7s}")
for pose in ("flat", "fist", "grip"):
for label, d in cols:
f = os.path.join(d, f"{pose}_hand_{hand}.obj")
if not os.path.exists(f):
continue
v, _ = load_obj(f)
if len(v) != len(rest_v):
print(f"{pose+' '+label:22s} VERT COUNT MISMATCH {len(v)} vs {len(rest_v)}")
continue
rs = []
gt5 = gt2 = real5 = vis = 0
for (a, b), rl in zip(edges, rest_len):
if rl <= 1e-9:
continue
lq = dist(v[a], v[b])
r = lq / rl
rs.append(r)
if r > 2:
gt2 += 1
if r > 5:
gt5 += 1
if rl >= 0.001:
real5 += 1
if lq >= 0.01:
vis += 1
rs.sort()
print(f"{pose+' '+label:22s} {rs[-1]:7.1f}x {rs[int(len(rs)*0.999)]:6.2f}x"
f" {gt2:6d} {gt5:6d} {real5:9d} {vis:7d}")
+109
View File
@@ -0,0 +1,109 @@
"""Classify torn edges (posed stretch >5x) by the dominant joint of each endpoint.
usage: fin_bones.py posed.glb"""
import json, struct, sys, math
from pathlib import Path
from collections import Counter
def read_glb(path):
d = Path(path).read_bytes()
length = struct.unpack_from("<I", d, 8)[0]
off = 12; g = None; b = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off); off += 8
if ct == 0x4E4F534A: g = json.loads(d[off:off+clen])
else: b = d[off:off+clen]
off += clen
return g, b
def acc(g, b, i):
a = g["accessors"][i]; bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR":1,"VEC2":2,"VEC3":3,"VEC4":4,"MAT4":16}[a["type"]]
fmt = {5121:"B",5123:"H",5125:"I",5126:"f"}[a["componentType"]]
size = struct.calcsize(fmt)*nc; stride = bv.get("byteStride") or size
off = bv.get("byteOffset",0)+a.get("byteOffset",0)
return [struct.unpack_from("<%d%s"%(nc,fmt), b, off+i*stride) for i in range(a["count"])], a["componentType"]
def quat_mat(q):
x,y,z,w = q
return [[1-2*(y*y+z*z),2*(x*y-z*w),2*(x*z+y*w)],
[2*(x*y+z*w),1-2*(x*x+z*z),2*(y*z-x*w)],
[2*(x*z-y*w),2*(y*z+x*w),1-2*(x*x+y*y)]]
def node_local(nd):
t = nd.get("translation",[0,0,0]); r = nd.get("rotation",[0,0,0,1]); s = nd.get("scale",[1,1,1])
R = quat_mat(r)
M = [[R[i][j]*s[j] for j in range(3)]+[t[i]] for i in range(3)]
return M+[[0,0,0,1]]
def matmul(A,B):
return [[sum(A[i][k]*B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
g, b = read_glb(sys.argv[1])
names = [nd.get("name","") for nd in g["nodes"]]
loc = [node_local(nd) for nd in g["nodes"]]
parent = {}
for i,nd in enumerate(g["nodes"]):
for c in nd.get("children",[]): parent[c] = i
memo = {}
def gm(i):
if i in memo: return memo[i]
m = loc[i] if i not in parent else matmul(gm(parent[i]), loc[i])
memo[i] = m; return m
G = [gm(i) for i in range(len(g["nodes"]))]
skin = g["skins"][0]; joints = skin["joints"]
ibm,_ = acc(g,b,skin["inverseBindMatrices"])
def m16(row): return [[row[c*4+r] for c in range(4)] for r in range(4)]
JM = [matmul(G[joints[j]], m16(ibm[j])) for j in range(len(joints))]
prim = g["meshes"][0]["primitives"][0]
P,_ = acc(g,b,prim["attributes"]["POSITION"])
J,_ = acc(g,b,prim["attributes"]["JOINTS_0"])
W,wt = acc(g,b,prim["attributes"]["WEIGHTS_0"])
wsc = 1.0 if wt==5126 else (1/255 if wt==5121 else 1/65535)
IDX,_ = acc(g,b,prim["indices"])
idx = [i[0] for i in IDX]
def skin_pos(vi):
p = P[vi]; x=y=z=0.0
for j,w in zip(J[vi],W[vi]):
w*=wsc
if w<=0: continue
M=JM[j]
x+=w*(M[0][0]*p[0]+M[0][1]*p[1]+M[0][2]*p[2]+M[0][3])
y+=w*(M[1][0]*p[0]+M[1][1]*p[1]+M[1][2]*p[2]+M[1][3])
z+=w*(M[2][0]*p[0]+M[2][1]*p[1]+M[2][2]*p[2]+M[2][3])
return (x,y,z)
def dom(vi):
best, bw = None, 0
for j,w in zip(J[vi],W[vi]):
w*=wsc
if w>bw: bw, best = w, j
return names[joints[best]] if best is not None else "?"
edges = set()
for t in range(0, len(idx), 3):
a_,b_,c_ = idx[t], idx[t+1], idx[t+2]
for u,v in ((a_,b_),(b_,c_),(c_,a_)):
edges.add((u,v) if u<v else (v,u))
pos_cache = {}
def sp(vi):
if vi not in pos_cache: pos_cache[vi] = skin_pos(vi)
return pos_cache[vi]
pairs = Counter(); n_bad = 0; maxr = 0
for u,v in edges:
rl = math.dist(P[u], P[v])
if rl <= 1e-9: continue
# cheap prefilter: only edges where an endpoint is finger/hand weighted
dn_u, dn_v = dom(u), dom(v)
lu, lv = dn_u.lower(), dn_v.lower()
keys = ("thumb","index","middle","ring","pinky","hand","lower_arm","wrist")
if not any(k in lu or k in lv for k in keys): continue
r = math.dist(sp(u), sp(v)) / rl
if r > 5:
n_bad += 1; maxr = max(maxr, r)
pairs[tuple(sorted((dn_u, dn_v)))] += 1
print(f"edges>5x: {n_bad} max stretch {maxr:.0f}x")
for (a_,b_), n in pairs.most_common(20):
print(f" {n:5d} {a_} <-> {b_}")
+84
View File
@@ -0,0 +1,84 @@
"""For each finger bone, report how much geometry it actually OWNS (verts where it is the
dominant influence) and where that geometry sits. On a two-finger hand rig the five-finger
bone set is present but several chains own nothing, or several chains share one fused mass.
usage: hand_bone_ownership.py body.glb [side l|r]
"""
import json, struct, sys, math
from pathlib import Path
from collections import defaultdict
FING = ("thumb", "index", "middle", "ring", "pinky")
side = sys.argv[2] if len(sys.argv) > 2 else None
def read_glb(p):
d = Path(p).read_bytes()
length = struct.unpack_from("<I", d, 8)[0]
off = 12
g = b_ = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off)
off += 8
if ct == 0x4E4F534A:
g = json.loads(d[off:off + clen])
else:
b_ = d[off:off + clen]
off += clen
return g, b_
def acc(g, b, i):
a = g["accessors"][i]
bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[a["type"]]
fmt = {5121: "B", 5123: "H", 5125: "I", 5126: "f"}[a["componentType"]]
size = struct.calcsize(fmt) * nc
stride = bv.get("byteStride") or size
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
return [struct.unpack_from("<%d%s" % (nc, fmt), b, off + k * stride)
for k in range(a["count"])], a["componentType"]
g, b = read_glb(sys.argv[1])
names = [nd.get("name", "") for nd in g["nodes"]]
prim = g["meshes"][0]["primitives"][0]
att = prim["attributes"]
skin = next(nd["skin"] for nd in g["nodes"] if nd.get("mesh") == 0 and "skin" in nd)
joints = g["skins"][skin]["joints"]
jname = [names[j] for j in joints]
P, _ = acc(g, b, att["POSITION"])
J, _ = acc(g, b, att["JOINTS_0"])
W, wt = acc(g, b, att["WEIGHTS_0"])
wsc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
own = defaultdict(list)
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
best = (0.0, None)
for j, w in zip(jrow, wrow):
w *= wsc
if w > best[0]:
best = (w, jname[j])
if best[1] and any(t in best[1].lower() for t in FING):
if side and not best[1].lower().endswith("_" + side):
continue
own[best[1]].append(p)
print(f"{Path(sys.argv[1]).name} dominant-owner geometry per finger bone"
f"{' (side ' + side + ')' if side else ''}\n")
print(f" {'bone':20s} {'verts':>7s} {'z-centre':>9s} {'z-span':>8s} {'x-centre':>9s}")
for fam in FING:
rows = [(n, v) for n, v in own.items() if fam in n.lower()]
if not rows:
print(f" {fam:20s} {'0':>7s} -- owns no geometry --")
continue
for n in sorted(r[0] for r in rows):
ps = own[n]
zc = sum(p[2] for p in ps) / len(ps) * 100
zs = (max(p[2] for p in ps) - min(p[2] for p in ps)) * 100
xc = sum(p[0] for p in ps) / len(ps) * 100
print(f" {n:20s} {len(ps):7d} {zc:8.1f}cm {zs:7.1f}cm {xc:8.1f}cm")
print()
tot = sum(len(v) for v in own.values())
print(f" total finger-owned verts: {tot}")
+61
View File
@@ -0,0 +1,61 @@
"""Bake a hand-pose JSON into a body GLB by rewriting finger node rest rotations.
IBMs untouched -> mesh deforms to the pose. usage: bake_preview.py body.glb pose.json out.glb"""
import json, struct, sys
from pathlib import Path
body, posef, out = sys.argv[1:4]
data = Path(body).read_bytes()
length = struct.unpack_from("<I", data, 8)[0]
off = 12
chunks = []
gltf = None
while off < length:
clen, ctype = struct.unpack_from("<II", data, off)
off += 8
if ctype == 0x4E4F534A:
gltf = json.loads(data[off:off+clen].decode("utf-8"))
chunks.append([ctype, None])
else:
chunks.append([ctype, data[off:off+clen]])
off += clen
pose = json.loads(Path(posef).read_text())["bones"]
byname = {nd.get("name"): nd for nd in gltf["nodes"]}
canon = None
if len(sys.argv) > 4: # canonical-rest GLB: apply pose as rest-relative delta
cdata = Path(sys.argv[4]).read_bytes()
clen2 = struct.unpack_from("<I", cdata, 12)[0]
cg = json.loads(cdata[20:20+clen2].decode("utf-8"))
canon = {nd.get("name"): nd.get("rotation", [0, 0, 0, 1]) for nd in cg["nodes"]}
def qmul(a, b):
ax, ay, az, aw = a; bx, by, bz, bw = b
return [aw*bx + ax*bw + ay*bz - az*by,
aw*by - ax*bz + ay*bw + az*bx,
aw*bz + ax*by - ay*bx + az*bw,
aw*bw - ax*bx - ay*by - az*bz]
n = 0
for bone, quat in pose.items():
if bone in byname:
if canon is not None:
cr = canon.get(bone, [0, 0, 0, 1])
delta = qmul([-cr[0], -cr[1], -cr[2], cr[3]], quat) # canon_rest^-1 * pose
body_rest = byname[bone].get("rotation", [0, 0, 0, 1])
quat = qmul(body_rest, delta)
byname[bone]["rotation"] = quat
n += 1
# strip animations so nothing overrides the pose
gltf.pop("animations", None)
js = json.dumps(gltf, separators=(",", ":")).encode("utf-8")
js += b" " * ((4 - len(js) % 4) % 4)
body_out = b""
for ctype, payload in chunks:
if ctype == 0x4E4F534A:
payload = js
body_out += struct.pack("<II", len(payload), ctype) + payload
hdr = struct.pack("<III", 0x46546C67, 2, 12 + len(body_out))
Path(out).write_bytes(hdr + body_out)
print(f"baked {n}/{len(pose)} bones -> {out}")
+71
View File
@@ -0,0 +1,71 @@
"""Strip axial roll (twist about the bone axis) from a hand-pose JSON, keeping the curl.
The pose delta vs the body's rest is swing-twist decomposed per bone; the twist factor is
dropped and the pose rebuilt as rest*swing. Thumb chains are left untouched (their roll is
functional opposition). usage: handpose_detwist.py body.glb pose_in.json pose_out.json"""
import json, math, struct, sys
from pathlib import Path
body, pose_in, pose_out = sys.argv[1:4]
data = Path(body).read_bytes()
jlen = struct.unpack_from("<I", data, 12)[0]
gltf = json.loads(data[20:20 + jlen].decode("utf-8"))
nodes = gltf["nodes"]
byname = {n.get("name"): i for i, n in enumerate(nodes)}
def qmul(a, b):
ax, ay, az, aw = a
bx, by, bz, bw = b
return [aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz]
def qinv(q):
return [-q[0], -q[1], -q[2], q[3]]
def qnorm(q):
m = math.sqrt(sum(v * v for v in q))
return [v / m for v in q]
def bone_axis(i):
for c in nodes[i].get("children", []):
t = nodes[c].get("translation")
if t:
m = math.sqrt(sum(v * v for v in t))
if m > 1e-8:
return [v / m for v in t]
return None
pose = json.loads(Path(pose_in).read_text())["bones"]
out = {}
report = []
for name, p in pose.items():
i = byname.get(name)
if i is None or name.startswith("thumb"):
out[name] = p
continue
a = bone_axis(i)
if a is None: # leaf tips: twist is invisible, keep as-is
out[name] = p
continue
r = nodes[i].get("rotation", [0, 0, 0, 1])
d = qmul(qinv(r), p) # delta in the bone's rest-local frame
dot = d[0] * a[0] + d[1] * a[1] + d[2] * a[2]
twist = qnorm([dot * a[0], dot * a[1], dot * a[2], d[3]])
swing = qmul(d, qinv(twist))
out[name] = [round(v, 6) for v in qnorm(qmul(r, swing))]
deg = 2 * math.degrees(math.atan2(abs(dot), abs(d[3])))
if deg > 1.0:
report.append((deg, name))
Path(pose_out).write_text(json.dumps({"bones": out}, indent=1))
report.sort(reverse=True)
print("wrote %s (%d bones, thumbs untouched)" % (pose_out, len(out)))
for deg, name in report[:6]:
print(" stripped %5.1f deg %s" % (deg, name))
+99
View File
@@ -0,0 +1,99 @@
"""Extract a hand pose (40 finger-bone quaternions) from a GLB clip at a chosen frame,
report per-bone curl (deviation from skeleton rest), optionally dump JSON.
usage: python extract_pose.py <glb> <animName> [--frame N | --max-curl] [--dump out.json]
python extract_pose.py <glb> --rest --dump out.json (rest pose itself)
"""
import json, struct, sys, math
from pathlib import Path
FINGER_TOKENS = ("thumb", "index", "middle", "ring", "pinky")
def read_glb(path):
data = Path(path).read_bytes()
magic, ver, length = struct.unpack_from("<III", data, 0)
off = 12
gltf = None; binc = None
while off < length:
clen, ctype = struct.unpack_from("<II", data, off)
off += 8
chunk = data[off:off+clen]
if ctype == 0x4E4F534A: gltf = json.loads(chunk.decode("utf-8"))
elif ctype == 0x004E4942: binc = chunk
off += clen
return gltf, binc
def acc_data(gltf, binc, idx):
acc = gltf["accessors"][idx]
bv = gltf["bufferViews"][acc["bufferView"]]
n = {"SCALAR":1, "VEC3":3, "VEC4":4}[acc["type"]]
off = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
vals = struct.unpack_from("<%d%s" % (acc["count"]*n, "f"), binc, off)
return [vals[i*n:(i+1)*n] for i in range(acc["count"])]
def qangle(a, b):
d = min(1.0, abs(sum(x*y for x, y in zip(a, b))))
return 2*math.degrees(math.acos(d))
def main():
glb = sys.argv[1]
gltf, binc = read_glb(glb)
nodes = gltf["nodes"]
names = [nd.get("name", f"n{i}") for i, nd in enumerate(nodes)]
finger_idx = {i: names[i] for i, nd in enumerate(nodes)
if any(t in names[i].lower() for t in FINGER_TOKENS)}
rest = {i: tuple(nodes[i].get("rotation", [0, 0, 0, 1])) for i in finger_idx}
dump = None
if "--dump" in sys.argv:
dump = sys.argv[sys.argv.index("--dump")+1]
if "--rest" in sys.argv:
pose = {names[i]: list(rest[i]) for i in finger_idx}
label = "REST"
else:
aname = sys.argv[2]
anim = next(a for a in gltf["animations"] if a.get("name") == aname)
# collect finger rotation samplers
tracks = {}
times_ref = None
for ch in anim["channels"]:
t = ch["target"]
if t.get("path") != "rotation" or t["node"] not in finger_idx: continue
samp = anim["samplers"][ch["sampler"]]
quats = acc_data(gltf, binc, samp["output"])
tracks[t["node"]] = quats
times_ref = acc_data(gltf, binc, samp["input"])
nframes = min(len(q) for q in tracks.values())
if "--max-curl" in sys.argv:
best, bestf = -1, 0
for f in range(nframes):
curl = sum(qangle(tracks[i][f], rest[i]) for i in tracks)
if curl > best: best, bestf = curl, f
frame = bestf
elif "--frame" in sys.argv:
frame = int(sys.argv[sys.argv.index("--frame")+1])
else:
frame = 0
t = times_ref[min(frame, len(times_ref)-1)][0] if times_ref else 0
pose = {names[i]: list(tracks[i][frame]) for i in tracks}
# fill missing finger bones from rest
for i in finger_idx:
pose.setdefault(names[i], list(rest[i]))
label = f"{aname} frame {frame} (t={t:.2f}s)"
# curl report per finger chain (sum of deviations from rest), L hand only for brevity
print(f"pose: {label} ({len(pose)} bones)")
for hand in ("_l", "_r"):
parts = []
for fing in ("thumb", "index", "middle", "ring", "pinky"):
tot = sum(qangle(pose[n], rest[i]) for i, n in finger_idx.items()
if n.startswith(fing) and n.endswith(hand))
parts.append(f"{fing} {tot:.0f}")
print(f" {hand}: curl-vs-rest deg " + " ".join(parts))
if dump:
Path(dump).write_text(json.dumps({"source": f"{Path(glb).name}:{label}",
"bones": pose}, indent=1))
print("dumped ->", dump)
main()
+59
View File
@@ -0,0 +1,59 @@
"""Clay-render each OBJ in a directory, 3 angles, framed on its bbox.
Only files matching *_hand_*.obj are picked up, and outdir MUST be absolute — a relative
one makes Blender write outside the tree and silently produce nothing.
usage: blender --background --factory-startup --python render_objs.py -- objdir outdir [res] [dist]
res square render resolution in px (default 900)
dist camera distance as a multiple of the mesh radius (default 2.6; lower = tighter)"""
import bpy, sys, math, glob, os
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
objdir, outdir = argv[0], argv[1]
RES = int(argv[2]) if len(argv) > 2 else 900
DIST = float(argv[3]) if len(argv) > 3 else 2.6
bpy.ops.wm.read_factory_settings(use_empty=True)
scn = bpy.context.scene
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = RES
mat = bpy.data.materials.new("Clay")
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (0.72, 0.55, 0.45, 1.0)
bsdf.inputs["Roughness"].default_value = 0.65
for rot, energy in (((50, 0, 30), 3.0), ((-40, 0, -140), 1.2), ((10, 0, 180), 0.8)):
sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", 'SUN'))
sun.data.energy = energy
sun.rotation_euler = tuple(math.radians(a) for a in rot)
scn.collection.objects.link(sun)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 60
scn.collection.objects.link(cam)
scn.camera = cam
for path in sorted(glob.glob(os.path.join(objdir, "*_hand_*.obj"))):
bpy.ops.wm.obj_import(filepath=path)
obj = bpy.context.selected_objects[0]
obj.data.materials.clear()
obj.data.materials.append(mat)
for p in obj.data.polygons: p.use_smooth = True
bb = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
ctr = sum(bb, Vector()) / 8
rad = max((v - ctr).length for v in bb)
tag = os.path.splitext(os.path.basename(path))[0]
# OBJ import is -Z forward +Y up by default: gltf Y-up mesh arrives Z-up in Blender
for label, direction in (("palm", Vector((0, -1, -0.25))),
("back", Vector((0, 1, 0.35))),
("side", Vector((-1, -0.3, 0.1)))):
d = direction.normalized()
cam.location = ctr - d * (rad * DIST)
cam.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
scn.render.filepath = os.path.join(outdir, f"{tag}_{label}.png")
bpy.ops.render.render(write_still=True)
print("[objr] wrote", scn.render.filepath)
bpy.data.objects.remove(obj, do_unlink=True)
print("[objr] DONE")
+82
View File
@@ -0,0 +1,82 @@
"""Scan GLBs: list finger joints and which animations have live (non-frozen) finger rotation tracks."""
import json, struct, sys, math
from pathlib import Path
FINGER_TOKENS = ("thumb", "index", "middle", "ring", "pinky", "finger")
def read_glb(path):
data = Path(path).read_bytes()
magic, ver, length = struct.unpack_from("<III", data, 0)
assert magic == 0x46546C67, "not glb"
off = 12
gltf = None
bin_chunk = None
while off < length:
clen, ctype = struct.unpack_from("<II", data, off)
off += 8
chunk = data[off:off+clen]
if ctype == 0x4E4F534A:
gltf = json.loads(chunk.decode("utf-8"))
elif ctype == 0x004E4942:
bin_chunk = chunk
off += clen
return gltf, bin_chunk
def accessor_data(gltf, binc, idx):
acc = gltf["accessors"][idx]
bv = gltf["bufferViews"][acc["bufferView"]]
comp = {5126: ("f", 4)}[acc["componentType"]]
n = {"SCALAR":1, "VEC3":3, "VEC4":4}[acc["type"]]
off = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
count = acc["count"]
vals = struct.unpack_from("<%d%s" % (count*n, comp[0]), binc, off)
return [vals[i*n:(i+1)*n] for i in range(count)]
def scan(path, verbose_joints=False):
gltf, binc = read_glb(path)
nodes = gltf.get("nodes", [])
names = [nd.get("name", f"node{i}") for i, nd in enumerate(nodes)]
# joints from skins
joint_set = set()
for skin in gltf.get("skins", []):
joint_set.update(skin.get("joints", []))
fingers = sorted(n for i in joint_set for n in [names[i]] if any(t in n.lower() for t in FINGER_TOKENS))
print(f"\n== {Path(path).name} ==")
print(f"joints: {len(joint_set)}, finger joints: {len(fingers)}")
if verbose_joints:
for n in sorted(names[i] for i in joint_set):
print(" ", n)
elif fingers:
print(" finger joints:", ", ".join(fingers))
for anim in gltf.get("animations", []):
aname = anim.get("name", "?")
live, frozen = [], []
for ch in anim.get("channels", []):
tgt = ch["target"]
if tgt.get("path") != "rotation":
continue
nname = names[tgt["node"]]
if not any(t in nname.lower() for t in FINGER_TOKENS):
continue
samp = anim["samplers"][ch["sampler"]]
quats = accessor_data(gltf, binc, samp["output"])
# measure max angular deviation from first frame
q0 = quats[0]
maxdot = 1.0
for q in quats[1:]:
d = abs(sum(a*b for a, b in zip(q0, q)))
maxdot = min(maxdot, min(d, 1.0))
ang = 2*math.degrees(math.acos(maxdot))
(live if ang > 2.0 else frozen).append((nname, ang))
total = len(live) + len(frozen)
if total:
print(f" anim '{aname}': {total} finger rot tracks, {len(live)} live (>2deg), {len(frozen)} frozen")
if live:
top = sorted(live, key=lambda x: -x[1])[:4]
print(" top movers:", ", ".join(f"{n} {a:.0f}deg" for n, a in top))
else:
print(f" anim '{aname}': NO finger tracks")
if __name__ == "__main__":
for p in sys.argv[1:]:
scan(p, verbose_joints="--joints" in sys.argv)
+98
View File
@@ -0,0 +1,98 @@
"""Skin a baked-pose GLB's hand region with Godot-exact LBS and write it as a plain OBJ.
usage: godot_skin_hand_obj.py posed.glb side(l|r) out.obj"""
import json, struct, sys
def read_glb(path):
d = open(path, "rb").read()
length = struct.unpack_from("<I", d, 8)[0]
off = 12; g = None; b = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off); off += 8
if ct == 0x4E4F534A: g = json.loads(d[off:off+clen])
else: b = d[off:off+clen]
off += clen
return g, b
def acc(g, b, i):
a = g["accessors"][i]; bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR":1,"VEC2":2,"VEC3":3,"VEC4":4,"MAT4":16}[a["type"]]
fmt = {5121:"B",5123:"H",5125:"I",5126:"f"}[a["componentType"]]
size = struct.calcsize(fmt)*nc; stride = bv.get("byteStride") or size
off = bv.get("byteOffset",0)+a.get("byteOffset",0)
return [struct.unpack_from("<%d%s"%(nc,fmt), b, off+k*stride) for k in range(a["count"])], a["componentType"]
def quat_mat(q):
x,y,z,w = q
return [[1-2*(y*y+z*z),2*(x*y-z*w),2*(x*z+y*w)],
[2*(x*y+z*w),1-2*(x*x+z*z),2*(y*z-x*w)],
[2*(x*z-y*w),2*(y*z+x*w),1-2*(x*x+y*y)]]
def node_local(nd):
t = nd.get("translation",[0,0,0]); r = nd.get("rotation",[0,0,0,1]); s = nd.get("scale",[1,1,1])
R = quat_mat(r)
M = [[R[i][j]*s[j] for j in range(3)]+[t[i]] for i in range(3)]
return M+[[0,0,0,1]]
def matmul(A,B):
return [[sum(A[i][k]*B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
glb, side, out = sys.argv[1:4]
g, b = read_glb(glb)
names = [nd.get("name","") for nd in g["nodes"]]
loc = [node_local(nd) for nd in g["nodes"]]
parent = {}
for i,nd in enumerate(g["nodes"]):
for c in nd.get("children",[]): parent[c] = i
memo = {}
def gm(i):
if i in memo: return memo[i]
m = loc[i] if i not in parent else matmul(gm(parent[i]), loc[i])
memo[i] = m; return m
G = [gm(i) for i in range(len(g["nodes"]))]
skin = g["skins"][0]; joints = skin["joints"]
ibm,_ = acc(g,b,skin["inverseBindMatrices"])
def m16(row):
return [[row[c*4+r] for c in range(4)] for r in range(4)]
JM = [matmul(G[joints[j]], m16(ibm[j])) for j in range(len(joints))]
REGION = ("hand_", "thumb_", "index_", "middle_", "ring_", "pinky_", "lowerarm_")
region_j = {j for j in range(len(joints))
if any(names[joints[j]].startswith(p) for p in REGION)
and names[joints[j]].endswith("_"+side)}
prim = g["meshes"][0]["primitives"][0]
P,_ = acc(g,b,prim["attributes"]["POSITION"])
J,_ = acc(g,b,prim["attributes"]["JOINTS_0"])
W,wt = acc(g,b,prim["attributes"]["WEIGHTS_0"])
I,_ = acc(g,b,prim["indices"])
wsc = 1.0 if wt==5126 else (1/255 if wt==5121 else 1/65535)
keep = {}
for vi,(p,jr,wr) in enumerate(zip(P,J,W)):
if not any(j in region_j and w>0 for j,w in zip(jr,wr)): continue
x=y=z=0.0
for j,w in zip(jr,wr):
w*=wsc
if w<=0: continue
M=JM[j]
x+=w*(M[0][0]*p[0]+M[0][1]*p[1]+M[0][2]*p[2]+M[0][3])
y+=w*(M[1][0]*p[0]+M[1][1]*p[1]+M[1][2]*p[2]+M[1][3])
z+=w*(M[2][0]*p[0]+M[2][1]*p[1]+M[2][2]*p[2]+M[2][3])
keep[vi]=(x,y,z)
remap = {vi:k+1 for k,vi in enumerate(keep)}
tris = []
flat = [ix[0] for ix in I]
for t in range(0, len(flat), 3):
a1,a2,a3 = flat[t], flat[t+1], flat[t+2]
if a1 in remap and a2 in remap and a3 in remap:
tris.append((remap[a1], remap[a2], remap[a3]))
with open(out, "w") as f:
for vi in keep:
x,y,z = keep[vi]
f.write(f"v {x:.6f} {y:.6f} {z:.6f}\n")
for t in tris:
f.write(f"f {t[0]} {t[1]} {t[2]}\n")
print(f"[skinobj] {out}: {len(keep)} verts, {len(tris)} tris")
+29
View File
@@ -0,0 +1,29 @@
"""Trim a skin_to_obj dump to the HAND only, so a bbox-framing renderer actually frames
the hand. The dumps span the whole arm (76cm in x); the hand is the outer ~13cm.
usage: trim_hand.py <in.obj> <out.obj> <l|r>"""
import sys
src, dst, side = sys.argv[1], sys.argv[2], sys.argv[3]
verts, faces = [], []
for line in open(src):
if line.startswith("v "):
verts.append([float(x) for x in line.split()[1:4]])
elif line.startswith("f "):
faces.append([int(t.split("/")[0]) - 1 for t in line.split()[1:]])
xs = [v[0] for v in verts]
# hand sits at the far end in |x|; keep the outer 15cm of the limb
cut = (max(xs) - 0.15) if side == "l" else (min(xs) + 0.15)
keep = [(v[0] >= cut) if side == "l" else (v[0] <= cut) for v in verts]
remap, out_v = {}, []
for i, v in enumerate(verts):
if keep[i]:
remap[i] = len(out_v)
out_v.append(v)
out_f = [f for f in faces if all(i in remap for i in f)]
with open(dst, "w") as f:
for v in out_v:
f.write(f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n")
for fc in out_f:
f.write("f " + " ".join(str(remap[i] + 1) for i in fc) + "\n")
print(f"[trim] {dst}: {len(out_v)}/{len(verts)} verts, {len(out_f)} faces (cut x={cut:.3f})")
+41
View File
@@ -0,0 +1,41 @@
"""Regenerate hand-poses/canonical_rest.json — the canonical Quaternius finger-bone REST
rotations, taken from the Kevin pack the hand poses were harvested against.
The runtime needs this to apply a pose REST-RELATIVE on rigs whose finger rest differs from
canonical (Mako's *_01 knuckles sit 11.5 deg off, so applying a canonical pose directly
rotates his knuckles away from his own rest and tears the palm/wrist boundary):
delta = canonical_rest^-1 * pose
target = body_rest * delta
usage: make_canonical_rest.py [kevin.glb] [out.json]
"""
import json, struct, sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
GAME = REPO.parent / "ariki-game"
src = Path(sys.argv[1]) if len(sys.argv) > 1 else \
GAME / "assets/quaternius/kevin/kevin_female_combat.glb"
out = Path(sys.argv[2]) if len(sys.argv) > 2 else REPO / "hand-poses/canonical_rest.json"
d = src.read_bytes()
jl = struct.unpack_from("<I", d, 12)[0]
g = json.loads(d[20:20 + jl].decode("utf-8"))
pose_bones = list(json.loads((REPO / "hand-poses/pose_flat.json").read_text())["bones"])
rest = {nd.get("name"): nd.get("rotation", [0, 0, 0, 1]) for nd in g["nodes"]}
missing = [b for b in pose_bones if b not in rest]
if missing:
raise SystemExit(f"canonical source lacks pose bones: {missing}")
payload = {
"_comment": ("Canonical Quaternius finger-bone REST rotations. The runtime applies a "
"pose rest-relative: delta = canonical_rest^-1 * pose, "
"target = body_rest * delta. Regenerate with tools/make_canonical_rest.py."),
"source": src.name,
"bones": {b: [round(v, 8) for v in rest[b]] for b in pose_bones},
}
out.write_text(json.dumps(payload, indent=1))
print(f"wrote {out} with {len(payload['bones'])} bones from {src.name}")
+50
View File
@@ -0,0 +1,50 @@
"""Crop an OBJ to the faces fully inside a sphere, so clay renders can frame the hand
instead of the whole arm. Keeps vertex order stable across files (same face set in/out)
only when the inputs share topology, so pass --like to reuse a reference file's face mask.
usage: obj_crop.py in.obj out.obj CX CY CZ R (metres)
obj_crop.py in.obj out.obj --mask mask.txt
obj_crop.py in.obj --write-mask mask.txt CX CY CZ R
"""
import sys, math
from pathlib import Path
def load(path):
vs, faces = [], []
for line in Path(path).read_text().splitlines():
if line.startswith("v "):
p = line.split()
vs.append((float(p[1]), float(p[2]), float(p[3])))
elif line.startswith("f "):
faces.append([int(t.split("/")[0]) - 1 for t in line.split()[1:]])
return vs, faces
args = sys.argv[1:]
src = args[0]
vs, faces = load(src)
if "--write-mask" in args:
maskfile = args[args.index("--write-mask") + 1]
cx, cy, cz, r = (float(x) for x in args[-4:])
keep = [i for i, f in enumerate(faces)
if all(math.dist(vs[k], (cx, cy, cz)) <= r for k in f)]
Path(maskfile).write_text("\n".join(map(str, keep)))
print(f"mask {len(keep)}/{len(faces)} faces -> {maskfile}")
sys.exit()
dst = args[1]
if "--mask" in args:
keep = [int(x) for x in Path(args[args.index("--mask") + 1]).read_text().split()]
else:
cx, cy, cz, r = (float(x) for x in args[-4:])
keep = [i for i, f in enumerate(faces)
if all(math.dist(vs[k], (cx, cy, cz)) <= r for k in f)]
used = sorted({k for i in keep for k in faces[i]})
remap = {old: n + 1 for n, old in enumerate(used)}
out = [f"v {vs[o][0]:.6f} {vs[o][1]:.6f} {vs[o][2]:.6f}" for o in used]
out += ["f " + " ".join(str(remap[k]) for k in faces[i]) for i in keep]
Path(dst).write_text("\n".join(out) + "\n")
print(f"{Path(dst).name}: {len(used)} verts, {len(keep)} faces")
+58
View File
@@ -0,0 +1,58 @@
"""Count connected components in a slab of an OBJ, to tell separated digits from a fused
mitt. Slice just past the knuckles: 4 components = separate fingers, 1 = fused paddle.
usage: obj_slice_components.py mesh.obj AXIS LO HI (axis x|y|z, bounds in metres)
"""
import sys
from pathlib import Path
from collections import defaultdict, deque
path, axis, lo, hi = sys.argv[1], sys.argv[2], float(sys.argv[3]), float(sys.argv[4])
ai = {"x": 0, "y": 1, "z": 2}[axis]
vs, faces = [], []
for line in Path(path).read_text().splitlines():
if line.startswith("v "):
p = line.split()
vs.append((float(p[1]), float(p[2]), float(p[3])))
elif line.startswith("f "):
faces.append([int(t.split("/")[0]) - 1 for t in line.split()[1:]])
inslab = [i for i, v in enumerate(vs) if lo <= v[ai] <= hi]
sel = set(inslab)
adj = defaultdict(set)
kept = 0
for f in faces:
if all(k in sel for k in f):
kept += 1
for a in f:
for b in f:
if a != b:
adj[a].add(b)
seen = set()
comps = []
for v in inslab:
if v in seen:
continue
q = deque([v]); seen.add(v); c = []
while q:
u = q.popleft(); c.append(u)
for w in adj.get(u, ()):
if w not in seen:
seen.add(w); q.append(w)
comps.append(c)
comps.sort(key=len, reverse=True)
print(f"{Path(path).name} slab {axis} in [{lo}, {hi}]")
print(f" verts in slab {len(inslab)}, faces kept {kept}, components {len(comps)}")
for n, c in enumerate(comps[:10]):
if len(c) < 4:
continue
ext = [(min(vs[k][d] for k in c) * 100, max(vs[k][d] for k in c) * 100) for d in range(3)]
span = [f"{e[1]-e[0]:.1f}" for e in ext]
ctr = [f"{(e[0]+e[1])/2:.1f}" for e in ext]
print(f" comp{n}: {len(c):5d} verts span(cm) x{span[0]} y{span[1]} z{span[2]}"
f" centre({ctr[0]}, {ctr[1]}, {ctr[2]})")
big = [c for c in comps if len(c) >= 20]
print(f" components with >=20 verts: {len(big)}")
+56
View File
@@ -0,0 +1,56 @@
"""Compare finger-bone REST rotations between GLB skeletons.
First GLB is the reference; each other is reported as per-bone angle deviation (degrees).
usage: rest_deviation.py canonical.glb other.glb [more.glb ...]
"""
import json, struct, sys, math
from pathlib import Path
FING = ("thumb", "index", "middle", "ring", "pinky")
def read_gltf(path):
d = Path(path).read_bytes()
jlen = struct.unpack_from("<I", d, 12)[0]
return json.loads(d[20:20 + jlen].decode("utf-8"))
def rests(path):
g = read_gltf(path)
out = {}
for nd in g["nodes"]:
n = nd.get("name", "")
if any(t in n.lower() for t in FING):
out[n] = nd.get("rotation", [0, 0, 0, 1])
return out
def angle_between(a, b):
"""Geodesic angle (deg) between two unit quaternions, sign-insensitive."""
d = abs(sum(x * y for x, y in zip(a, b)))
d = max(-1.0, min(1.0, d))
return math.degrees(2 * math.acos(d))
ref_path = sys.argv[1]
ref = rests(ref_path)
print(f"reference: {Path(ref_path).name} ({len(ref)} finger bones)")
for p in sys.argv[2:]:
other = rests(p)
print(f"\n== {Path(p).name} == {len(other)} finger bones")
missing = sorted(set(ref) - set(other))
extra = sorted(set(other) - set(ref))
if missing:
print(f" MISSING vs ref ({len(missing)}): {', '.join(missing)}")
if extra:
print(f" EXTRA vs ref ({len(extra)}): {', '.join(extra)}")
devs = []
for n in sorted(set(ref) & set(other)):
devs.append((angle_between(ref[n], other[n]), n))
devs.sort(reverse=True)
if not devs:
continue
over = [d for d in devs if d[0] > 1.0]
print(f" shared {len(devs)} | deviating >1deg: {len(over)} | max {devs[0][0]:.1f}deg ({devs[0][1]})")
for d, n in devs[:12]:
print(f" {n:24s} {d:6.1f}deg")
+81
View File
@@ -0,0 +1,81 @@
"""Per-finger-bone territory audit: how many verts does each finger bone actually OWN
(dominant weight) and how much total weight mass does it carry?
Why: fin_bones classifies exp05's fist tears as hand<->thumb_02 and hand<->index_02 —
the chain skips the _01 joints. If the _01 bones own no territory, every curl lands as a
hard one-edge step from the palm to phalanx 2, which must stretch. This measures that
directly instead of inferring it. usage: bone_territory.py body.glb
"""
import json
import struct
import sys
from pathlib import Path
DIGITS = ("thumb", "index", "middle", "ring", "pinky")
def read_glb(path):
d = Path(path).read_bytes()
ln = struct.unpack_from("<I", d, 8)[0]
off, g, b = 12, None, None
while off < ln:
clen, ct = struct.unpack_from("<II", d, off)
off += 8
if ct == 0x4E4F534A:
g = json.loads(d[off:off + clen])
else:
b = d[off:off + clen]
off += clen
return g, b
def acc(g, b, i):
a = g["accessors"][i]
bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[a["type"]]
fmt = {5121: "B", 5123: "H", 5125: "I", 5126: "f"}[a["componentType"]]
sz = struct.calcsize(fmt) * nc
stride = bv.get("byteStride") or sz
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
out = []
for k in range(a["count"]):
out.append(struct.unpack_from("<" + fmt * nc, b, off + k * stride))
return out
g, b = read_glb(sys.argv[1])
prim = g["meshes"][0]["primitives"][0]
joints = g["skins"][0]["joints"]
names = [g["nodes"][j].get("name", f"node{j}") for j in joints]
J = acc(g, b, prim["attributes"]["JOINTS_0"])
W = acc(g, b, prim["attributes"]["WEIGHTS_0"])
wt = g["accessors"][prim["attributes"]["WEIGHTS_0"]]["componentType"]
sc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
own = {n: 0 for n in names} # verts whose LARGEST weight is this bone
mass = {n: 0.0 for n in names} # total weight mass
any_w = {n: 0 for n in names} # verts with any weight >1%
for ji, wi in zip(J, W):
ws = [w * sc for w in wi]
best, bw = None, 0.0
for jj, w in zip(ji, ws):
n = names[jj]
mass[n] += w
if w > 0.01:
any_w[n] += 1
if w > bw:
best, bw = n, w
if best is not None and bw > 0:
own[best] += 1
print(f"{Path(sys.argv[1]).name} {len(J)} verts, {len(joints)} joints")
print(f"{'bone':16s} {'owns':>7s} {'any>1%':>8s} {'mass':>9s}")
for side in ("l", "r"):
print(f"--- hand_{side} chain ---")
for nm in [f"hand_{side}"] + [f"{d}_{p}_{side}" for d in DIGITS
for p in ("01", "02", "03")]:
if nm not in own:
print(f"{nm:16s} (absent from skin)")
continue
flag = " <-- STARVED" if own[nm] == 0 else ""
print(f"{nm:16s} {own[nm]:7d} {any_w[nm]:8d} {mass[nm]:9.1f}{flag}")
+272
View File
@@ -0,0 +1,272 @@
"""Repair cross-midline finger skin bindings by INPAINTING from the mesh's own healthy
neighbours.
Mako's shipped rig binds ~444 left-hand verts to RIGHT middle-finger bones (many at weight
1.0, lever arm ~1.8 m), so any middle-finger rotation hurls them across the body. A blunt
_r -> _l mirror remap does NOT fix it: those verts sit 5.8-10.5 cm from the mirrored bone and
their nearest correct joints are thumb/pinky, so remapping would bind thumb skin to the middle
finger and tear. Instead we discard the corrupt influences and refill each vert from its
HEALTHY neighbours on the same mesh (topological BFS first, spatial fallback), which is exactly
what the surrounding 13k correctly-bound left-hand verts already encode.
usage:
skin_crosshand_repair.py in.glb out.glb [--diagnose] [--k 8] [--report]
--diagnose analyse and print only, write nothing
--k N neighbours to blend per repaired vert (default 8)
"""
import json, struct, sys, math
from pathlib import Path
from collections import deque, defaultdict
FING = ("thumb", "index", "middle", "ring", "pinky")
MID = 0.02 # metres either side of x=0 that counts as "across the midline"
def read_glb(p):
d = Path(p).read_bytes()
length = struct.unpack_from("<I", d, 8)[0]
off = 12
chunks = []
g = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off)
off += 8
if ct == 0x4E4F534A:
g = json.loads(d[off:off + clen].decode("utf-8"))
chunks.append([ct, None])
else:
chunks.append([ct, bytearray(d[off:off + clen])])
off += clen
return g, chunks
def write_glb(path, g, chunks):
js = json.dumps(g, separators=(",", ":")).encode("utf-8")
js += b" " * ((4 - len(js) % 4) % 4)
body = b""
for ct, payload in chunks:
if ct == 0x4E4F534A:
payload = js
body += struct.pack("<II", len(payload), ct) + bytes(payload)
Path(path).write_bytes(struct.pack("<III", 0x46546C67, 2, 12 + len(body)) + body)
def acc_info(g, i):
a = g["accessors"][i]
bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[a["type"]]
fmt = {5121: "B", 5123: "H", 5125: "I", 5126: "f"}[a["componentType"]]
size = struct.calcsize(fmt) * nc
stride = bv.get("byteStride") or size
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
return a, bv, nc, fmt, stride, off
def read_acc(g, buf, i):
a, bv, nc, fmt, stride, off = acc_info(g, i)
return [struct.unpack_from("<%d%s" % (nc, fmt), buf, off + k * stride)
for k in range(a["count"])]
def quat_mat(q):
x, y, z, w = q
return [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
def node_local(nd):
t = nd.get("translation", [0, 0, 0])
r = nd.get("rotation", [0, 0, 0, 1])
s = nd.get("scale", [1, 1, 1])
R = quat_mat(r)
return [[R[i][j] * s[j] for j in range(3)] + [t[i]] for i in range(3)] + [[0, 0, 0, 1]]
def matmul(A, B):
return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
def global_mats(g):
loc = [node_local(nd) for nd in g["nodes"]]
parent = {}
for i, nd in enumerate(g["nodes"]):
for c in nd.get("children", []):
parent[c] = i
memo = {}
def gm(i):
if i in memo:
return memo[i]
m = loc[i]
p = parent.get(i)
if p is not None:
m = matmul(gm(p), m)
memo[i] = m
return m
return [gm(i) for i in range(len(g["nodes"]))]
# ---------------------------------------------------------------- main
argv = sys.argv[1:]
src = argv[0]
dst = argv[1] if len(argv) > 1 and not argv[1].startswith("--") else None
DIAG = "--diagnose" in argv
K = int(argv[argv.index("--k") + 1]) if "--k" in argv else 8
g, chunks = read_glb(src)
buf = next(p for ct, p in chunks if ct == 0x004E4942)
names = [nd.get("name", "") for nd in g["nodes"]]
GM = global_mats(g)
total_fixed = 0
for mi, mesh in enumerate(g.get("meshes", [])):
for pi, prim in enumerate(mesh.get("primitives", [])):
att = prim["attributes"]
if "JOINTS_0" not in att:
continue
skin_idx = next((nd.get("skin") for nd in g["nodes"]
if nd.get("mesh") == mi and "skin" in nd), None)
if skin_idx is None:
continue
joints = g["skins"][skin_idx]["joints"]
jname = [names[j] for j in joints]
jpos = [(GM[j][0][3], GM[j][1][3], GM[j][2][3]) for j in joints]
P = read_acc(g, buf, att["POSITION"])
J = [list(r) for r in read_acc(g, buf, att["JOINTS_0"])]
Wr = read_acc(g, buf, att["WEIGHTS_0"])
_, _, _, wfmt, _, _ = acc_info(g, att["WEIGHTS_0"])
wsc = 1.0 if wfmt == "f" else (1 / 255 if wfmt == "B" else 1 / 65535)
W = [[w * wsc for w in r] for r in Wr]
is_fing = [any(t in n.lower() for t in FING) for n in jname]
side = ["l" if n.lower().endswith("_l") else ("r" if n.lower().endswith("_r") else "")
for n in jname]
# ---- classify corrupt refs
corrupt = defaultdict(list) # vert -> [slot,...]
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
for s, (j, w) in enumerate(zip(jrow, wrow)):
if w <= 0.001 or not is_fing[j]:
continue
if (side[j] == "r" and p[0] > MID) or (side[j] == "l" and p[0] < -MID):
corrupt[vi].append(s)
if not corrupt:
print(f" mesh[{mi}] prim{pi}: no cross-midline finger refs — nothing to do")
continue
bad_verts = set(corrupt)
nref = sum(len(v) for v in corrupt.values())
print(f" mesh[{mi}] '{mesh.get('name','')}' prim{pi}: {len(P)} verts")
print(f" corrupt refs {nref} across {len(bad_verts)} verts")
# ---- topology adjacency
adj = defaultdict(set)
if "indices" in prim:
idx = [r[0] for r in read_acc(g, buf, prim["indices"])]
for t in range(0, len(idx) - 2, 3):
a_, b_, c_ = idx[t], idx[t + 1], idx[t + 2]
adj[a_].update((b_, c_))
adj[b_].update((a_, c_))
adj[c_].update((a_, b_))
# healthy = not corrupt AND has some weight
def healthy(v):
return v not in bad_verts and sum(W[v]) > 0.5
# spatial fallback pool: healthy verts near the affected region
cx = sum(P[v][0] for v in bad_verts) / len(bad_verts)
cy = sum(P[v][1] for v in bad_verts) / len(bad_verts)
cz = sum(P[v][2] for v in bad_verts) / len(bad_verts)
pool = [v for v in range(len(P))
if healthy(v) and abs(P[v][0] - cx) < 0.30
and abs(P[v][1] - cy) < 0.30 and abs(P[v][2] - cz) < 0.30]
print(f" healthy donor pool near region: {len(pool)} verts")
topo_used = spatial_used = 0
newJ, newW = {}, {}
for vi in sorted(bad_verts):
# BFS out to healthy neighbours through the mesh
found = []
seen = {vi}
q = deque([(vi, 0)])
while q and len(found) < K:
v, d = q.popleft()
if d > 4:
continue
for nb in adj.get(v, ()):
if nb in seen:
continue
seen.add(nb)
if healthy(nb):
found.append(nb)
if len(found) >= K:
break
q.append((nb, d + 1))
if found:
topo_used += 1
else:
# spatial fallback
ds = sorted(((math.dist(P[vi], P[v]), v) for v in pool))[:K]
found = [v for _, v in ds]
spatial_used += 1
# inverse-distance blend of neighbour weight sets
accw = defaultdict(float)
for nb in found:
d = math.dist(P[vi], P[nb])
wgt = 1.0 / max(d, 1e-4)
for j, w in zip(J[nb], W[nb]):
if w > 0.001:
accw[j] += w * wgt
# keep top 4, renormalise
top = sorted(accw.items(), key=lambda kv: -kv[1])[:4]
tot = sum(w for _, w in top)
if tot <= 0:
continue
nj = [0, 0, 0, 0]
nw = [0.0, 0.0, 0.0, 0.0]
for s, (j, w) in enumerate(top):
nj[s] = j
nw[s] = w / tot
newJ[vi] = nj
newW[vi] = nw
print(f" repaired {len(newJ)} verts (topological {topo_used}, spatial fallback {spatial_used})")
total_fixed += len(newJ)
if DIAG:
# show what the repair decided for a few verts
for vi in sorted(newJ)[:6]:
before = " ".join(f"{jname[j]}={w:.3f}" for j, w in zip(J[vi], W[vi]) if w > 0.001)
after = " ".join(f"{jname[j]}={w:.3f}" for j, w in zip(newJ[vi], newW[vi]) if w > 0.001)
print(f" v{vi}\n before: {before}\n after : {after}")
continue
# ---- write back
aJ, bvJ, ncJ, fmtJ, strideJ, offJ = acc_info(g, att["JOINTS_0"])
aW, bvW, ncW, fmtW, strideW, offW = acc_info(g, att["WEIGHTS_0"])
for vi in newJ:
struct.pack_into("<4%s" % fmtJ, buf, offJ + vi * strideJ, *newJ[vi])
if fmtW == "f":
vals = newW[vi]
elif fmtW == "B":
vals = [max(0, min(255, int(round(w * 255)))) for w in newW[vi]]
vals[0] += 255 - sum(vals)
else:
vals = [max(0, min(65535, int(round(w * 65535)))) for w in newW[vi]]
vals[0] += 65535 - sum(vals)
struct.pack_into("<4%s" % fmtW, buf, offW + vi * strideW, *vals)
if DIAG:
print("\ndiagnose only — nothing written")
elif dst:
write_glb(dst, g, chunks)
print(f"\nwrote {dst} ({total_fixed} verts repaired)")
else:
print("\nno output path given — nothing written")
+97
View File
@@ -0,0 +1,97 @@
"""Pure-python linear-blend skinning check: skin a GLB's verts with its current node TRS
(what Godot would render) and report max displacement of finger-weighted verts vs the
original file. usage: skin_check.py posed.glb original.glb"""
import json, struct, sys, math
from pathlib import Path
FING = ("thumb", "index", "middle", "ring", "pinky")
def read_glb(path):
d = Path(path).read_bytes()
length = struct.unpack_from("<I", d, 8)[0]
off = 12; g = None; b = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off); off += 8
if ct == 0x4E4F534A: g = json.loads(d[off:off+clen])
else: b = d[off:off+clen]
off += clen
return g, b
def acc(g, b, i):
a = g["accessors"][i]; bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR":1,"VEC2":2,"VEC3":3,"VEC4":4,"MAT4":16}[a["type"]]
fmt = {5121:"B",5123:"H",5125:"I",5126:"f"}[a["componentType"]]
size = struct.calcsize(fmt)*nc; stride = bv.get("byteStride") or size
off = bv.get("byteOffset",0)+a.get("byteOffset",0)
return [struct.unpack_from("<%d%s"%(nc,fmt), b, off+i*stride) for i in range(a["count"])], a["componentType"]
def quat_mat(q):
x,y,z,w = q
return [[1-2*(y*y+z*z),2*(x*y-z*w),2*(x*z+y*w)],
[2*(x*y+z*w),1-2*(x*x+z*z),2*(y*z-x*w)],
[2*(x*z-y*w),2*(y*z+x*w),1-2*(x*x+y*y)]]
def node_local(nd):
t = nd.get("translation",[0,0,0]); r = nd.get("rotation",[0,0,0,1]); s = nd.get("scale",[1,1,1])
R = quat_mat(r)
M = [[R[i][j]*s[j] for j in range(3)]+[t[i]] for i in range(3)]
return M+[[0,0,0,1]]
def matmul(A,B):
return [[sum(A[i][k]*B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
def globals_(g):
loc = [node_local(nd) for nd in g["nodes"]]
parent = {}
for i,nd in enumerate(g["nodes"]):
for c in nd.get("children",[]): parent[c] = i
memo = {}
def gm(i):
if i in memo: return memo[i]
m = loc[i] if i not in parent else matmul(gm(parent[i]), loc[i])
memo[i] = m; return m
return [gm(i) for i in range(len(g["nodes"]))]
def skinned_positions(g, b, only_finger=True):
names = [nd.get("name","") for nd in g["nodes"]]
G = globals_(g)
skin = g["skins"][0]
joints = skin["joints"]
ibm, _ = acc(g, b, skin["inverseBindMatrices"])
# glTF matrices are column-major
def m16(row):
return [[row[c*4+r] for c in range(4)] for r in range(4)]
JM = [matmul(G[joints[j]], m16(ibm[j])) for j in range(len(joints))]
prim = g["meshes"][0]["primitives"][0]
P,_ = acc(g,b,prim["attributes"]["POSITION"])
J,_ = acc(g,b,prim["attributes"]["JOINTS_0"])
W,wt = acc(g,b,prim["attributes"]["WEIGHTS_0"])
wsc = 1.0 if wt==5126 else (1/255 if wt==5121 else 1/65535)
out = {}
for vi,(p,jr,wr) in enumerate(zip(P,J,W)):
if only_finger and not any(any(t in names[joints[j]].lower() for t in FING)
for j,w in zip(jr,wr) if w>0):
continue
x=y=z=0.0
for j,w in zip(jr,wr):
w*=wsc
if w<=0: continue
M=JM[j]
x+=w*(M[0][0]*p[0]+M[0][1]*p[1]+M[0][2]*p[2]+M[0][3])
y+=w*(M[1][0]*p[0]+M[1][1]*p[1]+M[1][2]*p[2]+M[1][3])
z+=w*(M[2][0]*p[0]+M[2][1]*p[1]+M[2][2]*p[2]+M[2][3])
out[vi]=(x,y,z)
return out, names, joints
posed_g, posed_b = read_glb(sys.argv[1])
orig_g, orig_b = read_glb(sys.argv[2])
a, names, joints = skinned_positions(posed_g, posed_b)
c, _, _ = skinned_positions(orig_g, orig_b)
dmax = 0; worst = None
for vi in a:
d = math.dist(a[vi], c[vi])
if d > dmax: dmax, worst = d, vi
print(f"finger-weighted verts: {len(a)}; max displacement posed-vs-original: {dmax*100:.1f} cm (vert {worst})")
import statistics
ds = sorted(math.dist(a[vi], c[vi]) for vi in a)
print(f"median: {ds[len(ds)//2]*100:.2f} cm, p95: {ds[int(len(ds)*0.95)]*100:.2f} cm")
+156
View File
@@ -0,0 +1,156 @@
"""Find corrupt skin bindings: verts bound to a joint that is implausibly far away in
REST pose (long bind lever arm), and verts bound across the body midline to the opposite
hand's bones. These are invisible at rest and only explode once the joint rotates.
usage: skin_lever_audit.py body.glb [--lever CM] [--dump N]
"""
import json, struct, sys, math
from pathlib import Path
from collections import Counter, defaultdict
FING = ("thumb", "index", "middle", "ring", "pinky")
LEVER_CM = 20.0
DUMP = 0
args = [a for a in sys.argv[1:]]
path = args[0]
if "--lever" in args:
LEVER_CM = float(args[args.index("--lever") + 1])
if "--dump" in args:
DUMP = int(args[args.index("--dump") + 1])
def read_glb(p):
d = Path(p).read_bytes()
length = struct.unpack_from("<I", d, 8)[0]
off = 12
g = b_ = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off)
off += 8
if ct == 0x4E4F534A:
g = json.loads(d[off:off + clen])
else:
b_ = d[off:off + clen]
off += clen
return g, b_
def acc(g, b, i):
a = g["accessors"][i]
bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[a["type"]]
fmt = {5121: "B", 5123: "H", 5125: "I", 5126: "f"}[a["componentType"]]
size = struct.calcsize(fmt) * nc
stride = bv.get("byteStride") or size
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
return [struct.unpack_from("<%d%s" % (nc, fmt), b, off + k * stride)
for k in range(a["count"])], a["componentType"]
def quat_mat(q):
x, y, z, w = q
return [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
def node_local(nd):
t = nd.get("translation", [0, 0, 0])
r = nd.get("rotation", [0, 0, 0, 1])
s = nd.get("scale", [1, 1, 1])
R = quat_mat(r)
return [[R[i][j] * s[j] for j in range(3)] + [t[i]] for i in range(3)] + [[0, 0, 0, 1]]
def matmul(A, B):
return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
def global_mats(g):
loc = [node_local(nd) for nd in g["nodes"]]
parent = {}
for i, nd in enumerate(g["nodes"]):
for c in nd.get("children", []):
parent[c] = i
memo = {}
def gm(i):
if i in memo:
return memo[i]
m = loc[i]
p = parent.get(i)
if p is not None:
m = matmul(gm(p), m)
memo[i] = m
return m
return [gm(i) for i in range(len(g["nodes"]))]
g, b = read_glb(path)
names = [nd.get("name", "") for nd in g["nodes"]]
GM = global_mats(g)
print(f"== {Path(path).name} ==")
print(f" lever threshold {LEVER_CM:.0f} cm\n")
for mi, mesh in enumerate(g.get("meshes", [])):
for pi, prim in enumerate(mesh.get("primitives", [])):
att = prim["attributes"]
if "JOINTS_0" not in att:
continue
skin_idx = next((nd.get("skin") for nd in g["nodes"]
if nd.get("mesh") == mi and "skin" in nd), None)
if skin_idx is None:
continue
joints = g["skins"][skin_idx]["joints"]
jname = [names[j] for j in joints]
# joint rest world positions
jpos = [(GM[j][0][3], GM[j][1][3], GM[j][2][3]) for j in joints]
P, _ = acc(g, b, att["POSITION"])
J, _ = acc(g, b, att["JOINTS_0"])
W, wt = acc(g, b, att["WEIGHTS_0"])
wsc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
long_lever = []
cross = []
by_joint = Counter()
cross_by_joint = Counter()
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
for j, w in zip(jrow, wrow):
w *= wsc
if w <= 0.001:
continue
n = jname[j]
nl = n.lower()
if not any(t in nl for t in FING):
continue
jp = jpos[j]
d = math.dist(p, jp) * 100.0 # cm (glTF metres)
if d > LEVER_CM:
long_lever.append((d, vi, n, w, p))
by_joint[n] += 1
# cross-hand: vert on opposite side of midline from the joint
if nl.endswith("_r") and p[0] > 0.02:
cross.append((d, vi, n, w, p)); cross_by_joint[n] += 1
elif nl.endswith("_l") and p[0] < -0.02:
cross.append((d, vi, n, w, p)); cross_by_joint[n] += 1
print(f" mesh[{mi}] '{mesh.get('name','')}' prim{pi}: {len(P)} verts")
print(f" finger refs with lever > {LEVER_CM:.0f} cm : {len(long_lever)}")
if long_lever:
mx = max(long_lever)
print(f" worst {mx[0]:.1f} cm vert {mx[1]} joint {mx[2]} w={mx[3]:.3f}")
for n, c in by_joint.most_common(10):
print(f" {n:22s} {c}")
print(f" cross-midline finger refs : {len(cross)}")
if cross:
mx = max(cross)
print(f" worst {mx[0]:.1f} cm vert {mx[1]} joint {mx[2]} w={mx[3]:.3f}")
for n, c in cross_by_joint.most_common(10):
print(f" {n:22s} {c}")
for d, vi, n, w, p in sorted(long_lever, reverse=True)[:DUMP]:
print(f" v{vi} {n} w={w:.3f} lever={d:.1f}cm pos=({p[0]*100:.1f},{p[1]*100:.1f},{p[2]*100:.1f})cm")
+61
View File
@@ -0,0 +1,61 @@
"""Sum skin weights per bone group (finger vs hand vs rest) for each GLB. usage: weight_audit.py *.glb"""
import json, struct, sys
from pathlib import Path
FING = ("thumb", "index", "middle", "ring", "pinky")
def read_glb(path):
data = Path(path).read_bytes()
length = struct.unpack_from("<I", data, 8)[0]
off = 12; gltf = None; binc = None
while off < length:
clen, ctype = struct.unpack_from("<II", data, off); off += 8
if ctype == 0x4E4F534A: gltf = json.loads(data[off:off+clen])
elif ctype == 0x004E4942: binc = data[off:off+clen]
off += clen
return gltf, binc
def acc(gltf, binc, idx):
a = gltf["accessors"][idx]
bv = gltf["bufferViews"][a["bufferView"]]
ncomp = {"SCALAR":1, "VEC2":2, "VEC3":3, "VEC4":4}[a["type"]]
fmt = {5121:"B", 5123:"H", 5125:"I", 5126:"f"}[a["componentType"]]
stride = bv.get("byteStride")
size = struct.calcsize(fmt)*ncomp
off = bv.get("byteOffset",0) + a.get("byteOffset",0)
out = []
for i in range(a["count"]):
o = off + i*(stride or size)
out.append(struct.unpack_from("<%d%s" % (ncomp, fmt), binc, o))
return out, a["componentType"]
for path in sys.argv[1:]:
gltf, binc = read_glb(path)
names = [nd.get("name", "") for nd in gltf["nodes"]]
print(f"\n== {Path(path).name} ==")
for mi, mesh in enumerate(gltf.get("meshes", [])):
for pi, prim in enumerate(mesh.get("primitives", [])):
att = prim["attributes"]
if "JOINTS_0" not in att: continue
# which skin uses this mesh
skin_idx = next((nd.get("skin") for nd in gltf["nodes"]
if nd.get("mesh") == mi and "skin" in nd), None)
if skin_idx is None: continue
joints = gltf["skins"][skin_idx]["joints"]
jn = [names[j] for j in joints]
J, _ = acc(gltf, binc, att["JOINTS_0"])
W, wt = acc(gltf, binc, att["WEIGHTS_0"])
wsc = 1.0 if wt == 5126 else (1/255 if wt == 5121 else 1/65535)
fing_w = hand_w = 0.0
fing_verts = 0
for jrow, wrow in zip(J, W):
fv = 0
for j, w in zip(jrow, wrow):
w *= wsc
if w <= 0: continue
n = jn[j].lower()
if any(t in n for t in FING): fing_w += w; fv = 1
elif n.startswith("hand"): hand_w += w
fing_verts += fv
print(f" mesh[{mi}] '{mesh.get('name','')}' prim{pi}: {len(J)} verts | "
f"finger-weighted verts: {fing_verts} | total finger W: {fing_w:.0f} | hand W: {hand_w:.0f}")