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
@@ -724,6 +724,7 @@ dependencies = [ "cadcraft-engine", "cadcraft-fonts", "cadcraft-geom",+ "cadcraft-io", "cadcraft-render", "chrono", "craft-ui",@@ -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"));@@ -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();@@ -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"] } @@ -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, ¶ms, "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, ¶ms, "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, ¶ms));+ 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, ¶ms)).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, ¶ms)).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, ¶ms));+ 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}");+ } }@@ -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]@@ -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;new file mode 100644@@ -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");+}@@ -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 |