From 0b5a0a2eb858d5eab4106a90cd7be6b2a5d32c0a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:04:10 +0200 Subject: [PATCH] Plot: apply an explicit scale to non-fitted layout plots (#490) (#598) Squash of PR #598 by @MarekSpodBiedry. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/exchange.rs | 13 +++++++++++- crates/io/src/pdf.rs | 8 +++---- crates/io/tests/plot_layout_scale.rs | 31 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 crates/io/tests/plot_layout_scale.rs diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index 45943dce..ae3ff72b 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -339,7 +339,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result { let (from, k, to) = if fit || space == Space::Model { (if b.is_empty() { Vec2::ZERO } else { b.center() }, plot_scale(&b, &sheet, fit, scale), sheet.printable.center()) } else { - (Vec2::ZERO, 1.0, Vec2::ZERO) + (Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO) }; let world = Bounds2::new((Vec2::ZERO - to) / k + from, (sheet.size - to) / k + from); if ![world.min.x, world.min.y, world.max.x, world.max.y].iter().all(|v| v.is_finite()) { @@ -576,6 +576,17 @@ mod tests { } } + #[test] + fn preview_applies_an_explicit_layout_scale() { + let mut s = Session::new(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + s.execute("line", &json!({ "points": [[1.0, 1.0], [5.0, 1.0]] })).unwrap(); + let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string(); + let default = png(&mut s, json!({ "layout": "Layout1", "width": 200 })); + assert_eq!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 1 })), default, "scale 1 is the 1:1 default"); + assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1"); + } + #[test] fn publish_plots_every_sheet() { our_hooks(); diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index d7efff77..2e08bb54 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -1,9 +1,9 @@ //! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF writer of our own (pure Rust, no file //! access, so it runs on the web too). //! -//! One page per sheet: model-space extents fitted to a sheet, or a layout at 1:1 on its paper -//! (paper units are millimetres in metric drawings, inches otherwise). Lines are stroked paths -//! (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white +//! One page per sheet: model-space extents fitted to a sheet, or a layout at 1:1 (or the given +//! `scale`) on its paper (paper units are millimetres in metric drawings, inches otherwise). +//! Lines are stroked paths (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white //! paper, and line widths come from lineweights when enabled. The page setup's plot style //! table (or the `plotStyleTable` option) sets the printed colours, linetypes and lineweights. //! @@ -318,7 +318,7 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result { let from = if b.is_empty() { Vec2::ZERO } else { b.center() }; Map { from, s, to: sheet.printable.center(), k } } else { - Map { from: Vec2::ZERO, s: 1.0, to: Vec2::ZERO, k } + Map { from: Vec2::ZERO, s, to: Vec2::ZERO, k } }; // Plot style ends, joins and fill patterns at the plot scale (a layout's are already drawn). let mut list = list; diff --git a/crates/io/tests/plot_layout_scale.rs b/crates/io/tests/plot_layout_scale.rs new file mode 100644 index 00000000..8512d7ab --- /dev/null +++ b/crates/io/tests/plot_layout_scale.rs @@ -0,0 +1,31 @@ +//! An explicit numeric `scale` on a non-fitted layout plot scales the geometry (issue #490). + +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Space}; +use cadcraft_geom::Vec3; +use serde_json::{Value, json}; + +/// Plotted length in millimetres of the one 90-unit line in Layout1. +fn plotted_mm(opts: Value) -> f64 { + let mut d = Drawing::new_metric(); + let line = EntityKind::Line(Line { a: Vec3::new(10.0, 10.0, 0.0), b: Vec3::new(100.0, 10.0, 0.0) }); + d.add(&Space::Paper("Layout1".into()), Common::default(), line).unwrap(); + let mut o = json!({"compress": false}); + if let (Some(o), Some(e)) = (o.as_object_mut(), opts.as_object()) { + o.extend(e.clone()); + } + let bytes = cadcraft_io::plot(&d, &Space::Paper("Layout1".into()), &o).unwrap(); + let text = String::from_utf8_lossy(&bytes).into_owned(); + let xs: Vec = text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split(' ').next()?.parse().ok()).collect(); + assert_eq!(xs.len(), 2, "{text}"); + (xs[1] - xs[0]).abs() * 25.4 / 72.0 +} + +#[test] +fn layout_plot_honours_explicit_scale_without_fit() { + for (scale, mm) in [(1.0, 90.0), (0.5, 45.0), (2.0, 180.0)] { + let got = plotted_mm(json!({"fit": false, "scale": scale})); + assert!((got - mm).abs() < 0.01, "scale {scale}: {got} mm, want {mm}"); + } + let default = plotted_mm(json!({})); + assert!((default - 90.0).abs() < 0.01, "default stays 1:1: {default}"); +}