This commit is contained in:
Can Narin
2026-08-18 22:47:07 +03:00
113 changed files with 64781 additions and 27 deletions
+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_}")
+61
View File
@@ -0,0 +1,61 @@
"""Cluster the worst-stretched edges by REST position to name the digit region."""
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(sum((p[i]-q[i])**2 for i in range(3)))
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)
xs = sorted(v[0] for v in rest_v)
print(f'hand_{hand}: rest x range {xs[0]*100:.1f}..{xs[-1]*100:.1f} cm, '
f'y range {min(v[1] for v in rest_v)*100:.1f}..{max(v[1] for v in rest_v)*100:.1f}, '
f'z range {min(v[2] for v in rest_v)*100:.1f}..{max(v[2] for v in rest_v)*100:.1f}')
for pose in ('fist', 'grip'):
v, _ = load_obj(os.path.join(d, f'{pose}_hand_{hand}.obj'))
bad = []
for a, b in edges:
rl = dist(rest_v[a], rest_v[b])
if rl <= 1e-9:
continue
r = dist(v[a], v[b]) / rl
if r > 5:
bad.append((r, a))
# bounding box of bad verts in rest space
pts = [rest_v[a] for _, a in bad]
if not pts:
print(f' {pose}: no >5x edges')
continue
bx = (min(p[0] for p in pts)*100, max(p[0] for p in pts)*100)
by = (min(p[1] for p in pts)*100, max(p[1] for p in pts)*100)
bz = (min(p[2] for p in pts)*100, max(p[2] for p in pts)*100)
cx = sum(p[0] for p in pts)/len(pts)*100
cy = sum(p[1] for p in pts)/len(pts)*100
cz = sum(p[2] for p in pts)/len(pts)*100
print(f' {pose}: {len(bad)} edges>5x rest-bbox x[{bx[0]:.1f},{bx[1]:.1f}] '
f'y[{by[0]:.1f},{by[1]:.1f}] z[{bz[0]:.1f},{bz[1]:.1f}] centroid ({cx:.1f},{cy:.1f},{cz:.1f}) cm')
# z-histogram (palm axis?) to see if it's one digit or spread
zs = sorted(p[2]*100 for p in pts)
q = lambda f: zs[int(f*(len(zs)-1))]
print(f' rest z quartiles: {q(0):.1f} / {q(0.25):.1f} / {q(0.5):.1f} / {q(0.75):.1f} / {q(1):.1f}')
+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})")
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
"""Verify the emitted hand-morph GLB numerically:
1. CROSS-HAND INDEPENDENCE: each hand_<pose>_<side> shape must move ONLY that side's verts.
2. CURL DIRECTION: per shape, left-hand fingertip-region deltas must point toward the palm
(dot with palm normal < 0) catches a flipped normal making fingers bend backward.
"""
import json, struct, sys
from pathlib import Path
import numpy as np
def read_glb(path):
d = Path(path).read_bytes()
ln = struct.unpack_from("<I", d, 8)[0]
off = 12; g = b = None
while off < ln:
clen, ct = struct.unpack_from("<II", d, off); off += 8
if ct == 0x4E4F534A: g = json.loads(d[off:off+clen])
elif ct == 0x004E4942: b = d[off:off+clen]
off += clen
return g, b
def acc(g, b, i):
a = g["accessors"][i]; bv = g["bufferViews"][a["bufferView"]]
dt = np.dtype({5126: "<f4", 5123: "<u2", 5121: "u1", 5125: "<u4", 5122: "<i2"}[a["componentType"]])
nc = {"VEC3": 3, "SCALAR": 1, "VEC4": 4}[a["type"]]
size = dt.itemsize * nc
stride = bv.get("byteStride") or size
off2 = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
raw = np.frombuffer(b, np.uint8, a["count"] * stride, off2).reshape(a["count"], stride)
return raw[:, :size].copy().view(dt).reshape(a["count"], nc).astype(np.float64)
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else argv[1:]
g, b = read_glb(argv[0])
prim = g["meshes"][0]["primitives"][0]
P = acc(g, b, prim["attributes"]["POSITION"])
names = [g["nodes"][j].get("name", "") for j in g["skins"][0]["joints"]]
idx = {n: i for i, n in enumerate(names)}
# bone heads in world (mesh) space: node global translations
def node_local(nd):
t = np.array(nd.get("translation", [0, 0, 0])); s = np.array(nd.get("scale", [1, 1, 1]))
x, y, z, w = nd.get("rotation", [0, 0, 0, 1])
n = np.sqrt(x*x+y*y+z*z+w*w); x, y, z, w = x/n, y/n, z/n, w/n
R = np.array([[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)]])
M = np.eye(4); M[:3, :3] = R * s[None, :]; M[:3, 3] = t
return M
node_to_joint = {j: i for i, j in enumerate(g["skins"][0]["joints"])}
parent = {}
for i, j in enumerate(g["skins"][0]["joints"]):
for c in g["nodes"][j].get("children", []):
if c in node_to_joint: parent[node_to_joint[c]] = i
G = {}
def glob(i):
if i in G: return G[i]
j = g["skins"][0]["joints"][i]
M = node_local(g["nodes"][j])
G[i] = M if parent.get(i, -1) < 0 else glob(parent[i]) @ M
return G[i]
def head(n): return glob(idx[n])[:3, 3]
# palm plane per side: normal via middle/pinky/index MCP + hand
for side in ("l", "r"):
h = head(f"hand_{side}")
mid, ix, pk = head(f"middle_01_{side}"), head(f"index_01_{side}"), head(f"pinky_01_{side}")
n = np.cross(mid - h, pk - ix); n /= np.linalg.norm(n)
print(f"side {side}: palm normal (world) = {np.round(n, 3)}")
# nearest-bone segmentation for vert labeling (euclidean, fingers only — verification only)
finger_bones = [f"{d}_{p}{s}" for s in ("l", "r") for d in ("thumb","index","middle","ring","pinky")
for p in ("01","02","03") if f"{d}_{p}{s}" in idx]
heads = {bn: head(bn) for bn in finger_bones}
dmin = np.full(len(P), 1e9); label = np.full(len(P), -1, dtype=int)
for bi, bn in enumerate(finger_bones):
d = np.linalg.norm(P - heads[bn], axis=1)
closer = d < dmin
dmin[closer] = d[closer]; label[closer] = bi
# keep verts within 8cm of their nearest finger bone (fingertip region <= 3.5cm for direction)
targets = prim["targets"]
tnames = g["meshes"][0]["extras"]["targetNames"]
for ti, tname in enumerate(tnames):
delta = acc(g, b, targets[ti]["POSITION"])
mag = np.linalg.norm(delta, axis=1)
side = tname[-1]
# 1) cross-hand: verts whose nearest bone is the OTHER side must not move
other = np.array([finger_bones[l][-1] != side if l >= 0 else False for l in label])
other &= dmin < 0.08
cross = float(mag[other].max()) if other.any() else 0.0
# 2) curl direction: fingertip verts of THIS side (non-thumb, nearest <= 3.5cm of _03 bone)
tip = np.zeros(len(P), dtype=bool)
for d in ("index", "middle", "ring", "pinky"):
bn = f"{d}_03_{side}"
if bn in heads:
tip |= (np.linalg.norm(P - heads[bn], axis=1) < 0.035)
if tip.any():
h = head(f"hand_{side}")
mid, ix, pk = head(f"middle_01_{side}"), head(f"index_01_{side}"), head(f"pinky_01_{side}")
n = np.cross(mid - h, pk - ix); n /= np.linalg.norm(n)
dots = delta[tip] @ n
toward = float((dots < 0).mean())
else:
toward = -1
own = ~other & (dmin < 0.08)
ownmax = float(mag[own].max()) * 100 if own.any() else -1.0
print(f"{tname}: max|delta| other-hand={cross*100:.2f}cm tip-toward-palm={toward*100:.0f}% "
f"max|delta| own={ownmax:.1f}cm")
+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}")