Skip to main content

gpui_kit_material/motion/
expressive.rs

1//! The Expressive motion scheme: md3.io's sampled CSS spring curves.
2//!
3//! Reference priority is Figma kit > md3.io > m3e.kyralabs.dev, per value.
4//! The Figma Material 3 Design Kit defines no motion, and md3.io defines all
5//! six Expressive tokens (spatial and effects, fast/default/slow) with their
6//! durations, so md3.io wins over m3e's Compose springs for every one of them.
7//! Source: floor/material at c910072f3fc5b660276731d8a935d7186f288364,
8//! `src/styles/abstract/_variables.scss` (`linear()` curves sampled at 25
9//! uniform stops). Fixture: `conformance/fixtures/md3-expressive-browser.json`
10//! (Web Animations samples in Chromium). m3e's Expressive springs
11//! (`conformance/fixtures/m3e-motion.json`) are outranked and kept only as
12//! secondary evidence. See LICENSE-material for the MIT notice.
13//!
14//! Keep the sampled linear interpolation: an analytic spring is not the same
15//! curve.
16use std::time::Duration;
17
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct SampledSpring {
20    pub duration: Duration,
21    samples: [f32; 25],
22}
23
24impl SampledSpring {
25    /// CSS linear() interpolation between uniformly spaced reference samples.
26    /// Non-finite progress is treated as the start, so it never paints NaN.
27    pub fn position(self, progress: f32) -> f32 {
28        if !progress.is_finite() {
29            return 0.;
30        }
31        let progress = progress.clamp(0., 1.);
32        if progress >= 1. {
33            return 1.;
34        }
35        let position = progress * 24.;
36        let index = position.floor() as usize;
37        let fraction = position - index as f32;
38        self.samples[index] + (self.samples[index + 1] - self.samples[index]) * fraction
39    }
40}
41
42pub const FAST_SPATIAL: SampledSpring = SampledSpring {
43    duration: Duration::from_millis(425),
44    samples: [
45        0.0, 0.102, 0.323, 0.57, 0.784, 0.942, 1.039, 1.085, 1.094, 1.082, 1.06, 1.037, 1.018,
46        1.004, 0.995, 0.992, 0.991, 0.992, 0.995, 0.997, 0.999, 1.0, 1.001, 1.001, 1.0,
47    ],
48};
49
50pub const DEFAULT_SPATIAL: SampledSpring = SampledSpring {
51    duration: Duration::from_millis(450),
52    samples: [
53        0.0, 0.055, 0.18, 0.332, 0.483, 0.619, 0.733, 0.823, 0.891, 0.94, 0.973, 0.994, 1.007,
54        1.013, 1.015, 1.015, 1.013, 1.011, 1.009, 1.006, 1.005, 1.003, 1.002, 1.001, 1.0,
55    ],
56};
57
58pub const SLOW_SPATIAL: SampledSpring = SampledSpring {
59    duration: Duration::from_millis(625),
60    samples: [
61        0.0, 0.056, 0.182, 0.335, 0.488, 0.624, 0.738, 0.827, 0.895, 0.943, 0.975, 0.996, 1.007,
62        1.013, 1.015, 1.015, 1.013, 1.011, 1.008, 1.006, 1.004, 1.003, 1.002, 1.001, 1.0,
63    ],
64};
65
66pub const FAST_EFFECTS: SampledSpring = SampledSpring {
67    duration: Duration::from_millis(175),
68    samples: [
69        0.0, 0.075, 0.227, 0.39, 0.537, 0.657, 0.751, 0.822, 0.874, 0.912, 0.939, 0.958, 0.971,
70        0.98, 0.987, 0.991, 0.994, 0.996, 0.997, 0.998, 0.999, 0.999, 0.999, 1.0, 1.0,
71    ],
72};
73
74pub const DEFAULT_EFFECTS: SampledSpring = SampledSpring {
75    duration: Duration::from_millis(250),
76    samples: [
77        0.0, 0.066, 0.203, 0.355, 0.496, 0.616, 0.713, 0.788, 0.845, 0.888, 0.92, 0.943, 0.96,
78        0.971, 0.98, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.998, 0.999, 0.999, 1.0,
79    ],
80};
81
82pub const SLOW_EFFECTS: SampledSpring = SampledSpring {
83    duration: Duration::from_millis(350),
84    samples: [
85        0.0, 0.065, 0.2, 0.351, 0.491, 0.611, 0.708, 0.783, 0.841, 0.885, 0.917, 0.941, 0.958,
86        0.97, 0.979, 0.985, 0.99, 0.993, 0.995, 0.997, 0.998, 0.998, 0.999, 0.999, 1.0,
87    ],
88};
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn non_finite_progress_paints_the_start() {
96        for progress in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
97            assert_eq!(FAST_SPATIAL.position(progress), 0.);
98        }
99        assert_eq!(FAST_SPATIAL.position(1.), 1.);
100    }
101
102    #[test]
103    fn curves_match_browser_computed_transforms() {
104        let reference: serde_json::Value = serde_json::from_str(include_str!(
105            "../../../../conformance/fixtures/md3-expressive-browser.json"
106        ))
107        .unwrap();
108        let curves = reference["curves"].as_array().unwrap();
109        assert_eq!(curves.len(), 6);
110        let mut names = std::collections::BTreeSet::new();
111        for curve in curves {
112            let token = curve["token"].as_str().unwrap();
113            assert!(names.insert(token), "duplicate reference curve {token}");
114            assert_eq!(curve["samples"].as_array().unwrap().len(), 29);
115            let spring = match token {
116                "fast-spatial" => FAST_SPATIAL,
117                "default-spatial" => DEFAULT_SPATIAL,
118                "slow-spatial" => SLOW_SPATIAL,
119                "fast-effects" => FAST_EFFECTS,
120                "default-effects" => DEFAULT_EFFECTS,
121                "slow-effects" => SLOW_EFFECTS,
122                _ => panic!("unexpected reference token {token}"),
123            };
124            assert_eq!(
125                spring.duration.as_millis(),
126                curve["duration_ms"].as_u64().unwrap() as u128
127            );
128            for sample in curve["samples"].as_array().unwrap() {
129                let progress = sample[0].as_f64().unwrap() as f32;
130                let expected = sample[1].as_f64().unwrap() as f32;
131                let actual = spring.position(progress);
132                assert!(
133                    (actual - expected).abs() < 0.00001,
134                    "{token} at {progress}: {actual} vs browser {expected}"
135                );
136            }
137        }
138    }
139
140    #[test]
141    fn spatial_overshoot_is_preserved_and_effects_are_bounded() {
142        for spring in [FAST_SPATIAL, DEFAULT_SPATIAL, SLOW_SPATIAL] {
143            assert!((0..=1000).any(|t| spring.position(t as f32 / 1000.) > 1.01));
144            assert_eq!(spring.position(-1.), 0.);
145            assert_eq!(spring.position(1.), 1.);
146            assert_eq!(spring.position(2.), 1.);
147        }
148        for spring in [FAST_EFFECTS, DEFAULT_EFFECTS, SLOW_EFFECTS] {
149            let values = (0..=1000)
150                .map(|t| spring.position(t as f32 / 1000.))
151                .collect::<Vec<_>>();
152            assert!(values.iter().all(|v| (0. ..=1.).contains(v)));
153            assert!(values.windows(2).all(|p| p[0] <= p[1]));
154        }
155    }
156}