collective

Browse / github.com/storytold/cadcraft / patch

feat(plot): plot several sheets from the Plot dialog

feat(plot): plot several sheets from the Plot dialog ## What The engine already plots several sheets into one PDF (`plot` / `exportpdf` take `layouts: [names] | "all"`), but the Plot dialog could only plot the one layout picked in its drop-down. The dialog now has a **Plot several sheets** section: a checkbox per sheet (Model and every layout, in tab order), an **All layouts** button (every layout, not the Model tab) and **Only this one**. With one sheet ticked nothing changes. With several ticked, the page-setup rows are replaced by a short summary (one line per sheet: paper, orientation, plot style table) and **OK** writes **one PDF, one pag

Status
open
Score
0.00
Adopters
0 other operators
Author
lforgiarini (imported from pull request #2084, open)
Base
41d9b5e957b0719c04b9687c4f29088d77153c31
Size
+212 −37 in 3 files
Review
pending
Shared
2026-10-28
Id
6b8ee58f697732a2f8365484

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/managers.rs docs/gaps.md

diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv--- a/crates/ui-egui/src/i18n/it.tsv+++ b/crates/ui-egui/src/i18n/it.tsv@@ -81,6 +81,11 @@ menu	Modify	Edita 	Array	Serie 	Attach	Associa 	Attribute	Attributo+	All layouts	Tutti i layout+	Only this one	Solo questo+	Plot several sheets	Stampa più fogli+	{n} sheets in one PDF, each with its own page setup (change them in the Page Setup Manager):	{n} fogli in un solo PDF, ciascuno con la propria impostazione pagina (si modificano nel Gestore impostazioni pagina):+	{n} sheets	{n} fogli 	Attribute Definition	Definizione di attributo 	Attribute Display	Visualizzazione attributi 	Audit	Controlladiff --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@@ -808,6 +808,9 @@ struct PlotState {     generation: u32,     /// "Model" or a layout name.     target: String,+    /// The sheets OK plots, in tab order (`target` alone, until others are ticked). With several,+    /// they go into one PDF, each with its own page setup.+    sheets: Vec<String>,     page: cadcraft_doc::PageSetup,     /// Plot area "window" corners in drawing units: [x1, y1, x2, y2].     window: [f64; 4],@@ -835,6 +838,45 @@ fn plot_page(d: &cadcraft_doc::Drawing, target: &str) -> cadcraft_doc::PageSetup     page } +/// Tick or untick `name` among the sheets to plot. `targets` lists every sheet in tab order, which+/// is the order of the result. At least one sheet always stays.+fn toggle_sheet(sheets: &mut Vec<String>, targets: &[String], name: &str, on: bool) {+    let next: Vec<String> = targets.iter().filter(|t| if t.as_str() == name { on } else { sheets.contains(t) }).cloned().collect();+    if !next.is_empty() {+        *sheets = next;+    }+}++/// Drop the ticked sheets that no longer exist (`targets` lists those that do). With none left,+/// `target` is the one; with exactly one left, that one is the target, which the dialog then edits+/// and OK plots. Returns whether the target changed.+fn sync_sheets(sheets: &mut Vec<String>, target: &mut String, targets: &[String]) -> bool {+    sheets.retain(|n| targets.contains(n));+    if sheets.is_empty() {+        sheets.push(target.clone());+    }+    match sheets.as_slice() {+        [only] if only != target => {+            *target = only.clone();+            true+        }+        _ => false,+    }+}++/// The `plot` parameters for several sheets in one PDF: no device or page overrides, so each+/// sheet plots with its own page setup.+fn sheets_params(sheets: &[String]) -> Value {+    json!({ "layouts": sheets, "layout": sheets.first(), "device": "None" })+}++/// One line about a sheet's page setup for the summary shown with several sheets ticked.+fn sheet_summary(d: &cadcraft_doc::Drawing, name: &str) -> String {+    let page = plot_page(d, name);+    let orientation = if page.landscape { "landscape" } else { "portrait" };+    format!("{name}: {} · {orientation} · {}", page.paper, style_label(&page))+}+ /// The `plot` parameters for the dialog's settings (without the output path). `preview_dpi` /// asks for a PNG at that resolution instead of the device's output, on the same sheet. fn plot_params(st: &PlotState, preview_dpi: Option<f64>) -> Value {@@ -918,12 +960,17 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {         PlotState {             generation,             page: plot_page(d, &target),+            sheets: vec![target.clone()],             target,             window: [ext.min.x, ext.min.y, ext.max.x, ext.max.y],             center: true,             offset_mm: [0.0, 0.0],         }     });+    // Layouts deleted since the dialog was last open drop out of the ticked sheets.+    if sync_sheets(&mut st.sheets, &mut st.target, &targets) {+        st.page = plot_page(d, &st.target);+    }     let model = st.target == "Model";     let choices = PageChoices::new(ctx, d);     let offset_supported =@@ -938,52 +985,98 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {             ui.label("Layout");             egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| {                 for n in &targets {-                    if ui.selectable_label(*n == st.target, n).clicked() && *n != st.target {+                    if ui.selectable_label(*n == st.target, n).clicked() && (*n != st.target || st.sheets.len() > 1) {                         st.page = plot_page(d, n);                         st.target = n.clone();+                        st.sheets = vec![n.clone()];                     }                 }             });             ui.end_row();         });-        ui.separator();-        ui.horizontal_top(|ui| {-            egui::Grid::new("plot_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {-                let rows = if model {-                    PageRows { areas: MODEL_AREAS, scale: true, center: false }-                } else {-                    PageRows { areas: &["layout"], scale: false, center: false }-                };-                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);+        // Several sheets in one PDF: tick them here. Each plots with its own page setup.+        egui::CollapsingHeader::new("Plot several sheets").default_open(st.sheets.len() > 1).show(ui, |ui| {+            ui.horizontal(|ui| {+                if ui.small_button("All layouts").on_hover_text("Every layout, in tab order (not the Model tab)").clicked() {+                    st.sheets = targets.iter().filter(|n| *n != "Model").cloned().collect();+                    if let Some(first) = st.sheets.first().cloned() {+                        st.page = plot_page(d, &first);+                        st.target = first;+                    }                 }-                if model && st.page.plot_area == "window" {-                    for (i, label) in [(0, "First corner"), (2, "Opposite corner")] {-                        ui.label(label);-                        ui.horizontal(|ui| {-                            for (j, axis) in ["X", "Y"].iter().enumerate() {-                                ui.label(*axis);-                                let k = i + j;-                                if let Some(slot) = st.window.get_mut(k)-                                    && let Some(v) = num_field(ui, ("plot_window", k), *slot)-                                {-                                    *slot = v;-                                }-                            }-                        });-                        ui.end_row();+                if ui.small_button("Only this one").clicked() {+                    st.sheets = vec![st.target.clone()];+                }+            });+            egui::ScrollArea::vertical().id_salt("plot_sheets").max_height(120.0).auto_shrink([true, true]).show(ui, |ui| {+                for n in &targets {+                    let mut on = st.sheets.contains(n);+                    if ui.checkbox(&mut on, n).changed() {+                        toggle_sheet(&mut st.sheets, &targets, n, on);+                        // One sheet left: it is the one whose settings the dialog edits.+                        if let [only] = st.sheets.as_slice()+                            && *only != st.target+                        {+                            st.page = plot_page(d, only);+                            st.target = only.clone();+                        }                     }-                    ui.label("");-                    pick = ui.button("Window <").on_hover_text("Pick the window's corners in the drawing").clicked();-                    ui.end_row();                 }             });-            sheet_sketch(ui, &st.page, &t);         });+        let several = st.sheets.len() > 1;+        ui.separator();+        if several {+            ui.label(+                RichText::new(crate::tf!(+                    "{n} sheets in one PDF, each with its own page setup (change them in the Page Setup Manager):",+                    n = st.sheets.len()+                ))+                .color(t.text_dim),+            );+            for n in &st.sheets {+                ui.label(sheet_summary(d, n));+            }+        }+        if !several {+            ui.horizontal_top(|ui| {+                egui::Grid::new("plot_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {+                    let rows = if model {+                        PageRows { areas: MODEL_AREAS, scale: true, center: false }+                    } else {+                        PageRows { areas: &["layout"], scale: false, center: false }+                    };+                    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" {+                        for (i, label) in [(0, "First corner"), (2, "Opposite corner")] {+                            ui.label(label);+                            ui.horizontal(|ui| {+                                for (j, axis) in ["X", "Y"].iter().enumerate() {+                                    ui.label(*axis);+                                    let k = i + j;+                                    if let Some(slot) = st.window.get_mut(k)+                                        && let Some(v) = num_field(ui, ("plot_window", k), *slot)+                                    {+                                        *slot = v;+                                    }+                                }+                            });+                            ui.end_row();+                        }+                        ui.label("");+                        pick = ui.button("Window <").on_hover_text("Pick the window's corners in the drawing").clicked();+                        ui.end_row();+                    }+                });+                sheet_sketch(ui, &st.page, &t);+            });+        }         ui.separator();         ui.horizontal(|ui| {-            if !model && ui.button("Apply to Layout").on_hover_text("Save these settings in the layout's page setup").clicked() {+            if !model && !several && ui.button("Apply to Layout").on_hover_text("Save these settings in the layout's page setup").clicked() {                 let page = &st.page;                 action = Some((                     "pagesetup",@@ -1028,11 +1121,15 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {         return action;     }     if ok {-        let mut params = plot_params(&st, None);+        let several = st.sheets.len() > 1;+        let mut params = if several { sheets_params(&st.sheets) } else { plot_params(&st, None) };+        // Several sheets always make a PDF; one sheet's file type follows its device.+        let ext = if several { "pdf" } else { ext };+        let what = if several { crate::tf!("{n} sheets", n = st.sheets.len()) } else { st.target.clone() };         if let Some(pick) = app.services.pick_save.as_ref() {             let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();             let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());-            let suggested = format!("{}-{}.{ext}", stem.unwrap_or_else(|| "Drawing".into()), st.target);+            let suggested = format!("{}-{}.{ext}", stem.unwrap_or_else(|| "Drawing".into()), if several { "sheets" } else { st.target.as_str() });             // Cancelling the file dialog keeps the Plot dialog open with its settings.             let Some(path) = pick(&suggested) else { return action };             if let Some(o) = params.as_object_mut() {@@ -1043,8 +1140,8 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {         match app.run("plot", params) {             Ok(r) => {                 let msg = match r.get("path").and_then(Value::as_str) {-                    Some(path) => format!("Plotted {} to {path}", st.target),-                    None => format!("Plotted {} ({} bytes of PDF; there is no file dialog here)", st.target, r["bytes"]),+                    Some(path) => format!("Plotted {what} to {path}"),+                    None => format!("Plotted {what} ({} bytes of PDF; there is no file dialog here)", r["bytes"]),                 };                 app.session.echo(msg);             }@@ -1149,6 +1246,7 @@ mod plot_dialog_tests {         PlotState {             generation: 0,             page: plot_page(&d, target),+            sheets: vec![target.into()],             target: target.into(),             window: [0.0, 0.0, 10.0, 10.0],             center: true,@@ -1256,3 +1354,74 @@ mod plot_dialog_tests {         assert_eq!(temp::<PlotState>(&ctx, "plot_state").unwrap().window, [2.0, 3.0, 12.0, 9.0]);     } }++#[cfg(test)]+mod plot_sheets_tests {+    use super::*;++    /// Model and three layouts, in tab order.+    fn targets() -> Vec<String> {+        ["Model", "Layout1", "Layout2", "Sheet 3"].map(String::from).to_vec()+    }++    /// Sheets of deleted layouts drop out; one left becomes the target.+    #[test]+    fn deleted_sheets_drop_out_and_a_single_sheet_left_is_the_target() {+        let t = targets();+        let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();+        // Nothing deleted: nothing changes, whatever the target (it may have been unticked).+        let (mut sheets, mut target) = (names(&["Layout1", "Layout2"]), "Sheet 3".to_string());+        assert!(!sync_sheets(&mut sheets, &mut target, &t));+        assert_eq!((sheets.as_slice(), target.as_str()), (names(&["Layout1", "Layout2"]).as_slice(), "Sheet 3"));+        // One of three ticked layouts is deleted while the target was not among them: two are left.+        let (mut sheets, mut target) = (names(&["Layout1", "Gone", "Layout2"]), "Sheet 3".to_string());+        assert!(!sync_sheets(&mut sheets, &mut target, &t));+        assert_eq!(sheets, ["Layout1", "Layout2"]);+        // One left: it becomes the target, so the dialog's single sheet is what OK plots.+        let (mut sheets, mut target) = (names(&["Gone", "Layout2"]), "Layout1".to_string());+        assert!(sync_sheets(&mut sheets, &mut target, &t));+        assert_eq!((sheets, target.as_str()), (names(&["Layout2"]), "Layout2"));+        // None left: the target alone.+        let (mut sheets, mut target) = (names(&["Gone", "Also gone"]), "Layout1".to_string());+        assert!(!sync_sheets(&mut sheets, &mut target, &t));+        assert_eq!((sheets, target.as_str()), (names(&["Layout1"]), "Layout1"));+    }+    /// Ticking keeps tab order and leaves at least one sheet.+    #[test]+    fn ticking_sheets_keeps_tab_order_and_at_least_one() {+        let t = targets();+        let mut sheets = vec!["Layout2".to_string()];+        toggle_sheet(&mut sheets, &t, "Model", true);+        toggle_sheet(&mut sheets, &t, "Sheet 3", true);+        assert_eq!(sheets, ["Model", "Layout2", "Sheet 3"], "tab order, not the order they were ticked");+        toggle_sheet(&mut sheets, &t, "Layout2", false);+        assert_eq!(sheets, ["Model", "Sheet 3"]);+        toggle_sheet(&mut sheets, &t, "Model", false);+        toggle_sheet(&mut sheets, &t, "Sheet 3", false);+        assert_eq!(sheets, ["Sheet 3"], "the last sheet can't be unticked");+        toggle_sheet(&mut sheets, &t, "Nope", true);+        assert_eq!(sheets, ["Sheet 3"], "an unknown name changes nothing");+    }++    /// Several sheets plot with no page overrides, so each uses its own page setup.+    #[test]+    fn several_sheets_plot_with_their_own_page_setups_into_one_pdf() {+        let p = sheets_params(&["Layout1".to_string(), "Layout2".to_string()]);+        assert_eq!(p, json!({ "layouts": ["Layout1", "Layout2"], "layout": "Layout1", "device": "None" }));+        // No page overrides: each sheet keeps its paper, orientation, style table and lineweights.+        for k in ["paper", "width", "height", "landscape", "plotStyleTable", "lineweights", "plotTransparency", "fit"] {+            assert!(p.get(k).is_none(), "{k} would apply to every sheet");+        }+    }++    /// The summary names each sheet's paper and style table.+    #[test]+    fn the_summary_names_each_sheets_paper_and_style() {+        let mut app = crate::CadApp::new(cadcraft_engine::Session::default(), crate::Services::default());+        app.run("pagesetup", json!({ "layout": "Layout1", "paper": "A3", "plotStyleTable": "monochrome.ctb" })).unwrap();+        let d = app.session.doc().unwrap();+        let line = sheet_summary(d, "Layout1");+        assert!(line.starts_with("Layout1: ") && line.contains("A3") && line.ends_with("monochrome.ctb"), "{line}");+        assert!(sheet_summary(d, "Model").starts_with("Model: "));+    }+}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 (The Plot dialog plots several sheets into one PDF) · **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 | The Plot dialog has "Plot several sheets": the ticked layouts (or all of them) go into one PDF, one page each, every sheet with its own page setup (the engine's `layouts` list) | | 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 |