Files
Papay-Shooter/tools/verify_character.py
T
Nicholas ButzkeandClaude Opus 5 cd0d1b2d99 feat(characters): import the Quaternius mannequin as a selectable skin
A third playable character, built with the pipeline skill from a source
that was already in the repo: the animation library ships a rigged
Mannequin mesh on the exact 53-joint reference skeleton, CC0, so it needed
no download and retargets perfectly. 18 clips, 0.3% cross-leg bleed, 7% of
verts at four influences — a clean authored-weight import. Licence
recorded in mannequin.license.json as the other skins do.

It has no cloth chains, correctly: it is a mannequin and has neither hair
nor clothes.

Importing it turned up two real bugs, both of which would have hit any
flat-coloured or single-piece model:

- LevelMaterials.apply_character_look treated ANY untextured surface on a
  character as the model's own outline shell and hid it, so the mannequin
  rendered as a solid black silhouette — its body and joint materials are
  untextured flat colours, not ink. _is_line_work() now asks whether the
  surface is named eyes*, is drawn front-face-culled (the inverted-hull
  setup), or is near-black. Taila and Miku are unaffected: their materials
  are textured and never reach that branch. Verified by render.

- verify_character.py failed the build for having one mesh. That check
  cannot tell "the pipeline joined them" from "the artist authored one
  mesh" — Quaternius' mannequin is one piece on purpose. It is advisory
  now; the join path's two unambiguous signatures, cross-leg bleed and the
  4-influences-everywhere spread, are still hard checks.

Also restored Miku's description, which the re-import had blanked.

3 GLB skins selectable (6 with the built-in colour skins). Smoke 0
failures, 11/11 movement tests, cloth idle 0.024-0.078 deg/frame.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 13:25:26 -04:00

239 lines
9.3 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, advisory=False):
soft = advisory or (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)
# ADVISORY, not a gate. Several meshes is what we want — it is how body, cloth
# and hair stay separable for materials, for the outline pass and for the cloth
# solver's hull extraction — but the OUTPUT cannot tell "the pipeline joined
# them" from "the artist authored one mesh". Quaternius' mannequin is a single
# mesh on purpose and was failing a check about damage that had not happened.
#
# The join path leaves two signatures that ARE unambiguous, and both are hard
# checks above: cross-leg weight bleed, and the 4-influences-everywhere spread
# of a nearest-bone rebind. Those catch what this was standing in for.
check(len(meshes) > 1, "model keeps its per-part meshes",
f"{len(meshes)} mesh{'es' if len(meshes) != 1 else ''}"
+ (" — fine for a single-piece model; a costume should be several"
if len(meshes) == 1 else ""),
advisory=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")