"""Render diagnostic contact sheets for an animated RuneWaker actor GLB. Usage (after Blender's `--` separator): input.glb output-directory [action-name ...] The input should be an uncompressed diagnostic GLB because Blender's importer does not currently decode EXT_meshopt_compression. Each requested action is rendered at its first, middle, and final frame so bind-pose or skinning failures cannot hide behind an asset-existence/clip-name audit. """ from __future__ import annotations import math import os import sys import bpy from mathutils import Vector def arguments() -> tuple[str, str, list[str]]: values = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] if len(values) < 2: raise SystemExit("Expected input.glb and output-directory.") return values[0], values[1], values[2:] def look_at(obj: bpy.types.Object, point: Vector) -> None: obj.rotation_euler = (point - obj.location).to_track_quat("-Z", "Y").to_euler() input_glb, output_directory, requested_actions = arguments() os.makedirs(output_directory, exist_ok=True) bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete(use_global=False) for datablocks in (bpy.data.meshes, bpy.data.curves, bpy.data.materials, bpy.data.cameras, bpy.data.lights): for datablock in list(datablocks): if datablock.users == 0: datablocks.remove(datablock) bpy.ops.import_scene.gltf(filepath=input_glb) scene = bpy.context.scene scene.render.engine = "BLENDER_EEVEE" scene.render.resolution_x = 512 scene.render.resolution_y = 512 scene.render.resolution_percentage = 100 scene.render.image_settings.file_format = "PNG" scene.render.film_transparent = False scene.world.color = (0.025, 0.03, 0.04) mesh_objects = [obj for obj in scene.objects if obj.type == "MESH"] armatures = [obj for obj in scene.objects if obj.type == "ARMATURE"] if not mesh_objects or not armatures: raise SystemExit("The imported GLB must contain a mesh and armature.") armature = armatures[0] corners = [] for obj in mesh_objects: corners.extend(obj.matrix_world @ Vector(corner) for corner in obj.bound_box) minimum = Vector((min(v.x for v in corners), min(v.y for v in corners), min(v.z for v in corners))) maximum = Vector((max(v.x for v in corners), max(v.y for v in corners), max(v.z for v in corners))) center = (minimum + maximum) * 0.5 height = max(maximum.z - minimum.z, maximum.y - minimum.y, 1.0) camera_data = bpy.data.cameras.new("DiagnosticCamera") camera = bpy.data.objects.new("DiagnosticCamera", camera_data) scene.collection.objects.link(camera) scene.camera = camera camera.data.lens = 58 camera.location = center + Vector((height * 1.7, -height * 2.6, height * 0.45)) look_at(camera, center + Vector((0.0, 0.0, height * 0.05))) key_data = bpy.data.lights.new("Key", type="AREA") key_data.energy = 900 key_data.shape = "DISK" key_data.size = height * 2.0 key = bpy.data.objects.new("Key", key_data) scene.collection.objects.link(key) key.location = center + Vector((height * 1.8, -height * 1.5, height * 2.0)) look_at(key, center) fill_data = bpy.data.lights.new("Fill", type="AREA") fill_data.energy = 500 fill_data.size = height * 1.5 fill = bpy.data.objects.new("Fill", fill_data) scene.collection.objects.link(fill) fill.location = center + Vector((-height * 1.6, -height * 0.5, height * 0.7)) look_at(fill, center) actions_by_name = {action.name: action for action in bpy.data.actions} action_names = requested_actions or sorted(actions_by_name) if armature.animation_data is None: armature.animation_data_create() for action_name in action_names: if action_name == "Bind Pose": armature.animation_data.action = None scene.frame_set(0) scene.render.filepath = os.path.join(output_directory, "Bind-Pose.png") bpy.ops.render.render(write_still=True) print(f"RENDERED Bind Pose -> {scene.render.filepath}") continue action = actions_by_name.get(action_name) if action is None: print(f"SKIP missing action: {action_name}") continue armature.animation_data.action = action first, last = action.frame_range frames = [first, (first + last) * 0.5, last] safe_name = "".join(character if character.isalnum() else "-" for character in action_name).strip("-") for label, frame in zip(("start", "middle", "end"), frames): scene.frame_set(math.floor(frame), subframe=frame - math.floor(frame)) scene.render.filepath = os.path.join(output_directory, f"{safe_name}-{label}.png") bpy.ops.render.render(write_still=True) print(f"RENDERED {action_name} {label} frame={frame:.3f} -> {scene.render.filepath}")