collective

Browse / github.com/storytold/cadcraft / patch

Save As dialog: file type, version and binary DXF before the file picker

Save As dialog: file type, version and binary DXF before the file picker ### Before The host's file picker (rfd) can't report which file type the user picked. Save As therefore saved in File ▸ Default Save Format's format and version, and a drawing could not be saved as, say, DWG 2004 once, for a colleague, from the dialog. ### After Save As (menu, tool bar, Cmd+Shift+S or typed SAVEAS) opens a small **Save As dialog before the picker**: - **File type:** Drawing (DXF), Drawing (DWG), Drawing Template (DWT), SVG, PNG, PDF. - **Version:** AutoCAD 2000 … 2018, for DXF and DWG. It starts from the drawing's own version, or from the default save format and version for a

Status
open
Score
0.00
Adopters
0 other operators
Author
KvasFan (imported from pull request #2172, open)
Base
dfddd13656bde4ed7f6c579d85e34874650bc31a
Size
+419 −9 in 11 files
Review
pending
Shared
2026-10-28
Id
8ff00df547138591abbb0dec

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/saveas.rs crates/ui-egui/src/saveas/tests.rs crates/ui-egui/tests/it/dialog_dispatch.rs crates/ui-egui/tests/save_version.rs docs/gaps.md

diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -8,6 +8,8 @@ use serde_json::json; use crate::CadApp; use crate::theme::Tokens; +/// Draw the open dialog (`app.ui.dialog`), the MTEXT editor and Quick Properties for this frame; a+/// dialog closed by its own button is cleared. pub fn show(app: &mut CadApp, ctx: &egui::Context) {     crate::mtext_editor::show(app, ctx);     crate::quick::quick_properties(app, ctx);@@ -21,6 +23,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {         "parameters" => crate::parametric::parameters_dialog(app, ctx, &mut open),         "dsettings" => dsettings(app, ctx, &mut open),         "units" => units(app, ctx, &mut open),+        "saveas" => crate::saveas::dialog(app, ctx, &mut open),         "hatch" => crate::hatchdlg::dialog(app, ctx, &mut open),         "aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open),         "about" => about(ctx, &mut open),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@@ -1365,3 +1365,13 @@ menu	Modify	Edita 	Check for Updates…	Verifica aggiornamenti… 	CADCraft is up to date.	CADCraft è aggiornato. 	Could not check for updates.	Impossibile verificare gli aggiornamenti.+# Save As dialog: file type, version, binary DXF.+	Save As (file picker)	Salva con nome (selezione file)+	File type	Tipo di file+	Save...	Salva...+	Binary DXF	DXF binario+	An export of the drawing as shown: the drawing keeps its own file.	Un'esportazione del disegno com'è mostrato: il disegno mantiene il proprio file.+	Units of new drawings	Unità dei nuovi disegni+	Imperial (inches)	Imperiali (pollici)+	Metric (millimetres)	Metriche (millimetri)+	New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE).	Nuovo e il primo disegno all'avvio usano queste unità, a meno che non sia impostato un modello predefinito (QNEWTEMPLATE).diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs--- a/crates/ui-egui/src/i18n/tests.rs+++ b/crates/ui-egui/src/i18n/tests.rs@@ -59,6 +59,8 @@ fn translation_gaps() {     } } +/// The command labels, menu names, tool names and snap mode names that `language`'s catalog has no+/// row for. fn missing_commands_menus_tools_and_snap_labels(language: &str) -> Vec<String> {     let catalog = catalog(language).unwrap();     let mut sources = BTreeSet::new();@@ -81,6 +83,7 @@ fn missing_commands_menus_tools_and_snap_labels(language: &str) -> Vec<String> {     sources.into_iter().filter(|s| !s.is_empty() && !catalog.contains_key(&("", *s))).map(str::to_string).collect() } +/// The `tl!`/`tf!` literals of the listed UI source files that `language`'s catalog has no row for. fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {     let catalog = catalog(language).unwrap();     let sources = [@@ -92,6 +95,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {         include_str!("../palettes/quickcalc.rs"),         include_str!("../palettes/patterns.rs"),         include_str!("../dialogs.rs"),+        include_str!("../saveas.rs"),         include_str!("../hatchdlg.rs"),         include_str!("../aliasdlg.rs"),         include_str!("../mtext_editor.rs"),diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv--- a/crates/ui-egui/src/i18n/uk.tsv+++ b/crates/ui-egui/src/i18n/uk.tsv@@ -1371,3 +1371,9 @@ 	Check for Updates…	Перевірити оновлення… 	CADCraft is up to date.	Встановлено найновішу версію CADCraft. 	Could not check for updates.	Не вдалося перевірити оновлення.+# Save As dialog: file type, version, binary DXF.+	Save As (file picker)	Зберегти як (вибір файлу)+	File type	Тип файлу+	Save...	Зберегти...+	Binary DXF	Двійковий DXF+	An export of the drawing as shown: the drawing keeps its own file.	Експорт креслення як воно показане: креслення зберігає власний файл.diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -46,6 +46,7 @@ pub mod palettes; pub mod parametric; pub mod plotstyles; pub mod quick;+pub mod saveas; pub mod shortcut_menus; pub mod theme; #[cfg(not(target_arch = "wasm32"))]@@ -159,6 +160,7 @@ impl SaveFormat { }  impl Default for UiState {+    /// The interface as a first start shows it: every panel and bar on, the default save format and version.     fn default() -> Self {         UiState {             docking: docking::default_workspace(),@@ -223,6 +225,8 @@ pub struct CadApp {     pub status: Option<(String, f64)>,     /// Drawings being read on worker threads ([`opening`]).     pub opening: opening::Opening,+    /// The Save As dialog's choices while it is open ([`saveas`]).+    pub saveas: Option<saveas::SaveAsState>,     pub integrated_titlebar: bool,     control_rx: Option<Receiver<ControlRequest>>,     /// Fresh surface and identity snapshot (see [`capture`]).@@ -296,6 +300,7 @@ impl CadApp {             cmd: cmdline::CmdLine::default(),             status: None,             opening: opening::Opening::default(),+            saveas: None,             integrated_titlebar: false,             control_rx: None,             capture_geometry: None,diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -23,6 +23,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[     ("ui.open", "Open...", &[], Some("Cmd+O")),     ("ui.newtemplate", "New from Template...", &["File", "New from Template..."], None),     ("ui.saveas", "Save As...", &[], None),+    ("ui.saveas.pick", "Save As (file picker)", &[], None),     ("ui.sample", "Open Sample Drawing", &["Help", "Open Sample Drawing"], None),     ("ui.toggle.toolsets", "Tool Sets", &["Window", "Tool Sets"], Some("Cmd+3")),     ("ui.panel.properties", "Properties Inspector", &["Window", "Properties Inspector"], Some("Cmd+1")),@@ -85,6 +86,8 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option<Resu     crate::i18n::with_language(app.language(), || run_ui_command_inner(app, id, params)) } +/// [`run_ui_command`] in the interface language: the UI-only commands, and the file commands that+/// ask for a file (Open, Save As and its dialog, Export…) when they come without parameters. fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {     if let Some(result) = crate::spacemouse::route(app, id, params) {         return Some(result);@@ -197,15 +200,25 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re             }             Ok(Value::Null)         }+        // Save As from the menu, the tool bar, a shortcut or typed: the Save As dialog (file type,+        // version, binary DXF) before the host's file picker.         "ui.saveas" | "saveas" if no_path => {+            crate::saveas::open(app);+            Ok(Value::Null)+        }+        // The picker alone, in the default format and version: QSAVE of a drawing with no file+        // yet, and the close prompt's Save, which must finish in this call.+        "ui.saveas.pick" if no_path => {             let title = app.session.state().map(|s| s.title.clone()).ok();             let name = app.ui.save_format.suggested_name(title.as_deref());             if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name)) {+                // The default format is the format written, whatever extension the picker gave.+                let p = crate::saveas::with_extension(&p, app.ui.save_format.as_str());                 return Some(app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string()));             }             Ok(Value::Null)         }-        "qsave" if no_path && app.session.state().is_ok_and(|s| s.path.is_none()) => return run_ui_command(app, "ui.saveas", &Value::Null),+        "qsave" if no_path && app.session.state().is_ok_and(|s| s.path.is_none()) => return run_ui_command(app, "ui.saveas.pick", &Value::Null),         // EXPORTPDF from the menu or typed: ask where to save the PDF (PLOT and PRINT open the Plot         // dialog, `managers::route`). JSON calls (scripts, control channel, MCP) always carry         // params, never get here and never open a dialog.diff --git a/crates/ui-egui/src/saveas.rs b/crates/ui-egui/src/saveas.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/saveas.rs@@ -0,0 +1,225 @@+//! The Save As dialog: the file type, the DXF/DWG version and binary DXF, chosen before the+//! host's file picker, which cannot report the type the user picked. "Save..." opens the picker+//! for the chosen type and runs `saveas` (images: an export, as SAVEAS to an image does).+//!+//! QSAVE of a drawing that has no file yet skips this dialog and goes straight to the picker+//! (`ui.saveas.pick`): the close prompt's "Save" relies on that save finishing in the same call.++use egui::{RichText, vec2};+use serde_json::{Value, json};++use crate::theme::Tokens;+use crate::{CadApp, SaveFormat, SaveVersion};++/// A file type the dialog saves as.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum FileType {+    Dxf,+    Dwg,+    Dwt,+    Svg,+    Png,+    Pdf,+}++impl FileType {+    pub const ALL: [FileType; 6] = [FileType::Dxf, FileType::Dwg, FileType::Dwt, FileType::Svg, FileType::Png, FileType::Pdf];++    /// The file extension, without the dot.+    pub fn ext(self) -> &'static str {+        match self {+            FileType::Dxf => "dxf",+            FileType::Dwg => "dwg",+            FileType::Dwt => "dwt",+            FileType::Svg => "svg",+            FileType::Png => "png",+            FileType::Pdf => "pdf",+        }+    }++    /// The name the dialog shows (translated; the format names stay as they are).+    pub fn label(self) -> &'static str {+        match self {+            FileType::Dxf => crate::tl!("Drawing (DXF)"),+            FileType::Dwg => crate::tl!("Drawing (DWG)"),+            FileType::Dwt => crate::tl!("Drawing Template (DWT)"),+            FileType::Svg => "SVG",+            FileType::Png => "PNG",+            FileType::Pdf => "PDF",+        }+    }++    /// The type of an extension.+    pub fn from_ext(ext: &str) -> Option<FileType> {+        let ext = ext.trim().trim_start_matches('.');+        FileType::ALL.into_iter().find(|t| t.ext().eq_ignore_ascii_case(ext))+    }++    /// A drawing file whose version the dialog asks for (DXF, DWG).+    fn versioned(self) -> bool {+        matches!(self, FileType::Dxf | FileType::Dwg)+    }+}++/// The dialog's choices until Save.+#[derive(Clone, Debug, PartialEq)]+pub struct SaveAsState {+    pub file_type: FileType,+    pub version: SaveVersion,+    pub binary: bool,+}++impl SaveAsState {+    /// The choices a drawing starts from: its own file type and version (an untitled drawing:+    /// the default save format and version) and its binary flag.+    pub fn for_app(app: &CadApp) -> SaveAsState {+        let st = app.session.state().ok();+        let untitled = st.as_ref().is_none_or(|s| s.path.is_none());+        let title = st.as_ref().map(|s| s.title.clone()).unwrap_or_default();+        let from_title = std::path::Path::new(&title).extension().and_then(|e| FileType::from_ext(&e.to_string_lossy()));+        let default_type = match app.ui.save_format {+            SaveFormat::Dxf => FileType::Dxf,+            SaveFormat::Dwg => FileType::Dwg,+        };+        // A drawing from a file keeps its type (a template stays a template); an untitled one,+        // or one from an image, takes the default save format.+        let file_type = from_title.filter(|t| t.versioned() || (*t == FileType::Dwt && !untitled)).unwrap_or(default_type);+        let own =+            app.session.doc().ok().and_then(|d| d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(SaveVersion::for_acadver));+        let version = match (untitled, own) {+            (false, Some(v)) => v,+            _ => SaveVersion::parse(&app.ui.save_version).unwrap_or_default(),+        };+        SaveAsState { file_type, version, binary: st.as_ref().is_some_and(|s| s.binary_dxf) && file_type == FileType::Dxf }+    }++    /// The `saveas` parameters for `path`.+    pub fn params(&self, path: &str) -> Value {+        let mut p = json!({ "path": path });+        if self.file_type.versioned()+            && let Some(o) = p.as_object_mut()+        {+            o.insert("version".into(), json!(self.version.year()));+            if self.binary && self.file_type == FileType::Dxf {+                o.insert("binary".into(), json!(true));+            }+        }+        p+    }++    /// The file name the picker suggests: the drawing's name with the chosen extension.+    pub fn suggested_name(&self, title: &str) -> String {+        let stem = std::path::Path::new(title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.trim().is_empty());+        format!("{}.{}", stem.unwrap_or_else(|| "Drawing".into()), self.file_type.ext())+    }++    /// Binary is for DXF only.+    fn settle(&mut self) {+        if self.file_type != FileType::Dxf {+            self.binary = false;+        }+    }+}++/// Open the dialog for the active drawing.+pub fn open(app: &mut CadApp) {+    app.saveas = Some(SaveAsState::for_app(app));+    app.ui.dialog = Some("saveas".into());+}++/// `path` with the chosen type's extension: kept when the picker gave it (any case), added+/// otherwise (`plan.dwg` saved as DXF is `plan.dwg.dxf`), so the type chosen is the type written.+pub(crate) fn with_extension(path: &str, ext: &str) -> String {+    let has = std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case(ext));+    if has { path.to_string() } else { format!("{path}.{ext}") }+}++/// Ask the host where to save and run `saveas` with the dialog's choices. `Some(true)` once+/// saved, `Some(false)` when the picker was cancelled or the save failed (the dialog stays),+/// `None` without a picker (said on the command line).+pub fn save(app: &mut CadApp) -> Option<bool> {+    let st = app.saveas.clone().unwrap_or_else(|| SaveAsState::for_app(app));+    let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();+    let Some(pick) = app.services.pick_save.as_ref() else {+        app.session+            .echo(r#"SAVEAS: there is no file dialog here; run it with parameters, e.g. saveas {"path": "plan.dxf", "version": 2018}"#.to_string());+        return None;+    };+    let Some(path) = pick(&st.suggested_name(&title)) else { return Some(false) };+    Some(app.run("saveas", st.params(&with_extension(&path, st.file_type.ext()))).is_ok())+}++/// A dimmed, wrapping note.+fn note(ui: &mut egui::Ui, text: &str) {+    ui.add(egui::Label::new(RichText::new(text).color(Tokens::get().text_dim)).wrap());+}++/// The Save As window: file type, version and binary DXF, then Save… (the picker) or Cancel. The+/// choices live in `app.saveas` until the window closes.+pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {+    if app.saveas.is_none() {+        app.saveas = Some(SaveAsState::for_app(app));+    }+    let Some(mut st) = app.saveas.clone() else { return };+    let (mut save_clicked, mut cancel) = (false, false);+    egui::Window::new(crate::tl!("Save As"))+        .id(egui::Id::unique("saveas_dialog"))+        .open(open)+        .collapsible(false)+        .resizable(false)+        .pivot(egui::Align2::CENTER_CENTER)+        .default_pos(ctx.content_rect().center())+        .show(ctx, |ui| {+            ui.set_max_width(440.0);+            egui::Grid::new("saveas_grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {+                ui.label(crate::tl!("File type"));+                egui::ComboBox::from_id_salt("saveas_type").selected_text(st.file_type.label()).width(260.0).show_ui(ui, |ui| {+                    for ft in FileType::ALL {+                        ui.selectable_value(&mut st.file_type, ft, ft.label());+                    }+                });+                ui.end_row();+                if st.file_type.versioned() {+                    ui.label(crate::tl!("Version"));+                    egui::ComboBox::from_id_salt("saveas_version").selected_text(version_label(st.version)).width(260.0).show_ui(ui, |ui| {+                        for v in SaveVersion::ALL {+                            ui.selectable_value(&mut st.version, v, version_label(v));+                        }+                    });+                    ui.end_row();+                }+                if st.file_type == FileType::Dxf {+                    ui.label("");+                    ui.checkbox(&mut st.binary, crate::tl!("Binary DXF"));+                    ui.end_row();+                }+            });+            st.settle();+            if !st.file_type.versioned() && st.file_type != FileType::Dwt {+                note(ui, crate::tl!("An export of the drawing as shown: the drawing keeps its own file."));+            }+            ui.separator();+            ui.horizontal(|ui| {+                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+                    save_clicked = ui.button(crate::tl!("Save...")).clicked();+                    cancel = ui.button(crate::tl!("Cancel")).clicked();+                });+            });+        });+    app.saveas = Some(st);+    // Closed once saved, or when there is no picker to ask (the command line says how to save).+    if cancel || (save_clicked && save(app) != Some(false)) {+        *open = false;+    }+    if !*open {+        app.saveas = None;+    }+}++/// A version's label: "AutoCAD 2018".+pub fn version_label(v: SaveVersion) -> String {+    format!("AutoCAD {}", v.year())+}++#[cfg(test)]+mod tests;diff --git a/crates/ui-egui/src/saveas/tests.rs b/crates/ui-egui/src/saveas/tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/saveas/tests.rs@@ -0,0 +1,119 @@+use std::cell::RefCell;+use std::rc::Rc;++use cadcraft_engine::Session;+use serde_json::json;++use super::*;+use crate::Services;++/// An app whose file picker answers `answer` and records every name it was offered.+fn app_with_picker(answer: Option<String>) -> (CadApp, Rc<RefCell<Vec<String>>>) {+    let asked = Rc::new(RefCell::new(Vec::new()));+    let log = Rc::clone(&asked);+    let services = Services {+        pick_open: None,+        pick_save: Some(Box::new(move |name: &str| {+            log.borrow_mut().push(name.to_string());+            answer.clone()+        })),+        ..Services::default()+    };+    (CadApp::new(Session::new(), services), asked)+}++/// Save As opens the dialog, not the picker, on the drawing's own type and version (a template+/// stays a template), and its choices become the `saveas` parameters.+#[test]+fn save_as_opens_the_dialog_with_the_drawings_own_type_and_version() {+    let (mut app, asked) = app_with_picker(Some("x.dxf".into()));+    app.start("ui.saveas");+    assert_eq!(app.ui.dialog.as_deref(), Some("saveas"));+    assert!(asked.borrow().is_empty(), "the picker waits for Save...");+    let st = app.saveas.clone().unwrap();+    assert_eq!((st.file_type, st.version, st.binary), (FileType::Dxf, SaveVersion::R2018, false));+    // A drawing opened as a 2000 DWG.+    let mut d = cadcraft_doc::Drawing::new_metric();+    d.header.set_str("ACADVER", "AC1015");+    app.session.open_drawing(d, "plan.dwg", Some("/tmp/plan.dwg".into()));+    open(&mut app);+    let st = app.saveas.clone().unwrap();+    assert_eq!((st.file_type, st.version), (FileType::Dwg, SaveVersion::R2000));+    assert_eq!(st.suggested_name("plan.dwg"), "plan.dwg");+    assert_eq!(st.params("/tmp/plan.dwg"), json!({ "path": "/tmp/plan.dwg", "version": "2000" }));+    // A template from a file stays a template.+    app.session.open_drawing(cadcraft_doc::Drawing::new_metric(), "base.dwt", Some("/tmp/base.dwt".into()));+    open(&mut app);+    assert_eq!(app.saveas.as_ref().map(|s| s.file_type), Some(FileType::Dwt));+}++/// Choices that don't go together settle (binary is for DXF), images and templates carry no+/// version, and the chosen extension is always the one written.+#[test]+fn choices_settle_and_become_saveas_parameters() {+    let mut st = SaveAsState { file_type: FileType::Dwg, version: SaveVersion::R2004, binary: true };+    st.settle();+    assert!(!st.binary, "binary is for DXF");+    assert_eq!(st.params("a.dwg"), json!({ "path": "a.dwg", "version": "2004" }));+    // Images and templates carry no version.+    let st = SaveAsState { file_type: FileType::Pdf, version: SaveVersion::R2018, binary: true };+    assert_eq!(st.params("a.pdf"), json!({ "path": "a.pdf" }));+    let st = SaveAsState { file_type: FileType::Dwt, ..st };+    assert_eq!(st.params("a.dwt"), json!({ "path": "a.dwt" }));+    let st = SaveAsState { file_type: FileType::Dxf, version: SaveVersion::R2004, binary: true };+    assert_eq!(st.params("a.dxf"), json!({ "path": "a.dxf", "version": "2004", "binary": true }));+    assert_eq!(st.suggested_name("Plan.dwg"), "Plan.dxf");+    assert_eq!(st.suggested_name(""), "Drawing.dxf");+    assert_eq!(FileType::from_ext(".DWG"), Some(FileType::Dwg));+    assert_eq!(with_extension("/tmp/plan", "dxf"), "/tmp/plan.dxf");+    assert_eq!(with_extension("/tmp/plan.DXF", "dxf"), "/tmp/plan.DXF");+    assert_eq!(with_extension("/tmp/plan.dwg", "dxf"), "/tmp/plan.dwg.dxf", "the chosen type is the type written");+    assert!(SaveVersion::ALL.iter().all(|v| version_label(*v).starts_with("AutoCAD ")));+}++/// Save… offers the picker a name with the chosen extension and keeps the dialog when it is+/// cancelled; without a picker the command line says how to save.+#[test]+fn save_asks_the_picker_with_the_chosen_type_and_keeps_the_dialog_when_cancelled() {+    let (mut app, asked) = app_with_picker(None);+    open(&mut app);+    if let Some(st) = app.saveas.as_mut() {+        st.file_type = FileType::Svg;+    }+    assert_eq!(save(&mut app), Some(false));+    assert_eq!(*asked.borrow(), vec!["Drawing1.svg".to_string()]);+    // Without a picker the command line says how to save.+    let mut app = CadApp::new(Session::new(), Services::default());+    assert_eq!(save(&mut app), None);+    assert!(app.session.log.iter().any(|l| l.contains("no file dialog")));+}++/// QSAVE of a drawing with no file skips the dialog: the close prompt's Save must finish at once.+#[test]+fn qsave_of_an_untitled_drawing_goes_straight_to_the_picker() {+    let (mut app, asked) = app_with_picker(None);+    app.start("qsave");+    assert_eq!(*asked.borrow(), vec!["Drawing1.dxf".to_string()]);+    assert!(app.ui.dialog.is_none());+}++/// The dialog draws for every file type and keeps the choice across frames.+#[test]+fn the_dialog_renders_its_choices() {+    let (mut app, _) = app_with_picker(None);+    for ft in FileType::ALL {+        open(&mut app);+        if let Some(st) = app.saveas.as_mut() {+            st.file_type = ft;+        }+        let ctx = egui::Context::default();+        let input =+            egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };+        for _ in 0..2 {+            let mut output = ctx.run_ui(input.clone(), |ui| app.ui(ui));+            output.textures_delta.clear();+        }+        assert_eq!(app.ui.dialog.as_deref(), Some("saveas"), "{ft:?}");+        assert_eq!(app.saveas.as_ref().map(|s| s.file_type), Some(ft));+    }+}diff --git a/crates/ui-egui/tests/it/dialog_dispatch.rs b/crates/ui-egui/tests/it/dialog_dispatch.rs--- a/crates/ui-egui/tests/it/dialog_dispatch.rs+++ b/crates/ui-egui/tests/it/dialog_dispatch.rs@@ -39,6 +39,7 @@ fn drafting_settings_menu_opens_the_dialog() {     assert_eq!(app.session.settings.gridmajor, 10); } +/// The Properties menu item shows the Properties palette. #[test] fn properties_menu_shows_the_palette() {     let (mut app, _) = app();@@ -49,6 +50,8 @@ fn properties_menu_shows_the_palette() {     assert!(app.ui.show_palettes); } +/// File commands with JSON parameters never open a picker; typed or from a menu they ask, Save As+/// with its dialog first and QSAVE of an untitled drawing with the picker straight away. #[test] fn json_file_commands_never_open_pickers() {     let (mut app, picks) = app();@@ -57,14 +60,18 @@ fn json_file_commands_never_open_pickers() {     }     app.cmdline("open {}");     assert_eq!(picks.get(), 0);-    // Typed, chosen from the menu or the ui.* shortcut commands: the pickers.+    // Typed, chosen from the menu or the ui.* shortcut commands: the pickers — Save As first+    // its dialog (file type, version), QSAVE of an untitled drawing the picker straight away.     for c in ["open", "saveas", "qsave"] {         app.start(c);     }-    assert_eq!(picks.get(), 3);+    assert_eq!(picks.get(), 2);+    assert_eq!(app.ui.dialog.as_deref(), Some("saveas"));+    app.ui.dialog = None;     app.run("ui.open", json!({})).unwrap();     app.run("ui.saveas", json!({})).unwrap();-    assert_eq!(picks.get(), 5);+    assert_eq!(picks.get(), 3);+    assert_eq!(app.ui.dialog.as_deref(), Some("saveas")); }  #[test]diff --git a/crates/ui-egui/tests/save_version.rs b/crates/ui-egui/tests/save_version.rs--- a/crates/ui-egui/tests/save_version.rs+++ b/crates/ui-egui/tests/save_version.rs@@ -11,10 +11,13 @@ fn picked() -> std::path::PathBuf {     std::env::temp_dir().join(format!("cadcraft-ui-save-version-{}.dxf", std::process::id())) } +/// A writer that records the version and binary flag it was asked for instead of a drawing. fn with_options(_: &Drawing, _: &str, o: &SaveAs) -> Result<Vec<u8>, String> {     Ok(format!("{}|{}", o.version.map(|v| v.year()).unwrap_or("-"), o.binary).into_bytes()) } +/// The default save version is offered to untitled drawings by the Save As dialog, written by its+/// Save… and by an untitled QSAVE (in the default format), and kept in the preferences. #[test] fn default_save_version_applies_to_new_drawings_and_persists() {     set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"plain".to_vec()), plot: None });@@ -25,14 +28,28 @@ fn default_save_version_applies_to_new_drawings_and_persists() {     assert!(app.run("ui.saveversion", json!({ "version": "2005" })).is_err());     assert_eq!(app.run("ui.saveversion", json!({ "version": 2010 })).ok(), Some(json!({ "saveVersion": "2010" }))); -    // The Save dialog saves an untitled drawing in the default version …+    // The Save As dialog offers an untitled drawing's default version, and its Save... runs the+    // picker and saves in it …     app.run("ui.saveas", Value::Null).unwrap();+    assert_eq!(app.ui.dialog.as_deref(), Some("saveas"));+    assert_eq!(cadcraft_ui_egui::saveas::save(&mut app), Some(true));     assert_eq!(std::fs::read(picked()).unwrap(), b"2010|false");-    // … and a drawing that has a file in its own.+    // … and a drawing that has a file starts from its own version.     app.run("ui.saveas", Value::Null).unwrap();-    assert_eq!(std::fs::read(picked()).unwrap(), b"plain");+    assert_eq!(cadcraft_ui_egui::saveas::save(&mut app), Some(true));+    assert_eq!(std::fs::read(picked()).unwrap(), b"2010|false");     let _ = std::fs::remove_file(picked()); +    // QSAVE of an untitled drawing (the picker alone): a name picked with another extension+    // gets the default format's, so DXF is what is written.+    let dwg = std::env::temp_dir().join(format!("cadcraft-ui-save-version-{}.dwg", std::process::id()));+    let services = Services { pick_save: Some(Box::new(move |_: &str| Some(dwg.to_string_lossy().to_string()))), ..Services::default() };+    let mut untitled = CadApp::new(Session::new(), services);+    untitled.start("qsave");+    let written = std::env::temp_dir().join(format!("cadcraft-ui-save-version-{}.dwg.dxf", std::process::id()));+    assert!(written.exists(), "saved as {written:?}");+    let _ = std::fs::remove_file(&written);+     // Menu items, preferences (bad values ignored) and resetting the palettes.     app.start("ui.saveversion.2004");     assert_eq!(app.ui.save_version, "2004");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-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (the Save As dialog picks the version per file: FF3 updated) · **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@@ -75,7 +75,7 @@ Detail: [file-format-parity.md](file-format-parity.md). |---|---|---|---| | FF1 | **[alpha blocker]** Round-trip what we don't model. DXF done (#528): unknown entities and objects (with their CLASS records), foreign xdata (hyperlinks included), extension dictionaries, persistent reactors, DICTIONARYVAR and XRECORDs, layer states, colour book colours, references remapped on save; IMAGE, VIEW/UCS, groups, block descriptions, true colours, transparency (#201, #204, #208, #209, #213, #223); WIPEOUT and gradients (#147, #162). Left: through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour book names and reactors; UCS elevation; MTEXT background colours; unknown entities follow only layer edits, and renamed layers and blocks lose their foreign data | High | 6–10 | | FF2 | Multileader leftovers (native MULTILEADER read/write landed, #459): several landings per multileader (extra leader roots are folded into the lines), per-object overrides (leader colour and linetype, arrowhead symbol, text frame), annotative scale contexts; check the text placement of our MULTILEADER output in AutoCAD | Medium | 3–5 |-| FF3 | Save-version leftovers (DXF/DWG 2000–2018, binary DXF, SAVEAS `version`/`binary` and the default save version landed, #421): no R14 DWG or R12 DXF output; the Save dialog can't pick the version per file (the file picker doesn't report the chosen type; File > Default Save Format sets it); no command-line SAVEAS format prompt | Low | 2–4 |+| FF3 | Save-version leftovers (DXF/DWG 2000–2018, binary DXF, SAVEAS `version`/`binary` and the default save version landed, #421; the Save As dialog picks the file type, version and binary DXF per file before the picker): no R14 DWG or R12 DXF output; no command-line SAVEAS format prompt | Low | 1–3 | | FF4 | Code-page leftovers (pre-2007 DXF read in their `$DWGCODEPAGE` landed, #505): Johab (ANSI_1361) and DOS 855/857/860–865/869 read as ANSI_1252; saving declares `ANSI_1252` instead of the file's code page (the text is pure ASCII, so still correct); acadrust approximates DOS code pages in pre-2007 DWG (DOS437 as 866, DOS850 as 1252) | Low | 1–2 | | FF5 | **[alpha blocker]** Real-file corpus (`storytold/cadcraft-corpus`), open → save → reopen tests, black-box check in AutoCAD | High | 10–15 + human | | FF6 | **[alpha blocker]** DWG throughput: a 10 MB DWG took ~2 min to fail (#57) | High for real projects | 6–10 |@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Save As is a dialog: file type (DXF, DWG, DWT, SVG, PNG, PDF), version and binary DXF chosen per file before the file picker, which can't report them: FF3 updated | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |