"""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("