rustmotion-core 0.8.1

Core types, traits, and rendering utilities for rustmotion
Documentation
pub mod schema;
pub mod scope;
pub mod track;

pub use schema::{dynamic_names, VarDef, VarKeyframe, VarSet};
pub use scope::VarScope;
pub use track::{VarTable, VarTrack, VarsError};

#[cfg(test)]
mod tests {
    use super::*;
    use crate::expr::Expr;
    use crate::schema::animation::EasingType;
    use crate::schema::time::{TimeCtx, TimePoint};
    use std::f64::consts::TAU;

    #[test]
    fn wireframe_edges_rotate_via_a_shared_variable() {
        let mut vars = VarSet::new();
        vars.insert(
            "rotY".to_string(),
            VarDef {
                default: 0.0,
                animation: vec![VarKeyframe {
                    at: TimePoint::Seconds(0.0),
                    to: TAU,
                    duration: TimePoint::Seconds(4.0),
                    easing: EasingType::Linear,
                }],
            },
        );
        let ctx = TimeCtx {
            bpm: None,
            beat_offset: 0.0,
            scene_start: 0.0,
        };
        let scenario = VarTable::compile(&vars, &ctx).unwrap();

        const EDGE_COUNT: usize = 8;
        assert_eq!(dynamic_names(&vars).collect::<Vec<_>>(), vec!["rotY"]);

        let edge_exprs: Vec<Expr> = (0..EDGE_COUNT)
            .map(|i| {
                let angle = i as f64 / EDGE_COUNT as f64 * TAU;
                Expr::parse(&format!("= (cos($rotY + {angle}) + 1) / 2")).unwrap()
            })
            .collect();

        let sample_times = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
        let mut frames: Vec<Vec<f64>> = Vec::new();
        for &t in &sample_times {
            let scope = VarScope::new(&scenario, None, t);

            let rot_y = scope.resolve("rotY").unwrap();
            let expected_rot_y = TAU * (t / 4.0).min(1.0);
            assert!((rot_y - expected_rot_y).abs() < 1e-9);

            let opacities: Vec<f64> = edge_exprs.iter().map(|e| e.eval(&scope).unwrap()).collect();
            for (i, &opacity) in opacities.iter().enumerate() {
                let angle = i as f64 / EDGE_COUNT as f64 * TAU;
                let expected = (f64::cos(rot_y + angle) + 1.0) / 2.0;
                assert!((opacity - expected).abs() < 1e-9);
            }
            frames.push(opacities);
        }

        for (i, pair) in frames.windows(2).enumerate() {
            if i < 4 {
                assert_ne!(
                    pair[0],
                    pair[1],
                    "wireframe did not move between sampled frames {i} and {}",
                    i + 1
                );
            }
        }
        assert_eq!(
            frames[4], frames[5],
            "must hold, not reset, past the last keyframe"
        );
    }

    #[test]
    fn unanimated_variable_is_classified_static_and_folds() {
        let mut vars = VarSet::new();
        vars.insert(
            "constant".to_string(),
            VarDef {
                default: 7.0,
                animation: Vec::new(),
            },
        );
        vars.insert(
            "rotY".to_string(),
            VarDef {
                default: 0.0,
                animation: vec![VarKeyframe {
                    at: TimePoint::Seconds(0.0),
                    to: 1.0,
                    duration: TimePoint::Seconds(1.0),
                    easing: EasingType::Linear,
                }],
            },
        );

        let dynamic: Vec<&str> = dynamic_names(&vars).collect();
        assert_eq!(dynamic, vec!["rotY"]);

        let constant_expr = Expr::parse("= $constant * 2").unwrap();
        assert!(constant_expr.is_static());

        let ctx = TimeCtx {
            bpm: None,
            beat_offset: 0.0,
            scene_start: 0.0,
        };
        let table = VarTable::compile(&vars, &ctx).unwrap();
        let scope = VarScope::new(&table, None, 0.0);
        assert_eq!(constant_expr.eval(&scope).unwrap(), 14.0);
    }
}