502 lines
24 KiB
Python
502 lines
24 KiB
Python
|
|
"""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")
|