1use crate::core::RenderData;
6use crate::plots::Figure;
7use base64::Engine;
8use image::ImageEncoder;
9use std::fmt::Write;
10use std::path::Path;
11
12pub struct VectorExporter {
14 settings: VectorExportSettings,
16}
17
18#[derive(Debug, Clone)]
20pub struct VectorExportSettings {
21 pub width: f32,
23 pub height: f32,
25 pub background_color: [f32; 4],
27 pub stroke_width: f32,
29 pub include_metadata: bool,
31 pub anti_aliasing: bool,
33}
34
35#[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], stroke_width: 1.0,
50 include_metadata: true,
51 anti_aliasing: true,
52 }
53 }
54}
55
56impl VectorExporter {
57 pub fn new() -> Self {
59 Self {
60 settings: VectorExportSettings::default(),
61 }
62 }
63
64 pub fn with_settings(settings: VectorExportSettings) -> Self {
66 Self { settings }
67 }
68
69 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 pub fn export_pdf<P: AsRef<Path>>(&self, _figure: &mut Figure, _path: P) -> Result<(), String> {
79 Err("PDF export not yet implemented".to_string())
81 }
82
83 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 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 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 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 let sg_title_band = if figure
116 .sg_title
117 .as_deref()
118 .map(str::trim)
119 .is_some_and(|text| !text.is_empty())
120 {
121 let fs = figure
122 .sg_title_style
123 .font_size
124 .map(|size| size.max(12.0))
125 .unwrap_or(18.0);
126 let fill = figure
127 .sg_title_style
128 .color
129 .map(|color| self.color_to_hex(&color.to_array()))
130 .unwrap_or_else(|| "#000000".to_string());
131 let weight = if figure
132 .sg_title_style
133 .font_weight
134 .as_deref()
135 .map(|weight| weight.eq_ignore_ascii_case("bold"))
136 .unwrap_or(false)
137 {
138 " font-weight=\"bold\""
139 } else {
140 ""
141 };
142 let padding = (fs * 0.4).max(8.0f32);
145 let text_y = (fs + padding).round() as i32;
146 let band_height = text_y as f32 + padding;
147 writeln!(
148 &mut svg,
149 " <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"{}\" fill=\"{}\" font-family=\"sans-serif\"{}>{}</text>",
150 self.settings.width * 0.5,
151 text_y,
152 fs,
153 fill,
154 weight,
155 xml_escape(figure.sg_title.as_deref().unwrap_or_default())
156 ).map_err(|e| format!("SVG write error: {e}"))?;
157 band_height
158 } else {
159 0.0f32
160 };
161
162 let (rows, cols) = figure.axes_grid();
164 let gaps = (8.0f32, 8.0f32);
165 let (hgap, vgap) = gaps;
166 let total_hgap = hgap * (cols.saturating_sub(1) as f32);
167 let total_vgap = vgap * (rows.saturating_sub(1) as f32);
168 let cell_w = (self.settings.width - total_hgap).max(1.0) / (cols.max(1) as f32);
169 let cell_h =
170 (self.settings.height - total_vgap - sg_title_band).max(1.0) / (rows.max(1) as f32);
171
172 let axes_vps: Vec<(f32, f32, f32, f32)> = (0..rows)
173 .flat_map(|r| {
174 (0..cols).map(move |c| {
175 (
176 c as f32 * (cell_w + hgap),
177 sg_title_band + r as f32 * (cell_h + vgap),
178 cell_w,
179 cell_h,
180 )
181 })
182 })
183 .collect();
184
185 for (ax, vp) in axes_vps.iter().copied().enumerate().take(rows * cols) {
188 if let Some(meta) = figure.axes_metadata.get(ax) {
189 let title_text = meta
190 .title
191 .as_deref()
192 .map(str::trim)
193 .filter(|t| !t.is_empty());
194 if let Some(title) = title_text {
195 let style = &meta.title_style;
196 let fs = style.font_size.map(|s| s.max(10.0)).unwrap_or(14.0);
197 let fill = style
198 .color
199 .map(|c| self.color_to_hex(&c.to_array()))
200 .unwrap_or_else(|| "#000000".to_string());
201 let weight = if style
202 .font_weight
203 .as_deref()
204 .map(|w| w.eq_ignore_ascii_case("bold"))
205 .unwrap_or(false)
206 {
207 " font-weight=\"bold\""
208 } else {
209 ""
210 };
211 writeln!(
212 &mut svg,
213 " <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"{}\" fill=\"{}\" font-family=\"sans-serif\"{}>{}</text>",
214 vp.0 + vp.2 * 0.5,
215 vp.1 + fs + 2.0,
216 fs,
217 fill,
218 weight,
219 xml_escape(title)
220 )
221 .map_err(|e| format!("SVG write error: {e}"))?;
222 }
223 }
224 }
225
226 let axes_map = figure.plot_axes_indices().to_vec();
227 let rds = figure.render_data();
228 for (i, rd) in rds.iter().enumerate() {
229 let ax = axes_map.get(i).copied().unwrap_or(0).min(rows * cols - 1);
230 let vp = axes_vps[ax];
231 if let Some(lbl) = &figure.x_label {
233 let cx = vp.0 + vp.2 * 0.5;
234 let cy = vp.1 + vp.3 + 20.0;
235 writeln!(
236 &mut svg,
237 " <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"12\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
238 cx, cy, xml_escape(lbl)
239 ).map_err(|e| format!("SVG write error: {e}"))?;
240 }
241 if let Some(lbl) = &figure.y_label {
242 let cx = vp.0 - 24.0;
243 let cy = vp.1 + vp.3 * 0.5;
244 writeln!(
245 &mut svg,
246 " <text x=\"{}\" y=\"{}\" transform=\"rotate(-90, {}, {})\" text-anchor=\"middle\" font-size=\"12\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
247 cx, cy, cx, cy, xml_escape(lbl)
248 ).map_err(|e| format!("SVG write error: {e}"))?;
249 }
250 for pie_label in figure.pie_labels_for_axes(ax) {
251 let radius = vp.2.min(vp.3) * 0.4;
252 let screen_x = vp.0 + vp.2 * 0.5 + pie_label.position.x * radius;
253 let screen_y = vp.1 + vp.3 * 0.5 - pie_label.position.y * radius;
254 writeln!(
255 &mut svg,
256 " <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"12\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
257 screen_x,
258 screen_y,
259 xml_escape(&pie_label.label)
260 ).map_err(|e| format!("SVG write error: {e}"))?;
261 }
262 self.add_render_data_to_svg_viewport(&mut svg, rd, vp)?;
263 }
264
265 writeln!(&mut svg, "</svg>").map_err(|e| format!("SVG write error: {e}"))?;
267
268 log::debug!(target: "runmat_plot", "svg export size chars={}", svg.len());
269 Ok(svg)
270 }
271
272 #[allow(dead_code)]
275 fn add_render_data_to_svg_viewport(
276 &self,
277 svg: &mut String,
278 render_data: &RenderData,
279 viewport: (f32, f32, f32, f32),
280 ) -> Result<(), String> {
281 match render_data.pipeline_type {
282 crate::core::PipelineType::Lines => {
283 self.add_lines_to_svg_viewport(svg, render_data, viewport)?;
284 }
285 crate::core::PipelineType::Points => {
286 self.add_points_to_svg_viewport(svg, render_data, viewport)?;
287 }
288 crate::core::PipelineType::Triangles => {
289 self.add_triangles_to_svg_viewport(svg, render_data, viewport)?;
290 }
291 crate::core::PipelineType::Textured => {
292 self.add_textured_to_svg_viewport(svg, render_data, viewport)?;
293 }
294 crate::core::PipelineType::Scatter3 => {
295 self.add_points_to_svg_viewport(svg, render_data, viewport)?;
296 }
297 }
298 Ok(())
299 }
300
301 #[allow(dead_code)]
303 fn add_lines_to_svg_viewport(
304 &self,
305 svg: &mut String,
306 render_data: &RenderData,
307 vp: (f32, f32, f32, f32),
308 ) -> Result<(), String> {
309 if render_data.vertices.len() < 2 {
310 return Ok(());
311 }
312
313 writeln!(svg, " <g>").map_err(|e| format!("SVG write error: {e}"))?;
315
316 let lw = render_data.material.roughness.max(0.5);
318 let style_code = render_data.material.metallic as i32;
319 let cap_code = render_data.material.emissive.x as i32;
320 let join_code = render_data.material.emissive.y as i32;
321 let stroke = self.color_to_hex(
322 &render_data
323 .vertices
324 .first()
325 .map(|v| v.color)
326 .unwrap_or([0.0, 0.0, 0.0, 1.0]),
327 );
328 let stroke_linecap = match cap_code {
329 1 => "square",
330 2 => "round",
331 _ => "butt",
332 };
333 let stroke_linejoin = match join_code {
334 1 => "bevel",
335 2 => "round",
336 _ => "miter",
337 };
338 let stroke_dasharray: Option<String> = match style_code {
344 1 => Some(format!("{},{}", 6.0 * lw, 6.0 * lw)),
345 2 => Some(format!("{},{}", 1.0 * lw, 6.0 * lw)),
346 3 => Some(format!(
347 "{},{},{},{}",
348 6.0 * lw,
349 4.0 * lw,
350 1.0 * lw,
351 4.0 * lw
352 )),
353 _ => None,
354 };
355
356 for chunk in render_data.vertices.chunks(2) {
357 if chunk.len() == 2 {
358 let start = &chunk[0];
359 let end = &chunk[1];
360
361 let start_screen = self.world_to_screen_viewport(start.position, vp);
362 let end_screen = self.world_to_screen_viewport(end.position, vp);
363
364 if let Some(ref dash) = stroke_dasharray {
365 writeln!(
366 svg,
367 r#" <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}" stroke-linecap="{}" stroke-linejoin="{}" stroke-dasharray="{}"/>"#,
368 start_screen[0], start_screen[1], end_screen[0], end_screen[1], stroke, lw, stroke_linecap, stroke_linejoin, dash
369 ).map_err(|e| format!("SVG write error: {e}"))?;
370 } else {
371 writeln!(
372 svg,
373 r#" <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}" stroke-linecap="{}" stroke-linejoin="{}"/>"#,
374 start_screen[0], start_screen[1], end_screen[0], end_screen[1], stroke, lw, stroke_linecap, stroke_linejoin
375 ).map_err(|e| format!("SVG write error: {e}"))?;
376 }
377 }
378 }
379
380 writeln!(svg, " </g>").map_err(|e| format!("SVG write error: {e}"))?;
381 Ok(())
382 }
383
384 #[allow(dead_code)]
386 fn add_points_to_svg_viewport(
387 &self,
388 svg: &mut String,
389 render_data: &RenderData,
390 vp: (f32, f32, f32, f32),
391 ) -> Result<(), String> {
392 writeln!(svg, " <g>").map_err(|e| format!("SVG write error: {e}"))?;
393
394 let marker_shape = render_data.material.metallic as u32; let edge_color = render_data.material.emissive;
396 let edge_width = render_data.material.roughness.max(0.0);
397 for vertex in &render_data.vertices {
398 let screen_pos = self.world_to_screen_viewport(vertex.position, vp);
399 let radius = (vertex.normal[2] * 0.5).max(1.0);
400 if marker_shape == 1u32 {
403 let x = screen_pos[0] - radius;
405 let y = screen_pos[1] - radius;
406 writeln!(
407 svg,
408 r#" <rect x="{}" y="{}" width="{}" height="{}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
409 x,
410 y,
411 radius*2.0,
412 radius*2.0,
413 self.color_to_hex(&vertex.color),
414 self.color_to_hex(&edge_color.to_array()),
415 edge_width
416 ).map_err(|e| format!("SVG write error: {e}"))?;
417 } else {
418 writeln!(
420 svg,
421 r#" <circle cx="{}" cy="{}" r="{}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
422 screen_pos[0],
423 screen_pos[1],
424 radius,
425 self.color_to_hex(&vertex.color),
426 self.color_to_hex(&edge_color.to_array()),
427 edge_width
428 ).map_err(|e| format!("SVG write error: {e}"))?;
429 }
430 }
431
432 writeln!(svg, " </g>").map_err(|e| format!("SVG write error: {e}"))?;
433 Ok(())
434 }
435
436 #[allow(dead_code)]
438 fn add_triangles_to_svg_viewport(
439 &self,
440 svg: &mut String,
441 render_data: &RenderData,
442 vp: (f32, f32, f32, f32),
443 ) -> Result<(), String> {
444 writeln!(svg, " <g>").map_err(|e| format!("SVG write error: {e}"))?;
445
446 for triangle in render_data.vertices.chunks(3) {
447 if triangle.len() == 3 {
448 let p1 = self.world_to_screen_viewport(triangle[0].position, vp);
449 let p2 = self.world_to_screen_viewport(triangle[1].position, vp);
450 let p3 = self.world_to_screen_viewport(triangle[2].position, vp);
451
452 writeln!(
453 svg,
454 r#" <polygon points="{},{} {},{} {},{}" fill="{}"/>"#,
455 p1[0],
456 p1[1],
457 p2[0],
458 p2[1],
459 p3[0],
460 p3[1],
461 self.color_to_hex(&triangle[0].color)
462 )
463 .map_err(|e| format!("SVG write error: {e}"))?;
464 }
465 }
466
467 writeln!(svg, " </g>").map_err(|e| format!("SVG write error: {e}"))?;
468 Ok(())
469 }
470
471 #[allow(dead_code)]
473 fn world_to_screen_viewport(&self, world_pos: [f32; 3], vp: (f32, f32, f32, f32)) -> [f32; 2] {
474 let (vx, vy, vw, vh) = vp;
475 [
476 vx + (world_pos[0] + 1.0) * 0.5 * vw,
477 vy + (1.0 - (world_pos[1] + 1.0) * 0.5) * vh,
478 ]
479 }
480
481 fn add_textured_to_svg_viewport(
483 &self,
484 svg: &mut String,
485 render_data: &RenderData,
486 vp: (f32, f32, f32, f32),
487 ) -> Result<(), String> {
488 if render_data.vertices.is_empty() {
490 return Ok(());
491 }
492 let mut min_x = f32::INFINITY;
493 let mut min_y = f32::INFINITY;
494 let mut max_x = f32::NEG_INFINITY;
495 let mut max_y = f32::NEG_INFINITY;
496 for v in &render_data.vertices {
497 let p = self.world_to_screen_viewport(v.position, vp);
498 min_x = min_x.min(p[0]);
499 max_x = max_x.max(p[0]);
500 min_y = min_y.min(p[1]);
501 max_y = max_y.max(p[1]);
502 }
503 let w = (max_x - min_x).max(1.0);
504 let h = (max_y - min_y).max(1.0);
505 if let Some(crate::core::scene::ImageData::Rgba8 {
507 width,
508 height,
509 data,
510 }) = &render_data.image
511 {
512 if !data.is_empty() {
513 let mut png_buf: Vec<u8> = Vec::new();
515 {
516 let encoder = image::codecs::png::PngEncoder::new(&mut png_buf);
517 encoder
518 .write_image(data, *width, *height, image::ColorType::Rgba8)
519 .map_err(|e| format!("PNG encode failed: {e}"))?;
520 }
521 let b64 = base64::engine::general_purpose::STANDARD.encode(&png_buf);
522 let href = format!("data:image/png;base64,{}", b64);
523 writeln!(
524 svg,
525 r#" <image x="{}" y="{}" width="{}" height="{}" xlink:href="{}" preserveAspectRatio="none"/>"#,
526 min_x, min_y, w, h, href
527 ).map_err(|e| format!("SVG write error: {e}"))?;
528 return Ok(());
529 }
530 }
531 writeln!(
533 svg,
534 " <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"#999999\"/>",
535 min_x, min_y, w, h
536 )
537 .map_err(|e| format!("SVG write error: {e}"))?;
538 Ok(())
539 }
540
541 fn color_to_hex(&self, color: &[f32; 4]) -> String {
543 format!(
544 "#{:02x}{:02x}{:02x}",
545 (color[0] * 255.0) as u8,
546 (color[1] * 255.0) as u8,
547 (color[2] * 255.0) as u8
548 )
549 }
550
551 pub fn set_settings(&mut self, settings: VectorExportSettings) {
553 self.settings = settings;
554 }
555
556 pub fn settings(&self) -> &VectorExportSettings {
558 &self.settings
559 }
560}
561fn xml_escape(s: &str) -> String {
562 s.replace('&', "&")
563 .replace('<', "<")
564 .replace('>', ">")
565 .replace('"', """)
566 .replace('\'', "'")
567}
568
569impl Default for VectorExporter {
570 fn default() -> Self {
571 Self::new()
572 }
573}