animsmith-core 0.1.0

Engine-agnostic data model, sampling, measurements, and checks for the animsmith animation-clip linter
Documentation
//! `bind-pose` — a clip whose first frame deviates wildly from the
//! skeleton's rest pose was almost certainly authored against a
//! different bind (wrong seed rig, wrong export skeleton). Small
//! deviations are normal — few clips start exactly at rest — so only a
//! large mean deviation across the animated bones fires.
//!
//! (Rest-vs-inverse-bind disagreement is deferred: IBMs live in mesh
//! space, which needs per-mesh space handling to compare fairly.)

use crate::check::{Check, CheckCtx};
use crate::finding::{Finding, Severity};
use crate::model::Property;

/// Mean first-frame rotation deviation (degrees, across bones with
/// rotation tracks) above which the clip likely targets another bind.
pub const DEFAULT_MAX_MEAN_REST_DELTA_DEG: f64 = 45.0;

pub struct BindPose;

impl Check for BindPose {
    fn id(&self) -> &'static str {
        "bind-pose"
    }

    fn run(&self, ctx: &CheckCtx, out: &mut Vec<Finding>) {
        let cap = ctx
            .config
            .check_settings(self.id())
            .max_mean_rest_delta_deg
            .unwrap_or(DEFAULT_MAX_MEAN_REST_DELTA_DEG);
        for clip in &ctx.doc.clips {
            let mut total_deg = 0.0f64;
            let mut counted = 0usize;
            let mut worst: Option<(f64, &str)> = None;
            for track in &clip.tracks {
                if track.property != Property::Rotation || track.key_count() == 0 {
                    continue;
                }
                let Some(bone) = ctx.doc.skeleton.bones.get(track.bone) else {
                    continue;
                };
                let Some(first) = track.key_quat(0) else {
                    continue;
                };
                if !first.is_finite() || first.length_squared() == 0.0 {
                    continue;
                }
                let deg = bone
                    .rest
                    .rotation
                    .normalize()
                    .angle_between(first.normalize())
                    .to_degrees() as f64;
                total_deg += deg;
                counted += 1;
                if worst.is_none_or(|(w, _)| deg > w) {
                    worst = Some((deg, bone.name.as_str()));
                }
            }
            if counted < 3 {
                continue; // too few rotated bones to call a pose mismatch
            }
            let mean = total_deg / counted as f64;
            if mean > cap {
                let (worst_deg, worst_bone) = worst.expect("counted > 0");
                out.push(
                    Finding::new(
                        self.id(),
                        Severity::Warning,
                        format!(
                            "first frame deviates from the rest pose by {mean:.0}° on \
                             average across {counted} bones (worst {worst_bone}: \
                             {worst_deg:.0}°) — authored against a different bind?"
                        ),
                    )
                    .clip(&clip.name)
                    .time(0.0)
                    .measured(mean)
                    .expected(cap),
                );
            }
        }
    }
}