227 lines
8.5 KiB
Python
227 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Check a built character GLB against the things that have actually gone wrong.
|
|
|
|
Every assertion here corresponds to a real defect this project shipped, so a
|
|
green run means those specific failures are gone rather than that the file
|
|
merely loads:
|
|
|
|
CROSS-LEG BLEED A vertex pulled by both legs sits between them and stays
|
|
there while they separate, stretching every triangle around
|
|
it. The old nearest-bone rebind left 2817 of these on
|
|
Taila, worst at a dead 50/50 — the boots and thighs that
|
|
characters/skin_leg_repair.gd was written to patch at
|
|
runtime. Authored weights have none.
|
|
INFLUENCE SPREAD 86% of vertices carrying the full four influences is the
|
|
signature of K=4 Euclidean weighting, not of an artist.
|
|
Real weights are mostly one or two bones.
|
|
PART SPLIT One joined mesh means body, cloth and hair deform under one
|
|
rule. Separate meshes per material is what lets a thigh
|
|
stay solid while a skirt drapes.
|
|
ORPHAN CHAINS A Rigify DEF-rig exports its chain roots on the armature
|
|
root. If the rebuild missed one, that limb or strand floats
|
|
in place while the body moves.
|
|
CLIP MOTION A retarget that silently fails produces clips that exist
|
|
but never move — the rest-pose statue this pipeline has
|
|
produced before. Every clip must actually rotate the hips
|
|
and the legs.
|
|
CLOTH IS FREE Cloth bones must carry no keys, or the clips would fight
|
|
the spring solver for them.
|
|
|
|
Usage:
|
|
blender --background --python tools/verify_character.py -- <character.glb> [rig.json]
|
|
|
|
Exits non-zero if any check fails, so it can gate the pipeline.
|
|
"""
|
|
import bpy
|
|
import json
|
|
import os
|
|
import sys
|
|
from collections import defaultdict
|
|
|
|
argv = sys.argv
|
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
|
if not argv:
|
|
print(__doc__)
|
|
sys.exit(1)
|
|
PATH = argv[0]
|
|
SIDECAR = argv[1] if len(argv) > 1 else os.path.splitext(PATH)[0] + ".rig.json"
|
|
|
|
LEG_HINTS = ("thigh", "shin", "foot", "toe", "upleg", "calf")
|
|
failures = []
|
|
warnings = []
|
|
# Set from the sidecar. A model whose source had no skeleton at all has to go
|
|
# through autorig, and its weights are then a nearest-bone fit by construction —
|
|
# there is no better result to demand. The deformation checks still RUN and
|
|
# still print, so the cost is visible, but they cannot fail a build that had no
|
|
# alternative; SkinLegRepair covers those models at load time instead.
|
|
weights_authored = True
|
|
|
|
|
|
def check(ok, label, detail="", needs_authored_weights=False):
|
|
soft = needs_authored_weights and not weights_authored
|
|
tag = "PASS" if ok else ("WARN" if soft else "FAIL")
|
|
print(f" [{tag}] {label}" + (f" — {detail}" if detail else ""))
|
|
if ok:
|
|
return
|
|
if soft:
|
|
warnings.append(f"{label} — {detail}")
|
|
else:
|
|
failures.append(label)
|
|
|
|
|
|
def side_of(name):
|
|
n = name.lower()
|
|
if not any(h in n for h in LEG_HINTS):
|
|
return 0
|
|
if n.endswith(".l") or ".l." in n or "left" in n:
|
|
return -1
|
|
if n.endswith(".r") or ".r." in n or "right" in n:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if os.path.exists(SIDECAR):
|
|
with open(SIDECAR, "r", encoding="utf-8") as f:
|
|
weights_authored = bool(json.load(f).get("weights_authored", True))
|
|
|
|
bpy.ops.object.select_all(action="SELECT")
|
|
bpy.ops.object.delete()
|
|
bpy.ops.import_scene.gltf(filepath=PATH)
|
|
|
|
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
|
if not arms:
|
|
print("FAIL: no armature")
|
|
sys.exit(1)
|
|
arm = max(arms, key=lambda a: len(a.data.bones))
|
|
meshes = [o for o in bpy.data.objects if o.type == "MESH" and o.vertex_groups]
|
|
|
|
print(f"\n=== {os.path.basename(PATH)} — {len(arm.data.bones)} bones, "
|
|
f"{len(meshes)} meshes, {sum(len(m.data.vertices) for m in meshes)} verts ===\n")
|
|
|
|
# ---------------------------------------------------------------- deformation
|
|
total_bleed = 0
|
|
worst_bleed = 0.0
|
|
infl = defaultdict(int)
|
|
for m in meshes:
|
|
gside = {g.index: side_of(g.name) for g in m.vertex_groups}
|
|
for v in m.data.vertices:
|
|
wl = wr = 0.0
|
|
n = 0
|
|
for g in v.groups:
|
|
if g.weight <= 0.005:
|
|
continue
|
|
n += 1
|
|
s = gside.get(g.group, 0)
|
|
if s == -1:
|
|
wl += g.weight
|
|
elif s == 1:
|
|
wr += g.weight
|
|
infl[n] += 1
|
|
if wl > 0.005 and wr > 0.005:
|
|
total_bleed += 1
|
|
worst_bleed = max(worst_bleed, min(wl, wr) / (wl + wr))
|
|
|
|
nverts = sum(infl.values()) or 1
|
|
four = infl.get(4, 0) / nverts
|
|
# Not zero: a skirt hem genuinely spans both legs, and Taila's artist left 17
|
|
# such vertices (0.1%) on purpose. The failure mode being caught is the SOLVER
|
|
# signature — the nearest-bone rebind put 16% of the model in this state.
|
|
bleed_frac = total_bleed / nverts
|
|
check(bleed_frac < 0.005, "cross-leg blending is limited to draping cloth",
|
|
f"{total_bleed} verts ({bleed_frac * 100:.1f}%), worst minority share {worst_bleed:.2f}",
|
|
needs_authored_weights=True)
|
|
check(four < 0.5, "influences look authored, not solved",
|
|
f"{four * 100:.0f}% of verts carry 4 influences; spread {dict(sorted(infl.items()))}",
|
|
needs_authored_weights=True)
|
|
check(len(meshes) > 1, "model keeps its per-part meshes", f"{len(meshes)} meshes",
|
|
needs_authored_weights=True)
|
|
|
|
# ------------------------------------------------------------------- skeleton
|
|
def is_rootish(b):
|
|
return b is None or any(t in b.name.lower()
|
|
for t in ("root", "master", "armature"))
|
|
|
|
|
|
orphans = [b.name for b in arm.data.bones
|
|
if is_rootish(b.parent) and not is_rootish(b)]
|
|
check(len(orphans) <= 1, "every chain is attached to the body",
|
|
f"{len(orphans)} bones still on the armature root: {orphans[:6]}")
|
|
|
|
# ----------------------------------------------------------------------- clips
|
|
actions = {a.name: a for a in bpy.data.actions}
|
|
print(f"\n {len(actions)} clips: {', '.join(sorted(actions))}\n")
|
|
check(len(actions) >= 10, "the canonical clip set shipped", f"{len(actions)} clips")
|
|
|
|
|
|
def curves(action):
|
|
legacy = getattr(action, "fcurves", None)
|
|
if legacy is not None:
|
|
return list(legacy)
|
|
out = []
|
|
for layer in getattr(action, "layers", []):
|
|
for strip in layer.strips:
|
|
for cbag in getattr(strip, "channelbags", []):
|
|
out.extend(cbag.fcurves)
|
|
return out
|
|
|
|
|
|
def bone_of(path):
|
|
if 'pose.bones["' not in path:
|
|
return None
|
|
s = path.index('"') + 1
|
|
return path[s:path.index('"', s)]
|
|
|
|
|
|
cloth = set()
|
|
if os.path.exists(SIDECAR):
|
|
with open(SIDECAR, "r", encoding="utf-8") as f:
|
|
info = json.load(f)
|
|
for c in info.get("chains", []):
|
|
cloth.update(c["bones"])
|
|
print(f" sidecar: {len(info.get('chains', []))} cloth chains "
|
|
f"({len(cloth)} bones), {len(info.get('twist', []))} twist bones")
|
|
else:
|
|
warnings.append(f"no sidecar at {SIDECAR}")
|
|
|
|
# Clips whose legs MUST move. The library's Pistol_Idle_Loop and Pistol_Shoot
|
|
# are upper-body clips with genuinely static legs, so demanding leg motion from
|
|
# every clip fails on a correct build.
|
|
LOCOMOTION = {"Idle", "Walk", "Run", "Sprint", "Jump", "Fall", "Land",
|
|
"CrouchIdle", "CrouchWalk", "Dash"}
|
|
|
|
frozen = []
|
|
legless = []
|
|
keyed_cloth = set()
|
|
for name, action in sorted(actions.items()):
|
|
moved = defaultdict(float)
|
|
for fc in curves(action):
|
|
b = bone_of(fc.data_path)
|
|
if not b or len(fc.keyframe_points) < 2:
|
|
continue
|
|
if b in cloth:
|
|
keyed_cloth.add(b)
|
|
vals = [kp.co.y for kp in fc.keyframe_points]
|
|
moved[b] = max(moved[b], max(vals) - min(vals))
|
|
if max(moved.values(), default=0.0) < 0.005:
|
|
frozen.append(name)
|
|
legs = max((v for b, v in moved.items()
|
|
if any(h in b.lower() for h in ("thigh", "shin"))), default=0.0)
|
|
if name in LOCOMOTION and legs < 0.01:
|
|
legless.append(f"{name}({legs:.4f})")
|
|
|
|
check(not frozen, "no clip retargeted to a frozen rest pose",
|
|
f"frozen: {', '.join(frozen)}" if frozen else "")
|
|
check(not legless, "locomotion clips animate the legs",
|
|
f"static legs: {', '.join(legless)}" if legless else "")
|
|
check(not keyed_cloth, "cloth bones carry no animation keys",
|
|
f"{len(keyed_cloth)} keyed: {sorted(keyed_cloth)[:5]}")
|
|
|
|
print()
|
|
for w in warnings:
|
|
print(f" [WARN] {w}")
|
|
if failures:
|
|
print(f"\n{len(failures)} CHECK(S) FAILED: {failures}\n")
|
|
sys.exit(1)
|
|
print("\nAll checks passed.\n")
|