1use crate::orbit::render::{Camera, Color, RenderCommand};
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25use std::fmt::Write;
26
27pub type ElementKeyframes = BTreeMap<String, Vec<KeyframeValue>>;
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32#[serde(untagged)]
33pub enum KeyframeValue {
34 Number(f64),
36 Text(String),
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct KeyframesExport {
43 pub fps: u32,
45 pub duration_frames: usize,
47 pub seed: u64,
49 pub domain: String,
51 pub elements: BTreeMap<String, ElementKeyframes>,
53}
54
55#[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 counters: BTreeMap<String, u32>,
66}
67
68impl KeyframeRecorder {
69 #[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 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 #[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 #[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 #[must_use]
205 pub fn frame_count(&self) -> usize {
206 self.frame_count
207 }
208
209 #[must_use]
211 pub fn element_count(&self) -> usize {
212 self.elements.len()
213 }
214
215 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 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
234fn 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
246fn 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}