Release v0.1.3 2026-07-11
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user