"""Build animation-ready GLBs for the locally-authored inspired creature set. The legacy source FBX files use ASCII encoding, which modern Blender cannot read. Assimp converts each DAE to glTF without changing the source assets; Blender then adds reusable actions and exports a texture-packed GLB beside each source model. """ from __future__ import annotations from dataclasses import dataclass import json import math from pathlib import Path import re import shutil import subprocess import sys import tempfile import bpy from mathutils import Vector ROOT = Path(__file__).resolve().parents[2] MODEL_ROOT = ROOT / "game_assets/models/shelved/yugioh" FPS = 30 CLIP_SPECS = { "Idle": {"frames": 60, "loop": True}, "Move": {"frames": 36, "loop": True}, "Attack": {"frames": 36, "loop": False}, "HitReact": {"frames": 24, "loop": False}, "Death": {"frames": 72, "loop": False}, } @dataclass(frozen=True) class AssetProfile: asset_id: str display_name: str body: tuple[str, ...] creature: str red_eyes_roles: bool = False PROFILES = ( AssetProfile("blue-eyes-ultimate-dragon", "Tri-Headed Ivory Dragon", ("BEUD_UpperBody", "BEUD_Lumbar1"), "dragon"), AssetProfile("blue-eyes-white-dragon", "Ivory-Eyed Sky Dragon", ("MMD_004007_UpperBody", "MMD_004007_Lumbar1"), "dragon"), AssetProfile("gandora-the-dragon-of-destruction", "Ruin-Orb Black Dragon", ("UpperBody", "Lumber1"), "dragon"), AssetProfile("gate-guardian", "Tri-Element Gate Sentinel", ("Spine", "Spine1", "Spine2"), "guardian"), AssetProfile("insect-queen", "Royal Carapace Matriarch", ("UpperBody", "Spine1"), "insect"), AssetProfile("pumpking-the-king-of-ghosts", "Crowned Vine Wraith", ("hips", "upper_01", "upper_02"), "wraith"), AssetProfile("red-eyes-black-dragon", "Crimson-Eyed Night Dragon", ("bone0000", "bone0076", "bone0009"), "dragon", True), ) RED_EYES = { "body": ("bone0000", "bone0076", "bone0009", "bone0078", "bone0057"), "head": ("bone0025", "bone0031", "bone0047", "bone0073"), "jaw": ("bone0001", "bone0002", "bone0029", "bone0010", "bone0020"), "wing_l": ("bone0016", "bone0006", "bone0049", "bone0032", "bone0003", "bone0058", "bone0008", "bone0063", "bone0079", "bone0069", "bone0013", "bone0030", "bone0077"), "wing_r": ("bone0033", "bone0012", "bone0055", "bone0046", "bone0021", "bone0045", "bone0041", "bone0060", "bone0075", "bone0070", "bone0042", "bone0024", "bone0040"), "arm_l": ("bone0048", "bone0061", "bone0005", "bone0015", "bone0038", "bone0017", "bone0028", "bone0023"), "arm_r": ("bone0059", "bone0027", "bone0071", "bone0022", "bone0052", "bone0064", "bone0056", "bone0074", "bone0067"), "leg_l": ("bone0004", "bone0039", "bone0051", "bone0014", "bone0035", "bone0050", "bone0066"), "leg_r": ("bone0007", "bone0043", "bone0011", "bone0036", "bone0026", "bone0037", "bone0062"), "tail": ("bone0034", "bone0053", "bone0019", "bone0044", "bone0054", "bone0065", "bone0072", "bone0018"), } ULTIMATE_HEAD_CHAINS = { "head_l": tuple(f"BEUD_LNeck{index}" for index in range(1, 7)) + ("BEUD_LHead",), "head_c": tuple(f"BEUD_Neck{index}" for index in range(1, 7)) + ("BEUD_Head",), "head_r": tuple(f"BEUD_RNeck{index}" for index in range(1, 7)) + ("BEUD_RHead",), } 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 blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.images, bpy.data.actions, bpy.data.cameras, bpy.data.lights): for block in list(blocks): blocks.remove(block) def parse_smd_bones(path: Path, count: int) -> list[str]: nodes: list[tuple[int, str, int]] = [] in_nodes = False for line in path.read_text(errors="replace").splitlines(): if line == "nodes": in_nodes = True continue if in_nodes and line == "end": break if not in_nodes: continue match = re.fullmatch(r'(\d+)\s+"(.+)"\s+(-?\d+)', line) if match: nodes.append((int(match.group(1)), match.group(2), int(match.group(3)))) if len(nodes) < count: raise RuntimeError(f"{path}: found {len(nodes)} nodes for {count} Blender bones") return [name for _, name, _ in nodes[-count:]] def convert_and_import(profile: AssetProfile) -> tuple[bpy.types.Object, list[bpy.types.Object], Path]: source_dir = MODEL_ROOT / profile.asset_id dae_path = next(source_dir.glob("MMD_*.dae")) smd_path = dae_path.with_suffix(".smd") with tempfile.TemporaryDirectory(prefix=f"thor-{profile.asset_id}-") as temp_dir_name: temp_dir = Path(temp_dir_name) gltf_path = temp_dir / "model.gltf" subprocess.run( ["assimp", "export", str(dae_path), str(gltf_path), "-f", "gltf2"], check=True, capture_output=True, text=True, ) for image_path in source_dir.glob("*.png"): shutil.copy2(image_path, temp_dir / image_path.name) bpy.ops.import_scene.gltf(filepath=str(gltf_path), import_pack_images=True) armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"] if len(armatures) != 1: raise RuntimeError(f"{profile.asset_id}: expected one armature") rig = armatures[0] meshes = [ obj for obj in bpy.context.scene.objects if obj.type == "MESH" and ( obj.parent == rig or any(modifier.type == "ARMATURE" and modifier.object == rig for modifier in obj.modifiers) ) ] if not meshes: raise RuntimeError(f"{profile.asset_id}: expected one armature and at least one mesh") for helper in [obj for obj in bpy.context.scene.objects if obj.type == "MESH" and obj not in meshes]: bpy.data.objects.remove(helper, do_unlink=True) source_names = parse_smd_bones(smd_path, len(rig.data.bones)) old_names = [bone.name for bone in rig.data.bones] for old_name, source_name in zip(old_names, source_names, strict=True): rig.data.bones[old_name].name = source_name for mesh in meshes: group = mesh.vertex_groups.get(old_name) if group: group.name = source_name rig.name = f"{profile.asset_id}_Rig" rig.data.name = f"{profile.asset_id}_Armature" for index, mesh in enumerate(meshes): mesh.name = f"{profile.asset_id}_Mesh_{index:02d}" mesh.data.name = f"{profile.asset_id}_Geometry_{index:02d}" for action in list(bpy.data.actions): bpy.data.actions.remove(action) rig.animation_data_clear() return rig, meshes, dae_path def side(name: str) -> str | None: lower = name.lower() if "left" in lower or re.search(r"(^|[_\.])l(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower): return "l" if "right" in lower or re.search(r"(^|[_\.])r(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower): return "r" if lower.startswith("l") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")): return "l" if lower.startswith("r") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")): return "r" return None def build_roles(profile: AssetProfile, rig: bpy.types.Object) -> dict[str, list[str]]: names = [bone.name for bone in rig.data.bones] if profile.red_eyes_roles: return {role: [name for name in role_names if name in names] for role, role_names in RED_EYES.items()} roles = {key: [] for key in ("body", "head", "jaw", "wing_l", "wing_r", "arm_l", "arm_r", "leg_l", "leg_r", "tail")} roles["body"] = [name for name in profile.body if name in names] for name in names: lower = name.lower() bone_side = side(name) if "wing" in lower: if bone_side: roles[f"wing_{bone_side}"].append(name) elif any(token in lower for token in ("arm", "hand", "shoulder", "bicep", "forearm")): if bone_side: roles[f"arm_{bone_side}"].append(name) elif any(token in lower for token in ("foot", "thigh", "shin", "upleg")) or (profile.creature == "insect" and "foot" in lower): if bone_side: roles[f"leg_{bone_side}"].append(name) if "tail" in lower: roles["tail"].append(name) if "head" in lower or (profile.creature == "insect" and lower in {"neck1", "neck2"}): roles["head"].append(name) if any(token in lower for token in ("jaw", "chin", "mandible", "mouth")): roles["jaw"].append(name) if profile.creature == "wraith": roles["arm_l"] = [name for name in names if name.lower().startswith("l_arm")] roles["arm_r"] = [name for name in names if name.lower().startswith("r_arm")] roles["head"].extend(name for name in ("eye", "crown", "eye_upper", "eye_lower") if name in names) roles["jaw"].extend(name for name in ("chin_upper", "chin_lower", "under") if name in names) if profile.creature == "insect": roles["head"].extend(name for name in names if "sensor" in name.lower()) if profile.asset_id == "blue-eyes-ultimate-dragon": for role, chain in ULTIMATE_HEAD_CHAINS.items(): roles[role] = [name for name in chain if name in names] return {role: list(dict.fromkeys(role_names)) for role, role_names in roles.items()} def rotate_role(roles: dict[str, list[str]], role: str, degrees: tuple[float, float, float], limit: int = 99, falloff: float = 0.72) -> dict[str, tuple[float, float, float]]: result = {} for index, name in enumerate(roles.get(role, [])[:limit]): factor = falloff ** index result[name] = tuple(component * factor for component in degrees) return result def merge(*maps: dict[str, tuple[float, float, float]]) -> dict[str, tuple[float, float, float]]: merged: dict[str, tuple[float, float, float]] = {} for values in maps: for name, rotation in values.items(): previous = merged.get(name, (0.0, 0.0, 0.0)) merged[name] = tuple(previous[index] + rotation[index] for index in range(3)) return merged def dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]: neutral: dict[str, tuple[float, float, float]] = {} wings_up = merge(rotate_role(roles, "wing_l", (0, -14, 18), 5), rotate_role(roles, "wing_r", (0, 14, -18), 5)) wings_down = merge(rotate_role(roles, "wing_l", (0, 18, -14), 5), rotate_role(roles, "wing_r", (0, -18, 14), 5)) tail_l = rotate_role(roles, "tail", (0, 0, 8), 8, 0.82) tail_r = rotate_role(roles, "tail", (0, 0, -8), 8, 0.82) legs_a = merge(rotate_role(roles, "leg_l", (10, 0, 0), 2), rotate_role(roles, "leg_r", (-10, 0, 0), 2)) legs_b = merge(rotate_role(roles, "leg_l", (-10, 0, 0), 2), rotate_role(roles, "leg_r", (10, 0, 0), 2)) return { "Idle": [(1, neutral), (16, merge(wings_up, tail_l, rotate_role(roles, "head", (2, 0, 0), 3))), (31, neutral), (46, merge(wings_down, tail_r, rotate_role(roles, "head", (-2, 0, 0), 3))), (60, neutral)], "Move": [(1, merge(wings_up, legs_a, tail_l)), (10, neutral), (19, merge(wings_down, legs_b, tail_r)), (28, neutral), (36, merge(wings_up, legs_a, tail_l))], "Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), wings_up, rotate_role(roles, "head", (-14, 0, 0), 4), rotate_role(roles, "jaw", (-8, 0, 0), 3))), (16, merge(rotate_role(roles, "body", (17, 0, 0), 2), wings_down, rotate_role(roles, "head", (24, 0, 0), 5), rotate_role(roles, "jaw", (24, 0, 0), 4), rotate_role(roles, "arm_l", (18, 0, -14), 3), rotate_role(roles, "arm_r", (18, 0, 14), 3))), (25, merge(rotate_role(roles, "head", (5, 0, 0), 4), rotate_role(roles, "jaw", (8, 0, 0), 3))), (36, neutral)], "HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), rotate_role(roles, "head", (-22, 0, -10), 5), wings_up)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)], "Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), rotate_role(roles, "head", (18, 0, 8), 5), wings_down)), (44, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (28, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (30, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5)))], } def ultimate_head_pose( roles: dict[str, list[str]], left: tuple[float, float, float], center: tuple[float, float, float], right: tuple[float, float, float], ) -> dict[str, tuple[float, float, float]]: """Bend each long neck as a chain; rotating only head bones reads as static.""" result: dict[str, tuple[float, float, float]] = {} weights = (0.10, 0.14, 0.17, 0.18, 0.16, 0.12, 0.22) for role, motion in zip(("head_l", "head_c", "head_r"), (left, center, right), strict=True): pitch, yaw, roll = motion for index, name in enumerate(roles.get(role, [])): weight = weights[min(index, len(weights) - 1)] result[name] = (roll * weight, pitch * weight, yaw * weight) return result def ultimate_living_heads( roles: dict[str, list[str]], progress: float, intensity: float = 1.0, ) -> dict[str, tuple[float, float, float]]: """Use distinct harmonic timing so three heads never bob in lockstep.""" phase = math.tau * progress left = ( intensity * (2.8 * math.sin(phase) + 0.8 * math.sin(phase * 2 + 0.35)), intensity * (2.0 * math.sin(phase * 2 + 0.75)), intensity * 0.8 * math.sin(phase + 0.2), ) center = ( intensity * (2.5 * math.sin(phase + 2.05) + 0.65 * math.sin(phase * 3 + 0.25)), intensity * (1.5 * math.sin(phase * 3 + 1.4)), intensity * 0.65 * math.sin(phase + 2.6), ) right = ( intensity * (2.7 * math.sin(phase + 4.15) + 0.75 * math.sin(phase * 2 + 1.15)), intensity * (1.9 * math.sin(phase * 2 + 3.7)), intensity * 0.75 * math.sin(phase + 4.55), ) return ultimate_head_pose(roles, left, center, right) def ultimate_wings(flare: float) -> dict[str, tuple[float, float, float]]: """Flap around local Z only; local Y sweeps these wings around body.""" result: dict[str, tuple[float, float, float]] = {} for prefix, sign in (("BEUD_LWing", 1), ("BEUD_RWing", -1)): for index, weight in ((1, 1.0), (2, 0.34), (3, 0.12)): result[f"{prefix}{index}"] = (0.0, 0.0, sign * flare * weight) return result def ultimate_jaws(left: float, center: float, right: float) -> dict[str, tuple[float, float, float]]: """Jaw hinges use local Y; local X would twist along snout.""" return { "BEUD_LChin": (0.0, left, 0.0), "BEUD_Chin": (0.0, center, 0.0), "BEUD_RChin": (0.0, right, 0.0), } def ultimate_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]: idle = [] for frame in (1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56, 60): progress = (frame - 1) / 59 idle.append((frame, merge( ultimate_living_heads(roles, progress), ultimate_wings(1.8 * math.sin(math.tau * progress)), rotate_role(roles, "tail", (0, 0, 4.0 * math.sin(math.tau * progress + 0.6)), 8, 0.82), ))) move = [] for frame in (1, 5, 9, 13, 17, 21, 25, 29, 33, 36): progress = (frame - 1) / 35 stride = math.sin(math.tau * progress) move.append((frame, merge( ultimate_living_heads(roles, progress, 1.2), ultimate_wings(7.0 * stride), rotate_role(roles, "tail", (0, 0, 5.5 * math.sin(math.tau * progress + 0.8)), 8, 0.82), rotate_role(roles, "leg_l", (5.0 * stride, 0, 0), 2), rotate_role(roles, "leg_r", (-5.0 * stride, 0, 0), 2), ))) attack_specs = ( (1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0), (6, (-4, 2, -1), (-2, -1, 0), (-1, -2, 1), (0, 0, 0), 2), (10, (-8, 3, -1), (-5, 0, 0), (-3, -3, 1), (4, 0, 0), 4), (14, (11, -2, 1), (-8, 1, 0), (-5, -1, 1), (24, 3, 0), -3), (17, (6, 1, 0), (12, 0, 0), (-8, 2, -1), (8, 25, 3), -4), (21, (3, -1, 0), (7, -1, 0), (13, 2, -1), (2, 9, 25), -2), (28, (-1, 1, 0), (2, 0, 0), (5, -1, 0), (0, 2, 8), 1), (36, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0), ) attack = [ (frame, merge( ultimate_head_pose(roles, left, center, right), ultimate_jaws(*jaws), ultimate_wings(wing), )) for frame, left, center, right, jaws, wing in attack_specs ] hit_specs = ( (1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0), (4, (-10, -3, 2), (-6, 2, -1), (-4, 3, -2), (-14, 0, 10), 4), (7, (-5, 2, -1), (-11, -2, 1), (-7, -3, 2), (-8, 0, 6), 2), (11, (3, -1, 0), (-4, 1, 0), (-10, 2, -1), (5, 0, -4), -1), (16, (-1, 1, 0), (3, -1, 0), (-2, 0, 0), (2, 0, -2), 0), (24, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0), ) hit_react = [ (frame, merge( ultimate_head_pose(roles, left, center, right), rotate_role(roles, "body", body, 2), ultimate_wings(wing), )) for frame, left, center, right, body, wing in hit_specs ] death_specs = ( (1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0), (14, (-5, -3, 2), (-3, 2, -1), (-7, 2, -2), (-10, 0, 8), 2), (25, (-14, -7, 4), (-8, 4, -2), (-18, 6, -5), (-16, 8, 18), -3), (42, (-23, -10, 7), (-17, 6, -4), (-28, 9, -8), (8, 48, 58), -7), (58, (-30, -13, 9), (-24, 8, -6), (-34, 11, -10), (10, 62, 70), -9), (72, (-32, -14, 10), (-27, 9, -7), (-36, 12, -11), (10, 62, 70), -9), ) death = [ (frame, merge( ultimate_head_pose(roles, left, center, right), rotate_role(roles, "body", body, 2), ultimate_wings(wing), )) for frame, left, center, right, body, wing in death_specs ] return {"Idle": idle, "Move": move, "Attack": attack, "HitReact": hit_react, "Death": death} def red_eyes_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]: """Author Red-Eyes motion around its unusually oriented local bone axes. This rig's wing bones do not share the local axes used by the named dragon rigs. Rotating every wing segment as one role makes each membrane chain curl forward like an arm. Instead, rotate the two shoulder roots in mirrored armature-space directions and add only a small elbow fold. Both sides use identical keyframe timing so they read as one aerodynamic flap. """ neutral: dict[str, tuple[float, float, float]] = {} wings_up = { "bone0016": (38.0, 32.8, 38.3), "bone0033": (-38.0, -32.8, 38.3), "bone0006": (-11.3, 14.0, -0.4), "bone0012": (11.3, -14.0, -0.4), } wings_down = { "bone0016": (-12.8, -28.5, -13.1), "bone0033": (12.8, 28.5, -13.1), "bone0006": (7.6, -9.3, -1.3), "bone0012": (-7.6, 9.3, -1.3), } wings_recover = { "bone0016": (15.5, 16.0, 15.7), "bone0033": (-15.5, -16.0, 15.7), "bone0006": (-5.0, 6.0, -0.2), "bone0012": (5.0, -6.0, -0.2), } tail_l = rotate_role(roles, "tail", (0, 0, 7), 5, 0.78) tail_r = rotate_role(roles, "tail", (0, 0, -7), 5, 0.78) legs_a = merge(rotate_role(roles, "leg_l", (8, 0, 0), 2), rotate_role(roles, "leg_r", (-8, 0, 0), 2)) legs_b = merge(rotate_role(roles, "leg_l", (-8, 0, 0), 2), rotate_role(roles, "leg_r", (8, 0, 0), 2)) # The neck runs forward/down in armature space. Its local Z axis is the # pitch axis: negative lifts the head for anticipation; positive drives the # snout down through the bite. Jaw roots open during the windup, then close # at impact instead of swinging the forelimbs as the attack silhouette. head_lift = {"bone0025": (0, 0, 45), "bone0031": (0, 0, 15)} head_bite = {"bone0025": (0, 0, -32), "bone0031": (0, 0, -10)} head_follow_through = {"bone0025": (0, 0, -18), "bone0031": (0, 0, -6)} jaw_open = {"bone0001": (24, 0, 0), "bone0029": (24, 0, 0), "bone0020": (20, 0, 0)} jaw_closed = {"bone0001": (-4, 0, 0), "bone0029": (-4, 0, 0), "bone0020": (-3, 0, 0)} return { "Idle": [ (1, neutral), (14, wings_up), (28, neutral), (42, wings_down), (54, wings_recover), (60, neutral), ], "Move": [ (1, merge(wings_up, legs_a, tail_l)), (9, neutral), (18, merge(wings_down, legs_b, tail_r)), (27, neutral), (36, merge(wings_up, legs_a, tail_l)), ], "Attack": [ (1, neutral), (8, merge(wings_up, head_lift, jaw_open)), (14, merge(wings_down, head_bite, jaw_open)), (17, merge(wings_down, head_bite, jaw_closed)), (23, merge(wings_recover, head_follow_through)), (30, neutral), (36, neutral), ], "HitReact": [ (1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 2), head_lift, wings_up)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral), ], "Death": [ (1, neutral), (18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), head_follow_through, wings_down)), (44, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down, rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down)), ], } def guardian_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]: neutral: dict[str, tuple[float, float, float]] = {} arms_open = merge(rotate_role(roles, "arm_l", (0, -8, -12), 3), rotate_role(roles, "arm_r", (0, 8, 12), 3)) arms_close = merge(rotate_role(roles, "arm_l", (0, 5, 8), 3), rotate_role(roles, "arm_r", (0, -5, -8), 3)) step_a = merge(rotate_role(roles, "leg_l", (-20, 0, 0), 3), rotate_role(roles, "leg_r", (20, 0, 0), 3), rotate_role(roles, "arm_l", (14, 0, 0), 3), rotate_role(roles, "arm_r", (-14, 0, 0), 3)) step_b = merge(rotate_role(roles, "leg_l", (20, 0, 0), 3), rotate_role(roles, "leg_r", (-20, 0, 0), 3), rotate_role(roles, "arm_l", (-14, 0, 0), 3), rotate_role(roles, "arm_r", (14, 0, 0), 3)) return { "Idle": [(1, neutral), (16, merge(arms_open, rotate_role(roles, "body", (2, 0, 0), 2))), (31, neutral), (46, merge(arms_close, rotate_role(roles, "body", (-2, 0, 0), 2))), (60, neutral)], "Move": [(1, step_a), (10, neutral), (19, step_b), (28, neutral), (36, step_a)], "Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (0, -18, -8), 3), rotate_role(roles, "arm_r", (-35, 15, 26), 5))), (15, merge(rotate_role(roles, "body", (8, 20, 8), 3), rotate_role(roles, "arm_r", (42, -12, -35), 5), rotate_role(roles, "arm_l", (12, 0, 8), 3), rotate_role(roles, "jaw", (16, 0, 0), 2))), (25, rotate_role(roles, "arm_r", (8, 0, -8), 4)), (36, neutral)], "HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), arms_open)), (13, merge(rotate_role(roles, "body", (8, 0, -6), 2), arms_close)), (24, neutral)], "Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-24, 0, 18), 3), arms_open)), (44, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (24, 0, -38), 5), rotate_role(roles, "arm_r", (-18, 0, 34), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (26, 0, -40), 5), rotate_role(roles, "arm_r", (-20, 0, 36), 5)))], } def insect_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]: neutral: dict[str, tuple[float, float, float]] = {} legs_a = merge(rotate_role(roles, "leg_l", (12, 0, -10), 4), rotate_role(roles, "leg_r", (-12, 0, 10), 4)) legs_b = merge(rotate_role(roles, "leg_l", (-12, 0, 10), 4), rotate_role(roles, "leg_r", (12, 0, -10), 4)) wings_up = merge(rotate_role(roles, "wing_l", (0, -18, 20), 3), rotate_role(roles, "wing_r", (0, 18, -20), 3)) wings_down = merge(rotate_role(roles, "wing_l", (0, 22, -16), 3), rotate_role(roles, "wing_r", (0, -22, 16), 3)) return { "Idle": [(1, neutral), (16, merge(wings_up, rotate_role(roles, "head", (0, 0, 5), 4))), (31, neutral), (46, merge(wings_down, rotate_role(roles, "head", (0, 0, -5), 4))), (60, neutral)], "Move": [(1, merge(legs_a, wings_up)), (10, neutral), (19, merge(legs_b, wings_down)), (28, neutral), (36, merge(legs_a, wings_up))], "Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), rotate_role(roles, "arm_l", (-20, 0, -20), 3), rotate_role(roles, "arm_r", (-20, 0, 20), 3), wings_up)), (15, merge(rotate_role(roles, "body", (18, 0, 0), 2), rotate_role(roles, "head", (22, 0, 0), 4), rotate_role(roles, "jaw", (25, 0, 0), 4), rotate_role(roles, "arm_l", (28, 0, 22), 3), rotate_role(roles, "arm_r", (28, 0, -22), 3))), (25, rotate_role(roles, "jaw", (8, 0, 0), 3)), (36, neutral)], "HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), wings_up, legs_b)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)], "Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-18, 0, 16), 3), wings_down)), (44, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5), rotate_role(roles, "wing_l", (0, 32, -28), 3), rotate_role(roles, "wing_r", (0, -32, 28), 3))), (72, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5)))], } def wraith_tentacles( roles: dict[str, list[str]], sweep: float, curl: float, lift: float = 0, phase: float = 0, ) -> dict[str, tuple[float, float, float]]: """Pose every independently rigged vine chain instead of one flattened pair.""" result: dict[str, tuple[float, float, float]] = {} for role, side_sign in (("arm_l", -1), ("arm_r", 1)): chains: dict[str, list[str]] = {} for name in roles.get(role, []): match = re.match(r"([LR]_arm\d+)_", name, re.IGNORECASE) if match: chains.setdefault(match.group(1).lower(), []).append(name) for chain_index, chain_name in enumerate(sorted(chains)): chain = chains[chain_name] pair_direction = -1 if chain_index % 2 else 1 chain_phase = phase + chain_index * 0.85 for bone_index, name in enumerate(chain): progress = bone_index / max(1, len(chain) - 1) tip_weight = 0.38 + progress * 0.62 wave = math.sin(chain_phase + progress * math.pi * 1.65) result[name] = ( lift * pair_direction * (1 - progress * 0.45), side_sign * sweep * tip_weight * 0.42, side_sign * (curl * wave + sweep * pair_direction * 0.22) * tip_weight, ) return result def wraith_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]: neutral: dict[str, tuple[float, float, float]] = {} idle_a = wraith_tentacles(roles, sweep=3.5, curl=5.5, lift=1.5, phase=0.2) idle_b = wraith_tentacles(roles, sweep=-3.5, curl=5.5, lift=-1.5, phase=math.pi + 0.2) drift_a = wraith_tentacles(roles, sweep=6, curl=8, lift=2.5, phase=0.65) drift_b = wraith_tentacles(roles, sweep=-6, curl=8, lift=-2.5, phase=math.pi + 0.65) windup = wraith_tentacles(roles, sweep=-8, curl=10, lift=-4, phase=1.1) lash = wraith_tentacles(roles, sweep=15, curl=17, lift=7, phase=2.35) recoil = wraith_tentacles(roles, sweep=-11, curl=13, lift=-6, phase=1.75) collapse = wraith_tentacles(roles, sweep=-14, curl=7, lift=18, phase=math.pi * 0.5) return { "Idle": [(1, neutral), (16, merge(idle_a, rotate_role(roles, "body", (0, 0, 2), 3))), (31, neutral), (46, merge(idle_b, rotate_role(roles, "body", (0, 0, -2), 3))), (60, neutral)], "Move": [(1, drift_a), (10, merge(idle_a, rotate_role(roles, "body", (0, 0, 5), 3))), (19, drift_b), (28, merge(idle_b, rotate_role(roles, "body", (0, 0, -5), 3))), (36, drift_a)], "Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-12, 0, 0), 3), windup)), (16, merge(rotate_role(roles, "body", (18, 0, 0), 3), lash, rotate_role(roles, "jaw", (24, 0, 0), 3))), (25, drift_a), (36, neutral)], "HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), recoil)), (13, merge(rotate_role(roles, "body", (7, 0, -6), 3), idle_a)), (24, neutral)], "Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-22, 0, 20), 3), recoil)), (44, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse, rotate_role(roles, "jaw", (30, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse))], } def create_actions(profile: AssetProfile, rig: bpy.types.Object, roles: dict[str, list[str]]) -> dict[str, dict[str, object]]: pose_factory = { "dragon": dragon_poses, "guardian": guardian_poses, "insect": insect_poses, "wraith": wraith_poses, }[profile.creature] if profile.asset_id == "blue-eyes-ultimate-dragon": definitions = ultimate_dragon_poses(roles) elif profile.red_eyes_roles: definitions = red_eyes_dragon_poses(roles) else: definitions = pose_factory(roles) rig.animation_data_create() metadata = {} for clip_name, poses in definitions.items(): action = bpy.data.actions.new(clip_name) action.use_fake_user = True action.use_frame_range = True action.frame_start = 1 action.frame_end = CLIP_SPECS[clip_name]["frames"] action.use_cyclic = CLIP_SPECS[clip_name]["loop"] rig.animation_data.action = action animated_names = sorted({name for _, rotations in poses for name in rotations}) for frame, rotations in poses: for name in animated_names: bone = rig.pose.bones.get(name) if not bone: continue bone.rotation_mode = "XYZ" degrees = rotations.get(name, (0.0, 0.0, 0.0)) bone.rotation_euler = tuple(math.radians(component) for component in degrees) bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name) metadata[clip_name] = { "frames": CLIP_SPECS[clip_name]["frames"], "seconds": CLIP_SPECS[clip_name]["frames"] / FPS, "loop": CLIP_SPECS[clip_name]["loop"], } rig.animation_data.action = None for bone in rig.pose.bones: bone.rotation_euler = (0, 0, 0) return metadata def select_only(objects: list[bpy.types.Object]) -> None: bpy.ops.object.select_all(action="DESELECT") for obj in objects: obj.select_set(True) bpy.context.view_layer.objects.active = objects[0] def world_bounds(meshes: list[bpy.types.Object]) -> tuple[Vector, Vector]: depsgraph = bpy.context.evaluated_depsgraph_get() points: list[Vector] = [] for mesh in meshes: evaluated = mesh.evaluated_get(depsgraph) evaluated_mesh = evaluated.to_mesh() points.extend(evaluated.matrix_world @ vertex.co for vertex in evaluated_mesh.vertices) evaluated.to_mesh_clear() minimum = Vector(tuple(min(point[index] for point in points) for index in range(3))) maximum = Vector(tuple(max(point[index] for point in points) for index in range(3))) return minimum, maximum def look_at(obj: bpy.types.Object, target: Vector) -> None: obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler() def render_preview(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], out_path: Path, frame: int = 17) -> None: rig.animation_data.action = bpy.data.actions["Attack"] bpy.context.scene.frame_set(frame) bpy.context.view_layer.update() minimum, maximum = world_bounds(meshes) center = (minimum + maximum) * 0.5 radius = (maximum - minimum).length * 0.5 size = radius * 2 scene = bpy.context.scene scene.render.engine = "BLENDER_EEVEE" scene.render.resolution_x = 640 scene.render.resolution_y = 640 scene.render.resolution_percentage = 100 scene.render.image_settings.file_format = "PNG" scene.render.film_transparent = False scene.world.color = (0.008, 0.012, 0.02) camera_data = bpy.data.cameras.new("PreviewCamera") camera = bpy.data.objects.new("PreviewCamera", camera_data) bpy.context.collection.objects.link(camera) camera.data.lens = 65 view_direction = Vector((0.8, -1.05, 0.42)).normalized() half_fov = math.atan(camera.data.sensor_width / (2 * camera.data.lens)) camera.location = center + view_direction * (radius / math.sin(half_fov) * 0.45) look_at(camera, center) scene.camera = camera for name, offset, color, energy, radius in ( ("Key", (1.4, -1.2, 1.7), (1.0, 0.52, 0.28), 1000, 5.0), ("Fill", (-1.3, -0.4, 0.8), (0.22, 0.42, 1.0), 800, 4.0), ("Rim", (0.3, 1.4, 1.3), (0.65, 0.22, 1.0), 900, 3.5), ): light_data = bpy.data.lights.new(name, "AREA") light_data.energy = energy light_data.color = color light_data.shape = "DISK" light_data.size = size * radius light = bpy.data.objects.new(name, light_data) light.location = center + Vector(offset) * size bpy.context.collection.objects.link(light) look_at(light, center) out_path.parent.mkdir(parents=True, exist_ok=True) scene.render.filepath = str(out_path) bpy.ops.render.render(write_still=True) rig.animation_data.action = None def export_asset(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], source_path: Path, clips: dict[str, dict[str, object]], roles: dict[str, list[str]]) -> None: source_dir = MODEL_ROOT / profile.asset_id output_path = source_dir / f"{profile.asset_id}-animated.glb" select_only([rig, *meshes]) bpy.ops.export_scene.gltf( filepath=str(output_path), export_format="GLB", use_selection=True, export_animations=True, export_animation_mode="ACTIONS", export_frame_range=True, export_skins=True, export_morph=True, export_yup=True, export_apply=False, export_cameras=False, export_lights=False, export_image_format="AUTO", ) triangles = sum(len(polygon.vertices) - 2 for mesh in meshes for polygon in mesh.data.polygons) metadata = { "name": profile.display_name, "assetId": profile.asset_id, "source": source_path.name, "format": "glTF 2.0 binary (GLB)", "authoringTool": bpy.app.version_string, "fps": FPS, "trianglesApprox": triangles, "animations": clips, "animatedBones": {role: names for role, names in roles.items() if names}, } (source_dir / f"{profile.asset_id}-animated.asset.json").write_text(json.dumps(metadata, indent=2) + "\n") render_preview(profile, rig, meshes, source_dir / "previews" / f"{profile.asset_id}-attack.png") print(f"BUILT={profile.asset_id} BONES={len(rig.data.bones)} TRIANGLES={triangles} CLIPS={','.join(clips)}") def main() -> None: bpy.context.preferences.filepaths.save_version = 0 bpy.context.scene.render.fps = FPS requested_ids = set(sys.argv[sys.argv.index("--") + 1:]) if "--" in sys.argv else set() unknown_ids = requested_ids - {profile.asset_id for profile in PROFILES} if unknown_ids: raise RuntimeError(f"Unknown asset ids: {', '.join(sorted(unknown_ids))}") selected_profiles = [profile for profile in PROFILES if not requested_ids or profile.asset_id in requested_ids] for profile in selected_profiles: reset_scene() rig, meshes, source_path = convert_and_import(profile) roles = build_roles(profile, rig) clips = create_actions(profile, rig, roles) export_asset(profile, rig, meshes, source_path, clips, roles) if __name__ == "__main__": main()