wrote a Blender script to catch out-of-range bone rotations in animations, beats squinting at the graph editor

194 views 5 replies

Been burned too many times by rig breakage that only shows up in-engine. A forearm rotating past the mechanical stop on a mech rig, a spine bone going into weird territory on a fast motion. Usually one or two blown frames, invisible while scrubbing but obvious as a pop when the game plays it back at speed.

So I wrote a Blender Python script that samples the armature at every frame, checks rotation values against a per-bone limit table, and prints a report. You define limits by bone name substring, it flags anything outside the range.

import bpy
import math

# Define rotation limits per bone name substring (in degrees)
# Adjust to match your rig's intended range of motion
BONE_LIMITS = {
    "spine":    {"x": (-30, 30),   "y": (-20, 20),  "z": (-45, 45)},
    "neck":     {"x": (-40, 40),   "y": (-30, 30),  "z": (-60, 60)},
    "shoulder": {"x": (-90, 180),  "y": (-90, 90),  "z": (-90, 90)},
    "forearm":  {"x": (-140, 0),   "y": (-20, 20),  "z": (-20, 20)},
    "knee":     {"x": (0, 140),    "y": (-5, 5),    "z": (-5, 5)},
}

def check_rotation_limits(armature_name, frame_start=None, frame_end=None):
    scene = bpy.context.scene
    obj = bpy.data.objects.get(armature_name)
    if not obj or obj.type != 'ARMATURE':
        print(f"Armature '{armature_name}' not found.")
        return []

    start = frame_start or scene.frame_start
    end = frame_end or scene.frame_end
    violations = []

    for frame in range(start, end + 1):
        scene.frame_set(frame)
        for bone in obj.pose.bones:
            if bone.rotation_mode not in ('XYZ', 'XZY', 'YXZ', 'YZX', 'ZXY', 'ZYX'):
                continue  # skip quaternion bones

            matched_key = next(
                (k for k in BONE_LIMITS if k in bone.name.lower()), None
            )
            if not matched_key:
                continue

            limits = BONE_LIMITS[matched_key]
            rot = bone.rotation_euler

            for axis, val in zip('xyz', [rot.x, rot.y, rot.z]):
                if axis not in limits:
                    continue
                deg = math.degrees(val)
                lo, hi = limits[axis]
                if not (lo <= deg <= hi):
                    violations.append((frame, bone.name, axis.upper(), deg, lo, hi))

    if violations:
        print()
        print(f"=== Rotation Limit Violations ({len(violations)} found) ===")
        for frame, bone_name, axis, deg, lo, hi in violations:
            print(f"  Frame {frame:4d} | {bone_name:22s} | {axis}: {deg:+.1f}° (limit: {lo}° to {hi}°)")
    else:
        print("No violations found.")

    return violations

check_rotation_limits("Armature")

Only works for Euler rotation mode bones. If your rig mixes in quaternion bones you'd need to convert first, which I haven't gotten to yet. The substring matching means "forearm" catches forearm_L, forearm_R, etc., which is usually what you want.

Sample output from my current project:

=== Rotation Limit Violations (4 found) ===
  Frame   23 | forearm_L             | X: -147.3° (limit: -140° to 0°)
  Frame   24 | forearm_L             | X: -151.8° (limit: -140° to 0°)
  Frame   41 | spine_02              | Z: +52.1° (limit: -45° to 45°)
  Frame  108 | knee_R                | X: -3.2° (limit: 0° to 140°)

The knee one on frame 108 is the interesting case. Only 3 degrees of hyperextension, but it was causing a visible pop on one specific transition that I'd been chasing for two days.

Setting up the limit table is the tedious part since you have to know your rig's intended range of motion. But once it's set it runs in seconds even on long clips, which beats manually scrubbing or waiting to see it break in-engine.

Curious how others handle this. Do you enforce limits in the rig itself with bone rotation constraints, or do you prefer something advisory like this so the animator still has full control when they need it?

A workflow improvement worth adding: write the results to a CSV or JSON file in addition to (or instead of) console output. Columns for bone name, frame number, axis, actual rotation value, and the limit it violated. Animators can open it in a spreadsheet, sort by bone, and mark which violations are intentional versus need fixing.

Console output disappears the moment they close Blender. A report file in the project directory sticks around, can be committed alongside the blend file, and builds up a record of known issues across iterations. Small addition, but it makes the tool feel like part of an actual pipeline rather than a one-off debug utility.

This would be incredibly useful for facial rigs too. FACS-based setups have specific valid ranges per corrective shape and it's easy to push something into illegal territory during retargeting without noticing until you're in-engine. Does your script support per-bone range configs, or is it a flat threshold across the whole rig? For facial work I'd really want to specify limits individually (jaw only opens so far, eyelid can't invert past closed) rather than flagging anything beyond a generic degree limit.

Replying to IronLattice: A workflow improvement worth adding: write the results to a CSV or JSON file in ...

json over csv for this imo, if you want to do anything downstream with the results (pipe into a review tool, highlight flagged frames in a custom timeline UI, auto-generate a per-take report) json is way easier to parse programmatically. csv only makes sense if the destination is literally excel, and i have never once opened one of these error outputs in excel. i always end up writing a second script to process it anyway, so might as well not create the intermediate format.

Replying to DriftRay: json over csv for this imo, if you want to do anything downstream with the resul...

yeah, json is the obvious call. one thing worth baking into the schema from the start: stick the take name and source blend file as top-level keys so the output is self-describing. three weeks from now you won't remember which file the audit report came from.

also if you structure it as { "takes": [ { "name": "...", "file": "...", "violations": [...] } ] } instead of a flat violation list, aggregating across a full session folder becomes trivial. merging per-take objects is easy, merging a flat list is annoying every time.

This would save a lot of "why does this look wrong in engine" moments for combat animations specifically. Upper body overrides on spine bones are the worst offenders. Push a torso lean hard to sell a hit reaction and suddenly the spine is in territory that makes the mesh look like it's attempting a prison break from its own skeleton.

Any plans to make the per-bone limit config external, like a JSON or CSV, rather than hardcoded in the script? Would make it a lot easier to share the same checker across multiple rigs without touching the code every time.

Moonjump
Forum Search Shader Sandbox
Sign In Register