Skip to main content

rdi_core/
curve.rs

1//! Animation curves.
2//!
3//! Every axis of every icon animation is driven by a value in `[0, 1]`
4//! produced by an [`AnimationCurve`]. The engine linearly maps this value
5//! from the origin to the target coordinate, so overshoot curves (values
6//! outside `[0, 1]`) are permitted.
7//!
8//! Two concrete implementations share the trait:
9//!
10//! * [`BuiltinEasing`] — closed-form easings (`Linear`, `EaseInOut`,
11//!   `SineOut`, `CubicBezier { .. }`, …).
12//! * [`KeyframeCurve`] — a sorted list of `(t, v)` pairs plus a
13//!   [`KeyframeInterp`] mode.
14//!
15//! Keyframe curves can also be built by **sampling a user-supplied
16//! function** at spec-build time via [`KeyframeCurve::from_curve_fn`] or
17//! [`KeyframeCurve::from_motion_fn`]. The callable is invoked only during
18//! construction; the resulting curve is pure data and the tick loop never
19//! calls back into user code.
20
21use core::f32::consts::{FRAC_PI_2, PI};
22use std::collections::HashMap;
23
24use crate::{CurveError, Point};
25
26/// Trait implemented by every curve variant.
27///
28/// `eval` accepts any `t` and internally saturates it into `[0, 1]`.
29pub trait AnimationCurve: Send + Sync + core::fmt::Debug {
30    fn eval(&self, t: f32) -> f32;
31}
32
33// ---------------------------------------------------------------------------
34// BuiltinEasing
35// ---------------------------------------------------------------------------
36
37/// Closed-form easing functions.
38#[derive(Copy, Clone, Debug, PartialEq)]
39pub enum BuiltinEasing {
40    /// `f(t) = t`
41    Linear,
42
43    // Quadratic (aliased to the "ease" names).
44    EaseIn,
45    EaseOut,
46    EaseInOut,
47    QuadIn,
48    QuadOut,
49    QuadInOut,
50
51    // Cubic.
52    CubicIn,
53    CubicOut,
54    CubicInOut,
55
56    // Sinusoidal.
57    SineIn,
58    SineOut,
59    SineInOut,
60
61    /// CSS-style parametric cubic Bézier through `(0,0)`, `(c1x,c1y)`,
62    /// `(c2x,c2y)`, `(1,1)`. `c1x` and `c2x` must lie in `[0, 1]` so the
63    /// curve is a proper function of `t`.
64    CubicBezier { c1x: f32, c1y: f32, c2x: f32, c2y: f32 },
65}
66
67impl BuiltinEasing {
68    /// Evaluate the curve at `t`.
69    pub fn eval(&self, t: f32) -> f32 {
70        let t = t.clamp(0.0, 1.0);
71        use BuiltinEasing::*;
72        match *self {
73            Linear => t,
74            EaseIn | QuadIn => t * t,
75            EaseOut | QuadOut => t * (2.0 - t),
76            EaseInOut | QuadInOut => {
77                if t < 0.5 {
78                    2.0 * t * t
79                } else {
80                    1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
81                }
82            }
83            CubicIn => t * t * t,
84            CubicOut => {
85                let u = 1.0 - t;
86                1.0 - u * u * u
87            }
88            CubicInOut => {
89                if t < 0.5 {
90                    4.0 * t * t * t
91                } else {
92                    let u = -2.0 * t + 2.0;
93                    1.0 - u * u * u / 2.0
94                }
95            }
96            SineIn => 1.0 - (t * FRAC_PI_2).cos(),
97            SineOut => (t * FRAC_PI_2).sin(),
98            SineInOut => -((PI * t).cos() - 1.0) / 2.0,
99            CubicBezier { c1x, c1y, c2x, c2y } => cubic_bezier_eval(c1x, c1y, c2x, c2y, t),
100        }
101    }
102
103    /// Validate parametric variants. Called once at [`Curve`] construction.
104    pub fn validate(&self) -> Result<(), CurveError> {
105        if let BuiltinEasing::CubicBezier { c1x, c2x, .. } = *self {
106            if !c1x.is_finite() || !c2x.is_finite() || !(0.0..=1.0).contains(&c1x)
107                || !(0.0..=1.0).contains(&c2x)
108            {
109                return Err(CurveError::InvalidBezier { c1x, c2x });
110            }
111        }
112        Ok(())
113    }
114}
115
116/// Solve the cubic Bézier `Y` given a normalized time `x`, using
117/// Newton-Raphson with a bisection fallback. Mirrors WebKit's
118/// `UnitBezier` implementation.
119fn cubic_bezier_eval(c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32) -> f32 {
120    if x <= 0.0 {
121        return 0.0;
122    }
123    if x >= 1.0 {
124        return 1.0;
125    }
126
127    // Polynomial coefficients for the Bernstein basis with P0 = 0, P3 = 1.
128    let cx = 3.0 * c1x;
129    let bx = 3.0 * (c2x - c1x) - cx;
130    let ax = 1.0 - cx - bx;
131    let cy = 3.0 * c1y;
132    let by = 3.0 * (c2y - c1y) - cy;
133    let ay = 1.0 - cy - by;
134
135    let sample_x = |s: f32| ((ax * s + bx) * s + cx) * s;
136    let sample_dx = |s: f32| (3.0 * ax * s + 2.0 * bx) * s + cx;
137    let sample_y = |s: f32| ((ay * s + by) * s + cy) * s;
138
139    // Newton-Raphson for a few iterations.
140    let mut s = x;
141    for _ in 0..8 {
142        let x_est = sample_x(s);
143        let dx = sample_dx(s);
144        if dx.abs() < 1e-6 {
145            break;
146        }
147        let delta = (x_est - x) / dx;
148        s -= delta;
149        if delta.abs() < 1e-6 {
150            return sample_y(s.clamp(0.0, 1.0));
151        }
152    }
153
154    // Bisection fallback (guaranteed convergence).
155    let (mut lo, mut hi) = (0.0f32, 1.0f32);
156    let mut s = x;
157    for _ in 0..32 {
158        let x_est = sample_x(s);
159        if (x_est - x).abs() < 1e-7 {
160            break;
161        }
162        if x_est < x {
163            lo = s;
164        } else {
165            hi = s;
166        }
167        s = (lo + hi) * 0.5;
168    }
169    sample_y(s.clamp(0.0, 1.0))
170}
171
172// ---------------------------------------------------------------------------
173// Keyframes
174// ---------------------------------------------------------------------------
175
176/// One entry in a [`KeyframeCurve`].
177#[derive(Copy, Clone, Debug, PartialEq)]
178pub struct Keyframe {
179    pub t: f32,
180    pub v: f32,
181}
182
183impl Keyframe {
184    #[inline]
185    pub const fn new(t: f32, v: f32) -> Self {
186        Self { t, v }
187    }
188}
189
190/// How adjacent keyframes are interpolated.
191#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
192pub enum KeyframeInterp {
193    /// Straight-line interpolation.
194    #[default]
195    Linear,
196    /// Step function — value snaps to the left keyframe until the next one.
197    Step,
198    /// Hermite smoothstep `s = t² · (3 − 2t)` between each pair of keys.
199    SmoothStep,
200}
201
202/// A sorted, validated list of `(t, v)` pairs plus an interpolation mode.
203///
204/// Invariants (enforced at construction):
205///
206/// * At least 2 keys.
207/// * `keys[0].t == 0.0`, `keys.last().t == 1.0`.
208/// * All `t`s are strictly ascending and finite.
209/// * All `v`s are finite.
210#[derive(Clone, Debug, PartialEq)]
211pub struct KeyframeCurve {
212    keys: Vec<Keyframe>,
213    interp: KeyframeInterp,
214}
215
216/// Absolute clamp applied to every sampled value in
217/// [`KeyframeCurve::from_curve_fn`] / [`KeyframeCurve::from_motion_fn`].
218///
219/// Values outside `[-VALUE_CLAMP, VALUE_CLAMP]` (or non-finite) are rejected
220/// with [`CurveError::SampledValueInvalid`]. The bound is generous enough for
221/// spring / bounce / elastic curves while still catching runaway user code.
222pub const VALUE_CLAMP: f32 = 100.0;
223
224/// Minimum permissible sample count for a function-generated curve.
225pub const MIN_SAMPLES: u32 = 2;
226/// Maximum permissible sample count for a function-generated curve.
227pub const MAX_SAMPLES: u32 = 4096;
228
229impl KeyframeCurve {
230    /// Construct from an explicit list of keys.
231    pub fn new(keys: Vec<Keyframe>, interp: KeyframeInterp) -> Result<Self, CurveError> {
232        Self::validate(&keys)?;
233        Ok(Self { keys, interp })
234    }
235
236    /// Borrow the internal key list.
237    #[inline]
238    pub fn keys(&self) -> &[Keyframe] {
239        &self.keys
240    }
241
242    #[inline]
243    pub fn interpolation(&self) -> KeyframeInterp {
244        self.interp
245    }
246
247    /// Evaluate the curve at `t`, saturating outside `[0, 1]`.
248    pub fn eval(&self, t: f32) -> f32 {
249        let t = t.clamp(0.0, 1.0);
250        // `partition_point` returns the first index whose key.t is strictly
251        // greater than `t`. Because keys are strictly ascending it uniquely
252        // locates the segment `[idx-1, idx]`.
253        let idx = self.keys.partition_point(|k| k.t <= t);
254        if idx == 0 {
255            return self.keys[0].v;
256        }
257        if idx >= self.keys.len() {
258            return self.keys[self.keys.len() - 1].v;
259        }
260        let a = self.keys[idx - 1];
261        let b = self.keys[idx];
262        let span = b.t - a.t;
263        if span <= f32::EPSILON {
264            return b.v;
265        }
266        let local = (t - a.t) / span;
267        match self.interp {
268            KeyframeInterp::Linear => a.v + (b.v - a.v) * local,
269            KeyframeInterp::Step => a.v,
270            KeyframeInterp::SmoothStep => {
271                let s = local * local * (3.0 - 2.0 * local);
272                a.v + (b.v - a.v) * s
273            }
274        }
275    }
276
277    /// Build a curve by sampling a plain normalized function
278    /// `f(t) -> v` at `samples` evenly-spaced points in `[0, 1]`. Both
279    /// endpoints (`t = 0` and `t = 1`) are always sampled.
280    ///
281    /// The callable is invoked only during this call and dropped before
282    /// returning; the resulting curve is pure data.
283    ///
284    /// Rejects sample counts outside `[MIN_SAMPLES, MAX_SAMPLES]` and any
285    /// sample that is non-finite or outside `[-VALUE_CLAMP, VALUE_CLAMP]`.
286    pub fn from_curve_fn<F>(
287        mut f: F,
288        samples: u32,
289        interp: KeyframeInterp,
290    ) -> Result<Self, CurveError>
291    where
292        F: FnMut(f32) -> f32,
293    {
294        Self::sample(samples, interp, |t| f(t))
295    }
296
297    /// Build a curve by sampling a **motion-aware** function
298    /// `f(&ctx, t) -> v`. The callable receives the movement metadata
299    /// (origin, target, distance, duration, user-supplied params) so it
300    /// can shape the curve procedurally (spring, gravity, physics …).
301    ///
302    /// Same constraints and drop semantics as [`Self::from_curve_fn`].
303    pub fn from_motion_fn<F>(
304        mut f: F,
305        ctx: &MotionContext,
306        samples: u32,
307        interp: KeyframeInterp,
308    ) -> Result<Self, CurveError>
309    where
310        F: FnMut(&MotionContext, f32) -> f32,
311    {
312        Self::sample(samples, interp, |t| f(ctx, t))
313    }
314
315    fn sample<F: FnMut(f32) -> f32>(
316        samples: u32,
317        interp: KeyframeInterp,
318        mut f: F,
319    ) -> Result<Self, CurveError> {
320        if !(MIN_SAMPLES..=MAX_SAMPLES).contains(&samples) {
321            return Err(CurveError::InvalidSampleCount { got: samples });
322        }
323
324        let n = samples as usize;
325        let mut raw: Vec<Keyframe> = Vec::with_capacity(n);
326        let last_i = n - 1;
327        for i in 0..n {
328            // Force endpoints to exact 0.0 and 1.0 to survive float rounding.
329            let t = if i == 0 {
330                0.0
331            } else if i == last_i {
332                1.0
333            } else {
334                i as f32 / last_i as f32
335            };
336            let v = f(t);
337            if !v.is_finite() || v < -VALUE_CLAMP || v > VALUE_CLAMP {
338                return Err(CurveError::SampledValueInvalid { t, v });
339            }
340            raw.push(Keyframe { t, v });
341        }
342
343        // Collapse runs of identical values. Preserve the first and last
344        // key of every plateau so the curve shape is unchanged.
345        let mut dedup: Vec<Keyframe> = Vec::with_capacity(raw.len());
346        for (i, k) in raw.iter().enumerate() {
347            if i == 0 || i == last_i {
348                dedup.push(*k);
349                continue;
350            }
351            let prev_v = raw[i - 1].v;
352            let next_v = raw[i + 1].v;
353            if prev_v == k.v && next_v == k.v {
354                continue;
355            }
356            dedup.push(*k);
357        }
358
359        Self::new(dedup, interp)
360    }
361
362    fn validate(keys: &[Keyframe]) -> Result<(), CurveError> {
363        if keys.len() < 2 {
364            return Err(CurveError::TooFewKeys(keys.len()));
365        }
366        if keys[0].t != 0.0 {
367            return Err(CurveError::FirstKeyNotZero(keys[0].t));
368        }
369        let last = keys[keys.len() - 1];
370        if last.t != 1.0 {
371            return Err(CurveError::LastKeyNotOne(last.t));
372        }
373        let mut prev_t = f32::NEG_INFINITY;
374        for (i, k) in keys.iter().enumerate() {
375            if !k.t.is_finite() || k.t < 0.0 || k.t > 1.0 || k.t <= prev_t {
376                // Special-case the first key: prev_t is -inf so t=0 is fine.
377                if !(i == 0 && k.t == 0.0) {
378                    return Err(CurveError::InvalidKeyOrder { index: i, t: k.t });
379                }
380            }
381            if !k.v.is_finite() {
382                return Err(CurveError::NonFiniteValue {
383                    index: i,
384                    t: k.t,
385                    v: k.v,
386                });
387            }
388            prev_t = k.t;
389        }
390        Ok(())
391    }
392}
393
394impl AnimationCurve for KeyframeCurve {
395    #[inline]
396    fn eval(&self, t: f32) -> f32 {
397        self.eval(t)
398    }
399}
400
401// ---------------------------------------------------------------------------
402// MotionContext
403// ---------------------------------------------------------------------------
404
405/// Movement metadata handed to motion-aware curve samplers.
406#[derive(Clone, Debug)]
407pub struct MotionContext {
408    pub origin: Point,
409    pub target: Point,
410    /// Euclidean distance between `origin` and `target`, in pixels.
411    pub distance_px: f32,
412    /// Concrete wall-clock duration the animation will run for.
413    pub duration: std::time::Duration,
414    /// Free-form parameter bag for the sampler function.
415    pub params: HashMap<String, f64>,
416}
417
418impl MotionContext {
419    /// Construct with `distance_px` computed automatically.
420    pub fn new(origin: Point, target: Point, duration: std::time::Duration) -> Self {
421        Self {
422            origin,
423            target,
424            distance_px: Point::distance(origin, target),
425            duration,
426            params: HashMap::new(),
427        }
428    }
429
430    #[must_use]
431    pub fn with_params(mut self, params: HashMap<String, f64>) -> Self {
432        self.params = params;
433        self
434    }
435
436    #[must_use]
437    pub fn with_param(mut self, key: impl Into<String>, value: f64) -> Self {
438        self.params.insert(key.into(), value);
439        self
440    }
441
442    #[inline]
443    pub fn param(&self, key: &str) -> Option<f64> {
444        self.params.get(key).copied()
445    }
446}
447
448// ---------------------------------------------------------------------------
449// Curve
450// ---------------------------------------------------------------------------
451
452/// The public curve type used inside every [`crate::IconAnimationSpec`].
453///
454/// It combines the two variants under one enum so specs can be cloned and
455/// stored without dynamic dispatch on the hot path.
456#[derive(Clone, Debug, PartialEq)]
457pub enum Curve {
458    Builtin(BuiltinEasing),
459    Keyframe(KeyframeCurve),
460}
461
462impl Curve {
463    // --- Builtin constructors ---------------------------------------------
464
465    pub fn linear() -> Self {
466        Self::Builtin(BuiltinEasing::Linear)
467    }
468    pub fn ease_in() -> Self {
469        Self::Builtin(BuiltinEasing::EaseIn)
470    }
471    pub fn ease_out() -> Self {
472        Self::Builtin(BuiltinEasing::EaseOut)
473    }
474    pub fn ease_in_out() -> Self {
475        Self::Builtin(BuiltinEasing::EaseInOut)
476    }
477    pub fn quad_in() -> Self {
478        Self::Builtin(BuiltinEasing::QuadIn)
479    }
480    pub fn quad_out() -> Self {
481        Self::Builtin(BuiltinEasing::QuadOut)
482    }
483    pub fn quad_in_out() -> Self {
484        Self::Builtin(BuiltinEasing::QuadInOut)
485    }
486    pub fn cubic_in() -> Self {
487        Self::Builtin(BuiltinEasing::CubicIn)
488    }
489    pub fn cubic_out() -> Self {
490        Self::Builtin(BuiltinEasing::CubicOut)
491    }
492    pub fn cubic_in_out() -> Self {
493        Self::Builtin(BuiltinEasing::CubicInOut)
494    }
495    pub fn sine_in() -> Self {
496        Self::Builtin(BuiltinEasing::SineIn)
497    }
498    pub fn sine_out() -> Self {
499        Self::Builtin(BuiltinEasing::SineOut)
500    }
501    pub fn sine_in_out() -> Self {
502        Self::Builtin(BuiltinEasing::SineInOut)
503    }
504
505    /// Parametric cubic Bézier easing. `c1x` and `c2x` must lie in `[0, 1]`.
506    pub fn cubic_bezier(c1x: f32, c1y: f32, c2x: f32, c2y: f32) -> Result<Self, CurveError> {
507        let e = BuiltinEasing::CubicBezier { c1x, c1y, c2x, c2y };
508        e.validate()?;
509        Ok(Self::Builtin(e))
510    }
511
512    // --- Keyframe constructors --------------------------------------------
513
514    pub fn keyframes(keys: Vec<Keyframe>, interp: KeyframeInterp) -> Result<Self, CurveError> {
515        Ok(Self::Keyframe(KeyframeCurve::new(keys, interp)?))
516    }
517
518    pub fn from_curve_fn<F>(f: F, samples: u32, interp: KeyframeInterp) -> Result<Self, CurveError>
519    where
520        F: FnMut(f32) -> f32,
521    {
522        Ok(Self::Keyframe(KeyframeCurve::from_curve_fn(
523            f, samples, interp,
524        )?))
525    }
526
527    pub fn from_motion_fn<F>(
528        f: F,
529        ctx: &MotionContext,
530        samples: u32,
531        interp: KeyframeInterp,
532    ) -> Result<Self, CurveError>
533    where
534        F: FnMut(&MotionContext, f32) -> f32,
535    {
536        Ok(Self::Keyframe(KeyframeCurve::from_motion_fn(
537            f, ctx, samples, interp,
538        )?))
539    }
540}
541
542impl AnimationCurve for Curve {
543    #[inline]
544    fn eval(&self, t: f32) -> f32 {
545        match self {
546            Curve::Builtin(e) => e.eval(t),
547            Curve::Keyframe(k) => KeyframeCurve::eval(k, t),
548        }
549    }
550}
551
552// ---------------------------------------------------------------------------
553// Tests
554// ---------------------------------------------------------------------------
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    const EPS: f32 = 1e-4;
561
562    fn near(a: f32, b: f32) {
563        assert!((a - b).abs() < EPS, "expected ~{b}, got {a}");
564    }
565
566    // --- BuiltinEasing ----------------------------------------------------
567
568    #[test]
569    fn builtin_endpoints_for_non_overshoot() {
570        for e in [
571            BuiltinEasing::Linear,
572            BuiltinEasing::EaseIn,
573            BuiltinEasing::EaseOut,
574            BuiltinEasing::EaseInOut,
575            BuiltinEasing::QuadIn,
576            BuiltinEasing::QuadOut,
577            BuiltinEasing::QuadInOut,
578            BuiltinEasing::CubicIn,
579            BuiltinEasing::CubicOut,
580            BuiltinEasing::CubicInOut,
581            BuiltinEasing::SineIn,
582            BuiltinEasing::SineOut,
583            BuiltinEasing::SineInOut,
584        ] {
585            near(e.eval(0.0), 0.0);
586            near(e.eval(1.0), 1.0);
587        }
588    }
589
590    #[test]
591    fn linear_is_identity() {
592        for i in 0..=10 {
593            let t = i as f32 / 10.0;
594            near(BuiltinEasing::Linear.eval(t), t);
595        }
596    }
597
598    #[test]
599    fn builtin_saturates_outside_unit_interval() {
600        near(BuiltinEasing::CubicInOut.eval(-5.0), 0.0);
601        near(BuiltinEasing::CubicInOut.eval(5.0), 1.0);
602    }
603
604    #[test]
605    fn cubic_bezier_matches_linear_for_default_line() {
606        // The Bezier (0,0)-(1/3,1/3)-(2/3,2/3)-(1,1) is a straight line.
607        let c = Curve::cubic_bezier(1.0 / 3.0, 1.0 / 3.0, 2.0 / 3.0, 2.0 / 3.0).unwrap();
608        for i in 0..=10 {
609            let t = i as f32 / 10.0;
610            near(c.eval(t), t);
611        }
612    }
613
614    #[test]
615    fn cubic_bezier_rejects_out_of_range_x() {
616        let err = Curve::cubic_bezier(-0.1, 0.0, 1.1, 0.0).unwrap_err();
617        assert!(matches!(err, CurveError::InvalidBezier { .. }));
618    }
619
620    // --- KeyframeCurve ----------------------------------------------------
621
622    #[test]
623    fn keyframe_rejects_too_few_keys() {
624        let err = KeyframeCurve::new(vec![Keyframe::new(0.0, 0.0)], KeyframeInterp::Linear)
625            .unwrap_err();
626        assert!(matches!(err, CurveError::TooFewKeys(1)));
627    }
628
629    #[test]
630    fn keyframe_rejects_bad_endpoints() {
631        let err = KeyframeCurve::new(
632            vec![Keyframe::new(0.1, 0.0), Keyframe::new(1.0, 1.0)],
633            KeyframeInterp::Linear,
634        )
635        .unwrap_err();
636        assert!(matches!(err, CurveError::FirstKeyNotZero(_)));
637
638        let err = KeyframeCurve::new(
639            vec![Keyframe::new(0.0, 0.0), Keyframe::new(0.9, 1.0)],
640            KeyframeInterp::Linear,
641        )
642        .unwrap_err();
643        assert!(matches!(err, CurveError::LastKeyNotOne(_)));
644    }
645
646    #[test]
647    fn keyframe_rejects_non_ascending_times() {
648        let err = KeyframeCurve::new(
649            vec![
650                Keyframe::new(0.0, 0.0),
651                Keyframe::new(0.5, 0.5),
652                Keyframe::new(0.4, 0.4),
653                Keyframe::new(1.0, 1.0),
654            ],
655            KeyframeInterp::Linear,
656        )
657        .unwrap_err();
658        assert!(matches!(err, CurveError::InvalidKeyOrder { .. }));
659    }
660
661    #[test]
662    fn keyframe_rejects_nan_value() {
663        let err = KeyframeCurve::new(
664            vec![
665                Keyframe::new(0.0, 0.0),
666                Keyframe::new(0.5, f32::NAN),
667                Keyframe::new(1.0, 1.0),
668            ],
669            KeyframeInterp::Linear,
670        )
671        .unwrap_err();
672        assert!(matches!(err, CurveError::NonFiniteValue { .. }));
673    }
674
675    #[test]
676    fn keyframe_linear_interpolates() {
677        let c = KeyframeCurve::new(
678            vec![
679                Keyframe::new(0.0, 0.0),
680                Keyframe::new(0.5, 0.2),
681                Keyframe::new(1.0, 1.0),
682            ],
683            KeyframeInterp::Linear,
684        )
685        .unwrap();
686        near(c.eval(0.0), 0.0);
687        near(c.eval(0.25), 0.1);
688        near(c.eval(0.5), 0.2);
689        near(c.eval(0.75), 0.6);
690        near(c.eval(1.0), 1.0);
691    }
692
693    #[test]
694    fn keyframe_step_interpolates() {
695        let c = KeyframeCurve::new(
696            vec![
697                Keyframe::new(0.0, 0.0),
698                Keyframe::new(0.5, 1.0),
699                Keyframe::new(1.0, 1.0),
700            ],
701            KeyframeInterp::Step,
702        )
703        .unwrap();
704        near(c.eval(0.49), 0.0);
705        near(c.eval(0.5), 1.0);
706        near(c.eval(0.99), 1.0);
707    }
708
709    #[test]
710    fn keyframe_smoothstep_matches_hermite_midpoint() {
711        let c = KeyframeCurve::new(
712            vec![Keyframe::new(0.0, 0.0), Keyframe::new(1.0, 1.0)],
713            KeyframeInterp::SmoothStep,
714        )
715        .unwrap();
716        // smoothstep(0.5) = 0.5.
717        near(c.eval(0.5), 0.5);
718        // smoothstep(0.25) = 0.25² · (3 − 2·0.25) = 0.15625.
719        near(c.eval(0.25), 0.15625);
720    }
721
722    // --- from_curve_fn / from_motion_fn -----------------------------------
723
724    #[test]
725    fn from_curve_fn_rejects_bad_sample_counts() {
726        for &bad in &[0u32, 1, MAX_SAMPLES + 1, u32::MAX] {
727            let err =
728                KeyframeCurve::from_curve_fn(|t| t, bad, KeyframeInterp::Linear).unwrap_err();
729            assert!(matches!(err, CurveError::InvalidSampleCount { .. }), "sample {bad}");
730        }
731    }
732
733    #[test]
734    fn from_curve_fn_samples_linear_identity() {
735        let c = KeyframeCurve::from_curve_fn(|t| t, 33, KeyframeInterp::Linear).unwrap();
736        for i in 0..=10 {
737            let t = i as f32 / 10.0;
738            near(c.eval(t), t);
739        }
740    }
741
742    #[test]
743    fn from_curve_fn_rejects_nan_or_infinite() {
744        let err = KeyframeCurve::from_curve_fn(|_| f32::NAN, 8, KeyframeInterp::Linear)
745            .unwrap_err();
746        assert!(matches!(err, CurveError::SampledValueInvalid { .. }));
747
748        let err = KeyframeCurve::from_curve_fn(|_| f32::INFINITY, 8, KeyframeInterp::Linear)
749            .unwrap_err();
750        assert!(matches!(err, CurveError::SampledValueInvalid { .. }));
751    }
752
753    #[test]
754    fn from_curve_fn_rejects_out_of_clamp() {
755        let err =
756            KeyframeCurve::from_curve_fn(|_| 200.0, 8, KeyframeInterp::Linear).unwrap_err();
757        assert!(matches!(err, CurveError::SampledValueInvalid { t: _, v }
758                          if (v - 200.0).abs() < 1e-6));
759    }
760
761    #[test]
762    fn from_curve_fn_only_calls_callable_at_build_time() {
763        let count = std::sync::atomic::AtomicUsize::new(0);
764        let curve = KeyframeCurve::from_curve_fn(
765            |t| {
766                count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
767                t
768            },
769            10,
770            KeyframeInterp::Linear,
771        )
772        .unwrap();
773        let calls_after_build = count.load(std::sync::atomic::Ordering::Relaxed);
774        // Sample the curve many times; the underlying closure must never
775        // be re-invoked.
776        for _ in 0..1_000 {
777            let _ = curve.eval(0.42);
778        }
779        assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), calls_after_build);
780        assert_eq!(calls_after_build, 10);
781    }
782
783    #[test]
784    fn from_curve_fn_deduplicates_flat_middle() {
785        // Constant 0.5 in the middle: expect the two edge keys to survive
786        // plus at least one entry per plateau boundary.
787        let c = KeyframeCurve::from_curve_fn(
788            |t| if t < 0.25 || t > 0.75 { t } else { 0.5 },
789            33,
790            KeyframeInterp::Linear,
791        )
792        .unwrap();
793        // Should be fewer than 33 keys after dedup.
794        assert!(c.keys().len() < 33, "dedup left {} keys", c.keys().len());
795        near(c.eval(0.0), 0.0);
796        near(c.eval(1.0), 1.0);
797    }
798
799    #[test]
800    fn from_motion_fn_receives_context() {
801        let ctx = MotionContext::new(
802            Point::new(0, 0),
803            Point::new(300, 400),
804            std::time::Duration::from_secs(1),
805        )
806        .with_param("g", 9.81);
807        assert!((ctx.distance_px - 500.0).abs() < 1e-4);
808        assert_eq!(ctx.param("g"), Some(9.81));
809
810        let curve = KeyframeCurve::from_motion_fn(
811            |ctx, t| {
812                // Simple: scale t by params["g"]/10.
813                let g = ctx.params.get("g").copied().unwrap_or(1.0) as f32;
814                (t * g / 10.0).min(1.0)
815            },
816            &ctx,
817            16,
818            KeyframeInterp::Linear,
819        )
820        .unwrap();
821        // At t=1 the raw value is 0.981 (< 1) → curve endpoint stays 0.981.
822        assert!(curve.eval(1.0) > 0.9);
823    }
824
825    // --- Curve wrapping ---------------------------------------------------
826
827    #[test]
828    fn curve_dispatch_matches_underlying_variant() {
829        let a = Curve::linear();
830        let b = Curve::from_curve_fn(|t| t, 8, KeyframeInterp::Linear).unwrap();
831        near(a.eval(0.42), 0.42);
832        near(b.eval(0.42), 0.42);
833    }
834
835    // --- Send/Sync -------------------------------------------------------
836
837    fn assert_send_sync<T: Send + Sync>() {}
838
839    #[test]
840    fn curves_are_send_sync() {
841        assert_send_sync::<Curve>();
842        assert_send_sync::<KeyframeCurve>();
843        assert_send_sync::<BuiltinEasing>();
844    }
845}