657 lines
22 KiB
Python
Executable file
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
|