388 lines
17 KiB
Python
388 lines
17 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
|
|
from mathutils import Vector
|
|
|
|
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")
|
|
# Resolved bone roles from the sidecar — filled in below. Every check that needs
|
|
# to know "which bones are the legs" asks THIS, not the name hints.
|
|
#
|
|
# The hints are a fallback for a build with no sidecar, and they are only ever
|
|
# right for rigs that happen to spell things the way the library does. A VRoid
|
|
# rig calls its thigh J_Bip_L_UpperLeg: no hint matches it, so the cross-leg
|
|
# bleed check found no leg vertex groups at all and PASSED vacuously, and the
|
|
# leg-motion check declared every locomotion clip static while the legs were in
|
|
# fact animating perfectly. Two green-looking lies from one missing lookup.
|
|
ROLE_LEGS = {"L": set(), "R": set()}
|
|
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):
|
|
if name in ROLE_LEGS["L"]:
|
|
return -1
|
|
if name in ROLE_LEGS["R"]:
|
|
return 1
|
|
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
|
|
|
|
|
|
SIDECAR_INFO = {}
|
|
if os.path.exists(SIDECAR):
|
|
with open(SIDECAR, "r", encoding="utf-8") as f:
|
|
SIDECAR_INFO = json.load(f)
|
|
weights_authored = bool(SIDECAR_INFO.get("weights_authored", True))
|
|
_roles = SIDECAR_INFO.get("roles", {})
|
|
for _r in ("thigh", "shin", "foot", "toe"):
|
|
for _s in ("L", "R"):
|
|
_b = _roles.get(f"{_r}.{_s}")
|
|
if _b:
|
|
ROLE_LEGS[_s].add(_b)
|
|
|
|
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)
|
|
|
|
# ------------------------------------------------------------------- posture
|
|
#
|
|
# Is this character the right size and the right way up?
|
|
#
|
|
# Nothing asked before, and it is the single cheapest check in the file. The
|
|
# scale step normalises the bounding box along Blender's Z because Z is up — for
|
|
# a model that arrives lying along Y that measures the character's THICKNESS, so
|
|
# it gets scaled by ~7 and left on its back. Three of seven shipped that way.
|
|
#
|
|
# The trap is that the normalised number always comes out right: the export maps
|
|
# Blender Z to glTF Y, so "is the height 1.75" passes on a character who is
|
|
# 7.5 m tall lying down. The real question is whether the axis that was
|
|
# normalised is the one the SKELETON is tall along.
|
|
# From the VERTICES, not from object.bound_box — that is cached and is still
|
|
# stale immediately after an import, which quietly reported a 1.75 m character
|
|
# as 1.18 m tall.
|
|
lo = [1e9] * 3
|
|
hi = [-1e9] * 3
|
|
for m in meshes:
|
|
mw = m.matrix_world
|
|
for v in m.data.vertices:
|
|
p = mw @ v.co
|
|
for k in range(3):
|
|
lo[k] = min(lo[k], p[k])
|
|
hi[k] = max(hi[k], p[k])
|
|
ext = [hi[k] - lo[k] for k in range(3)]
|
|
|
|
up_axis = None
|
|
_hips = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("hips", ""))
|
|
_head = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("head", ""))
|
|
if _hips and _head:
|
|
d = [abs(_head.head_local[k] - _hips.head_local[k]) for k in range(3)]
|
|
up_axis = d.index(max(d))
|
|
|
|
if up_axis is not None and max(ext) > 1e-4:
|
|
tall = ext.index(max(ext))
|
|
others = [ext[k] for k in range(3) if k != up_axis]
|
|
detail = ("%.2f m tall along %s, %.2f x %.2f m across"
|
|
% (ext[up_axis], "XYZ"[up_axis], others[0], others[1]))
|
|
# Against WORLD up (Blender Z), not against the widest axis. A model that has
|
|
# been rotated as a whole is internally consistent — its spine IS its longest
|
|
# axis — so comparing the two agrees with itself and catches nothing. The
|
|
# question is whether the character stands up in the world the game runs in.
|
|
check(up_axis == 2, "character stands up in world space",
|
|
detail + ("" if up_axis == 2 else
|
|
" — the spine runs along %s, not Z, so the character is "
|
|
"lying down; whatever was normalised to the target height "
|
|
"was not their height" % "XYZ"[up_axis]))
|
|
check(max(others) < 2.5, "character is a plausible size", detail)
|
|
check(1.2 < ext[up_axis] < 2.4, "character is a plausible height", detail)
|
|
|
|
# --------------------------------------------------- reachable from the runtime
|
|
#
|
|
# The game looks these up to attach a weapon and to drive the pose layer. It used
|
|
# to do it with hardcoded spellings, so four characters whose roles resolved
|
|
# perfectly still could not hold a gun. The roles are the contract; if one the
|
|
# runtime needs is missing from the sidecar, the character will be subtly broken
|
|
# in a way no other check here can see.
|
|
_roles = SIDECAR_INFO.get("roles", {})
|
|
if _roles:
|
|
needed = ["hips", "head", "hand.R", "hand.L", "upper_arm.R", "upper_arm.L",
|
|
"forearm.R", "forearm.L", "thigh.L", "thigh.R", "shin.L", "shin.R"]
|
|
absent = [r for r in needed
|
|
if not _roles.get(r) or _roles[r] not in arm.data.bones]
|
|
check(not absent, "every role the runtime needs is resolved",
|
|
"missing or unresolvable: %s" % absent)
|
|
|
|
# ------------------------------------------------------------------- skeleton
|
|
def is_rootish(b):
|
|
if b is None:
|
|
return True
|
|
# Some production rigs call their weighted pelvis simply ``root``. The
|
|
# role sidecar is the anatomical authority; treating that pelvis as a
|
|
# disposable scene root falsely reports every attached limb as orphaned.
|
|
if b.name == SIDECAR_INFO.get("roles", {}).get("hips"):
|
|
return False
|
|
return 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)]
|
|
root_names = {bone.name for bone in arm.data.bones}
|
|
root_allowance = 2 if {"全ての親", "操作中心"}.issubset(root_names) else 1
|
|
check(len(orphans) <= root_allowance, "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")
|
|
required_clips = {
|
|
"Idle", "Walk", "Run", "Sprint", "Jump", "Fall", "Land",
|
|
"CrouchIdle", "CrouchWalk", "Dash", "Slide",
|
|
"RunForward", "RunBackward", "RunLeft", "RunRight",
|
|
"StrafeWalkForward", "StrafeWalkBackward",
|
|
"StrafeWalkLeft", "StrafeWalkRight",
|
|
"WallRunLeft", "WallRunRight", "Grapple",
|
|
}
|
|
missing_clips = sorted(required_clips - actions.keys())
|
|
check(not missing_clips, "the canonical clip set shipped",
|
|
f"missing: {', '.join(missing_clips)}" if missing_clips
|
|
else f"{len(actions)} clips")
|
|
check("WallRun" not in actions, "the placeholder run-cycle wall run was removed",
|
|
"legacy WallRun is still present" if "WallRun" in actions else "")
|
|
|
|
|
|
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")
|
|
# A chain whose bones have no measurable extent is DEAD: the runtime drops
|
|
# any bone whose tip is shorter than a millimetre, so such a chain simulates
|
|
# nothing and the costume is welded solid — silently, because the sidecar
|
|
# still reports it. hikari shipped ten of them; her cosmetic bones are
|
|
# zero-length terminators from a rig that had been through two toolchains.
|
|
dead = 0
|
|
for c in info.get("chains", []):
|
|
ext = max((sum(v * v for v in t) ** 0.5 for t in c.get("tips", [])),
|
|
default=0.0)
|
|
if ext < 0.001:
|
|
dead += 1
|
|
if info.get("chains"):
|
|
check(dead == 0, "cloth chains have measurable extent",
|
|
f"{dead} of {len(info['chains'])} chains are zero-length and will "
|
|
f"not simulate", advisory=True)
|
|
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",
|
|
"WallRunLeft", "WallRunRight"}
|
|
|
|
# The thighs and shins by ROLE, so this works on any rig's spelling.
|
|
leg_bones = {b for s in ROLE_LEGS.values() for b in s}
|
|
if not leg_bones:
|
|
leg_bones = {b.name for b in arm.data.bones
|
|
if any(h in b.name.lower() for h in ("thigh", "shin"))}
|
|
|
|
frozen = []
|
|
held_rest = []
|
|
legless = []
|
|
keyed_cloth = set()
|
|
held_poses = {"Grapple"}
|
|
for name, action in sorted(actions.items()):
|
|
moved = defaultdict(float)
|
|
authored_offset = 0.0
|
|
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))
|
|
# A held pose is intentionally almost constant. Quaternion XYZ and
|
|
# translation channels are zero in the rest pose, so a meaningful
|
|
# absolute value proves the clip contains an authored silhouette.
|
|
if (fc.data_path.endswith("rotation_quaternion")
|
|
and fc.array_index in (1, 2, 3)) \
|
|
or fc.data_path.endswith("location"):
|
|
authored_offset = max(
|
|
authored_offset, max((abs(value) for value in vals), default=0.0)
|
|
)
|
|
if name in held_poses:
|
|
if authored_offset < 0.03:
|
|
held_rest.append(f"{name}({authored_offset:.4f})")
|
|
elif max(moved.values(), default=0.0) < 0.005:
|
|
frozen.append(name)
|
|
legs = max((v for b, v in moved.items() if b in leg_bones), 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 held_rest, "held clips contain a non-rest authored pose",
|
|
f"rest-like: {', '.join(held_rest)}" if held_rest 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")
|