From c2f8782cc2a4e330c173bd8ad8131c900b73098a Mon Sep 17 00:00:00 2001 From: aldozar Date: Mon, 12 Oct 2026 01:21:33 -0600 Subject: [PATCH] Plot dialog: device, plot style table, Window <, and a preview of its settings (#673) Squash of PR #673 by @aldozar. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/plotters.rs | 67 ++++++- crates/engine/src/lib.rs | 3 + crates/engine/tests/plotters.rs | 18 ++ crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/exchange.rs | 40 ++++- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/pt_br.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/managers.rs | 282 +++++++++++++++++++++++++++--- 12 files changed, 388 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index 48bedcb..a682728 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -11,6 +11,7 @@ use std::sync::{Mutex, OnceLock}; use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfig}; +use cadcraft_geom::{Bounds2, Vec2}; use serde_json::{Value, json}; use super::*; @@ -233,7 +234,7 @@ fn unknown_device(cmd: &str, name: &str) -> EngineError { /// paper's printable area. A device change also takes the device's default orientation unless /// `landscape` is given. Returns whether the paper was set here (else `paper`, if any, is a /// standard size for the caller to look up). -pub(crate) fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result { +pub fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result { let given = match p.get("device") { Some(v) => Some(v.as_str().ok_or_else(|| bad(cmd, "`device` must be a plotter name"))?.trim()), None => None, @@ -325,10 +326,74 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result { Ok(out) } +// ---------- PLOTWINDOW ---------- + +fn run_plotwindow(s: &mut Session, p: &Value) -> Result { + let (a, b) = (point_req("plotwindow", p, "p1")?, point_req("plotwindow", p, "p2")?); + set_plot_window(s, a, b) +} + +fn set_plot_window(s: &mut Session, a: Vec2, b: Vec2) -> Result { + let w = Bounds2::new(a, b); + if !(w.width() > 0.0 && w.height() > 0.0) { + return Err(bad("plotwindow", "the window needs a width and a height")); + } + s.plot_window = Some(w); + Ok(json!({ "window": [[w.min.x, w.min.y], [w.max.x, w.max.y]] })) +} + +/// PLOTWINDOW: two corners picked in the drawing, with the window's rectangle following the cursor. +#[derive(Default)] +struct PlotWindowM { + first: Option, +} + +impl Interactive for PlotWindowM { + fn name(&self) -> &'static str { + "PLOTWINDOW" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.first { + None => Prompt::new("Specify first corner", Accept::POINT), + Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (i, self.first) { + (Input::Point(p), None) => { + self.first = Some(p); + Ok(Step::Continue) + } + (Input::Point(p), Some(a)) => { + set_plot_window(s, a, p)?; + Ok(Step::Done) + } + (Input::Cancel, _) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + let Some(a) = self.first else { return Vec::new() }; + let corners = [a, Vec2::new(c.x, a.y), c, Vec2::new(a.x, c.y), a]; + corners + .windows(2) + .filter_map(|w| match w { + [p, q] => Some(super::helpers::line(*p, *q)), + _ => None, + }) + .collect() + } +} + // ---------- PLOTTERMANAGER ---------- pub fn specs() -> Vec { vec![ + CommandSpec::new("plotwindow", "Plot Window", run_plotwindow) + .params("{p1: [x,y], p2: [x,y]} (typed: two corners): the window the Plot dialog's \"Window <\" plots, kept in the session") + .enabled(has_doc) + .noundo() + .interactive(|_| Ok(Box::new(PlotWindowM::default()))), CommandSpec::new("plottermanager", "Plotter Manager...", run_plottermanager) .menu(&["File", "Plotter Manager..."]) .params( diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 32751ae..c5bed2e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -584,6 +584,8 @@ pub struct Session { pub last_used: LastUsed, /// Where `clipboard` came from and the layers, styles and blocks its objects need. pub clipboard_source: Option, + /// The window last picked with PLOTWINDOW (the Plot dialog's "Window <"). + pub plot_window: Option, } impl Default for Session { @@ -627,6 +629,7 @@ impl Session { last_dim: None, last_used: LastUsed::default(), clipboard_source: None, + plot_window: None, } } pub fn new_drawing(&mut self, metric: bool) -> usize { diff --git a/crates/engine/tests/plotters.rs b/crates/engine/tests/plotters.rs index 1bc76f3..43268d3 100644 --- a/crates/engine/tests/plotters.rs +++ b/crates/engine/tests/plotters.rs @@ -172,3 +172,21 @@ fn hostile_names_and_values_are_refused() { assert!(s.execute("plottermanager", &p).is_err(), "{p}"); } } + +#[test] +fn plotwindow_keeps_the_picked_window_for_the_plot_dialog() { + let mut s = Session::new(); + // Typed: two corners, with the window's rectangle following the cursor after the first. + s.cmdline("plotwindow").unwrap(); + assert_eq!(s.current_prompt().unwrap().message, "Specify first corner"); + s.cmdline("10,20").unwrap(); + assert_eq!(s.preview(cadcraft_engine::geom::Vec2::new(40.0, 5.0)).len(), 4, "the rubber-band rectangle"); + s.cmdline("40,5").unwrap(); + assert!(s.running.is_none()); + let w = s.plot_window.expect("the picked window"); + assert_eq!((w.min.x, w.min.y, w.max.x, w.max.y), (10.0, 5.0, 40.0, 20.0)); + // JSON, and a window without area is refused. + let r = s.execute("plotwindow", &json!({"p1": [0, 0], "p2": [3, 4]})).unwrap(); + assert_eq!(r["window"], json!([[0.0, 0.0], [3.0, 4.0]])); + assert!(s.execute("plotwindow", &json!({"p1": [0, 0], "p2": [3, 0]})).is_err()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ec76d6..a7d95bf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -29,7 +29,8 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { o if o == "options" || o.starts_with("options:") => crate::options::dialog(app, ctx, o, &mut open), other => crate::managers::dialog(app, ctx, other, &mut open), } - if !open { + // A dialog that closed by opening another (Plot Preview back to Plot) keeps the new one. + if !open && app.ui.dialog.as_deref() == Some(d.as_str()) { app.ui.dialog = None; } } diff --git a/crates/ui-egui/src/exchange.rs b/crates/ui-egui/src/exchange.rs index 97652a9..d78464b 100644 --- a/crates/ui-egui/src/exchange.rs +++ b/crates/ui-egui/src/exchange.rs @@ -59,8 +59,34 @@ struct Preview { shown: Result<(TextureHandle, String), String>, } -/// Render the plot preview of the current layout (or model) as a texture. +/// A preview the Plot dialog asked for: its PLOT parameters (PNG output) and a caption. +#[derive(Clone)] +struct FromPlot { + params: Value, + info: String, +} + +const FROM_PLOT: &str = "cc_plot_preview_from_plot"; + +/// Show the Plot dialog's settings as they plot (PLOT with `params`, a PNG); Print... and Close +/// return to the dialog. +pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value, info: String) { + PREVIEW_GEN.fetch_add(1, Ordering::Relaxed); + ctx.data_mut(|d| d.insert_temp(egui::Id::new(FROM_PLOT), FromPlot { params, info })); + app.ui.dialog = Some("preview".into()); +} + +/// Render the plot preview of the current layout (or model), or of the Plot dialog's settings, +/// as a texture. fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> { + if let Some(f) = ctx.data(|d| d.get_temp::(egui::Id::new(FROM_PLOT))) { + let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?; + let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?; + let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8(); + let size = [img.width() as usize, img.height() as usize]; + let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR); + return Ok((tex, f.info)); + } let r = app.session.execute("preview", &json!({ "width": 1600 })).map_err(|e| e.to_string())?; let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?; let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8(); @@ -111,10 +137,18 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { if close || print { *open = false; } + let from_plot = ctx.data(|d| d.get_temp::(egui::Id::new(FROM_PLOT))).is_some(); if !*open { - ctx.data_mut(|d| d.remove::(id)); + ctx.data_mut(|d| { + d.remove::(id); + d.remove::(egui::Id::new(FROM_PLOT)); + }); } - if print { + if from_plot && !*open { + // Back to the Plot dialog with its settings; Print... plots them. + crate::managers::set_flag(ctx, crate::managers::PLOT_NOW, print); + app.ui.dialog = Some("plot".into()); + } else if print { app.start("plot"); } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d896a9d..36df0c1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -618,6 +618,7 @@ Plot Tisk Plot (click: do not plot) Tisknout (kliknutím: netisknout) Plot Preview Náhled tisku + Plot Window Okno tisku Plot style Tiskový styl Plot style attached to Tiskový styl přiřazený k Plot style table Tabulka tiskových stylů diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a940c4a..f704314 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -524,6 +524,7 @@ Plot Plotten Plot (click: do not plot) Plotten (Klick: nicht plotten) Plot Preview Plotvorschau + Plot Window Plotfenster Plot style Plotstil Plot style attached to Plotstil zugeordnet zu Plot style table Plotstiltabelle diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5d110dd..c3329b0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -659,6 +659,7 @@ Plot Trazar Plot (click: do not plot) Se trazará (clic: no trazar) Plot Preview Vista previa de trazado + Plot Window Ventana de trazado Plot style Estilo de trazado Plot style attached to Estilo de trazado asociado a Plot style table Tabla de estilos de trazado diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 5d92560..18fc7bf 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -524,6 +524,7 @@ Plot Plotar Plot (click: do not plot) Plotar (clique para não plotar) Plot Preview Visualizar plotagem + Plot Window Janela de plotagem Plot style Estilo de plotagem Plot style attached to Estilo de plotagem anexado a Plot style table Tabela de estilos de plotagem diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 318e882..9ad605c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -622,6 +622,7 @@ Plot Друк креслення Plot (click: do not plot) Друкувати (натисніть, щоб вимкнути друк) Plot Preview Попередній перегляд друку + Plot Window Вікно друку Plot style Стиль друку Plot style attached to Стиль друку застосовано до Plot style table Таблиця стилів друку diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f409a7f..a019404 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -432,6 +432,7 @@ impl CadApp { self.collect_screenshots(ctx); self.issue_screenshots(ctx); self.gpu_fault_tick(ctx); + managers::plot_window_resume(self, ctx); self.autosave_tick(ctx); menus::shortcuts(self, ctx); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs index 35242a4..db64a9e 100644 --- a/crates/ui-egui/src/managers.rs +++ b/crates/ui-egui/src/managers.rs @@ -484,6 +484,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action .or_else(|| layouts.first().map(|l| l.1.clone())) .unwrap_or_default(); let mut action = None; + let choices = PageChoices::new(ctx, d); egui::Window::new("Page Setup Manager").open(open).default_size(vec2(560.0, 300.0)).resizable(true).show(ctx, |ui| { if layouts.is_empty() { ui.label("This drawing has no layouts."); @@ -504,7 +505,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action // Layout plotting always prints the whole sheet, so only that area is offered. let rows = PageRows { areas: &["layout"], scale: true, center: true }; egui::Grid::new("pagesetup_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(right, |ui| { - if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows) { + if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices) { action = Some(("pagesetup", json!({ "layout": name, k: v }))); } }); @@ -524,6 +525,43 @@ struct PageRows { center: bool, } +/// The devices and plot style tables the page-setup rows offer. +struct PageChoices { + devices: std::sync::Arc>, + styles: Vec, +} + +impl PageChoices { + fn new(ctx: &egui::Context, d: &cadcraft_doc::Drawing) -> Self { + PageChoices { devices: device_choices(ctx), styles: cadcraft_doc::plot_style_table_names(d) } + } +} + +/// The plotter devices (built-in, then the user's files), read again at most every 2 s: the +/// dialogs ask every frame and user devices are files. +fn device_choices(ctx: &egui::Context) -> std::sync::Arc> { + type Cached = (f64, std::sync::Arc>); + let now = crate::now_ms(); + if let Some((at, list)) = temp::(ctx, "plot_devices") + && now - at < 2000.0 + { + return list; + } + let list = std::sync::Arc::new(cadcraft_engine::cmd::plotters::devices()); + set_temp::(ctx, "plot_devices", (now, list.clone())); + list +} + +/// A page setup's device, or `"None"` (plot to PDF on the page's paper). +fn device_label(page: &cadcraft_doc::PageSetup) -> &str { + if page.device.trim().is_empty() { "None" } else { page.device.as_str() } +} + +/// A page setup's plot style table, or `"None"`. +fn style_label(page: &cadcraft_doc::PageSetup) -> &str { + if cadcraft_doc::no_plot_style_table(&page.plot_style_table) { "None" } else { page.plot_style_table.as_str() } +} + fn area_label(a: &str) -> &str { match a { "layout" => "Layout", @@ -544,17 +582,51 @@ fn parse_ratio(t: &str) -> Option { (v.is_finite() && v > 0.0).then_some(v) } -/// The page-setup rows (paper, orientation, plot area, fit, scale, center, lineweights) shared by -/// the Page Setup Manager and the Plot dialog, inside a two-column grid. Returns the setting the -/// user changed as a `pagesetup` parameter (`paper`, `landscape`, `plotArea`, `scaleToFit`, -/// `scale`, `center` or `lineweights`). -fn page_rows(ui: &mut egui::Ui, salt: egui::Id, page: &cadcraft_doc::PageSetup, rows: &PageRows) -> Option<(&'static str, Value)> { +/// The page-setup rows (device, paper, orientation, plot area, fit, scale, center, lineweights, +/// plot style table) shared by the Page Setup Manager and the Plot dialog, inside a two-column +/// grid. Returns the setting the user changed as a `pagesetup` parameter (`device`, `paper`, +/// `landscape`, `plotArea`, `scaleToFit`, `scale`, `center`, `lineweights` or `plotStyleTable`). +fn page_rows( + ui: &mut egui::Ui, + salt: egui::Id, + page: &cadcraft_doc::PageSetup, + rows: &PageRows, + choices: &PageChoices, +) -> Option<(&'static str, Value)> { let mut change = None; + ui.label("Printer/plotter"); + egui::ComboBox::from_id_salt(salt.with("device")).selected_text(device_label(page)).width(190.0).show_ui(ui, |ui| { + if ui.selectable_label(device_label(page) == "None", "None").on_hover_text("PDF on the paper chosen below").clicked() + && device_label(page) != "None" + { + change = Some(("device", json!("None"))); + } + for dev in choices.devices.iter() { + let on = dev.name.eq_ignore_ascii_case(&page.device); + if ui.selectable_label(on, &dev.name).on_hover_text(format!("{} ({})", dev.description, dev.output.as_str().to_uppercase())).clicked() + && !on + { + change = Some(("device", json!(dev.name))); + } + } + }); + ui.end_row(); + // The device's papers, else the standard sizes. + let device = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&page.device)); ui.label("Paper size"); - egui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| { - for p in cadcraft_render::PAPER_SIZES { - if ui.selectable_label(p.name == page.paper, p.name).clicked() && p.name != page.paper { - change = Some(("paper", json!(p.name))); + egui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| match device { + Some(dev) => { + for p in &dev.papers { + if ui.selectable_label(p.name == page.paper, &p.name).clicked() && p.name != page.paper { + change = Some(("paper", json!(p.name))); + } + } + } + None => { + for p in cadcraft_render::PAPER_SIZES { + if ui.selectable_label(p.name == page.paper, p.name).clicked() && p.name != page.paper { + change = Some(("paper", json!(p.name))); + } } } }); @@ -611,19 +683,42 @@ fn page_rows(ui: &mut egui::Ui, salt: egui::Id, page: &cadcraft_doc::PageSetup, change = Some(("lineweights", json!(lw))); } ui.end_row(); + ui.label("Plot style table"); + egui::ComboBox::from_id_salt(salt.with("styles")).selected_text(style_label(page)).width(190.0).show_ui(ui, |ui| { + if ui.selectable_label(style_label(page) == "None", "None").clicked() && style_label(page) != "None" { + change = Some(("plotStyleTable", json!("None"))); + } + for name in &choices.styles { + let on = name.eq_ignore_ascii_case(&page.plot_style_table); + if ui.selectable_label(on, name).clicked() && !on { + change = Some(("plotStyleTable", json!(name))); + } + } + }); + ui.end_row(); change } /// Apply a [`page_rows`] change to a page setup held by a dialog. +/// Device and paper changes follow PAGESETUP's rules (`plotters::page_device`): a device brings +/// its papers, orientation and printable areas. fn apply_page_change(page: &mut cadcraft_doc::PageSetup, key: &str, v: &Value) { + use cadcraft_engine::cmd::plotters::page_device; match key { + "device" => { + let _ = page_device("plot", &json!({ "device": v }), page); + } "paper" => { - if let Some(ps) = v.as_str().and_then(cadcraft_render::paper_size) { + // A device's paper, else a standard size (no device). + if !page_device("plot", &json!({ "paper": v }), page).unwrap_or(false) + && let Some(ps) = v.as_str().and_then(cadcraft_render::paper_size) + { page.paper = ps.name.into(); page.width_mm = ps.width_mm; page.height_mm = ps.height_mm; } } + "plotStyleTable" => page.plot_style_table = v.as_str().unwrap_or_default().chars().take(260).collect(), "plotArea" => page.plot_area = v.as_str().unwrap_or("extents").into(), "scale" => page.scale = v.as_f64().filter(|s| s.is_finite() && *s > 0.0).unwrap_or(page.scale), _ => { @@ -644,6 +739,31 @@ fn apply_page_change(page: &mut cadcraft_doc::PageSetup, key: &str, v: &Value) { /// Bumped each time PLOT opens its dialog, so it starts again from the page setup. static PLOT_GEN: AtomicU32 = AtomicU32::new(0); +/// Set while PLOTWINDOW picks the Plot dialog's window ("Window <"). +const PICKING_WINDOW: &str = "plot_picking_window"; +/// Set by Print... in a preview opened from the Plot dialog: plot as soon as the dialog is back. +pub(crate) const PLOT_NOW: &str = "plot_now"; + +/// Every frame: once PLOTWINDOW (started by the Plot dialog's "Window <") ends, bring the dialog +/// back with its settings, plotting the picked window (unchanged if the pick was cancelled). +pub fn plot_window_resume(app: &mut CadApp, ctx: &egui::Context) { + if temp::(ctx, PICKING_WINDOW) != Some(true) || app.session.running.is_some() { + return; + } + set_temp(ctx, PICKING_WINDOW, false); + if let (Some(w), Some(mut st)) = (app.session.plot_window.take(), temp::(ctx, "plot_state")) { + st.window = [w.min.x, w.min.y, w.max.x, w.max.y]; + st.page.plot_area = "window".into(); + set_temp(ctx, "plot_state", st); + } + app.ui.dialog = Some("plot".into()); +} + +/// Set a flag the Plot dialog reads (see [`PLOT_NOW`]). +pub(crate) fn set_flag(ctx: &egui::Context, id: &str, on: bool) { + set_temp(ctx, id, on); +} + /// Plot areas for model space (a layout always plots its sheet). const MODEL_AREAS: &[&str] = &["extents", "display", "limits", "window"]; @@ -677,8 +797,9 @@ fn plot_page(d: &cadcraft_doc::Drawing, target: &str) -> cadcraft_doc::PageSetup page } -/// The `plot` parameters for the dialog's settings (without the output path). -fn plot_params(st: &PlotState) -> Value { +/// The `plot` parameters for the dialog's settings (without the output path). `preview_dpi` +/// asks for a PNG at that resolution instead of the device's output, on the same sheet. +fn plot_params(st: &PlotState, preview_dpi: Option) -> Value { let page = &st.page; let mut p = json!({ "layout": st.target, @@ -687,11 +808,33 @@ fn plot_params(st: &PlotState) -> Value { "lineweights": page.lineweights, }); if let Some(o) = p.as_object_mut() { - if cadcraft_render::paper_size(&page.paper).is_some() { - o.insert("paper".into(), json!(page.paper)); - } else { - o.insert("width".into(), json!(page.width_mm)); - o.insert("height".into(), json!(page.height_mm)); + let device = device_label(page) != "None"; + match preview_dpi { + // The page's own sheet, so a device's paper and printable area show as they plot. + Some(dpi) => { + o.insert("device".into(), json!("None")); + o.insert("output".into(), json!("png")); + o.insert("resolution".into(), json!(dpi)); + o.insert("width".into(), json!(page.width_mm)); + o.insert("height".into(), json!(page.height_mm)); + o.insert("margins".into(), json!(page.margins_mm)); + } + None if device => { + o.insert("device".into(), json!(page.device)); + o.insert("paper".into(), json!(page.paper)); + } + None if cadcraft_render::paper_size(&page.paper).is_some() => { + o.insert("device".into(), json!("None")); + o.insert("paper".into(), json!(page.paper)); + } + None => { + o.insert("device".into(), json!("None")); + o.insert("width".into(), json!(page.width_mm)); + o.insert("height".into(), json!(page.height_mm)); + } + } + if style_label(page) != "None" { + o.insert("plotStyleTable".into(), json!(page.plot_style_table)); } if st.target == "Model" { o.insert("plotArea".into(), json!(page.plot_area)); @@ -732,12 +875,13 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { PlotState { generation, page: plot_page(d, &target), target, window: [ext.min.x, ext.min.y, ext.max.x, ext.max.y] } }); let model = st.target == "Model"; - let (mut ok, mut cancel, mut action) = (false, false, None); + let choices = PageChoices::new(ctx, d); + // Print... in a preview opened from here plots with these settings. + let mut ok = temp::(ctx, PLOT_NOW) == Some(true); + set_temp(ctx, PLOT_NOW, false); + let (mut cancel, mut action, mut pick, mut preview) = (false, None, false, false); egui::Window::new("Plot").open(open).collapsible(false).resizable(false).show(ctx, |ui| { egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label("Printer/plotter"); - ui.label("PDF (vector)").on_hover_text("CADCraft plots to vector PDF files"); - ui.end_row(); ui.label("Layout"); egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| { for n in &targets { @@ -757,7 +901,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { } else { PageRows { areas: &["layout"], scale: false, center: false } }; - if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows) { + if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices) { apply_page_change(&mut st.page, k, &v); } if model && st.page.plot_area == "window" { @@ -776,6 +920,9 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { }); ui.end_row(); } + ui.label(""); + pick = ui.button("Window <").on_hover_text("Pick the window's corners in the drawing").clicked(); + ui.end_row(); } }); sheet_sketch(ui, &st.page, &t); @@ -789,22 +936,42 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { json!({ "layout": st.target, "paper": page.paper, "landscape": page.landscape, "scaleToFit": page.scale_to_fit, "lineweights": page.lineweights }), )); } + preview = ui.button("Preview").on_hover_text("The sheet as these settings plot it").clicked(); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { cancel = ui.button("Cancel").clicked(); - ok = ui.button("OK").clicked(); + ok |= ui.button("OK").clicked(); }); }); }); + // The output file type follows the device. + let ext = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&st.page.device)).map_or("pdf", |dv| dv.output.as_str()); set_temp(ctx, "plot_state", st.clone()); if cancel { *open = false; } + if pick { + // The dialog comes back with these settings and the picked window (`plot_window_resume`). + set_temp(ctx, PICKING_WINDOW, true); + *open = false; + app.start("plotwindow"); + return action; + } + if preview { + // A sheet about 1600 pixels on its long side. + let long_in = st.page.width_mm.max(st.page.height_mm).max(1.0) / 25.4; + let mut info = format!("{} · {}", st.target, st.page.paper); + if st.page.landscape { + info.push_str(" · landscape"); + } + crate::exchange::preview_plot(app, ctx, plot_params(&st, Some((1600.0 / long_in).clamp(50.0, 300.0))), info); + return action; + } if ok { - let mut params = plot_params(&st); + let mut params = plot_params(&st, None); if let Some(pick) = app.services.pick_save.as_ref() { let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default(); let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()); - let suggested = format!("{}-{}.pdf", stem.unwrap_or_else(|| "Drawing".into()), st.target); + let suggested = format!("{}-{}.{ext}", stem.unwrap_or_else(|| "Drawing".into()), st.target); // Cancelling the file dialog keeps the Plot dialog open with its settings. let Some(path) = pick(&suggested) else { return action }; if let Some(o) = params.as_object_mut() { @@ -910,3 +1077,66 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) - }); action } + +#[cfg(test)] +mod plot_dialog_tests { + use super::*; + use cadcraft_engine::Session; + + fn state(target: &str) -> PlotState { + let d = cadcraft_doc::Drawing::new_metric(); + PlotState { generation: 0, page: plot_page(&d, target), target: target.into(), window: [0.0, 0.0, 10.0, 10.0] } + } + + #[test] + fn a_device_brings_its_papers_and_the_plot_names_them() { + let mut st = state("Model"); + apply_page_change(&mut st.page, "device", &json!("CADCraft PNG")); + assert_eq!(st.page.device, "CADCraft PNG"); + let dev = cadcraft_engine::cmd::plotters::find_device("CADCraft PNG").unwrap(); + assert!(dev.papers.iter().any(|p| p.name == st.page.paper), "a paper of the device: {}", st.page.paper); + assert!(st.page.margins_mm.iter().any(|m| *m > 0.0), "the paper's printable area"); + apply_page_change(&mut st.page, "plotStyleTable", &json!("monochrome.ctb")); + let p = plot_params(&st, None); + assert_eq!((p["device"].as_str(), p["paper"].as_str()), (Some("CADCraft PNG"), Some(st.page.paper.as_str()))); + assert_eq!(p["plotStyleTable"], json!("monochrome.ctb")); + // The preview plots the same sheet to PNG, whatever the device. + let q = plot_params(&st, Some(150.0)); + assert_eq!((q["output"].as_str(), q["device"].as_str()), (Some("png"), Some("None"))); + assert_eq!(q["width"].as_f64(), Some(st.page.width_mm)); + assert_eq!(q["margins"], json!(st.page.margins_mm)); + // Back to no device: a standard paper, no device sent. + apply_page_change(&mut st.page, "device", &json!("None")); + apply_page_change(&mut st.page, "paper", &json!("A3")); + let p = plot_params(&st, None); + let a3 = cadcraft_render::paper_size("A3").unwrap().name; + assert_eq!((p["device"].as_str(), p["paper"].as_str()), (Some("None"), Some(a3))); + apply_page_change(&mut st.page, "plotStyleTable", &json!("None")); + assert!(plot_params(&st, None).get("plotStyleTable").is_none()); + } + + #[test] + fn window_pick_brings_the_dialog_back_with_the_picked_window() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), crate::Services::default()); + set_temp(&ctx, "plot_state", state("Model")); + set_temp(&ctx, PICKING_WINDOW, true); + app.start("plotwindow"); + plot_window_resume(&mut app, &ctx); + assert_eq!(app.ui.dialog, None, "still picking"); + app.session.cmdline("2,3").unwrap(); + app.session.cmdline("12,9").unwrap(); + plot_window_resume(&mut app, &ctx); + assert_eq!(app.ui.dialog.as_deref(), Some("plot")); + let st = temp::(&ctx, "plot_state").unwrap(); + assert_eq!((st.page.plot_area.as_str(), st.window), ("window", [2.0, 3.0, 12.0, 9.0])); + // A cancelled pick brings the dialog back unchanged. + app.ui.dialog = None; + set_temp(&ctx, PICKING_WINDOW, true); + app.start("plotwindow"); + app.session.input(cadcraft_engine::Input::Cancel).ok(); + plot_window_resume(&mut app, &ctx); + assert_eq!(app.ui.dialog.as_deref(), Some("plot")); + assert_eq!(temp::(&ctx, "plot_state").unwrap().window, [2.0, 3.0, 12.0, 9.0]); + } +}