From 666b5d561a02bf038d18ffb6b1c9fd4dbdc6630b Mon Sep 17 00:00:00 2001 From: francesco fantoni Date: Sat, 10 Oct 2026 14:41:31 +0200 Subject: [PATCH] added SVG export feature --- src/app/commands/display.rs | 7 + src/app/commands/mod.rs | 1 + src/app/control/actions.rs | 60 ++- src/app/update/file.rs | 54 +- src/app/update/mod.rs | 20 +- src/io/mod.rs | 2 + src/io/pdf_export.rs | 137 ++--- src/io/plot.rs | 91 ++++ src/io/svg_export.rs | 1007 +++++++++++++++++++++++++++++++++++ src/ui/window/plot.rs | 19 +- 10 files changed, 1274 insertions(+), 124 deletions(-) create mode 100644 src/io/plot.rs create mode 100644 src/io/svg_export.rs diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index b194a71f9..9f00e6cf4 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -895,6 +895,13 @@ impl OpenCADStudio { "EXPORT" | "EXPORTPDF" => { return Some(Task::done(Message::PlotExport)); } + // EXPORTSVG — the same direct export, but the save dialog + // proposes the SVG device's defaults (`stem.svg`). + "EXPORTSVG" => { + self.plot_dialog.to_file = true; + self.plot_dialog.file_svg = true; + return Some(Task::done(Message::PlotExport)); + } // PLOTSTYLE — load or clear CTB/STB plot style table cmd if cmd == "PLOTSTYLE" || cmd.starts_with("PLOTSTYLE ") => { let sub = cmd diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 72c664de6..ab6991ffd 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -475,6 +475,7 @@ inventory::submit!(crate::command::CommandRegistration { // Standard aliases for existing commands. "BMAKE", "EXPORTPDF", + "EXPORTSVG", "PRINTALL", "DDIM", // Inquiry: list the whole drawing database. diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 1ea345409..b83287a3b 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -474,8 +474,11 @@ impl OpenCADStudio { use crate::io::pdf_export::PdfPageInput; let path = string(req, "path")?; - if !path.to_ascii_lowercase().ends_with(".pdf") { - return Err(failure("invalid_path", "plot writes .pdf files")); + let lower = path.to_ascii_lowercase(); + // SVG shares the plot page with PDF: the writer follows the extension. + let is_svg = lower.ends_with(".svg"); + if !is_svg && !lower.ends_with(".pdf") { + return Err(failure("invalid_path", "plot writes .pdf or .svg files")); } self.stamp_plot_fields(); let i = self.active_tab; @@ -688,22 +691,37 @@ impl OpenCADStudio { self.tabs[i].scene.camera_generation = original_camera_generation; let pages = built.map_err(|e| failure("plot_failed", e))?; if req["per_page"].as_bool().unwrap_or(false) { - // One PDF per layout: -.pdf beside the requested + // One file per layout: -.pdf/.svg beside the requested // path (sheet-by-sheet delivery). - let stem = path.strip_suffix(".pdf").unwrap_or(path).to_owned(); + let stem = path + .strip_suffix(".pdf") + .or_else(|| path.strip_suffix(".PDF")) + .or_else(|| path.strip_suffix(".svg")) + .or_else(|| path.strip_suffix(".SVG")) + .unwrap_or(path) + .to_owned(); + let ext = if is_svg { "svg" } else { "pdf" }; let mut files = Vec::with_capacity(pages.len()); for (name, page) in targets.iter().zip(&pages) { let safe: String = name .chars() .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) .collect(); - let file = format!("{stem}-{safe}.pdf"); - crate::io::pdf_export::export_pdf_pages( - std::slice::from_ref(page), - std::path::Path::new(&file), - loaded_style.as_ref(), - ) - .map_err(|e| failure("plot_failed", e))?; + let file = format!("{stem}-{safe}.{ext}"); + let result = if is_svg { + crate::io::svg_export::export_svg_pages( + std::slice::from_ref(page), + std::path::Path::new(&file), + loaded_style.as_ref(), + ) + } else { + crate::io::pdf_export::export_pdf_pages( + std::slice::from_ref(page), + std::path::Path::new(&file), + loaded_style.as_ref(), + ) + }; + result.map_err(|e| failure("plot_failed", e))?; files.push(json!({"layout": name, "path": file})); } self.set_control_result(json!({ "files": files, "pages": pages.len() })); @@ -712,12 +730,20 @@ impl OpenCADStudio { // Pages may drop the style when a stored page setup overwrites the // dialog's style fields, so the explicit request style rides along as // the export-level fallback. - crate::io::pdf_export::export_pdf_pages( - &pages, - std::path::Path::new(path), - loaded_style.as_ref(), - ) - .map_err(|e| failure("plot_failed", e))?; + let result = if is_svg { + crate::io::svg_export::export_svg_pages( + &pages, + std::path::Path::new(path), + loaded_style.as_ref(), + ) + } else { + crate::io::pdf_export::export_pdf_pages( + &pages, + std::path::Path::new(path), + loaded_style.as_ref(), + ) + }; + result.map_err(|e| failure("plot_failed", e))?; self.set_control_result(json!({ "path": path, "pages": pages.len(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index a1dee8a1d..4b17f8625 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -3832,7 +3832,17 @@ impl OpenCADStudio { }; let worker_path = path.clone(); let work = move || { - crate::io::pdf_export::export_pdf(&page, &worker_path) + // SVG shares the plot page with PDF: pick the writer from the + // chosen extension. + let result = if worker_path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("svg")) + { + crate::io::svg_export::export_svg(&page, &worker_path) + } else { + crate::io::pdf_export::export_pdf(&page, &worker_path) + }; + result .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) }; @@ -3859,7 +3869,15 @@ impl OpenCADStudio { let worker_path = path.clone(); self.close_active_modal(); let work = move || { - crate::io::pdf_export::export_pdf(&page, &worker_path) + let result = if worker_path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("svg")) + { + crate::io::svg_export::export_svg(&page, &worker_path) + } else { + crate::io::pdf_export::export_pdf(&page, &worker_path) + }; + result .map(|_| { crate::tf!( "Plotted window to {}", @@ -4856,7 +4874,7 @@ impl OpenCADStudio { msg: crate::ui::window::plot::PlotDlgMsg, ) -> Task { use crate::ui::window::plot::{ - PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF, STYLE_NONE, + PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF, OUT_SVG, STYLE_NONE, }; match msg { M::Close => { @@ -4872,11 +4890,17 @@ impl OpenCADStudio { self.plot_dialog.printer_editor = None; if s == OUT_PDF { self.plot_dialog.to_file = true; + self.plot_dialog.file_svg = false; + } else if s == OUT_SVG { + self.plot_dialog.to_file = true; + self.plot_dialog.file_svg = true; } else if s == OUT_DEFAULT { self.plot_dialog.to_file = false; + self.plot_dialog.file_svg = false; self.plot_dialog.printer = None; } else { self.plot_dialog.to_file = false; + self.plot_dialog.file_svg = false; self.plot_dialog.printer = Some(s); } self.save_config(); @@ -5371,6 +5395,7 @@ impl OpenCADStudio { let is_model = self.tabs[self.active_tab].scene.current_layout == "Model"; let d = &mut self.plot_dialog; d.to_file = true; + d.file_svg = false; let a4 = crate::io::paper_catalog::default_paper(); d.paper = a4.canonical.to_string(); d.orientation = "Landscape".into(); @@ -5593,14 +5618,17 @@ impl OpenCADStudio { match crate::io::plot_device::PlotDevice::from_stored_name(&ps.printer_name).0 { crate::io::plot_device::PlotDevice::Pdf => { d.to_file = true; + d.file_svg = false; d.printer = None; } crate::io::plot_device::PlotDevice::Printer(name) => { d.to_file = false; + d.file_svg = false; d.printer = Some(name); } crate::io::plot_device::PlotDevice::None => { d.to_file = false; + d.file_svg = false; d.printer = None; } } @@ -6714,6 +6742,26 @@ mod plot_paper_tests { assert_eq!(ps.margins, codec::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0)); } + #[test] + fn svg_device_is_session_state_and_stores_as_pdf() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + // The SVG device behaves like file output… + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_SVG.into())); + assert!(app.plot_dialog.to_file); + assert!(app.plot_dialog.file_svg); + // …but switching back to PDF (or a printer) clears it. + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + assert!(app.plot_dialog.to_file); + assert!(!app.plot_dialog.file_svg); + // The drawing never learns about SVG: page setups only know printers + // and PDF drivers, so an SVG plot stores the PDF driver spelling. + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_SVG.into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.printer_name, "DWG To PDF.pc3"); + } + #[test] fn legacy_pdf_label_is_upgraded_on_save() { let mut app = app_with_printer_named_sheet(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 985df0ebb..05dd72207 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -9824,14 +9824,14 @@ impl OpenCADStudio { return Task::done(Message::PrintAllPdfPath(None)); }; iced::window::run(window_id, move |parent| { - crate::io::pdf_export::pick_pdf_path_owned(stem, parent) + crate::io::pdf_export::pick_plot_path_owned(stem, parent, false) }) .map(Message::PrintAllPdfPath) } #[cfg(target_os = "windows")] { Task::perform( - crate::io::pdf_export::pick_pdf_path_async(stem), + crate::io::pdf_export::pick_plot_path_async(stem, false), Message::PrintAllPdfPath, ) } @@ -9870,27 +9870,30 @@ impl OpenCADStudio { .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); + // The save dialog follows the dialog's file device, so the + // SVG choice proposes `stem.svg` up front. + let svg = self.plot_dialog.file_svg; #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PlotExportPath(None)); }; iced::window::run(window_id, move |parent| { - crate::io::pdf_export::pick_pdf_path_owned(stem, parent) + crate::io::pdf_export::pick_plot_path_owned(stem, parent, svg) }) .map(Message::PlotExportPath) } #[cfg(target_os = "windows")] { Task::perform( - crate::io::pdf_export::pick_pdf_path_async(stem), + crate::io::pdf_export::pick_plot_path_async(stem, svg), Message::PlotExportPath, ) } #[cfg(target_arch = "wasm32")] { Task::perform( - crate::io::pdf_export::pick_pdf_path_owned(stem), + crate::io::pdf_export::pick_plot_path_owned(stem), Message::PlotExportPath, ) } @@ -9906,27 +9909,28 @@ impl OpenCADStudio { .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); + let svg = self.plot_dialog.file_svg; #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PlotWindowExportPath(None)); }; iced::window::run(window_id, move |parent| { - crate::io::pdf_export::pick_pdf_path_owned(stem, parent) + crate::io::pdf_export::pick_plot_path_owned(stem, parent, svg) }) .map(Message::PlotWindowExportPath) } #[cfg(target_os = "windows")] { Task::perform( - crate::io::pdf_export::pick_pdf_path_async(stem), + crate::io::pdf_export::pick_plot_path_async(stem, svg), Message::PlotWindowExportPath, ) } #[cfg(target_arch = "wasm32")] { Task::perform( - crate::io::pdf_export::pick_pdf_path_owned(stem), + crate::io::pdf_export::pick_plot_path_owned(stem), Message::PlotWindowExportPath, ) } diff --git a/src/io/mod.rs b/src/io/mod.rs index 7ba12b2c0..fe5e29556 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -12,6 +12,8 @@ pub mod ole_embed; #[cfg(not(target_arch = "wasm32"))] pub mod single_instance; pub mod pdf_export; +pub mod plot; +pub mod svg_export; pub mod plot_style; pub mod print_to_printer; pub mod recovery; diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 89d11e2cd..3285804cf 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -13,7 +13,6 @@ use crate::scene::model::hatch_model::HatchModel; #[cfg(not(target_arch = "wasm32"))] use crate::scene::model::hatch_model::HatchPattern; use crate::scene::WireModel; -use crate::scene::model::image_model::ImageModel; #[cfg(not(target_arch = "wasm32"))] use crate::scene::model::wire_model::SearchableTextRun; #[cfg(not(target_arch = "wasm32"))] @@ -25,27 +24,13 @@ use printpdf::{ }; use std::path::Path; -#[derive(Clone, Debug)] -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] -pub struct PlotWire { - pub wire: WireModel, - pub draw_depth: f32, -} - -impl std::ops::Deref for PlotWire { - type Target = WireModel; - - fn deref(&self) -> &Self::Target { - &self.wire - } -} - -/// Decoded image geometry plus inherited block/viewport clip boundaries. -#[derive(Clone, Debug)] -pub struct PlotImage { - pub image: ImageModel, - pub clips: Vec>, -} +// Shared plot model: `PlotWire`, `PlotImage`, `PlotContent`, `PlotGroupSplits` +// and friends live in `super::plot`. The `Pdf*` names remain as aliases so +// the scene, the print pipeline and every other call site keep compiling. +pub use super::plot::{ + PlotContent, PlotGroupSplits, PlotImage, PlotOptions as PdfPlotOptions, + PlotPage as PdfPageInput, PlotWire, +}; // The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via // lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web @@ -65,7 +50,7 @@ pub fn export_pdf_pages( } #[cfg(target_arch = "wasm32")] -pub async fn pick_pdf_path_owned(_stem: String) -> Option { +pub async fn pick_plot_path_owned(_stem: String) -> Option { None } @@ -82,63 +67,6 @@ const LW_PX_TO_PT: f32 = MM_TO_PT / (96.0 / 25.4); #[cfg(not(target_arch = "wasm32"))] const SCREEN_DOT_MM: f32 = 25.4 / 96.0; -/// Output controls shared by preview, PDF export, and printer rendering. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PdfPlotOptions { - pub object_lineweights: bool, - pub scale_lineweights: bool, - pub transparency: bool, - pub stamp: bool, - pub merge_lines: bool, -} - -/// End indexes of the first paper/model render group in each flat input list. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct PlotGroupSplits { - pub wires: usize, - pub hatches: usize, - pub wipeouts: usize, - pub images: usize, -} - -#[derive(Default)] -pub struct PlotContent { - pub wires: std::sync::Arc>, - pub hatches: Vec, - pub wipeouts: Vec, - pub images: Vec, - pub group_splits: PlotGroupSplits, -} - -/// Owned geometry and settings for one PDF page. -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] -pub struct PdfPageInput { - pub content: PlotContent, - /// Page dimensions in mm, after any 90/270-degree rotation. - pub paper_w: f64, - pub paper_h: f64, - /// Absolute-world offsets stay f64 to preserve local detail at UTM coordinates. - pub offset_x: f64, - pub offset_y: f64, - pub rotation_deg: i32, - pub scale: f32, - pub clip: Option<(f32, f32, f32, f32)>, - pub options: PdfPlotOptions, - pub plot_style: Option, -} - -impl Default for PdfPlotOptions { - fn default() -> Self { - Self { - object_lineweights: true, - scale_lineweights: false, - transparency: false, - stamp: false, - merge_lines: false, - } - } -} - // ── Public entry point ──────────────────────────────────────────────────── /// Export one page to a PDF file. @@ -381,21 +309,33 @@ fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6] ]) } -/// Show a parented PDF save-file dialog and return the chosen path. +/// Show a parented plot save-file dialog and return the chosen path. /// /// The parent comes from `iced::window::run`, keeping the portal request tied /// to the visible app window on Wayland instead of silently resolving to /// `None` on desktops that reject a parentless save dialog (#537). +/// `svg` selects the SVG device's defaults (name, title, first filter). #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] -pub fn pick_pdf_path_owned( +pub fn pick_plot_path_owned( stem: String, parent: &dyn iced::window::Window, + svg: bool, ) -> Option { - let path = crate::sys::blocking_file_dialog() - .set_parent(parent) - .set_title(crate::t!("Export as PDF").as_ref()) - .set_file_name(&format!("{stem}.pdf")) - .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + let dialog = crate::sys::blocking_file_dialog().set_parent(parent); + let dialog = if svg { + dialog + .set_title(crate::t!("Export as SVG").as_ref()) + .set_file_name(&format!("{stem}.svg")) + .add_filter(crate::t!("SVG Files").as_ref(), &["svg"]) + .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + } else { + dialog + .set_title(crate::t!("Export as PDF").as_ref()) + .set_file_name(&format!("{stem}.pdf")) + .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + .add_filter(crate::t!("SVG Files").as_ref(), &["svg"]) + }; + let path = dialog .add_filter(crate::t!("All Files").as_ref(), &["*"]) .save_file() ?; @@ -403,7 +343,7 @@ pub fn pick_pdf_path_owned( Some(path) } -/// Windows: pick the PDF destination with the async dialog on a worker +/// Windows: pick the plot destination with the async dialog on a worker /// thread. The parented blocking dialog ran `IFileDialog::Show` on the UI /// thread inside the window callback, and when the target name already /// existed the overwrite-confirmation popup is a second nested modal that @@ -411,11 +351,22 @@ pub fn pick_pdf_path_owned( /// backend runs the dialog off-thread; it is the same pattern the DWG /// Save As flow uses, whose confirm popup works. #[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))] -pub async fn pick_pdf_path_async(stem: String) -> Option { - let handle = crate::sys::file_dialog() - .set_title(crate::t!("Export as PDF").as_ref()) - .set_file_name(format!("{stem}.pdf")) - .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) +pub async fn pick_plot_path_async(stem: String, svg: bool) -> Option { + let dialog = crate::sys::file_dialog(); + let dialog = if svg { + dialog + .set_title(crate::t!("Export as SVG").as_ref()) + .set_file_name(format!("{stem}.svg")) + .add_filter(crate::t!("SVG Files").as_ref(), &["svg"]) + .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + } else { + dialog + .set_title(crate::t!("Export as PDF").as_ref()) + .set_file_name(format!("{stem}.pdf")) + .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + .add_filter(crate::t!("SVG Files").as_ref(), &["svg"]) + }; + let handle = dialog .add_filter(crate::t!("All Files").as_ref(), &["*"]) .save_file() .await?; diff --git a/src/io/plot.rs b/src/io/plot.rs new file mode 100644 index 000000000..b4273550a --- /dev/null +++ b/src/io/plot.rs @@ -0,0 +1,91 @@ +// Shared plot model — the paper-space geometry every plot backend consumes. +// +// `PdfPageInput`/`PdfPlotOptions` used to live in `pdf_export`, which made the +// scene, the print pipeline and now the SVG exporter all import from the PDF +// backend. The structs themselves are backend-agnostic (wires, hatches, +// images, page size/offset/clip), so they live here; `pdf_export` re-exports +// the historical `Pdf*` names as aliases so existing call sites keep working. + +use crate::io::plot_style::PlotStyleTable; +use crate::scene::WireModel; +use crate::scene::model::hatch_model::HatchModel; +use crate::scene::model::image_model::ImageModel; + +#[derive(Clone, Debug)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub struct PlotWire { + pub wire: WireModel, + pub draw_depth: f32, +} + +impl std::ops::Deref for PlotWire { + type Target = WireModel; + + fn deref(&self) -> &Self::Target { + &self.wire + } +} + +/// Decoded image geometry plus inherited block/viewport clip boundaries. +#[derive(Clone, Debug)] +pub struct PlotImage { + pub image: ImageModel, + pub clips: Vec>, +} + +/// Output controls shared by preview, plot exports, and printer rendering. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlotOptions { + pub object_lineweights: bool, + pub scale_lineweights: bool, + pub transparency: bool, + pub stamp: bool, + pub merge_lines: bool, +} + +/// End indexes of the first paper/model render group in each flat input list. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PlotGroupSplits { + pub wires: usize, + pub hatches: usize, + pub wipeouts: usize, + pub images: usize, +} + +#[derive(Default)] +pub struct PlotContent { + pub wires: std::sync::Arc>, + pub hatches: Vec, + pub wipeouts: Vec, + pub images: Vec, + pub group_splits: PlotGroupSplits, +} + +/// Owned geometry and settings for one plot page. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub struct PlotPage { + pub content: PlotContent, + /// Page dimensions in mm, after any 90/270-degree rotation. + pub paper_w: f64, + pub paper_h: f64, + /// Absolute-world offsets stay f64 to preserve local detail at UTM coordinates. + pub offset_x: f64, + pub offset_y: f64, + pub rotation_deg: i32, + pub scale: f32, + pub clip: Option<(f32, f32, f32, f32)>, + pub options: PlotOptions, + pub plot_style: Option, +} + +impl Default for PlotOptions { + fn default() -> Self { + Self { + object_lineweights: true, + scale_lineweights: false, + transparency: false, + stamp: false, + merge_lines: false, + } + } +} diff --git a/src/io/svg_export.rs b/src/io/svg_export.rs new file mode 100644 index 000000000..d76ac624c --- /dev/null +++ b/src/io/svg_export.rs @@ -0,0 +1,1007 @@ +// SVG export — vector plot from the shared paper-space `PlotPage` model. +// +// Each page becomes one standalone `.svg` file in millimetre user units +// (`viewBox="0 0 paper_w paper_h"`), Y-flipped because SVG's origin is +// top-left while the plot geometry (like PDF) is bottom-left. Rotation and +// plot scale are baked into the coordinates with the same transform the PDF +// backend puts in its page CTM, so the two outputs agree geometrically. +// +// Scope notes (prototype): +// - Text goes out as searchable `` runs (same `SearchableTextRun` source +// as the PDF search layer). SDF-quad vector outlines are not emitted, and +// there is no `Tz`-style advance width-matching: centered/right-aligned runs +// may highlight slightly off their glyphs in text-heavy viewers. +// - Stationed dash phases (`pattern_stations`, PLINEGEN) are approximated with +// a plain `stroke-dasharray`; non-affine (perspective) image quads are drawn +// with their affine corner fit. +// - Like PDF export this is native-only for file writing; `build_svg` itself +// is pure and works on wasm too. + +use std::path::Path; + +use crate::io::plot::{PlotImage, PlotOptions, PlotPage, PlotWire}; +use crate::io::plot_style::PlotStyleTable; +use crate::scene::model::hatch_model::{HatchModel, HatchPattern}; +use crate::scene::WireModel; + +/// Screen pixels (96 dpi) to millimetres. +const PX_TO_MM: f64 = 25.4 / 96.0; +/// PDF points to millimetres (for CTB lineweights, which resolve in mm already +/// here — kept for the hairline floor only). +const PT_TO_MM: f64 = 25.4 / 72.0; +/// Helvetica cap-height fraction of em, same value the PDF backend uses to +/// size its fallback font from a run's cap height. +const HELVETICA_CAP_RATIO: f64 = 0.72; + +// ── Public entry points ──────────────────────────────────────────────────── + +/// Export one page to an SVG file. +#[cfg(not(target_arch = "wasm32"))] +pub fn export_svg(page: &PlotPage, path: &Path) -> Result<(), String> { + export_svg_pages(std::slice::from_ref(page), path, None) +} + +/// Export several pages. One page writes `path` itself; several write +/// `-1.svg`, `-2.svg`, … beside `path` (SVG has no multi-page +/// container, unlike PDF). +#[cfg(not(target_arch = "wasm32"))] +pub fn export_svg_pages( + pages: &[PlotPage], + path: &Path, + plot_style: Option<&PlotStyleTable>, +) -> Result<(), String> { + if pages.is_empty() { + return Err("No pages were selected.".into()); + } + if pages.len() == 1 { + let svg = build_svg(&pages[0], plot_style)?; + return write_svg_atomically(path, svg.as_bytes()); + } + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing".to_string()); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + for (index, page) in pages.iter().enumerate() { + let svg = build_svg(page, plot_style) + .map_err(|e| format!("Page {}: {e}", index + 1))?; + let numbered = parent.join(format!("{stem}-{}.svg", index + 1)); + write_svg_atomically(&numbered, svg.as_bytes()) + .map_err(|e| format!("Page {}: {e}", index + 1))?; + } + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +pub fn export_svg(_page: &PlotPage, _path: &Path) -> Result<(), String> { + Err("SVG export is not available in the web version.".into()) +} + +#[cfg(target_arch = "wasm32")] +pub fn export_svg_pages( + _pages: &[PlotPage], + _path: &Path, + _plot_style: Option<&PlotStyleTable>, +) -> Result<(), String> { + Err("SVG export is not available in the web version.".into()) +} + +/// Write beside the destination, then rename atomically (same pattern as the +/// PDF and DWG savers: a failed write never truncates the previous file). +#[cfg(not(target_arch = "wasm32"))] +fn write_svg_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { + let temp_path = super::save_temp_path(path); + if let Err(error) = std::fs::write(&temp_path, bytes) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("Failed to write SVG data: {error}")); + } + if let Err(error) = super::replace_save_file(&temp_path, path) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("Failed to replace SVG file: {error}")); + } + Ok(()) +} + +/// Build a standalone SVG document for one plot page. +pub fn build_svg( + page: &PlotPage, + fallback_plot_style: Option<&PlotStyleTable>, +) -> Result { + let paper_w = page.paper_w.max(1.0); + let paper_h = page.paper_h.max(1.0); + let plot_style = page.plot_style.as_ref().or(fallback_plot_style); + let map = PageMap { + paper_w, + paper_h, + scale: page.scale.max(1e-6) as f64, + rotation_deg: page.rotation_deg, + }; + let mut out = String::new(); + out.push_str("\n"); + out.push_str("\nOpen CAD Studio plot\n"); + out.push_str("\n", fmt(paper_w), fmt(paper_h))); + + let mut clip_id: Option = None; + if let Some((cx, cy, cw, ch)) = page.clip { + // Same pre-transform space as the PDF clip path: map the corners and + // take the bounds (rotation keeps axis-alignment for 0/90/180/270). + let corners = [ + map.apply(cx as f64, cy as f64), + map.apply((cx + cw) as f64, cy as f64), + map.apply((cx + cw) as f64, (cy + ch) as f64), + map.apply(cx as f64, (cy + ch) as f64), + ]; + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for (x, y) in corners { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + if x1 > x0 && y1 > y0 && x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() { + out.push_str(&format!( + "\n", + fmt(x0), fmt(y0), fmt(x1 - x0), fmt(y1 - y0) + )); + clip_id = Some("plot-clip".to_string()); + } + } + + let blend = if page.options.merge_lines { + " style=\"mix-blend-mode:multiply\"" + } else { + "" + }; + out.push_str(&format!("\n"); + + emit_page_content(&mut out, page, plot_style, &map)?; + + out.push_str("\n"); + if page.options.stamp { + out.push_str(&stamp_element(paper_w, paper_h)); + } + out.push_str("\n"); + Ok(out) +} + +// ── Page transform ───────────────────────────────────────────────────────── + +/// Bakes the PDF page CTM (plot scale + 0/90/180/270° rotation) plus the SVG +/// Y-flip into one mapping from pre-transform paper millimetres to SVG units. +struct PageMap { + paper_w: f64, + paper_h: f64, + scale: f64, + rotation_deg: i32, +} + +impl PageMap { + fn apply(&self, x: f64, y: f64) -> (f64, f64) { + let s = self.scale; + let (xt, yt) = match self.rotation_deg { + 90 => (self.paper_w - s * y, s * x), + 180 => (self.paper_w - s * x, self.paper_h - s * y), + 270 => (s * y, self.paper_h - s * x), + _ => (s * x, s * y), + }; + (xt, self.paper_h - yt) + } +} + +// ── Draw-ordered emission (mirrors the PDF backend) ──────────────────────── + +enum DrawItem<'a> { + WireFill(&'a PlotWire), + Hatch(&'a HatchModel), + Image(&'a PlotImage), + Wire(&'a PlotWire), + Text(&'a PlotWire), +} + +fn emit_page_content( + out: &mut String, + page: &PlotPage, + plot_style: Option<&PlotStyleTable>, + map: &PageMap, +) -> Result<(), String> { + let content = &page.content; + let (ox, oy) = (page.offset_x, page.offset_y); + let options = page.options; + + let (first_wires, second_wires) = content + .wires + .split_at(content.group_splits.wires.min(content.wires.len())); + let (first_hatches, second_hatches) = content + .hatches + .split_at(content.group_splits.hatches.min(content.hatches.len())); + let (first_wipeouts, second_wipeouts) = content + .wipeouts + .split_at(content.group_splits.wipeouts.min(content.wipeouts.len())); + let (first_images, second_images) = content + .images + .split_at(content.group_splits.images.min(content.images.len())); + + for (wires, hatches, wipeouts, images) in [ + (first_wires, first_hatches, first_wipeouts, first_images), + (second_wires, second_hatches, second_wipeouts, second_images), + ] { + let mut items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len() + images.len()); + let mut sequence = 0usize; + for wire in wires { + if !wire.fill_tris.is_empty() { + items.push((wire.draw_depth, 0u8, sequence, DrawItem::WireFill(wire))); + sequence += 1; + } + items.push((wire.draw_depth, 2u8, sequence, DrawItem::Wire(wire))); + sequence += 1; + if !wire.searchable_text.is_empty() { + items.push((wire.draw_depth, 3u8, sequence, DrawItem::Text(wire))); + sequence += 1; + } + } + for hatch in wipeouts.iter().chain(hatches.iter()) { + items.push((hatch.draw_depth, 1u8, sequence, DrawItem::Hatch(hatch))); + sequence += 1; + } + for image in images { + items.push((image.image.draw_depth, 1u8, sequence, DrawItem::Image(image))); + sequence += 1; + } + items.sort_by(|a, b| { + a.0.total_cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + for (_, _, _, item) in items { + match item { + DrawItem::WireFill(wire) => emit_wire_fills(out, &wire.wire, ox, oy, plot_style, options, map), + DrawItem::Hatch(hatch) => emit_hatch(out, hatch, ox, oy, plot_style, options, map), + DrawItem::Text(wire) => emit_searchable_text(out, &wire.wire, ox, oy, plot_style, options, map), + DrawItem::Wire(wire) => emit_wire(out, &wire.wire, ox, oy, plot_style, options, map), + DrawItem::Image(image) => emit_image(out, image, ox, oy, options, map)?, + } + } + } + Ok(()) +} + +// ── Wires ────────────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +fn emit_wire( + out: &mut String, + wire: &WireModel, + ox: f64, + oy: f64, + plot_style: Option<&PlotStyleTable>, + options: PlotOptions, + map: &PageMap, +) { + let [mut r, mut g, mut b, a] = wire.color; + if a < 0.01 { + return; + } + if matches!(wire.name.as_str(), "__paper_boundary__" | "paper_printable_area") { + return; + } + let mut lw_override_mm: Option = None; + let mut screening = 1.0; + let mut color_overridden = false; + let mut cap = "round"; + let mut join = "round"; + if let Some(table) = plot_style { + if wire.aci > 0 { + if let Some([cr, cg, cb]) = table.resolve_color(wire.aci) { + [r, g, b] = [cr, cg, cb]; + color_overridden = true; + } + lw_override_mm = table.resolve_lineweight(wire.aci).map(|mm| mm as f64); + screening = table.resolve_screening(wire.aci); + if let Some(entry) = table.aci_entries.get(wire.aci as usize) { + cap = match entry.end_style { + 0 => "butt", + 1 | 3 => "square", + _ => "round", + }; + join = match entry.join_style { + 0 => "miter", + 1 | 3 => "bevel", + _ => "round", + }; + } + } + } + if !color_overridden && (wire.aci != 0 || [r, g, b] == [1.0; 3]) { + [r, g, b] = adapt_text_color([r, g, b]); + } + [r, g, b] = plotted_color([r, g, b], a, screening, options); + + let s = map.scale; + let width_mm = if wire.world_width > 0.0 { + wire.world_width as f64 * s + } else { + let physical = if options.object_lineweights { + lw_override_mm.unwrap_or_else(|| wire.line_weight_px as f64 * PX_TO_MM) + } else { + 0.1 * PT_TO_MM + } + .max(0.1 * PT_TO_MM); + if options.scale_lineweights { + physical * s + } else { + physical + } + }; + + let dash = svg_dasharray(wire.pattern_length, &wire.pattern, s); + let dash_attr = dash + .map(|d| format!(" stroke-dasharray=\"{d}\"")) + .unwrap_or_default(); + let opacity_attr = svg_opacity(a, options.transparency); + + // NaN separates sub-paths (pen-up), like the PDF `flush_line` pass. + let view_h = map.paper_h / s; + let mut d = String::new(); + let mut pen_down = false; + let mut any = false; + for (pi, &[x, y, _]) in wire.points.iter().enumerate() { + if x.is_nan() || y.is_nan() { + pen_down = false; + continue; + } + let p = wire.point_world(pi, view_h); + let (sx, sy) = map.apply(p.x + ox, p.y + oy); + if !sx.is_finite() || !sy.is_finite() { + pen_down = false; + continue; + } + d.push_str(&format!("{} {} {} ", if pen_down { 'L' } else { 'M' }, fmt(sx), fmt(sy))); + pen_down = true; + any = true; + } + if !any { + return; + } + out.push_str(&format!( + "\n", + d.trim_end(), + hex_color([r, g, b]), + fmt(width_mm.max(0.01)), + )); +} + +/// Solid triangle fills (SOLID fills, arrowheads, 2-D overlays). +fn emit_wire_fills( + out: &mut String, + wire: &WireModel, + ox: f64, + oy: f64, + plot_style: Option<&PlotStyleTable>, + options: PlotOptions, + map: &PageMap, +) { + if wire.fill_tris.is_empty() { + return; + } + let [mut r, mut g, mut b, a] = wire.color; + if a < 0.01 { + return; + } + if wire.bg_adapt.as_deref().is_some_and(|adapt| adapt.canvas_color) { + [r, g, b] = [1.0, 1.0, 1.0]; + } else { + let mut screening = 1.0; + let mut color_overridden = false; + if let Some(table) = plot_style { + if wire.aci > 0 { + if let Some(color) = table.resolve_color(wire.aci) { + [r, g, b] = color; + color_overridden = true; + } + screening = table.resolve_screening(wire.aci); + } + } + if !color_overridden { + [r, g, b] = adapt_text_color([r, g, b]); + } + [r, g, b] = plotted_color([r, g, b], a, screening, options); + } + let mut d = String::new(); + for (ti, tri) in wire.fill_tris.chunks_exact(3).enumerate() { + let mut pts = Vec::with_capacity(3); + let mut ok = true; + for (pi, &[x, y, _]) in tri.iter().enumerate() { + let low = wire.fill_tris_low.get(ti * 3 + pi).copied().unwrap_or([0.0; 3]); + let (sx, sy) = map.apply(x as f64 + low[0] as f64 + ox, y as f64 + low[1] as f64 + oy); + if !sx.is_finite() || !sy.is_finite() { + ok = false; + break; + } + pts.push((sx, sy)); + } + if !ok { + continue; + } + d.push_str(&format!( + "M {} {} L {} {} L {} {} Z ", + fmt(pts[0].0), fmt(pts[0].1), + fmt(pts[1].0), fmt(pts[1].1), + fmt(pts[2].0), fmt(pts[2].1), + )); + } + if d.is_empty() { + return; + } + out.push_str(&format!( + "\n", + d.trim_end(), + hex_color([r, g, b]), + svg_opacity(a, options.transparency), + )); +} + +// ── Hatches / wipeouts ───────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +fn emit_hatch( + out: &mut String, + hatch: &HatchModel, + ox: f64, + oy: f64, + plot_style: Option<&PlotStyleTable>, + options: PlotOptions, + map: &PageMap, +) { + if hatch.boundary.is_empty() { + return; + } + let is_wipeout = hatch.name == "WIPEOUT_FILL"; + let [mut r, mut g, mut b, a] = hatch.color; + if a < 0.01 { + return; + } + let mut screening = 1.0; + let mut lw_override_mm: Option = None; + let mut color_overridden = false; + if !is_wipeout { + if let Some(table) = plot_style { + if hatch.aci > 0 { + if let Some([cr, cg, cb]) = table.resolve_color(hatch.aci) { + [r, g, b] = [cr, cg, cb]; + color_overridden = true; + } + screening = table.resolve_screening(hatch.aci); + lw_override_mm = table.resolve_lineweight(hatch.aci).map(|mm| mm as f64); + } + } + } + if is_wipeout { + [r, g, b] = [1.0, 1.0, 1.0]; + } else if !color_overridden && hatch.aci != 0 && !(hatch.aci == 7 && matches!(hatch.pattern, HatchPattern::Solid)) { + let is_light = r > 0.80 + && g > 0.80 + && b > 0.80 + && !crate::scene::convert::tess_util::is_authored_white([r, g, b]); + let is_yellow = r > 0.80 && g > 0.70 && b < 0.30; + let is_cyan = r < 0.30 && g > 0.70 && b > 0.70; + if is_light || is_yellow { + [r, g, b] = [0.0, 0.0, 0.0]; + } else if is_cyan { + [r, g, b] = [0.0, 0.15, 0.50]; + } + } + [r, g, b] = plotted_color([r, g, b], a, screening, options); + let (wox, woy) = (hatch.world_origin[0], hatch.world_origin[1]); + + if matches!(hatch.pattern, HatchPattern::Pattern(_)) { + let s = map.scale; + let physical = if options.object_lineweights { + lw_override_mm.unwrap_or_else(|| hatch.line_weight_px as f64 * PX_TO_MM) + } else { + 0.1 * PT_TO_MM + } + .max(0.1 * PT_TO_MM); + let width_mm = if options.scale_lineweights { physical * s } else { physical }; + let segments = hatch.pattern_segments_for_plot(); + if segments.is_empty() { + return; + } + let mut d = String::new(); + for [p, q] in segments { + let (ax, ay) = map.apply(p[0] + ox, p[1] + oy); + let (bx, by) = map.apply(q[0] + ox, q[1] + oy); + if !(ax.is_finite() && ay.is_finite() && bx.is_finite() && by.is_finite()) { + continue; + } + if (ax - bx).abs() < 1e-9 && (ay - by).abs() < 1e-9 { + let rad = fmt((width_mm * 0.5).max(0.02)); + let diam = fmt(width_mm.max(0.04)); + d.push_str(&format!("M {x} {y} m -{rad} 0 a {rad} {rad} 0 1 0 {diam} 0 a {rad} {rad} 0 1 0 -{diam} 0 ", x = fmt(ax), y = fmt(ay))); + } else { + d.push_str(&format!("M {} {} L {} {} ", fmt(ax), fmt(ay), fmt(bx), fmt(by))); + } + } + if d.is_empty() { + return; + } + out.push_str(&format!( + "\n", + d.trim_end(), + hex_color([r, g, b]), + fmt(width_mm.max(0.01)), + svg_opacity(a, options.transparency), + )); + return; + } + + if let HatchPattern::Gradient { color2, .. } = &hatch.pattern { + let second = plotted_color( + adapt_text_color([color2[0], color2[1], color2[2]]), + color2[3], + screening, + options, + ); + [r, g, b] = [(r + second[0]) * 0.5, (g + second[1]) * 0.5, (b + second[2]) * 0.5]; + } + + let mut d = String::new(); + let mut current_empty = true; + let mut any = false; + for &[bx, by] in hatch.boundary.iter() { + if bx.is_nan() || by.is_nan() { + if !current_empty { + d.push_str("Z "); + } + current_empty = true; + continue; + } + let (sx, sy) = map.apply(bx as f64 + wox + ox, by as f64 + woy + oy); + if !sx.is_finite() || !sy.is_finite() { + continue; + } + d.push_str(&format!("{} {} {} ", if current_empty { 'M' } else { 'L' }, fmt(sx), fmt(sy))); + current_empty = false; + any = true; + } + if !current_empty { + d.push_str("Z "); + } + if !any { + return; + } + out.push_str(&format!( + "\n", + d.trim_end(), + hex_color([r, g, b]), + svg_opacity(a, options.transparency), + )); +} + +// ── Searchable text as visible ────────────────────────────────────── + +fn emit_searchable_text( + out: &mut String, + wire: &WireModel, + ox: f64, + oy: f64, + plot_style: Option<&PlotStyleTable>, + options: PlotOptions, + map: &PageMap, +) { + let mut ctb_color: Option<[f32; 3]> = None; + let mut screening = 1.0; + if let Some(table) = plot_style { + if wire.aci > 0 { + ctb_color = table.resolve_color(wire.aci); + screening = table.resolve_screening(wire.aci); + } + } + for run in &wire.searchable_text { + if run.text.is_empty() || run.height <= 0.0 || !run.height.is_finite() { + continue; + } + let rgb = ctb_color + .unwrap_or_else(|| adapt_text_color([run.color[0], run.color[1], run.color[2]])); + let [r, g, b] = plotted_color(rgb, run.color[3], screening, options); + let (sx, sy) = map.apply(run.origin[0] + ox, run.origin[1] + oy); + if !sx.is_finite() || !sy.is_finite() { + continue; + } + // Cap height → em size, then the page scale baked in like the PDF CTM. + let size_mm = (run.height as f64 * map.scale / HELVETICA_CAP_RATIO).max(0.1); + let deg = -(run.rotation as f64).to_degrees(); + let rotate = if deg.abs() > 1e-6 { + format!(" transform=\"rotate({} {} {})\"", fmt(deg), fmt(sx), fmt(sy)) + } else { + String::new() + }; + let weight = if run.bold { " font-weight=\"bold\"" } else { "" }; + out.push_str(&format!( + "{text}\n", + fmt(sx), + fmt(sy), + fmt(size_mm), + hex_color([r, g, b]), + xml_escape(&svg_font_family(&run.font)), + opacity = svg_opacity(run.color[3], options.transparency), + text = xml_escape(&run.text), + )); + } +} + +fn svg_font_family(name: &str) -> String { + let trimmed = name.trim(); + if trimmed.is_empty() { + return "sans-serif".to_string(); + } + // SHX names (`txt`, `romans`) are stroke fonts with no SVG equivalent. + if !trimmed.contains([' ', '-', '_']) && trimmed.chars().all(|c| c.is_ascii_lowercase()) { + return "sans-serif".to_string(); + } + format!("{trimmed}, sans-serif") +} + +// ── Images ───────────────────────────────────────────────────────────────── + +fn emit_image( + out: &mut String, + plot: &PlotImage, + ox: f64, + oy: f64, + options: PlotOptions, + map: &PageMap, +) -> Result<(), String> { + let image = &plot.image; + let expected = (image.width as usize) + .checked_mul(image.height as usize) + .and_then(|n| n.checked_mul(4)); + if image.width == 0 || image.height == 0 || expected != Some(image.pixels.len()) { + return Err("Cannot plot bitmap: dimensions do not match its RGBA pixels.".into()); + } + if image.verts.len() < 3 || image.verts.len() % 3 != 0 { + return Err("Cannot plot bitmap: incomplete triangle geometry.".into()); + } + if options.transparency && image.opacity <= 0.0 { + return Ok(()); + } + let to_svg = |high: [f32; 3], low: [f32; 3]| { + map.apply(high[0] as f64 + low[0] as f64 + ox, high[1] as f64 + low[1] as f64 + oy) + }; + let corners: [[f64; 2]; 4] = + std::array::from_fn(|i| { let (x, y) = to_svg(image.corners[i], image.corners_low[i]); [x, y] }); + if !corners.iter().flatten().all(|v| v.is_finite()) + || !image.verts.iter().all(|v| { + let (x, y) = to_svg(v.pos, v.pos_low); + x.is_finite() && y.is_finite() && v.uv.iter().all(|c| c.is_finite()) + }) + || !plot + .clips + .iter() + .all(|ring| ring.len() >= 3 && ring.iter().flatten().all(|v| v.is_finite())) + || !image.opacity.is_finite() + { + return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into()); + } + + let mut rgba: Vec = image.pixels.as_ref().clone(); + if !image.use_alpha { + rgba.chunks_exact_mut(4).for_each(|px| px[3] = 255); + } + let png = { + let img = image::RgbaImage::from_raw(image.width, image.height, rgba) + .ok_or("Cannot plot bitmap: pixel buffer mismatch.")?; + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png) + .map_err(|e| format!("Cannot plot bitmap: PNG encode failed: {e}"))?; + buf.into_inner() + }; + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png); + + // Pixel space (u right, v down from the top row) onto the quad. Affine fit + // through top-left / top-right / bottom-left; perspective quads keep the + // corner fit (same approximation the PDF backend documents). + let w = image.width as f64; + let h = image.height as f64; + let (p0, p2, p3) = (corners[0], corners[2], corners[3]); + let a = (p2[0] - p3[0]) / w; + let b = (p2[1] - p3[1]) / w; + let c = (p0[0] - p3[0]) / h; + let d = (p0[1] - p3[1]) / h; + if ![a, b, c, d].iter().all(|v| v.is_finite()) { + return Err("Cannot plot bitmap: degenerate placement.".into()); + } + let opacity_attr = if options.transparency && image.opacity < 1.0 { + format!(" opacity=\"{}\"", fmt(image.opacity.clamp(0.0, 1.0) as f64)) + } else { + String::new() + }; + // Inherited clip boundaries become one evenodd clip path per image. + let mut clip_open = String::new(); + let mut clip_close = ""; + if !plot.clips.is_empty() { + static CLIP_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let id = CLIP_SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut rings = String::new(); + for ring in &plot.clips { + let mut d = String::new(); + for (i, p) in ring.iter().enumerate() { + let (x, y) = map.apply(p[0] + ox, p[1] + oy); + d.push_str(&format!("{} {} {} ", if i == 0 { 'M' } else { 'L' }, fmt(x), fmt(y))); + } + d.push_str("Z "); + rings.push_str(&format!("\n", d.trim_end())); + } + out.push_str(&format!("{rings}\n")); + clip_open = format!("\n"); + clip_close = "\n"; + } + out.push_str(&clip_open); + out.push_str(&format!( + "\n", + fmt(a), fmt(b), fmt(c), fmt(d), fmt(p3[0]), fmt(p3[1]), + )); + out.push_str(clip_close); + Ok(()) +} + +// ── Colour / dash / formatting helpers ───────────────────────────────────── + +/// White-sheet adaptation shared with the PDF backend: colours authored for +/// the dark screen remap so they stay readable on white paper. +fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] { + let is_light = r > 0.80 + && g > 0.80 + && b > 0.80 + && !crate::scene::convert::tess_util::is_authored_white([r, g, b]); + let is_yellow = r > 0.80 && g > 0.70 && b < 0.30; + let is_cyan = r < 0.30 && g > 0.70 && b > 0.70; + if is_light || is_yellow { + [0.0, 0.0, 0.0] + } else if is_cyan { + [0.0, 0.15, 0.50] + } else { + [r, g, b] + } +} + +/// Screening/alpha blend towards white, mirroring `plotted_color` in PDF export. +fn plotted_color(rgb: [f32; 3], alpha: f32, screening: f32, options: PlotOptions) -> [f32; 3] { + let amount = screening.clamp(0.0, 1.0) + * if options.transparency { + alpha.clamp(0.0, 1.0) + } else { + 1.0 + }; + [ + 1.0 - (1.0 - rgb[0]) * amount, + 1.0 - (1.0 - rgb[1]) * amount, + 1.0 - (1.0 - rgb[2]) * amount, + ] +} + +fn hex_color([r, g, b]: [f32; 3]) -> String { + let channel = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}", channel(r), channel(g), channel(b)) +} + +/// `stroke-dasharray` in final millimetres from a linetype pattern +/// (positive = dash, negative = gap, 0 = dot). `None` = solid. +fn svg_dasharray(pattern_length: f32, pattern: &[f32; 8], scale: f64) -> Option { + if pattern_length <= 1e-6 { + return None; + } + let count = pattern.iter().rposition(|&v| v != 0.0).map(|i| i + 1)?; + if count == 0 { + return None; + } + let parts: Vec = pattern[..count] + .iter() + .map(|&v| { + let mm = if v == 0.0 { + // Dot: same hairline floor the PDF backend uses (1 pt). + 1.0 * PT_TO_MM * scale + } else { + v.abs() as f64 * scale + }; + fmt(mm.max(0.05)) + }) + .collect(); + Some(parts.join(",")) +} + +fn svg_opacity(alpha: f32, transparency: bool) -> String { + if transparency && alpha < 1.0 && alpha.is_finite() { + format!(" opacity=\"{}\"", fmt(alpha.clamp(0.0, 1.0) as f64)) + } else { + String::new() + } +} + +fn stamp_element(_paper_w: f64, paper_h: f64) -> String { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let user = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "user".into()); + format!( + "Open CAD Studio | {} | {}\n", + fmt(paper_h - 3.0), + xml_escape(&user), + timestamp + ) +} + +/// Compact number formatting: up to 3 decimals, no trailing zeros. +fn fmt(v: f64) -> String { + if !v.is_finite() { + return "0".to_string(); + } + if v.abs() < 0.0005 { + return "0".to_string(); + } + let rounded = (v * 1000.0).round() / 1000.0; + let s = format!("{rounded:.3}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page_with(content: crate::io::plot::PlotContent, w: f64, h: f64) -> PlotPage { + PlotPage { + content, + paper_w: w, + paper_h: h, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PlotOptions::default(), + plot_style: None, + } + } + + #[test] + fn empty_page_builds_a_valid_svg_shell() { + let page = page_with(Default::default(), 210.0, 297.0); + let svg = build_svg(&page, None).expect("builds"); + assert!(svg.starts_with(""), "{svg}"); + } + + #[test] + fn y_flip_puts_bottom_left_geometry_at_the_svg_bottom() { + // A wire point at paper y=0 (sheet bottom) must land at y=paper_h in SVG. + let map = PageMap { paper_w: 210.0, paper_h: 297.0, scale: 1.0, rotation_deg: 0 }; + assert_eq!(map.apply(10.0, 0.0), (10.0, 297.0)); + assert_eq!(map.apply(0.0, 297.0), (0.0, 0.0)); + } + + #[test] + fn rotation_matches_the_pdf_ctm_convention() { + // PDF 90°: x' = paper_w - s*y, y' = s*x, then SVG flips y. + let map = PageMap { paper_w: 297.0, paper_h: 210.0, scale: 1.0, rotation_deg: 90 }; + let (x, y) = map.apply(0.0, 0.0); + assert_eq!((x, y), (297.0, 210.0)); + let (x, y) = map.apply(5.0, 7.0); + assert_eq!((x, y), (297.0 - 7.0, 210.0 - 5.0)); + let map = PageMap { paper_w: 210.0, paper_h: 297.0, scale: 1.0, rotation_deg: 180 }; + let (x, y) = map.apply(0.0, 0.0); + assert_eq!((x, y), (210.0, 0.0)); + } + + #[test] + fn wire_points_become_a_stroked_path() { + let wire = WireModel::solid( + "L1".to_string(), + vec![[10.0, 20.0, 0.0], [30.0, 40.0, 0.0]], + [1.0, 0.0, 0.0, 1.0], + false, + ); + let content = crate::io::plot::PlotContent { + wires: std::sync::Arc::new(vec![PlotWire { + wire, + draw_depth: 0.5, + }]), + ..Default::default() + }; + let svg = build_svg(&page_with(content, 210.0, 297.0), None).expect("builds"); + // y=20 → 277, y=40 → 257 after the flip. + assert!(svg.contains("M 10 277 L 30 257"), "{svg}"); + assert!(svg.contains("stroke=\"#ff0000\""), "{svg}"); + } + + #[test] + fn nan_splits_subpaths_and_helpers_stay_paper_coloured() { + let wire = WireModel::solid( + "L1".to_string(), + vec![[0.0, 0.0, 0.0], [f32::NAN, f32::NAN, 0.0], [5.0, 5.0, 0.0], [6.0, 5.0, 0.0]], + [0.0, 0.0, 1.0, 1.0], + false, + ); + let content = crate::io::plot::PlotContent { + wires: std::sync::Arc::new(vec![PlotWire { + wire, + draw_depth: 0.5, + }]), + ..Default::default() + }; + let svg = build_svg(&page_with(content, 100.0, 100.0), None).expect("builds"); + assert!(svg.contains("M 0 100"), "{svg}"); + assert!(svg.contains("M 5 95 L 6 95"), "{svg}"); + // Screen helpers never plot. + let helper = WireModel::solid( + "__paper_boundary__".to_string(), + vec![[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + [0.0, 0.0, 0.0, 1.0], + false, + ); + let content = crate::io::plot::PlotContent { + wires: std::sync::Arc::new(vec![PlotWire { + wire: helper, + draw_depth: 0.5, + }]), + ..Default::default() + }; + let svg = build_svg(&page_with(content, 100.0, 100.0), None).expect("builds"); + assert!(!svg.contains("\"d\""), "a&b<c>"d""); + assert_eq!(hex_color([1.0, 0.0, 0.0]), "#ff0000"); + assert_eq!(fmt(10.0), "10"); + assert_eq!(fmt(2.5004), "2.5"); + assert_eq!(fmt(0.0001), "0"); + } + + #[test] + fn clip_rect_becomes_a_clip_path() { + let mut page = page_with(Default::default(), 210.0, 297.0); + page.clip = Some((10.0, 20.0, 100.0, 50.0)); + let svg = build_svg(&page, None).expect("builds"); + assert!(svg.contains(""), "{svg}"); + // y-flipped: rect y = 297 - (20 + 50) = 227. + assert!(svg.contains("x=\"10\" y=\"227\" width=\"100\" height=\"50\""), "{svg}"); + } +} diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index f04e41b76..741054548 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -22,6 +22,7 @@ use std::fmt; /// Sentinel entries in the printer dropdown (not real printer names). pub const OUT_DEFAULT: &str = "System default printer"; pub const OUT_PDF: &str = "Save to PDF file…"; +pub const OUT_SVG: &str = "Save to SVG file…"; /// Top-of-list entries: no page setup (defaults + PDF), and the last-used /// settings captured when the dialog opened. @@ -449,8 +450,13 @@ pub struct PlotDialogState { pub page_setup_on_new_layout: bool, /// Chosen printer name, or `None` for the system default. pub printer: Option, - /// Output goes to a PDF file instead of a printer. + /// Output goes to a file instead of a printer. pub to_file: bool, + /// File output is SVG instead of PDF (only meaningful when `to_file`). + /// Session state, like the printer choice: never written into the + /// drawing's page setup, which only knows printers and PDF drivers. + #[serde(default)] + pub file_svg: bool, pub paper: String, #[serde(skip)] pub paper_width_mm: f64, @@ -560,6 +566,7 @@ impl Default for PlotDialogState { page_setup_on_new_layout: false, printer: None, to_file: false, + file_svg: false, paper: paper_catalog::default_paper().canonical.to_string(), paper_width_mm: 297.0, paper_height_mm: 210.0, @@ -616,6 +623,7 @@ impl PlotDialogState { pub fn copy_settings_from(&mut self, o: &PlotDialogState) { self.printer = o.printer.clone(); self.to_file = o.to_file; + self.file_svg = o.file_svg; self.paper = o.paper.clone(); self.paper_width_mm = o.paper_width_mm; self.paper_height_mm = o.paper_height_mm; @@ -1077,7 +1085,11 @@ pub fn view_window( let action = if print_all_options { t!("Apply") } else if s.to_file { - t!("Export PDF") + if s.file_svg { + t!("Export SVG") + } else { + t!("Export PDF") + } } else { t!("Print") }; @@ -1217,8 +1229,9 @@ pub fn view_window( let mut printer_opts = vec![default_entry.clone()]; printer_opts.extend(s.printers.iter().cloned().map(PlotChoice::raw)); printer_opts.push(PlotChoice::localized(OUT_PDF)); + printer_opts.push(PlotChoice::localized(OUT_SVG)); let printer_sel = if s.to_file { - Some(PlotChoice::localized(OUT_PDF)) + Some(PlotChoice::localized(if s.file_svg { OUT_SVG } else { OUT_PDF })) } else { Some(match &s.printer { Some(printer) => PlotChoice::raw(printer.clone()),