Release v0.1.3 2026-07-11

This commit is contained in:
Warren H
2026-07-11 23:23:02 -04:00
parent 076f6cf97c
commit b48b3a4f8f
103 changed files with 6708 additions and 454 deletions
+709
View File
@@ -0,0 +1,709 @@
"""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/downloaded/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()