motion-core 0.1.0

Backend-independent motion engine primitives
Documentation
//! Finite tick-addressed keyframe tracks with explicit interpolation and holds.

use super::{Animation, Easing};
use crate::{Duration, Interpolate, Time};

#[derive(Clone, Debug)]
struct Keyframe<Value> {
    at: Time,
    value: Value,
    easing: Option<Easing>,
}

/// A nonempty track starting at zero, with absolute destinations and relative holds.
///
/// Exact timestamps return stored values. Intervals use the arriving point's
/// easing, while holds clone their value without invoking interpolation. Sampling
/// is stateless and uses integer ticks for interval selection before f64 progress.
///
/// This is the canonical [`crate::Keyframes`] type.
///
/// ```
/// use motion_core::prelude::*;
/// use motion_core::{
///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
/// };
/// let track = Keyframes::new(0.0_f64)
///     .to(1.seconds(), 10.0, Easing::QuadraticIn)
///     .hold(500.milliseconds())
///     .to(2.5.seconds(), 20.0, Easing::Linear);
/// assert_eq!(track.sample(500.milliseconds()), 2.5);
/// assert_eq!(track.sample(1250.milliseconds()), 10.0);
/// assert_eq!(track.sample(2.seconds()), 15.0);
/// assert_eq!(track.duration(), Some(2.5.seconds()));
/// ```
#[derive(Clone, Debug)]
pub struct Keyframes<Value> {
    points: Vec<Keyframe<Value>>,
}

impl<Value> Keyframes<Value> {
    /// Starts with a value at zero. Without further points the finite duration
    /// is zero and every sample returns this value.
    pub fn new(value: Value) -> Self {
        Self {
            points: vec![Keyframe {
                at: Time::ZERO,
                value,
                easing: None,
            }],
        }
    }

    /// Appends a destination at an absolute local time, not an added duration.
    /// The easing controls the segment arriving at this value.
    ///
    /// # Panics
    /// Panics unless `at` is strictly later than the previous point, including
    /// any appended hold, or for invalid easing parameters. Equal, negative
    /// and descending times are not sorted or converted into instantaneous jumps.
    pub fn to(mut self, at: Time, value: Value, easing: Easing) -> Self {
        assert!(
            at > self.last().at,
            "keyframe times must be strictly increasing"
        );
        self.points.push(Keyframe {
            at,
            value,
            easing: Some(easing.validated()),
        });
        self
    }

    fn last(&self) -> &Keyframe<Value> {
        self.points
            .last()
            .expect("keyframes contain an initial value")
    }
}

impl<Value: Clone> Keyframes<Value> {
    /// Holds the last value for an additional duration, without interpolation.
    /// Zero is a no-op, leaving the last timestamp unchanged.
    ///
    /// # Panics
    /// Panics for a negative duration or signed tick overflow.
    pub fn hold(mut self, duration: Duration) -> Self {
        assert!(duration >= Time::ZERO, "keyframe hold must be nonnegative");
        if duration == Time::ZERO {
            return self;
        }
        let last = self.last();
        let point = Keyframe {
            at: last.at + duration,
            value: last.value.clone(),
            easing: None,
        };
        self.points.push(point);
        self
    }
}

impl<Value: Interpolate + Send + Sync> Animation<Value> for Keyframes<Value> {
    fn sample(&self, time: Time) -> Value {
        let upper = self.points.partition_point(|point| point.at <= time);
        if upper == 0 {
            return self.points[0].value.clone();
        }
        let left = &self.points[upper - 1];
        if upper == self.points.len() || time == left.at {
            return left.value.clone();
        }
        let right = &self.points[upper];
        let Some(easing) = &right.easing else {
            return left.value.clone();
        };
        let duration = right.at - left.at;
        let progress = easing.apply((time - left.at) / duration);
        left.value.interpolate(&right.value, progress)
    }

    fn duration(&self) -> Option<Duration> {
        Some(self.last().at)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Point, TimeLiteral, Tween};

    #[test]
    fn absolute_destinations_relative_holds_and_arriving_easing_are_seekable() {
        let track = Keyframes::new(0.0_f64)
            .to(1.seconds(), 10.0, Easing::QuadraticIn)
            .hold(500.milliseconds())
            .to(2.5.seconds(), 20.0, Easing::Linear);
        assert_eq!(track.duration(), Some(2.5.seconds()));
        for (milliseconds, expected) in [
            (2_000, 15.0),
            (0, 0.0),
            (500, 2.5),
            (1_000, 10.0),
            (1_250, 10.0),
            (1_500, 10.0),
            (2_500, 20.0),
            (500, 2.5),
        ] {
            assert_eq!(track.sample(milliseconds.milliseconds()), expected);
        }
        assert_eq!(track.sample(Time::from_ticks(i64::MIN)), 0.0);
        assert_eq!(track.sample(Time::from_ticks(i64::MAX)), 20.0);
    }

    #[test]
    fn progress_and_adjacent_ticks_survive_large_absolute_timestamps() {
        let track =
            Keyframes::new(0.0_f64).to(Time::from_ticks(10_000_000_000), 1.0, Easing::Linear);
        let progress = track.sample(Time::from_ticks(5_000_000_001));
        assert_eq!(progress, 0.500_000_000_1);
        assert_ne!(progress, f64::from(progress as f32));
        let start = Time::from_ticks(1_i64 << 53);
        let end = start + Time::from_ticks(3);
        let track = Keyframes::new(0.0_f64)
            .hold(start)
            .to(end, 3.0, Easing::Linear)
            .hold(Time::from_ticks(i64::MAX) - end);
        assert_eq!(track.duration(), Some(Time::from_ticks(i64::MAX)));
        assert_eq!(track.sample(start), 0.0);
        assert_eq!(track.sample(start + Time::from_ticks(1)), 1.0);
        assert_eq!(track.sample(start + Time::from_ticks(2)), 2.0);
        assert_eq!(track.sample(end), 3.0);
        assert_eq!(track.sample(Time::from_ticks(i64::MAX)), 3.0);
        let last_tick = Keyframes::new(2.0_f64)
            .hold(Time::from_ticks(i64::MAX - 1))
            .to(Time::from_ticks(i64::MAX), 7.0, Easing::Linear);
        assert_eq!(last_tick.sample(Time::from_ticks(i64::MAX - 1)), 2.0);
        assert_eq!(last_tick.sample(Time::from_ticks(i64::MAX)), 7.0);
    }

    #[test]
    fn holds_and_exact_points_never_call_value_interpolation() {
        #[derive(Clone, Debug, PartialEq)]
        struct ExactOnly(u32);

        impl Interpolate for ExactOnly {
            fn interpolate(&self, _target: &Self, _progress: f64) -> Self {
                panic!("hold or exact timestamp must return the stored value");
            }
        }
        let track = Keyframes::new(ExactOnly(1))
            .to(Time::from_ticks(10), ExactOnly(2), Easing::QuadraticOut)
            .hold(Time::from_ticks(5));
        for (ticks, expected) in [
            (i64::MIN, 1),
            (0, 1),
            (10, 2),
            (12, 2),
            (15, 2),
            (i64::MAX, 2),
        ] {
            assert_eq!(track.sample(Time::from_ticks(ticks)), ExactOnly(expected));
        }
        let constant = Keyframes::new(ExactOnly(3));
        assert_eq!(constant.duration(), Some(Time::ZERO));
        assert_eq!(constant.sample(Time::ZERO), ExactOnly(3));
        assert_eq!(constant.sample(Time::from_ticks(i64::MAX)), ExactOnly(3));
    }

    #[test]
    fn invalid_order_negative_holds_and_overflow_fail_during_building() {
        for ticks in [i64::MIN, -1, 0] {
            assert!(
                std::panic::catch_unwind(|| {
                    Keyframes::new(0.0_f64).to(Time::from_ticks(ticks), 1.0, Easing::Linear)
                })
                .is_err()
            );
        }

        fn track() -> Keyframes<f64> {
            Keyframes::new(0.0_f64).hold(Time::from_ticks(10))
        }
        for ticks in [9, 10] {
            assert!(
                std::panic::catch_unwind(|| {
                    track().to(Time::from_ticks(ticks), 1.0, Easing::Linear)
                })
                .is_err()
            );
        }
        assert!(std::panic::catch_unwind(|| track().hold(Time::from_ticks(-1))).is_err());
        let unchanged = track().hold(Time::ZERO);
        assert_eq!(unchanged.points.len(), 2);
        assert_eq!(unchanged.duration(), Some(Time::from_ticks(10)));
        assert!(
            std::panic::catch_unwind(|| {
                Keyframes::new(0.0_f64)
                    .hold(Time::from_ticks(i64::MAX))
                    .hold(Time::from_ticks(1))
            })
            .is_err()
        );
    }

    #[test]
    fn tracks_use_existing_combinators_dynamic_dispatch_and_local_frames() -> crate::Result {
        let track = crate::Keyframes::new(0.0_f64)
            .to(1.seconds(), 10.0, Easing::Linear)
            .hold(500.milliseconds())
            .to(2.seconds(), 20.0, Easing::Linear);
        let cache = crate::SceneCache::new(0);
        let local = crate::SceneContext::new(
            1500.milliseconds(),
            crate::FrameRate::from(60),
            crate::Size::new(64.0, 64.0),
            &cache,
        )
        .shifted(1.seconds());
        assert_eq!(local.sample(&track), 5.0);
        let boxed = track
            .clone()
            .map(|value| Point::new(value, value * 2.0))
            .boxed();
        assert_eq!(local.sample(boxed.as_ref()), Point::new(5.0, 10.0));
        assert_eq!(track.clone().reverse().sample(500.milliseconds()), 10.0);
        let repeated = track.clone().repeat(2).delay(250.milliseconds());
        assert_eq!(repeated.duration(), Some(4.25.seconds()));
        assert_eq!(repeated.sample(2.25.seconds()), 0.0);
        assert_eq!(repeated.sample(4.25.seconds()), 20.0);
        let joined = track.then(Tween::new(20.0_f64, 40.0, 1.seconds()));
        assert_eq!(joined.duration(), Some(3.seconds()));
        assert_eq!(joined.sample(2.5.seconds()), 30.0);
        assert_eq!(
            std::thread::spawn(move || boxed.sample(1.25.seconds()))
                .join()
                .unwrap(),
            Point::new(10.0, 20.0)
        );
        Ok(())
    }

    #[test]
    fn moving_segments_match_double_precision_tweens_including_overshoot() {
        for easing in [
            Easing::Linear,
            Easing::QuadraticIn,
            Easing::QuadraticOut,
            Easing::QuadraticInOut,
            Easing::CubicBezier {
                x1: 0.3,
                y1: 1.8,
                x2: 0.7,
                y2: 1.8,
            },
        ] {
            let track = Keyframes::new(10.0_f64).hold(300.milliseconds()).to(
                2.3.seconds(),
                30.0,
                easing.clone(),
            );
            let tween = Tween::new(10.0_f64, 30.0, 2.seconds()).easing(easing);
            for milliseconds in [1_000, 0, 1, 500, 1_999, 2_000] {
                let time = milliseconds.milliseconds();
                assert_eq!(track.sample(300.milliseconds() + time), tween.sample(time));
            }
        }
    }
}