Files
animation/tools/verify_body_variant.py
T

208 lines
9.5 KiB
Python
Raw Normal View History

# Read-only structural gate for a derived body GLB: assert a variant is interchangeable with the
# canonical body as far as the engine is concerned.
#
# python tools/verify_body_variant.py <variant.glb> [--ref <stock.glb>] [--json]
#
# Exit 0 = pass, 2 = fail. Plain stdlib so it runs under system python (no numpy needed).
#
# What it is guarding against, all of which actually happened while building the nude variant:
# * the material silently renamed to "MI_Body_Lena.001" because a donor import had reserved the
# name — the game keys outfits off the material name,
# * an attribute set drifting (a TANGENT appearing, a UV set vanishing),
# * the skin losing joints or having them REORDERED — Godot binds outfit parts by joint name,
# * the stray Icosphere node disappearing or multiplying,
# * the texture ending up referenced by URI instead of embedded, so the GLB is no longer
# self-contained,
# * the mesh splitting into several primitives, which changes how the body is drawn.
import sys, os, json, struct, argparse
COMP_FMT = {5120: "b", 5121: "B", 5122: "h", 5123: "H", 5125: "I", 5126: "f"}
NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
# This tool lives in the ANIMATION repo (moved from ariki-game 2026-08-06: it authors
# characters, and characters/REGISTRY.md governs its subjects). The canonical RIGGED body it
# compares against still lives in the game repo, so that path is RESOLVED, never assumed:
# $ARIKI_GAME wins, otherwise a sibling checkout is guessed and then verified. An
# ariki-relative default that silently fails to resolve is a known trap in this repo — see
# .agents/wiki/ARCHITECTURE.md on the retargeters' --target.
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GAME = os.environ.get("ARIKI_GAME") or os.path.abspath(os.path.join(REPO, os.pardir, "ariki-game"))
REF = os.path.join(GAME, "assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb")
def game_asset(path, flag):
"""Fail loudly on an unresolved cross-repo path instead of reading the wrong file."""
if not os.path.isfile(path):
raise SystemExit(
f"FATAL: cannot find the canonical body\n"
f" looked in: {path}\n"
f" That is an ariki-game asset. Pass {flag} explicitly, or set ARIKI_GAME\n"
f" to your ariki-game checkout (the sibling-directory guess failed).")
return path
IBM_TOL = 1e-4 # inverse-bind matrices: roundtrip noise measured at 4.6e-6
HEIGHT_TOL = 0.01 # metres
WEIGHT_TOL = 0.01
class Glb:
def __init__(self, path):
self.path = path
with open(path, "rb") as f:
blob = f.read()
magic, _, _ = struct.unpack_from("<III", blob, 0)
if magic != 0x46546C67:
raise SystemExit(f"not a GLB: {path}")
clen, _ = struct.unpack_from("<II", blob, 12)
self.d = json.loads(blob[20:20 + clen])
blen, _ = struct.unpack_from("<II", blob, 20 + clen)
self.buf = blob[20 + clen + 8: 20 + clen + 8 + blen]
def read(self, idx):
a = self.d["accessors"][idx]
bv = self.d["bufferViews"][a["bufferView"]]
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
fmt = COMP_FMT[a["componentType"]]
n = NCOMP[a["type"]]
size = struct.calcsize("<" + fmt * n)
stride = bv.get("byteStride") or size
return [struct.unpack_from("<" + fmt * n, self.buf, off + i * stride)
for i in range(a["count"])]
def body(self):
"""(mesh, primitive) with the most vertices — the body, never the stray Icosphere."""
best = None
for m in self.d["meshes"]:
for p in m["primitives"]:
n = self.d["accessors"][p["attributes"]["POSITION"]]["count"]
if best is None or n > best[0]:
best = (n, m, p)
return best[1], best[2]
def joint_names(self):
if not self.d.get("skins"):
return []
return [self.d["nodes"][j].get("name", f"<{j}>") for j in self.d["skins"][0]["joints"]]
def facts(g):
mesh, prim = g.body()
pos = g.read(prim["attributes"]["POSITION"])
ys = [p[1] for p in pos] # glTF Y-up
mat = g.d["materials"][prim["material"]] if "material" in prim else {}
imgs = g.d.get("images", [])
return {
"mesh_name": mesh.get("name"),
"n_meshes": len(g.d["meshes"]),
"n_primitives": sum(len(m["primitives"]) for m in g.d["meshes"]),
"attributes": sorted(prim["attributes"].keys()),
"verts": len(pos),
"tris": len(g.read(prim["indices"])) // 3,
"height": round(max(ys) - min(ys), 4),
"material": mat.get("name"),
"n_skins": len(g.d.get("skins", [])),
"joints": g.joint_names(),
"node_names": sorted(n.get("name", "") for n in g.d["nodes"]),
"images_embedded": all("uri" not in im for im in imgs),
"n_images": len(imgs),
"image_names": [im.get("name") for im in imgs],
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("variant")
ap.add_argument("--ref", default=REF)
ap.add_argument("--json", action="store_true")
a = ap.parse_args()
v, r = Glb(a.variant), Glb(game_asset(a.ref, "--ref"))
fv, fr = facts(v), facts(r)
fails, warns = [], []
def need(cond, msg):
(fails if not cond else warns if False else fails).append(msg) if not cond else None
if fv["n_primitives"] != 1:
fails.append(f"body must be 1 primitive, found {fv['n_primitives']}")
if fv["attributes"] != fr["attributes"]:
fails.append(f"attribute set {fv['attributes']} != reference {fr['attributes']}")
if fv["material"] != fr["material"]:
fails.append(f"material '{fv['material']}' != reference '{fr['material']}'")
if fv["mesh_name"] != fr["mesh_name"]:
fails.append(f"mesh name '{fv['mesh_name']}' != reference '{fr['mesh_name']}'")
if fv["n_skins"] != 1:
fails.append(f"expected exactly 1 skin, found {fv['n_skins']}")
if fv["joints"] != fr["joints"]:
if sorted(fv["joints"]) == sorted(fr["joints"]):
fails.append("joints are REORDERED vs reference (same names, different order)")
else:
miss = set(fr["joints"]) - set(fv["joints"])
extra = set(fv["joints"]) - set(fr["joints"])
fails.append(f"joint list differs: {len(fv['joints'])} vs {len(fr['joints'])}"
+ (f", missing {sorted(miss)[:6]}" if miss else "")
+ (f", extra {sorted(extra)[:6]}" if extra else ""))
if fv["node_names"] != fr["node_names"]:
miss = set(fr["node_names"]) - set(fv["node_names"])
extra = set(fv["node_names"]) - set(fr["node_names"])
fails.append(f"node-name set differs (missing {sorted(miss)[:5]}, extra {sorted(extra)[:5]})")
if not fv["images_embedded"]:
fails.append("texture is referenced by URI, not embedded — GLB is not self-contained")
if fv["n_images"] != fr["n_images"]:
fails.append(f"{fv['n_images']} images, reference has {fr['n_images']}")
if abs(fv["height"] - fr["height"]) > HEIGHT_TOL:
fails.append(f"height {fv['height']} m vs reference {fr['height']} m")
# inverse-bind matrices must be untouched: the rig has to keep binding identically
if v.d.get("skins") and r.d.get("skins"):
iv = v.d["skins"][0].get("inverseBindMatrices")
ir = r.d["skins"][0].get("inverseBindMatrices")
if iv is not None and ir is not None:
mv, mr = v.read(iv), r.read(ir)
if len(mv) != len(mr):
fails.append(f"{len(mv)} inverse-bind matrices vs {len(mr)}")
else:
worst = max(abs(x - y) for a_, b_ in zip(mv, mr) for x, y in zip(a_, b_))
if worst > IBM_TOL:
fails.append(f"inverse-bind matrices drifted by {worst:.2e} (> {IBM_TOL})")
else:
warns.append(f"inverse-bind max drift {worst:.2e} OK")
# skin weights must still normalise
mesh, prim = v.body()
if "WEIGHTS_0" in prim["attributes"]:
acc = v.d["accessors"][prim["attributes"]["WEIGHTS_0"]]
rows = v.read(prim["attributes"]["WEIGHTS_0"])
norm = 1.0
if acc["componentType"] == 5121:
norm = 255.0
elif acc["componentType"] == 5123:
norm = 65535.0
bad = sum(1 for row in rows if abs(sum(row) / norm - 1.0) > WEIGHT_TOL)
if bad:
fails.append(f"{bad} vertices have skin weights not summing to 1")
else:
warns.append(f"skin weights normalised across {len(rows)} verts")
if a.json:
print(json.dumps({"variant": fv, "reference": fr,
"fails": fails, "notes": warns}, indent=2))
else:
print(f"variant {os.path.basename(a.variant)}")
print(f"reference {os.path.basename(a.ref)}")
for k in ("mesh_name", "n_primitives", "attributes", "verts", "tris", "height",
"material", "n_skins", "n_images", "image_names", "images_embedded"):
same = " " if fv[k] == fr[k] else " *"
print(f"{same} {k:16s} {fv[k]}"
+ ("" if fv[k] == fr[k] else f" (ref: {fr[k]})"))
print(f" joints {len(fv['joints'])}"
f" {'(identical order)' if fv['joints'] == fr['joints'] else '(DIFFERS)'}")
for w in warns:
print(f" note: {w}")
for f in fails:
print(f" FAIL: {f}")
print("PASS" if not fails else f"FAIL ({len(fails)} problem(s))")
return 0 if not fails else 2
sys.exit(main())