Skip to main content

simular/renderers/
svg.rs

1//! SVG Renderer for simulation visualization.
2//!
3//! Consumes `Vec<RenderCommand>` and outputs Grid Protocol SVG strings
4//! for consumption by rmedia's native SVG producer (`type="svg"`).
5//!
6//! # Grid Protocol Compliance
7//!
8//! Output SVGs follow the Grid Protocol specification:
9//! - Canvas: 1920x1080, `viewBox` matches `width`/`height`
10//! - 16x9 grid (120px cells) for element positioning
11//! - Dark palette (`#0f172a` canvas, `#1e293b` panels)
12//! - 18px minimum font size, 4.5:1 WCAG AA contrast
13//! - Element IDs on every `<g>` group for rmedia targeting
14//!
15//! # References
16//!
17//! - SVG Grid Protocol: `docs/specifications/svg-grid-protocol.md`
18//! - rmedia SVG producer: `paiml/rmedia#6`, `paiml/rmedia#7`
19
20use crate::orbit::render::{Camera, Color, RenderCommand};
21use serde::{Deserialize, Serialize};
22use std::fmt::Write;
23
24/// Grid Protocol canvas dimensions.
25const CANVAS_WIDTH: f64 = 1920.0;
26const CANVAS_HEIGHT: f64 = 1080.0;
27
28/// Grid Protocol dark palette — canvas background.
29const BG_CANVAS: &str = "#0f172a";
30
31/// Grid Protocol dark palette — primary text.
32const TEXT_PRIMARY: &str = "#f1f5f9";
33
34/// SVG renderer configuration.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SvgConfig {
37    /// Canvas width in pixels.
38    pub width: f64,
39    /// Canvas height in pixels.
40    pub height: f64,
41    /// Background color hex.
42    pub background: String,
43    /// Font family for text elements.
44    pub font_family: String,
45    /// Minimum font size (Grid Protocol: 18px).
46    pub min_font_size: f64,
47    /// Whether to include the grid manifest comment.
48    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/// SVG renderer that consumes render commands and produces SVG strings.
65#[derive(Debug, Clone)]
66pub struct SvgRenderer {
67    config: SvgConfig,
68    camera: Camera,
69}
70
71impl SvgRenderer {
72    /// Create a new SVG renderer with default configuration.
73    #[must_use]
74    pub fn new() -> Self {
75        Self::with_config(SvgConfig::default())
76    }
77
78    /// Create a new SVG renderer with custom configuration.
79    #[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    /// Render a set of commands to an SVG string.
90    #[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    /// Build the complete SVG document from rendered elements.
230    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        // Arrowhead marker definition
249        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/// Convert RGBA color to hex string.
275#[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
287/// Escape XML special characters in text content.
288fn xml_escape(s: &str) -> String {
289    s.replace('&', "&amp;")
290        .replace('<', "&lt;")
291        .replace('>', "&gt;")
292        .replace('"', "&quot;")
293        .replace('\'', "&apos;")
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&lt;b&gt;c");
326        assert_eq!(xml_escape("a&b"), "a&amp;b");
327        assert_eq!(xml_escape(r#"a"b"#), "a&quot;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("&amp;"));
476        assert!(svg.contains("&lt;"));
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        // At zoom=2, x=100 should map to 960 + 100*2 = 1160
530        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        // Grid Protocol check #20: viewBox must match width/height
563        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        // Structural checks
639        assert!(svg.starts_with("<svg"));
640        assert!(svg.ends_with("</svg>\n"));
641        assert!(svg.contains("#ffcc00")); // Sun color
642        assert!(svg.contains("Earth"));
643
644        // Count elements
645        let circle_count = svg.matches("<circle").count();
646        assert_eq!(circle_count, 2); // Sun + Earth
647        let path_count = svg.matches("<path").count();
648        assert_eq!(path_count, 1); // Orbit trail
649        let text_count = svg.matches("<text").count();
650        assert_eq!(text_count, 1); // Earth label
651    }
652}