208 lines
9.5 KiB
Python
208 lines
9.5 KiB
Python
|
|
# 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())
|