SAVEAS version and binary DXF, default save version (fixes #421) (#431)

Squash of PR #431 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 17:19:47 +02:00 committed by GitHub
parent 9e3379882f
commit b3a73626b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 359 additions and 32 deletions

1
Cargo.lock generated
View File

@ -616,6 +616,7 @@ dependencies = [
"cadcraft-color",
"cadcraft-constraints",
"cadcraft-doc",
"cadcraft-dxf",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",

View File

@ -11,7 +11,7 @@ scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
can't be edited (no block editor, REFEDIT or xrefs); plots ignore plot styles; and a DWG saved by
CADCraft drops everything it doesn't model, is always R2000 and has never been checked in
CADCraft drops everything it doesn't model and has never been checked in
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
is ≈ 600–930 h away.

View File

@ -132,6 +132,9 @@ fn install_io() {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
}
fn open(s: &mut Session, path: &str) -> Result<(), String> {

View File

@ -60,6 +60,9 @@ mod web {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// No file system here: saved files reach the user as browser downloads.
cadcraft_engine::cmd::file::set_deliver(crate::download::download);
wasm_bindgen_futures::spawn_local(async {

View File

@ -96,6 +96,9 @@ pub fn install_io() {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
}
fn services() -> Services {

View File

@ -12,6 +12,7 @@ workspace = true
cadcraft-geom = { workspace = true }
cadcraft-color = { workspace = true }
cadcraft-doc = { workspace = true }
cadcraft-dxf = { workspace = true }
cadcraft-constraints = { workspace = true }
cadcraft-fonts = { workspace = true }
cadcraft-render = { workspace = true }

View File

@ -5,6 +5,7 @@
use std::sync::OnceLock;
use cadcraft_doc::Drawing;
pub use cadcraft_dxf::Version;
use serde_json::{Value, json};
use super::*;
@ -45,6 +46,77 @@ pub fn deliver() -> Option<DeliverHook> {
DELIVER.get().copied()
}
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`) and
/// binary instead of ASCII DXF.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SaveAs {
pub version: Option<Version>,
pub binary: bool,
}
/// Write a drawing with [`SaveAs`] options (installed by hosts whose writer supports them).
pub type WriteAsHook = fn(&Drawing, &str, &SaveAs) -> std::result::Result<Vec<u8>, String>;
static WRITE_AS: OnceLock<WriteAsHook> = OnceLock::new();
/// Install the host's writer for [`SaveAs`] options; saves that ask for none use [`IoHooks::write`].
pub fn set_write_as(h: WriteAsHook) {
let _ = WRITE_AS.set(h);
}
pub fn write_as() -> Option<WriteAsHook> {
WRITE_AS.get().copied()
}
/// The versions a drawing can be saved in, for messages ("2000, 2004, …, 2018").
fn version_list() -> String {
Version::ALL.iter().map(|v| v.year()).collect::<Vec<_>>().join(", ")
}
/// The `version` and `binary` parameters of a save: a version is a year (2018 or "2018"), "R2018"
/// or an `$ACADVER` code ("AC1032").
fn save_options(cmd: &str, p: &Value) -> Result<SaveAs> {
let version = match p.get("version") {
None | Some(Value::Null) => None,
Some(v) => {
let text = match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
_ => String::new(),
};
let name: String = text.chars().take(40).collect();
Some(Version::parse(&text).ok_or_else(|| bad(cmd, format!("unknown version `{name}` (use {})", version_list())))?)
}
};
let binary = match p.get("binary") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`binary` must be true or false")),
};
Ok(SaveAs { version, binary })
}
/// Whether `path` names a DXF file (or has no extension, which saves as DXF).
fn is_dxf(path: &str) -> bool {
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
matches!(ext.as_str(), "dxf" | "")
}
/// The drawing written to `path` with `opts`: through [`IoHooks::write`] when no option is set,
/// else through the [`write_as`] hook.
fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<u8>> {
let hooks = io().ok_or_else(|| bad(cmd, "file formats are not available in this build"))?;
if opts.binary && !is_dxf(path) {
return Err(bad(cmd, "binary applies to DXF files only"));
}
let r = if *opts == SaveAs::default() {
(hooks.write)(s.doc()?, path)
} else {
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
w(s.doc()?, path, opts)
};
r.map_err(|e| bad(cmd, e))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("new", "New Drawing...", run_new)
@ -66,10 +138,12 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("saveas", "Save As...", run_saveas_reporting)
.menu(&["File", "Save As..."])
.key("Cmd+Shift+S")
.params("{path, format?: dxf|dwg}")
.params("{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool}")
.noundo(),
CommandSpec::new("document.switch", "Switch Drawing", run_switch).params("{index}").enabled(always).noundo(),
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes).params("{format?: dxf} → {data: base64}").noundo(),
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
.noundo(),
]
}
@ -85,7 +159,12 @@ fn file_name(path: &str) -> String {
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
let d = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
Ok(s.open_drawing(d, &file_name(name), path))
let i = s.open_drawing(d, &file_name(name), path);
if let Some(st) = s.docs.get_mut(i) {
// QSAVE keeps a binary DXF binary.
st.binary_dxf = cadcraft_dxf::is_binary(bytes);
}
Ok(i)
}
fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
@ -133,9 +212,26 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
}
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
let hooks = io().ok_or_else(|| bad("save", "file formats are not available in this build"))?;
let bytes = (hooks.write)(s.doc()?, path).map_err(|e| bad("save", e))?;
commit_save(s, path, &bytes, store)
let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(path) };
save_as(s, "save", path, &opts)
}
/// Save to `path` with `opts`. A drawing file then keeps the version and binary choice: the
/// version goes into the drawing's `$ACADVER`, so later saves (QSAVE) write it again.
fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usize> {
let bytes = write_bytes(s, cmd, path, opts)?;
let n = commit_save(s, path, &bytes, store)?;
if is_drawing_file(path) {
let st = s.state_mut()?;
st.binary_dxf = opts.binary;
if let Some(v) = opts.version
&& st.doc.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
{
std::sync::Arc::make_mut(&mut st.doc).header.set_str("ACADVER", v.code());
st.saved = st.doc.clone();
}
}
Ok(n)
}
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
@ -194,8 +290,22 @@ fn run_save(s: &mut Session, p: &Value) -> Result<Value> {
fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("saveas", "`path` is required"))?.to_string();
let n = save_to(s, &path)?;
Ok(saved(&path, n))
let opts = save_options("saveas", p)?;
let n = save_as(s, "saveas", &path, &opts)?;
let mut r = saved(&path, n);
if is_drawing_file(&path) {
r["version"] = json!(opts.version.unwrap_or_else(|| drawing_version(s, &path)).year());
r["binary"] = json!(opts.binary);
}
Ok(r)
}
/// The version a drawing is saved in at `path` when none is asked for: its `$ACADVER` (2018 by
/// default), and for DWG at least 2004 (the io layer's rule: R2000 DWG has no true colours).
fn drawing_version(s: &Session, path: &str) -> Version {
let v =
s.doc().ok().and_then(|d| d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver)).unwrap_or_default();
if is_dxf(path) { v } else { v.max(Version::R2004) }
}
fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
@ -209,10 +319,10 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
}
fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let hooks = io().ok_or_else(|| bad("document.bytes", "file formats are not available in this build"))?;
let fmt = str_param(p, "format").unwrap_or("dxf");
let opts = save_options("document.bytes", p)?;
let lost = not_saved(s.doc()?);
let bytes = (hooks.write)(s.doc()?, &format!("drawing.{fmt}")).map_err(|e| bad("document.bytes", e))?;
let bytes = write_bytes(s, "document.bytes", &format!("drawing.{fmt}"), &opts)?;
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
if is_drawing_file(&format!("drawing.{fmt}")) {
report_not_saved(s, &mut r, &lost);

View File

@ -100,6 +100,8 @@ pub struct DocState {
pub views: Vec<(Space, View)>,
pub view_history: Vec<View>,
pub uid: u64,
/// The drawing is saved as binary DXF (it was opened from one, or last saved as one).
pub binary_dxf: bool,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@ -122,6 +124,7 @@ impl DocState {
views: Vec::new(),
view_history: Vec::new(),
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
binary_dxf: false,
}
}
pub fn is_dirty(&self) -> bool {

View File

@ -0,0 +1,98 @@
//! SAVEAS at a chosen version and as binary DXF (#421): the options reach the host's writer, the
//! drawing keeps them for later saves, and bad values are errors.
use cadcraft_doc::{Drawing, HVal};
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, SaveAs, base64_decode, base64_encode, set_io, set_write_as};
use serde_json::json;
/// The plain writer (no options).
fn plain(_: &Drawing, _: &str) -> Result<Vec<u8>, String> {
Ok(b"plain".to_vec())
}
/// The writer with options: the bytes name the options it got.
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())
}
fn install() {
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: plain, plot: None });
set_write_as(with_options);
}
fn acadver(s: &Session) -> Option<String> {
s.state().unwrap().doc.header.get("ACADVER").and_then(HVal::as_str).map(str::to_string)
}
#[test]
fn saveas_version_and_binary_reach_the_writer_and_stay_with_the_drawing() {
install();
let dir = std::env::temp_dir().join(format!("cadcraft-save-version-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
let read = |name: &str| String::from_utf8(std::fs::read(dir.join(name)).unwrap()).unwrap();
let mut s = Session::new();
s.cmdline("circle 0,0 5").unwrap();
assert_eq!(acadver(&s).as_deref(), Some("AC1032"), "a new drawing is 2018");
let r = s.execute("saveas", &json!({ "path": path("a.dxf"), "version": "2010" })).unwrap();
assert_eq!(read("a.dxf"), "2010|false");
assert_eq!((r["version"].clone(), r["binary"].clone()), (json!("2010"), json!(false)));
assert_eq!(acadver(&s).as_deref(), Some("AC1024"), "the drawing is now a 2010 drawing");
assert!(!s.state().unwrap().is_dirty());
// QSAVE writes the drawing's own version (the plain writer reads it from the drawing).
s.execute("qsave", &json!({})).unwrap();
assert_eq!(read("a.dxf"), "plain");
// Binary, version as a number; QSAVE stays binary.
s.execute("saveas", &json!({ "path": path("b.dxf"), "version": 2004, "binary": true })).unwrap();
assert_eq!(read("b.dxf"), "2004|true");
s.execute("qsave", &json!({})).unwrap();
assert_eq!(read("b.dxf"), "-|true");
// Saving as DXF without `binary` goes back to ASCII.
s.execute("saveas", &json!({ "path": path("c.dxf") })).unwrap();
assert_eq!(read("c.dxf"), "plain");
assert!(!s.state().unwrap().binary_dxf);
// document.bytes takes the same options.
let r = s.execute("document.bytes", &json!({ "version": "R2013" })).unwrap();
assert_eq!(base64_decode(r["data"].as_str().unwrap()).unwrap(), b"2013|false");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bad_save_options_are_errors() {
install();
let dir = std::env::temp_dir().join(format!("cadcraft-save-version-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
let mut s = Session::new();
let err = |s: &mut Session, p: serde_json::Value| s.execute("saveas", &p).unwrap_err().to_string();
assert!(err(&mut s, json!({ "path": path("x.dxf"), "version": "2005" })).contains("2000, 2004, 2007, 2010, 2013, 2018"));
assert!(err(&mut s, json!({ "path": path("x.dxf"), "version": [2018] })).contains("unknown version"));
assert!(err(&mut s, json!({ "path": path("x.dxf"), "binary": "yes" })).contains("binary"));
assert!(err(&mut s, json!({ "path": path("x.dwg"), "binary": true })).contains("DXF"));
assert!(!dir.join("x.dxf").exists() && !dir.join("x.dwg").exists(), "nothing is written");
assert_eq!(acadver(&s).as_deref(), Some("AC1032"), "a failed save leaves the version alone");
let _ = std::fs::remove_dir_all(&dir);
}
/// A drawing opened from a binary DXF saves as binary DXF again.
#[test]
fn opening_binary_dxf_keeps_it_binary() {
install();
let dir = std::env::temp_dir().join(format!("cadcraft-save-version-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
let binary = b"AutoCAD Binary DXF\r\n\x1a\0\0\0EOF\0";
s.execute("open", &json!({ "data": base64_encode(binary), "name": "bin.dxf" })).unwrap();
assert!(s.state().unwrap().binary_dxf);
let out = dir.join("again.dxf");
s.execute("qsave", &json!({ "path": out.to_string_lossy() })).unwrap();
assert_eq!(std::fs::read(&out).unwrap(), b"-|true");
// An ASCII file stays ASCII.
s.execute("open", &json!({ "data": base64_encode(b"0\nEOF\n"), "name": "text.dxf" })).unwrap();
assert!(!s.state().unwrap().binary_dxf);
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -151,6 +151,7 @@
Dark Темна
Default Типово
Default Save Format Типовий формат збереження
Default Save Version Типова версія збереження
Define Attributes Визначити атрибути
Define Attributes... Визначити атрибути...
Defined width Задана ширина
@ -948,6 +949,12 @@
Value Значення
Value: Значення:
Version Версія
Version 2000 Версія 2000
Version 2004 Версія 2004
Version 2007 Версія 2007
Version 2010 Версія 2010
Version 2013 Версія 2013
Version 2018 Версія 2018
Version {version} Версія {version}
Vertical Вертикальність
VerticalDistance Вертикальна відстань

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -81,8 +81,14 @@ pub struct UiState {
pub theme: theme::ThemePref,
/// The format Save As suggests for a drawing that has no file yet: "dxf" or "dwg".
pub save_format: SaveFormat,
/// The version Save As writes for a drawing that has no file yet ("2000" … "2018"); a
/// drawing opened from a file keeps that file's version.
pub save_version: String,
}
/// A DXF/DWG file version (2000 … 2018).
pub use cadcraft_engine::cmd::file::Version as SaveVersion;
/// The default save format for new drawings, like AutoCAD's Options > Open and Save > "Save as".
/// A drawing opened from a file keeps that file's format.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -145,6 +151,7 @@ impl Default for UiState {
history_lines: 3,
theme: theme::ThemePref::default(),
save_format: SaveFormat::default(),
save_version: SaveVersion::default().year().to_string(),
}
}
}
@ -292,6 +299,7 @@ impl CadApp {
"theme": self.ui.theme.as_str(),
"interfaceLanguage": self.ui.interface_language.code(),
"saveFormat": self.ui.save_format.as_str(),
"saveVersion": self.ui.save_version,
})
.to_string()
}
@ -308,6 +316,9 @@ impl CadApp {
if let Some(f) = v.get("saveFormat").and_then(Value::as_str).and_then(SaveFormat::parse) {
self.ui.save_format = f;
}
if let Some(v) = v.get("saveVersion").and_then(Value::as_str).and_then(SaveVersion::parse) {
self.ui.save_version = v.year().to_string();
}
}
pub fn set_status(&mut self, s: impl Into<String>) {
@ -597,7 +608,7 @@ mod tests {
frame(&mut a, &ctx, None);
assert_eq!(Tokens::get(), Tokens::of(theme::SYSTEM_FALLBACK), "no OS appearance: the documented fallback");
assert_eq!(a.ui.theme, ThemePref::System);
assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","saveFormat":"dxf","theme":"system"}"#);
assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","saveFormat":"dxf","saveVersion":"2018","theme":"system"}"#);
}
#[test]

View File

@ -32,6 +32,13 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.saveformat.dxf", "DXF", &["File", "Default Save Format", "DXF"], None),
("ui.saveformat.dwg", "DWG", &["File", "Default Save Format", "DWG"], None),
("ui.saveformat", "Default Save Format", &[], None),
("ui.saveversion.2018", "Version 2018", &["File", "Default Save Format", "Version 2018"], None),
("ui.saveversion.2013", "Version 2013", &["File", "Default Save Format", "Version 2013"], None),
("ui.saveversion.2010", "Version 2010", &["File", "Default Save Format", "Version 2010"], None),
("ui.saveversion.2007", "Version 2007", &["File", "Default Save Format", "Version 2007"], None),
("ui.saveversion.2004", "Version 2004", &["File", "Default Save Format", "Version 2004"], None),
("ui.saveversion.2000", "Version 2000", &["File", "Default Save Format", "Version 2000"], None),
("ui.saveversion", "Default Save Version", &[], None),
("ui.toggle.menubar", "In-window Menu Bar", &["Window", "In-window Menu Bar"], None),
("ui.hidepalettes", "Hide Palettes", &["Window", "Hide Palettes"], None),
("ui.resetpalettes", "Reset Palettes", &["Window", "Reset Palettes"], None),
@ -110,7 +117,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
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)) {
return Some(app.session.execute("saveas", &json!({ "path": p })).map_err(|e| e.to_string()));
return Some(app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string()));
}
Ok(Value::Null)
}
@ -209,6 +216,25 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
},
None => Ok(json!({ "saveFormat": app.ui.save_format.as_str() })),
},
// `{"version": "2000"…"2018"}` sets the version new drawings are saved in; without it,
// reports it.
id if id.starts_with("ui.saveversion") => {
let name = id
.strip_prefix("ui.saveversion.")
.map(str::to_string)
.or_else(|| params.get("version").map(|v| v.as_str().map_or_else(|| v.to_string(), str::to_string)));
match name {
Some(name) => match crate::SaveVersion::parse(&name) {
Some(v) => {
app.ui.save_version = v.year().to_string();
app.session.echo(format!("New drawings will be saved as version {v}"));
Ok(json!({ "saveVersion": app.ui.save_version }))
}
None => Err(format!("unknown version \"{}\" (2000, 2004, 2007, 2010, 2013 or 2018)", name.chars().take(40).collect::<String>())),
},
None => Ok(json!({ "saveVersion": app.ui.save_version })),
}
}
"ui.hidepalettes" => {
app.ui.show_palettes = false;
app.ui.show_toolsets = false;
@ -216,7 +242,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
}
"ui.resetpalettes" => {
let (menu, theme, interface_language, save_format) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language, app.ui.save_format);
app.ui = crate::UiState { in_window_menu: menu, theme, interface_language, save_format, ..Default::default() };
let save_version = std::mem::take(&mut app.ui.save_version);
app.ui = crate::UiState { in_window_menu: menu, theme, interface_language, save_format, save_version, ..Default::default() };
Ok(Value::Null)
}
"ui.start" => {
@ -300,6 +327,16 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
Some(r)
}
/// SAVEAS parameters for a path picked in the Save dialog: a drawing that has no file yet is saved
/// in the default save version; one opened from a file keeps that file's version.
fn saveas_params(ui: &crate::UiState, session: &cadcraft_engine::Session, path: &str) -> Value {
let untitled = session.state().is_ok_and(|s| s.path.is_none());
match crate::SaveVersion::parse(&ui.save_version) {
Some(v) if untitled => json!({ "path": path, "version": v.year() }),
_ => json!({ "path": path }),
}
}
fn set_save_format(app: &mut CadApp, format: crate::SaveFormat) -> Value {
app.ui.save_format = format;
app.session.echo(format!("New drawings will be saved as {}", format.as_str().to_ascii_uppercase()));

View File

@ -0,0 +1,46 @@
//! The default save version (#421): File > Default Save Format > Version …, kept in the
//! preferences, applied by the Save dialog to drawings that have no file yet.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, SaveAs, set_io, set_write_as};
use cadcraft_engine::doc::Drawing;
use cadcraft_ui_egui::{CadApp, Services};
use serde_json::{Value, json};
fn picked() -> std::path::PathBuf {
std::env::temp_dir().join(format!("cadcraft-ui-save-version-{}.dxf", std::process::id()))
}
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())
}
#[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 });
set_write_as(with_options);
let services = Services { pick_save: Some(Box::new(|_: &str| Some(picked().to_string_lossy().to_string()))), ..Services::default() };
let mut app = CadApp::new(Session::new(), services);
assert_eq!(app.run("ui.saveversion", json!({})).ok(), Some(json!({ "saveVersion": "2018" })), "2018 by default");
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 …
app.run("ui.saveas", Value::Null).unwrap();
assert_eq!(std::fs::read(picked()).unwrap(), b"2010|false");
// … and a drawing that has a file in its own.
app.run("ui.saveas", Value::Null).unwrap();
assert_eq!(std::fs::read(picked()).unwrap(), b"plain");
let _ = std::fs::remove_file(picked());
// Menu items, preferences (bad values ignored) and resetting the palettes.
app.start("ui.saveversion.2004");
assert_eq!(app.ui.save_version, "2004");
let mut b = CadApp::new(Session::empty(), Services::default());
b.load_prefs(&app.prefs_json());
assert_eq!(b.ui.save_version, "2004");
b.load_prefs(r#"{"saveVersion": "R14"}"#);
assert_eq!(b.ui.save_version, "2004");
b.start("ui.resetpalettes");
assert_eq!(b.ui.save_version, "2004");
}

View File

@ -35,7 +35,7 @@ The server waits up to 60 s for the app to answer a request. After that it repli
| `ui.click` / `ui.move` | `{x, y, button?, shift?}` | Real egui pointer events in screen points (reach every widget). |
| `ui.drag` | `{x, y, to: [x, y], button?}` | Real press, move and release (drag the command line's top edge, the ViewCube ring…). |
| `ui.key` / `ui.text` | `{key, cmd?, shift?, alt?}` / `{text}` | Synthetic keyboard input. |
| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings. |
| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). |
| `ui.resize` | `{width, height}` | Resize the window. |
| `ui.focus` | | Activate the native window. Does not assign focus to a widget. |
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). |

View File

@ -1,14 +1,14 @@
# File-format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (WIPEOUT and hatch gradients now written, #147, #162) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (DXF/DWG saved at a chosen version 2000–2018, binary DXF written, #421) · **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
[target-app-parity.md](target-app-parity.md).
**Headline (estimated):** file formats ≈ **30%** of AutoCAD's. Opening the commonest 2D DXF/DWG
drawings works, but saving is lossy: everything CADCraft doesn't model is dropped, output is
always R2000, and no test uses a real third-party file. Remaining: **≈ 170–280 Opus 5.5 hours**:
drawings works, but saving is lossy: everything CADCraft doesn't model is dropped, and no test
uses a real third-party file. DXF and DWG save at 2000–2018 (2018 by default, #421). Remaining: **≈ 170–280 Opus 5.5 hours**:
≈ 40–60 h for DXF/DWG fidelity (the part that decides beta), ≈ 110–180 h for the other formats
below, and ≈ 20–40 h for 3D exchange formats that wait for 3D modelling.
@ -31,20 +31,23 @@ below, and ≈ 20–40 h for 3D exchange formats that wait for 3D modelling.
AutoCAD 2027 opens every DWG version from R2.0 onward and saves 2018 (AC1032, still the current
DWG format), 2013, 2010, 2007, 2004, 2000 and R14. CADCraft reads DWG by converting it to DXF
with the `acadrust` 0.6.1 crate and reading that DXF; it saves by writing its own R2000 DXF and
converting it to DWG.
with the `acadrust` 0.6.3 crate and reading that DXF; it saves by writing its own R2000 DXF and
converting it to a DWG of the chosen version (#421): SAVEAS `version`, else the drawing's own
version (the file's `$ACADVER`, 2018 for a new drawing), at least 2004 unless 2000 is asked for.
The bridge repairs two acadrust 0.6.3 gaps in 2010+ DWG: merged table cells and table-style
title/header suppression.
| DWG version | Code | AutoCAD 2027 | CADCraft read | CADCraft write | Tested how |
|---|---|---|---|---|---|
| R1.0–R2.6, R9, R10, R11/R12 | MC0.0–AC1009 | read | **no** (`is_dwg` accepts only `AC10xx` magic; acadrust doesn't target pre-R13) | no | — |
| R13 | AC1012 | read | acadrust claims support; untested | no | none |
| R14 | AC1014 | read, write | acadrust claims support; untested | **no** | none |
| 2000–2002 | AC1015 | read, write | yes via bridge | **yes, always this version** | synthetic round trips (`dwg_roundtrip_keeps_non_ascii_text`, `dwg_roundtrip_keeps_extension_data`) |
| 2004–2006 | AC1018 | read, write | via bridge; untested | no (version list in `crates/dwg` is unused) | none |
| 2007–2009 | AC1021 | read, write | via bridge; untested | no | none |
| 2010–2012 | AC1024 | read, write | via bridge | no | one synthetic file (`ac1024_dwg_with_many_entities_opens`, made by rewriting `$ACADVER`) |
| 2013–2017 | AC1027 | read, write | via bridge; untested | no | none |
| 2018–2027 | AC1032 | read, write (default) | via bridge; untested on real files | no | none |
| 2000–2002 | AC1015 | read, write | yes via bridge | yes, when asked for (no true colours, transparency or table styles: the format has none) | synthetic round trips (`dwg_at_every_version_reads_back`, `dwg_roundtrip_keeps_non_ascii_text`) |
| 2004–2006 | AC1018 | read, write | via bridge | yes | `dwg_at_every_version_reads_back`, `dwg_roundtrip_keeps_extension_data` |
| 2007–2009 | AC1021 | read, write | via bridge | yes | `dwg_at_every_version_reads_back` |
| 2010–2012 | AC1024 | read, write | via bridge | yes | `dwg_at_every_version_reads_back`, `ac1024_dwg_with_many_entities_opens` |
| 2013–2017 | AC1027 | read, write | via bridge | yes | `dwg_at_every_version_reads_back` |
| 2018–2027 | AC1032 | read, write (default) | via bridge; untested on real files | yes (default for new drawings) | `dwg_at_every_version_reads_back` (synthetic only) |
| Web build | — | — | **no** (error: "save as DXF") | no | — |
Everything lost in the DXF read/write paths below is also lost through DWG, plus whatever
@ -58,8 +61,8 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr
|---|---|---|---|
| R12 (AC1009), ASCII | read, write | yes: no version gating; old-style POLYLINE/VERTEX read (polyface meshes flattened to 3D polylines) | **no** |
| R12 binary | read, write | **no** (binary parser expects R13+ 2-byte group codes) | no |
| R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | **R2000 (AC1015) only** |
| R13–R2018, binary | read, write | yes | **no** (ASCII only) |
| R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | yes, each of 2000–2018 (#421): R2000 structure (later versions add only optional groups) with that `$ACADVER`; UTF-8 text from 2007; no CLASS instance counts (91) |
| R13–R2018, binary | read, write | yes | yes, each of 2000–2018 (SAVEAS `binary`; a binary file stays binary on QSAVE) |
| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | **no**: UTF-8, else Latin-1, so pre-2007 CJK and Cyrillic files are garbled | writes `\U+` escapes (#169), which is correct |
| `\U+`/`\M+` escapes | yes | `\U+` yes; `\M+` not checked | `\U+` yes |
@ -127,5 +130,6 @@ our output in AutoCAD black-box.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | DXF (ASCII and binary) and DWG saved at 2000–2018, 2018 by default (#421) |
| 2026-10-10 | minor | WIPEOUT and gradients written since #147/#162; other save losses remain (#201, #204, #208, #209, #213, #223) |
| 2026-10-10 | major | First file-format parity doc: DWG and DXF version by version, per-object read/write, other formats |

View File

@ -16,7 +16,7 @@ do them first.
| 1 | **[alpha blocker]** **Saving drops whatever we don't model** (Unknown entities, IMAGE, layer states, block descriptions, true colours, MULTILEADER written as LEADER+MTEXT, groups, named views/UCS, foreign xdata) | Opening a colleague's DWG and saving it silently deletes content: the one thing a CAD user can't forgive | 15–25 | [file formats](file-format-parity.md) |
| 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) |
| 3 | **Tracking leftovers** (object snap tracking, Extension, Parallel and Apparent Intersection landed, #379): polar angles relative to the last segment, PolarSnap, temporary tracking point | Small now; ranks lower at the next review | 3–5 | [ui](ui-parity.md#tracking-polar-ortho-grid-and-snap--70) |
| 4 | **[alpha blocker]** **DWG always saves as R2000; DXF only R2000 ASCII; no code pages** | Can't exchange at 2018 format; pre-2007 CJK/Cyrillic files are garbled | 10–15 | [file formats](file-format-parity.md) |
| 4 | **No code pages when reading pre-2007 files** (saving DWG/DXF at 2000–2018 and binary DXF landed, #421) | Pre-2007 CJK/Cyrillic files are garbled | 3–5 | [file formats](file-format-parity.md) |
| 5 | **[alpha blocker]** **Stubbed prompt options** left in PEDIT Edit vertex, SPLINEDIT and MTEXT columns | Scripts and muscle memory hit "not available yet" (the most-used commands are done) | 3–6 | [features](#features) |
| 6 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) |
| 7 | **[alpha blocker]** **No xrefs, block editor, dynamic blocks or groups** | Most production drawings use them | 80–120 | [features](#features) |
@ -82,7 +82,7 @@ Detail: [file-format-parity.md](file-format-parity.md).
|---|---|---|---|
| FF1 | **[alpha blocker]** Round-trip what we don't model: keep Unknown entities and objects, foreign xdata, extension dictionaries; write IMAGE (with IMAGEDEF), VIEW/UCS/VPORT tables, groups, layer states, block descriptions, true colours (#201, #204, #208, #209, #213, #223); WIPEOUT and gradients done (#147, #162) | High | 12–20 |
| FF2 | **[alpha blocker]** Native MULTILEADER objects (read and write) | High | 6–10 |
| FF3 | **[alpha blocker]** Write DXF 2004–2018 and binary DXF; save DWG at a chosen version (2018 default) instead of always R2000; SAVEAS version choice (PR #198 adds DWG to the dialog) | High | 8–12 |
| 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 |
| FF4 | Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) when reading pre-2007 files | Medium-high (Asia, Eastern Europe) | 3–5 |
| 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 |

View File

@ -36,7 +36,7 @@ the alpha checklist. Main platform: macOS.
| Dimension and annotate | partial, not blocking | All core DIM types, associativity, DIMSTYLE variables (43 of ~80), TEXT/MTEXT, MLEADER, tables work; no DIMSTYLE dialog, MTEXT editor not in place, MLEADER thin (F7, F9, F10). A drafter can dimension a sheet end to end | 0 for the gate (beta work) |
| Use blocks (insert library blocks, edit them, attributes, xrefs) | **partial, blocking** | Static BLOCK/INSERT/attributes work; no block editor (BEDIT) or REFEDIT, so a block can't be edited in place; no xrefs; dynamic blocks from other drawings lose their behaviour (F2, F3) | 35–55 |
| Layers, layouts and plotting a sheet | **partial, blocking** | Layers, layouts, viewports, page setups, PDF output and plot styles (CTB/STB, #410) work; no plot preview or plot dialog (#160); printers/plotters only via PDF (F14, H3) | 15–25 |
| Save DWG that AutoCAD opens without loss, and reopen others' DWGs | **no** | Saving drops every entity and object we don't model (Unknown, IMAGE, WIPEOUT, gradients, groups, views, foreign xdata); MULTILEADER becomes LEADER+MTEXT; DWG always written as R2000; never checked in AutoCAD; no real-file tests; a 10 MB DWG failed after ~2 min (#57) (FF1–FF6) | 40–60 + owner (real-file corpus, AutoCAD check) |
| Save DWG that AutoCAD opens without loss, and reopen others' DWGs | **no** | Saving drops every entity and object we don't model (Unknown, IMAGE, WIPEOUT, gradients, groups, views, foreign xdata); MULTILEADER becomes LEADER+MTEXT; never checked in AutoCAD; no real-file tests; a 10 MB DWG failed after ~2 min (#57) (FF1–FF6) | 40–60 + owner (real-file corpus, AutoCAD check) |
| **To alpha** | | | **≈ 125–190** |
Ready for real work is ≈ 38%, also under the ~40% alpha bar; closing the gate rows lifts it past

View File

@ -92,7 +92,7 @@ area here; it is counted once, as the file-exchange discount.
|---|---:|---|
| Interaction fidelity (beyond the precision-input area) | ×0.90 | hot-grip options ignored, no context menus, selection keywords W/C/F/WP/CP rejected (#224), transparent 'ZOOM cancels the running command (#220), ELLIPSE/BREAK keywords ignored (#219) |
| Stability on real machines | ×0.85 | 27 of 37 open issues are core-path bugs; about 60 correctness fixes merged on 2026-10-10 alone; lines not visible mid-command (#44); locked layers edited (#87); no CI runs the Rust tests on PRs |
| Exchanging files with AutoCAD users | ×0.80 | saves drop unmodelled entities, images, layer states, multileader styles, true colours and more (#79, #201, #204, #208, #209, #213, #223); DWG always R2000; never checked in AutoCAD; a 10 MB DWG failed (#57) |
| Exchanging files with AutoCAD users | ×0.80 | saves drop unmodelled entities, images, layer states, multileader styles, true colours and more (#79, #201, #204, #208, #209, #213, #223); never checked in AutoCAD; a 10 MB DWG failed (#57) |
**Mainstream practitioner ≈ 45.3% × 0.90 × 0.85 × 0.80 ≈ 28%** (estimated). It is lower than
ready for real work (≈ 38%) for CADCraft, unlike most apps: the full number credits additively