Skip to main content

simular/orbit/
render.rs

1//! Platform-agnostic render commands for orbital visualization.
2//!
3//! Implements the command pattern for rendering, allowing the same
4//! simulation to be displayed on TUI (ratatui) or WASM (Canvas/WebGL).
5//!
6//! # References
7//!
8//! [19] Gamma et al., "Design Patterns," 1994.
9
10use crate::orbit::heijunka::HeijunkaStatus;
11use crate::orbit::jidoka::JidokaStatus;
12use crate::orbit::physics::NBodyState;
13use serde::{Deserialize, Serialize};
14
15/// RGBA color representation.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Color {
18    pub r: u8,
19    pub g: u8,
20    pub b: u8,
21    pub a: u8,
22}
23
24impl Color {
25    /// Create new color.
26    #[must_use]
27    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
28        Self { r, g, b, a }
29    }
30
31    /// Create opaque color.
32    #[must_use]
33    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
34        Self::new(r, g, b, 255)
35    }
36
37    // Common colors
38    pub const WHITE: Self = Self::rgb(255, 255, 255);
39    pub const BLACK: Self = Self::rgb(0, 0, 0);
40    pub const RED: Self = Self::rgb(255, 0, 0);
41    pub const GREEN: Self = Self::rgb(0, 255, 0);
42    pub const BLUE: Self = Self::rgb(0, 0, 255);
43    pub const YELLOW: Self = Self::rgb(255, 255, 0);
44    pub const CYAN: Self = Self::rgb(0, 255, 255);
45    pub const ORANGE: Self = Self::rgb(255, 165, 0);
46
47    // Celestial body colors
48    pub const SUN: Self = Self::rgb(255, 204, 0);
49    pub const MERCURY: Self = Self::rgb(169, 169, 169);
50    pub const VENUS: Self = Self::rgb(255, 198, 73);
51    pub const EARTH: Self = Self::rgb(100, 149, 237);
52    pub const MARS: Self = Self::rgb(193, 68, 14);
53}
54
55/// Platform-agnostic render command.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub enum RenderCommand {
58    /// Clear the screen.
59    Clear { color: Color },
60
61    /// Draw a circle (body).
62    DrawCircle {
63        x: f64,
64        y: f64,
65        radius: f64,
66        color: Color,
67        filled: bool,
68    },
69
70    /// Draw a line.
71    DrawLine {
72        x1: f64,
73        y1: f64,
74        x2: f64,
75        y2: f64,
76        color: Color,
77    },
78
79    /// Draw orbit path (series of points).
80    DrawOrbitPath {
81        points: Vec<(f64, f64)>,
82        color: Color,
83    },
84
85    /// Draw text label.
86    DrawText {
87        x: f64,
88        y: f64,
89        text: String,
90        color: Color,
91    },
92
93    /// Draw velocity vector.
94    DrawVelocity {
95        x: f64,
96        y: f64,
97        vx: f64,
98        vy: f64,
99        scale: f64,
100        color: Color,
101    },
102
103    /// Highlight a body (Jidoka warning).
104    HighlightBody {
105        x: f64,
106        y: f64,
107        radius: f64,
108        color: Color,
109    },
110
111    /// Set camera view.
112    SetCamera {
113        center_x: f64,
114        center_y: f64,
115        zoom: f64,
116    },
117}
118
119/// Camera/view configuration.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Camera {
122    pub center_x: f64,
123    pub center_y: f64,
124    pub zoom: f64,
125    pub width: f64,
126    pub height: f64,
127}
128
129impl Default for Camera {
130    fn default() -> Self {
131        Self {
132            center_x: 0.0,
133            center_y: 0.0,
134            zoom: 1.0,
135            width: 800.0,
136            height: 600.0,
137        }
138    }
139}
140
141impl Camera {
142    /// Convert world coordinates to screen coordinates.
143    #[must_use]
144    pub fn world_to_screen(&self, x: f64, y: f64) -> (f64, f64) {
145        let sx = (x - self.center_x) * self.zoom + self.width / 2.0;
146        let sy = (y - self.center_y) * self.zoom + self.height / 2.0;
147        (sx, sy)
148    }
149
150    /// Convert screen coordinates to world coordinates.
151    #[must_use]
152    pub fn screen_to_world(&self, sx: f64, sy: f64) -> (f64, f64) {
153        let x = (sx - self.width / 2.0) / self.zoom + self.center_x;
154        let y = (sy - self.height / 2.0) / self.zoom + self.center_y;
155        (x, y)
156    }
157
158    /// Adjust zoom to fit given bounds.
159    pub fn fit_bounds(&mut self, min_x: f64, max_x: f64, min_y: f64, max_y: f64) {
160        self.center_x = (min_x + max_x) / 2.0;
161        self.center_y = (min_y + max_y) / 2.0;
162
163        let width_span = max_x - min_x;
164        let height_span = max_y - min_y;
165
166        let zoom_x = if width_span > 0.0 {
167            self.width / width_span * 0.9
168        } else {
169            1.0
170        };
171        let zoom_y = if height_span > 0.0 {
172            self.height / height_span * 0.9
173        } else {
174            1.0
175        };
176
177        self.zoom = zoom_x.min(zoom_y);
178    }
179}
180
181/// Body appearance configuration.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct BodyAppearance {
184    pub name: String,
185    pub color: Color,
186    pub radius: f64,
187    pub show_velocity: bool,
188    pub show_orbit_trail: bool,
189}
190
191impl Default for BodyAppearance {
192    fn default() -> Self {
193        Self {
194            name: "Body".to_string(),
195            color: Color::WHITE,
196            radius: 5.0,
197            show_velocity: false,
198            show_orbit_trail: true,
199        }
200    }
201}
202
203/// Renderer configuration.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct RenderConfig {
206    pub camera: Camera,
207    pub bodies: Vec<BodyAppearance>,
208    pub show_jidoka_status: bool,
209    pub show_heijunka_status: bool,
210    pub velocity_scale: f64,
211    pub orbit_trail_length: usize,
212    pub scale_factor: f64, // meters per pixel
213}
214
215impl Default for RenderConfig {
216    fn default() -> Self {
217        Self {
218            camera: Camera::default(),
219            bodies: vec![
220                BodyAppearance {
221                    name: "Sun".to_string(),
222                    color: Color::SUN,
223                    radius: 10.0,
224                    show_velocity: false,
225                    show_orbit_trail: false,
226                },
227                BodyAppearance {
228                    name: "Earth".to_string(),
229                    color: Color::EARTH,
230                    radius: 5.0,
231                    show_velocity: true,
232                    show_orbit_trail: true,
233                },
234            ],
235            show_jidoka_status: true,
236            show_heijunka_status: true,
237            velocity_scale: 1e-4,
238            orbit_trail_length: 1000,
239            scale_factor: 1e9, // 1 pixel = 1e9 meters
240        }
241    }
242}
243
244/// Orbit trail for visualizing past positions.
245#[derive(Debug, Clone, Default)]
246pub struct OrbitTrail {
247    points: Vec<(f64, f64)>,
248    max_length: usize,
249}
250
251impl OrbitTrail {
252    /// Create new orbit trail.
253    #[must_use]
254    pub fn new(max_length: usize) -> Self {
255        Self {
256            points: Vec::with_capacity(max_length),
257            max_length,
258        }
259    }
260
261    /// Add a point to the trail.
262    pub fn push(&mut self, x: f64, y: f64) {
263        // Don't add points if max_length is 0
264        if self.max_length == 0 {
265            return;
266        }
267        if self.points.len() >= self.max_length {
268            self.points.remove(0);
269        }
270        self.points.push((x, y));
271    }
272
273    /// Get trail points.
274    #[must_use]
275    pub fn points(&self) -> &[(f64, f64)] {
276        &self.points
277    }
278
279    /// Clear the trail.
280    pub fn clear(&mut self) {
281        self.points.clear();
282    }
283}
284
285/// Generate render commands from simulation state.
286#[must_use]
287pub fn render_state(
288    state: &NBodyState,
289    config: &RenderConfig,
290    trails: &[OrbitTrail],
291    jidoka: Option<&JidokaStatus>,
292    heijunka: Option<&HeijunkaStatus>,
293) -> Vec<RenderCommand> {
294    let mut commands = Vec::new();
295
296    commands.push(RenderCommand::Clear {
297        color: Color::BLACK,
298    });
299    commands.push(RenderCommand::SetCamera {
300        center_x: config.camera.center_x,
301        center_y: config.camera.center_y,
302        zoom: config.camera.zoom,
303    });
304
305    render_orbit_trails(&mut commands, trails, config);
306    render_bodies(&mut commands, state, config);
307    render_jidoka_status(&mut commands, jidoka, config.show_jidoka_status);
308    render_heijunka_status(&mut commands, heijunka, config.show_heijunka_status);
309
310    commands
311}
312
313fn render_orbit_trails(
314    commands: &mut Vec<RenderCommand>,
315    trails: &[OrbitTrail],
316    config: &RenderConfig,
317) {
318    for (i, trail) in trails.iter().enumerate() {
319        let Some(body_config) = config.bodies.get(i) else {
320            continue;
321        };
322        if !body_config.show_orbit_trail {
323            continue;
324        }
325
326        let scaled_points: Vec<(f64, f64)> = trail
327            .points()
328            .iter()
329            .map(|(x, y)| (x / config.scale_factor, y / config.scale_factor))
330            .collect();
331
332        if !scaled_points.is_empty() {
333            commands.push(RenderCommand::DrawOrbitPath {
334                points: scaled_points,
335                color: body_config.color,
336            });
337        }
338    }
339}
340
341fn render_bodies(commands: &mut Vec<RenderCommand>, state: &NBodyState, config: &RenderConfig) {
342    for (i, body) in state.bodies.iter().enumerate() {
343        let (x, y, _) = body.position.as_meters();
344        let sx = x / config.scale_factor;
345        let sy = y / config.scale_factor;
346        let appearance = config.bodies.get(i).cloned().unwrap_or_default();
347
348        commands.push(RenderCommand::DrawCircle {
349            x: sx,
350            y: sy,
351            radius: appearance.radius,
352            color: appearance.color,
353            filled: true,
354        });
355
356        if appearance.show_velocity {
357            let (vx, vy, _) = body.velocity.as_mps();
358            commands.push(RenderCommand::DrawVelocity {
359                x: sx,
360                y: sy,
361                vx: vx * config.velocity_scale,
362                vy: vy * config.velocity_scale,
363                scale: 1.0,
364                color: Color::GREEN,
365            });
366        }
367
368        commands.push(RenderCommand::DrawText {
369            x: sx + appearance.radius + 2.0,
370            y: sy,
371            text: appearance.name.clone(),
372            color: Color::WHITE,
373        });
374    }
375}
376
377fn render_jidoka_status(
378    commands: &mut Vec<RenderCommand>,
379    jidoka: Option<&JidokaStatus>,
380    show: bool,
381) {
382    let Some(status) = jidoka.filter(|_| show) else {
383        return;
384    };
385
386    let status_color = jidoka_status_color(status);
387    let suffix = if status.close_encounter_warning {
388        "⚠ Close"
389    } else {
390        "OK"
391    };
392    commands.push(RenderCommand::DrawText {
393        x: 10.0,
394        y: 10.0,
395        text: format!(
396            "Jidoka: E={:.2e} L={:.2e} {suffix}",
397            status.energy_error, status.angular_momentum_error
398        ),
399        color: status_color,
400    });
401}
402
403fn jidoka_status_color(status: &JidokaStatus) -> Color {
404    if status.energy_ok && status.angular_momentum_ok && status.finite_ok {
405        Color::GREEN
406    } else if status.warning_count > 0 {
407        Color::ORANGE
408    } else {
409        Color::RED
410    }
411}
412
413fn render_heijunka_status(
414    commands: &mut Vec<RenderCommand>,
415    heijunka: Option<&HeijunkaStatus>,
416    show: bool,
417) {
418    let Some(status) = heijunka.filter(|_| show) else {
419        return;
420    };
421
422    let budget_color = if status.utilization <= 1.0 {
423        Color::GREEN
424    } else {
425        Color::RED
426    };
427    commands.push(RenderCommand::DrawText {
428        x: 10.0,
429        y: 25.0,
430        text: format!(
431            "Heijunka: {:.1}ms/{:.1}ms ({:.0}%) Q={:?}",
432            status.used_ms,
433            status.budget_ms,
434            status.utilization * 100.0,
435            status.quality,
436        ),
437        color: budget_color,
438    });
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::orbit::physics::OrbitBody;
445    use crate::orbit::units::{OrbitMass, Position3D, Velocity3D, AU, EARTH_MASS, G, SOLAR_MASS};
446
447    fn create_test_state() -> NBodyState {
448        let v_circular = (G * SOLAR_MASS / AU).sqrt();
449        let bodies = vec![
450            OrbitBody::new(
451                OrbitMass::from_kg(SOLAR_MASS),
452                Position3D::zero(),
453                Velocity3D::zero(),
454            ),
455            OrbitBody::new(
456                OrbitMass::from_kg(EARTH_MASS),
457                Position3D::from_au(1.0, 0.0, 0.0),
458                Velocity3D::from_mps(0.0, v_circular, 0.0),
459            ),
460        ];
461        NBodyState::new(bodies, 0.0)
462    }
463
464    #[test]
465    fn test_color_rgb() {
466        let c = Color::rgb(255, 128, 0);
467        assert_eq!(c.r, 255);
468        assert_eq!(c.g, 128);
469        assert_eq!(c.b, 0);
470        assert_eq!(c.a, 255);
471    }
472
473    #[test]
474    fn test_color_constants() {
475        assert_eq!(Color::WHITE.r, 255);
476        assert_eq!(Color::BLACK.r, 0);
477        assert_eq!(Color::SUN.r, 255);
478    }
479
480    #[test]
481    fn test_camera_default() {
482        let cam = Camera::default();
483        assert!((cam.center_x - 0.0).abs() < 1e-10);
484        assert!((cam.zoom - 1.0).abs() < 1e-10);
485    }
486
487    #[test]
488    fn test_camera_world_to_screen() {
489        let mut cam = Camera::default();
490        cam.width = 800.0;
491        cam.height = 600.0;
492
493        let (sx, sy) = cam.world_to_screen(0.0, 0.0);
494        assert!((sx - 400.0).abs() < 1e-10);
495        assert!((sy - 300.0).abs() < 1e-10);
496    }
497
498    #[test]
499    fn test_camera_screen_to_world() {
500        let mut cam = Camera::default();
501        cam.width = 800.0;
502        cam.height = 600.0;
503
504        let (x, y) = cam.screen_to_world(400.0, 300.0);
505        assert!((x - 0.0).abs() < 1e-10);
506        assert!((y - 0.0).abs() < 1e-10);
507    }
508
509    #[test]
510    fn test_camera_fit_bounds() {
511        let mut cam = Camera::default();
512        cam.width = 800.0;
513        cam.height = 600.0;
514
515        cam.fit_bounds(-100.0, 100.0, -100.0, 100.0);
516        assert!((cam.center_x - 0.0).abs() < 1e-10);
517        assert!((cam.center_y - 0.0).abs() < 1e-10);
518    }
519
520    #[test]
521    fn test_orbit_trail_new() {
522        let trail = OrbitTrail::new(100);
523        assert_eq!(trail.points().len(), 0);
524    }
525
526    #[test]
527    fn test_orbit_trail_push() {
528        let mut trail = OrbitTrail::new(3);
529        trail.push(1.0, 1.0);
530        trail.push(2.0, 2.0);
531        trail.push(3.0, 3.0);
532        assert_eq!(trail.points().len(), 3);
533
534        trail.push(4.0, 4.0);
535        assert_eq!(trail.points().len(), 3);
536        assert!((trail.points()[0].0 - 2.0).abs() < 1e-10);
537    }
538
539    #[test]
540    fn test_orbit_trail_clear() {
541        let mut trail = OrbitTrail::new(100);
542        trail.push(1.0, 1.0);
543        trail.clear();
544        assert_eq!(trail.points().len(), 0);
545    }
546
547    #[test]
548    fn test_render_config_default() {
549        let config = RenderConfig::default();
550        assert!(config.show_jidoka_status);
551        assert!(config.show_heijunka_status);
552        assert_eq!(config.bodies.len(), 2);
553    }
554
555    #[test]
556    fn test_render_state_generates_commands() {
557        let state = create_test_state();
558        let config = RenderConfig::default();
559        let trails = vec![OrbitTrail::new(100), OrbitTrail::new(100)];
560
561        let commands = render_state(&state, &config, &trails, None, None);
562
563        assert!(!commands.is_empty());
564        // Should have Clear, SetCamera, and at least 2 DrawCircle (for bodies)
565        assert!(commands.len() >= 4);
566    }
567
568    #[test]
569    fn test_render_state_with_jidoka_status() {
570        let state = create_test_state();
571        let config = RenderConfig::default();
572        let trails = vec![OrbitTrail::new(100), OrbitTrail::new(100)];
573
574        let jidoka = JidokaStatus {
575            energy_ok: true,
576            angular_momentum_ok: true,
577            finite_ok: true,
578            energy_error: 1e-9,
579            angular_momentum_error: 1e-12,
580            min_separation: AU,
581            close_encounter_warning: false,
582            warning_count: 0,
583        };
584
585        let commands = render_state(&state, &config, &trails, Some(&jidoka), None);
586
587        // Should include Jidoka status text
588        let has_jidoka_text = commands.iter().any(
589            |cmd| matches!(cmd, RenderCommand::DrawText { text, .. } if text.contains("Jidoka")),
590        );
591        assert!(has_jidoka_text);
592    }
593
594    #[test]
595    fn test_body_appearance_default() {
596        let appearance = BodyAppearance::default();
597        assert_eq!(appearance.name, "Body");
598        assert!(appearance.show_orbit_trail);
599    }
600
601    #[test]
602    fn test_orbit_trail_zero_max_length() {
603        // This should not panic - edge case fix
604        let mut trail = OrbitTrail::new(0);
605        trail.push(1.0, 1.0);
606        trail.push(2.0, 2.0);
607        // With max_length=0, no points should be added
608        assert_eq!(trail.points().len(), 0);
609    }
610}