1use crate::orbit::heijunka::HeijunkaStatus;
11use crate::orbit::jidoka::JidokaStatus;
12use crate::orbit::physics::NBodyState;
13use serde::{Deserialize, Serialize};
14
15#[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 #[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 #[must_use]
33 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
34 Self::new(r, g, b, 255)
35 }
36
37 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
57pub enum RenderCommand {
58 Clear { color: Color },
60
61 DrawCircle {
63 x: f64,
64 y: f64,
65 radius: f64,
66 color: Color,
67 filled: bool,
68 },
69
70 DrawLine {
72 x1: f64,
73 y1: f64,
74 x2: f64,
75 y2: f64,
76 color: Color,
77 },
78
79 DrawOrbitPath {
81 points: Vec<(f64, f64)>,
82 color: Color,
83 },
84
85 DrawText {
87 x: f64,
88 y: f64,
89 text: String,
90 color: Color,
91 },
92
93 DrawVelocity {
95 x: f64,
96 y: f64,
97 vx: f64,
98 vy: f64,
99 scale: f64,
100 color: Color,
101 },
102
103 HighlightBody {
105 x: f64,
106 y: f64,
107 radius: f64,
108 color: Color,
109 },
110
111 SetCamera {
113 center_x: f64,
114 center_y: f64,
115 zoom: f64,
116 },
117}
118
119#[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 #[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 #[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 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#[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#[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, }
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, }
241 }
242}
243
244#[derive(Debug, Clone, Default)]
246pub struct OrbitTrail {
247 points: Vec<(f64, f64)>,
248 max_length: usize,
249}
250
251impl OrbitTrail {
252 #[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 pub fn push(&mut self, x: f64, y: f64) {
263 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 #[must_use]
275 pub fn points(&self) -> &[(f64, f64)] {
276 &self.points
277 }
278
279 pub fn clear(&mut self) {
281 self.points.clear();
282 }
283}
284
285#[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 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 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 let mut trail = OrbitTrail::new(0);
605 trail.push(1.0, 1.0);
606 trail.push(2.0, 2.0);
607 assert_eq!(trail.points().len(), 0);
609 }
610}