"""Audit evaluated skinned poses in an uncompressed animated RuneWaker GLB. Run through Blender so the same armature and modifier evaluation used by the diagnostic renderer is exercised:: blender --background --factory-startup --python audit-animated-actor-poses.py -- \ input.animated.raw.glb output.pose-audit.json [options] The default samples the bind pose plus the first, middle, and last frame of every action. The resulting JSON is deterministic: it contains no timestamps and all actions, meshes, components, issues, and rendered filenames are sorted. Options: --sample-mode all|families Audit every action, or one per semantic family. --render-mode none|flagged|families|all --render-dir DIRECTORY Directory for compact 256px review frames. --contact-sheet FILE Combine rendered frames into one PNG. --render-limit INTEGER Maximum non-bind review frames (default 24). """ from __future__ import annotations import argparse import hashlib import json import math import re import sys from pathlib import Path from typing import Iterable import bpy from mathutils import Vector TOOL_VERSION = "1.0.0" EPSILON = 1.0e-9 THRESHOLDS = { "minimumDiagonalRatio": 0.2, "maximumDiagonalRatio": 5.0, "minimumRadiusRatio": 0.2, "maximumRadiusRatio": 5.0, "maximumCentroidShiftRatio": 2.0, "maximumAxisExtentRatio": 8.0, "minimumAxisExtentRatio": 0.05, "maximumComponentCenterGlobalRatio": 0.75, "maximumComponentCenterLocalRatio": 12.0, "maximumComponentDiagonalRatio": 8.0, "minimumComponentDiagonalRatio": 0.05, "disconnectedEnvelopeRadiusRatio": 4.0, } def parse_arguments() -> argparse.Namespace: values = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("input_glb", type=Path) parser.add_argument("output_json", type=Path) parser.add_argument( "--source-file", type=Path, help="Original shipping GLB when input_glb is a temporary decompressed copy.", ) parser.add_argument( "--source-sha256", help="Precomputed SHA-256 for --source-file (avoids reading it again in Blender).", ) parser.add_argument( "--variant-id", help="Stable packaged-variant identifier, normally its path below public assets/creatures.", ) parser.add_argument("--sample-mode", choices=("all", "families"), default="all") parser.add_argument( "--render-mode", choices=("none", "flagged", "families", "all"), default="none", ) parser.add_argument("--render-dir", type=Path) parser.add_argument("--contact-sheet", type=Path) parser.add_argument("--render-limit", type=int, default=24) result = parser.parse_args(values) if result.render_limit < 0: parser.error("--render-limit cannot be negative") if result.render_mode != "none" and result.render_dir is None: parser.error("--render-dir is required when review frames are enabled") if result.contact_sheet is not None and result.render_dir is None: parser.error("--render-dir is required with --contact-sheet") return result def rounded(value: float) -> float: return round(float(value), 6) def vector_json(value: Vector) -> list[float]: return [rounded(value.x), rounded(value.y), rounded(value.z)] def ratio(numerator: float, denominator: float, default: float = 1.0) -> float: return numerator / denominator if abs(denominator) > EPSILON else default def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as source: for chunk in iter(lambda: source.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def actor_id(path: Path) -> str: suffix = ".animated.raw.glb" return path.name[: -len(suffix)] if path.name.endswith(suffix) else path.stem def semantic_family(name: str) -> str: value = re.sub(r"[^a-z0-9]+", " ", name.casefold()) families = ( ("death", ("death", "dead", "die")), ("attack", ("attack", "strike", "melee", "shoot")), ("idle", ("idle", "stand")), ("run", ("run", "sprint")), ("walk", ("walk", "move")), ("hit", ("hit", "hurt", "wound", "damage")), ("cast", ("cast", "spell", "magic")), ("spawn", ("spawn", "birth", "emerge")), ("special", ("skill", "special", "roar", "stun", "knock")), ) words = set(value.split()) for family, needles in families: if any(needle in words or needle in value for needle in needles): return family return "other" class UnionFind: def __init__(self, size: int) -> None: self.parent = list(range(size)) self.rank = [0] * size def find(self, value: int) -> int: root = value while self.parent[root] != root: root = self.parent[root] while self.parent[value] != value: next_value = self.parent[value] self.parent[value] = root value = next_value return root def union(self, left: int, right: int) -> None: left_root = self.find(left) right_root = self.find(right) if left_root == right_root: return if self.rank[left_root] < self.rank[right_root]: left_root, right_root = right_root, left_root self.parent[right_root] = left_root if self.rank[left_root] == self.rank[right_root]: self.rank[left_root] += 1 def mesh_components(mesh_object: bpy.types.Object) -> list[list[int]]: mesh = mesh_object.data union_find = UnionFind(len(mesh.vertices)) for edge in mesh.edges: union_find.union(edge.vertices[0], edge.vertices[1]) grouped: dict[int, list[int]] = {} for index in range(len(mesh.vertices)): grouped.setdefault(union_find.find(index), []).append(index) return sorted(grouped.values(), key=lambda indices: (indices[0], len(indices))) def bounds_metrics(points: Iterable[Vector]) -> dict[str, object]: point_list = list(points) finite = [point for point in point_list if all(math.isfinite(axis) for axis in point)] non_finite = len(point_list) - len(finite) if not finite: return { "vertexCount": len(point_list), "finiteVertices": 0, "nonFiniteVertices": non_finite, "minimum": None, "maximum": None, "centroid": None, "axisExtents": None, "diagonal": None, "maximumRadius": None, "rmsRadius": None, } minimum = Vector((min(p.x for p in finite), min(p.y for p in finite), min(p.z for p in finite))) maximum = Vector((max(p.x for p in finite), max(p.y for p in finite), max(p.z for p in finite))) centroid = sum(finite, Vector()) / len(finite) squared_distances = [(point - centroid).length_squared for point in finite] extents = maximum - minimum return { "vertexCount": len(point_list), "finiteVertices": len(finite), "nonFiniteVertices": non_finite, "minimum": vector_json(minimum), "maximum": vector_json(maximum), "centroid": vector_json(centroid), "axisExtents": vector_json(extents), "diagonal": rounded(extents.length), "maximumRadius": rounded(math.sqrt(max(squared_distances))), "rmsRadius": rounded(math.sqrt(sum(squared_distances) / len(squared_distances))), } def evaluate_vertices( mesh_objects: list[bpy.types.Object], ) -> tuple[dict[str, list[Vector]], list[str]]: dependency_graph = bpy.context.evaluated_depsgraph_get() result: dict[str, list[Vector]] = {} warnings: list[str] = [] for mesh_object in mesh_objects: evaluated_object = mesh_object.evaluated_get(dependency_graph) evaluated_mesh = evaluated_object.to_mesh() try: points = [evaluated_object.matrix_world @ vertex.co for vertex in evaluated_mesh.vertices] result[mesh_object.name] = points if len(points) != len(mesh_object.data.vertices): warnings.append( f"{mesh_object.name}: evaluated vertex count {len(points)} differs from source " f"count {len(mesh_object.data.vertices)}; component metrics omitted" ) finally: evaluated_object.to_mesh_clear() return result, warnings def set_bind_pose(scene: bpy.types.Scene, armatures: list[bpy.types.Object]) -> None: for armature in armatures: if armature.animation_data is not None: armature.animation_data.action = None armature.data.pose_position = "REST" scene.frame_set(0) bpy.context.view_layer.update() def set_action_pose( scene: bpy.types.Scene, armatures: list[bpy.types.Object], action: bpy.types.Action, frame: float, ) -> None: for index, armature in enumerate(armatures): armature.data.pose_position = "POSE" if armature.animation_data is None: armature.animation_data_create() armature.animation_data.action = action if index == 0 else None integer_frame = math.floor(frame) scene.frame_set(integer_frame, subframe=frame - integer_frame) bpy.context.view_layer.update() def component_metrics( vertices_by_mesh: dict[str, list[Vector]], topology: dict[str, list[list[int]]], ) -> dict[str, dict[str, object]]: result: dict[str, dict[str, object]] = {} for mesh_name in sorted(topology, key=str.casefold): vertices = vertices_by_mesh.get(mesh_name, []) components = topology[mesh_name] expected_vertices = sum(len(component) for component in components) if len(vertices) != expected_vertices: continue for index, vertex_indices in enumerate(components): key = f"{mesh_name}#{index:04d}" result[key] = bounds_metrics(vertices[vertex_index] for vertex_index in vertex_indices) return result def flatten_vertices(vertices_by_mesh: dict[str, list[Vector]]) -> list[Vector]: return [ vertex for mesh_name in sorted(vertices_by_mesh, key=str.casefold) for vertex in vertices_by_mesh[mesh_name] ] def compare_pose( pose: dict[str, object], bind: dict[str, object], posed_components: dict[str, dict[str, object]], bind_components: dict[str, dict[str, object]], ) -> tuple[dict[str, object], list[dict[str, object]]]: comparisons: dict[str, object] = { "diagonalRatio": None, "maximumRadiusRatio": None, "rmsRadiusRatio": None, "centroidShiftRatio": None, "axisExtentRatios": None, "componentCount": len(posed_components), "extremeComponentCount": 0, "disconnectedComponentCount": 0, "componentOutliers": [], } issues: list[dict[str, object]] = [] non_finite = int(pose["nonFiniteVertices"]) if non_finite: issues.append({"severity": "error", "code": "non-finite-vertices", "value": non_finite}) if pose["diagonal"] is None or bind["diagonal"] is None: issues.append({"severity": "error", "code": "missing-finite-bounds"}) return comparisons, issues diagonal_ratio = ratio(float(pose["diagonal"]), float(bind["diagonal"])) maximum_radius_ratio = ratio(float(pose["maximumRadius"]), float(bind["maximumRadius"])) rms_radius_ratio = ratio(float(pose["rmsRadius"]), float(bind["rmsRadius"])) centroid = Vector(pose["centroid"]) bind_centroid = Vector(bind["centroid"]) centroid_shift_ratio = ratio((centroid - bind_centroid).length, float(bind["diagonal"]), 0.0) pose_extents = pose["axisExtents"] bind_extents = bind["axisExtents"] axis_ratios = [ratio(float(pose_extents[i]), float(bind_extents[i])) for i in range(3)] comparisons.update( { "diagonalRatio": rounded(diagonal_ratio), "maximumRadiusRatio": rounded(maximum_radius_ratio), "rmsRadiusRatio": rounded(rms_radius_ratio), "centroidShiftRatio": rounded(centroid_shift_ratio), "axisExtentRatios": [rounded(value) for value in axis_ratios], } ) def ratio_issue(code: str, value: float, minimum: float, maximum: float) -> None: if value < minimum or value > maximum: issues.append({"severity": "warning", "code": code, "value": rounded(value)}) ratio_issue( "extreme-aabb-diagonal", diagonal_ratio, THRESHOLDS["minimumDiagonalRatio"], THRESHOLDS["maximumDiagonalRatio"], ) ratio_issue( "extreme-maximum-radius", maximum_radius_ratio, THRESHOLDS["minimumRadiusRatio"], THRESHOLDS["maximumRadiusRatio"], ) if centroid_shift_ratio > THRESHOLDS["maximumCentroidShiftRatio"]: issues.append( {"severity": "warning", "code": "extreme-centroid-shift", "value": rounded(centroid_shift_ratio)} ) if any( value < THRESHOLDS["minimumAxisExtentRatio"] or value > THRESHOLDS["maximumAxisExtentRatio"] for value in axis_ratios ): issues.append( { "severity": "warning", "code": "extreme-axis-extent", "value": [rounded(value) for value in axis_ratios], } ) outliers: list[dict[str, object]] = [] disconnected = 0 for key in sorted(set(posed_components) & set(bind_components), key=str.casefold): current = posed_components[key] reference = bind_components[key] if current["centroid"] is None or reference["centroid"] is None: continue current_center = Vector(current["centroid"]) reference_center = Vector(reference["centroid"]) center_shift = (current_center - reference_center).length global_ratio = ratio(center_shift, float(bind["diagonal"]), 0.0) local_denominator = max(float(reference["diagonal"] or 0.0), float(bind["diagonal"]) * 0.01) local_ratio = ratio(center_shift, local_denominator, 0.0) component_diagonal_ratio = ratio( float(current["diagonal"] or 0.0), float(reference["diagonal"] or 0.0) ) outside_ratio = ratio((current_center - bind_centroid).length, float(bind["maximumRadius"]), 0.0) is_extreme = ( global_ratio > THRESHOLDS["maximumComponentCenterGlobalRatio"] and local_ratio > THRESHOLDS["maximumComponentCenterLocalRatio"] ) or not ( THRESHOLDS["minimumComponentDiagonalRatio"] <= component_diagonal_ratio <= THRESHOLDS["maximumComponentDiagonalRatio"] ) is_disconnected = outside_ratio > THRESHOLDS["disconnectedEnvelopeRadiusRatio"] if is_disconnected: disconnected += 1 if is_extreme or is_disconnected: outliers.append( { "component": key, "vertexCount": current["vertexCount"], "centerShiftGlobalRatio": rounded(global_ratio), "centerShiftLocalRatio": rounded(local_ratio), "diagonalRatio": rounded(component_diagonal_ratio), "envelopeRadiusRatio": rounded(outside_ratio), "disconnected": is_disconnected, } ) outliers.sort( key=lambda item: ( -max( float(item["centerShiftGlobalRatio"]), float(item["envelopeRadiusRatio"]), abs(math.log(max(float(item["diagonalRatio"]), EPSILON))), ), str(item["component"]).casefold(), ) ) comparisons["extremeComponentCount"] = len(outliers) comparisons["disconnectedComponentCount"] = disconnected comparisons["componentOutliers"] = outliers[:12] if outliers: issues.append({"severity": "warning", "code": "extreme-components", "value": len(outliers)}) if disconnected: issues.append( {"severity": "warning", "code": "disconnected-components", "value": disconnected} ) return comparisons, issues def unique_sample_frames(action: bpy.types.Action) -> list[tuple[str, float]]: first, last = (float(value) for value in action.frame_range) candidates = (("start", first), ("middle", (first + last) * 0.5), ("end", last)) result: list[tuple[str, float]] = [] seen: set[float] = set() for label, frame in candidates: key = round(frame, 6) if key not in seen: seen.add(key) result.append((label, frame)) return result def look_at(obj: bpy.types.Object, point: Vector) -> None: obj.rotation_euler = (point - obj.location).to_track_quat("-Z", "Y").to_euler() def configure_review_scene(scene: bpy.types.Scene, bind: dict[str, object]) -> None: scene.render.engine = "BLENDER_EEVEE" scene.render.resolution_x = 256 scene.render.resolution_y = 256 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) minimum = Vector(bind["minimum"]) maximum = Vector(bind["maximum"]) center = (minimum + maximum) * 0.5 size = max(*(maximum - minimum), 1.0) camera_data = bpy.data.cameras.new("PoseAuditCamera") camera = bpy.data.objects.new("PoseAuditCamera", camera_data) scene.collection.objects.link(camera) scene.camera = camera camera_data.lens = 55 camera.location = center + Vector((size * 1.7, -size * 2.7, size * 0.45)) look_at(camera, center + Vector((0.0, 0.0, size * 0.05))) for name, energy, size_factor, offset in ( ("PoseAuditKey", 900, 2.0, (1.8, -1.5, 2.0)), ("PoseAuditFill", 500, 1.5, (-1.6, -0.5, 0.7)), ): light_data = bpy.data.lights.new(name, type="AREA") light_data.energy = energy light_data.shape = "DISK" light_data.size = size * size_factor light = bpy.data.objects.new(name, light_data) scene.collection.objects.link(light) light.location = center + Vector(tuple(size * value for value in offset)) look_at(light, center) def safe_filename(value: str) -> str: return re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-") or "unnamed" def make_contact_sheet(frame_paths: list[Path], output_path: Path) -> None: if not frame_paths: return try: import numpy as np except ImportError as error: raise RuntimeError("Blender's NumPy module is required for contact sheets") from error images = [bpy.data.images.load(str(path), check_existing=False) for path in frame_paths] try: width = max(image.size[0] for image in images) height = max(image.size[1] for image in images) columns = min(4, len(images)) rows = math.ceil(len(images) / columns) canvas_pixels = np.zeros((rows * height, columns * width, 4), dtype=np.float32) canvas_pixels[:, :, 3] = 1.0 for index, image in enumerate(images): pixels = np.asarray(image.pixels[:], dtype=np.float32).reshape( (image.size[1], image.size[0], 4) ) row, column = divmod(index, columns) canvas_pixels[ row * height : row * height + image.size[1], column * width : column * width + image.size[0], :, ] = pixels canvas = bpy.data.images.new( "PoseAuditContactSheet", width=columns * width, height=rows * height, alpha=True, ) try: canvas.pixels.foreach_set(canvas_pixels.ravel()) output_path.parent.mkdir(parents=True, exist_ok=True) canvas.filepath_raw = str(output_path) canvas.file_format = "PNG" canvas.save() finally: bpy.data.images.remove(canvas) finally: for image in images: bpy.data.images.remove(image) def main() -> None: args = parse_arguments() input_glb = args.input_glb.resolve() output_json = args.output_json.resolve() source_file = args.source_file.resolve() if args.source_file is not None else input_glb if not input_glb.is_file(): raise RuntimeError(f"Input GLB does not exist: {input_glb}") if not source_file.is_file(): raise RuntimeError(f"Source GLB does not exist: {source_file}") output_json.parent.mkdir(parents=True, exist_ok=True) bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete(use_global=False) result = bpy.ops.import_scene.gltf(filepath=str(input_glb)) if "FINISHED" not in result: raise RuntimeError(f"GLB import failed: {result}") scene = bpy.context.scene mesh_objects = sorted( (obj for obj in scene.objects if obj.type == "MESH"), key=lambda obj: obj.name.casefold() ) armatures = sorted( (obj for obj in scene.objects if obj.type == "ARMATURE"), key=lambda obj: obj.name.casefold() ) if not mesh_objects: raise RuntimeError("The imported GLB must contain at least one mesh") topology = {mesh.name: mesh_components(mesh) for mesh in mesh_objects} set_bind_pose(scene, armatures) bind_vertices, bind_warnings = evaluate_vertices(mesh_objects) bind = bounds_metrics(flatten_vertices(bind_vertices)) bind_component_metrics = component_metrics(bind_vertices, topology) bind["meshObjects"] = len(mesh_objects) bind["topologyComponents"] = len(bind_component_metrics) asset_issues: list[dict[str, object]] = [] if not armatures: asset_issues.append( { "severity": "warning", "code": "pose-only-no-armature", "detail": "The packaged actor contains static posed meshes and no skeletal armature.", } ) if bind["nonFiniteVertices"]: asset_issues.append( { "severity": "error", "code": "bind-non-finite-vertices", "value": bind["nonFiniteVertices"], } ) if bind["diagonal"] is None: asset_issues.append({"severity": "error", "code": "bind-missing-finite-bounds"}) actions = sorted(bpy.data.actions, key=lambda action: action.name.casefold()) if args.sample_mode == "families": family_actions: dict[str, bpy.types.Action] = {} for action in actions: family_actions.setdefault(semantic_family(action.name), action) actions = [family_actions[name] for name in sorted(family_actions)] action_plan = [ { "actionIndex": action_index, "action": action.name, "semanticFamily": semantic_family(action.name), "frameRange": [rounded(value) for value in action.frame_range], "frames": [ {"label": label, "frame": rounded(frame)} for label, frame in unique_sample_frames(action) ], } for action_index, action in enumerate(actions) ] samples: list[dict[str, object]] = [] evaluation_warnings = set(bind_warnings) for action_index, action in enumerate(actions): family = semantic_family(action.name) for label, frame in unique_sample_frames(action): set_action_pose(scene, armatures, action, frame) vertices, warnings = evaluate_vertices(mesh_objects) evaluation_warnings.update(warnings) pose = bounds_metrics(flatten_vertices(vertices)) components = component_metrics(vertices, topology) comparison, issues = compare_pose(pose, bind, components, bind_component_metrics) samples.append( { "sampleId": f"action-{action_index:04d}-{label}", "action": action.name, "semanticFamily": family, "frameLabel": label, "frame": rounded(frame), "metrics": pose, "bindComparison": comparison, "issues": issues, } ) rendered: list[dict[str, str]] = [] if args.render_mode != "none": args.render_dir.mkdir(parents=True, exist_ok=True) configure_review_scene(scene, bind) candidates: list[dict[str, object]] if args.render_mode == "all": candidates = samples elif args.render_mode == "flagged": candidates = [sample for sample in samples if sample["issues"]] else: by_family: dict[str, dict[str, object]] = {} for sample in samples: if sample["frameLabel"] == "middle": by_family.setdefault(str(sample["semanticFamily"]), sample) candidates = [by_family[family] for family in sorted(by_family)] candidates = candidates[: args.render_limit] set_bind_pose(scene, armatures) bind_path = args.render_dir / "0000-Bind-Pose.png" scene.render.filepath = str(bind_path) bpy.ops.render.render(write_still=True) rendered.append({"sampleId": "bind", "file": bind_path.name}) actions_by_name = {action.name: action for action in bpy.data.actions} for render_index, sample in enumerate(candidates, start=1): action = actions_by_name[str(sample["action"])] set_action_pose(scene, armatures, action, float(sample["frame"])) frame_path = args.render_dir / ( f"{render_index:04d}-{safe_filename(str(sample['action']))}-" f"{sample['frameLabel']}.png" ) scene.render.filepath = str(frame_path) bpy.ops.render.render(write_still=True) rendered.append({"sampleId": str(sample["sampleId"]), "file": frame_path.name}) if args.contact_sheet is not None: make_contact_sheet([args.render_dir / item["file"] for item in rendered], args.contact_sheet) issue_samples = [sample for sample in samples if sample["issues"]] errors = sum(1 for issue in asset_issues if issue["severity"] == "error") + sum( 1 for sample in samples for issue in sample["issues"] if issue["severity"] == "error" ) warnings = sum(1 for issue in asset_issues if issue["severity"] == "warning") + sum( 1 for sample in samples for issue in sample["issues"] if issue["severity"] == "warning" ) status = "error" if errors else "warning" if warnings else "pass" report = { "schemaVersion": 1, "tool": {"name": "RuneWaker evaluated-pose audit", "version": TOOL_VERSION}, "actorId": actor_id(source_file), "variantId": args.variant_id or actor_id(source_file), "source": { "file": source_file.name, "sha256": args.source_sha256 or sha256_file(source_file), "auditInput": input_glb.name, "auditInputDecompressed": source_file != input_glb, }, "sampling": { "mode": args.sample_mode, "rig": "skeletal" if armatures else "static-pose-only", "bindPose": True, "framesPerAction": ["start", "middle", "end"], "actions": len(actions), "samples": len(samples), "actionPlan": action_plan, }, "thresholds": THRESHOLDS, "bindPose": bind, "assetIssues": asset_issues, "samples": samples, "summary": { "status": status, "sampledActions": len(actions), "sampledPoses": len(samples) + 1, "flaggedSamples": len(issue_samples), "errors": errors, "warnings": warnings, "evaluationWarnings": sorted(evaluation_warnings, key=str.casefold), "assetIssues": len(asset_issues), }, "review": { "mode": args.render_mode, "frames": rendered, "contactSheet": args.contact_sheet.name if args.contact_sheet is not None else None, }, "limitations": [ "Thresholds detect gross numeric deformation; they cannot prove that an animation is semantically correct.", "Fast motion, projectiles, weapon trails, and deliberately detached body parts can produce legitimate outliers.", "Only start, middle, and end frames are sampled; a defect isolated between those frames may be missed.", "Component metrics require modifiers to preserve vertex ordering and count.", ], } output_json.write_text(json.dumps(report, indent=2, sort_keys=False) + "\n", encoding="utf-8") print( "POSE_AUDIT " + json.dumps( { "actorId": report["actorId"], "status": status, "actions": len(actions), "poses": len(samples) + 1, "flaggedSamples": len(issue_samples), "report": str(output_json), }, sort_keys=True, ) ) if __name__ == "__main__": main()