"""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(" bytes: data = path.read_bytes() json_length, json_type = struct.unpack_from(" 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()