1use crate::orbit::render::{Camera, Color, RenderCommand};
21use serde::{Deserialize, Serialize};
22use std::fmt::Write;
23
24const CANVAS_WIDTH: f64 = 1920.0;
26const CANVAS_HEIGHT: f64 = 1080.0;
27
28const BG_CANVAS: &str = "#0f172a";
30
31const TEXT_PRIMARY: &str = "#f1f5f9";
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SvgConfig {
37 pub width: f64,
39 pub height: f64,
41 pub background: String,
43 pub font_family: String,
45 pub min_font_size: f64,
47 pub include_manifest: bool,
49}
50
51impl Default for SvgConfig {
52 fn default() -> Self {
53 Self {
54 width: CANVAS_WIDTH,
55 height: CANVAS_HEIGHT,
56 background: BG_CANVAS.to_string(),
57 font_family: "'Segoe UI', 'Helvetica Neue', sans-serif".to_string(),
58 min_font_size: 18.0,
59 include_manifest: true,
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct SvgRenderer {
67 config: SvgConfig,
68 camera: Camera,
69}
70
71impl SvgRenderer {
72 #[must_use]
74 pub fn new() -> Self {
75 Self::with_config(SvgConfig::default())
76 }
77
78 #[must_use]
80 pub fn with_config(config: SvgConfig) -> Self {
81 let camera = Camera {
82 width: config.width,
83 height: config.height,
84 ..Camera::default()
85 };
86 Self { config, camera }
87 }
88
89 #[must_use]
91 pub fn render(&mut self, commands: &[RenderCommand]) -> String {
92 let mut elements: Vec<String> = Vec::new();
93 let mut element_count = 0u32;
94
95 for cmd in commands {
96 match cmd {
97 RenderCommand::SetCamera {
98 center_x,
99 center_y,
100 zoom,
101 } => {
102 self.camera.center_x = *center_x;
103 self.camera.center_y = *center_y;
104 self.camera.zoom = *zoom;
105 }
106 RenderCommand::Clear { color } => {
107 let w = self.config.width;
108 let h = self.config.height;
109 let hex = color_to_hex(*color);
110 elements.push(format!(
111 r#" <rect id="bg" x="0" y="0" width="{w}" height="{h}" fill="{hex}"/>"#
112 ));
113 }
114 RenderCommand::DrawCircle {
115 x,
116 y,
117 radius,
118 color,
119 filled,
120 } => {
121 let (sx, sy) = self.camera.world_to_screen(*x, *y);
122 let hex = color_to_hex(*color);
123 let id = element_count;
124 element_count += 1;
125
126 if *filled {
127 elements.push(format!(
128 r#" <circle id="circle-{id}" cx="{sx:.1}" cy="{sy:.1}" r="{radius:.1}" fill="{hex}"/>"#
129 ));
130 } else {
131 elements.push(format!(
132 r#" <circle id="circle-{id}" cx="{sx:.1}" cy="{sy:.1}" r="{radius:.1}" fill="none" stroke="{hex}" stroke-width="2"/>"#
133 ));
134 }
135 }
136 RenderCommand::DrawLine {
137 x1,
138 y1,
139 x2,
140 y2,
141 color,
142 } => {
143 let (sx1, sy1) = self.camera.world_to_screen(*x1, *y1);
144 let (sx2, sy2) = self.camera.world_to_screen(*x2, *y2);
145 let hex = color_to_hex(*color);
146 let id = element_count;
147 element_count += 1;
148
149 elements.push(format!(
150 r#" <line id="line-{id}" x1="{sx1:.1}" y1="{sy1:.1}" x2="{sx2:.1}" y2="{sy2:.1}" stroke="{hex}" stroke-width="2"/>"#
151 ));
152 }
153 RenderCommand::DrawOrbitPath { points, color } => {
154 if points.len() < 2 {
155 continue;
156 }
157 let hex = color_to_hex(*color);
158 let id = element_count;
159 element_count += 1;
160
161 let mut d = String::new();
162 for (i, (x, y)) in points.iter().enumerate() {
163 let (sx, sy) = self.camera.world_to_screen(*x, *y);
164 if i == 0 {
165 let _ = write!(d, "M{sx:.1},{sy:.1}");
166 } else {
167 let _ = write!(d, " L{sx:.1},{sy:.1}");
168 }
169 }
170
171 elements.push(format!(
172 r#" <path id="orbit-path-{id}" d="{d}" fill="none" stroke="{hex}" stroke-width="2" stroke-opacity="0.7"/>"#
173 ));
174 }
175 RenderCommand::DrawText { x, y, text, color } => {
176 let (sx, sy) = self.camera.world_to_screen(*x, *y);
177 let hex = color_to_hex(*color);
178 let id = element_count;
179 element_count += 1;
180 let escaped = xml_escape(text);
181 let font = &self.config.font_family;
182 let size = self.config.min_font_size;
183
184 elements.push(format!(
185 r#" <text id="text-{id}" x="{sx:.1}" y="{sy:.1}" font-family="{font}" font-size="{size}" fill="{hex}">{escaped}</text>"#
186 ));
187 }
188 RenderCommand::DrawVelocity {
189 x,
190 y,
191 vx,
192 vy,
193 scale,
194 color,
195 } => {
196 let (sx, sy) = self.camera.world_to_screen(*x, *y);
197 let ex = sx + vx * scale;
198 let ey = sy + vy * scale;
199 let hex = color_to_hex(*color);
200 let id = element_count;
201 element_count += 1;
202
203 elements.push(format!(
204 r#" <line id="velocity-{id}" x1="{sx:.1}" y1="{sy:.1}" x2="{ex:.1}" y2="{ey:.1}" stroke="{hex}" stroke-width="2" marker-end="url(#arrowhead)"/>"#
205 ));
206 }
207 RenderCommand::HighlightBody {
208 x,
209 y,
210 radius,
211 color,
212 } => {
213 let (sx, sy) = self.camera.world_to_screen(*x, *y);
214 let hex = color_to_hex(*color);
215 let id = element_count;
216 element_count += 1;
217 let outer_r = radius * 1.5;
218
219 elements.push(format!(
220 r#" <circle id="highlight-{id}" cx="{sx:.1}" cy="{sy:.1}" r="{outer_r:.1}" fill="none" stroke="{hex}" stroke-width="3" stroke-dasharray="4,4"/>"#
221 ));
222 }
223 }
224 }
225
226 self.build_svg(&elements)
227 }
228
229 fn build_svg(&self, elements: &[String]) -> String {
231 let w = self.config.width;
232 let h = self.config.height;
233
234 let mut svg = String::with_capacity(4096);
235
236 let _ = writeln!(
237 svg,
238 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {w} {h}\" width=\"{w}\" height=\"{h}\">"
239 );
240
241 if self.config.include_manifest {
242 let _ = writeln!(
243 svg,
244 "<!-- GRID PROTOCOL MANIFEST\n Canvas: {w}x{h} | Grid: 16x9 | Cell: 120px\n Renderer: simular SVG\n-->"
245 );
246 }
247
248 svg.push_str(" <defs>\n");
250 svg.push_str(" <marker id=\"arrowhead\" markerWidth=\"10\" markerHeight=\"7\" refX=\"10\" refY=\"3.5\" orient=\"auto\">\n");
251 let _ = writeln!(
252 svg,
253 " <polygon points=\"0 0, 10 3.5, 0 7\" fill=\"{TEXT_PRIMARY}\"/>"
254 );
255 svg.push_str(" </marker>\n");
256 svg.push_str(" </defs>\n");
257
258 for element in elements {
259 svg.push_str(element);
260 svg.push('\n');
261 }
262
263 svg.push_str("</svg>\n");
264 svg
265 }
266}
267
268impl Default for SvgRenderer {
269 fn default() -> Self {
270 Self::new()
271 }
272}
273
274#[must_use]
276pub fn color_to_hex(color: Color) -> String {
277 if color.a == 255 {
278 format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b)
279 } else {
280 format!(
281 "#{:02x}{:02x}{:02x}{:02x}",
282 color.r, color.g, color.b, color.a
283 )
284 }
285}
286
287fn xml_escape(s: &str) -> String {
289 s.replace('&', "&")
290 .replace('<', "<")
291 .replace('>', ">")
292 .replace('"', """)
293 .replace('\'', "'")
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::orbit::render::Color;
300
301 #[test]
302 fn test_color_to_hex_opaque() {
303 let c = Color::rgb(255, 128, 0);
304 assert_eq!(color_to_hex(c), "#ff8000");
305 }
306
307 #[test]
308 fn test_color_to_hex_transparent() {
309 let c = Color::new(255, 0, 0, 128);
310 assert_eq!(color_to_hex(c), "#ff000080");
311 }
312
313 #[test]
314 fn test_color_to_hex_black() {
315 assert_eq!(color_to_hex(Color::BLACK), "#000000");
316 }
317
318 #[test]
319 fn test_color_to_hex_white() {
320 assert_eq!(color_to_hex(Color::WHITE), "#ffffff");
321 }
322
323 #[test]
324 fn test_xml_escape() {
325 assert_eq!(xml_escape("a<b>c"), "a<b>c");
326 assert_eq!(xml_escape("a&b"), "a&b");
327 assert_eq!(xml_escape(r#"a"b"#), "a"b");
328 }
329
330 #[test]
331 fn test_svg_config_default() {
332 let config = SvgConfig::default();
333 assert!((config.width - 1920.0).abs() < f64::EPSILON);
334 assert!((config.height - 1080.0).abs() < f64::EPSILON);
335 assert!((config.min_font_size - 18.0).abs() < f64::EPSILON);
336 assert!(config.include_manifest);
337 }
338
339 #[test]
340 fn test_renderer_new() {
341 let renderer = SvgRenderer::new();
342 assert!((renderer.config.width - 1920.0).abs() < f64::EPSILON);
343 }
344
345 #[test]
346 fn test_renderer_default() {
347 let renderer = SvgRenderer::default();
348 assert!((renderer.config.width - 1920.0).abs() < f64::EPSILON);
349 }
350
351 #[test]
352 fn test_render_empty_commands() {
353 let mut renderer = SvgRenderer::new();
354 let svg = renderer.render(&[]);
355 assert!(svg.contains("viewBox=\"0 0 1920 1080\""));
356 assert!(svg.contains("width=\"1920\""));
357 assert!(svg.contains("height=\"1080\""));
358 assert!(svg.contains("</svg>"));
359 }
360
361 #[test]
362 fn test_render_clear() {
363 let mut renderer = SvgRenderer::new();
364 let svg = renderer.render(&[RenderCommand::Clear {
365 color: Color::BLACK,
366 }]);
367 assert!(svg.contains("id=\"bg\""));
368 assert!(svg.contains("#000000"));
369 }
370
371 #[test]
372 fn test_render_filled_circle() {
373 let mut renderer = SvgRenderer::new();
374 let commands = vec![
375 RenderCommand::SetCamera {
376 center_x: 0.0,
377 center_y: 0.0,
378 zoom: 1.0,
379 },
380 RenderCommand::DrawCircle {
381 x: 0.0,
382 y: 0.0,
383 radius: 10.0,
384 color: Color::SUN,
385 filled: true,
386 },
387 ];
388 let svg = renderer.render(&commands);
389 assert!(svg.contains("circle"));
390 assert!(svg.contains("id=\"circle-0\""));
391 assert!(svg.contains("#ffcc00"));
392 }
393
394 #[test]
395 fn test_render_unfilled_circle() {
396 let mut renderer = SvgRenderer::new();
397 let commands = vec![RenderCommand::DrawCircle {
398 x: 0.0,
399 y: 0.0,
400 radius: 50.0,
401 color: Color::WHITE,
402 filled: false,
403 }];
404 let svg = renderer.render(&commands);
405 assert!(svg.contains("fill=\"none\""));
406 assert!(svg.contains("stroke="));
407 }
408
409 #[test]
410 fn test_render_line() {
411 let mut renderer = SvgRenderer::new();
412 let commands = vec![RenderCommand::DrawLine {
413 x1: 0.0,
414 y1: 0.0,
415 x2: 100.0,
416 y2: 100.0,
417 color: Color::GREEN,
418 }];
419 let svg = renderer.render(&commands);
420 assert!(svg.contains("line"));
421 assert!(svg.contains("id=\"line-0\""));
422 }
423
424 #[test]
425 fn test_render_orbit_path() {
426 let mut renderer = SvgRenderer::new();
427 let commands = vec![RenderCommand::DrawOrbitPath {
428 points: vec![(0.0, 0.0), (10.0, 10.0), (20.0, 0.0)],
429 color: Color::EARTH,
430 }];
431 let svg = renderer.render(&commands);
432 assert!(svg.contains("path"));
433 assert!(svg.contains("id=\"orbit-path-0\""));
434 assert!(svg.contains("M"));
435 assert!(svg.contains("L"));
436 }
437
438 #[test]
439 fn test_render_orbit_path_single_point_skipped() {
440 let mut renderer = SvgRenderer::new();
441 let commands = vec![RenderCommand::DrawOrbitPath {
442 points: vec![(0.0, 0.0)],
443 color: Color::EARTH,
444 }];
445 let svg = renderer.render(&commands);
446 assert!(!svg.contains("<path"));
447 }
448
449 #[test]
450 fn test_render_text() {
451 let mut renderer = SvgRenderer::new();
452 let commands = vec![RenderCommand::DrawText {
453 x: 10.0,
454 y: 10.0,
455 text: "Hello World".to_string(),
456 color: Color::WHITE,
457 }];
458 let svg = renderer.render(&commands);
459 assert!(svg.contains("<text"));
460 assert!(svg.contains("id=\"text-0\""));
461 assert!(svg.contains("Hello World"));
462 assert!(svg.contains("font-size=\"18\""));
463 }
464
465 #[test]
466 fn test_render_text_escaping() {
467 let mut renderer = SvgRenderer::new();
468 let commands = vec![RenderCommand::DrawText {
469 x: 10.0,
470 y: 10.0,
471 text: "E=1e-9 & L<1e-12".to_string(),
472 color: Color::WHITE,
473 }];
474 let svg = renderer.render(&commands);
475 assert!(svg.contains("&"));
476 assert!(svg.contains("<"));
477 }
478
479 #[test]
480 fn test_render_velocity() {
481 let mut renderer = SvgRenderer::new();
482 let commands = vec![RenderCommand::DrawVelocity {
483 x: 0.0,
484 y: 0.0,
485 vx: 50.0,
486 vy: 30.0,
487 scale: 1.0,
488 color: Color::GREEN,
489 }];
490 let svg = renderer.render(&commands);
491 assert!(svg.contains("<line"));
492 assert!(svg.contains("id=\"velocity-0\""));
493 assert!(svg.contains("marker-end"));
494 }
495
496 #[test]
497 fn test_render_highlight() {
498 let mut renderer = SvgRenderer::new();
499 let commands = vec![RenderCommand::HighlightBody {
500 x: 0.0,
501 y: 0.0,
502 radius: 20.0,
503 color: Color::RED,
504 }];
505 let svg = renderer.render(&commands);
506 assert!(svg.contains("<circle"));
507 assert!(svg.contains("id=\"highlight-0\""));
508 assert!(svg.contains("stroke-dasharray"));
509 }
510
511 #[test]
512 fn test_render_camera_transform() {
513 let mut renderer = SvgRenderer::new();
514 let commands = vec![
515 RenderCommand::SetCamera {
516 center_x: 0.0,
517 center_y: 0.0,
518 zoom: 2.0,
519 },
520 RenderCommand::DrawCircle {
521 x: 100.0,
522 y: 0.0,
523 radius: 5.0,
524 color: Color::WHITE,
525 filled: true,
526 },
527 ];
528 let svg = renderer.render(&commands);
529 assert!(svg.contains("1160.0"));
531 }
532
533 #[test]
534 fn test_manifest_included() {
535 let mut renderer = SvgRenderer::new();
536 let svg = renderer.render(&[]);
537 assert!(svg.contains("GRID PROTOCOL MANIFEST"));
538 }
539
540 #[test]
541 fn test_manifest_excluded() {
542 let config = SvgConfig {
543 include_manifest: false,
544 ..SvgConfig::default()
545 };
546 let mut renderer = SvgRenderer::with_config(config);
547 let svg = renderer.render(&[]);
548 assert!(!svg.contains("GRID PROTOCOL MANIFEST"));
549 }
550
551 #[test]
552 fn test_arrowhead_marker_defined() {
553 let mut renderer = SvgRenderer::new();
554 let svg = renderer.render(&[]);
555 assert!(svg.contains("marker id=\"arrowhead\""));
556 }
557
558 #[test]
559 fn test_viewbox_parity() {
560 let mut renderer = SvgRenderer::new();
561 let svg = renderer.render(&[]);
562 assert!(svg.contains("viewBox=\"0 0 1920 1080\""));
564 assert!(svg.contains("width=\"1920\""));
565 assert!(svg.contains("height=\"1080\""));
566 }
567
568 #[test]
569 fn test_element_ids_unique() {
570 let mut renderer = SvgRenderer::new();
571 let commands = vec![
572 RenderCommand::DrawCircle {
573 x: 0.0,
574 y: 0.0,
575 radius: 5.0,
576 color: Color::WHITE,
577 filled: true,
578 },
579 RenderCommand::DrawCircle {
580 x: 10.0,
581 y: 10.0,
582 radius: 5.0,
583 color: Color::RED,
584 filled: true,
585 },
586 RenderCommand::DrawText {
587 x: 0.0,
588 y: 0.0,
589 text: "A".to_string(),
590 color: Color::WHITE,
591 },
592 ];
593 let svg = renderer.render(&commands);
594 assert!(svg.contains("id=\"circle-0\""));
595 assert!(svg.contains("id=\"circle-1\""));
596 assert!(svg.contains("id=\"text-2\""));
597 }
598
599 #[test]
600 fn test_full_orbit_render() {
601 let mut renderer = SvgRenderer::new();
602 let commands = vec![
603 RenderCommand::Clear {
604 color: Color::BLACK,
605 },
606 RenderCommand::SetCamera {
607 center_x: 0.0,
608 center_y: 0.0,
609 zoom: 300.0,
610 },
611 RenderCommand::DrawCircle {
612 x: 0.0,
613 y: 0.0,
614 radius: 15.0,
615 color: Color::SUN,
616 filled: true,
617 },
618 RenderCommand::DrawOrbitPath {
619 points: vec![(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)],
620 color: Color::EARTH,
621 },
622 RenderCommand::DrawCircle {
623 x: 1.0,
624 y: 0.0,
625 radius: 5.0,
626 color: Color::EARTH,
627 filled: true,
628 },
629 RenderCommand::DrawText {
630 x: 1.1,
631 y: 0.0,
632 text: "Earth".to_string(),
633 color: Color::WHITE,
634 },
635 ];
636 let svg = renderer.render(&commands);
637
638 assert!(svg.starts_with("<svg"));
640 assert!(svg.ends_with("</svg>\n"));
641 assert!(svg.contains("#ffcc00")); assert!(svg.contains("Earth"));
643
644 let circle_count = svg.matches("<circle").count();
646 assert_eq!(circle_count, 2); let path_count = svg.matches("<path").count();
648 assert_eq!(path_count, 1); let text_count = svg.matches("<text").count();
650 assert_eq!(text_count, 1); }
652}