straywild/tools/blender/export_character.py

657 lines
22 KiB
Python
Executable file

#!/usr/bin/env python3
"""Validate and export the authoritative straywild character as a GLB.
Run this script through Blender with the authoritative source file open:
blender --background straywild_character_source.blend \
--python tools/blender/export_character.py -- \
--output art/exported/characters/base/straywild_base_character.glb \
--verify-determinism
The exporter deliberately uses an explicit object manifest. It exports every
runtime character mesh even when the object is hidden in the authoring file,
while excluding the ``unused`` collection and the rod-socket preview mesh.
The loaded blend is only modified in memory and is never saved.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import struct
import sys
import tempfile
from pathlib import Path
import bpy
ARMATURE_NAME = "CharacterRig"
GODOT_SKELETON_NODE_PATH = "CharacterRig/Skeleton3D"
PREVIEW_MESH_NAME = "rod_socket_preview-noimp"
UNUSED_COLLECTION_NAME = "unused"
UNUSED_MESH_NAMES = {
"frog_eyes_1",
"frog_eyes_2",
"frog_head_1",
"frog_head_2",
}
EXPECTED_BONE_PARENTS = {
"root": None,
"hips": "root",
"spine": "hips",
"clavicle.L": "spine",
"upper_arm.L": "clavicle.L",
"forearm.L": "upper_arm.L",
"hand.L": "forearm.L",
"clavicle.R": "spine",
"upper_arm.R": "clavicle.R",
"forearm.R": "upper_arm.R",
"hand.R": "forearm.R",
"rod_socket": "hand.R",
"rod_socket.001": "rod_socket",
"neck": "spine",
"head": "neck",
"thigh.L": "hips",
"shin.L": "thigh.L",
"thigh.R": "hips",
"shin.R": "thigh.R",
}
HEAD_MESH_NAMES = (
"head_round",
"head_pointy",
"head_anteater",
"head_axolotl",
"head_bat",
"head_bird",
"head_boar",
"head_butterfly",
"head_donkey",
"head_elephant",
"head_goat",
"head_hamster",
"head_horse",
"head_moth",
"head_opossum",
"head_owl",
"head_panda",
"head_pig",
"head_shark",
"head_sheep",
"head_snail",
)
FACE_FEATURE_SUFFIXES = ("eyes", "nose", "mouth")
FACE_DECAL_MESH_NAMES = tuple(
f"{head_name}.{suffix}"
for head_name in HEAD_MESH_NAMES
for suffix in FACE_FEATURE_SUFFIXES
)
ARM_MESH_NAMES = ("arms_mammal", "arms_fish", "arms_avian")
EAR_MESH_NAMES = (
"ears_cat",
"ears_long",
"ears_fox",
"ears_bunny",
"ears_antlers_round",
"ears_bear",
"ears_anteater",
"ears_bat",
"ears_boar",
"ears_butterfly",
"ears_donkey",
"ears_axolotl",
"ears_elephant",
"ears_goat",
"ears_hamster",
"ears_horse",
"ears_opossum",
"ears_moth",
"ears_pig",
"ears_owl",
"ears_panda",
)
TAIL_MESH_NAMES = (
"tails_gator",
"tails_fox",
"tails_bunny",
"tails_cat",
"tails_bear",
"tails_pointy",
"tails_anteater",
"tails_boar",
"tails_butterfly",
"tail_donkey",
"tails_axolotl",
"tails_horse",
"tails_opossum",
"tails_moth",
"tails_pig",
"tails_shark",
"tails_bird",
)
SPECIAL_MESH_NAMES = (
"tusks_boar",
"tusks_elephant",
"wings_butterfly",
"wings_bat",
"wings_moth",
"horns_goat",
"horns_sheep",
"back_shell",
"head_fin",
"beak_owl",
"beak_bird",
)
RUNTIME_MESH_NAMES = {
"body_main",
*ARM_MESH_NAMES,
*HEAD_MESH_NAMES,
*FACE_DECAL_MESH_NAMES,
*EAR_MESH_NAMES,
*TAIL_MESH_NAMES,
*SPECIAL_MESH_NAMES,
}
SOURCE_ACTION_NAMES = {
"casting",
"casting_sit",
"draw",
"fighting_loop",
"fighting_sit_loop",
"fishing_loop",
"fishing_sit_loop",
"idle_loop",
"idle_show_loop",
"idle_sit_loop",
"idle_sit_show_loop",
"idle_sneak_loop",
"pocket_idle_idle",
"pocket_idle_show",
"pocket_show_idle",
"pocket_show_show",
"pocket_sit_idle_idle",
"pocket_sit_idle_show",
"pocket_sit_show_idle",
"pocket_sit_show_show",
"pocket_walking_idle_idle",
"pocket_walking_idle_show",
"pocket_walking_show_idle",
"pocket_walking_show_show",
"release",
"release_sit",
"retract",
"retract_sit",
"running_loop",
"running_show_loop",
"sneaking_loop",
"strike",
"walking_loop",
"walking_show_loop",
}
class ExportValidationError(RuntimeError):
"""Raised when the source or exported GLB violates the contract."""
def _parse_arguments() -> argparse.Namespace:
script_arguments: list[str] = []
if "--" in sys.argv:
script_arguments = sys.argv[sys.argv.index("--") + 1 :]
parser = argparse.ArgumentParser(
description="Export the authoritative straywild character to GLB."
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Runtime .glb file to write.",
)
parser.add_argument(
"--verify-determinism",
action="store_true",
help="Export twice and require byte-identical output.",
)
return parser.parse_args(script_arguments)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source_file:
for block in iter(lambda: source_file.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _validate_source() -> tuple[Path, bpy.types.Object]:
if not bpy.data.filepath:
raise ExportValidationError("Open a character .blend before exporting.")
source_path = Path(bpy.data.filepath).resolve()
armatures = [
item for item in bpy.data.objects if item.type == "ARMATURE"
]
if [item.name for item in armatures] != [ARMATURE_NAME]:
raise ExportValidationError(
"Expected only the CharacterRig armature; found "
f"{sorted(item.name for item in armatures)}."
)
armature = armatures[0]
actual_bone_parents = {
bone.name: bone.parent.name if bone.parent is not None else None
for bone in armature.data.bones
}
if set(actual_bone_parents) != set(EXPECTED_BONE_PARENTS):
missing = sorted(set(EXPECTED_BONE_PARENTS) - set(actual_bone_parents))
unexpected = sorted(set(actual_bone_parents) - set(EXPECTED_BONE_PARENTS))
raise ExportValidationError(
f"{ARMATURE_NAME} bone manifest mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
incorrect_bone_parents = {
bone_name: {
"expected": expected_parent,
"actual": actual_bone_parents[bone_name],
}
for bone_name, expected_parent in EXPECTED_BONE_PARENTS.items()
if actual_bone_parents[bone_name] != expected_parent
}
if incorrect_bone_parents:
raise ExportValidationError(
f"{ARMATURE_NAME} bone parent mismatch: "
f"{incorrect_bone_parents}."
)
unused_collection = bpy.data.collections.get(UNUSED_COLLECTION_NAME)
if unused_collection is None:
raise ExportValidationError("The exact unused collection is missing.")
unused_names = {item.name for item in unused_collection.all_objects}
if unused_names != UNUSED_MESH_NAMES:
raise ExportValidationError(
"The unused collection must contain exactly "
f"{sorted(UNUSED_MESH_NAMES)}; found {sorted(unused_names)}."
)
actual_mesh_names = {
item.name for item in bpy.data.objects if item.type == "MESH"
}
expected_mesh_names = (
RUNTIME_MESH_NAMES | UNUSED_MESH_NAMES | {PREVIEW_MESH_NAME}
)
if actual_mesh_names != expected_mesh_names:
missing = sorted(expected_mesh_names - actual_mesh_names)
unexpected = sorted(actual_mesh_names - expected_mesh_names)
raise ExportValidationError(
f"Character mesh manifest mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
if len(RUNTIME_MESH_NAMES) != 137:
raise ExportValidationError(
"Internal runtime mesh manifest is not exactly 137 unique names."
)
if len(FACE_DECAL_MESH_NAMES) != 63:
raise ExportValidationError(
"Internal face decal manifest is not exactly 63 unique names."
)
action_names = {action.name for action in bpy.data.actions}
if action_names != SOURCE_ACTION_NAMES:
missing = sorted(SOURCE_ACTION_NAMES - action_names)
unexpected = sorted(action_names - SOURCE_ACTION_NAMES)
raise ExportValidationError(
f"Character action manifest mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
for mesh_name in sorted(RUNTIME_MESH_NAMES):
mesh_object = bpy.data.objects[mesh_name]
armature_modifiers = [
modifier
for modifier in mesh_object.modifiers
if modifier.type == "ARMATURE"
]
if mesh_object.parent is not armature:
raise ExportValidationError(
f"{mesh_name} is not parented to {ARMATURE_NAME}."
)
if len(armature_modifiers) != 1:
raise ExportValidationError(
f"{mesh_name} has {len(armature_modifiers)} armature modifiers; "
"expected exactly one."
)
if armature_modifiers[0].object is not armature:
raise ExportValidationError(
f"{mesh_name} armature modifier does not target {ARMATURE_NAME}."
)
return source_path, armature
def _prepare_in_memory_export() -> None:
excluded_names = UNUSED_MESH_NAMES | {PREVIEW_MESH_NAME}
for object_name in sorted(excluded_names):
bpy.data.objects.remove(bpy.data.objects[object_name], do_unlink=True)
remaining_mesh_names = {
item.name for item in bpy.context.scene.objects if item.type == "MESH"
}
if remaining_mesh_names != RUNTIME_MESH_NAMES:
raise ExportValidationError(
"Active scene membership differs from the runtime manifest after "
"excluding authoring-only meshes."
)
def _export(output_path: Path) -> None:
result = bpy.ops.export_scene.gltf(
filepath=str(output_path),
check_existing=False,
export_format="GLB",
export_texcoords=True,
export_normals=True,
export_tangents=False,
export_materials="EXPORT",
export_attributes=False,
use_selection=False,
use_visible=False,
use_renderable=False,
use_active_collection=False,
use_active_collection_with_nested=False,
use_active_scene=True,
export_cameras=False,
export_lights=False,
export_yup=True,
export_apply=False,
export_extras=False,
export_animations=True,
export_frame_range=False,
export_force_sampling=True,
export_animation_mode="ACTIONS",
export_def_bones=False,
export_leaf_bone=False,
export_reset_pose_bones=True,
export_rest_position_armature=True,
export_skins=True,
export_influence_nb=4,
export_all_influences=False,
export_morph=False,
will_save_settings=False,
)
if result != {"FINISHED"}:
raise RuntimeError(f"Blender failed to export the character: {result}")
def _read_glb_document(output_path: Path) -> dict[str, object]:
with output_path.open("rb") as binary_file:
header = binary_file.read(20)
if len(header) != 20 or header[:4] != b"glTF":
raise ExportValidationError(
f"{output_path} is not a valid GLB file."
)
json_length, json_kind = struct.unpack_from("<II", header, 12)
if json_kind != 0x4E4F534A:
raise ExportValidationError(
f"{output_path} does not begin with a GLB JSON chunk."
)
return json.loads(binary_file.read(json_length))
def _node_parent_indices(nodes: list[dict[str, object]]) -> dict[int, int]:
parents: dict[int, int] = {}
for parent_index, node in enumerate(nodes):
for child_index in node.get("children", []):
if not isinstance(child_index, int) or not 0 <= child_index < len(nodes):
raise ExportValidationError(
f"GLB node {parent_index} has invalid child {child_index}."
)
if child_index in parents:
raise ExportValidationError(
f"GLB node {child_index} has more than one parent."
)
parents[child_index] = parent_index
return parents
def _validate_export(output_path: Path) -> None:
document = _read_glb_document(output_path)
nodes = document.get("nodes", [])
if not isinstance(nodes, list) or not all(
isinstance(node, dict) for node in nodes
):
raise ExportValidationError("GLB nodes must be an array of objects.")
expected_node_count = (
1 + len(EXPECTED_BONE_PARENTS) + len(RUNTIME_MESH_NAMES)
)
if len(nodes) != expected_node_count:
raise ExportValidationError(
f"GLB has {len(nodes)} nodes; expected {expected_node_count} "
"(one armature, 19 joints, and 137 meshes)."
)
scenes = document.get("scenes", [])
if not isinstance(scenes, list) or len(scenes) != 1:
raise ExportValidationError("GLB must contain exactly one scene.")
scene_index = document.get("scene", 0)
if scene_index != 0:
raise ExportValidationError(
f"GLB default scene index is {scene_index}; expected 0."
)
scene_roots = scenes[0].get("nodes", [])
if not isinstance(scene_roots, list) or len(scene_roots) != 1:
raise ExportValidationError(
"GLB scene must contain exactly one CharacterRig root node."
)
armature_node_index = scene_roots[0]
if (
not isinstance(armature_node_index, int)
or not 0 <= armature_node_index < len(nodes)
or str(nodes[armature_node_index].get("name", "")) != ARMATURE_NAME
):
raise ExportValidationError(
"GLB scene root must be CharacterRig so Godot imports the skeleton "
f"at {GODOT_SKELETON_NODE_PATH}."
)
parents = _node_parent_indices(nodes)
if armature_node_index in parents:
raise ExportValidationError("GLB CharacterRig scene root has a parent.")
mesh_node_indices = [
node_index
for node_index, node in enumerate(nodes)
if "mesh" in node
]
exported_mesh_names = [
str(nodes[node_index].get("name", ""))
for node_index in mesh_node_indices
]
duplicate_mesh_names = sorted(
{
mesh_name
for mesh_name in exported_mesh_names
if exported_mesh_names.count(mesh_name) > 1
}
)
if duplicate_mesh_names:
raise ExportValidationError(
f"GLB has duplicate mesh node names: {duplicate_mesh_names}."
)
exported_mesh_name_set = set(exported_mesh_names)
if exported_mesh_name_set != RUNTIME_MESH_NAMES:
missing = sorted(RUNTIME_MESH_NAMES - exported_mesh_name_set)
unexpected = sorted(exported_mesh_name_set - RUNTIME_MESH_NAMES)
raise ExportValidationError(
f"Exported mesh membership mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
meshes = document.get("meshes", [])
if not isinstance(meshes, list) or len(meshes) != len(RUNTIME_MESH_NAMES):
raise ExportValidationError(
f"GLB has {len(meshes)} mesh resources; "
f"expected {len(RUNTIME_MESH_NAMES)}."
)
for node_index in mesh_node_indices:
node = nodes[node_index]
if node.get("skin") != 0:
raise ExportValidationError(
f"Mesh node {node.get('name', '')} targets skin "
f"{node.get('skin')}; expected skin 0."
)
if parents.get(node_index) != armature_node_index:
raise ExportValidationError(
f"Mesh node {node.get('name', '')} is not a direct child of "
f"{ARMATURE_NAME}; Godot would not import the expected skeleton "
f"path {GODOT_SKELETON_NODE_PATH}."
)
mesh_index = node.get("mesh")
if not isinstance(mesh_index, int) or not 0 <= mesh_index < len(meshes):
raise ExportValidationError(
f"Mesh node {node.get('name', '')} has invalid mesh index "
f"{mesh_index}."
)
referenced_mesh_indices = [
int(nodes[node_index]["mesh"]) for node_index in mesh_node_indices
]
if (
len(referenced_mesh_indices) != len(meshes)
or set(referenced_mesh_indices) != set(range(len(meshes)))
):
raise ExportValidationError(
"GLB mesh resources must each be referenced by exactly one mesh node."
)
if len(document.get("animations", [])) != len(SOURCE_ACTION_NAMES):
raise ExportValidationError(
f"GLB has {len(document.get('animations', []))} animations; "
f"expected {len(SOURCE_ACTION_NAMES)}."
)
if len(document.get("skins", [])) != 1:
raise ExportValidationError("GLB must contain exactly one skin.")
skin = document["skins"][0]
if str(skin.get("name", "")) != ARMATURE_NAME:
raise ExportValidationError(
f"GLB skin must be named {ARMATURE_NAME}."
)
joints = skin.get("joints", [])
if not isinstance(joints, list) or len(joints) != len(EXPECTED_BONE_PARENTS):
raise ExportValidationError(
f"GLB skin has {len(joints)} joints; "
f"expected {len(EXPECTED_BONE_PARENTS)}."
)
if len(set(joints)) != len(joints) or not all(
isinstance(joint_index, int) and 0 <= joint_index < len(nodes)
for joint_index in joints
):
raise ExportValidationError("GLB skin contains invalid or duplicate joints.")
joint_indices_by_name = {
str(nodes[joint_index].get("name", "")): joint_index
for joint_index in joints
}
if set(joint_indices_by_name) != set(EXPECTED_BONE_PARENTS):
missing = sorted(
set(EXPECTED_BONE_PARENTS) - set(joint_indices_by_name)
)
unexpected = sorted(
set(joint_indices_by_name) - set(EXPECTED_BONE_PARENTS)
)
raise ExportValidationError(
f"GLB joint manifest mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
root_joint_index = joint_indices_by_name["root"]
if skin.get("skeleton", root_joint_index) != root_joint_index:
raise ExportValidationError(
"GLB skin skeleton must resolve to the root joint."
)
for bone_name, expected_parent_name in EXPECTED_BONE_PARENTS.items():
joint_index = joint_indices_by_name[bone_name]
expected_parent_index = (
armature_node_index
if expected_parent_name is None
else joint_indices_by_name[expected_parent_name]
)
if parents.get(joint_index) != expected_parent_index:
actual_parent_index = parents.get(joint_index)
actual_parent_name = (
str(nodes[actual_parent_index].get("name", ""))
if actual_parent_index is not None
else None
)
raise ExportValidationError(
f"GLB joint {bone_name} parent is {actual_parent_name}; "
f"expected {expected_parent_name or ARMATURE_NAME}."
)
animation_names = {
str(animation.get("name", ""))
for animation in document.get("animations", [])
}
if animation_names != SOURCE_ACTION_NAMES:
raise ExportValidationError(
"Exported animation names differ from the source action manifest."
)
def _temporary_export_path(output_path: Path, label: str) -> Path:
descriptor, raw_path = tempfile.mkstemp(
prefix=f".{output_path.stem}.{label}.",
suffix=".glb",
dir=output_path.parent,
)
os.close(descriptor)
os.unlink(raw_path)
return Path(raw_path)
def _run() -> int:
arguments = _parse_arguments()
output_path = arguments.output.expanduser().resolve()
if output_path.suffix.lower() != ".glb":
raise ExportValidationError("--output must name a .glb file.")
output_path.parent.mkdir(parents=True, exist_ok=True)
source_path, _armature = _validate_source()
source_sha256 = _sha256(source_path)
_prepare_in_memory_export()
temporary_paths = [_temporary_export_path(output_path, "first")]
try:
first_export_path = temporary_paths[0]
_export(first_export_path)
_validate_export(first_export_path)
if arguments.verify_determinism:
second_export_path = _temporary_export_path(output_path, "second")
temporary_paths.append(second_export_path)
_export(second_export_path)
_validate_export(second_export_path)
if second_export_path.read_bytes() != first_export_path.read_bytes():
raise ExportValidationError(
"Repeated character exports were not byte-identical."
)
os.replace(first_export_path, output_path)
output_sha256 = _sha256(output_path)
finally:
for temporary_path in temporary_paths:
try:
temporary_path.unlink()
except FileNotFoundError:
pass
print(
"Character export: PASS "
f"(source={source_sha256}, output={output_sha256}, "
f"meshes={len(RUNTIME_MESH_NAMES)}, "
f"face_decals={len(FACE_DECAL_MESH_NAMES)}, "
f"animations={len(SOURCE_ACTION_NAMES)}, bones=19)"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(_run())
except ExportValidationError as error:
print(f"CHARACTER EXPORT FAILED:\n{error}", file=sys.stderr)
raise SystemExit(2) from error