241 lines
9.9 KiB
Python
241 lines
9.9 KiB
Python
"""Re-import and validate every generated creature animation GLB."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import struct
|
|
|
|
import bpy
|
|
from mathutils import Vector
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
|
|
EXPECTED_CLIPS = {"Idle", "Move", "Attack", "HitReact", "Death"}
|
|
|
|
|
|
def read_glb_json(path: Path) -> dict[str, object]:
|
|
data = path.read_bytes()
|
|
magic, version, total_length = struct.unpack_from("<4sII", data, 0)
|
|
if magic != b"glTF" or version != 2 or total_length != len(data):
|
|
raise RuntimeError(f"{path}: invalid GLB header")
|
|
chunk_length, chunk_type = struct.unpack_from("<II", data, 12)
|
|
if chunk_type != 0x4E4F534A:
|
|
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
|
|
return json.loads(data[20:20 + chunk_length].decode("utf-8"))
|
|
|
|
|
|
def read_glb_binary(path: Path) -> bytes:
|
|
data = path.read_bytes()
|
|
json_length, json_type = struct.unpack_from("<II", data, 12)
|
|
if json_type != 0x4E4F534A:
|
|
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
|
|
binary_header = 20 + json_length
|
|
binary_length, binary_type = struct.unpack_from("<II", data, binary_header)
|
|
if binary_type != 0x004E4942:
|
|
raise RuntimeError(f"{path}: binary data is not the second GLB chunk")
|
|
binary_start = binary_header + 8
|
|
return data[binary_start:binary_start + binary_length]
|
|
|
|
|
|
def float_accessor(document: dict[str, object], binary: bytes, index: int) -> list[tuple[float, ...]]:
|
|
accessor = document["accessors"][index]
|
|
if accessor["componentType"] != 5126:
|
|
raise RuntimeError(f"Accessor {index}: expected float data")
|
|
component_counts = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}
|
|
component_count = component_counts[accessor["type"]]
|
|
view = document["bufferViews"][accessor["bufferView"]]
|
|
element_size = component_count * 4
|
|
stride = view.get("byteStride", element_size)
|
|
start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
|
|
return [
|
|
struct.unpack_from(f"<{component_count}f", binary, start + item * stride)
|
|
for item in range(accessor["count"])
|
|
]
|
|
|
|
|
|
def values_vary(values: list[tuple[float, ...]], tolerance: float = 1e-5) -> bool:
|
|
first = values[0]
|
|
return any(
|
|
abs(component - first[index]) > tolerance
|
|
for value in values[1:]
|
|
for index, component in enumerate(value)
|
|
)
|
|
|
|
|
|
def validate_pumpking_tentacles(path: Path) -> int:
|
|
document = read_glb_json(path)
|
|
nodes = document.get("nodes", [])
|
|
animations = document.get("animations", [])
|
|
tentacle_nodes = {
|
|
index for index, node in enumerate(nodes)
|
|
if re.fullmatch(r"[LR]_arm\d+_\d+", node.get("name", ""), re.IGNORECASE)
|
|
}
|
|
if not tentacle_nodes:
|
|
raise RuntimeError(f"{path}: no Pumpking tentacle nodes")
|
|
for animation in animations:
|
|
targeted = {channel["target"]["node"] for channel in animation.get("channels", [])}
|
|
missing = tentacle_nodes - targeted
|
|
if missing:
|
|
missing_names = [nodes[index].get("name", str(index)) for index in sorted(missing)]
|
|
raise RuntimeError(f"{path}: {animation.get('name')} misses tentacles {missing_names}")
|
|
return len(tentacle_nodes)
|
|
|
|
|
|
def validate_ultimate_dragon(path: Path) -> dict[str, int]:
|
|
document = read_glb_json(path)
|
|
binary = read_glb_binary(path)
|
|
nodes = document.get("nodes", [])
|
|
animations = document.get("animations", [])
|
|
head_chains = (
|
|
{f"BEUD_LNeck{index}" for index in range(1, 7)} | {"BEUD_LHead"},
|
|
{f"BEUD_Neck{index}" for index in range(1, 7)} | {"BEUD_Head"},
|
|
{f"BEUD_RNeck{index}" for index in range(1, 7)} | {"BEUD_RHead"},
|
|
)
|
|
animated_wings = {
|
|
f"BEUD_{side}Wing{index}"
|
|
for side in ("L", "R")
|
|
for index in range(1, 4)
|
|
}
|
|
static_wings = {
|
|
f"BEUD_{side}Wing{index}"
|
|
for side in ("L", "R")
|
|
for index in range(4, 9)
|
|
}
|
|
head_tips = {"BEUD_LHead", "BEUD_Head", "BEUD_RHead"}
|
|
for animation in animations:
|
|
variation = {}
|
|
rotation_values = {}
|
|
for channel in animation.get("channels", []):
|
|
if channel["target"].get("path") != "rotation":
|
|
continue
|
|
name = nodes[channel["target"]["node"]].get("name", "")
|
|
accessor_index = animation["samplers"][channel["sampler"]]["output"]
|
|
values = float_accessor(document, binary, accessor_index)
|
|
rotation_values[name] = values
|
|
variation[name] = values_vary(values)
|
|
for chain in head_chains:
|
|
static = {name for name in chain if not variation.get(name, False)}
|
|
if static:
|
|
raise RuntimeError(f"{path}: {animation.get('name')} has static head-chain bones {sorted(static)}")
|
|
static_roots = {name for name in animated_wings if not variation.get(name, False)}
|
|
moving_tips = {name for name in static_wings if variation.get(name, False)}
|
|
if static_roots or moving_tips:
|
|
raise RuntimeError(
|
|
f"{path}: {animation.get('name')} static wing roots {sorted(static_roots)}, "
|
|
f"moving wing tips {sorted(moving_tips)}"
|
|
)
|
|
if animation.get("name") in {"Idle", "Move", "Attack"}:
|
|
peak_frames = {}
|
|
for name in head_tips:
|
|
values = rotation_values[name]
|
|
start = values[0]
|
|
distances = [1 - min(1.0, abs(sum(component * start[index] for index, component in enumerate(value)))) for value in values]
|
|
peak_frames[name] = distances.index(max(distances))
|
|
if len(set(peak_frames.values())) != len(head_tips):
|
|
raise RuntimeError(f"{path}: {animation.get('name')} head cadences overlap at {peak_frames}")
|
|
return {
|
|
"headChainBonesPerClip": sum(len(chain) for chain in head_chains),
|
|
"wingBonesPerClip": len(animated_wings),
|
|
"staggeredHeadClips": 3,
|
|
}
|
|
|
|
|
|
def reset_scene() -> None:
|
|
if bpy.context.object and bpy.context.object.mode != "OBJECT":
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
for obj in list(bpy.data.objects):
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
for action in list(bpy.data.actions):
|
|
bpy.data.actions.remove(action)
|
|
for image in list(bpy.data.images):
|
|
bpy.data.images.remove(image)
|
|
|
|
|
|
def validate_red_eyes_motion(rig: bpy.types.Object) -> dict[str, float]:
|
|
"""Regression-check the paired flap and bite silhouette in the shipped GLB."""
|
|
rig.animation_data.action = bpy.data.actions["Attack"]
|
|
|
|
def sample(frame: int) -> dict[str, Vector]:
|
|
bpy.context.scene.frame_set(frame)
|
|
bpy.context.view_layer.update()
|
|
return {
|
|
"wing_l": rig.pose.bones["bone0079"].tail.copy(),
|
|
"wing_r": rig.pose.bones["bone0075"].tail.copy(),
|
|
"head": rig.pose.bones["bone0073"].tail.copy(),
|
|
}
|
|
|
|
windup = sample(8)
|
|
downstroke = sample(14)
|
|
impact = sample(17)
|
|
left_flap = windup["wing_l"].z - downstroke["wing_l"].z
|
|
right_flap = windup["wing_r"].z - downstroke["wing_r"].z
|
|
head_drop = windup["head"].z - impact["head"].z
|
|
head_lunge = windup["head"].y - impact["head"].y
|
|
if left_flap < 0.2 or right_flap < 0.2:
|
|
raise RuntimeError(f"Red-Eyes wings do not complete a paired flap: {left_flap=:.4f}, {right_flap=:.4f}")
|
|
if head_drop < 0.01 or head_lunge < 0.05:
|
|
raise RuntimeError(f"Red-Eyes bite lacks head lift/lunge: {head_drop=:.4f}, {head_lunge=:.4f}")
|
|
return {
|
|
"leftWingDrop": round(left_flap, 4),
|
|
"rightWingDrop": round(right_flap, 4),
|
|
"headDrop": round(head_drop, 4),
|
|
"headLunge": round(head_lunge, 4),
|
|
}
|
|
|
|
|
|
def validate(path: Path) -> dict[str, object]:
|
|
reset_scene()
|
|
bpy.context.scene.render.fps = 30
|
|
bpy.ops.import_scene.gltf(filepath=str(path))
|
|
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
|
|
meshes = [
|
|
obj for obj in bpy.context.scene.objects
|
|
if obj.type == "MESH" and any(
|
|
modifier.type == "ARMATURE" and modifier.object in armatures for modifier in obj.modifiers
|
|
)
|
|
]
|
|
clips = {action.name: tuple(round(value, 3) for value in action.frame_range) for action in bpy.data.actions}
|
|
if len(armatures) != 1:
|
|
raise RuntimeError(f"{path}: expected one armature, found {len(armatures)}")
|
|
if not meshes:
|
|
raise RuntimeError(f"{path}: no render meshes")
|
|
if set(clips) != EXPECTED_CLIPS:
|
|
raise RuntimeError(f"{path}: clips {sorted(clips)} != {sorted(EXPECTED_CLIPS)}")
|
|
if any(end <= start for start, end in clips.values()):
|
|
raise RuntimeError(f"{path}: empty animation range in {clips}")
|
|
if not bpy.data.images:
|
|
raise RuntimeError(f"{path}: no packed texture images")
|
|
corners = [mesh.matrix_world @ Vector(corner) for mesh in meshes for corner in mesh.bound_box]
|
|
dimensions = [round(max(point[index] for point in corners) - min(point[index] for point in corners), 4) for index in range(3)]
|
|
report = {
|
|
"asset": path.parent.name,
|
|
"sizeBytes": path.stat().st_size,
|
|
"bones": len(armatures[0].data.bones),
|
|
"meshes": len(meshes),
|
|
"images": len(bpy.data.images),
|
|
"dimensions": dimensions,
|
|
"clips": clips,
|
|
}
|
|
if path.parent.name == "pumpking-the-king-of-ghosts":
|
|
report["tentacleBonesPerClip"] = validate_pumpking_tentacles(path)
|
|
if path.parent.name == "blue-eyes-ultimate-dragon":
|
|
report.update(validate_ultimate_dragon(path))
|
|
if path.parent.name == "red-eyes-black-dragon":
|
|
report["motion"] = validate_red_eyes_motion(armatures[0])
|
|
return report
|
|
|
|
|
|
def main() -> None:
|
|
paths = sorted(MODEL_ROOT.glob("*/*-animated.glb"))
|
|
if len(paths) != 7:
|
|
raise RuntimeError(f"Expected 7 generated GLBs, found {len(paths)}")
|
|
report = [validate(path) for path in paths]
|
|
print("YUGIOH_ANIMATION_VALIDATION=" + json.dumps(report, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|