motion.rsannotatedmotion.rssource318 lines · 12.0 KB · raw

How a group moves, as a function of time. The site plays it as CSS keyframes sampled from at and its preview video bakes at(t) into each frame; the app evaluates the same formulas in Kotlin (art/Motion.kt), and fixtures/frames.txt holds the two to each other.

5use std::f32::consts::TAU;

A transform and an opacity, applied about the group's origin.

8#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct Xf {
10    pub tx: f32,
11    pub ty: f32,
12    pub rotate: f32,
13    pub sx: f32,
14    pub sy: f32,
15    pub opacity: f32,
16}
18impl Xf {
19    pub const IDENTITY: Xf = Xf { tx: 0.0, ty: 0.0, rotate: 0.0, sx: 1.0, sy: 1.0, opacity: 1.0 };
20    const fn translate(tx: f32, ty: f32) -> Xf {
21        Xf { tx, ty, ..Xf::IDENTITY }
22    }
23    const fn scale_y(sy: f32) -> Xf {
24        Xf { sy, ..Xf::IDENTITY }
25    }
26    const fn fade(opacity: f32) -> Xf {
27        Xf { opacity, ..Xf::IDENTITY }
28    }

For a CSS transform (the same three functions in the same order every time, so that keyframes interpolate).

32    pub fn css(&self) -> String {
33        format!(
34            "translate({}px,{}px) rotate({}deg) scale({},{})",
35            num(self.tx),
36            num(self.ty),
37            num(self.rotate),
38            num(self.sx),
39            num(self.sy)
40        )
41    }

For an SVG transform attribute, about origin.

44    pub fn attr(&self, origin: (f32, f32)) -> String {
45        format!(
46            "translate({} {}) translate({} {}) rotate({}) scale({} {}) translate({} {})",
47            num(origin.0),
48            num(origin.1),
49            num(self.tx),
50            num(self.ty),
51            num(self.rotate),
52            num(self.sx),
53            num(self.sy),
54            num(-origin.0),
55            num(-origin.1)
56        )
57    }
58}

A number as short as it can be written: no trailing zeros, no -0.

61pub fn num(v: f32) -> String {
62    let s = format!("{v:.3}");
63    let s = s.trim_end_matches('0').trim_end_matches('.');
64    if s == "-0" || s.is_empty() { "0".to_owned() } else { s.to_owned() }
65}
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
68pub enum Motion {
69    Still,

The standing body breathing.

71    Breathe,

The sleeping body breathing, slower and deeper.

73    BreatheAsleep,

Dizzy: the whole cat swaying.

75    Sway,

Dizzy: and leaning with it.

77    Tilt,
78    TailWag,

Tickled: the tail whipping about.

80    TailFlick,

Tickled: the whole cat shaking with laughter.

82    Shiver,

Working: a paw on the keys, 0 the left and 1 the right, taking turns.

84    Type(u8),

Busy: a paw thrown up and caught, 0 the left and 1 the right, in opposition.

86    Bob(u8),

Busy: one of three balls on its arc over the head. Drawn about its own middle.

88    Ball(u8),

Tickled: a heart floating up and fading, 0 or 1. Drawn at its start.

90    Heart(u8),

Tangled: a drop of sweat sliding down and fading.

92    Drip,

Busy: the eyes following the balls round.

94    Scan,

The eyes shutting for an instant every four seconds.

96    Blink,

The mouth while speaking, flapping on a beat.

98    MouthFlap,

Listening: the sound waves round the head, 0 the near one and 1 the far.

100    Pulse(u8),

Thinking: the three dots lighting in turn.

102    Dot(u8),

Dizzy: the gap walking round the ring that makes the spiral. One of eight cells.

104    Gap(u8),

Dizzy: the three stars circling the head.

106    Orbit(u8),

Sleeping: the three z's rising.

108    Rise(u8),
109}
111impl Motion {

Every motion that exists, for the stylesheet. A test checks no scene uses one that is missing, so a new variant cannot be used and left out of the CSS.

114    pub fn all() -> Vec<Motion> {
115        let mut all = vec![
116            Motion::Breathe,
117            Motion::BreatheAsleep,
118            Motion::Sway,
119            Motion::Tilt,
120            Motion::TailWag,
121            Motion::TailFlick,
122            Motion::Shiver,
123            Motion::Drip,
124            Motion::Scan,
125            Motion::Blink,
126            Motion::MouthFlap,
127        ];
128        all.extend((0..2).map(Motion::Type));
129        all.extend((0..2).map(Motion::Bob));
130        all.extend((0..3).map(Motion::Ball));
131        all.extend((0..2).map(Motion::Heart));
132        all.extend((0..2).map(Motion::Pulse));
133        all.extend((0..3).map(Motion::Dot));
134        all.extend((0..8).map(Motion::Gap));
135        all.extend((0..3).map(Motion::Orbit));
136        all.extend((0..3).map(Motion::Rise));
137        all
138    }
140    pub fn name(self) -> String {
141        match self {
142            Motion::Still => "still".into(),
143            Motion::Breathe => "breathe".into(),
144            Motion::BreatheAsleep => "breathe-asleep".into(),
145            Motion::Sway => "sway".into(),
146            Motion::Tilt => "tilt".into(),
147            Motion::TailWag => "tail-wag".into(),
148            Motion::TailFlick => "tail-flick".into(),
149            Motion::Shiver => "shiver".into(),
150            Motion::Type(i) => format!("type-{i}"),
151            Motion::Bob(i) => format!("bob-{i}"),
152            Motion::Ball(i) => format!("ball-{i}"),
153            Motion::Heart(i) => format!("heart-{i}"),
154            Motion::Drip => "drip".into(),
155            Motion::Scan => "scan".into(),
156            Motion::Blink => "blink".into(),
157            Motion::MouthFlap => "mouth-flap".into(),
158            Motion::Pulse(i) => format!("pulse-{i}"),
159            Motion::Dot(i) => format!("dot-{i}"),
160            Motion::Gap(i) => format!("gap-{i}"),
161            Motion::Orbit(i) => format!("orbit-{i}"),
162            Motion::Rise(i) => format!("rise-{i}"),
163        }
164    }

Seconds until it repeats exactly.

167    pub fn period(self) -> f32 {
168        match self {
169            Motion::Still => 1.0,
170            Motion::Breathe | Motion::TailWag => TAU / 2.0,
171            Motion::BreatheAsleep => TAU / 1.4,
172            Motion::Sway | Motion::Tilt => TAU / 5.0,
173            Motion::Orbit(_) => TAU / 4.0,
174            Motion::TailFlick => TAU / 18.0,
175            Motion::Shiver => TAU / 40.0,
176            Motion::Type(_) => TAU / 14.0,
177            Motion::Bob(_) => TAU / 7.0,
178            Motion::Ball(_) => 1.0 / 1.4,
179            Motion::Heart(_) => 1.0 / 0.9,
180            Motion::Drip => 1.0 / 0.8,
181            Motion::Scan => TAU / 3.5,
182            Motion::Blink => 4.0,
183            Motion::MouthFlap => TAU / 2.0 / 13.0,
184            Motion::Pulse(_) => TAU / 6.0,
185            Motion::Dot(_) => 1.2,
186            Motion::Gap(_) => 0.8,
187            Motion::Rise(_) => 1.0 / 0.35,
188        }
189    }

Whether a value holds until the next change (blinking, a dot lit or not) rather than sliding towards the next sample.

193    pub fn steps(self) -> bool {
194        matches!(self, Motion::Blink | Motion::Dot(_) | Motion::Gap(_) | Motion::Type(_) | Motion::Scan)
195    }

Where the group is at time t seconds.

198    pub fn at(self, t: f32) -> Xf {
199        match self {
200            Motion::Still => Xf::IDENTITY,
201            Motion::Breathe => Xf::scale_y(1.0 + 0.015 * (t * 2.0).sin()),
202            Motion::BreatheAsleep => Xf::scale_y(1.0 + 0.02 * (t * 1.4).sin()),
203            Motion::Sway => Xf::translate((t * 5.0).sin() * 0.7, 0.0),
204            Motion::Tilt => Xf { rotate: (t * 5.0).sin() * 6.0, ..Xf::IDENTITY },
205            Motion::TailWag => Xf::translate(0.0, (t * 2.0).sin() * 0.4),
206            Motion::TailFlick => Xf::translate(0.0, (t * 18.0).sin()),
207            Motion::Shiver => Xf::translate((t * 40.0).sin() * 0.25, 0.0),
208            Motion::Type(i) => {
209                // One paw is down while the other is up; the app's `a` is 0.7 half of every cycle.
210                let first_down = (t * 14.0).sin() > 0.0;
211                Xf::translate(0.0, if (i == 0) == first_down { 0.7 } else { 0.0 })
212            }
213            Motion::Bob(i) => {
214                let bob = (t * 7.0).sin() * 0.6;
215                Xf::translate(0.0, if i == 0 { bob } else { -bob })
216            }
217            Motion::Ball(i) => {
218                let phase = (t * 1.4 + f32::from(i) / 3.0).rem_euclid(1.0);
219                Xf::translate(1.5 + phase * 12.0, 5.5 - 7.0 * 4.0 * phase * (1.0 - phase))
220            }
221            Motion::Heart(i) => {
222                let phase = (t * 0.9 + f32::from(i) * 0.5).rem_euclid(1.0);
223                Xf { tx: 5.5 + f32::from(i) * 4.0, ty: 4.0 - phase * 5.0, opacity: (1.0 - phase).clamp(0.0, 1.0), ..Xf::IDENTITY }
224            }
225            Motion::Drip => {
226                let drip = (t * 0.8).rem_euclid(1.0);
227                Xf { ty: drip * 1.5, opacity: 1.0 - drip * 0.6, ..Xf::IDENTITY }
228            }
229            Motion::Scan => {
230                let snap = |v: f32| (v / 0.5).round() * 0.5;
231                Xf::translate(snap((t * 3.5).sin() * 1.5), -0.5)
232            }
233            Motion::Blink => Xf::scale_y(if t.rem_euclid(4.0) < 0.12 { 0.1 } else { 1.0 }),
234            Motion::MouthFlap => Xf::scale_y(0.5 + (t * 13.0).sin().abs() * 0.9),
235            Motion::Pulse(i) => {
236                let pulse = 0.5 + 0.5 * (t * 6.0).sin();
237                Xf::fade(if i == 0 { 0.4 + 0.5 * pulse } else { 0.9 - 0.5 * pulse })
238            }
239            Motion::Dot(i) => {
240                let lit = (t * 2.5).floor().rem_euclid(3.0) as u8;
241                Xf::fade(if i <= lit { 0.85 } else { 0.2 })
242            }
243            Motion::Gap(i) => {
244                let gap = (t * 10.0).floor().rem_euclid(8.0) as u8;
245                Xf::fade(if i == gap { 0.0 } else { 1.0 })
246            }
247            Motion::Orbit(i) => {
248                let a = t * 4.0 + f32::from(i) * TAU / 3.0;
249                Xf::translate(a.cos() * 4.5, a.sin() * 1.1)
250            }
251            Motion::Rise(i) => {
252                let phase = (t * 0.35 + f32::from(i) / 3.0).rem_euclid(1.0);
253                let s = 0.7 + phase * 0.7;
254                Xf { tx: phase * 2.5, ty: -phase * 5.0, sx: s, sy: s, opacity: (1.2 - phase).clamp(0.25, 1.0), ..Xf::IDENTITY }
255            }
256        }
257    }

Where it stands when nothing plays (reduced motion, or before the stylesheet loads): open eyes, every cell of the ring, the stars spread round the head.

261    pub fn rest(self) -> Xf {
262        match self {
263            Motion::MouthFlap => Xf::scale_y(1.0),
264            Motion::Dot(_) => Xf::fade(0.85),
265            Motion::Pulse(_) | Motion::Orbit(_) | Motion::Rise(_) | Motion::Ball(_) | Motion::Heart(_) | Motion::Bob(_) => self.at(0.0),
266            Motion::Type(_) => Xf::IDENTITY,
267            _ => Xf::IDENTITY,
268        }
269    }
270}
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn every_motion_repeats_after_its_period() {
278        for motion in Motion::all() {
279            let p = motion.period();
280            for t in [0.013, 0.31, 1.7] {
281                let (a, b) = (motion.at(t), motion.at(t + p));
282                for (x, y) in [(a.tx, b.tx), (a.ty, b.ty), (a.rotate, b.rotate), (a.sx, b.sx), (a.sy, b.sy), (a.opacity, b.opacity)] {
283                    assert!((x - y).abs() < 2e-3, "{} at {t}: {a:?} against {b:?}", motion.name());
284                }
285            }
286        }
287    }
288
289    #[test]
290    fn names_are_unique_because_they_are_css_class_names() {
291        let mut names: Vec<String> = Motion::all().into_iter().map(Motion::name).collect();
292        let n = names.len();
293        names.sort();
294        names.dedup();
295        assert_eq!(names.len(), n);
296    }
297
298    #[test]
299    fn the_app_s_formulas_hold() {
300        // Cat.kt: breathe = 1 + 0.015 sin(2t); the blink is closed for the first 0.12 s of 4.
301        assert!((Motion::Breathe.at(0.7853982).sy - 1.015).abs() < 1e-4);
302        assert_eq!(Motion::Blink.at(0.05).sy, 0.1);
303        assert_eq!(Motion::Blink.at(0.2).sy, 1.0);
304        // The ring has exactly one gap at a time.
305        let open = (0..8).filter(|i| Motion::Gap(*i).at(0.35).opacity == 1.0).count();
306        assert_eq!(open, 7);
307        // One dot is lit first, then two, then three.
308        let lit = |t| (0..3).filter(|i| Motion::Dot(*i).at(t).opacity > 0.5).count();
309        assert_eq!((lit(0.0), lit(0.5), lit(0.9)), (1, 2, 3));
310    }
311
312    #[test]
313    fn numbers_are_short() {
314        assert_eq!(num(1.0), "1");
315        assert_eq!(num(-0.0001), "0");
316        assert_eq!(num(0.12345), "0.123");
317    }
318}