animsmith-core 0.4.1

Engine-agnostic data model, sampling, measurements, and checks for the animsmith animation-clip linter
Documentation
//! Public gait-phase availability boundaries on analytic clips.

use animsmith_core::glam::Vec3;
use animsmith_core::measure::{MeasurementAvailability, measure_document};
use animsmith_core::metrics::{MIN_STRIDE_STEP_M, foot_cycle_metrics};
use animsmith_core::model::{
    Bone, Clip, Document, Interpolation, Property, Skeleton, Track, TrackValues, Transform,
};
use animsmith_core::profile::{ResolvedRoles, Role};
use animsmith_core::{Config, MetricGrids};

fn translation_track(bone: usize, values: Vec<Vec3>) -> Track {
    let times = match values.len() {
        2 => vec![0.0, 1.0],
        3 => vec![0.0, 0.5, 1.0],
        5 => vec![0.0, 0.25, 0.5, 0.75, 1.0],
        count => panic!("unsupported analytic key count {count}"),
    };
    Track {
        bone,
        property: Property::Translation,
        interpolation: Interpolation::Linear,
        times,
        values: TrackValues::Vec3s(values),
    }
}

fn analytic_document() -> (Document, ResolvedRoles) {
    let skeleton = Skeleton {
        bones: vec![
            Bone {
                name: "hips".into(),
                parent: None,
                rest: Transform::IDENTITY,
                inverse_bind: None,
            },
            Bone {
                name: "left_foot".into(),
                parent: Some(0),
                rest: Transform {
                    translation: Vec3::new(-0.1, -1.0, 0.0),
                    ..Transform::IDENTITY
                },
                inverse_bind: None,
            },
            Bone {
                name: "right_foot".into(),
                parent: Some(0),
                rest: Transform {
                    translation: Vec3::new(0.1, -1.0, 0.0),
                    ..Transform::IDENTITY
                },
                inverse_bind: None,
            },
        ],
    };
    let roles = ResolvedRoles::from_names(
        &skeleton,
        [
            (Role::Hips, "hips".into()),
            (Role::LeftFoot, "left_foot".into()),
            (Role::RightFoot, "right_foot".into()),
        ],
    );
    let clips = vec![
        Clip {
            name: "stationary".into(),
            duration_s: 1.0,
            tracks: vec![translation_track(0, vec![Vec3::ZERO; 3])],
        },
        Clip {
            name: "common_mode".into(),
            duration_s: 1.0,
            tracks: vec![
                translation_track(1, vec![Vec3::ZERO, Vec3::Y * 0.2, Vec3::ZERO]),
                translation_track(2, vec![Vec3::ZERO, Vec3::Y * 0.2, Vec3::ZERO]),
            ],
        },
        Clip {
            name: "constant_offset".into(),
            duration_s: 1.0,
            tracks: vec![
                translation_track(1, vec![Vec3::Y * 0.1; 3]),
                translation_track(2, vec![Vec3::ZERO; 3]),
            ],
        },
        Clip {
            name: "alternating_in_place".into(),
            duration_s: 1.0,
            tracks: vec![
                translation_track(1, vec![Vec3::ZERO, Vec3::Y * 1.0e-4, Vec3::ZERO]),
                translation_track(2, vec![Vec3::ZERO, Vec3::Y * -1.0e-4, Vec3::ZERO]),
            ],
        },
        Clip {
            name: "minimum_positive".into(),
            duration_s: 1.0,
            tracks: vec![
                translation_track(
                    1,
                    vec![
                        Vec3::ZERO,
                        Vec3::new(0.0, f32::from_bits(1), 0.0),
                        Vec3::ZERO,
                    ],
                ),
                translation_track(2, vec![Vec3::ZERO; 3]),
            ],
        },
        Clip {
            name: "higher_harmonic".into(),
            duration_s: 1.0,
            tracks: vec![
                translation_track(
                    1,
                    vec![
                        Vec3::ZERO,
                        Vec3::Y * 0.1,
                        Vec3::ZERO,
                        Vec3::Y * 0.1,
                        Vec3::ZERO,
                    ],
                ),
                translation_track(2, vec![Vec3::ZERO; 5]),
            ],
        },
        Clip {
            name: "too_short".into(),
            duration_s: 1.0,
            tracks: vec![translation_track(0, vec![Vec3::ZERO; 2])],
        },
    ];
    (
        Document {
            skeleton,
            clips,
            ..Document::default()
        },
        roles,
    )
}

#[test]
fn public_measurements_distinguish_no_phase_subject_from_derivation_failure() {
    let (document, roles) = analytic_document();
    let grids = MetricGrids::new(&document);

    for name in ["stationary", "common_mode", "constant_offset"] {
        let clip_index = document
            .clips
            .iter()
            .position(|clip| clip.name == name)
            .expect("named flat clip");
        let grid = grids.grid(clip_index).expect("analytic metric grid");
        let raw = foot_cycle_metrics(&grid, &roles, MIN_STRIDE_STEP_M)
            .expect("flat bilateral cycle remains measurable");
        assert_eq!(raw.lr_amplitude_m, 0.0);
        assert_eq!(
            raw.gait_phase, None,
            "the raw metric boundary must not retain an arbitrary phase for a flat signal"
        );
    }
    for name in [
        "alternating_in_place",
        "minimum_positive",
        "higher_harmonic",
    ] {
        let clip_index = document
            .clips
            .iter()
            .position(|clip| clip.name == name)
            .expect("named positive-swing clip");
        let raw = foot_cycle_metrics(
            &grids.grid(clip_index).expect("positive-swing metric grid"),
            &roles,
            MIN_STRIDE_STEP_M,
        )
        .expect("positive-swing bilateral cycle");
        assert!(raw.lr_amplitude_m > 0.0, "{name}");
        assert!(
            raw.gait_phase.is_some(),
            "{name}: every representable positive swing retains the fitted raw phase"
        );
    }

    let config: Config = serde_json::from_value(serde_json::json!({
        "gait_groups": { "consumer_only": {
            "clips": ["minimum_positive", "higher_harmonic"],
            "max_gait_phase_spread": 0.1,
            "min_lr_amplitude_m": 1.0
        }}
    }))
    .expect("consumer gait confidence policy");
    let measured = measure_document(&grids, &roles, &config);

    for name in ["stationary", "common_mode", "constant_offset"] {
        let clip = &measured[name];
        assert_eq!(clip.gait_availability, MeasurementAvailability::Measured);
        let gait = clip.gait.as_ref().expect("parent gait remains measured");
        assert_eq!(gait.lr_amplitude_m, 0.0);
        assert_eq!(gait.phase, None);
        assert_eq!(
            gait.phase_availability,
            MeasurementAvailability::NotApplicable,
            "{name} has no L-R oscillation and therefore no phase subject"
        );
    }

    for name in [
        "alternating_in_place",
        "minimum_positive",
        "higher_harmonic",
    ] {
        let gait = measured[name]
            .gait
            .as_ref()
            .expect("positive foot alternation remains a measured gait");
        assert!(gait.lr_amplitude_m > 0.0, "{name}");
        assert!(gait.phase.is_some(), "{name}");
        assert_eq!(
            gait.phase_availability,
            MeasurementAvailability::Measured,
            "{name}: positive evidence remains measured despite a larger consumer confidence floor"
        );
    }

    let too_short = &measured["too_short"];
    assert!(too_short.gait.is_none());
    assert_eq!(
        too_short.gait_availability,
        MeasurementAvailability::Unavailable,
        "a failed applicable cycle is a parent derivation failure, not nested phase non-applicability"
    );
}