Skip to main content

simular/renderers/
keyframes.rs

1//! Keyframe export for rmedia SVG animation pipeline.
2//!
3//! Captures per-frame element positions and properties from render commands,
4//! then exports as JSON for consumption by resolve-pipeline's `animate.*`
5//! properties on rmedia SVG producers.
6//!
7//! # Output Format
8//!
9//! ```json
10//! {
11//!   "fps": 60,
12//!   "duration_frames": 600,
13//!   "seed": 42,
14//!   "domain": "orbit",
15//!   "elements": {
16//!     "earth": { "cx": [960.0, 962.1, ...], "cy": [540.0, 538.2, ...] },
17//!     "trail": { "d": ["M960,540", "M960,540 L962,538", ...] }
18//!   }
19//! }
20//! ```
21
22use crate::orbit::render::{Camera, Color, RenderCommand};
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25use std::fmt::Write;
26
27/// Per-element keyframe data: attribute name → frame values.
28pub type ElementKeyframes = BTreeMap<String, Vec<KeyframeValue>>;
29
30/// A single keyframe value (numeric, color, or path string).
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32#[serde(untagged)]
33pub enum KeyframeValue {
34    /// Numeric value (position, radius, opacity).
35    Number(f64),
36    /// String value (SVG path data, text content).
37    Text(String),
38}
39
40/// Complete keyframes export for a simulation run.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct KeyframesExport {
43    /// Frames per second.
44    pub fps: u32,
45    /// Total number of frames captured.
46    pub duration_frames: usize,
47    /// Simulation seed for reproducibility.
48    pub seed: u64,
49    /// Simulation domain name.
50    pub domain: String,
51    /// Per-element keyframe data.
52    pub elements: BTreeMap<String, ElementKeyframes>,
53}
54
55/// Keyframe recorder that captures element state per frame.
56#[derive(Debug, Clone)]
57pub struct KeyframeRecorder {
58    fps: u32,
59    seed: u64,
60    domain: String,
61    camera: Camera,
62    frame_count: usize,
63    elements: BTreeMap<String, ElementKeyframes>,
64    /// Element naming counters per command type.
65    counters: BTreeMap<String, u32>,
66}
67
68impl KeyframeRecorder {
69    /// Create a new keyframe recorder.
70    #[must_use]
71    pub fn new(fps: u32, seed: u64, domain: &str) -> Self {
72        Self {
73            fps,
74            seed,
75            domain: domain.to_string(),
76            camera: Camera {
77                width: 1920.0,
78                height: 1080.0,
79                ..Camera::default()
80            },
81            frame_count: 0,
82            elements: BTreeMap::new(),
83            counters: BTreeMap::new(),
84        }
85    }
86
87    /// Record one frame of render commands.
88    ///
89    /// Elements are tracked by ID across frames. The first frame establishes
90    /// the element set; subsequent frames append values to existing elements.
91    pub fn record_frame(&mut self, commands: &[RenderCommand]) {
92        self.counters.clear();
93
94        for cmd in commands {
95            match cmd {
96                RenderCommand::SetCamera {
97                    center_x,
98                    center_y,
99                    zoom,
100                } => {
101                    self.camera.center_x = *center_x;
102                    self.camera.center_y = *center_y;
103                    self.camera.zoom = *zoom;
104                }
105                RenderCommand::DrawCircle {
106                    x,
107                    y,
108                    radius,
109                    color,
110                    ..
111                } => {
112                    let id = self.next_id("circle");
113                    let (sx, sy) = self.camera.world_to_screen(*x, *y);
114                    self.push_value(&id, "cx", KeyframeValue::Number(round2(sx)));
115                    self.push_value(&id, "cy", KeyframeValue::Number(round2(sy)));
116                    self.push_value(&id, "r", KeyframeValue::Number(round2(*radius)));
117                    self.push_value(&id, "fill", KeyframeValue::Text(color_to_hex(*color)));
118                }
119                RenderCommand::DrawOrbitPath { points, color } => {
120                    if points.len() < 2 {
121                        continue;
122                    }
123                    let id = self.next_id("path");
124                    let mut d = String::new();
125                    for (i, (x, y)) in points.iter().enumerate() {
126                        let (sx, sy) = self.camera.world_to_screen(*x, *y);
127                        if i == 0 {
128                            let _ = write!(d, "M{sx:.1},{sy:.1}");
129                        } else {
130                            let _ = write!(d, " L{sx:.1},{sy:.1}");
131                        }
132                    }
133                    self.push_value(&id, "d", KeyframeValue::Text(d));
134                    self.push_value(&id, "stroke", KeyframeValue::Text(color_to_hex(*color)));
135                }
136                RenderCommand::DrawText { x, y, text, color } => {
137                    let id = self.next_id("text");
138                    let (sx, sy) = self.camera.world_to_screen(*x, *y);
139                    self.push_value(&id, "x", KeyframeValue::Number(round2(sx)));
140                    self.push_value(&id, "y", KeyframeValue::Number(round2(sy)));
141                    self.push_value(&id, "text", KeyframeValue::Text(text.clone()));
142                    self.push_value(&id, "fill", KeyframeValue::Text(color_to_hex(*color)));
143                }
144                RenderCommand::DrawVelocity {
145                    x,
146                    y,
147                    vx,
148                    vy,
149                    scale,
150                    ..
151                } => {
152                    let id = self.next_id("velocity");
153                    let (sx, sy) = self.camera.world_to_screen(*x, *y);
154                    let ex = sx + vx * scale;
155                    let ey = sy + vy * scale;
156                    self.push_value(&id, "x1", KeyframeValue::Number(round2(sx)));
157                    self.push_value(&id, "y1", KeyframeValue::Number(round2(sy)));
158                    self.push_value(&id, "x2", KeyframeValue::Number(round2(ex)));
159                    self.push_value(&id, "y2", KeyframeValue::Number(round2(ey)));
160                }
161                RenderCommand::DrawLine {
162                    x1,
163                    y1,
164                    x2,
165                    y2,
166                    color,
167                } => {
168                    let id = self.next_id("line");
169                    let (sx1, sy1) = self.camera.world_to_screen(*x1, *y1);
170                    let (sx2, sy2) = self.camera.world_to_screen(*x2, *y2);
171                    self.push_value(&id, "x1", KeyframeValue::Number(round2(sx1)));
172                    self.push_value(&id, "y1", KeyframeValue::Number(round2(sy1)));
173                    self.push_value(&id, "x2", KeyframeValue::Number(round2(sx2)));
174                    self.push_value(&id, "y2", KeyframeValue::Number(round2(sy2)));
175                    self.push_value(&id, "stroke", KeyframeValue::Text(color_to_hex(*color)));
176                }
177                RenderCommand::Clear { .. } | RenderCommand::HighlightBody { .. } => {}
178            }
179        }
180
181        self.frame_count += 1;
182    }
183
184    /// Export captured keyframes as a serializable struct.
185    #[must_use]
186    pub fn export(&self) -> KeyframesExport {
187        KeyframesExport {
188            fps: self.fps,
189            duration_frames: self.frame_count,
190            seed: self.seed,
191            domain: self.domain.clone(),
192            elements: self.elements.clone(),
193        }
194    }
195
196    /// Export keyframes as a JSON string.
197    #[must_use]
198    pub fn to_json(&self) -> String {
199        serde_json::to_string_pretty(&self.export())
200            .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
201    }
202
203    /// Get the number of recorded frames.
204    #[must_use]
205    pub fn frame_count(&self) -> usize {
206        self.frame_count
207    }
208
209    /// Get the number of tracked elements.
210    #[must_use]
211    pub fn element_count(&self) -> usize {
212        self.elements.len()
213    }
214
215    /// Generate next sequential ID for a command type.
216    fn next_id(&mut self, prefix: &str) -> String {
217        let counter = self.counters.entry(prefix.to_string()).or_insert(0);
218        let id = format!("{prefix}-{counter}");
219        *counter += 1;
220        id
221    }
222
223    /// Push a keyframe value for an element attribute.
224    fn push_value(&mut self, element_id: &str, attribute: &str, value: KeyframeValue) {
225        self.elements
226            .entry(element_id.to_string())
227            .or_default()
228            .entry(attribute.to_string())
229            .or_default()
230            .push(value);
231    }
232}
233
234/// Convert RGBA color to hex string.
235fn color_to_hex(color: Color) -> String {
236    if color.a == 255 {
237        format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b)
238    } else {
239        format!(
240            "#{:02x}{:02x}{:02x}{:02x}",
241            color.r, color.g, color.b, color.a
242        )
243    }
244}
245
246/// Round to 2 decimal places for compact JSON.
247fn round2(v: f64) -> f64 {
248    (v * 100.0).round() / 100.0
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::orbit::render::Color;
255
256    #[test]
257    fn test_recorder_new() {
258        let recorder = KeyframeRecorder::new(60, 42, "orbit");
259        assert_eq!(recorder.fps, 60);
260        assert_eq!(recorder.seed, 42);
261        assert_eq!(recorder.domain, "orbit");
262        assert_eq!(recorder.frame_count(), 0);
263        assert_eq!(recorder.element_count(), 0);
264    }
265
266    #[test]
267    fn test_record_single_frame() {
268        let mut recorder = KeyframeRecorder::new(60, 42, "orbit");
269        recorder.record_frame(&[
270            RenderCommand::SetCamera {
271                center_x: 0.0,
272                center_y: 0.0,
273                zoom: 1.0,
274            },
275            RenderCommand::DrawCircle {
276                x: 0.0,
277                y: 0.0,
278                radius: 10.0,
279                color: Color::SUN,
280                filled: true,
281            },
282        ]);
283
284        assert_eq!(recorder.frame_count(), 1);
285        assert_eq!(recorder.element_count(), 1);
286        assert!(recorder.elements.contains_key("circle-0"));
287    }
288
289    #[test]
290    fn test_record_multiple_frames() {
291        let mut recorder = KeyframeRecorder::new(60, 42, "orbit");
292
293        for i in 0..3 {
294            recorder.record_frame(&[
295                RenderCommand::SetCamera {
296                    center_x: 0.0,
297                    center_y: 0.0,
298                    zoom: 1.0,
299                },
300                RenderCommand::DrawCircle {
301                    x: i as f64 * 10.0,
302                    y: 0.0,
303                    radius: 5.0,
304                    color: Color::EARTH,
305                    filled: true,
306                },
307            ]);
308        }
309
310        assert_eq!(recorder.frame_count(), 3);
311        let circle = &recorder.elements["circle-0"];
312        let cx_values = &circle["cx"];
313        assert_eq!(cx_values.len(), 3);
314    }
315
316    #[test]
317    fn test_record_orbit_path() {
318        let mut recorder = KeyframeRecorder::new(60, 42, "orbit");
319        recorder.record_frame(&[RenderCommand::DrawOrbitPath {
320            points: vec![(0.0, 0.0), (10.0, 10.0), (20.0, 0.0)],
321            color: Color::EARTH,
322        }]);
323
324        assert!(recorder.elements.contains_key("path-0"));
325        let path = &recorder.elements["path-0"];
326        assert!(path.contains_key("d"));
327        if let KeyframeValue::Text(d) = &path["d"][0] {
328            assert!(d.starts_with('M'));
329            assert!(d.contains('L'));
330        } else {
331            panic!("Expected Text value for path d");
332        }
333    }
334
335    #[test]
336    fn test_record_text() {
337        let mut recorder = KeyframeRecorder::new(60, 42, "orbit");
338        recorder.record_frame(&[RenderCommand::DrawText {
339            x: 10.0,
340            y: 20.0,
341            text: "Jidoka: E=1e-9".to_string(),
342            color: Color::GREEN,
343        }]);
344
345        assert!(recorder.elements.contains_key("text-0"));
346        let text = &recorder.elements["text-0"];
347        if let KeyframeValue::Text(t) = &text["text"][0] {
348            assert_eq!(t, "Jidoka: E=1e-9");
349        } else {
350            panic!("Expected Text value");
351        }
352    }
353
354    #[test]
355    fn test_record_velocity() {
356        let mut recorder = KeyframeRecorder::new(60, 42, "orbit");
357        recorder.record_frame(&[
358            RenderCommand::SetCamera {
359                center_x: 0.0,
360                center_y: 0.0,
361                zoom: 1.0,
362            },
363            RenderCommand::DrawVelocity {
364                x: 0.0,
365                y: 0.0,
366                vx: 50.0,
367                vy: 30.0,
368                scale: 1.0,
369                color: Color::GREEN,
370            },
371        ]);
372
373        assert!(recorder.elements.contains_key("velocity-0"));
374        let vel = &recorder.elements["velocity-0"];
375        assert!(vel.contains_key("x1"));
376        assert!(vel.contains_key("y1"));
377        assert!(vel.contains_key("x2"));
378        assert!(vel.contains_key("y2"));
379    }
380
381    #[test]
382    fn test_export_json() {
383        let mut recorder = KeyframeRecorder::new(60, 42, "orbit");
384        recorder.record_frame(&[RenderCommand::DrawCircle {
385            x: 0.0,
386            y: 0.0,
387            radius: 5.0,
388            color: Color::SUN,
389            filled: true,
390        }]);
391
392        let json = recorder.to_json();
393        assert!(json.contains("\"fps\": 60"));
394        assert!(json.contains("\"seed\": 42"));
395        assert!(json.contains("\"domain\": \"orbit\""));
396        assert!(json.contains("\"duration_frames\": 1"));
397        assert!(json.contains("circle-0"));
398    }
399
400    #[test]
401    fn test_export_struct() {
402        let mut recorder = KeyframeRecorder::new(30, 123, "monte_carlo");
403        recorder.record_frame(&[RenderCommand::DrawCircle {
404            x: 5.0,
405            y: 5.0,
406            radius: 3.0,
407            color: Color::RED,
408            filled: true,
409        }]);
410
411        let export = recorder.export();
412        assert_eq!(export.fps, 30);
413        assert_eq!(export.seed, 123);
414        assert_eq!(export.domain, "monte_carlo");
415        assert_eq!(export.duration_frames, 1);
416        assert!(export.elements.contains_key("circle-0"));
417    }
418
419    #[test]
420    fn test_multiple_elements_per_frame() {
421        let mut recorder = KeyframeRecorder::new(60, 42, "orbit");
422        recorder.record_frame(&[
423            RenderCommand::DrawCircle {
424                x: 0.0,
425                y: 0.0,
426                radius: 15.0,
427                color: Color::SUN,
428                filled: true,
429            },
430            RenderCommand::DrawCircle {
431                x: 1.0,
432                y: 0.0,
433                radius: 5.0,
434                color: Color::EARTH,
435                filled: true,
436            },
437            RenderCommand::DrawText {
438                x: 10.0,
439                y: 10.0,
440                text: "test".to_string(),
441                color: Color::WHITE,
442            },
443        ]);
444
445        assert_eq!(recorder.element_count(), 3);
446        assert!(recorder.elements.contains_key("circle-0"));
447        assert!(recorder.elements.contains_key("circle-1"));
448        assert!(recorder.elements.contains_key("text-0"));
449    }
450
451    #[test]
452    fn test_clear_and_highlight_ignored() {
453        let mut recorder = KeyframeRecorder::new(60, 42, "orbit");
454        recorder.record_frame(&[
455            RenderCommand::Clear {
456                color: Color::BLACK,
457            },
458            RenderCommand::HighlightBody {
459                x: 0.0,
460                y: 0.0,
461                radius: 20.0,
462                color: Color::RED,
463            },
464        ]);
465
466        assert_eq!(recorder.element_count(), 0);
467    }
468
469    #[test]
470    fn test_single_point_path_skipped() {
471        let mut recorder = KeyframeRecorder::new(60, 42, "orbit");
472        recorder.record_frame(&[RenderCommand::DrawOrbitPath {
473            points: vec![(0.0, 0.0)],
474            color: Color::EARTH,
475        }]);
476
477        assert_eq!(recorder.element_count(), 0);
478    }
479
480    #[test]
481    fn test_round2() {
482        assert!((round2(3.14159) - 3.14).abs() < 0.001);
483        assert!((round2(0.0) - 0.0).abs() < f64::EPSILON);
484        assert!((round2(-1.555) - (-1.56)).abs() < 0.001);
485    }
486
487    #[test]
488    fn test_keyframe_value_serialize() {
489        let num = KeyframeValue::Number(42.5);
490        let json = serde_json::to_string(&num).unwrap();
491        assert_eq!(json, "42.5");
492
493        let text = KeyframeValue::Text("hello".to_string());
494        let json = serde_json::to_string(&text).unwrap();
495        assert_eq!(json, "\"hello\"");
496    }
497}