collective

Browse / github.com/storytold/cadcraft / patch

feat(preview): draw plot styles as plotted, zoom and pan the preview

feat(preview): draw plot styles as plotted, zoom and pan the preview ## What Plot Preview (the toolbar's *Plot Preview*, and *Preview* in the Plot dialog) did not show a sheet the way it prints, and had no way to look closer. 1. **Plot styles are drawn.** The preview from the toolbar / `PRE` ignored the page setup's plot style table: a layout set to `monochrome.ctb` previewed in colour and printed black. It now draws the table's colours, screening, lineweights, line ends, joins and fills, as the plotters do. The Plot dialog's own preview already drew a table, but its **None** was not sent, so a layout with a table of its own previewed (and printed) with that

Status
open
Score
0.00
Adopters
0 other operators
Author
lforgiarini (imported from pull request #2086, open)
Base
41d9b5e957b0719c04b9687c4f29088d77153c31
Size
+574 −47 in 9 files
Review
pending
Shared
2026-10-28
Id
ad28d65a0e4379512f3fc63f

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/engine/src/cmd/exchange.rs crates/io/src/plotter.rs crates/ui-egui/Cargo.toml crates/ui-egui/src/exchange.rs crates/ui-egui/src/managers.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/preview_zoom.rs docs/gaps.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -724,6 +724,7 @@ dependencies = [  "cadcraft-engine",  "cadcraft-fonts",  "cadcraft-geom",+ "cadcraft-io",  "cadcraft-render",  "chrono",  "craft-ui",diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs--- a/crates/engine/src/cmd/exchange.rs+++ b/crates/engine/src/cmd/exchange.rs@@ -341,6 +341,10 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {     // Curves are flattened to about half a pixel.     let est = plot_scale(&d.extents(&space), &sheet, fit, scale);     let tol = 0.5 / (px_per_paper_unit * est);+    // The plot style table the sheet plots with (the `plotStyleTable` option, else its page setup's),+    // so the preview shows the printed colours, screening and lineweights.+    let style_name = str_param(p, "plotStyleTable").map_or_else(|| page.plot_style_table.clone(), str::to_string);+    let plot_style_table = cadcraft_doc::plot_style_table(d, &style_name).map(std::sync::Arc::new);     let ropts = cadcraft_render::Options {         tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 },         min_dash: 0.0,@@ -348,9 +352,10 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {         fill: true,         lineweights,         transparency,+        plot_style_table,         ..Default::default()     };-    let list = cadcraft_render::build_plot(d, &space, &ropts);+    let mut list = cadcraft_render::build_plot(d, &space, &ropts);     let b = list.bounds;     // Paper point q shows drawing point (q - to) / k + from.     let (from, k, to) = if fit || space == Space::Model {@@ -358,6 +363,8 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {     } else {         (Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO)     };+    // The table's line ends, joins and fill patterns at the plot scale, as the plotters draw them.+    cadcraft_render::pen::apply(&mut list, k * sheet.unit_mm);     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()) {         return Err(bad("preview", "the drawing is too large to preview at this scale"));diff --git a/crates/io/src/plotter.rs b/crates/io/src/plotter.rs--- a/crates/io/src/plotter.rs+++ b/crates/io/src/plotter.rs@@ -62,20 +62,42 @@ fn fit_scale(b: &Bounds2, sheet: &Sheet, o: &PdfOptions) -> f64 {     pdf::plot_scale(b, sheet, o.fit != Some(false), o.scale) } +/// The part of the sheet a PNG plot draws, from the `crop` option: `[x0, y0, x1, y1]` as fractions+/// of the sheet, measured from its top-left corner (so `[0, 0, 1, 1]` is the whole sheet). A preview+/// zoomed in on a detail asks for just that part at the resolution it needs.+fn crop(opts: &Value) -> Result<Option<[f64; 4]>> {+    let Some(raw) = opts.get("crop").filter(|v| !v.is_null()) else { return Ok(None) };+    let v: Vec<f64> = raw.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();+    match v[..] {+        [x0, y0, x1, y1]+            if raw.as_array().is_some_and(|a| a.len() == 4)+                && [x0, y0, x1, y1].iter().all(|f| f.is_finite() && (0.0..=1.0).contains(f))+                && x1 - x0 >= 1e-6+                && y1 - y0 >= 1e-6 =>+        {+            Ok(Some([x0, y0, x1, y1]))+        }+        _ => Err(IoError::Format("`crop` must be [x0, y0, x1, y1]: fractions of the sheet from 0 to 1, with x0 < x1 and y0 < y1".into())),+    }+}+ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {     let o = PdfOptions::from_json(opts);     let (sheet, page) = sheet(d, space, &o)?;+    let crop = crop(opts)?;+    // The size of what is drawn, as fractions of the sheet.+    let (frac_x, frac_y) = crop.map_or((1.0, 1.0), |c| (c[2] - c[0], c[3] - c[1]));     let dpi = opts.get("resolution").and_then(Value::as_f64).filter(|r| r.is_finite()).unwrap_or(300.0).clamp(50.0, 2400.0);     // Pixels per paper unit, lowered so the image stays within the caps.     let mut k = sheet.unit_mm / 25.4 * dpi;-    let (w, h) = (sheet.size.x * k, sheet.size.y * k);+    let (w, h) = (sheet.size.x * frac_x * k, sheet.size.y * frac_y * k);     let shrink = (MAX_PNG_PIXELS / (w * h).max(1.0)).sqrt().min(16384.0 / w.max(h).max(1.0)).min(1.0);     if !(shrink.is_finite() && shrink > 0.0) {         return Err(IoError::Format("the sheet is too large to plot as an image".into()));     }     k *= shrink;     let px = |v: f64| (v * k).round().clamp(1.0, 16384.0) as u32;-    let (width, height) = (px(sheet.size.x), px(sheet.size.y));+    let (width, height) = (px(sheet.size.x * frac_x), px(sheet.size.y * frac_y));     let lineweights = o.lineweights.unwrap_or(if space.is_paper() { page.lineweights } else { true });     let table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(Arc::new);     // Paper units per drawing unit: 1 for a layout at 1:1, the fitted scale for model space.@@ -87,7 +109,12 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {     let transparency = o.plot_transparency.unwrap_or(page.plot_transparency);     let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, transparency, ..cadcraft_render::Options::default() };     let mut list = cadcraft_render::build_plot(d, space, &ropts);-    let half = Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k);+    // The paper point at the image centre (the sheet's centre, or the middle of the crop; the+    // sheet's origin is its lower-left corner and the crop's is its top-left).+    let half = match crop {+        Some(c) => Vec2::new((c[0] + c[2]) / 2.0 * sheet.size.x, (1.0 - (c[1] + c[3]) / 2.0) * sheet.size.y),+        None => Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k),+    };     let b = window.unwrap_or(list.bounds);     let s = if fit { fit_scale(&b, &sheet, &o) } else { 1.0 };     // Plot style ends, joins and fill patterns at the plot scale, as PDF draws them (a layout's@@ -150,6 +177,63 @@ mod tests {         d     } +    /// A PNG plot decoded.+    fn decode(bytes: &[u8]) -> tiny_skia::Pixmap {+        tiny_skia::Pixmap::decode_png(bytes).unwrap()+    }++    /// A PNG crop is that part of the sheet at the same scale.+    #[test]+    fn a_png_crop_is_that_part_of_the_sheet_at_the_same_scale() {+        let d = drawing();+        // 127 dpi is 5 pixels per mm: a 200 x 100 mm sheet is 1000 x 500 pixels, and a crop of its+        // middle half starts on whole pixels.+        let base = json!({"output": "png", "resolution": 127, "width": 100, "height": 200, "landscape": true, "margins": [0, 0, 0, 0]});+        let full = decode(&plot(&d, &Space::Model, &base).unwrap());+        assert_eq!((full.width(), full.height()), (1000, 500));+        let mut cropped = base.clone();+        cropped["crop"] = json!([0.25, 0.25, 0.75, 0.75]);+        let part = decode(&plot(&d, &Space::Model, &cropped).unwrap());+        assert_eq!((part.width(), part.height()), (500, 250), "half the sheet each way");+        let mut drawn = 0;+        for y in 0..part.height() {+            for x in 0..part.width() {+                let (a, b) = (part.pixel(x, y).unwrap(), full.pixel(x + 250, y + 125).unwrap());+                let close = |p: u8, q: u8| p.abs_diff(q) <= 2;+                assert!(close(a.red(), b.red()) && close(a.green(), b.green()) && close(a.blue(), b.blue()), "pixel {x},{y}");+                drawn += usize::from(a.red() < 128);+            }+        }+        assert!(drawn > 100, "the crop shows part of the drawing ({drawn} dark pixels)");+        // A layout is cropped the same way.+        let layout = Space::Paper(d.layouts[0].name.clone());+        let lay = json!({"output": "png", "resolution": 127, "width": 100, "height": 200, "landscape": true, "margins": [0, 0, 0, 0]});+        let l_full = decode(&plot(&d, &layout, &lay).unwrap());+        let mut l_crop = lay.clone();+        l_crop["crop"] = json!([0.0, 0.5, 0.5, 1.0]);+        let l_part = decode(&plot(&d, &layout, &l_crop).unwrap());+        assert_eq!((l_part.width(), l_part.height()), (l_full.width() / 2, l_full.height() / 2));+    }++    /// Bad crops are errors; no crop, or the whole sheet, is fine.+    #[test]+    fn a_bad_png_crop_is_an_error() {+        let d = drawing();+        for crop in [+            json!([0.5, 0.0, 0.5, 1.0]),+            json!([0.0, 0.8, 1.0, 0.2]),+            json!([0, 0, 1]),+            json!([0, 0, 1, 2]),+            json!([-0.1, 0, 1, 1]),+            json!("all"),+            json!([0, 0, 1, f64::NAN]),+        ] {+            assert!(plot(&d, &Space::Model, &json!({"output": "png", "resolution": 50, "crop": crop})).is_err(), "{crop}");+        }+        assert!(plot(&d, &Space::Model, &json!({"output": "png", "resolution": 50, "crop": null})).is_ok(), "no crop is the whole sheet");+        assert!(plot(&d, &Space::Model, &json!({"output": "png", "resolution": 50, "crop": [0, 0, 1, 1]})).is_ok());+    }+     #[test]     fn png_and_svg_devices() {         let d = drawing();diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml--- a/crates/ui-egui/Cargo.toml+++ b/crates/ui-egui/Cargo.toml@@ -28,6 +28,8 @@ image = { workspace = true }  [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }+# The real plotter, to check what the preview draws (tests only).+cadcraft-io = { workspace = true } # Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses). naga = { version = "30", features = ["wgsl-in", "glsl-out"] } diff --git a/crates/ui-egui/src/exchange.rs b/crates/ui-egui/src/exchange.rs--- a/crates/ui-egui/src/exchange.rs+++ b/crates/ui-egui/src/exchange.rs@@ -4,7 +4,7 @@  use std::sync::atomic::{AtomicU32, Ordering}; -use egui::TextureHandle;+use egui::{Pos2, Rect, Sense, TextureHandle, Vec2, pos2, vec2}; use serde_json::{Value, json};  use crate::CadApp;@@ -53,10 +53,127 @@ fn no_picker(app: &mut CadApp, name: &str, example: &str) -> Result<Value, Strin     Err(msg) } +/// How far the preview can be zoomed in, as a multiple of the whole sheet fitting its area.+const MAX_ZOOM: f32 = 24.0;+/// How long (seconds) the view must rest before the part in view is drawn again in more detail.+const SETTLE: f64 = 0.15;++/// How the sheet's image sits in the preview area: `zoom` 1 fits the whole sheet, `pan` moves the+/// image's centre from the area's centre (points).+#[derive(Clone, Copy, Debug, PartialEq)]+struct PanZoom {+    zoom: f32,+    pan: Vec2,+}++impl Default for PanZoom {+    /// The whole sheet in view.+    fn default() -> Self {+        PanZoom { zoom: 1.0, pan: Vec2::ZERO }+    }+}++impl PanZoom {+    /// Points per image pixel when the whole image fits `area`.+    fn fit(area: Rect, image: Vec2) -> f32 {+        (area.width() / image.x.max(1.0)).min(area.height() / image.y.max(1.0)).max(1e-6)+    }++    /// Where the whole image is drawn.+    fn image_rect(&self, area: Rect, image: Vec2) -> Rect {+        Rect::from_center_size(area.center() + self.pan, image * (Self::fit(area, image) * self.zoom))+    }++    /// Keep the zoom in range and the image over the area: it can't be dragged away, and is+    /// centred along an axis where it is smaller than the area.+    fn clamp(&mut self, area: Rect, image: Vec2) {+        self.zoom = if self.zoom.is_finite() { self.zoom.clamp(1.0, MAX_ZOOM) } else { 1.0 };+        let size = image * (Self::fit(area, image) * self.zoom);+        let room = |image: f32, area: f32| ((image - area) / 2.0).max(0.0);+        let (rx, ry) = (room(size.x, area.width()), room(size.y, area.height()));+        self.pan =+            if self.pan.x.is_finite() && self.pan.y.is_finite() { vec2(self.pan.x.clamp(-rx, rx), self.pan.y.clamp(-ry, ry)) } else { Vec2::ZERO };+    }++    /// Zoom by `factor` about the screen point `at`, which stays over the same point of the image.+    fn zoom_at(&mut self, area: Rect, image: Vec2, at: Pos2, factor: f32) {+        if !(factor.is_finite() && factor > 0.0) {+            return;+        }+        let centre = self.image_rect(area, image).center();+        let zoom = (self.zoom * factor).clamp(1.0, MAX_ZOOM);+        let moved = at + (centre - at) * (zoom / self.zoom);+        self.zoom = zoom;+        self.pan = moved - area.center();+        self.clamp(area, image);+    }+}++/// The part of the image drawn at `shown` that is inside `area`, as fractions of the image from its+/// top-left corner `[x0, y0, x1, y1]`; `None` when none of it is.+fn visible_crop(area: Rect, shown: Rect) -> Option<[f32; 4]> {+    let v = area.intersect(shown);+    if shown.width() < 1.0 || shown.height() < 1.0 || v.width() < 1.0 || v.height() < 1.0 {+        return None;+    }+    let (w, h) = (shown.width(), shown.height());+    Some([(v.min.x - shown.min.x) / w, (v.min.y - shown.min.y) / h, (v.max.x - shown.min.x) / w, (v.max.y - shown.min.y) / h])+}++/// `crop` grown by a quarter of its size on each side (within the sheet): what is drawn in detail,+/// so that a small pan stays sharp.+fn with_margin(c: [f32; 4]) -> [f32; 4] {+    let (mx, my) = ((c[2] - c[0]) / 4.0, (c[3] - c[1]) / 4.0);+    [(c[0] - mx).max(0.0), (c[1] - my).max(0.0), (c[2] + mx).min(1.0), (c[3] + my).min(1.0)]+}++/// Whether crop `outer` covers crop `inner`.+fn covers(outer: [f32; 4], inner: [f32; 4]) -> bool {+    outer[0] <= inner[0] + 1e-4 && outer[1] <= inner[1] + 1e-4 && outer[2] >= inner[2] - 1e-4 && outer[3] >= inner[3] - 1e-4+}++/// The sheet as plotted: the whole of it drawn once, and what the preview needs to draw parts of it+/// again in more detail.+#[derive(Clone)]+struct Shown {+    base: TextureHandle,+    info: String,+    /// The sheet's width and height in mm, as plotted (the orientation applied).+    sheet_mm: Vec2,+    /// The `plot` parameters of the whole sheet.+    params: Value,+}++/// A part of the sheet drawn at the resolution of the screen it is shown on.+#[derive(Clone)]+struct Detail {+    tex: TextureHandle,+    /// The part, as fractions of the sheet from its top-left corner.+    crop: [f32; 4],+    /// The width in points the whole sheet had on screen when it was drawn.+    sheet_width: f32,+}++impl Detail {+    /// Whether it is drawn at the scale the sheet has on screen now.+    fn matches(&self, sheet_width: f32) -> bool {+        sheet_width > 0.0 && (self.sheet_width / sheet_width - 1.0).abs() < 0.01+    }+}+ #[derive(Clone)] struct Preview {     generation: u32,-    shown: Result<(TextureHandle, String), String>,+    shown: Result<Shown, String>,+    view: PanZoom,+    detail: Option<Detail>,+    /// When the view, or the size the sheet is drawn at, last changed (seconds), to draw in detail+    /// once it rests.+    changed: f64,+    /// Where the sheet was drawn last: a resized window changes it with the view unchanged.+    drawn: Rect,+    /// The part and scale last drawn in detail, so a failure is not retried every frame.+    tried: Option<([i32; 4], i32)>, }  /// A preview the Plot dialog asked for: its PLOT parameters (PNG output) and a caption.@@ -76,50 +193,137 @@ pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value,     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::<FromPlot>(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())?;+/// Plot `params` (a PNG) and load it as a texture called `name`.+fn plot_texture(app: &mut CadApp, ctx: &egui::Context, params: &Value, name: &str) -> Result<TextureHandle, String> {+    let r = app.session.execute("plot", 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);-    let mut info = format!("{} · {}", r["layout"].as_str().unwrap_or(""), r["paper"].as_str().unwrap_or(""));-    if r["landscape"].as_bool() == Some(true) {-        info.push_str(" · landscape");+    Ok(ctx.load_texture(name, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR))+}++/// Render the plot preview of the tab being shown, or of the Plot dialog's settings, as it plots+/// (page setup, plot style table, lineweights): the whole sheet at about 1600 pixels.+fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<Shown, String> {+    let (params, info) = match ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))) {+        Some(f) => (f.params, f.info),+        None => crate::managers::current_preview(app)?,+    };+    let base = plot_texture(app, ctx, &params, "plot-preview")?;+    let mm = |k: &str| params.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(210.0) as f32;+    let (a, b) = (mm("width"), mm("height"));+    let (long, short) = (a.max(b), a.min(b));+    let sheet_mm = if params.get("landscape").and_then(Value::as_bool) == Some(true) { vec2(long, short) } else { vec2(short, long) };+    Ok(Shown { base, info, sheet_mm, params })+}++/// Draw the part `crop` of the sheet again at the resolution of the screen, where the whole sheet is+/// `sheet_width` points wide.+fn render_detail(app: &mut CadApp, ctx: &egui::Context, shown: &Shown, crop: [f32; 4], sheet_width: f32) -> Result<Detail, String> {+    // One image pixel per screen pixel: the sheet's pixel width over its width in inches.+    let pixels = f64::from(sheet_width) * f64::from(ctx.pixels_per_point());+    let dpi = (pixels / (f64::from(shown.sheet_mm.x) / 25.4)).clamp(50.0, 2400.0);+    let mut params = shown.params.clone();+    if let Some(o) = params.as_object_mut() {+        o.insert("resolution".into(), json!(dpi));+        o.insert("crop".into(), json!(crop));     }-    Ok((tex, info))+    let tex = plot_texture(app, ctx, &params, "plot-preview-detail")?;+    Ok(Detail { tex, crop, sheet_width }) } -/// The Plot Preview window: the sheet as it plots, with Print and Close.+/// The Plot Preview window: the sheet as it plots, with Print and Close. The mouse wheel zooms+/// about the pointer, dragging pans and a double click shows the whole sheet again; the part in+/// view is drawn again at the screen's resolution once the view rests. pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {     let id = egui::Id::new("cc_plot_preview");     let generation = PREVIEW_GEN.load(Ordering::Relaxed);+    let now = ctx.input(|i| i.time);     let cached = ctx.data(|d| d.get_temp::<Preview>(id)).filter(|p| p.generation == generation);-    let preview = match cached {+    let mut pv = match cached {         Some(p) => p,         None => {-            let p = Preview { generation, shown: render(app, ctx) };-            ctx.data_mut(|d| d.insert_temp(id, p.clone()));-            p+            Preview { generation, shown: render(app, ctx), view: PanZoom::default(), detail: None, changed: now, drawn: Rect::NOTHING, tried: None }         }     };     let (mut close, mut print) = (false, false);-    egui::Window::new("Plot Preview").open(open).collapsible(false).resizable(false).show(ctx, |ui| {-        match &preview.shown {-            Ok((tex, info)) => {-                ui.label(info);-                let size = tex.size_vec2();-                let k = (760.0 / size.x).min(540.0 / size.y).min(1.0);-                ui.image((tex.id(), size * k));+    // What to draw in detail after the window: the part and the sheet's width on screen.+    let mut want: Option<([f32; 4], f32)> = None;+    egui::Window::new("Plot Preview").open(open).collapsible(false).resizable(true).default_size(vec2(900.0, 680.0)).show(ctx, |ui| {+        match &pv.shown {+            Ok(shown) => {+                let image = shown.base.size_vec2();+                let mut view = pv.view;+                let (mut zoom_out, mut fit, mut zoom_in) = (false, false, false);+                ui.horizontal(|ui| {+                    ui.label(&shown.info);+                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+                        ui.label(format!("{:.0}%", view.zoom * 100.0));+                        fit = ui.small_button("Fit").on_hover_text("The whole sheet (or double-click)").clicked();+                        zoom_in = ui.small_button("+").on_hover_text("Zoom in (or the mouse wheel)").clicked();+                        zoom_out = ui.small_button("−").on_hover_text("Zoom out").clicked();+                    });+                });+                let size = vec2(ui.available_width().max(160.0), (ui.available_height() - 36.0).max(160.0));+                let (area, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());+                let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| area.contains(*p));+                if resp.hovered() {+                    let (scroll, pinch) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta()));+                    if let Some(at) = hover {+                        view.zoom_at(area, image, at, (scroll * 0.002).exp() * pinch);+                    }+                }+                if resp.dragged() {+                    view.pan += resp.drag_delta();+                }+                if resp.double_clicked() || fit {+                    view = PanZoom::default();+                }+                if zoom_in {+                    view.zoom_at(area, image, area.center(), 1.5);+                }+                if zoom_out {+                    view.zoom_at(area, image, area.center(), 1.0 / 1.5);+                }+                view.clamp(area, image);+                if resp.dragged() || resp.hovered() {+                    ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });+                }+                let sheet = view.image_rect(area, image);+                let painter = ui.painter_at(area);+                painter.rect_filled(area, 0.0, egui::Color32::from_gray(70));+                let full = Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0));+                painter.image(shown.base.id(), sheet, full, egui::Color32::WHITE);+                if let Some(d) = pv.detail.as_ref().filter(|d| d.matches(sheet.width())) {+                    let [x0, y0, x1, y1] = d.crop;+                    let (w, h) = (sheet.width(), sheet.height());+                    let part = Rect::from_min_max(sheet.min + vec2(x0 * w, y0 * h), sheet.min + vec2(x1 * w, y1 * h));+                    painter.image(d.tex.id(), part, full, egui::Color32::WHITE);+                }+                painter.rect_stroke(sheet, 0.0, egui::Stroke::new(1.0, egui::Color32::from_gray(120)), egui::StrokeKind::Outside);+                // A change of the view or of the size the sheet is drawn at (the window was resized)+                // restarts the wait, so the detail is not drawn again at every step of a resize.+                if view != pv.view || sheet != pv.drawn {+                    pv.changed = now;+                    pv.view = view;+                    pv.drawn = sheet;+                }+                // Drawn in detail when the sheet is shown larger than its image, once the view rests.+                let larger = sheet.width() * ui.ctx().pixels_per_point() > image.x * 1.1;+                if let Some(crop) = visible_crop(area, sheet).filter(|_| larger) {+                    let covered = pv.detail.as_ref().is_some_and(|d| d.matches(sheet.width()) && covers(d.crop, crop));+                    let key = (crop.map(|v| (v * 1000.0) as i32), sheet.width() as i32);+                    if !covered && pv.tried != Some(key) {+                        if now - pv.changed >= SETTLE {+                            want = Some((with_margin(crop), sheet.width()));+                            pv.tried = Some(key);+                        } else {+                            ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(SETTLE));+                        }+                    }+                } else {+                    pv.detail = None;+                }             }             Err(e) => {                 ui.label(e);@@ -131,6 +335,12 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {             close = ui.button("Close").clicked();         });     });+    if let (Some((crop, width)), Ok(shown)) = (want, pv.shown.as_ref()) {+        // A failure keeps the sheet as it is and is not tried again for this view.+        pv.detail = render_detail(app, ctx, shown, crop, width).ok();+        ctx.request_repaint();+    }+    ctx.data_mut(|d| d.insert_temp(id, pv));     if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {         close = true;     }@@ -207,4 +417,127 @@ mod tests {         let e = app.run("share", json!({})).unwrap_err();         assert!(e.contains("not available yet"), "{e}");     }++    use egui::{Rect, Vec2, pos2, vec2};++    use super::{MAX_ZOOM, PanZoom, covers, visible_crop, with_margin};++    /// The preview area the zoom tests use.+    fn area() -> Rect {+        Rect::from_min_size(pos2(10.0, 20.0), vec2(800.0, 600.0))+    }++    const IMAGE: Vec2 = vec2(1600.0, 1000.0);++    /// The sheet fits the area and is centred until zoomed.+    #[test]+    fn the_sheet_fits_the_area_and_is_centred_until_zoomed() {+        let r = PanZoom::default().image_rect(area(), IMAGE);+        assert!((r.width() - 800.0).abs() < 1e-3 && (r.height() - 500.0).abs() < 1e-3, "fits by width: {r:?}");+        assert!((r.center() - area().center()).length() < 1e-3);+    }++    /// Zooming keeps the image point under the pointer in place.+    #[test]+    fn zoom_keeps_the_point_under_the_pointer() {+        let mut v = PanZoom::default();+        let at = pos2(430.0, 330.0);+        let r = v.image_rect(area(), IMAGE);+        let u = ((at.x - r.min.x) / r.width(), (at.y - r.min.y) / r.height());+        v.zoom_at(area(), IMAGE, at, 3.0);+        assert!((v.zoom - 3.0).abs() < 1e-5);+        let r = v.image_rect(area(), IMAGE);+        let now = pos2(r.min.x + u.0 * r.width(), r.min.y + u.1 * r.height());+        assert!((now - at).length() < 0.01, "the point under the pointer stayed: {now:?} vs {at:?}");+        // And back out: the whole sheet again, centred.+        v.zoom_at(area(), IMAGE, at, 1.0 / 3.0);+        assert_eq!(v, PanZoom::default());+    }++    /// The zoom is limited and the sheet stays over the area.+    #[test]+    fn zoom_is_limited_and_the_sheet_stays_over_the_area() {+        let mut v = PanZoom::default();+        v.zoom_at(area(), IMAGE, area().center(), 1e6);+        assert_eq!(v.zoom, MAX_ZOOM);+        v.zoom_at(area(), IMAGE, area().center(), 1e-6);+        assert_eq!(v.zoom, 1.0, "no further out than the whole sheet");+        // Dragged far away, the sheet still covers the area along the axes where it is larger.+        v.zoom_at(area(), IMAGE, area().center(), 4.0);+        v.pan = vec2(1e6, -1e6);+        v.clamp(area(), IMAGE);+        let r = v.image_rect(area(), IMAGE);+        assert!(+            r.min.x <= area().min.x + 1e-3 && r.max.x >= area().max.x - 1e-3 && r.min.y <= area().min.y + 1e-3 && r.max.y >= area().max.y - 1e-3,+            "{r:?}"+        );+        // Nonsense values change nothing or reset.+        let before = v;+        v.zoom_at(area(), IMAGE, area().center(), f32::NAN);+        v.zoom_at(area(), IMAGE, area().center(), -2.0);+        assert_eq!(v, before);+        v.pan = vec2(f32::NAN, 0.0);+        v.zoom = f32::INFINITY;+        v.clamp(area(), IMAGE);+        assert!(v.zoom == 1.0 && v.pan == Vec2::ZERO);+    }++    /// The part in view is what is drawn again in detail.+    #[test]+    fn the_visible_part_is_what_is_drawn_again_in_detail() {+        // Whole sheet in view: all of it.+        let whole = PanZoom::default().image_rect(area(), IMAGE);+        assert_eq!(visible_crop(area(), whole), Some([0.0, 0.0, 1.0, 1.0]));+        // Zoomed 4x on the centre: the middle eighth... of each side.+        let mut v = PanZoom::default();+        v.zoom_at(area(), IMAGE, area().center(), 4.0);+        let c = visible_crop(area(), v.image_rect(area(), IMAGE)).unwrap();+        assert!((c[0] - 0.375).abs() < 1e-4 && (c[2] - 0.625).abs() < 1e-4, "{c:?}");+        assert!(c[1] > 0.0 && c[3] < 1.0 && c[1] < c[3]);+        // The detail drawn covers it with a margin all round, within the sheet.+        let d = with_margin(c);+        assert!(covers(d, c) && !covers(c, d));+        assert!(d.iter().all(|v| (0.0..=1.0).contains(v)));+        assert_eq!(with_margin([0.0, 0.0, 1.0, 1.0]), [0.0, 0.0, 1.0, 1.0], "the margin stays inside the sheet");+        // Nothing of the sheet in the area: no part.+        let away = Rect::from_min_size(pos2(5000.0, 5000.0), vec2(100.0, 100.0));+        assert_eq!(visible_crop(area(), away), None);+    }++    /// The red-looking pixels of a plotted PNG, and its size.+    fn red_pixels(png: &[u8]) -> (usize, u32, u32) {+        let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();+        let red = img.pixels().filter(|p| p.0[0] > 180 && p.0[1] < 90 && p.0[2] < 90).count();+        (red, img.width(), img.height())+    }++    /// The preview draws the page setup's plot style table, and `None` overrides it.+    #[test]+    fn the_preview_draws_the_plot_style_table_of_the_page_setup() {+        let (mut app, _) = app();+        app.run("color", json!({ "color": 1 })).unwrap();+        app.run("line", json!({ "points": [[0, 0], [100, 60]] })).unwrap();+        app.run("mview", json!({ "layout": "Layout1" })).unwrap();+        app.run("layout.set", json!({ "name": "Layout1" })).unwrap();+        // The real plotter is called directly: the plot hook is global to the test process, which+        // other tests replace.+        let plot = |app: &CadApp, params: &serde_json::Value| {+            cadcraft_io::plotter::plot(app.session.doc().unwrap(), &cadcraft_doc::Space::Paper("Layout1".into()), params).unwrap()+        };+        let (params, info) = crate::managers::current_preview(&app).unwrap();+        assert!(info.starts_with("Layout1"), "{info}");+        let (plain, w, h) = red_pixels(&plot(&app, &params));+        assert!(plain > 50, "without a table the red line is drawn red ({plain} red pixels)");+        // The page setup's table is what the preview shows, as it is what plots.+        app.run("pagesetup", json!({ "layout": "Layout1", "plotStyleTable": "monochrome.ctb" })).unwrap();+        let (mut params, _) = crate::managers::current_preview(&app).unwrap();+        assert_eq!(red_pixels(&plot(&app, &params)).0, 0, "monochrome.ctb prints it black");+        // The Plot dialog's "None" is sent as such, so it overrides the layout's own table.+        params["plotStyleTable"] = json!("None");+        assert!(red_pixels(&plot(&app, &params)).0 > 50, "None overrides the page setup's table");+        // A crop is that part of the sheet, at the same scale.+        params["crop"] = json!([0.25, 0.25, 0.75, 0.75]);+        let (_, cw, ch) = red_pixels(&plot(&app, &params));+        assert!((i64::from(cw) - i64::from(w) / 2).abs() <= 1 && (i64::from(ch) - i64::from(h) / 2).abs() <= 1, "{cw}x{ch} of {w}x{h}");+    } }diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs--- a/crates/ui-egui/src/managers.rs+++ b/crates/ui-egui/src/managers.rs@@ -873,9 +873,9 @@ fn plot_params(st: &PlotState, preview_dpi: Option<f64>) -> Value {                 o.insert("height".into(), json!(page.height_mm));             }         }-        if style_label(page) != "None" {-            o.insert("plotStyleTable".into(), json!(page.plot_style_table));-        }+        // Always sent, "None" included: left out, the layout's own table would apply to a plot+        // the dialog shows without one.+        o.insert("plotStyleTable".into(), json!(if style_label(page) == "None" { "None" } else { page.plot_style_table.as_str() }));         if st.target == "Model" {             o.insert("plotArea".into(), json!(page.plot_area));             if output != "svg" {@@ -1018,13 +1018,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {         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);+        crate::exchange::preview_plot(app, ctx, plot_params(&st, Some(preview_dpi(&st.page))), plot_caption(&st));         return action;     }     if ok {@@ -1055,6 +1049,32 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {     action } +/// The resolution (dpi) that draws a sheet about 1600 pixels on its long side: the preview's+/// starting image of the whole sheet.+fn preview_dpi(page: &cadcraft_doc::PageSetup) -> f64 {+    let long_in = page.width_mm.max(page.height_mm).max(1.0) / 25.4;+    (1600.0 / long_in).clamp(50.0, 300.0)+}++/// The caption of a preview of these settings: the sheet and its paper.+fn plot_caption(st: &PlotState) -> String {+    let mut info = format!("{} · {}", st.target, st.page.paper);+    if st.page.landscape {+        info.push_str(" · landscape");+    }+    info+}++/// The Plot Preview of the tab being shown: the `plot` parameters of a PNG of its sheet as its page+/// setup plots it (the Plot dialog's starting settings, so the two previews agree), and a caption.+pub(crate) fn current_preview(app: &CadApp) -> Result<(Value, String), String> {+    let d = app.session.doc().map_err(|e| e.to_string())?;+    let target = app.session.layout_space().tab_name().to_string();+    let page = plot_page(d, &target);+    let st = PlotState { generation: 0, page, target, window: [0.0; 4], center: true, offset_mm: [0.0, 0.0] };+    Ok((plot_params(&st, Some(preview_dpi(&st.page))), plot_caption(&st)))+}+ /// The sheet in the chosen orientation with its printable area, and its size. fn sheet_sketch(ui: &mut egui::Ui, page: &cadcraft_doc::PageSetup, t: &Tokens) {     let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };@@ -1179,8 +1199,10 @@ mod plot_dialog_tests {         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)));+        // "None" is sent as such: left out, a layout's own table would plot and preview in a dialog+        // that shows none.         apply_page_change(&mut st.page, "plotStyleTable", &json!("None"));-        assert!(plot_params(&st, None).get("plotStyleTable").is_none());+        assert_eq!(plot_params(&st, None)["plotStyleTable"], json!("None"));     }      #[test]diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -19,6 +19,7 @@ mod mspace_viewport_view; mod options_dialog; mod parameters_rename; mod pickdrag;+mod preview_zoom; mod properties_units; mod rapid_clicks; mod selection_cycling;diff --git a/crates/ui-egui/tests/it/preview_zoom.rs b/crates/ui-egui/tests/it/preview_zoom.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/preview_zoom.rs@@ -0,0 +1,76 @@+//! The Plot Preview window draws the sheet once, and zooming in on it draws the part in view again+//! at the screen's resolution (a `crop` of the sheet) once the view rests.++use std::sync::Mutex;++use cadcraft_doc::{Drawing, Space};+use cadcraft_engine::Session;+use cadcraft_engine::cmd::file::{IoHooks, set_io};+use cadcraft_ui_egui::{CadApp, Services};+use egui::{Event, Modifiers, MouseWheelUnit, vec2};+use egui_kittest::Harness;+use serde_json::{Value, json};++/// The options of every `plot` the preview made.+static PLOTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());++/// A plot hook that records the options it is given.+fn recording_plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>, String> {+    if let Ok(mut plots) = PLOTS.lock() {+        plots.push(opts.clone());+    }+    cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())+}++/// The PNG plots so far: other tests in this process also plot (to PDF) through the global hook.+fn plots() -> Vec<Value> {+    PLOTS.lock().map(|p| p.iter().filter(|o| o["output"] == "png").cloned().collect()).unwrap_or_default()+}++/// Zooming in draws the part in view again at the screen's resolution.+#[test]+fn zooming_in_draws_the_part_in_view_at_the_screens_resolution() {+    set_io(IoHooks { read: |_, _| Err("no".into()), write: |_, _| Err("no".into()), plot: Some(recording_plot) });+    let mut app = CadApp::new(Session::default(), Services::default());+    app.ui.start_tab = false;+    app.ui.in_window_menu = false;+    app.run("line", json!({ "points": [[0, 0], [100, 60]] })).unwrap();+    app.run("mview", json!({ "layout": "Layout1" })).unwrap();+    app.run("layout.set", json!({ "name": "Layout1" })).unwrap();+    let mut h = Harness::builder().with_size(vec2(1280.0, 900.0)).build_ui_state(+        |ui, app: &mut CadApp| {+            app.logic(&ui.ctx().clone());+            app.ui(ui);+        },+        app,+    );+    h.run_steps(5);++    PLOTS.lock().unwrap().clear();+    h.state_mut().start("preview");+    h.run_steps(5);+    let first = plots();+    assert_eq!(first.len(), 1, "the whole sheet is drawn once: {first:?}");+    assert!(first[0].get("crop").is_none());+    assert_eq!(first[0]["output"], "png");++    // Scroll up over the preview: zoom in. Rendered again only once the view rests.+    let over = egui::pos2(450.0, 380.0);+    h.hover_at(over);+    h.run_steps(2);+    h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 1000.0), modifiers: Modifiers::NONE, phase: egui::TouchPhase::Move });+    h.run_steps(12);+    let after = plots();+    assert!(after.len() >= 2, "the part in view is drawn again: {} plots", after.len());+    let detail = after.last().unwrap();+    let crop: Vec<f64> = detail["crop"].as_array().expect("a crop").iter().filter_map(Value::as_f64).collect();+    assert_eq!(crop.len(), 4);+    assert!(crop[2] - crop[0] < 1.0 && crop[3] - crop[1] < 1.0, "a part of the sheet, not all of it: {crop:?}");+    assert!(detail["resolution"].as_f64().unwrap() > first[0]["resolution"].as_f64().unwrap(), "at a higher resolution than the whole sheet");++    // Zooming back out to the whole sheet needs no further drawing.+    let n = plots().len();+    h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -2000.0), modifiers: Modifiers::NONE, phase: egui::TouchPhase::Move });+    h.run_steps(12);+    assert_eq!(plots().len(), n, "the whole sheet is shown from its first image");+}diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Plot Preview shows plot styles and zooms and pans) · **Target:** Autodesk AutoCAD 2027  Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Plot Preview draws the sheet as it plots: the page setup's plot style table (colours, screening, lineweights, line ends and fills; before, only the Plot dialog's preview did, and its "None" now overrides the layout's table), and the window zooms (mouse wheel, + / −, Fit or double-click) and pans (drag); the part in view is drawn again at the screen's resolution once the view rests (PNG plots take `crop`, a part of the sheet as fractions) | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | | 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed | | 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed |