Skip to main content

runmat_plot/export/
vector.rs

1//! High-quality vector graphics export (SVG, PDF, etc.)
2//!
3//! Production-ready vector output using same rendering pipeline data.
4
5use crate::core::RenderData;
6use crate::plots::Figure;
7use base64::Engine;
8use image::ImageEncoder;
9use std::fmt::Write;
10use std::path::Path;
11
12/// High-quality vector graphics exporter
13pub struct VectorExporter {
14    /// Export settings
15    settings: VectorExportSettings,
16}
17
18/// Vector export configuration
19#[derive(Debug, Clone)]
20pub struct VectorExportSettings {
21    /// Output width in user units
22    pub width: f32,
23    /// Output height in user units
24    pub height: f32,
25    /// Background color [R, G, B, A] (0.0-1.0)
26    pub background_color: [f32; 4],
27    /// Stroke width for lines
28    pub stroke_width: f32,
29    /// Include metadata in output
30    pub include_metadata: bool,
31    /// Enable anti-aliasing
32    pub anti_aliasing: bool,
33}
34
35/// Supported vector formats
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum VectorFormat {
38    Svg,
39    Pdf,
40    Eps,
41}
42
43impl Default for VectorExportSettings {
44    fn default() -> Self {
45        Self {
46            width: 800.0,
47            height: 600.0,
48            background_color: [1.0, 1.0, 1.0, 1.0], // White background
49            stroke_width: 1.0,
50            include_metadata: true,
51            anti_aliasing: true,
52        }
53    }
54}
55
56impl VectorExporter {
57    /// Create a new vector exporter
58    pub fn new() -> Self {
59        Self {
60            settings: VectorExportSettings::default(),
61        }
62    }
63
64    /// Create exporter with custom settings
65    pub fn with_settings(settings: VectorExportSettings) -> Self {
66        Self { settings }
67    }
68
69    /// Export figure to SVG file
70    pub fn export_svg<P: AsRef<Path>>(&self, figure: &mut Figure, path: P) -> Result<(), String> {
71        let svg_content = self.render_to_svg(figure)?;
72        std::fs::write(path, svg_content).map_err(|e| format!("Failed to write SVG file: {e}"))?;
73        log::debug!(target: "runmat_plot", "svg export completed");
74        Ok(())
75    }
76
77    /// Export figure to PDF file (placeholder)
78    pub fn export_pdf<P: AsRef<Path>>(&self, _figure: &mut Figure, _path: P) -> Result<(), String> {
79        // TODO: Implement PDF export using a PDF library
80        Err("PDF export not yet implemented".to_string())
81    }
82
83    /// Render figure to SVG string using same rendering pipeline data
84    pub fn render_to_svg(&self, figure: &mut Figure) -> Result<String, String> {
85        log::debug!(target: "runmat_plot", "svg export render start");
86
87        let mut svg = String::new();
88
89        // SVG header
90        writeln!(
91            &mut svg,
92            r#"<?xml version="1.0" encoding="UTF-8"?>
93<svg width="{}" height="{}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">"#,
94            self.settings.width, self.settings.height
95        ).map_err(|e| format!("SVG write error: {e}"))?;
96
97        // Background
98        if self.settings.background_color[3] > 0.0 {
99            writeln!(
100                &mut svg,
101                r#"  <rect width="100%" height="100%" fill="{}"/>"#,
102                self.color_to_hex(&self.settings.background_color)
103            )
104            .map_err(|e| format!("SVG write error: {e}"))?;
105        }
106
107        // Add metadata if requested
108        if self.settings.include_metadata {
109            writeln!(
110                &mut svg,
111                "  <metadata>\n    <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n      <rdf:Description>\n        <dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">RunMat Plot System</dc:creator>\n      </rdf:Description>\n    </rdf:RDF>\n  </metadata>"
112            ).map_err(|e| format!("SVG write error: {e}"))?;
113        }
114
115        // Figure title (if any)
116        if let Some(title) = &figure.title {
117            let fs = 18;
118            writeln!(
119                &mut svg,
120                "  <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"{}\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
121                self.settings.width * 0.5,
122                24,
123                fs,
124                xml_escape(title)
125            ).map_err(|e| format!("SVG write error: {e}"))?;
126        }
127
128        // Render each plot element grouped by axes (subplots)
129        let (rows, cols) = figure.axes_grid();
130        let gaps = (8.0f32, 8.0f32);
131        let (hgap, vgap) = gaps;
132        let total_hgap = hgap * (cols.saturating_sub(1) as f32);
133        let total_vgap = vgap * (rows.saturating_sub(1) as f32);
134        let cell_w = (self.settings.width - total_hgap).max(1.0) / (cols.max(1) as f32);
135        let cell_h = (self.settings.height - total_vgap).max(1.0) / (rows.max(1) as f32);
136
137        let axes_vps: Vec<(f32, f32, f32, f32)> = (0..rows)
138            .flat_map(|r| {
139                (0..cols).map(move |c| {
140                    (
141                        c as f32 * (cell_w + hgap),
142                        r as f32 * (cell_h + vgap),
143                        cell_w,
144                        cell_h,
145                    )
146                })
147            })
148            .collect();
149
150        let axes_map = figure.plot_axes_indices().to_vec();
151        let rds = figure.render_data();
152        for (i, rd) in rds.iter().enumerate() {
153            let ax = axes_map.get(i).copied().unwrap_or(0).min(rows * cols - 1);
154            let vp = axes_vps[ax];
155            // Axes labels
156            if let Some(lbl) = &figure.x_label {
157                let cx = vp.0 + vp.2 * 0.5;
158                let cy = vp.1 + vp.3 + 20.0;
159                writeln!(
160                    &mut svg,
161                    "  <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"12\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
162                    cx, cy, xml_escape(lbl)
163                ).map_err(|e| format!("SVG write error: {e}"))?;
164            }
165            if let Some(lbl) = &figure.y_label {
166                let cx = vp.0 - 24.0;
167                let cy = vp.1 + vp.3 * 0.5;
168                writeln!(
169                    &mut svg,
170                    "  <text x=\"{}\" y=\"{}\" transform=\"rotate(-90, {}, {})\" text-anchor=\"middle\" font-size=\"12\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
171                    cx, cy, cx, cy, xml_escape(lbl)
172                ).map_err(|e| format!("SVG write error: {e}"))?;
173            }
174            for pie_label in figure.pie_labels_for_axes(ax) {
175                let radius = vp.2.min(vp.3) * 0.4;
176                let screen_x = vp.0 + vp.2 * 0.5 + pie_label.position.x * radius;
177                let screen_y = vp.1 + vp.3 * 0.5 - pie_label.position.y * radius;
178                writeln!(
179                    &mut svg,
180                    "  <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"12\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
181                    screen_x,
182                    screen_y,
183                    xml_escape(&pie_label.label)
184                ).map_err(|e| format!("SVG write error: {e}"))?;
185            }
186            self.add_render_data_to_svg_viewport(&mut svg, rd, vp)?;
187        }
188
189        // SVG footer
190        writeln!(&mut svg, "</svg>").map_err(|e| format!("SVG write error: {e}"))?;
191
192        log::debug!(target: "runmat_plot", "svg export size chars={}", svg.len());
193        Ok(svg)
194    }
195
196    /// Add render data to SVG using same pipeline data
197    /// Note: Will be used when Figure iteration is implemented
198    #[allow(dead_code)]
199    fn add_render_data_to_svg_viewport(
200        &self,
201        svg: &mut String,
202        render_data: &RenderData,
203        viewport: (f32, f32, f32, f32),
204    ) -> Result<(), String> {
205        match render_data.pipeline_type {
206            crate::core::PipelineType::Lines => {
207                self.add_lines_to_svg_viewport(svg, render_data, viewport)?;
208            }
209            crate::core::PipelineType::Points => {
210                self.add_points_to_svg_viewport(svg, render_data, viewport)?;
211            }
212            crate::core::PipelineType::Triangles => {
213                self.add_triangles_to_svg_viewport(svg, render_data, viewport)?;
214            }
215            crate::core::PipelineType::Textured => {
216                self.add_textured_to_svg_viewport(svg, render_data, viewport)?;
217            }
218            crate::core::PipelineType::Scatter3 => {
219                self.add_points_to_svg_viewport(svg, render_data, viewport)?;
220            }
221        }
222        Ok(())
223    }
224
225    /// Add line data to SVG
226    #[allow(dead_code)]
227    fn add_lines_to_svg_viewport(
228        &self,
229        svg: &mut String,
230        render_data: &RenderData,
231        vp: (f32, f32, f32, f32),
232    ) -> Result<(), String> {
233        if render_data.vertices.len() < 2 {
234            return Ok(());
235        }
236
237        // Convert vertices to SVG path
238        writeln!(svg, "  <g>").map_err(|e| format!("SVG write error: {e}"))?;
239
240        // Material encodes style: roughness=line_width, metallic=style, emissive.x=cap, emissive.y=join
241        let lw = render_data.material.roughness.max(0.5);
242        let style_code = render_data.material.metallic as i32;
243        let cap_code = render_data.material.emissive.x as i32;
244        let join_code = render_data.material.emissive.y as i32;
245        let stroke = self.color_to_hex(
246            &render_data
247                .vertices
248                .first()
249                .map(|v| v.color)
250                .unwrap_or([0.0, 0.0, 0.0, 1.0]),
251        );
252        let stroke_linecap = match cap_code {
253            1 => "square",
254            2 => "round",
255            _ => "butt",
256        };
257        let stroke_linejoin = match join_code {
258            1 => "bevel",
259            2 => "round",
260            _ => "miter",
261        };
262        // Normalize dash spacing in SVG to on-screen units scaled by stroke width
263        // Patterns:
264        //  - Dashed:   on=6*lw, off=6*lw
265        //  - Dotted:   on=1*lw, off=6*lw
266        //  - DashDot:  on=6*lw, off=4*lw, dot=1*lw, gap=4*lw
267        let stroke_dasharray: Option<String> = match style_code {
268            1 => Some(format!("{},{}", 6.0 * lw, 6.0 * lw)),
269            2 => Some(format!("{},{}", 1.0 * lw, 6.0 * lw)),
270            3 => Some(format!(
271                "{},{},{},{}",
272                6.0 * lw,
273                4.0 * lw,
274                1.0 * lw,
275                4.0 * lw
276            )),
277            _ => None,
278        };
279
280        for chunk in render_data.vertices.chunks(2) {
281            if chunk.len() == 2 {
282                let start = &chunk[0];
283                let end = &chunk[1];
284
285                let start_screen = self.world_to_screen_viewport(start.position, vp);
286                let end_screen = self.world_to_screen_viewport(end.position, vp);
287
288                if let Some(ref dash) = stroke_dasharray {
289                    writeln!(
290                        svg,
291                        r#"    <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}" stroke-linecap="{}" stroke-linejoin="{}" stroke-dasharray="{}"/>"#,
292                        start_screen[0], start_screen[1], end_screen[0], end_screen[1], stroke, lw, stroke_linecap, stroke_linejoin, dash
293                    ).map_err(|e| format!("SVG write error: {e}"))?;
294                } else {
295                    writeln!(
296                        svg,
297                        r#"    <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}" stroke-linecap="{}" stroke-linejoin="{}"/>"#,
298                        start_screen[0], start_screen[1], end_screen[0], end_screen[1], stroke, lw, stroke_linecap, stroke_linejoin
299                    ).map_err(|e| format!("SVG write error: {e}"))?;
300                }
301            }
302        }
303
304        writeln!(svg, "  </g>").map_err(|e| format!("SVG write error: {e}"))?;
305        Ok(())
306    }
307
308    /// Add point data to SVG
309    #[allow(dead_code)]
310    fn add_points_to_svg_viewport(
311        &self,
312        svg: &mut String,
313        render_data: &RenderData,
314        vp: (f32, f32, f32, f32),
315    ) -> Result<(), String> {
316        writeln!(svg, "  <g>").map_err(|e| format!("SVG write error: {e}"))?;
317
318        let marker_shape = render_data.material.metallic as u32; // 0 circle, 1 square
319        let edge_color = render_data.material.emissive;
320        let edge_width = render_data.material.roughness.max(0.0);
321        for vertex in &render_data.vertices {
322            let screen_pos = self.world_to_screen_viewport(vertex.position, vp);
323            let radius = (vertex.normal[2] * 0.5).max(1.0);
324            // Map to viewport scale roughly (already in pixels; SVG units are px)
325
326            if marker_shape == 1u32 {
327                // square
328                let x = screen_pos[0] - radius;
329                let y = screen_pos[1] - radius;
330                writeln!(
331                    svg,
332                    r#"    <rect x="{}" y="{}" width="{}" height="{}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
333                    x,
334                    y,
335                    radius*2.0,
336                    radius*2.0,
337                    self.color_to_hex(&vertex.color),
338                    self.color_to_hex(&edge_color.to_array()),
339                    edge_width
340                ).map_err(|e| format!("SVG write error: {e}"))?;
341            } else {
342                // circle default
343                writeln!(
344                    svg,
345                    r#"    <circle cx="{}" cy="{}" r="{}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
346                    screen_pos[0],
347                    screen_pos[1],
348                    radius,
349                    self.color_to_hex(&vertex.color),
350                    self.color_to_hex(&edge_color.to_array()),
351                    edge_width
352                ).map_err(|e| format!("SVG write error: {e}"))?;
353            }
354        }
355
356        writeln!(svg, "  </g>").map_err(|e| format!("SVG write error: {e}"))?;
357        Ok(())
358    }
359
360    /// Add triangle data to SVG
361    #[allow(dead_code)]
362    fn add_triangles_to_svg_viewport(
363        &self,
364        svg: &mut String,
365        render_data: &RenderData,
366        vp: (f32, f32, f32, f32),
367    ) -> Result<(), String> {
368        writeln!(svg, "  <g>").map_err(|e| format!("SVG write error: {e}"))?;
369
370        for triangle in render_data.vertices.chunks(3) {
371            if triangle.len() == 3 {
372                let p1 = self.world_to_screen_viewport(triangle[0].position, vp);
373                let p2 = self.world_to_screen_viewport(triangle[1].position, vp);
374                let p3 = self.world_to_screen_viewport(triangle[2].position, vp);
375
376                writeln!(
377                    svg,
378                    r#"    <polygon points="{},{} {},{} {},{}" fill="{}"/>"#,
379                    p1[0],
380                    p1[1],
381                    p2[0],
382                    p2[1],
383                    p3[0],
384                    p3[1],
385                    self.color_to_hex(&triangle[0].color)
386                )
387                .map_err(|e| format!("SVG write error: {e}"))?;
388            }
389        }
390
391        writeln!(svg, "  </g>").map_err(|e| format!("SVG write error: {e}"))?;
392        Ok(())
393    }
394
395    /// Convert world coordinates to screen coordinates
396    #[allow(dead_code)]
397    fn world_to_screen_viewport(&self, world_pos: [f32; 3], vp: (f32, f32, f32, f32)) -> [f32; 2] {
398        let (vx, vy, vw, vh) = vp;
399        [
400            vx + (world_pos[0] + 1.0) * 0.5 * vw,
401            vy + (1.0 - (world_pos[1] + 1.0) * 0.5) * vh,
402        ]
403    }
404
405    /// Draw textured quad as a filled rectangle (placeholder), sampling average color if available
406    fn add_textured_to_svg_viewport(
407        &self,
408        svg: &mut String,
409        render_data: &RenderData,
410        vp: (f32, f32, f32, f32),
411    ) -> Result<(), String> {
412        // Compute screen-space bounding box from vertices
413        if render_data.vertices.is_empty() {
414            return Ok(());
415        }
416        let mut min_x = f32::INFINITY;
417        let mut min_y = f32::INFINITY;
418        let mut max_x = f32::NEG_INFINITY;
419        let mut max_y = f32::NEG_INFINITY;
420        for v in &render_data.vertices {
421            let p = self.world_to_screen_viewport(v.position, vp);
422            min_x = min_x.min(p[0]);
423            max_x = max_x.max(p[0]);
424            min_y = min_y.min(p[1]);
425            max_y = max_y.max(p[1]);
426        }
427        let w = (max_x - min_x).max(1.0);
428        let h = (max_y - min_y).max(1.0);
429        // Embed image as base64 data URI if available
430        if let Some(crate::core::scene::ImageData::Rgba8 {
431            width,
432            height,
433            data,
434        }) = &render_data.image
435        {
436            if !data.is_empty() {
437                // Encode RGBA8 as PNG in-memory
438                let mut png_buf: Vec<u8> = Vec::new();
439                {
440                    let encoder = image::codecs::png::PngEncoder::new(&mut png_buf);
441                    encoder
442                        .write_image(data, *width, *height, image::ColorType::Rgba8)
443                        .map_err(|e| format!("PNG encode failed: {e}"))?;
444                }
445                let b64 = base64::engine::general_purpose::STANDARD.encode(&png_buf);
446                let href = format!("data:image/png;base64,{}", b64);
447                writeln!(
448                    svg,
449                    r#"  <image x="{}" y="{}" width="{}" height="{}" xlink:href="{}" preserveAspectRatio="none"/>"#,
450                    min_x, min_y, w, h, href
451                ).map_err(|e| format!("SVG write error: {e}"))?;
452                return Ok(());
453            }
454        }
455        // Fallback: gray rect
456        writeln!(
457            svg,
458            "  <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"#999999\"/>",
459            min_x, min_y, w, h
460        )
461        .map_err(|e| format!("SVG write error: {e}"))?;
462        Ok(())
463    }
464
465    /// Convert color array to hex string
466    fn color_to_hex(&self, color: &[f32; 4]) -> String {
467        format!(
468            "#{:02x}{:02x}{:02x}",
469            (color[0] * 255.0) as u8,
470            (color[1] * 255.0) as u8,
471            (color[2] * 255.0) as u8
472        )
473    }
474
475    /// Update export settings
476    pub fn set_settings(&mut self, settings: VectorExportSettings) {
477        self.settings = settings;
478    }
479
480    /// Get current export settings
481    pub fn settings(&self) -> &VectorExportSettings {
482        &self.settings
483    }
484}
485fn xml_escape(s: &str) -> String {
486    s.replace('&', "&amp;")
487        .replace('<', "&lt;")
488        .replace('>', "&gt;")
489        .replace('"', "&quot;")
490        .replace('\'', "&apos;")
491}
492
493impl Default for VectorExporter {
494    fn default() -> Self {
495        Self::new()
496    }
497}