diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 924aed5ee..1182189f6 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1,4 +1,4 @@ -//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy), +//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy/saveas), //! MVIEW / VPORTS, viewport scale/lock/layer settings, PAGESETUP, PLOT and EXPORTPDF. //! //! Plotting goes through the host's [`super::file::IoHooks::plot`] hook so the engine stays @@ -22,7 +22,7 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("layout", "Layout", run_layout) .alias(&["lo"]) - .params("{option: new|copy|delete|rename|set|list, name?, to?} (typed LAYOUT / -LAYOUT: option prompts)") + .params("{option: new|copy|delete|rename|saveas|set|list, name?, to?, path? (saveas)} (typed LAYOUT / -LAYOUT: option prompts)") .interactive(|_| Ok(Box::new(prompts::LayoutM::default()))), CommandSpec::new("layout.new", "New Layout", run_new) .menu(&["Insert", "Layout", "New Layout"]) @@ -32,6 +32,9 @@ pub fn specs() -> Vec { CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"), CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"), CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(), + CommandSpec::new("layout.saveas", "Save Layout as Template", saveas::run_saveas) + .params("{name?: layout (default: the current layout, else the first tab), path: .dwt (or .dwg, .dxf; no extension adds .dwt)} → {name, path, bytes, notSaved?}") + .noundo(), CommandSpec::new("mview", "Viewports", run_mview) .alias(&["mv"]) .params( @@ -334,6 +337,9 @@ fn run_list(s: &mut Session, _p: &Value) -> Result { )) } +/// LAYOUT SAveas: a layout written as a drawing template. +mod saveas; + /// `-LAYOUT`-style dispatcher. fn run_layout(s: &mut Session, p: &Value) -> Result { let opt = str_param(p, "option").unwrap_or("list").to_ascii_lowercase(); @@ -362,8 +368,9 @@ fn run_layout(s: &mut Session, p: &Value) -> Result { let set = find_command("layout.set").ok_or_else(|| bad("layout", "layout.set is missing"))?; (set.run)(s, p) } + "saveas" | "sa" => saveas::run_saveas(s, p), "list" | "?" => run_list(s, p), - _ => Err(bad("layout", "option must be new, copy, delete, rename, set or list")), + _ => Err(bad("layout", "option must be new, copy, delete, rename, saveas, set or list")), } } diff --git a/crates/engine/src/cmd/layout/prompts.rs b/crates/engine/src/cmd/layout/prompts.rs index d25b40c86..0d0e20f9b 100644 --- a/crates/engine/src/cmd/layout/prompts.rs +++ b/crates/engine/src/cmd/layout/prompts.rs @@ -25,6 +25,8 @@ enum Stage { RenameFrom, RenameTo(String), Set, + SaveasLayout, + SaveasFile(String), List, } @@ -34,7 +36,7 @@ pub struct LayoutM { } /// The layout the name prompts default to: the current one, else the first tab. -fn current_layout(s: &Session) -> Option { +pub(super) fn current_layout(s: &Session) -> Option { if let Space::Paper(n) = s.layout_space() { return Some(n); } @@ -70,9 +72,10 @@ impl LayoutM { "New" => Stage::New, "Rename" => Stage::RenameFrom, "Set" => Stage::Set, + "SAveas" => Stage::SaveasLayout, "?" => Stage::List, other => { - // Template, SAveas: report and end, leaving the next input to the command line. + // Template: report and end, leaving the next input to the command line. s.echo(format!("LAYOUT {other} is not available yet.")); return Ok(Step::Done); } @@ -131,6 +134,8 @@ impl Interactive for LayoutM { let tab = s.layout_space().tab_name().to_string(); Prompt::new("Enter layout to make current", Accept::TEXT).default(tab) } + Stage::SaveasLayout => with_cur(Prompt::new("Enter layout to save to template", Accept::TEXT)), + Stage::SaveasFile(layout) => Prompt::new("Enter template file name", Accept::TEXT).default(super::saveas::default_path(s, layout)), Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"), } } @@ -199,6 +204,19 @@ impl Interactive for LayoutM { (set.run)(s, &json!({ "name": n })).map_err(retry)?; Ok(Step::Done) } + Stage::SaveasLayout => { + let n = need(name.or_else(|| current_layout(s)))?; + let n = super::layout_name("layout.saveas", s.doc()?, &n).map_err(retry)?; + self.stage = Stage::SaveasFile(n); + Ok(Step::Continue) + } + Stage::SaveasFile(layout) => { + let path = name.unwrap_or_else(|| super::saveas::default_path(s, &layout)); + let r = super::saveas::run_saveas(s, &json!({ "name": layout, "path": path })).map_err(retry)?; + let path = r.get("path").and_then(Value::as_str).unwrap_or_default(); + s.echo(format!("Layout \"{layout}\" saved to template \"{path}\".")); + Ok(Step::Done) + } Stage::List => { Self::list(s, name.as_deref().unwrap_or("*"))?; Ok(Step::Done) diff --git a/crates/engine/src/cmd/layout/saveas.rs b/crates/engine/src/cmd/layout/saveas.rs new file mode 100644 index 000000000..b6d3f6873 --- /dev/null +++ b/crates/engine/src/cmd/layout/saveas.rs @@ -0,0 +1,133 @@ +//! LAYOUT SAveas: a layout written to a drawing template file (`.dwt`, or a `.dwg` or `.dxf`). +//! +//! The template holds the layout's page setup and paper space objects, viewports included, and +//! no model space objects and no other layout. Named objects the layout does not use (blocks, +//! layers, linetypes, text, dimension, multileader, multiline and table styles, empty groups) +//! are left out, as PURGE All removes them; layers the layout's viewports freeze or recolor stay. +//! The open drawing is not changed. + +use std::collections::BTreeSet; + +use cadcraft_doc::{Common, Drawing, Entity, EntityKind, EntityStore, Point}; +use cadcraft_geom::Vec3; +use serde_json::{Value, json}; + +use super::super::file::{drops_unknown, io, not_saved, not_saved_message}; +use super::super::{bad, str_param}; +use super::layout_name; +use super::prompts::current_layout; +use crate::{Result, Session}; + +/// File types a layout template is written as; a path without an extension gets `.dwt`. +const TEMPLATE_TYPES: [&str; 3] = ["dwt", "dwg", "dxf"]; + +/// The most PURGE All passes run on a template; they stop at the first pass that purges nothing. +const MAX_PURGE_PASSES: usize = 64; + +/// The file a SAveas typed at the command line suggests: `.dwt` beside the drawing. +pub(super) fn default_path(s: &Session, layout: &str) -> String { + let file = format!("{layout}.dwt"); + match s.state().ok().and_then(|st| st.path.clone()) { + Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(), + None => file, + } +} + +/// `path` with `.dwt` added when it has no extension; an error for any other file type. +fn template_path(path: &str) -> Result { + let path = path.trim(); + if path.is_empty() { + return Err(bad("layout.saveas", "`path` is empty")); + } + match std::path::Path::new(path).extension() { + None => Ok(format!("{path}.dwt")), + Some(e) if TEMPLATE_TYPES.iter().any(|t| e.eq_ignore_ascii_case(t)) => Ok(path.to_string()), + Some(_) => Err(bad("layout.saveas", "a layout template is saved as a .dwt, .dwg or .dxf file")), + } +} + +/// The drawing a template of `layout` holds: `d` with that layout alone, an empty model space, +/// and without the named objects nothing left in it uses. +fn template_of(d: &Drawing, layout: &str) -> Result { + let mut t = d.clone(); + t.layouts.retain(|l| l.name == layout); + t.active_layout = Some(layout.to_string()); + t.model = EntityStore::new(); + t.hidden_objects.clear(); + let gone: Vec = t.constraints.iter().filter(|c| c.refs.iter().any(|r| t.entity(r.handle).is_none())).map(|c| c.id).collect(); + t.constraints.retain(|c| !gone.contains(&c.id)); + // Layers the layout's viewports freeze or recolor stay, with those settings. PURGE counts + // only objects, so each such layer gets a point in model space while PURGE runs. + let named: BTreeSet = t + .layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) => Some(v.frozen_layers.iter().cloned().chain(v.layer_colors.iter().map(|(n, _)| n.clone())).collect::>()), + _ => None, + }) + .flatten() + .collect(); + for layer in named { + let handle = t.new_handle(); + t.model.push(Entity { handle, common: Common { layer, ..Common::default() }, kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) }); + } + // PURGE runs on a session of its own, so the open drawing keeps everything. + let mut scratch = Session::empty(); + scratch.open_drawing(t, layout, None); + for _ in 0..MAX_PURGE_PASSES { + let r = super::super::blocks::purge::run(&mut scratch, &json!({ "type": "all" }))?; + let purged = r.get("purged").and_then(Value::as_object).is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty()))); + if !purged { + break; + } + } + let st = scratch.docs.pop().ok_or_else(|| bad("layout.saveas", "the template could not be made"))?; + let mut t = std::sync::Arc::unwrap_or_clone(st.doc); + t.model = EntityStore::new(); + Ok(t) +} + +/// `layout.saveas {name?, path}`: write a layout as a drawing template. +pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result { + let path = template_path(str_param(p, "path").ok_or_else(|| bad("layout.saveas", "`path` is required"))?)?; + if s.doc()?.refedit.is_some() { + return Err(bad("layout.saveas", "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving.")); + } + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => current_layout(s).ok_or_else(|| bad("layout.saveas", "the drawing has no layout"))?, + }; + // A file is the drawing outside the Block Editor. + let d = s.state()?.drawing(); + let name = layout_name("layout.saveas", d, &name)?; + let t = template_of(d, &name)?; + let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?; + let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?; + store(&path, &bytes)?; + let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() }); + let lost = not_saved(&t); + if drops_unknown(&path) + && let Some(msg) = not_saved_message(&lost) + { + s.echo(msg); + if let Some(o) = r.as_object_mut() { + o.insert("notSaved".into(), json!(lost)); + } + } + Ok(r) +} + +/// Write the template: natively to `path` through a temporary file; on the web as a download. +fn store(path: &str, bytes: &[u8]) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + { + // Exclusively create a fresh temporary file and rename it into place so a planted + // `.cadcraft-tmp` symlink cannot redirect the write (CWE-59). + crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}"))) + } + #[cfg(target_arch = "wasm32")] + { + super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver()) + } +} diff --git a/crates/engine/tests/it/layout_prompts.rs b/crates/engine/tests/it/layout_prompts.rs index 775230541..afa784e0b 100644 --- a/crates/engine/tests/it/layout_prompts.rs +++ b/crates/engine/tests/it/layout_prompts.rs @@ -39,7 +39,7 @@ fn typed_layout_options_prompt_for_names() { s.script("LAYOUT\n\nModel\n").unwrap(); assert_eq!(s.layout_space(), Space::Model); - // ? lists; Template and SAveas report and end without taking the next input. + // ? lists; Template reports and ends without taking the next input. let n = s.log.len(); s.cmdline("LAYOUT ? Sheet*").unwrap(); assert!(s.log[n..].iter().any(|l| l == "Layout: Sheet A (2)"), "{:?}", &s.log[n..]); diff --git a/crates/engine/tests/templates.rs b/crates/engine/tests/templates.rs index e85eb2242..c518e3b06 100644 --- a/crates/engine/tests/templates.rs +++ b/crates/engine/tests/templates.rs @@ -1,5 +1,5 @@ //! Drawing templates (#552): SAVEAS to a .dwt, NEW/QNEW from a template file, and the default -//! template (QNEWTEMPLATE). The io hooks here keep drawings in memory: a "file" holds the number +//! template (QNEWTEMPLATE), and a layout saved as a template (LAYOUT SAveas). The io hooks here keep drawings in memory: a "file" holds the number //! of the drawing written, so the test sees exactly what the engine hands the file formats. use std::sync::Mutex; @@ -105,3 +105,95 @@ fn a_template_that_cannot_be_read_makes_no_drawing() { assert!(s.execute("new", &json!({ "template": 42 })).is_err()); assert_eq!(s.docs.len(), docs); } + +/// The drawing the io hooks were last handed for a file whose name ends with `name`. +fn written(name: &str) -> Drawing { + FILES.lock().unwrap().iter().rev().find(|(n, _)| n.ends_with(name)).map(|f| f.1.clone()).unwrap() +} + +fn layout_names(d: &Drawing) -> Vec { + d.layouts.iter().map(|l| l.name.clone()).collect() +} + +/// LAYOUT SAveas: the template holds the layout alone, with the named objects it uses, and the +/// drawing it came from keeps everything. +#[test] +fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() { + hooks(); + let mut s = Session::new(); + for name in ["TITLE", "PARTS-LAYER", "UNUSED", "WALLS", "NOTES"] { + s.execute("layer.new", &json!({ "name": name })).unwrap(); + } + s.execute("layer.current", &json!({ "name": "PARTS-LAYER" })).unwrap(); + let c = s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap()["handle"].clone(); + s.execute("block", &json!({ "name": "PARTS", "base": [0, 0], "handles": [c], "keep": "retain" })).unwrap(); + // WALLS has model space objects only, and NOTES none; the sheet's viewport freezes one and recolors the other. + s.execute("layer.current", &json!({ "name": "WALLS" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [0, 10]] })).unwrap(); + let vp = s.execute("layout.new", &json!({ "name": "Sheet" })).unwrap()["viewport"].clone(); + s.execute("viewport.set", &json!({ "handle": vp, "freeze": ["WALLS"], "colors": { "NOTES": 1 } })).unwrap(); + s.execute("layout.set", &json!({ "name": "Sheet" })).unwrap(); + s.execute("layer.current", &json!({ "name": "TITLE" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("layer.current", &json!({ "name": "0" })).unwrap(); + let sheet_objects = s.doc().unwrap().layout("Sheet").unwrap().entities.iter().count(); + + let p = path("sheet.dwt"); + let r = s.execute("layout.saveas", &json!({ "path": p })).unwrap(); + assert_eq!(r["name"], json!("Sheet"), "the current layout by default"); + assert_eq!(r["path"], json!(p)); + let t = written("sheet.dwt"); + assert_eq!(layout_names(&t), ["Sheet"]); + assert_eq!(t.active_layout.as_deref(), Some("Sheet")); + assert_eq!(t.model.iter().count(), 0, "no model space objects"); + assert_eq!(t.layout("Sheet").unwrap().entities.iter().count(), sheet_objects, "the layout's viewport and line"); + assert!(t.layer("TITLE").is_some() && t.layer("0").is_some()); + assert!(t.layer("UNUSED").is_none() && t.layer("PARTS-LAYER").is_none(), "unused layers are left out"); + assert!(t.block("PARTS").is_none(), "unused blocks are left out"); + // Layers the viewport freezes or recolors stay, and so do those settings. + assert!(t.layer("WALLS").is_some() && t.layer("NOTES").is_some()); + let sheet_vp = t.layout("Sheet").unwrap().entities.iter().find_map(|e| match &e.kind { + cadcraft_engine::doc::EntityKind::Viewport(v) if v.id != 1 => Some(v.clone()), + _ => None, + }); + let sheet_vp = sheet_vp.unwrap(); + assert_eq!(sheet_vp.frozen_layers, ["WALLS"]); + assert_eq!(sheet_vp.layer_colors.len(), 1); + + // The drawing is not changed. + let d = s.doc().unwrap(); + assert_eq!(layout_names(d), ["Layout1", "Layout2", "Sheet"]); + assert_eq!(d.model.iter().count(), 2); + assert!(d.layer("UNUSED").is_some() && d.block("PARTS").is_some()); + + // A drawing started from the template has the layout. + s.execute("new", &json!({ "template": p })).unwrap(); + assert_eq!(layout_names(s.doc().unwrap()), ["Sheet"]); +} + +#[test] +fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() { + hooks(); + let mut s = Session::new(); + s.cmdline("LAYOUT").unwrap(); + s.cmdline("sa").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Enter layout to save to template :"); + s.cmdline("Layout2").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Enter template file name :"); + // No extension: the template is a .dwt. + let p = path("typed"); + s.cmdline(&p).unwrap(); + assert!(s.current_prompt().is_none()); + let saved = format!("{p}.dwt"); + assert!(s.log.iter().any(|l| *l == format!("Layout \"Layout2\" saved to template \"{saved}\".")), "{:?}", s.log); + assert_eq!(layout_names(&written("typed.dwt")), ["Layout2"]); + + // The JSON form of LAYOUT, other file types, a missing path and an unknown layout. + let dxf = path("sheet1.dxf"); + let r = s.execute("layout", &json!({ "option": "saveas", "name": "layout1", "path": dxf })).unwrap(); + assert_eq!(r["name"], json!("Layout1")); + assert_eq!(layout_names(&written("sheet1.dxf")), ["Layout1"]); + assert!(s.execute("layout.saveas", &json!({ "path": path("sheet.png") })).is_err()); + assert!(s.execute("layout.saveas", &json!({})).is_err()); + assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": path("nope.dwt") })).is_err()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6c852ba76..2bfea4a51 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LAYOUT SAveas writes a layout as a DWT template) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -108,7 +108,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | Format | AutoCAD 2027 | CADCraft | Hours to parity (est.) | |---|---|---|---| -| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552) | 0–1 | +| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552); LAYOUT SAveas writes one layout and the named objects it uses as a `.dwt` (`layout.saveas`) | 0–1 | | DWS standards | open, save, CHECKSTANDARDS | no | 6–10 | | PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571); object transparency as PDF alpha when "Plot transparency" is on (the option isn't saved in DXF/DWG: no public plot layout flag) | 1–2 | | PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 | @@ -140,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |