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
@@ -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 Controlla@@ -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: "));+ }+}@@ -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 |