From bcd6322ee90b528d75401ed50d021efb55d02bc2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:34:04 +0900 Subject: [PATCH] Plotting: honor model-space center and X/Y offsets (#751) * plot: add bounded model-space paper placement * plot: expose center and paper offset parameters * ui: add model-space Plot offset controls --- contributors/people.toml | 5 ++ crates/engine/src/cmd/layout.rs | 34 ++++++++++- crates/io/src/pdf.rs | 76 ++++++++++++++++++++++-- crates/io/src/plotter.rs | 85 +++++++++++++++++++++++++-- crates/render/src/paper.rs | 3 + crates/ui-egui/src/managers.rs | 101 ++++++++++++++++++++++++++++++-- docs/plotters.md | 5 ++ 7 files changed, 293 insertions(+), 16 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index aefa0ff..86860a0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -48,3 +48,8 @@ sync_github_name = true [people.Losminek] sync_github_name = true + +[people.cyfung1031] +real_name = "Chester Fung" +display_name = "Chester Fung" +sync_github_name = true diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index c4acffa..d2f3da6 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -80,11 +80,11 @@ pub fn specs() -> Vec { .menu(&["File", "Print..."]) .key("Cmd+P") .alias(&["print"]) - .params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", lineweights?: bool, plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)") + .params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)") .noundo(), CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf")) .menu(&["File", "Export to PDF..."]) - .params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, lineweights?, plotArea?, window?} (as PLOT)") + .params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotArea?, window?} (as PLOT)") .noundo(), CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace) .alias(&["ms"]) @@ -774,6 +774,28 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result { None => s.space(), }; let mut opts = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(raw) = p.get("offset") { + let values = raw + .as_array() + .filter(|values| values.len() == 2) + .ok_or_else(|| bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000"))?; + let x = values.first().and_then(Value::as_f64); + let y = values.get(1).and_then(Value::as_f64); + if !x.zip(y).is_some_and(|(x, y)| { + x.is_finite() + && y.is_finite() + && x.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM + && y.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM + }) { + return Err(bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000")); + } + } + if matches!(space, Space::Model) + && opts.get("output").and_then(Value::as_str).is_some_and(|output| output.eq_ignore_ascii_case("svg")) + && opts.get("center").and_then(Value::as_bool) == Some(false) + { + return Err(bad(cmd, "model-space plot offsets are not supported for SVG; choose PDF/PNG or center the plot")); + } if let Some(o) = opts.as_object_mut() { if let Some(v) = p.get("scale") { let k = parse_scale(v).ok_or_else(|| bad(cmd, "`scale` must be positive: paper units per drawing unit, or a ratio such as \"1:50\""))?; @@ -1055,12 +1077,20 @@ mod tests { assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4")); let r = s.execute("exportpdf", &json!({})).unwrap(); assert_eq!(r["layout"], "Model"); + let baseline = String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + assert!(!baseline.contains("\"center\"") && !baseline.contains("\"offset\""), "omitted options remain omitted: {baseline}"); assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err()); // Plot area and a ratio scale reach the plotter as a model-space window and a number. let opts = |r: Value| String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); let p = json!({"layout": "Model", "plotArea": "window", "window": [[10, 0], [0, 5]], "fit": false, "scale": "1:2"}); let text = opts(s.execute("plot", &p).unwrap()); assert!(text.contains(r#""window":[[0.0,0.0],[10.0,5.0]]"#) && text.contains(r#""scale":0.5"#), "{text}"); + let text = opts(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": [12.5, -7.0]})).unwrap()); + assert!(text.contains(r#""center":false"#) && text.contains(r#""offset":[12.5,-7.0]"#), "{text}"); + for offset in [json!([1]), json!([1, 2, 3]), json!([f64::NAN, 0]), json!([100_001, 0])] { + assert!(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": offset})).is_err()); + } + assert!(s.execute("plot", &json!({"layout": "Model", "output": "svg", "center": false, "offset": [0, 0]})).is_err()); assert!(opts(s.execute("plot", &json!({"layout": "Model", "plotArea": "limits"})).unwrap()).contains(r#""window":[[0.0,0.0],"#)); assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners"); assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet"); diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 2173d95..87136ed 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -50,6 +50,10 @@ pub struct PdfOptions { pub fit: Option, /// Plot scale in paper units per drawing unit (when not fitting). pub scale: Option, + /// Model-space plot placement. `None` keeps the legacy centered behavior. + pub center: Option, + /// Model-space X/Y plot offset in millimetres from the printable area's lower-left corner. + pub offset: Option<[f64; 2]>, /// Plot object lineweights. Default: the layout's setting; on for model space. pub lineweights: Option, /// Flate-compress the content stream. @@ -87,6 +91,8 @@ impl Default for PdfOptions { landscape: None, fit: None, scale: None, + center: None, + offset: None, lineweights: None, compress: false, title: String::new(), @@ -102,8 +108,9 @@ impl Default for PdfOptions { } impl PdfOptions { - /// Parse `{paper?, width?, height?, landscape?, fit?, scale?, lineweights?, compress?, title?, - /// window?: [[x1, y1], [x2, y2]], margins?: [l, b, r, t] (mm), hyperlinks?: bool}`. + /// Parse `{paper?, width?, height?, landscape?, fit?, scale?, center?, offset?: [x,y] mm, + /// lineweights?, compress?, title?, window?: [[x1, y1], [x2, y2]], margins?: [l, b, r, t] (mm), + /// hyperlinks?: bool}`. pub fn from_json(v: &Value) -> PdfOptions { let num = |k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()); let paper_mm = match (num("width"), num("height")) { @@ -123,6 +130,8 @@ impl PdfOptions { landscape: v.get("landscape").and_then(Value::as_bool), fit: v.get("fit").and_then(Value::as_bool), scale: num("scale").filter(|s| *s > 0.0), + center: v.get("center").and_then(Value::as_bool), + offset: offset(v.get("offset")), lineweights: v.get("lineweights").and_then(Value::as_bool), compress: v.get("compress").and_then(Value::as_bool).unwrap_or(true), title: v.get("title").and_then(Value::as_str).unwrap_or("").to_string(), @@ -133,6 +142,35 @@ impl PdfOptions { } } +fn offset(v: Option<&Value>) -> Option<[f64; 2]> { + let a = v?.as_array()?; + if a.len() != 2 { + return None; + } + let x = a.first()?.as_f64()?; + let y = a.get(1)?.as_f64()?; + valid_plot_offset([x, y]).then_some([x, y]) +} + +fn valid_plot_offset(offset: [f64; 2]) -> bool { + offset.iter().all(|v| v.is_finite() && v.abs() <= paper::MAX_PLOT_OFFSET_MM) +} + +/// Reject malformed offsets at the JSON plotting boundary, before a permissive option parser +/// could treat them as absent. +pub(crate) fn validate_plot_offset_json(v: &Value) -> Result<()> { + if let Some(raw) = v.get("offset") + && offset(Some(raw)).is_none() + { + return Err(IoError::Format(format!( + "plot offset must contain exactly two finite values from -{} to {} mm", + paper::MAX_PLOT_OFFSET_MM, + paper::MAX_PLOT_OFFSET_MM + ))); + } + Ok(()) +} + /// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`. pub(crate) fn margins(v: Option<&Value>) -> Option<[f64; 4]> { let a: Vec = v?.as_array()?.iter().take(5).filter_map(Value::as_f64).collect(); @@ -156,6 +194,7 @@ fn window(v: Option<&Value>) -> Option { /// Plot with JSON options (the engine's `plot` hook). pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result> { + validate_plot_offset_json(opts)?; // `layouts: [name | "Model"] | "all"` plots those sheets into one file, one page each, // instead of `space`; "all" = Model if it has objects, then the layouts in tab order. let o = PdfOptions::from_json(opts); @@ -274,6 +313,11 @@ fn sheet_name(space: &Space) -> String { /// Write a vector PDF with one page per space (at most [`MAX_PAGES`]), each labelled with its /// layout name. The options apply to every sheet; layouts use their own page setups. pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result> { + if let Some(offset) = o.offset + && !valid_plot_offset(offset) + { + return Err(IoError::Format("plot offset must contain two finite values within ±100000 mm".into())); + } if spaces.is_empty() { return Err(IoError::Format("there are no sheets to plot".into())); } @@ -348,8 +392,8 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String let b = window.unwrap_or(list.bounds); let s = plot_scale(&b, &sheet, fit, o.scale); let map = if fit || matches!(space, Space::Model) { - let from = if b.is_empty() { Vec2::ZERO } else { b.center() }; - Map { from, s, to: sheet.printable.center(), k } + let (from, to) = placement(space, &b, fit, &sheet, o); + Map { from, s, to, k } } else { Map { from: Vec2::ZERO, s, to: Vec2::ZERO, k } }; @@ -376,6 +420,30 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String Ok(SheetPage { media, list, map, clip_pt, layers, links, label: sheet_name(space) }) } +/// Source and destination origins for the shared model-space PDF/PNG placement behavior. +pub(crate) fn placement(space: &Space, bounds: &Bounds2, fit: bool, sheet: &Sheet, o: &PdfOptions) -> (Vec2, Vec2) { + if matches!(space, Space::Model) { + let from = if bounds.is_empty() { + Vec2::ZERO + } else if o.center.unwrap_or(true) { + bounds.center() + } else { + bounds.min + }; + let to = if o.center.unwrap_or(true) { + sheet.printable.center() + } else { + let offset = o.offset.unwrap_or([0.0, 0.0]); + sheet.printable.min + Vec2::new(offset[0] / sheet.unit_mm, offset[1] / sheet.unit_mm) + }; + (from, to) + } else if fit { + (if bounds.is_empty() { Vec2::ZERO } else { bounds.center() }, sheet.printable.center()) + } else { + (Vec2::ZERO, Vec2::ZERO) + } +} + /// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the /// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all. fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> { diff --git a/crates/io/src/plotter.rs b/crates/io/src/plotter.rs index e65710c..03c26ac 100644 --- a/crates/io/src/plotter.rs +++ b/crates/io/src/plotter.rs @@ -22,6 +22,13 @@ pub const MAX_PNG_PIXELS: f64 = 40.0e6; /// `svg`. `margins: [l, b, r, t]` (mm) bound the printable area of this plot (model space or a /// layout). pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result> { + pdf::validate_plot_offset_json(opts)?; + if matches!(space, Space::Model) + && opts.get("output").and_then(Value::as_str).is_some_and(|output| output.eq_ignore_ascii_case("svg")) + && opts.get("center").and_then(Value::as_bool) == Some(false) + { + return Err(IoError::Format("model-space plot offsets are not supported for SVG; choose PDF/PNG or center the plot".into())); + } let margins = pdf::margins(opts.get("margins")); let with_margins; let d = match (space, margins) { @@ -97,9 +104,9 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { // are already drawn). cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm); let view = if fit { - let from = if b.is_empty() { Vec2::ZERO } else { b.center() }; - // The world point at the image centre, with `from` on the printable area's centre. - let center = from + (half - sheet.printable.center()) / s; + let (from, to) = pdf::placement(space, &b, true, &sheet, &o); + // The world point at the image centre, mapped through the same paper origin as PDF. + let center = from + (half - to) / s; raster::View { center, scale: k * s, width, height } } else { raster::View { center: half, scale: k, width, height } @@ -136,7 +143,7 @@ fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result> { mod tests { use super::*; use cadcraft_doc::{Common, EntityKind, Line}; - use cadcraft_geom::Vec3; + use cadcraft_geom::{Bounds2, Vec2, Vec3}; use serde_json::json; /// Width and height from a PNG's header. @@ -168,4 +175,74 @@ mod tests { assert!(plot(&d, &Space::Model, &json!({"output": "dwf"})).is_err()); assert!(plot(&d, &Space::Model, &json!({})).unwrap().starts_with(b"%PDF")); } + + #[test] + fn model_plot_placement_preserves_centering_and_applies_mm_offsets() { + let model = Space::Model; + let bounds = Bounds2::new(Vec2::new(10.0, 20.0), Vec2::new(110.0, 70.0)); + let metric = drawing(); + let metric_sheet = Sheet::from_page(&pdf::page_for(&metric, &model, &PdfOptions::default()).unwrap(), 1.0); + let centered = PdfOptions::from_json(&json!({"center": true})); + assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, &PdfOptions::default()), (bounds.center(), metric_sheet.printable.center())); + assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, ¢ered), (bounds.center(), metric_sheet.printable.center())); + let moved = PdfOptions::from_json(&json!({"center": false, "offset": [10.0, 20.0]})); + assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, &moved), (bounds.min, metric_sheet.printable.min + Vec2::new(10.0, 20.0))); + let origin = PdfOptions::from_json(&json!({"center": false})); + assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, &origin), (bounds.min, metric_sheet.printable.min)); + let negative = PdfOptions::from_json(&json!({"center": false, "offset": [-10.0, -20.0]})); + assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, &negative), (bounds.min, metric_sheet.printable.min - Vec2::new(10.0, 20.0))); + + let imperial = Drawing::new_imperial(); + let imperial_sheet = Sheet::from_page(&pdf::page_for(&imperial, &model, &PdfOptions::default()).unwrap(), 25.4); + assert_eq!( + pdf::placement(&model, &bounds, true, &imperial_sheet, &moved), + (bounds.min, imperial_sheet.printable.min + Vec2::new(10.0 / 25.4, 20.0 / 25.4)) + ); + assert_eq!( + pdf::placement(&model, &bounds, true, &imperial_sheet, &negative), + (bounds.min, imperial_sheet.printable.min - Vec2::new(10.0 / 25.4, 20.0 / 25.4)) + ); + } + + #[test] + fn plot_offsets_keep_default_bytes_and_match_across_pdf_and_png() { + let d = drawing(); + let model = Space::Model; + let pdf_default = plot(&d, &model, &json!({})).unwrap(); + let pdf_centered = plot(&d, &model, &json!({"center": true})).unwrap(); + assert_eq!(pdf_default, pdf_centered, "explicit center preserves legacy PDF bytes"); + + let png_options = json!({"output": "png", "resolution": 100, "width": 210, "height": 297, "landscape": true, "margins": [5, 5, 5, 5]}); + let png_default = plot(&d, &model, &png_options).unwrap(); + let mut centered_png = png_options.clone(); + centered_png["center"] = json!(true); + assert_eq!(png_default, plot(&d, &model, ¢ered_png).unwrap(), "explicit center preserves legacy PNG bytes"); + + let mut pdf_moved = json!({"center": false, "offset": [10.0, 20.0]}); + let moved_pdf = plot(&d, &model, &pdf_moved).unwrap(); + assert_ne!(pdf_default, moved_pdf); + pdf_moved["output"] = json!("png"); + pdf_moved["resolution"] = json!(100); + pdf_moved["width"] = json!(210); + pdf_moved["height"] = json!(297); + pdf_moved["landscape"] = json!(true); + pdf_moved["margins"] = json!([5, 5, 5, 5]); + let moved_png = plot(&d, &model, &pdf_moved).unwrap(); + assert_ne!(png_default, moved_png); + + let layout = Space::Paper(d.layouts[0].name.clone()); + assert_eq!(plot(&d, &layout, &json!({})).unwrap(), plot(&d, &layout, &json!({"center": false, "offset": [10.0, 20.0]})).unwrap()); + } + + #[test] + fn invalid_and_unsupported_plot_offsets_are_reported() { + let d = drawing(); + let model = Space::Model; + for offset in [json!([1]), json!([1, 2, 3]), json!([1, "2"]), json!([f64::NAN, 2]), json!([100_001, 0])] { + assert!(plot(&d, &model, &json!({"offset": offset})).is_err()); + } + let err = plot(&d, &model, &json!({"output": "svg", "center": false, "offset": [0, 0]})).unwrap_err(); + assert!(err.to_string().contains("not supported for SVG")); + assert!(plot(&d, &model, &json!({"output": "svg", "center": true})).unwrap().starts_with(b" 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, &choices) { + if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices, None) { action = Some(("pagesetup", json!({ "layout": name, k: v }))); } }); @@ -592,6 +592,7 @@ fn page_rows( page: &cadcraft_doc::PageSetup, rows: &PageRows, choices: &PageChoices, + placement: Option<(&mut bool, &mut [f64; 2], bool)>, ) -> Option<(&'static str, Value)> { let mut change = None; ui.label("Printer/plotter"); @@ -658,6 +659,31 @@ fn page_rows( change = Some(("scaleToFit", json!(fit))); } ui.end_row(); + if let Some((center, offset_mm, supported)) = placement { + ui.label("Plot offset"); + ui.add_enabled_ui(supported, |ui| ui.checkbox(center, "Center the plot")).response.on_hover_text(if supported { + "Center the plotted model bounds on the printable area" + } else { + "Model-space offset placement is not supported by SVG" + }); + ui.end_row(); + for (i, label) in [(0, "X offset (mm)"), (1, "Y offset (mm)")] { + ui.label(label); + ui.add_enabled_ui(supported && !*center, |ui| { + if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i]) { + offset_mm[i] = v; + } + }) + .response + .on_hover_text("Distance from the printable area's lower-left corner; values range from −100000 to 100000 mm"); + ui.end_row(); + } + if supported && !*center && offset_mm.iter().any(|v| !v.is_finite() || v.abs() > cadcraft_render::paper::MAX_PLOT_OFFSET_MM) { + ui.label(""); + ui.colored_label(Tokens::get().warn, "Offsets must be within ±100000 mm"); + ui.end_row(); + } + } if rows.scale { ui.label("Scale"); ui.add_enabled_ui(!page.scale_to_fit, |ui| { @@ -776,6 +802,9 @@ struct PlotState { page: cadcraft_doc::PageSetup, /// Plot area "window" corners in drawing units: [x1, y1, x2, y2]. window: [f64; 4], + /// Model-space placement; stays outside PageSetup so layouts keep their existing behavior. + center: bool, + offset_mm: [f64; 2], } /// The page setup a plot of `target` starts from: the layout's, or model-space defaults (fitted @@ -809,6 +838,7 @@ fn plot_params(st: &PlotState, preview_dpi: Option) -> Value { }); if let Some(o) = p.as_object_mut() { let device = device_label(page) != "None"; + let output = cadcraft_engine::cmd::plotters::find_device(&page.device).map_or("pdf", |d| d.output.as_str()); match preview_dpi { // The page's own sheet, so a device's paper and printable area show as they plot. Some(dpi) => { @@ -838,6 +868,12 @@ fn plot_params(st: &PlotState, preview_dpi: Option) -> Value { } if st.target == "Model" { o.insert("plotArea".into(), json!(page.plot_area)); + if output != "svg" { + o.insert("center".into(), json!(st.center)); + if !st.center { + o.insert("offset".into(), json!(st.offset_mm)); + } + } if !page.scale_to_fit { o.insert("scale".into(), json!(page.scale)); } @@ -872,15 +908,31 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { if ext.is_empty() { ext = cadcraft_geom::Bounds2::new(cadcraft_geom::Vec2::ZERO, cadcraft_geom::Vec2::new(12.0, 9.0)); } - PlotState { generation, page: plot_page(d, &target), target, window: [ext.min.x, ext.min.y, ext.max.x, ext.max.y] } + PlotState { + generation, + page: plot_page(d, &target), + target, + window: [ext.min.x, ext.min.y, ext.max.x, ext.max.y], + center: true, + offset_mm: [0.0, 0.0], + } }); let model = st.target == "Model"; let choices = PageChoices::new(ctx, d); + let offset_supported = + choices.devices.iter().find(|dev| dev.name.eq_ignore_ascii_case(&st.page.device)).is_none_or(|dev| dev.output.as_str() != "svg"); // 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| { + let center = ctx.content_rect().center(); + egui::Window::new("Plot") + .open(open) + .collapsible(false) + .resizable(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(center) + .show(ctx, |ui| { egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label("Layout"); egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| { @@ -901,7 +953,8 @@ 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, &choices) { + let placement = model.then_some((&mut st.center, &mut st.offset_mm, offset_supported)); + if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) { apply_page_change(&mut st.page, k, &v); } if model && st.page.plot_area == "window" { @@ -942,7 +995,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { 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()); @@ -1085,7 +1138,14 @@ mod plot_dialog_tests { 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] } + PlotState { + generation: 0, + page: plot_page(&d, target), + target: target.into(), + window: [0.0, 0.0, 10.0, 10.0], + center: true, + offset_mm: [0.0, 0.0], + } } #[test] @@ -1115,6 +1175,35 @@ mod plot_dialog_tests { assert!(plot_params(&st, None).get("plotStyleTable").is_none()); } + #[test] + fn plot_center_and_offsets_are_model_only_and_omitted_for_svg() { + let mut st = state("Model"); + assert_eq!(plot_params(&st, None)["center"], json!(true)); + st.center = false; + st.offset_mm = [12.5, -7.0]; + let params = plot_params(&st, None); + assert_eq!(params["center"], json!(false)); + assert_eq!(params["offset"], json!([12.5, -7.0])); + let preview = plot_params(&st, Some(150.0)); + assert_eq!(preview["output"], json!("png")); + assert_eq!(preview["offset"], json!([12.5, -7.0])); + + let svg = cadcraft_engine::cmd::plotters::devices().into_iter().find(|dev| dev.output.as_str() == "svg").expect("built-in SVG device"); + apply_page_change(&mut st.page, "device", &json!(svg.name)); + let params = plot_params(&st, None); + assert!(params.get("center").is_none() && params.get("offset").is_none(), "SVG does not receive unsupported placement options: {params}"); + let preview = plot_params(&st, Some(150.0)); + assert!( + preview.get("center").is_none() && preview.get("offset").is_none(), + "disabled SVG placement controls cannot affect preview output either" + ); + + st.target = "Layout1".into(); + st.page = plot_page(&cadcraft_doc::Drawing::new_metric(), "Layout1"); + let params = plot_params(&st, None); + assert!(params.get("center").is_none() && params.get("offset").is_none(), "layout plots retain their page setup mapping"); + } + #[test] fn window_pick_brings_the_dialog_back_with_the_picked_window() { let ctx = egui::Context::default(); diff --git a/docs/plotters.md b/docs/plotters.md index a4c6f4f..a4fd5d6 100644 --- a/docs/plotters.md +++ b/docs/plotters.md @@ -101,6 +101,11 @@ In the file name, characters other than letters, digits, spaces and `-_()+,` bec - The device sets the output and resolution. - `paper` (one of the device's papers) sets the size and margins for this plot. - A model-space plot without `paper` uses the device's default paper and orientation. + - For Model plots, `center` defaults to `true`, preserving the existing centered output. Set + `center: false` with `offset: [x, y]` to place the plotted bounds' lower-left corner from the + printable area's lower-left origin; offsets are millimetres, default to `[0, 0]`, and are + bounded to ±100,000 mm. PDF and PNG support these controls. SVG plots keep their extents view; + an active offset is rejected rather than ignored. Layout plots do not use model offsets. - `exportpdf` takes the same options and always writes PDF. Model-space PDF plots don't apply the paper's margins yet. They fit the drawing inside the