Skip to main content

planarian_console/
mark.rs

1//! The planarian mark, drawn natively, still or in motion.
2//!
3//! The identity kit's `animations/keyframes.json` gives the mark as SVG path
4//! strings (absolute `M`, `C` and `Z` only) in a 1024-square box, plus two
5//! looping animations whose keyframes share one path structure: "loading"
6//! (breathing, 3.6 s) and "working" (a counterclockwise contraction, 2.4 s).
7//! Between keyframes the coordinates are interpolated linearly, as the kit
8//! describes for native renderers. Three contours: the body and the two eye
9//! cutouts, filled even-odd so the eyes stay transparent.
10
11use crate::Message;
12use iced::mouse;
13use iced::widget::canvas::{self, Fill, Frame, Geometry, Path, fill};
14use iced::{Color, Point, Rectangle, Renderer, Theme};
15use std::sync::OnceLock;
16
17const KEYFRAMES: &str = include_str!("../../../assets/identity/animations/keyframes.json");
18
19/// One contour: a start point, then cubic segments (control 1, control 2, end).
20type Contour = (Point, Vec<[Point; 3]>);
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Motion {
24    Still,
25    Loading,
26    Working,
27}
28
29struct Animation {
30    duration: f32,
31    frames: Vec<(f32, Vec<Contour>)>,
32}
33
34struct Kit {
35    still: Vec<Contour>,
36    loading: Animation,
37    working: Animation,
38}
39
40/// Parses the kit's path strings. `None` on anything but absolute M/C/Z.
41fn parse(d: &str) -> Option<Vec<Contour>> {
42    let mut out: Vec<Contour> = vec![];
43    let mut nums: Vec<f32> = vec![];
44    let mut cmd = ' ';
45    let flush = |cmd: char, nums: &mut Vec<f32>, out: &mut Vec<Contour>| -> Option<()> {
46        let pts: Vec<Point> = nums.chunks(2).map(|c| Point::new(c[0], *c.get(1).unwrap_or(&0.0))).collect();
47        match cmd {
48            'M' => out.push((*pts.first()?, vec![])),
49            'C' => {
50                if pts.len() % 3 != 0 {
51                    return None;
52                }
53                let segs = &mut out.last_mut()?.1;
54                for c in pts.chunks(3) {
55                    segs.push([c[0], c[1], c[2]]);
56                }
57            }
58            ' ' | 'Z' => {}
59            _ => return None,
60        }
61        nums.clear();
62        Some(())
63    };
64    for tok in d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()) {
65        let first = tok.chars().next()?;
66        if first.is_ascii_alphabetic() {
67            flush(cmd, &mut nums, &mut out)?;
68            cmd = first;
69            let rest = &tok[1..];
70            if !rest.is_empty() {
71                nums.push(rest.parse().ok()?);
72            }
73        } else {
74            nums.push(tok.parse().ok()?);
75        }
76    }
77    flush(cmd, &mut nums, &mut out)?;
78    Some(out)
79}
80
81fn kit() -> &'static Option<Kit> {
82    static KIT: OnceLock<Option<Kit>> = OnceLock::new();
83    KIT.get_or_init(|| {
84        let v: serde_json::Value = serde_json::from_str(KEYFRAMES).ok()?;
85        let still = parse(v["staticPath"].as_str()?)?;
86        let anim = |name: &str| -> Option<Animation> {
87            let a = v["animations"].as_array()?.iter().find(|a| a["name"] == name)?;
88            let frames = a["keyframes"]
89                .as_array()?
90                .iter()
91                .map(|k| Some((k["offset"].as_f64()? as f32, parse(k["d"].as_str()?)?)))
92                .collect::<Option<Vec<_>>>()?;
93            Some(Animation { duration: a["durationMs"].as_f64()? as f32 / 1000.0, frames })
94        };
95        Some(Kit { still, loading: anim("loading")?, working: anim("working")? })
96    })
97}
98
99fn lerp(a: Point, b: Point, t: f32) -> Point {
100    Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
101}
102
103/// The mark's contours at `seconds` into the motion.
104fn shape(motion: Motion, seconds: f32) -> Option<Vec<Contour>> {
105    let k = kit().as_ref()?;
106    let a = match motion {
107        Motion::Still => return Some(k.still.clone()),
108        Motion::Loading => &k.loading,
109        Motion::Working => &k.working,
110    };
111    let phase = (seconds / a.duration).rem_euclid(1.0);
112    let i = a.frames.iter().rposition(|(o, _)| *o <= phase).unwrap_or(0);
113    let (o0, f0) = &a.frames[i];
114    let Some((o1, f1)) = a.frames.get(i + 1) else { return Some(f0.clone()) };
115    let t = if o1 > o0 { (phase - o0) / (o1 - o0) } else { 0.0 };
116    // Keyframes share one structure; fall back to the nearer frame if not.
117    if f0.len() != f1.len() || f0.iter().zip(f1).any(|(a, b)| a.1.len() != b.1.len()) {
118        return Some(if t < 0.5 { f0.clone() } else { f1.clone() });
119    }
120    Some(
121        f0.iter()
122            .zip(f1)
123            .map(|((s0, c0), (s1, c1))| {
124                let segs =
125                    c0.iter().zip(c1).map(|(a, b)| [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]);
126                (lerp(*s0, *s1, t), segs.collect())
127            })
128            .collect(),
129    )
130}
131
132/// A canvas program: the mark at `seconds` into `motion`, in `color`.
133pub struct Mark {
134    pub motion: Motion,
135    pub seconds: f32,
136    pub color: Color,
137}
138
139impl canvas::Program<Message> for Mark {
140    type State = ();
141
142    fn draw(
143        &self,
144        _state: &(),
145        renderer: &Renderer,
146        _theme: &Theme,
147        bounds: Rectangle,
148        _cursor: mouse::Cursor,
149    ) -> Vec<Geometry> {
150        let mut f = Frame::new(renderer, bounds.size());
151        let scale = bounds.width.min(bounds.height) / 1024.0;
152        let at = |p: Point| Point::new(p.x * scale, p.y * scale);
153        if let Some(contours) = shape(self.motion, self.seconds) {
154            let path = Path::new(|b| {
155                for (start, segs) in &contours {
156                    b.move_to(at(*start));
157                    for [c1, c2, end] in segs {
158                        b.bezier_curve_to(at(*c1), at(*c2), at(*end));
159                    }
160                    b.close();
161                }
162            });
163            f.fill(&path, Fill { style: fill::Style::Solid(self.color), rule: fill::Rule::EvenOdd });
164        }
165        vec![f.into_geometry()]
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn the_kit_parses_and_animates() {
175        let k = kit().as_ref().expect("keyframes.json parses");
176        assert_eq!(k.still.len(), 3, "body and two eyes");
177        for a in [&k.loading, &k.working] {
178            assert!(a.duration > 1.0 && a.frames.len() > 10);
179        }
180        let a = shape(Motion::Working, 0.3).unwrap();
181        let b = shape(Motion::Working, 0.9).unwrap();
182        assert_eq!(a.len(), 3);
183        assert_ne!(a[0].1[5], b[0].1[5], "the body moves");
184        // A cycle later it is where it was.
185        let c = shape(Motion::Working, 0.3 + 2.4).unwrap();
186        assert!((a[0].0.x - c[0].0.x).abs() < 0.5);
187    }
188}