From 28fe95fff00fef5d9f8232272219746d0556a4cb Mon Sep 17 00:00:00 2001 From: Rodrigo Gressler Date: Mon, 19 Oct 2026 16:36:13 -0300 Subject: [PATCH 001/151] DXF: save pre-2007 text in the file's code page instead of all \U+ escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before 2007, DXF text is in the code page `$DWGCODEPAGE` names. Since #169 every non-ASCII character was written as a `\U+XXXX` escape, so a Portuguese drawing saved at 2000/2004 had layers, blocks and text such as `A\U+00E7\U+00E3o` in the file. Characters the declared code page has are now written as its bytes (`Ação` in ANSI_1252); the others are still escaped, and an unknown code page still gives pure ASCII. The DWG bridge is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/dxf/src/codepage.rs | 24 +++++++++ crates/dxf/src/lib.rs | 79 ++++++++++++++++++++--------- crates/dxf/tests/code_page_write.rs | 57 +++++++++++++++++++++ crates/io/src/dxf_write.rs | 8 +-- crates/io/src/lib.rs | 11 ++-- crates/io/tests/codepages.rs | 30 +++++++++++ crates/io/tests/dxf_versions.rs | 16 ++++-- crates/io/tests/followups_626.rs | 7 ++- docs/file-format-parity.md | 5 +- docs/localization-parity.md | 4 +- 10 files changed, 197 insertions(+), 44 deletions(-) create mode 100644 crates/dxf/tests/code_page_write.rs diff --git a/crates/dxf/src/codepage.rs b/crates/dxf/src/codepage.rs index 6f27b5be4..53f24c3b6 100644 --- a/crates/dxf/src/codepage.rs +++ b/crates/dxf/src/codepage.rs @@ -69,6 +69,30 @@ pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option { decoder(code_page).map(|d| decode(bytes, d).into_owned()) } +/// Append the bytes of the non-ASCII character `c` in the code page `d`, and return true, when +/// the code page has it; otherwise append nothing and return false (the caller escapes it). +pub(crate) fn encode_char(c: char, d: Decoder, out: &mut Vec) -> bool { + let mut buf = [0; 4]; + match d { + Decoder::Utf8 => out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()), + Decoder::Encoding(e) => { + let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf)); + // A code page byte that is ASCII would read back as another character, and a NUL, + // CR or LF would break the group, so only all-high-bit sequences or proper double-byte + // characters are kept (their trail byte may be ASCII, as in Shift-JIS). + if unmappable || bytes.is_empty() || !bytes.first().is_some_and(|b| *b >= 0x80) || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) { + return false; + } + out.extend_from_slice(&bytes); + } + Decoder::Table(t) => { + let Some(i) = t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) else { return false }; + out.push(0x80 | i); + } + } + true +} + /// Decode bytes with a decoder. UTF-8 that isn't valid falls back to Windows-1252. pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> { match d { diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 82ecf55e8..e37e3e13e 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] use std::fmt::Write as _; +use std::io::Write as _; mod version; pub use version::Version; @@ -409,18 +410,41 @@ pub fn write_ascii(tags: &[Tag]) -> String { /// characters as they are) or escaped as `\U+XXXX` (older versions, see [`write_ascii`]). Line /// breaks inside strings become spaces either way. pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String { - let mut out = String::with_capacity(tags.len() * 16); + let bytes = ascii(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) }); + // Both forms are UTF-8 (the escaped one is ASCII). + String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) +} + +/// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a +/// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions: +/// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A +/// code page CADCraft doesn't know gives [`write_ascii`]'s pure ASCII output. +pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec { + ascii(tags, Strings::Escaped(codepage::decoder(code_page))) +} + +/// How a writer stores string values. +#[derive(Clone, Copy)] +enum Strings { + /// As they are (R2007+). + Utf8, + /// ASCII as it is, other characters in this code page when it has them, otherwise as + /// `\U+XXXX` escapes (before R2007). + Escaped(Option), +} + +fn ascii(tags: &[Tag], strings: Strings) -> Vec { + let mut out = Vec::with_capacity(tags.len() * 16); for t in tags { let _ = write!(out, "{:>3}\r\n", t.code); match &t.value { - Value::Str(s) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })), - Value::Str(s) => escape_unicode(s, &mut out), - Value::Real(r) => out.push_str(&format_real(*r)), - Value::Int(i) => out.push_str(&i.to_string()), - Value::Bool(b) => out.push_str(&i64::from(*b).to_string()), - Value::Hex(h) => out.push_str(h), + Value::Str(s) => push_string(s, strings, &mut out), + Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()), + Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()), + Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()), + Value::Hex(h) => out.extend_from_slice(h.as_bytes()), } - out.push_str("\r\n"); + out.extend_from_slice(b"\r\n"); } out } @@ -433,6 +457,16 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String { /// in [`write_ascii`]. A tag's value is converted to its code's kind, so a number written as text /// is stored as a number; a code outside the 16-bit range is skipped. pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec { + binary(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) }) +} + +/// Write tags as pre-R2007 binary DXF with text in the code page `code_page` names, as +/// [`write_ascii_in`] does. +pub fn write_binary_in(tags: &[Tag], code_page: &str) -> Vec { + binary(tags, Strings::Escaped(codepage::decoder(code_page))) +} + +fn binary(tags: &[Tag], strings: Strings) -> Vec { let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12); out.extend_from_slice(BINARY_SENTINEL); for t in tags { @@ -445,13 +479,9 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec { Value::Str(s) => s.clone(), v => v.as_str(), }; - let mut text = String::with_capacity(s.len()); - if utf8 { - text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })); - } else { - escape_unicode(&s, &mut text); - } - out.extend(text.bytes().filter(|&b| b != 0)); + let mut text = Vec::with_capacity(s.len()); + push_string(&s, strings, &mut text); + out.extend(text.into_iter().filter(|&b| b != 0)); out.push(0); } Kind::Real => { @@ -505,15 +535,18 @@ pub fn is_binary(bytes: &[u8]) -> bool { bytes.starts_with(BINARY_SENTINEL) } -/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII -/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes -/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces. -fn escape_unicode(s: &str, out: &mut String) { +/// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is +/// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes +/// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without +/// a code page the result is ASCII, so it is valid in any code page. +fn push_string(s: &str, strings: Strings, out: &mut Vec) { for c in s.chars() { - match c { - '\r' | '\n' => out.push(' '), - c if c.is_ascii() => out.push(c), - c => { + match (c, strings) { + ('\r' | '\n', _) => out.push(b' '), + (c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()), + (c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()), + (c, Strings::Escaped(Some(d))) if codepage::encode_char(c, d, out) => {} + (c, Strings::Escaped(_)) => { let mut units = [0u16; 2]; for u in c.encode_utf16(&mut units) { let _ = write!(out, "\\U+{u:04X}"); diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs new file mode 100644 index 000000000..316f2edd5 --- /dev/null +++ b/crates/dxf/tests/code_page_write.rs @@ -0,0 +1,57 @@ +//! Pre-R2007 DXF text is written in the `$DWGCODEPAGE` code page: characters it has as their +//! bytes, the others as `\U+XXXX` escapes; every form reads back to the same text. + +use cadcraft_dxf::{Tag, parse, write_ascii, write_ascii_in, write_binary_in}; + +fn file(code_page: &str, text: &str) -> Vec { + vec![ + Tag::s(0, "SECTION"), + Tag::s(2, "HEADER"), + Tag::s(9, "$ACADVER"), + Tag::s(1, "AC1015"), + Tag::s(9, "$DWGCODEPAGE"), + Tag::s(3, code_page), + Tag::s(0, "ENDSEC"), + Tag::s(0, "SECTION"), + Tag::s(2, "ENTITIES"), + Tag::s(0, "TEXT"), + Tag::s(1, text), + Tag::s(0, "ENDSEC"), + Tag::s(0, "EOF"), + ] +} + +fn contains(bytes: &[u8], part: &[u8]) -> bool { + bytes.windows(part.len()).any(|w| w == part) +} + +#[test] +fn text_is_written_in_the_code_page() { + // (code page, text, the bytes written for it) + let cases: [(&str, &str, &[u8]); 6] = [ + ("ANSI_1252", "Ação € 图", b"A\xE7\xE3o \x80 \\U+56FE"), + ("ANSI_1251", "Слой é", b"\xD1\xEB\xEE\xE9 \\U+00E9"), + ("ANSI_932", "表図面 é", b"\x95\x5C\x90\x7D\x96\xCA \\U+00E9"), + ("DOS850", "Größe €", b"Gr\x94\xE1e \\U+20AC"), + ("ANSI_1252", "😀", b"\\U+D83D\\U+DE00"), + // A code page CADCraft doesn't know: everything non-ASCII is escaped. + ("ANSI_1361", "Ação", b"A\\U+00E7\\U+00E3o"), + ]; + for (cp, text, raw) in cases { + let tags = file(cp, text); + for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] { + assert!(contains(&bytes, raw), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes)); + assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}"); + } + } +} + +/// Without a known code page the output is what `write_ascii` writes, line breaks included. +#[test] +fn unknown_code_page_is_pure_ascii() { + let tags = file("EBCDIC", "Ação\r\nfim"); + let bytes = write_ascii_in(&tags, "EBCDIC"); + assert!(bytes.is_ascii()); + assert_eq!(bytes, write_ascii(&tags).into_bytes()); + assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim")); +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 521654e3d..ec4b8595f 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1234,10 +1234,10 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx } } -/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it (the text -/// is ASCII with `\U+XXXX` escapes, right in any code page, but the file keeps its declared one); -/// otherwise, and from 2007 (UTF-8 text), `ANSI_1252`. -fn code_page(d: &Drawing, version: Version) -> String { +/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it, in which +/// a saved file's text is written (characters outside it as `\U+XXXX` escapes); otherwise, and +/// from 2007 (UTF-8 text), `ANSI_1252`. +pub(crate) fn code_page(d: &Drawing, version: Version) -> String { let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default(); let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some(); if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index ef56e6602..605298db9 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -88,10 +88,13 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result match ext(name).as_str() { "dxf" | "" => { let tags = dxf_write::tags(d, version); - Ok(if opts.binary { - cadcraft_dxf::write_binary(&tags, version.utf8()) - } else { - cadcraft_dxf::write_ascii_as(&tags, version.utf8()).into_bytes() + // Before 2007 the text is in the code page the header declares. + let code_page = dxf_write::code_page(d, version); + Ok(match (opts.binary, version.utf8()) { + (true, true) => cadcraft_dxf::write_binary(&tags, true), + (true, false) => cadcraft_dxf::write_binary_in(&tags, &code_page), + (false, true) => cadcraft_dxf::write_ascii_as(&tags, true).into_bytes(), + (false, false) => cadcraft_dxf::write_ascii_in(&tags, &code_page), }) } // The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without diff --git a/crates/io/tests/codepages.rs b/crates/io/tests/codepages.rs index 40d85b3f4..53c22e908 100644 --- a/crates/io/tests/codepages.rs +++ b/crates/io/tests/codepages.rs @@ -116,6 +116,36 @@ fn code_page_text_survives_a_round_trip() { assert_eq!(names(&back), ("Слой".into(), "Блок".into(), "Привет".into())); } +/// Saving a pre-2007 DXF writes the text in the file's code page, as the DXF Reference +/// describes: a Portuguese drawing keeps `Elétrica`, `Ação` and `Função` as ANSI_1252 bytes +/// instead of `\U+00E9` escapes, a Cyrillic one its ANSI_1251 bytes, and characters the code +/// page lacks are still escaped. ASCII and binary, R2000 and R2004, read back the same. +#[test] +fn saving_writes_text_in_the_code_page() { + use cadcraft_io::{SaveOptions, Version}; + let pt = ascii_dxf("AC1015", "ANSI_1252", b"El\xE9trica", b"A\xE7\xE3o", b"Fun\xE7\xE3o \\U+56FE"); + let ru = ascii_dxf("AC1015", "ANSI_1251", b"\xD1\xEB\xEE\xE9", b"\xC1\xEB\xEE\xEA", b"\xCF\xF0\xE8\xE2\xE5\xF2 \\U+00E9"); + let cases: [(&[u8], (&str, &str, &str), &[&[u8]]); 2] = [ + (&pt, ("Elétrica", "Ação", "Função 图"), &[b"El\xE9trica", b"A\xE7\xE3o", b"Fun\xE7\xE3o \\U+56FE"]), + (&ru, ("Слой", "Блок", "Привет é"), &[b"\xD1\xEB\xEE\xE9", b"\xC1\xEB\xEE\xEA", b"\xCF\xF0\xE8\xE2\xE5\xF2 \\U+00E9"]), + ]; + for (file, want, raw) in cases { + let d = cadcraft_io::read_dxf(file).unwrap(); + assert_eq!(names(&d), (want.0.into(), want.1.into(), want.2.into())); + for version in [Version::R2000, Version::R2004] { + for binary in [false, true] { + let out = cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + for r in raw { + assert!(out.windows(r.len()).any(|w| w == *r), "{version} binary={binary}: {}", String::from_utf8_lossy(r)); + } + assert!(!out.windows(7).any(|w| w == b"\\U+00E7"), "{version} binary={binary}: ç is written as its byte"); + let back = cadcraft_io::read_dxf(&out).unwrap(); + assert_eq!(names(&back), (want.0.into(), want.1.into(), want.2.into()), "{version} binary={binary}"); + } + } + } +} + /// A binary DXF: R2000 strings are in the code page, R2007+ strings UTF-8. #[test] fn binary_dxf_honours_the_code_page() { diff --git a/crates/io/tests/dxf_versions.rs b/crates/io/tests/dxf_versions.rs index 3fc2f1c73..eafd989e9 100644 --- a/crates/io/tests/dxf_versions.rs +++ b/crates/io/tests/dxf_versions.rs @@ -87,9 +87,11 @@ fn dxf_at_every_version_reads_back_the_same() { assert_eq!(acadver(&bytes), v.code(), "{what}"); assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{what}"); let raw = bytes.windows("图号".len()).any(|w| w == "图号".as_bytes()); - assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, \\U+ escapes before"); - if !v.utf8() && !binary { - assert!(bytes.is_ascii(), "{what}"); + assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, the code page before"); + if !v.utf8() { + // ANSI_1252 has the Latin characters; the others are escaped. + let has = |b: &[u8]| bytes.windows(b.len()).any(|w| w == b); + assert!(has(b"\\U+56FE\\U+53F7 Caf\xe9 \xd8 \\U+D83D\\U+DE00"), "{what}"); } let back = read(&bytes, "x.dxf").unwrap(); assert_eq!(first_text(&back).as_deref(), Some(INTL), "{what}"); @@ -99,11 +101,15 @@ fn dxf_at_every_version_reads_back_the_same() { } } -/// R2000 ASCII is exactly the R2000 DXF CADCraft always wrote. +/// R2000 ASCII is the R2000 DXF CADCraft always wrote, tag for tag; only the text is in the +/// code page instead of all `\U+` escapes. #[test] fn r2000_ascii_is_unchanged() { let d = sample(); - assert_eq!(write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap(), write_dxf(&d).into_bytes()); + let saved = write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap(); + let escaped = write_dxf(&d); + assert_ne!(saved, escaped.as_bytes(), "Café is written in ANSI_1252"); + assert_eq!(cadcraft_dxf::parse(&saved).unwrap(), cadcraft_dxf::parse(escaped.as_bytes()).unwrap()); } /// Without a version asked for: 2018 for a new drawing, otherwise the file's own version (the diff --git a/crates/io/tests/followups_626.rs b/crates/io/tests/followups_626.rs index a44a27eb4..10e355cd3 100644 --- a/crates/io/tests/followups_626.rs +++ b/crates/io/tests/followups_626.rs @@ -177,10 +177,9 @@ fn code_page_is_kept_before_2007() { .unwrap(); for v in [Version::R2000, Version::R2004] { let bytes = save(&d, "x.dxf", v); - assert!(bytes.is_ascii(), "{v:?}: ASCII with escapes"); - let text = String::from_utf8(bytes).unwrap(); - assert_eq!(code_page(&text), "ANSI_1251", "{v:?}"); - let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert!(bytes.windows(6).any(|w| w == b"\xCF\xF0\xE8\xE2\xE5\xF2"), "{v:?}: the text in ANSI_1251"); + assert_eq!(code_page(&String::from_utf8_lossy(&bytes)), "ANSI_1251", "{v:?}"); + let back = cadcraft_io::read_dxf(&bytes).unwrap(); assert_eq!(back.header.str("DWGCODEPAGE", ""), "ANSI_1251"); assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.value == "Привет"))); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c598193d7..122b654a8 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-17 · **Change:** minor (DWG read leaves out orphaned DGN line style components) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (pre-2007 DXF text saved in its code page) · **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 @@ -66,7 +66,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | 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 | 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 | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | writes `\U+` escapes (#169), which is correct; declares `ANSI_1252` | +| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page gives pure ASCII with escapes (#169) | | `\U+`/`\M+` escapes | yes | yes (`\M+` since #505; Johab `\M+4` stays literal) | `\U+` yes | ### DXF content: read and write by object type @@ -138,6 +138,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Pre-2007 DXF saves write text in the file's code page (`Ação` as ANSI_1252 bytes, not `A\U+00E7\U+00E3o`); characters outside it stay `\U+` escapes | | 2026-10-17 | minor | DWG read: DGN line style components no line style definition reaches are left out before the DXF bridge, with a read note; an 83 MB site plan with 888,483 of them (1.2 GB of DXF, over the limit) now opens (#1482) | | 2026-10-12 | minor | PDF output: object transparency plotted as PDF alpha (`/ExtGState` `/ca` `/CA`) when the page setup's or the plot's "Plot transparency" is on; PNG plots blend it, SVG uses `opacity`; plots without it are unchanged byte for byte | | 2026-10-12 | minor | DXF/DWG save: the Model layout's plot settings are written as CADCraft plots model space, drawing extents scaled to fit (PLOTSETTINGS 74 = 1, 75 = 0), instead of plot type 5 "Layout information", which other programs' Plot dialogs can't use for model space | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3dbd01e22..5c0574616 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -31,8 +31,8 @@ shaping and right-to-left text through TrueType, vertical SHX text styles. English. For scale: about 1,160 unique capitalised string literals exist in `crates/engine` and `crates/ui-egui` (grep, 2026-10-10). - **Drawing text:** TrueType/OpenType/`.ttc` through `skrifa` with per-character font fallback - and FONTALT, so CJK characters render (#173). DXF text is written with `\U+` escapes so - non-ASCII survives in R2000 files (#169). Lines with right-to-left text get the Unicode + and FONTALT, so CJK characters render (#173). Pre-2007 DXF text is written in the file's code page, + with `\U+` escapes for characters outside it, so non-ASCII survives in R2000 files (#169). Lines with right-to-left text get the Unicode Bidirectional Algorithm and OpenType shaping with `harfrust` (Arabic, Persian and Urdu joining, lam-alef, ZWNJ, marks; Hebrew), in TEXT and MTEXT including wrapping (#287). Left-to-right lines are still placed one glyph per code point: **no shaping** for Devanagari and other Indic From 936f08db1feba5515f5ea7d51a9d3a37dda3aecd Mon Sep 17 00:00:00 2001 From: Rodrigo Gressler Date: Mon, 19 Oct 2026 22:25:35 -0300 Subject: [PATCH 002/151] DXF: keep files that declare ASCII or UTF8 pure ASCII when saving A pre-2007 file whose header says $DWGCODEPAGE ASCII (or UTF8/UNICODE, which no pre-2007 reader expects) is still written with \U+ escapes only, instead of Windows-1252 (or UTF-8) bytes the header doesn't declare. Review of #1693. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/dxf/src/codepage.rs | 14 +++++++++++++- crates/dxf/src/lib.rs | 7 ++++--- crates/dxf/tests/code_page_write.rs | 18 +++++++++++------- crates/io/tests/codepages.rs | 19 +++++++++++++++++++ 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/dxf/src/codepage.rs b/crates/dxf/src/codepage.rs index 53f24c3b6..9377404d9 100644 --- a/crates/dxf/src/codepage.rs +++ b/crates/dxf/src/codepage.rs @@ -69,12 +69,24 @@ pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option { decoder(code_page).map(|d| decode(bytes, d).into_owned()) } +/// The code page a pre-2007 file's text is written in, or `None` for pure ASCII with escapes: +/// when CADCraft doesn't know `code_page`, and for `ASCII` (the reader takes such files as +/// Windows-1252, but a writer must keep them ASCII) and `UTF8`/`UNICODE`, which no pre-2007 +/// reader expects. +pub(crate) fn encoder(code_page: &str) -> Option { + if code_page.trim().eq_ignore_ascii_case("ASCII") { + return None; + } + decoder(code_page).filter(|d| !matches!(d, Decoder::Utf8)) +} + /// Append the bytes of the non-ASCII character `c` in the code page `d`, and return true, when /// the code page has it; otherwise append nothing and return false (the caller escapes it). pub(crate) fn encode_char(c: char, d: Decoder, out: &mut Vec) -> bool { let mut buf = [0; 4]; match d { - Decoder::Utf8 => out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()), + // Not a pre-2007 code page ([`encoder`] never gives it): escaped. + Decoder::Utf8 => return false, Decoder::Encoding(e) => { let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf)); // A code page byte that is ASCII would read back as another character, and a NUL, diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index e37e3e13e..0bbb4ee68 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -418,9 +418,10 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String { /// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a /// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions: /// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A -/// code page CADCraft doesn't know gives [`write_ascii`]'s pure ASCII output. +/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page) +/// give [`write_ascii`]'s pure ASCII output. pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec { - ascii(tags, Strings::Escaped(codepage::decoder(code_page))) + ascii(tags, Strings::Escaped(codepage::encoder(code_page))) } /// How a writer stores string values. @@ -463,7 +464,7 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec { /// Write tags as pre-R2007 binary DXF with text in the code page `code_page` names, as /// [`write_ascii_in`] does. pub fn write_binary_in(tags: &[Tag], code_page: &str) -> Vec { - binary(tags, Strings::Escaped(codepage::decoder(code_page))) + binary(tags, Strings::Escaped(codepage::encoder(code_page))) } fn binary(tags: &[Tag], strings: Strings) -> Vec { diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs index 316f2edd5..67e7e7e57 100644 --- a/crates/dxf/tests/code_page_write.rs +++ b/crates/dxf/tests/code_page_write.rs @@ -46,12 +46,16 @@ fn text_is_written_in_the_code_page() { } } -/// Without a known code page the output is what `write_ascii` writes, line breaks included. +/// Without a known code page, and for `ASCII`, `UTF8` and `UNICODE` headers, the output is what +/// `write_ascii` writes (pure ASCII with escapes), line breaks included. #[test] -fn unknown_code_page_is_pure_ascii() { - let tags = file("EBCDIC", "Ação\r\nfim"); - let bytes = write_ascii_in(&tags, "EBCDIC"); - assert!(bytes.is_ascii()); - assert_eq!(bytes, write_ascii(&tags).into_bytes()); - assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim")); +fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() { + for cp in ["EBCDIC", "ASCII", "ascii ", "UTF8", "UNICODE"] { + let tags = file(cp, "Ação\r\nfim"); + let bytes = write_ascii_in(&tags, cp); + assert!(bytes.is_ascii(), "{cp}"); + assert_eq!(bytes, write_ascii(&tags).into_bytes(), "{cp}"); + assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim"), "{cp}"); + assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary"); + } } diff --git a/crates/io/tests/codepages.rs b/crates/io/tests/codepages.rs index 53c22e908..56f9af80f 100644 --- a/crates/io/tests/codepages.rs +++ b/crates/io/tests/codepages.rs @@ -146,6 +146,25 @@ fn saving_writes_text_in_the_code_page() { } } +/// A file whose header declares `ASCII` keeps that code page and stays ASCII when saved: its +/// non-ASCII text is written as `\U+` escapes, not as Windows-1252 bytes. +#[test] +fn saving_an_ascii_file_keeps_it_ascii() { + use cadcraft_io::{SaveOptions, Version}; + let d = cadcraft_io::read_dxf(&ascii_dxf("AC1015", "ASCII", b"El\\U+00E9trica", b"B", b"Eletrica \\U+00E9")).unwrap(); + assert_eq!(names(&d), ("Elétrica".into(), "B".into(), "Eletrica é".into())); + for version in [Version::R2000, Version::R2004] { + let ascii = cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: false }).unwrap(); + assert!(ascii.is_ascii(), "{version}"); + let binary = cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: true }).unwrap(); + for out in [&ascii, &binary] { + assert!(out.windows(14).any(|w| w == b"El\\U+00E9trica"), "{version}"); + assert!(out.windows(5).any(|w| w == b"ASCII"), "{version}: the header keeps its code page"); + assert_eq!(names(&cadcraft_io::read_dxf(out).unwrap()), ("Elétrica".into(), "B".into(), "Eletrica é".into()), "{version}"); + } + } +} + /// A binary DXF: R2000 strings are in the code page, R2007+ strings UTF-8. #[test] fn binary_dxf_honours_the_code_page() { From a6ba747329634706a5c938fe73492f558f46ffdd Mon Sep 17 00:00:00 2001 From: MrBeeboh Date: Thu, 22 Oct 2026 20:20:11 -0700 Subject: [PATCH 003/151] dxf: raise the group-code limit to 150 million and allow CADCRAFT_MAX_DXF_TAGS to override it (#1948) --- crates/dxf/src/lib.rs | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 82ecf55e8..b824e4e57 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -162,16 +162,31 @@ fn typed(code: i32, text: &str) -> Value { } const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0"; -/// Most group codes read from one file. A `Tag` takes 40 bytes plus its text, so this bounds the -/// tag list to a few GB; real drawings stay far below (a 10 MB DWG is typically a few million). -pub const MAX_TAGS: usize = 50_000_000; +/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this +/// bounds the tag list to roughly 6 GB plus text. It matches the 1 GB DXF rendition a DWG may +/// convert to (a 1 GB DXF holds well over 50 million group codes, so a lower cap rejected large +/// DWGs that converted fine, issue #1948). Set `CADCRAFT_MAX_DXF_TAGS` to raise or lower it. +pub const MAX_TAGS: usize = 150_000_000; + +/// Environment variable overriding [`MAX_TAGS`] (a positive integer). +pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS"; + +/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else +/// [`MAX_TAGS`]. +pub fn max_tags() -> usize { + max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref()) +} + +fn max_tags_from(v: Option<&str>) -> usize { + v.and_then(|s| s.trim().replace('_', "").parse::().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS) +} mod codepage; pub use codepage::decode_code_page; /// Parse a DXF file (ASCII or binary). pub fn parse(bytes: &[u8]) -> Result> { - parse_with_limit(bytes, MAX_TAGS) + parse_with_limit(bytes, max_tags()) } /// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from @@ -258,7 +273,7 @@ fn unescape_unicode(s: &str) -> String { } pub fn parse_ascii(text: &str) -> Result> { - parse_ascii_with_limit(text, MAX_TAGS) + parse_ascii_with_limit(text, max_tags()) } #[allow(clippy::while_let_loop)] @@ -344,7 +359,7 @@ impl<'a> Cur<'a> { /// Binary DXF (R13+: 2-byte group codes). pub fn parse_binary(b: &[u8]) -> Result> { - parse_binary_with_limit(b, MAX_TAGS) + parse_binary_with_limit(b, max_tags()) } fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result> { @@ -683,3 +698,19 @@ mod tests { assert_eq!(parse(&b).unwrap()[0].str(), "图号"); } } + +#[cfg(test)] +mod limit_tests { + use super::*; + + #[test] + fn tag_limit_defaults_and_env_override() { + assert_eq!(max_tags_from(None), MAX_TAGS); + assert_eq!(max_tags_from(Some("")), MAX_TAGS); + assert_eq!(max_tags_from(Some("0")), MAX_TAGS); + assert_eq!(max_tags_from(Some("abc")), MAX_TAGS); + assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000); + // Issue #1948: the default must admit a DXF rendition well above 50 million group codes. + assert!(MAX_TAGS >= 100_000_000); + } +} From 6ed822ec30e6e8ab7b3cc137ad0cbcd2e8e324da Mon Sep 17 00:00:00 2001 From: MrBeeboh Date: Thu, 22 Oct 2026 20:22:09 -0700 Subject: [PATCH 004/151] Plot style and plotter files save atomically through write_atomic (#1928 follow-up) --- crates/engine/src/cmd/layout/plotstyle.rs | 5 ++--- crates/engine/src/cmd/plotters.rs | 4 +--- crates/engine/tests/plot_styles.rs | 27 +++++++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/layout/plotstyle.rs b/crates/engine/src/cmd/layout/plotstyle.rs index b92b15826..48e22f5ce 100644 --- a/crates/engine/src/cmd/layout/plotstyle.rs +++ b/crates/engine/src/cmd/layout/plotstyle.rs @@ -332,9 +332,8 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result { Some(_path) => { #[cfg(not(target_arch = "wasm32"))] { - let tmp = format!("{_path}.cadcraft-tmp"); - std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?; - std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?; + crate::atomic_write::write_atomic(std::path::Path::new(_path), text.as_bytes()) + .map_err(|e| bad(cmd, format!("{_path}: {e}")))?; Ok(json!({ "name": name, "path": _path, "bytes": text.len() })) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index 454b977ae..c8ea9b9a8 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -110,9 +110,7 @@ impl PlotterStore { write: |n, t| { let p = file(n)?; std::fs::create_dir_all(dir()?).map_err(|e| e.to_string())?; - let tmp = p.with_extension("json.cadcraft-tmp"); - std::fs::write(&tmp, t).map_err(|e| format!("{n}: {e}"))?; - std::fs::rename(&tmp, &p).map_err(|e| format!("{n}: {e}")) + crate::atomic_write::write_atomic(&p, t.as_bytes()).map_err(|e| format!("{n}: {e}")) }, delete: |n| std::fs::remove_file(file(n)?).map_err(|e| format!("{n}: {e}")), } diff --git a/crates/engine/tests/plot_styles.rs b/crates/engine/tests/plot_styles.rs index 06f8cb424..4ad0922c3 100644 --- a/crates/engine/tests/plot_styles.rs +++ b/crates/engine/tests/plot_styles.rs @@ -104,3 +104,30 @@ fn stylesmanager_creates_edits_saves_and_loads_tables() { // Undo takes a table edit back. s.execute("undo", &json!({})).unwrap(); } + +/// Issue #1928: saving a plot style table to a file goes through the atomic writer: a symlink +/// planted at the old fixed temporary name is left alone, and the file keeps its permissions. +#[cfg(unix)] +#[test] +fn plot_style_save_is_atomic_and_keeps_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("cadcraft-pstyle-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("office.stb.json"); + std::fs::write(&path, "old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let victim = dir.join("victim"); + std::fs::write(&victim, "keep").unwrap(); + std::os::unix::fs::symlink(&victim, dir.join("office.stb.json.cadcraft-tmp")).unwrap(); + + let mut s = Session::new(); + s.execute("stylesmanager", &json!({"action": "new", "name": "office.stb"})).unwrap(); + let p = path.to_string_lossy().to_string(); + s.execute("stylesmanager", &json!({"action": "save", "name": "office.stb", "path": p})).unwrap(); + + assert_ne!(std::fs::read_to_string(&path).unwrap(), "old"); + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = std::fs::remove_dir_all(&dir); +} From a13870f376c4e6f6d7e7814ab8238d74424117d5 Mon Sep 17 00:00:00 2001 From: MrBeeboh Date: Thu, 22 Oct 2026 20:56:55 -0700 Subject: [PATCH 005/151] dxf: check the tag limit in a const block --- crates/dxf/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index b824e4e57..4eacd5cfc 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -711,6 +711,6 @@ mod limit_tests { assert_eq!(max_tags_from(Some("abc")), MAX_TAGS); assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000); // Issue #1948: the default must admit a DXF rendition well above 50 million group codes. - assert!(MAX_TAGS >= 100_000_000); + const { assert!(MAX_TAGS >= 100_000_000) }; } } From 0646dca86ef4624a6665b04025acaaa33e1a5c44 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:05:36 -0300 Subject: [PATCH 006/151] DXF: escape ASCII output whose code page bytes are valid UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ASCII DXF that is valid UTF-8 is read as UTF-8, so pre-2007 text such as СІ in ANSI_1251 (D1 B2) came back as U+0472. write_ascii_in now falls back to \U+ escapes for such a file; binary DXF, read by its header's code page, keeps the bytes. Reported by CodeRabbit on #1693. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/dxf/src/lib.rs | 10 ++++++++-- crates/dxf/tests/code_page_write.rs | 17 +++++++++++++++++ docs/file-format-parity.md | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 0bbb4ee68..72f834978 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -419,9 +419,15 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String { /// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions: /// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A /// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page) -/// give [`write_ascii`]'s pure ASCII output. +/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be +/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII +/// DXF that is valid UTF-8 as UTF-8. pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec { - ascii(tags, Strings::Escaped(codepage::encoder(code_page))) + let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page))); + if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() { + return ascii(tags, Strings::Escaped(None)); + } + bytes } /// How a writer stores string values. diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs index 67e7e7e57..bec95b73a 100644 --- a/crates/dxf/tests/code_page_write.rs +++ b/crates/dxf/tests/code_page_write.rs @@ -59,3 +59,20 @@ fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() { assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary"); } } + +/// Code page bytes that happen to form valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, which is U+0472 +/// in UTF-8) would be read back as UTF-8 from an ASCII file, so that file is written with +/// escapes. Binary DXF reads strings by the header's code page and keeps the bytes. +#[test] +fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() { + let tags = file("ANSI_1251", "СІ"); + let ascii = write_ascii_in(&tags, "ANSI_1251"); + assert!(ascii.is_ascii(), "{:?}", String::from_utf8_lossy(&ascii)); + assert!(contains(&ascii, b"\\U+0421\\U+0406")); + assert_eq!(parse(&ascii).unwrap()[10].str(), "СІ"); + let binary = write_binary_in(&tags, "ANSI_1251"); + assert!(contains(&binary, b"\xD1\xB2")); + assert_eq!(parse(&binary).unwrap()[10].str(), "СІ"); + // Bytes that are not valid UTF-8 stay as they are. + assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9")); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6dd6f783f..d919cf6ba 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -66,7 +66,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | 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 | 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 | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page gives pure ASCII with escapes (#169) | +| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page, or ASCII DXF whose code page bytes would also read as UTF-8, gives pure ASCII with escapes (#169) | | `\U+`/`\M+` escapes | yes | yes (`\M+` since #505; Johab `\M+4` stays literal) | `\U+` yes | ### DXF content: read and write by object type From 4fdde4f30161059ee0ef49847a1fc3ef8f645ace Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:40:24 +0000 Subject: [PATCH 007/151] Update symlink target in write_atomic instead of replacing the link Found while testing crates/engine/src/atomic_write.rs:33-58 (write_atomic) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/atomic_write.rs | 84 +++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/crates/engine/src/atomic_write.rs b/crates/engine/src/atomic_write.rs index ff9e141d3..c7cabe877 100644 --- a/crates/engine/src/atomic_write.rs +++ b/crates/engine/src/atomic_write.rs @@ -30,6 +30,9 @@ const MAX_TRIES: u32 = 32; /// are written through the retained handle and flushed, and the temporary is then renamed over /// `path`. On any failure the temporary is removed and the original `path` is left untouched. pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + // A symlinked destination is resolved so the link stays a link and its target is replaced. + let resolved = resolve_symlink(path)?; + let path = resolved.as_deref().unwrap_or(path); let permissions = match std::fs::symlink_metadata(path) { Ok(meta) if meta.is_file() => Some(meta.permissions()), Ok(_) => None, @@ -58,6 +61,41 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { Ok(()) } +/// Resolve `path` when it is a symlink, or return `None` if it is not one. +/// +/// A dangling link (for example into an unavailable mount) cannot be canonicalized, so its target +/// is followed by hand with `read_link`, joining relative targets onto the link's directory. +fn resolve_symlink(path: &Path) -> io::Result> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => {} + _ => return Ok(None), + } + if let Ok(target) = std::fs::canonicalize(path) { + return Ok(Some(target)); + } + let mut current = path.to_path_buf(); + for _ in 0..40 { + let target = std::fs::read_link(¤t)?; + current = if target.is_absolute() { target } else { current.parent().map_or(target.clone(), |p| p.join(&target)) }; + match std::fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => {} + Ok(_) => return finish_target(¤t), + Err(e) if e.kind() == io::ErrorKind::NotFound => return finish_target(¤t), + Err(e) => return Err(e), + } + } + Err(io::Error::new(io::ErrorKind::InvalidInput, "too many levels of symbolic links")) +} + +/// Build the write path for a resolved, non-link (possibly missing) target. +fn finish_target(current: &Path) -> io::Result> { + // The target's directory must exist; otherwise report the error rather than replacing the link. + let parent = current.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf); + let parent = std::fs::canonicalize(parent)?; + let name = current.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "symlink target has no file name"))?; + Ok(Some(parent.join(name))) +} + /// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path. /// /// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing @@ -147,6 +185,52 @@ mod tests { assert_eq!(std::fs::read(&dest).unwrap(), b"private report"); std::fs::remove_dir_all(dir).unwrap(); } + #[cfg(unix)] + #[test] + fn symlinked_destination_updates_the_target() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = std::env::temp_dir().join(format!("cadcraft-aw-link-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let real = dir.join("real.dxf"); + let link = dir.join("link.dxf"); + std::fs::write(&real, b"old").unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&real, &link).unwrap(); + write_atomic(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "symlink was replaced by a regular file"); + assert_eq!(std::fs::read(&real).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o600); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn dangling_symlink_destination_creates_the_target() { + use std::os::unix::fs::symlink; + let dir = std::env::temp_dir().join(format!("cadcraft-aw-dangle-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(dir.join("mnt")).unwrap(); + let real = dir.join("mnt").join("real.dxf"); + let link = dir.join("link.dxf"); + symlink("mnt/real.dxf", &link).unwrap(); + write_atomic(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "symlink was replaced by a regular file"); + assert_eq!(std::fs::read(&real).unwrap(), b"new"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn dangling_symlink_with_missing_parent_errors_and_keeps_the_link() { + use std::os::unix::fs::symlink; + let dir = std::env::temp_dir().join(format!("cadcraft-aw-nomnt-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let link = dir.join("link.dxf"); + symlink("missing/real.dxf", &link).unwrap(); + assert!(write_atomic(&link, b"new").is_err()); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + std::fs::remove_dir_all(dir).unwrap(); + } + #[cfg(unix)] #[test] fn private_temp_is_restricted_from_creation() { From 62b9b5acd468a5988bccb01625f7beb867e64ce7 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:58:10 +0300 Subject: [PATCH 008/151] engine: save, render and export create a missing target folder instead of failing with os error 3 --- crates/engine/src/atomic_write.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/engine/src/atomic_write.rs b/crates/engine/src/atomic_write.rs index ff9e141d3..cd7d0fb4b 100644 --- a/crates/engine/src/atomic_write.rs +++ b/crates/engine/src/atomic_write.rs @@ -37,6 +37,9 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { Err(e) => return Err(e), }; let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf); + if let Err(e) = std::fs::create_dir_all(&dir) { + return Err(e); + } let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?; let write = (|| { if let Some(permissions) = permissions { @@ -101,6 +104,15 @@ fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Resul mod tests { use super::*; + #[test] + fn creates_missing_parent_directories() { + let dir = std::env::temp_dir().join(format!("cadcraft-aw-mkdir-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + let dest = dir.join("new_folder").join("out.bin"); + write_atomic(&dest, b"created").unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), b"created"); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn replaces_contents_atomically() { let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); From 4398a1aab12d10c29a7088963106ab7963849c60 Mon Sep 17 00:00:00 2001 From: Andrea Brandi Date: Tue, 27 Oct 2026 18:47:15 +0100 Subject: [PATCH 009/151] fix(it): correct CADCraft chamfer terminology --- ATTRIBUTION.md | 2 +- contributors/people.toml | 2 ++ crates/ui-egui/src/i18n/it.tsv | 8 ++++---- docs/localization-parity.md | 5 +++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index e124d102a..73ea1c0e0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -70,7 +70,7 @@ Generated-in-code assets are original and have no file to list: | `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | | `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | | `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker | -| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | +| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted); terminology corrections: Andrea Brandi (@starise) with OpenAI Codex (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; targeted Chamfer terminology review by a native speaker | | `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added | | `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | | `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | diff --git a/contributors/people.toml b/contributors/people.toml index 40d6ba823..7d45ed5f2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -91,3 +91,5 @@ sync_github_name = false real_name = "Robert Lee" sync_github_name = true +[people.starise] +real_name = "Andrea Brandi" diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 27786b2e5..f012f0907 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -131,7 +131,7 @@ menu Modify Edita Center, Start, Angle Centro, inizio, angolo Center, Start, End Centro, inizio, fine Center, Start, Length Centro, inizio, lunghezza - Chamfer Cima + Chamfer Smusso Change Space Cambia spazio Change to Current Layer Cambia al layer corrente Changed: {n} Modificati: {n} @@ -915,8 +915,8 @@ menu Modify Edita Specify fillet radius Specificare il raggio di raccordo Specify fillet radius for rectangles Specificare il raggio di raccordo dei rettangoli Specify first angle endpoint Specificare il primo estremo dell'angolo - Specify first chamfer distance Specificare la prima distanza di cimatura - Specify first chamfer distance for rectangles Specificare la prima distanza di cimatura dei rettangoli + Specify first chamfer distance Specificare la prima distanza di smusso + Specify first chamfer distance for rectangles Specificare la prima distanza di smusso dei rettangoli Specify first constraint point Specificare il primo punto del vincolo Specify first corner Specificare il primo angolo Specify first corner point Specificare il primo angolo @@ -1001,7 +1001,7 @@ menu Modify Edita Specify scale factor Specificare il fattore di scala Specify second angle endpoint Specificare il secondo estremo dell'angolo Specify second break point Specificare il secondo punto di interruzione - Specify second chamfer distance for rectangles Specificare la seconda distanza di cimatura dei rettangoli + Specify second chamfer distance for rectangles Specificare la seconda distanza di smusso dei rettangoli Specify second constraint point Specificare il secondo punto del vincolo Specify second destination point Specificare il secondo punto di destinazione Specify second end point of circle's diameter Specificare il secondo estremo del diametro del cerchio diff --git a/docs/localization-parity.md b/docs/localization-parity.md index b4ff05f68..b19a07eb0 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (the command line enables the OS IME and handles composition/commit, so single-line TEXT edits accept CJK input; previously: Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** patch (Italian Chamfer terminology corrected; previously: the command line enables the OS IME and handles composition/commit, so single-line TEXT edits accept CJK input; previously: Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How far CADCraft's interface and drawing text are from AutoCAD's languages. Summarised in [ROADMAP.md](../ROADMAP.md#languages); work items in [gaps.md](gaps.md#localization). @@ -71,7 +71,7 @@ plus native review by a human; extending the catalog to the remaining English-on | Ukrainian | uk | 989 catalog rows (≈ 100% of the catalog; ≈ 80% of all UI strings, estimated: engine diagnostics and uncatalogued text stay English) | dialogs and tooltips yes; command reference searchable in Ukrainian | Cyrillic: yes (bundled egui fonts) | translated by a contributor; no second review | partial | 2–4 h | | Czech | cs | 1,202 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes; contextual menu headings; command reference searchable in Czech | Latin: yes (bundled egui proportional and monospace fonts; Czech letters and quotes tested) | AI-assisted by @Losminek; independent review pass; not yet proofread by a native speaker | complete current catalog; English-only interface gaps remain | native review + shared English-only UI work | | Swedish | sv | 1,330 catalog rows; reference catalog, marked UI literals and menu headings covered (no gaps reported) | dialogs and tooltips yes; contextual menu headings; command reference searchable in Swedish | Latin: yes (bundled egui proportional and monospace fonts; å ä ö é and Swedish quotes tested) | AI-assisted (Claude Code) by @holmbom; terminology and the running app reviewed by a native speaker | complete current catalog; English-only interface gaps remain | line-by-line proofreading + shared English-only UI work | -| Italian | it | 1,227 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes; contextual menu headings (Visualizza / Vista, Edita / Modifica); command reference searchable in Italian | Latin: yes (bundled egui fonts; accented letters tested) | AI-assisted by Emanuele Bottelli (Claude), written from the English strings with Italian desktop-CAD vocabulary; no native proofreading pass yet | complete current catalog; English-only interface gaps remain | native review + shared English-only UI work | +| Italian | it | 1,227 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes; contextual menu headings (Visualizza / Vista, Edita / Modifica); command reference searchable in Italian | Latin: yes (bundled egui fonts; accented letters tested) | AI-assisted by Emanuele Bottelli (Claude), written from the English strings with Italian desktop-CAD vocabulary; targeted Chamfer terminology review by Andrea Brandi (@starise), a native speaker, with OpenAI Codex; full native proofreading pending | complete current catalog; English-only interface gaps remain | native review + shared English-only UI work | | Hungarian | hu | 1,326 catalog rows; reference catalog, marked UI literals and menu headings covered (no gaps reported) | dialogs and tooltips yes; contextual menu headings; command reference searchable in Hungarian | Latin: yes (bundled egui proportional and monospace fonts; ő ű Ő Ű and the other accented letters tested) | translated by @Skaywalker from the English strings; independent native proofreading pending | complete current catalog; English-only interface gaps remain | native proofreading + shared English-only UI work | | Turkish | tr | 1,330 catalog rows; reference catalog, marked UI literals and menu headings covered (no gaps reported) | dialogs and tooltips yes; contextual menu headings (Ölçülendirme / Ölçü); command reference searchable in Turkish | Latin: yes (bundled egui proportional and monospace fonts; Ç Ğ İ Ö Ş Ü ç ğ ı ö ş ü tested) | AI-assisted by @ogzhnklvz, written from the English strings with Turkish desktop-CAD vocabulary; the running app reviewed by a native speaker | complete current catalog; English-only interface gaps remain | line-by-line proofreading + shared English-only UI work | @@ -89,6 +89,7 @@ each language, which needs humans. Translations parallelise fully once the catal | Date | Change | Summary | |---|---|---| +| 2026-10-27 | patch | Italian: corrected Chamfer and its three existing distance prompts to use Smusso; targeted native-speaker terminology review with OpenAI Codex assistance | | 2026-10-22 | minor | Command line IME: enabled while a prompt collects text, composition shown but not saved, commit inserted once; single-line TEXT edits accept CJK input (#1761) | | 2026-10-19 | minor | Brazilian Portuguese: row brought up to date (it still read 0%); native-speaker terminology pass on 69 rows (Bind as Integrar, prompt keywords and SPDX kept in English, Thaw Layer, Snap type, Revolve fixed; snap, viewport, grip, offset, layer on/off and multiline wording made consistent with Brazilian usage) | | 2026-10-15 | minor | Greek (`el`) catalog: 1,330 rows, context/plural/locale/font tests; review pending | From b17ae78cec362ac42ccf8929afcc6593202a2a0d Mon Sep 17 00:00:00 2001 From: austin62jake <340374338+austin62jake@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:01:18 -0500 Subject: [PATCH 010/151] fix(fonts): place Middle and bottom-justified text as AutoCAD does (#2229) A justified TEXT's insertion point (group 10) is computed from its alignment point, and AutoCAD takes it from a DWG as it is (it recomputes it only for DXF). CADCraft placed a Middle (M) text at half its height and a bottom-justified one a third of its height below the baseline, whatever the string or font, so in AutoCAD those texts were drawn off their alignment points: 0.35 at height 2.5 for "Tg 1234 ABC" justified M, 0.09 for BL with Arial (#2229). - Middle (M) now centres the text on its own ink, descenders included, as AutoCAD does. Text that draws nothing keeps half the height. Middle Center (MC) and the others are unchanged. - Bottom justifications with a TrueType face put the alignment point at the face's descent (its descent over its cap height, the text height): 0.296 of the height for Arial, where AutoCAD puts it. The stroke font keeps a third, which matches AutoCAD's SHX fonts. `frame` (the DWG insertion point) and `place` (drawing) share the new `justify_offset`, so what is drawn and what is saved agree. With the reporter's commands, the saved points are now within 0.0006 of AutoCAD's: M -0.9032 (AutoCAD -0.9038), BL -19.2604 (-19.2599). Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/lib.rs | 156 ++++++++++++++++++++++++++++++------- crates/fonts/src/ttf.rs | 10 +++ docs/file-format-parity.md | 5 +- 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 13af05646..8ca1bc62f 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -502,6 +502,42 @@ pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 { origin + Vec2::new(local.x * mirror.x, local.y * mirror.y).rotate(rot) } +/// How far below the baseline a bottom-justified text's alignment point lies: a third of the +/// height for the stroke font (as AutoCAD places it with SHX fonts), the face's descent for an +/// outline font (#2229). +fn bottom_depth(font: &TextFont, height: f64) -> f64 { + match font { + TextFont::Outline(face) => ttf::descent_ratio(face).map_or(height / 3.0, |r| r * height), + TextFont::Stroke => height / 3.0, + } +} + +/// The vertical middle of shaped text's ink (its strokes and outlines), above the baseline; +/// `None` when it draws nothing. A Middle (M) text is centred there: AutoCAD centres it on the +/// string's own extents, descenders included, not at half the height (#2229). +fn ink_middle(sh: &Shaped) -> Option { + let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y).filter(|y| y.is_finite()); + let (lo, hi) = ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y))); + (lo <= hi).then_some((lo + hi) / 2.0) +} + +/// The offset of a line's baseline start from its alignment point, for justification `h`/`v`. +fn justify_offset(font: &TextFont, sh: &Shaped, height: f64, h: Align, v: VAlign) -> Vec2 { + let dx = match h { + Align::Left | Align::Aligned | Align::Fit => 0.0, + Align::Center | Align::Middle => -sh.width / 2.0, + Align::Right => -sh.width, + }; + let dy = match (h, v) { + (Align::Middle, _) => -ink_middle(sh).unwrap_or(height / 2.0), + (_, VAlign::Baseline) => 0.0, + (_, VAlign::Bottom) => bottom_depth(font, height), + (_, VAlign::Middle) => -height / 2.0, + (_, VAlign::Top) => -height, + }; + Vec2::new(dx, dy) +} + /// How [`place`] puts a line in the world: the point it is placed by, the offset of its baseline /// start from that point (along the text), its rotation and its mirroring (backwards, upside /// down). Aligned and fit text are placed by their insertion point. @@ -512,25 +548,13 @@ fn frame(font: &TextFont, s: &str, p: &TextParams) -> (Vec2, Vec2, f64, Vec2) { let rot = p.align_pt.map_or(rot, |p2| p.insert.angle_to(p2)); return (p.insert, Vec2::ZERO, rot, Vec2::new(1.0, 1.0)); } - let width = shape_line(font, s, height, p.width_factor, p.oblique).width; + let sh = shape_line(font, s, height, p.width_factor, p.oblique); let origin = match (p.h, p.v) { (Align::Left, VAlign::Baseline) => p.insert, _ => p.align_pt.unwrap_or(p.insert), }; - let dx = match p.h { - Align::Left | Align::Aligned | Align::Fit => 0.0, - Align::Center | Align::Middle => -width / 2.0, - Align::Right => -width, - }; - let dy = match (p.h, p.v) { - (Align::Middle, _) => -height / 2.0, - (_, VAlign::Baseline) => 0.0, - (_, VAlign::Bottom) => height / 3.0, - (_, VAlign::Middle) => -height / 2.0, - (_, VAlign::Top) => -height, - }; let mirror = Vec2::new(if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 }); - (origin, Vec2::new(dx, dy), rot, mirror) + (origin, justify_offset(font, &sh, height, p.h, p.v), rot, mirror) } /// Place a single-line TEXT in world coordinates with any font. Returns the shaped text and its @@ -563,28 +587,15 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) { (Align::Left, VAlign::Baseline) | (Align::Aligned | Align::Fit, _) => insert, _ => p.align_pt.unwrap_or(insert), }; - let dx = match p.h { - Align::Left | Align::Aligned | Align::Fit => 0.0, - Align::Center | Align::Middle => -width / 2.0, - Align::Right => -width, - }; - let dy = match (p.h, p.v) { - (Align::Middle, _) => -height / 2.0, - (_, VAlign::Baseline) => 0.0, - (_, VAlign::Bottom) => height / 3.0, - (_, VAlign::Middle) => -height / 2.0, - (_, VAlign::Top) => -height, - }; - let local = Vec2::new(dx, dy); + let local = justify_offset(font, &sh, height, p.h, p.v); let (mx, my) = (if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 }); let xf = |q: Vec2| { let l = q + local; origin + Vec2::new(l.x * mx, l.y * my).rotate(rot) }; sh.map(xf); - let bb = Bounds2::from_points( - [Vec2::new(0.0, -height / 3.0), Vec2::new(width, -height / 3.0), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf), - ); + let depth = bottom_depth(font, height); + let bb = Bounds2::from_points([Vec2::new(0.0, -depth), Vec2::new(width, -depth), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf)); (sh, bb) } @@ -797,3 +808,88 @@ mod ttf_tests { assert!(crate::is_wide('\u{3042}') && crate::is_wide('\u{AC00}') && crate::is_wide('\u{FF21}') && !crate::is_wide('A')); } } + +#[cfg(test)] +mod justify_tests { + use super::*; + + const H: f64 = 2.5; + + /// `s` placed by the alignment point (0, 0) with justification `h`/`v` in `font`. + fn placed(font: &TextFont, s: &str, h: Align, v: VAlign) -> (Shaped, Vec2) { + let p = TextParams { align_pt: Some(Vec2::ZERO), h, v, ..TextParams::new(Vec2::ZERO, H) }; + (place(font, s, &p).0, baseline_start(font, s, &p)) + } + + /// The lowest and highest point of placed text's ink. + fn ink_y(sh: &Shaped) -> (f64, f64) { + let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y); + ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y))) + } + + /// A Middle (M) text is centred on its own ink, descenders included, as AutoCAD centres it, + /// not at half the height: lowercase and descenders move the baseline (#2229). + #[test] + fn a_middle_text_is_centred_on_its_ink() { + let font = TextFont::Stroke; + let mut baselines = Vec::new(); + for s in ["ABC 123", "gjpqy", "Tg 1234 ABC", "ace"] { + let (sh, start) = placed(&font, s, Align::Middle, VAlign::Baseline); + let (lo, hi) = ink_y(&sh); + assert!(((lo + hi) / 2.0).abs() < 1e-9, "{s}: ink {lo}..{hi} is centred on the alignment point"); + // The insertion point a DWG keeps (group 10) is the baseline start, below the ink's + // middle by as much as the unplaced text's ink middle is above its baseline. + let (lo0, hi0) = ink_y(&shape_line(&font, s, H, 1.0, 0.0)); + assert!((start.y + (lo0 + hi0) / 2.0).abs() < 1e-9, "{s}: baseline start {start:?}"); + baselines.push((s, start.y)); + } + // Capitals and digits span the height, so they stay at half of it. + assert!((baselines[0].1 + H / 2.0).abs() < 1e-9, "{baselines:?}"); + // Descenders, or lowercase alone, bring the ink's middle down, so the baseline sits higher. + for (s, y) in &baselines[1..] { + if *s != "Tg 1234 ABC" { + assert!(*y > -H / 2.0 + 0.1, "{s}: {baselines:?}"); + } + } + } + + /// Text that draws nothing has no ink to centre: it keeps half the height. + #[test] + fn a_middle_text_without_ink_keeps_half_the_height() { + let (_, start) = placed(&TextFont::Stroke, " ", Align::Middle, VAlign::Baseline); + assert!((start.y + H / 2.0).abs() < 1e-9, "{start:?}"); + } + + /// With the stroke font (as with AutoCAD's SHX fonts), a bottom-justified text's baseline is + /// a third of the height above its alignment point, and Middle Center still uses half. + #[test] + fn stroke_font_bottom_and_middle_center_are_unchanged() { + let font = TextFont::Stroke; + for h in [Align::Left, Align::Center, Align::Right] { + let (_, start) = placed(&font, "gjpqy", h, VAlign::Bottom); + assert!((start.y - H / 3.0).abs() < 1e-9, "{h:?}: {start:?}"); + } + let (_, start) = placed(&font, "gjpqy", Align::Center, VAlign::Middle); + assert!((start.y + H / 2.0).abs() < 1e-9, "MC: {start:?}"); + } + + /// With a TrueType font, a bottom-justified text's alignment point is at the face's descent, + /// where its descenders end, as AutoCAD places it (#2229: Arial's is 0.296 of the height). + /// Skips when Arial isn't installed. + #[test] + fn a_bottom_justified_truetype_text_sits_on_its_descent() { + let Some(face) = ttf::find("Arial") else { return }; + let ratio = ttf::descent_ratio(&face).unwrap(); + assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}"); + let font = TextFont::Outline(face); + let (sh, start) = placed(&font, "gjpqy", Align::Left, VAlign::Bottom); + assert!((start.y - ratio * H).abs() < 1e-9, "{start:?}"); + // The descenders reach the alignment point's line: h/3 left them 0.1 above it. + let (lo, _) = ink_y(&sh); + assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point"); + // And Middle centres the ink, as with the stroke font. + let (sh, _) = placed(&font, "Tg 1234 ABC", Align::Middle, VAlign::Baseline); + let (lo, hi) = ink_y(&sh); + assert!(((lo + hi) / 2.0).abs() < 1e-9, "{lo}..{hi}"); + } +} diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 6b4c5ebdd..9c1888aa8 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -709,6 +709,16 @@ impl OutlinePen for Pen { } } +/// How far below the baseline a face reaches, as a fraction of the text height (which is the +/// face's cap height): its descent over its cap height. A bottom-justified text's alignment +/// point is there, as AutoCAD places it (#2229). `None` when the face has no usable descent. +pub fn descent_ratio(face: &Face) -> Option { + let f = face.font()?; + let descent = -f64::from(f.metrics(Size::new(UPEM), LocationRef::default()).descent); + let r = descent / cap_height(&f); + (r.is_finite() && r > 0.0 && r < 2.0).then_some(r) +} + /// Cap height of a font in font units at 1000 units per em: the OS/2 value, else the height /// of its "H", else an estimate from the ascent. fn cap_height(f: &FontRef) -> f64 { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2bfea4a51..51d4ac74c 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-25 · **Change:** minor (LAYOUT SAveas writes a layout as a DWT template) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a Middle or bottom-justified TEXT's insertion point as AutoCAD computes it) · **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 @@ -75,7 +75,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG |---|---|---|---| | LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests | | POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy | -| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line) | ATTDEF prompt kept (#137) | +| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line) | ATTDEF prompt kept (#137); a justified TEXT's insertion point (group 10, which AutoCAD takes from a DWG as it is) is computed as AutoCAD computes it: Middle (M) centred on the string's own extents, bottom justifications at a TrueType face's descent (#2229) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written for every associated kind — linear/aligned, angular, radial/diametric, ordinate and arc length — and read back without CADCraft's xdata (#1651) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written; angular block text honors DIMDSEP, including per-dimension overrides (#1646) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks; their text keeps the diameter sign as `%%c`, so SHX styles plot it (#1645) | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written | @@ -140,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | TEXT placement: a Middle (M) text is centred on its own extents, descenders included, and a bottom-justified text with a TrueType font sits on the face's descent, as AutoCAD places them; the stroke font's bottom stays at a third of the height. A DWG's group 10 for these texts now matches AutoCAD's within 0.001 at height 2.5 (it was up to 0.35 off) (#2229) | | 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) | From 263eba2ef6430c570cd3cce5c2266a495075d95f Mon Sep 17 00:00:00 2001 From: austin62jake <340374338+austin62jake@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:15:20 -0500 Subject: [PATCH 011/151] Test bottom-justified TrueType text with any common installed font The test used Arial only, so it skipped on Linux and macOS CI. It now uses the first of Arial, Helvetica, DejaVuSans, Verdana or Liberation Sans that is installed (as the fonts crate's other TrueType tests do), and keeps the Arial-specific checks for Arial. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/lib.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8ca1bc62f..5bbae6883 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -875,21 +875,25 @@ mod justify_tests { /// With a TrueType font, a bottom-justified text's alignment point is at the face's descent, /// where its descenders end, as AutoCAD places it (#2229: Arial's is 0.296 of the height). - /// Skips when Arial isn't installed. + /// Uses the first of these common fonts that is installed (Windows, macOS and Linux each have + /// one); skips without any. #[test] fn a_bottom_justified_truetype_text_sits_on_its_descent() { - let Some(face) = ttf::find("Arial") else { return }; + let found = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].into_iter().find_map(|n| ttf::find(n).map(|f| (n, f))); + let Some((name, face)) = found else { return }; let ratio = ttf::descent_ratio(&face).unwrap(); - assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}"); let font = TextFont::Outline(face); let (sh, start) = placed(&font, "gjpqy", Align::Left, VAlign::Bottom); - assert!((start.y - ratio * H).abs() < 1e-9, "{start:?}"); - // The descenders reach the alignment point's line: h/3 left them 0.1 above it. - let (lo, _) = ink_y(&sh); - assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point"); + assert!((start.y - ratio * H).abs() < 1e-9, "{name}: {start:?}"); + if name == "Arial" { + assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}"); + // The descenders reach the alignment point's line: h/3 left them 0.1 above it. + let (lo, _) = ink_y(&sh); + assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point"); + } // And Middle centres the ink, as with the stroke font. let (sh, _) = placed(&font, "Tg 1234 ABC", Align::Middle, VAlign::Baseline); let (lo, hi) = ink_y(&sh); - assert!(((lo + hi) / 2.0).abs() < 1e-9, "{lo}..{hi}"); + assert!(((lo + hi) / 2.0).abs() < 1e-9, "{name}: {lo}..{hi}"); } } From 4226a2610abc648880c7be0fc7768641fa805b97 Mon Sep 17 00:00:00 2001 From: assamaral Date: Thu, 29 Oct 2026 15:37:34 -0300 Subject: [PATCH 012/151] test(io): an MTEXT's R2018 embedded column object is read and written (#2248) Co-Authored-By: Claude Sonnet 5.5 --- crates/io/tests/it/mtext_columns_roundtrip.rs | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/io/tests/it/mtext_columns_roundtrip.rs b/crates/io/tests/it/mtext_columns_roundtrip.rs index 7db9ed685..633fb1396 100644 --- a/crates/io/tests/it/mtext_columns_roundtrip.rs +++ b/crates/io/tests/it/mtext_columns_roundtrip.rs @@ -3,7 +3,7 @@ use cadcraft_doc::{Common, Drawing, EntityKind, MText, MTextColumnType, MTextColumns, Space}; use cadcraft_geom::Vec3; -use cadcraft_io::{read_dxf, write_dxf}; +use cadcraft_io::{SaveOptions, Version, entity_from_groups, read_dxf, write_dxf, write_with}; fn mtext(contents: &str, rotation: f64, columns: Option) -> MText { MText { @@ -91,3 +91,88 @@ fn hostile_column_groups_do_not_panic() { let _ = read_dxf(bad.as_bytes()); } } + +// ---- #2248: R2018 embedded column object ---- + +fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec { + use cadcraft_dxf::Tag; + let mut v = vec![ + Tag::s(8, "0"), + Tag::f(10, 10.0), + Tag::f(20, 10.0), + Tag::f(30, 0.0), + Tag::f(40, 2.5), + Tag::f(41, width), + Tag::i(71, 5), + Tag::i(72, 5), + Tag::s(1, "Hello"), + Tag::s(101, "Embedded Object"), + Tag::i(70, 1), + Tag::f(10, 1.0), + Tag::f(20, 0.0), + Tag::f(30, 0.0), + Tag::f(11, 10.0), + Tag::f(21, 10.0), + Tag::f(31, 0.0), + Tag::f(40, width), + Tag::f(41, 0.0), + Tag::f(42, width), + Tag::f(43, 0.0), + Tag::i(71, 1), + Tag::i(72, count), + Tag::f(44, width), + Tag::f(45, gutter), + Tag::i(73, 0), + Tag::i(74, 0), + ]; + v.extend(heights.iter().map(|h| Tag::f(46, *h))); + v +} + +fn mtext_from_groups(tags: &[cadcraft_dxf::Tag]) -> (Common, MText) { + let Some((common, EntityKind::MText(t))) = entity_from_groups("MTEXT", tags) else { panic!("MTEXT was not read") }; + (common, t) +} + +fn dxf_as(t: &MText, version: Version) -> String { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::MText(t.clone())).unwrap(); + let bytes = write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: false }).unwrap(); + String::from_utf8_lossy(&bytes).into_owned() +} + +#[test] +fn embedded_object_does_not_leak_into_the_entity() { + // A single column (the usual R2018 block of a plain MTEXT): 44 is the column width, not the + // line spacing factor, and the columns are kept. + let (_, t) = mtext_from_groups(&embedded_tags(1, 38.8, 12.5, &[0.0])); + assert_eq!(t.line_spacing, 1.0, "44 of the embedded object is the column width"); + assert_eq!(t.attach, 5); + assert_eq!(t.width, 38.8); + let c = MTextColumns { kind: MTextColumnType::Static, count: 1, gutter: 12.5, height: 0.0, ..Default::default() }; + assert_eq!(t.columns, Some(c)); +} + +#[test] +fn embedded_static_columns_are_read() { + let (_, t) = mtext_from_groups(&embedded_tags(3, 12.5, 2.5, &[20.0, 20.0, 20.0])); + let c = MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 2.5, height: 20.0, ..Default::default() }; + assert_eq!(t.columns, Some(c)); +} + +#[test] +fn r2018_writes_the_embedded_object_and_older_versions_the_legacy_groups() { + let (_, t) = mtext_from_groups(&embedded_tags(3, 12.5, 2.5, &[20.0, 20.0, 20.0])); + let r2018 = dxf_as(&t, Version::R2018); + assert!(r2018.contains("101\r\nEmbedded Object\r\n"), "R2018 keeps the embedded object"); + assert!(!r2018.contains(" 75\r\n"), "no legacy column groups in R2018"); + assert!(r2018.contains(" 72\r\n5\r\n"), "the flow direction (72) is kept"); + assert!(r2018.contains(" 44\r\n12.5\r\n 45\r\n2.5\r\n"), "column width and gutter"); + assert!(!r2018.contains(" 44\r\n1.0\r\n"), "the entity's line spacing is not the column width"); + let back = read_dxf(r2018.as_bytes()).unwrap(); + let Some(EntityKind::MText(b)) = back.model.iter().map(|e| e.kind.clone()).find(|k| matches!(k, EntityKind::MText(_))) else { panic!("no mtext") }; + assert_eq!(b.columns, t.columns); + let r2000 = dxf_as(&t, Version::R2000); + assert!(!r2000.contains("Embedded Object"), "R2000 has no embedded object"); + assert!(r2000.contains(" 75\r\n1\r\n"), "R2000 keeps the legacy column groups"); +} From c2cb5b86109b4df3a822a29ac0d706284e0c7b54 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <236787783+midasmatterhorn@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:49:48 +0300 Subject: [PATCH 013/151] engine: propagate create_dir_all errors and route render PNG writes through write_atomic --- crates/engine/src/atomic_write.rs | 4 +--- crates/mcp/src/backend.rs | 3 ++- crates/ui-egui/src/control.rs | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/atomic_write.rs b/crates/engine/src/atomic_write.rs index cd7d0fb4b..2d05e019d 100644 --- a/crates/engine/src/atomic_write.rs +++ b/crates/engine/src/atomic_write.rs @@ -37,9 +37,7 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { Err(e) => return Err(e), }; let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf); - if let Err(e) = std::fs::create_dir_all(&dir) { - return Err(e); - } + std::fs::create_dir_all(&dir)?; let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?; let write = (|| { if let Some(permissions) = permissions { diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 9d012076a..d27012418 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -395,7 +395,8 @@ impl Backend for Headless { let png = cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()).ok_or("render failed")?; if let Some(path) = str_p("path") { - std::fs::write(&path, &png).map_err(|e| format!("{path}: {e}"))?; + cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(&path), &png) + .map_err(|e| format!("{path}: {e}"))?; } Ok(json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h})) } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2dc1f898a..4370b5de1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -489,7 +489,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou }; #[cfg(not(target_arch = "wasm32"))] if let Some(path) = s("path") { - if let Err(e) = std::fs::write(path, &png) { + if let Err(e) = cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(&path), &png) { return err(e); } return ok(render_reply(&png, w, h, Some(path))); From 9ff37ba2622ef365dead8693a79455244542635b Mon Sep 17 00:00:00 2001 From: assamaral Date: Thu, 29 Oct 2026 15:55:52 -0300 Subject: [PATCH 014/151] fix(io): an MTEXT's R2018 embedded column object is kept and its groups stay out of the entity (#2248) The reader took the embedded object's 44 (column width) as the MTEXT's line spacing factor and dropped its columns; the writer wrote no embedded object, the legacy column groups (which R2018 ignores) and a drawing direction of 1 instead of 5 (by style). Now the embedded object is read (type, count, width, gutter, heights), written for R2018 and later, and the MTEXT's own groups are the ones before it. AutoLISP entget keeps the legacy groups. Co-Authored-By: Claude Sonnet 5.5 --- crates/io/src/dxf_read/mtext_dxf.rs | 61 +++++++++++++++++-- crates/io/src/dxf_read/text.rs | 5 +- crates/io/src/dxf_write.rs | 2 +- crates/io/src/dxf_write/ctx.rs | 2 + crates/io/src/dxf_write/entity.rs | 5 +- crates/io/tests/it/mtext_columns_roundtrip.rs | 22 ++++--- 6 files changed, 82 insertions(+), 15 deletions(-) diff --git a/crates/io/src/dxf_read/mtext_dxf.rs b/crates/io/src/dxf_read/mtext_dxf.rs index f955603e6..5a7b6ec55 100644 --- a/crates/io/src/dxf_read/mtext_dxf.rs +++ b/crates/io/src/dxf_read/mtext_dxf.rs @@ -56,7 +56,7 @@ pub(crate) fn encode_contents(s: &str) -> String { } /// The MTEXT's own tags: up to an embedded object (group 101), whose groups reuse MTEXT's codes. -fn own(tags: &[Tag]) -> &[Tag] { +pub(crate) fn own(tags: &[Tag]) -> &[Tag] { let end = tags.iter().position(|t| t.code == 101).unwrap_or(tags.len()); tags.get(..end).unwrap_or(tags) } @@ -74,8 +74,8 @@ fn finite(v: f64) -> f64 { /// The columns of an MTEXT, and the column width (group 48) when there are columns. pub(crate) fn read_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { + let Some(start) = own(tags).iter().position(|t| t.code == 75) else { return read_embedded_columns(tags) }; let tags = own(tags); - let start = tags.iter().position(|t| t.code == 75)?; let kind = match tags.get(start).map(Tag::i64) { Some(1) => MTextColumnType::Static, Some(2) => MTextColumnType::Dynamic, @@ -101,12 +101,65 @@ pub(crate) fn read_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { Some((c, first(48).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0))) } -/// The column groups of an MTEXT with columns. -pub(crate) fn column_tags(t: &MText) -> Vec { +/// The columns of an R2018 MTEXT, from its embedded object (group 101), whose groups reuse +/// MTEXT's codes: 71 type, 72 count, 44 column width, 45 gutter, 73 auto height, 74 flow +/// reversed, 46 heights. The column width is also the MTEXT's width. +fn read_embedded_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { + let obj = tags.get(tags.iter().position(|t| t.code == 101)? + 1..)?; + let first = |code: i32| obj.iter().find(|t| t.code == code); + let kind = match first(71).map(Tag::i64) { + Some(1) => MTextColumnType::Static, + Some(2) => MTextColumnType::Dynamic, + _ => return None, + }; + let heights: Vec = obj.iter().filter(|t| t.code == 46).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect(); + let auto_height = first(73).map(Tag::i64).unwrap_or(0) != 0; + let manual = kind == MTextColumnType::Dynamic && !auto_height; + let c = MTextColumns { + kind, + count: u16::try_from(first(72).map(Tag::i64).unwrap_or(1).clamp(1, MAX_COLUMNS as i64)).unwrap_or(1), + gutter: first(45).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0), + height: if manual { heights.iter().copied().fold(0.0, f64::max) } else { heights.first().copied().unwrap_or(0.0) }, + auto_height, + heights: if manual { heights } else { Vec::new() }, + flow_reversed: first(74).map(Tag::i64).unwrap_or(0) != 0, + }; + Some((c, first(44).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0))) +} + +/// The column groups of an MTEXT with columns: the legacy groups 75/76/78/79/48/49/50 before +/// R2018, an embedded object (group 101) from R2018 on, which reads those as the entity's own. +pub(crate) fn column_tags(t: &MText, embedded: bool) -> Vec { let Some(c) = &t.columns else { return Vec::new() }; let manual = c.kind == MTextColumnType::Dynamic && !c.auto_height; let heights: Vec = if manual { c.heights.clone() } else { vec![c.height; usize::from(c.count.max(1))] }; let count = if manual { heights.len().max(1) } else { usize::from(c.count.max(1)) }; + if embedded { + let (sin, cos) = t.rotation.sin_cos(); + let total_width = t.width * count as f64 + c.gutter * (count as f64 - 1.0); + let mut v = vec![ + Tag::s(101, "Embedded Object"), + Tag::i(70, 1), + Tag::f(10, cos), + Tag::f(20, sin), + Tag::f(30, 0.0), + Tag::f(11, t.insert.x), + Tag::f(21, t.insert.y), + Tag::f(31, t.insert.z), + Tag::f(40, t.width), + Tag::f(41, 0.0), + Tag::f(42, total_width), + Tag::f(43, heights.iter().copied().fold(0.0, f64::max)), + Tag::i(71, if c.kind == MTextColumnType::Dynamic { 2 } else { 1 }), + Tag::i(72, count as i64), + Tag::f(44, t.width), + Tag::f(45, c.gutter), + Tag::i(73, i64::from(c.auto_height)), + Tag::i(74, i64::from(c.flow_reversed)), + ]; + v.extend(heights.into_iter().map(|h| Tag::f(46, h))); + return v; + } let mut v = vec![ Tag::i(75, if c.kind == MTextColumnType::Dynamic { 2 } else { 1 }), Tag::i(76, count as i64), diff --git a/crates/io/src/dxf_read/text.rs b/crates/io/src/dxf_read/text.rs index 10d770c0e..158015169 100644 --- a/crates/io/src/dxf_read/text.rs +++ b/crates/io/src/dxf_read/text.rs @@ -42,6 +42,10 @@ pub(super) fn multiline_attrib(embedded: Option<&[Tag]>, c: &Common) -> Option MText { + // The MTEXT's own groups: an embedded object (group 101) reuses their codes (44 is its + // column width, not the line spacing factor). + let columns = mtext_dxf::read_columns(tags); + let tags = mtext_dxf::own(tags); let t = T(tags); // Contents: code 3 chunks followed by code 1. let mut contents = String::new(); @@ -50,7 +54,6 @@ pub(super) fn mtext_from(tags: &[Tag], c: &Common) -> MText { } contents.push_str(&t.s(1).unwrap_or_default()); let contents = mtext_dxf::decode_contents(&contents); - let columns = mtext_dxf::read_columns(tags); // The direction comes from the MTEXT's own groups only: in an R2018+ embedded object // (group 101), 11 is the insertion point, not the X-axis direction (#667, as in #834). let own = T(mtext_dxf::before_columns(tags)); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 4ec9fc636..dba29c7f7 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -146,7 +146,7 @@ pub fn entity_groups(d: &Drawing, e: &Entity) -> (String, Vec) { layers: HashMap::new(), reactors: HashMap::new(), }; - let cx = Ctx { target: Version::R2018, ..Ctx::default() }; + let cx = Ctx { target: Version::R2018, legacy_columns: true, ..Ctx::default() }; let paper = d.space_of(e.handle).is_some_and(|s| !matches!(s, cadcraft_doc::Space::Model)); entity(&mut w, d, e, "0", paper, &cx); let mut tags = w.t.into_iter(); diff --git a/crates/io/src/dxf_write/ctx.rs b/crates/io/src/dxf_write/ctx.rs index ce9c38b7a..157dcd441 100644 --- a/crates/io/src/dxf_write/ctx.rs +++ b/crates/io/src/dxf_write/ctx.rs @@ -53,6 +53,8 @@ pub(super) struct Ctx { /// The version the file ends up in (the DWG version for a DWG save): it decides which /// entity types exist (MULTILEADER from 2007). pub(super) target: Version, + /// MTEXT columns as the legacy groups 75..79 even from R2018 (the groups AutoLISP lists). + pub(super) legacy_columns: bool, } impl Ctx { diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index cd41cdf91..fa4c0f3e5 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -256,7 +256,8 @@ fn write_text_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo w.f(40, t.height); w.f(41, t.width); w.i(71, i64::from(t.attach)); - w.i(72, 1); + // Drawing direction: 5, by style. + w.i(72, 5); // Long contents go in 250-char 3-chunks, the remainder in 1. let chars: Vec = crate::dxf_read::mtext_dxf::encode_contents(&t.contents).chars().collect(); let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); @@ -274,7 +275,7 @@ fn write_text_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo // Line spacing style: 1 = at least, 2 = exactly. w.i(73, if t.line_spacing_exact { 2 } else { 1 }); w.f(44, t.line_spacing); - w.t.extend(crate::dxf_read::mtext_dxf::column_tags(t)); + w.t.extend(crate::dxf_read::mtext_dxf::column_tags(t, cx.target >= Version::R2018 && !cx.legacy_columns)); } _ => {} } diff --git a/crates/io/tests/it/mtext_columns_roundtrip.rs b/crates/io/tests/it/mtext_columns_roundtrip.rs index 633fb1396..d9a692a8e 100644 --- a/crates/io/tests/it/mtext_columns_roundtrip.rs +++ b/crates/io/tests/it/mtext_columns_roundtrip.rs @@ -134,11 +134,16 @@ fn mtext_from_groups(tags: &[cadcraft_dxf::Tag]) -> (Common, MText) { (common, t) } -fn dxf_as(t: &MText, version: Version) -> String { +/// The DXF of `t` written as `version` and its MTEXT record only (other sections reuse the codes). +fn dxf_as(t: &MText, version: Version) -> (String, String) { let mut d = Drawing::new_metric(); d.add(&Space::Model, Common::default(), EntityKind::MText(t.clone())).unwrap(); let bytes = write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: false }).unwrap(); - String::from_utf8_lossy(&bytes).into_owned() + let text = String::from_utf8_lossy(&bytes).into_owned(); + let from = text.find(" 0\r\nMTEXT\r\n").expect("an MTEXT"); + let len = text[from + 5..].find(" 0\r\n").map_or(text.len() - from, |n| n + 5); + let record = text[from..from + len].to_string(); + (text, record) } #[test] @@ -163,16 +168,19 @@ fn embedded_static_columns_are_read() { #[test] fn r2018_writes_the_embedded_object_and_older_versions_the_legacy_groups() { let (_, t) = mtext_from_groups(&embedded_tags(3, 12.5, 2.5, &[20.0, 20.0, 20.0])); - let r2018 = dxf_as(&t, Version::R2018); + let (full, r2018) = dxf_as(&t, Version::R2018); assert!(r2018.contains("101\r\nEmbedded Object\r\n"), "R2018 keeps the embedded object"); assert!(!r2018.contains(" 75\r\n"), "no legacy column groups in R2018"); assert!(r2018.contains(" 72\r\n5\r\n"), "the flow direction (72) is kept"); assert!(r2018.contains(" 44\r\n12.5\r\n 45\r\n2.5\r\n"), "column width and gutter"); - assert!(!r2018.contains(" 44\r\n1.0\r\n"), "the entity's line spacing is not the column width"); - let back = read_dxf(r2018.as_bytes()).unwrap(); - let Some(EntityKind::MText(b)) = back.model.iter().map(|e| e.kind.clone()).find(|k| matches!(k, EntityKind::MText(_))) else { panic!("no mtext") }; + let own = r2018.split("Embedded Object").next().unwrap_or_default(); + assert!(own.contains(" 44\r\n1.0\r\n") && !own.contains(" 44\r\n12.5\r\n"), "the entity's line spacing is not the column width"); + let back = read_dxf(full.as_bytes()).unwrap(); + let Some(EntityKind::MText(b)) = back.model.iter().map(|e| e.kind.clone()).find(|k| matches!(k, EntityKind::MText(_))) else { + panic!("no mtext") + }; assert_eq!(b.columns, t.columns); - let r2000 = dxf_as(&t, Version::R2000); + let (_, r2000) = dxf_as(&t, Version::R2000); assert!(!r2000.contains("Embedded Object"), "R2000 has no embedded object"); assert!(r2000.contains(" 75\r\n1\r\n"), "R2000 keeps the legacy column groups"); } From c5d186ae92520b554e42d87d1fc6a88cb1c90ae4 Mon Sep 17 00:00:00 2001 From: assamaral Date: Thu, 29 Oct 2026 16:07:47 -0300 Subject: [PATCH 015/151] fix(io): the embedded column object carries the column height like AutoCAD's (#2248) Co-Authored-By: Claude Sonnet 5.5 --- crates/io/src/dxf_read/mtext_dxf.rs | 16 ++++++++++++---- crates/io/tests/it/mtext_columns_roundtrip.rs | 10 ++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/io/src/dxf_read/mtext_dxf.rs b/crates/io/src/dxf_read/mtext_dxf.rs index 5a7b6ec55..e7752f711 100644 --- a/crates/io/src/dxf_read/mtext_dxf.rs +++ b/crates/io/src/dxf_read/mtext_dxf.rs @@ -115,11 +115,14 @@ fn read_embedded_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { let heights: Vec = obj.iter().filter(|t| t.code == 46).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect(); let auto_height = first(73).map(Tag::i64).unwrap_or(0) != 0; let manual = kind == MTextColumnType::Dynamic && !auto_height; + // The column height: the entity's own 46 (defined height), else the object's 41 or first 46. + let own_46 = own(tags).iter().find(|t| t.code == 46).map(|t| finite(t.f64()).max(0.0)); + let defined = [own_46, first(41).map(|t| finite(t.f64())), heights.first().copied()].into_iter().flatten().find(|h| *h > 0.0).unwrap_or(0.0); let c = MTextColumns { kind, count: u16::try_from(first(72).map(Tag::i64).unwrap_or(1).clamp(1, MAX_COLUMNS as i64)).unwrap_or(1), gutter: first(45).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0), - height: if manual { heights.iter().copied().fold(0.0, f64::max) } else { heights.first().copied().unwrap_or(0.0) }, + height: if manual { heights.iter().copied().fold(0.0, f64::max) } else { defined }, auto_height, heights: if manual { heights } else { Vec::new() }, flow_reversed: first(74).map(Tag::i64).unwrap_or(0) != 0, @@ -137,7 +140,10 @@ pub(crate) fn column_tags(t: &MText, embedded: bool) -> Vec { if embedded { let (sin, cos) = t.rotation.sin_cos(); let total_width = t.width * count as f64 + c.gutter * (count as f64 - 1.0); + // The column height (static, and dynamic with auto height) is the entity's group 46. + let height = if manual { heights.iter().copied().fold(0.0, f64::max) } else { c.height }; let mut v = vec![ + Tag::f(46, height), Tag::s(101, "Embedded Object"), Tag::i(70, 1), Tag::f(10, cos), @@ -147,9 +153,9 @@ pub(crate) fn column_tags(t: &MText, embedded: bool) -> Vec { Tag::f(21, t.insert.y), Tag::f(31, t.insert.z), Tag::f(40, t.width), - Tag::f(41, 0.0), + Tag::f(41, height), Tag::f(42, total_width), - Tag::f(43, heights.iter().copied().fold(0.0, f64::max)), + Tag::f(43, height), Tag::i(71, if c.kind == MTextColumnType::Dynamic { 2 } else { 1 }), Tag::i(72, count as i64), Tag::f(44, t.width), @@ -157,7 +163,9 @@ pub(crate) fn column_tags(t: &MText, embedded: bool) -> Vec { Tag::i(73, i64::from(c.auto_height)), Tag::i(74, i64::from(c.flow_reversed)), ]; - v.extend(heights.into_iter().map(|h| Tag::f(46, h))); + if manual { + v.extend(heights.into_iter().map(|h| Tag::f(46, h))); + } return v; } let mut v = vec![ diff --git a/crates/io/tests/it/mtext_columns_roundtrip.rs b/crates/io/tests/it/mtext_columns_roundtrip.rs index d9a692a8e..3525c8d29 100644 --- a/crates/io/tests/it/mtext_columns_roundtrip.rs +++ b/crates/io/tests/it/mtext_columns_roundtrip.rs @@ -165,6 +165,16 @@ fn embedded_static_columns_are_read() { assert_eq!(t.columns, Some(c)); } +#[test] +fn embedded_column_height_is_the_entitys_defined_height() { + // As ezdxf and AutoCAD write static columns: the height in the entity's 46, none in the object. + let mut tags = embedded_tags(3, 12.5, 2.5, &[]); + let at = tags.iter().position(|t| t.code == 101).unwrap(); + tags.insert(at, cadcraft_dxf::Tag::f(46, 20.0)); + let (_, t) = mtext_from_groups(&tags); + assert_eq!(t.columns.map(|c| c.height), Some(20.0)); +} + #[test] fn r2018_writes_the_embedded_object_and_older_versions_the_legacy_groups() { let (_, t) = mtext_from_groups(&embedded_tags(3, 12.5, 2.5, &[20.0, 20.0, 20.0])); From b6c1ab74f99dc4c66ab38ba63178fed5a1e441ba Mon Sep 17 00:00:00 2001 From: SBaksa Date: Fri, 30 Oct 2026 10:38:00 +1100 Subject: [PATCH 016/151] DXF: leave SAT-text solids out of files of 2013 and later, with a save note (#2334) REGION, 3DSOLID and BODY read from a DXF 2000 hold SAT text; DXF 2013 and later keep ACIS geometry in the data store section, so writing the text there made a file other readers reject. Those entities are now left out of 2013+ DXF output and counted in the save note; 2010 and earlier, and DWG, are unchanged. Test: sat_text_solids_are_left_out_of_dxf_2013_and_later. --- crates/doc/src/foreign.rs | 26 +++++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 26 +++++++++++++++++++++++++ crates/io/src/dxf_raw.rs | 16 ++++++++++++--- crates/io/src/dxf_write/sections.rs | 2 +- crates/io/tests/it/acis_data_store.rs | 28 +++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/foreign.rs b/crates/doc/src/foreign.rs index e0ebb81a4..3b6aa8ef9 100644 --- a/crates/doc/src/foreign.rs +++ b/crates/doc/src/foreign.rs @@ -94,3 +94,29 @@ impl Foreign { self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect() } } + +/// Whether a solid modeler entity (3DSOLID, REGION, BODY) holds its ACIS geometry as SAT text +/// (groups 1 and 3), which DXF 2013 and later no longer has: those files keep it in the data +/// store section, so an entity with SAT text and no record there can't be written to one (#2334). +pub fn sat_text_acis(u: &crate::Unknown, handle: Handle, stored: &std::collections::BTreeSet) -> bool { + ["3DSOLID", "REGION", "BODY"].iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) + && !stored.contains(&handle) + && u.tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 1 || r.code == 3) +} + +/// The entities a DXF of 2013 or later leaves out because they hold SAT-text ACIS data +/// ([`sat_text_acis`]), counted by DXF type, across model space, layouts and block definitions. +pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap { + let stored = d.foreign.data_store_entities(); + let mut out = BTreeMap::new(); + let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); + for e in stores.flat_map(|s| s.iter()) { + if let crate::EntityKind::Unknown(u) = &e.kind + && sat_text_acis(u, e.handle, &stored) + { + let n: &mut usize = out.entry(u.dxf_type.to_ascii_uppercase()).or_default(); + *n = n.saturating_add(1); + } + } + out +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3a5296e88..5b012bb4e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -520,6 +520,8 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result { let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() }); if drops_unknown(format!("drawing.{fmt}")) { report_not_saved(s, &mut r, &lost); + } else if version >= Version::R2013 && is_dxf_file(format!("drawing.{fmt}")) { + report_sat_text_dropped(s, &mut r); } Ok(r) } @@ -575,6 +577,28 @@ fn is_drawing_file(path: impl AsRef) -> bool { Format::from_path(path).is_some_and(Format::is_drawing) } +/// Whether `path` is a DXF file. +fn is_dxf_file(path: impl AsRef) -> bool { + Format::from_path(path) == Some(Format::Dxf) +} + +/// Say on the command line which solid modeler entities a DXF of 2013 or later left out because +/// they hold SAT-text ACIS data, which such files keep in a data store instead (#2334). +fn report_sat_text_dropped(s: &mut Session, r: &mut Value) { + let lost = s.doc().map(cadcraft_doc::foreign::sat_text_dropped).unwrap_or_default(); + if lost.is_empty() { + return; + } + let list: Vec = lost.iter().map(|(t, n)| format!("{n} {t}")).collect(); + s.echo(format!( + "Not saved in the DXF file (2013 and later keep ACIS geometry in a data store these entities have no record in; save as 2010 or earlier to keep them): {}", + list.join(", ") + )); + if let Some(o) = r.as_object_mut() { + o.insert("notSaved".into(), json!(lost)); + } +} + /// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`. fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap) { let Some(msg) = not_saved_message(lost) else { return }; @@ -593,6 +617,8 @@ fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> R let mut r = run(s, p)?; if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) { report_not_saved(s, &mut r, &lost); + } else if version >= Version::R2013 && r.get("path").and_then(Value::as_str).is_some_and(is_dxf_file) { + report_sat_text_dropped(s, &mut r); } Ok(r) } diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index 0154b01d3..5a2aa398c 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -848,7 +848,7 @@ fn unknown_handles(d: &Drawing) -> HashSet { } /// Put the drawing's foreign data back into a written file's tags (see the module comment). -pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { +pub(crate) fn finish(tags: &mut Vec, d: &Drawing, modern: bool) { let f = d.foreign.as_ref(); let unknown = unknown_handles(d); let xd: XdataOf = all_entities(d).filter(|e| !e.common.xdata.is_empty()).map(|e| (e.handle, e.common.xdata.as_slice())).collect(); @@ -877,7 +877,7 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { } let renumber = |old: Option| class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied()); // Entities of types CADCraft doesn't model, placed among the written ones. - let mut planned = plan_unknown(d, &recs, tags, &roles); + let mut planned = plan_unknown(d, &recs, tags, &roles, modern); for list in planned.values_mut() { list.retain_mut(|(_, t)| { if !t.first().is_some_and(|z| is_proxy(&z.str())) { @@ -1019,7 +1019,14 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { /// Where the entities of types CADCraft doesn't model go: each before the next written entity of /// its space or block (else at the end of it), as `(section, record)` lists by the index of the /// record they precede. Owners come from the written block records. -fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap) -> HashMap)>> { +fn plan_unknown( + d: &Drawing, + recs: &[Rec], + tags: &[Tag], + roles: &HashMap, + modern: bool, +) -> HashMap)>> { + let stored = d.foreign.data_store_entities(); let mut out: HashMap)>> = HashMap::new(); let body = |r: &Rec| tags.get(r.start + 1..r.end).unwrap_or(&[]); let mut at: HashMap = HashMap::new(); @@ -1057,6 +1064,9 @@ fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap { + if modern && cadcraft_doc::foreign::sat_text_acis(u, e.handle, &stored) { + continue; + } let t = unknown_entity(u, e, &owner, paper); if !t.is_empty() { pending.push((sec.to_string(), t)); diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index fe865515b..90214b401 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -1115,7 +1115,7 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec= Version::R2013); crate::dxf_raw::data_store(&mut w.t, d, target >= Version::R2013); add_class_instance_counts(&mut w.t); mark_xrefs(&mut w.t, d); diff --git a/crates/io/tests/it/acis_data_store.rs b/crates/io/tests/it/acis_data_store.rs index 74b9809ce..36c947892 100644 --- a/crates/io/tests/it/acis_data_store.rs +++ b/crates/io/tests/it/acis_data_store.rs @@ -101,3 +101,31 @@ fn dwg_keeps_the_solids_geometry() { let again = cadcraft_io::read(&dwg, "x.dwg").unwrap(); assert_eq!(again.foreign.data_store_entities().len(), 2); } + +/// A solid with SAT-text ACIS data (an R2000-style file) can't be written to a DXF of 2013 or +/// later, whose files keep that data in the data store: it is left out there and counted, while +/// 2010 and earlier keep it, and solids with a data store record are kept everywhere (#2334). +#[test] +fn sat_text_solids_are_left_out_of_dxf_2013_and_later() { + let sat = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n\ + 0\nREGION\n5\n2B\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n70\n1\n1\n400 1 1 0\n1\n16 ZWCAD\n3\nEnd-of-ACIS-data\n\ + 0\n3DSOLID\n5\n2C\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n70\n1\n1\n400 1 1 0\n3\nEnd-of-ACIS-data\n\ + 0\nLINE\n5\n2D\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n"; + let d = cadcraft_io::read_dxf(sat.as_bytes()).unwrap(); + let count = |v: cadcraft_io::Version, ty: &str| { + let out = cadcraft_io::write_with(&d, "x.dxf", &cadcraft_io::SaveOptions { version: Some(v), binary: false }).unwrap(); + let t = String::from_utf8(out).unwrap().replace("\r\n", "\n"); + t.matches(&format!("\n0\n{ty}\n")).count() + t.matches(&format!("\n 0\n{ty}\n")).count() + }; + assert_eq!(cadcraft_doc::foreign::sat_text_dropped(&d), [("3DSOLID".to_string(), 1), ("REGION".to_string(), 1)].into()); + for v in [cadcraft_io::Version::R2000, cadcraft_io::Version::R2010] { + assert_eq!((count(v, "REGION"), count(v, "3DSOLID")), (1, 1), "{v:?} keeps them"); + } + for v in [cadcraft_io::Version::R2013, cadcraft_io::Version::R2018] { + assert_eq!((count(v, "REGION"), count(v, "3DSOLID")), (0, 0), "{v:?} leaves them out"); + assert_eq!(count(v, "LINE"), 1, "{v:?} keeps the rest"); + } + // Solids with a data store record keep their geometry in 2013 and later. + let stored = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap(); + assert!(cadcraft_doc::foreign::sat_text_dropped(&stored).is_empty()); +} From 2eecaef6c2958cc78422c9600426fdc0f5b81004 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Fri, 30 Oct 2026 10:41:07 +1100 Subject: [PATCH 017/151] Parity checklist: regenerated after merging main --- docs/parity-checklist.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index a6250d5dc..162a59e3b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,10 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-25 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-29 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 297 / 491 items (60%)** · 442 registered commands. +**Menu breadth: 298 / 491 items (61%)** · 444 registered commands. | Menu | Items | Covered | % | |---|---|---|---| From 49fdbff529dea0f64e7d88a77dfedabe632a939d Mon Sep 17 00:00:00 2001 From: SBaksa Date: Fri, 30 Oct 2026 11:23:09 +1100 Subject: [PATCH 018/151] Test: saving SAT-text solids as DXF 2013 reports them, 2010 does not (#2334) --- crates/engine/tests/save_reports_not_saved.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index 615028a21..f231f8ace 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -3,7 +3,7 @@ //! writes them back as read: nothing to report. use cadcraft_engine::Session; -use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::cmd::file::{IoHooks, SaveAs, set_io, set_write_as}; use cadcraft_engine::doc::{Block, Common, Drawing, EntityKind, RawTag, Space, Unknown}; use serde_json::json; @@ -82,3 +82,33 @@ fn solids_whose_geometry_the_data_store_keeps_are_not_reported_from_2013() { // Before 2013 a file has no data store: every solid loses its geometry. assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 2), ("REGION".to_string(), 1)].into_iter().collect()); } + +/// A DXF of 2013 or later can't hold a solid's SAT-text geometry (it lives in the data store +/// there), so the save leaves it out and says so; 2010 and earlier keep it (#2334). +#[test] +fn saving_sat_text_solids_as_dxf_2013_reports_them() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + set_write_as(|_, _, _: &SaveAs| Ok(b"x".to_vec())); + let mut d = Drawing::new_imperial(); + let sat = EntityKind::Unknown(Unknown { + dxf_type: "REGION".into(), + tags: vec![ + RawTag { code: 8, value: "0".into() }, + RawTag { code: 1, value: "400 1 1 0".into() }, + RawTag { code: 3, value: "End-of-ACIS-data".into() }, + ], + }); + d.add(&Space::Model, Common::default(), sat).unwrap(); + let mut s = Session::empty(); + s.open_drawing(d, "region.dxf", None); + + let path = std::env::temp_dir().join(format!("cadcraft-sat-note-{}.dxf", std::process::id())); + let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2018 })).unwrap(); + assert_eq!(r["notSaved"], json!({ "REGION": 1 })); + assert!(s.log.iter().any(|l| l.contains("Not saved in the DXF file") && l.contains("1 REGION")), "{:?}", s.log); + + let n = s.log.len(); + let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2010 })).unwrap(); + let _ = std::fs::remove_file(&path); + assert!(r.get("notSaved").is_none() && s.log.len() == n); +} From 89771962fcbe5f238613d107c3dfe2a1a82292a8 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Fri, 30 Oct 2026 11:26:25 +1100 Subject: [PATCH 019/151] Parity checklist: regenerated after merging main --- docs/parity-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 162a59e3b..53ffffc96 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Parity checklist -> **Generated:** 2026-10-29 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-30 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. From 1013b482a81067f7d9f6e1e4247070ba99b8b37b Mon Sep 17 00:00:00 2001 From: SBaksa Date: Fri, 30 Oct 2026 12:26:09 +1100 Subject: [PATCH 020/151] DXF: SAT-text note only for DXF output, from the host drawing, without xref contents (#2334) The DWG bridge writes R2000 text for the DWG writer, which keeps SAT-text solids: the skip is now keyed on the DXF's declared version, not the DWG target. The save note reads the version and count from the host drawing while the Block Editor is open, and leaves out xref blocks (path or xref| name), which the DXF writer empties anyway; the xref name rule is shared with the writer. --- crates/doc/src/foreign.rs | 10 ++++++- crates/engine/src/cmd/file.rs | 10 ++++--- crates/engine/tests/save_reports_not_saved.rs | 27 +++++++++++++++++++ crates/io/src/dxf_write/sections.rs | 4 ++- crates/io/src/dxf_xref.rs | 2 +- crates/io/tests/it/acis_data_store.rs | 27 +++++++++++++++++++ 6 files changed, 74 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/foreign.rs b/crates/doc/src/foreign.rs index 3b6aa8ef9..60000c6dd 100644 --- a/crates/doc/src/foreign.rs +++ b/crates/doc/src/foreign.rs @@ -109,7 +109,9 @@ pub fn sat_text_acis(u: &crate::Unknown, handle: Handle, stored: &std::collectio pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap { let stored = d.foreign.data_store_entities(); let mut out = BTreeMap::new(); - let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); + // The writer saves xrefs as references: their blocks (and `xref|` ones) hold nothing in the file. + let own_blocks = d.blocks.values().filter(|b| b.xref_path.is_none() && !xref_dependent(&b.name)); + let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(own_blocks.map(|b| &b.entities)); for e in stores.flat_map(|s| s.iter()) { if let crate::EntityKind::Unknown(u) = &e.kind && sat_text_acis(u, e.handle, &stored) @@ -120,3 +122,9 @@ pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap { } out } + +/// Whether a symbol name belongs to an attached xref (`xref|name`): the DXF writer leaves such +/// symbols out, and the contents of xref blocks too ([`Block::xref_path`](crate::Block::xref_path)). +pub fn xref_dependent(name: &str) -> bool { + name.contains('|') +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5b012bb4e..5d89a196e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -490,8 +490,11 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result { /// 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(); + let v = s + .state() + .ok() + .and_then(|st| st.drawing().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) } } @@ -585,7 +588,8 @@ fn is_dxf_file(path: impl AsRef) -> bool { /// Say on the command line which solid modeler entities a DXF of 2013 or later left out because /// they hold SAT-text ACIS data, which such files keep in a data store instead (#2334). fn report_sat_text_dropped(s: &mut Session, r: &mut Value) { - let lost = s.doc().map(cadcraft_doc::foreign::sat_text_dropped).unwrap_or_default(); + // The drawing a file holds: the host while the Block Editor is open. + let lost = s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default(); if lost.is_empty() { return; } diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index f231f8ace..839c2489b 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -112,3 +112,30 @@ fn saving_sat_text_solids_as_dxf_2013_reports_them() { let _ = std::fs::remove_file(&path); assert!(r.get("notSaved").is_none() && s.log.len() == n); } + +/// In the Block Editor the file still holds the host drawing: its solids and version decide the +/// note, not the edited block's (#2334). +#[test] +fn the_note_counts_the_host_drawing_while_a_block_is_edited() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + set_write_as(|_, _, _: &SaveAs| Ok(b"x".to_vec())); + let mut d = Drawing::new_imperial(); + let sat = EntityKind::Unknown(Unknown { + dxf_type: "REGION".into(), + tags: vec![ + RawTag { code: 8, value: "0".into() }, + RawTag { code: 1, value: "400 1 1 0".into() }, + RawTag { code: 3, value: "End-of-ACIS-data".into() }, + ], + }); + d.add(&Space::Model, Common::default(), sat).unwrap(); + d.blocks.insert("Door".into(), std::sync::Arc::new(Block::new("Door"))); + let mut s = Session::empty(); + s.open_drawing(d, "region.dxf", None); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + + let path = std::env::temp_dir().join(format!("cadcraft-sat-edit-{}.dxf", std::process::id())); + let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2018 })).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(r["notSaved"], json!({ "REGION": 1 })); +} diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index 90214b401..815f4eb32 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -1115,7 +1115,9 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec= Version::R2013); + // Only a DXF declares the version it is written for: the DWG bridge writes R2000 text for the + // DWG writer, which keeps SAT-text solids (#2334). + crate::dxf_raw::finish(&mut w.t, d, version >= Version::R2013); crate::dxf_raw::data_store(&mut w.t, d, target >= Version::R2013); add_class_instance_counts(&mut w.t); mark_xrefs(&mut w.t, d); diff --git a/crates/io/src/dxf_xref.rs b/crates/io/src/dxf_xref.rs index f98a044f7..9ea7904cd 100644 --- a/crates/io/src/dxf_xref.rs +++ b/crates/io/src/dxf_xref.rs @@ -13,7 +13,7 @@ use cadcraft_doc::Drawing; /// Whether a symbol name belongs to an attached xref (`xref|name`). pub(crate) fn dependent(name: &str) -> bool { - name.contains('|') + cadcraft_doc::foreign::xref_dependent(name) } /// The drawing as it is saved: xref blocks emptied, xref-dependent symbols other than layers and diff --git a/crates/io/tests/it/acis_data_store.rs b/crates/io/tests/it/acis_data_store.rs index 36c947892..308b532d9 100644 --- a/crates/io/tests/it/acis_data_store.rs +++ b/crates/io/tests/it/acis_data_store.rs @@ -129,3 +129,30 @@ fn sat_text_solids_are_left_out_of_dxf_2013_and_later() { let stored = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap(); assert!(cadcraft_doc::foreign::sat_text_dropped(&stored).is_empty()); } + +/// A DWG keeps SAT-text solids at any version (its writer handles them), and a DXF's note leaves +/// out the contents of xref blocks, which the DXF doesn't hold anyway (#2334). +#[test] +fn sat_text_solids_stay_in_dwg_2018_and_xref_contents_are_not_counted() { + let sat = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n\ + 0\nREGION\n5\n2B\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n70\n1\n1\n400 1 1 0\n3\nEnd-of-ACIS-data\n\ + 0\nLINE\n5\n2D\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n"; + let d = cadcraft_io::read_dxf(sat.as_bytes()).unwrap(); + let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2018), binary: false }; + let dwg = cadcraft_io::write_with(&d, "x.dwg", &opts).unwrap(); + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + let unknown = |d: &cadcraft_doc::Drawing| d.model.iter().filter(|e| matches!(&e.kind, EntityKind::Unknown(u) if u.dxf_type == "REGION")).count(); + assert_eq!(unknown(&back), 1, "the DWG 2018 keeps the region"); + + // Xref blocks (and `xref|` ones) hold no entities in the file: not reported as left out. + let mut x = d.clone(); + let region = d.model.iter().find(|e| matches!(&e.kind, EntityKind::Unknown(_))).cloned().unwrap(); + let mut host = cadcraft_doc::Block::new("REF"); + host.xref_path = Some("other.dwg".into()); + host.entities.push((*region).clone()); + x.blocks.insert("REF".into(), std::sync::Arc::new(host)); + let mut dep = cadcraft_doc::Block::new("REF|PART"); + dep.entities.push((*region).clone()); + x.blocks.insert("REF|PART".into(), std::sync::Arc::new(dep)); + assert_eq!(cadcraft_doc::foreign::sat_text_dropped(&x), [("REGION".to_string(), 1)].into(), "only the model-space region"); +} From fc8270d8c52acd6dba69e221b889193b1a497739 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Fri, 30 Oct 2026 12:47:03 +1100 Subject: [PATCH 021/151] Parity checklist: regenerated after merging main --- docs/parity-checklist.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d0bd6f967..53ffffc96 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,7 +1,6 @@ # Parity checklist -> **Generated:** 2026-10-29 by `cargo xtask parity`. Do not edit by hand. -> **Generated:** 2026-10-28 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-30 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. From 86d010eaac0d458a528d91705c0a2118f80fa528 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Fri, 30 Oct 2026 14:59:16 +1100 Subject: [PATCH 022/151] DXF: SAT text is groups 1 and 3 of the modeler subclass; only ASM_Data records keep geometry (#2334) sat_text_acis now looks for groups 1 and 3 inside AcDbModelerGeometry only (all groups when a file has no subclass markers), and the data store counts an entity as stored only when its ACDSRECORD holds ASM_Data. The writer and the reader share one record splitter (data_store_records). --- crates/doc/src/foreign.rs | 46 ++++++++++++++++++++++++--- crates/io/src/dxf_raw.rs | 6 +--- crates/io/tests/it/acis_data_store.rs | 29 +++++++++++++++++ 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/foreign.rs b/crates/doc/src/foreign.rs index 60000c6dd..133c525e2 100644 --- a/crates/doc/src/foreign.rs +++ b/crates/doc/src/foreign.rs @@ -89,9 +89,17 @@ impl Foreign { && self.data_store.is_empty() } - /// The entities the data store has records for (their 320 handles). + /// The entities with a geometry record in the data store: an `ACDSRECORD` naming the entity + /// (320) that holds its `ASM_Data`. A 320 without one doesn't keep an entity's geometry. pub fn data_store_entities(&self) -> std::collections::BTreeSet { - self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect() + let mut out = std::collections::BTreeSet::new(); + for rec in data_store_records(&self.data_store) { + let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD")); + if is_record && rec.iter().any(|t| t.code == 2 && t.value.trim().eq_ignore_ascii_case("ASM_Data")) { + out.extend(rec.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim()))); + } + } + out } } @@ -99,9 +107,7 @@ impl Foreign { /// (groups 1 and 3), which DXF 2013 and later no longer has: those files keep it in the data /// store section, so an entity with SAT text and no record there can't be written to one (#2334). pub fn sat_text_acis(u: &crate::Unknown, handle: Handle, stored: &std::collections::BTreeSet) -> bool { - ["3DSOLID", "REGION", "BODY"].iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) - && !stored.contains(&handle) - && u.tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 1 || r.code == 3) + ["3DSOLID", "REGION", "BODY"].iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && !stored.contains(&handle) && has_sat_text(&u.tags) } /// The entities a DXF of 2013 or later leaves out because they hold SAT-text ACIS data @@ -128,3 +134,33 @@ pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap { pub fn xref_dependent(name: &str) -> bool { name.contains('|') } + +/// The records of a data store section's groups: each starts at a `0` group and runs to the next +/// (the section's own groups before the first record are not part of one). +pub fn data_store_records(tags: &[RawTag]) -> Vec<&[RawTag]> { + let mut out = Vec::new(); + let mut start = tags.iter().position(|t| t.code == 0).unwrap_or(tags.len()); + while start < tags.len() { + let end = tags.iter().skip(start + 1).position(|t| t.code == 0).map_or(tags.len(), |k| start + 1 + k); + out.push(tags.get(start..end).unwrap_or(&[])); + start = end; + } + out +} + +/// Whether an entity's groups hold SAT text: groups 1 and 3 inside its `AcDbModelerGeometry` +/// subclass. A file of a version before the subclass markers has no `100` groups: all of its +/// groups (before the extended data) are the entity's. +fn has_sat_text(tags: &[RawTag]) -> bool { + let own = tags.iter().take_while(|r| r.code < 1000); + let marked = tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 100); + let mut in_modeler = !marked; + for r in own { + if r.code == 100 { + in_modeler = r.value.trim().eq_ignore_ascii_case("AcDbModelerGeometry"); + } else if in_modeler && (r.code == 1 || r.code == 3) { + return true; + } + } + false +} diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index 5a2aa398c..7856a24b8 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -601,17 +601,13 @@ pub(crate) fn data_store(tags: &mut Vec, d: &Drawing, modern: bool) { let first = ds.iter().position(|t| t.code == 0).unwrap_or(ds.len()); out.extend(ds.iter().take(first).map(tag)); let mut records = 0usize; - let mut i = first; - while i < ds.len() { - let end = ds.iter().skip(i + 1).position(|t| t.code == 0).map_or(ds.len(), |k| i + 1 + k); - let rec = ds.get(i..end).unwrap_or(&[]); + for rec in cadcraft_doc::foreign::data_store_records(ds) { let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD")); let owner = rec.iter().find(|t| t.code == 320).map(|t| norm(&t.value)); if !is_record || owner.as_ref().is_some_and(|h| written.contains(h)) { records += usize::from(is_record); out.extend(rec.iter().map(tag)); } - i = end; } if records == 0 { return; diff --git a/crates/io/tests/it/acis_data_store.rs b/crates/io/tests/it/acis_data_store.rs index 308b532d9..4f8f455b4 100644 --- a/crates/io/tests/it/acis_data_store.rs +++ b/crates/io/tests/it/acis_data_store.rs @@ -156,3 +156,32 @@ fn sat_text_solids_stay_in_dwg_2018_and_xref_contents_are_not_counted() { x.blocks.insert("REF|PART".into(), std::sync::Arc::new(dep)); assert_eq!(cadcraft_doc::foreign::sat_text_dropped(&x), [("REGION".to_string(), 1)].into(), "only the model-space region"); } + +/// Only groups 1 and 3 of the modeler geometry subclass are SAT text, and only a record holding +/// the entity's ASM_Data keeps its geometry in the data store (#2334). +#[test] +fn sat_text_is_told_by_its_subclass_and_a_geometry_record() { + use cadcraft_doc::foreign::{Foreign, sat_text_acis}; + use cadcraft_doc::{Handle, RawTag, Unknown}; + let tag = |code: i32, value: &str| RawTag { code, value: value.into() }; + let solid = |tags: Vec| Unknown { dxf_type: "3DSOLID".into(), tags }; + let none = std::collections::BTreeSet::new(); + let h = Handle(0x2B); + // A stray 1 or 3 outside AcDbModelerGeometry is not SAT text. + let stray = + solid(vec![tag(100, "AcDbEntity"), tag(1, "name"), tag(100, "AcDbModelerGeometry"), tag(70, "1"), tag(100, "AcDb3dSolid"), tag(3, "x")]); + assert!(!sat_text_acis(&stray, h, &none)); + let real = solid(vec![tag(100, "AcDbEntity"), tag(100, "AcDbModelerGeometry"), tag(70, "1"), tag(1, "400 1 1 0"), tag(100, "AcDb3dSolid")]); + assert!(sat_text_acis(&real, h, &none)); + // Without subclass markers (an old file) the groups are the entity's own. + assert!(sat_text_acis(&solid(vec![tag(8, "0"), tag(1, "400 1 1 0")]), h, &none)); + + // A 320 in the data store without an ASM_Data record doesn't keep the geometry. + let store = |asm: bool| { + let mut t = vec![tag(0, "ACDSRECORD"), tag(90, "1"), tag(320, "2B")]; + t.push(tag(2, if asm { "ASM_Data" } else { "Other" })); + Foreign { data_store: t, ..Foreign::default() } + }; + assert!(store(true).data_store_entities().contains(&h)); + assert!(store(false).data_store_entities().is_empty()); +} From 590b97a3211743a780de5996f7e2dadb1918879d Mon Sep 17 00:00:00 2001 From: SBaksa Date: Fri, 30 Oct 2026 18:42:24 +1100 Subject: [PATCH 023/151] Save note: QSAVE reports by the version it writes, not a `version` it ignores (#2334) QSAVE takes no version and writes the drawing's own, but the SAT-text note read a `version` passed to it, so `qsave {version: 2010}` on a 2018 drawing skipped the note while still leaving the solids out. Only SAVEAS's requested version now picks the reporting branch. Test: saving_sat_text_solids_as_dxf_2013_reports_them (qsave case fails without the fix). --- crates/engine/src/cmd/file.rs | 10 ++++++---- crates/engine/tests/save_reports_not_saved.rs | 5 +++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 28c8d66be..e91bbd9da 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -613,10 +613,11 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr } /// Save, then report the entities the file couldn't keep (nothing is dropped silently). -fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result) -> Result { +fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result, asked: Option) -> Result { // What the file will hold depends on the version it is saved as (the data store from 2013). + // QSAVE takes no `version` and keeps the drawing's own; SAVEAS writes the one asked for. let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default(); - let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path)); + let version = asked.unwrap_or_else(|| drawing_version(s, &path)); let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default(); let mut r = run(s, p)?; if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) { @@ -628,11 +629,12 @@ fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> R } fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result { - save_reporting(s, p, run_save) + save_reporting(s, p, run_save, None) } fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result { - save_reporting(s, p, run_saveas) + let asked = save_options("saveas", p)?.version; + save_reporting(s, p, run_saveas, asked) } const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index 839c2489b..5472254a1 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -107,6 +107,11 @@ fn saving_sat_text_solids_as_dxf_2013_reports_them() { assert_eq!(r["notSaved"], json!({ "REGION": 1 })); assert!(s.log.iter().any(|l| l.contains("Not saved in the DXF file") && l.contains("1 REGION")), "{:?}", s.log); + // QSAVE takes no version: a `version` passed to it doesn't change what it writes (the + // drawing's own 2018), so the note still comes. + let r = s.execute("qsave", &json!({ "path": path.to_string_lossy(), "version": 2010 })).unwrap(); + assert_eq!(r["notSaved"], json!({ "REGION": 1 })); + let n = s.log.len(); let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2010 })).unwrap(); let _ = std::fs::remove_file(&path); From 7baa9703818416d132e88e0978c8de83a75ef648 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Fri, 30 Oct 2026 19:18:41 +1100 Subject: [PATCH 024/151] Save note: a data store record stores its first entity; a DWG keeps SAT-text solids (#2334) The writer keeps an ACDSRECORD by its first 320 only, so data_store_entities now credits that handle alone (one shared rule, data_store_owner). The DWG writer converts a solid's SAT text itself at any version, so not_saved_as no longer lists solids that hold SAT text as left out of a DWG. --- crates/doc/src/foreign.rs | 10 ++++++++-- crates/engine/src/cmd/file.rs | 6 +++++- crates/engine/tests/save_reports_not_saved.rs | 15 +++++++++++++++ crates/io/src/dxf_raw.rs | 2 +- crates/io/tests/it/acis_data_store.rs | 11 +++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/foreign.rs b/crates/doc/src/foreign.rs index 133c525e2..dbae3ab49 100644 --- a/crates/doc/src/foreign.rs +++ b/crates/doc/src/foreign.rs @@ -96,7 +96,7 @@ impl Foreign { for rec in data_store_records(&self.data_store) { let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD")); if is_record && rec.iter().any(|t| t.code == 2 && t.value.trim().eq_ignore_ascii_case("ASM_Data")) { - out.extend(rec.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim()))); + out.extend(data_store_owner(rec).and_then(|t| Handle::parse_hex(t.value.trim()))); } } out @@ -151,7 +151,7 @@ pub fn data_store_records(tags: &[RawTag]) -> Vec<&[RawTag]> { /// Whether an entity's groups hold SAT text: groups 1 and 3 inside its `AcDbModelerGeometry` /// subclass. A file of a version before the subclass markers has no `100` groups: all of its /// groups (before the extended data) are the entity's. -fn has_sat_text(tags: &[RawTag]) -> bool { +pub fn has_sat_text(tags: &[RawTag]) -> bool { let own = tags.iter().take_while(|r| r.code < 1000); let marked = tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 100); let mut in_modeler = !marked; @@ -164,3 +164,9 @@ fn has_sat_text(tags: &[RawTag]) -> bool { } false } + +/// The group naming the entity a data store record belongs to: its first `320`. A record is kept +/// or dropped by this handle alone, in the writer and wherever the store is read. +pub fn data_store_owner(record: &[RawTag]) -> Option<&RawTag> { + record.iter().find(|t| t.code == 320) +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e91bbd9da..0bccbfb4f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -550,7 +550,11 @@ pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); for e in stores.flat_map(|s| s.iter()) { if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind { - if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) { + // A DWG writer converts SAT text to its own form at any version, and from 2013 a data + // store record keeps the geometry too: neither loses the solid. + if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) + && (stored.contains(&e.handle) || cadcraft_doc::foreign::has_sat_text(&u.tags)) + { continue; } let n = out.entry(u.dxf_type.clone()).or_insert(0); diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index 5472254a1..d50b1a503 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -144,3 +144,18 @@ fn the_note_counts_the_host_drawing_while_a_block_is_edited() { let _ = std::fs::remove_file(&path); assert_eq!(r["notSaved"], json!({ "REGION": 1 })); } + +/// A DWG writer converts a solid's SAT text itself (at any version), so a DWG save doesn't list +/// solids that hold it as left out; ones with neither SAT text nor a data store record are (#2334). +#[test] +fn a_dwg_keeps_solids_that_hold_sat_text() { + use cadcraft_engine::cmd::file::{Version, not_saved_as}; + let tag = |code: i32, value: &str| RawTag { code, value: value.into() }; + let solid = |tags: Vec| EntityKind::Unknown(Unknown { dxf_type: "3DSOLID".into(), tags }); + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(1, "400 1 1 0")])).unwrap(); + d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(70, "1")])).unwrap(); + for v in [Version::R2010, Version::R2018] { + assert_eq!(not_saved_as(&d, v), [("3DSOLID".to_string(), 1)].into_iter().collect(), "{v:?}"); + } +} diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index 7856a24b8..9efb5b07d 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -603,7 +603,7 @@ pub(crate) fn data_store(tags: &mut Vec, d: &Drawing, modern: bool) { let mut records = 0usize; for rec in cadcraft_doc::foreign::data_store_records(ds) { let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD")); - let owner = rec.iter().find(|t| t.code == 320).map(|t| norm(&t.value)); + let owner = cadcraft_doc::foreign::data_store_owner(rec).map(|t| norm(&t.value)); if !is_record || owner.as_ref().is_some_and(|h| written.contains(h)) { records += usize::from(is_record); out.extend(rec.iter().map(tag)); diff --git a/crates/io/tests/it/acis_data_store.rs b/crates/io/tests/it/acis_data_store.rs index 4f8f455b4..5bb4bc8ee 100644 --- a/crates/io/tests/it/acis_data_store.rs +++ b/crates/io/tests/it/acis_data_store.rs @@ -185,3 +185,14 @@ fn sat_text_is_told_by_its_subclass_and_a_geometry_record() { assert!(store(true).data_store_entities().contains(&h)); assert!(store(false).data_store_entities().is_empty()); } + +/// The writer keeps a data store record by its first 320 only, so only that entity counts as +/// stored (#2334). +#[test] +fn a_data_store_record_stores_only_its_first_entity() { + use cadcraft_doc::foreign::Foreign; + use cadcraft_doc::{Handle, RawTag}; + let tag = |code: i32, value: &str| RawTag { code, value: value.into() }; + let store = Foreign { data_store: vec![tag(0, "ACDSRECORD"), tag(320, "2B"), tag(320, "2C"), tag(2, "ASM_Data")], ..Foreign::default() }; + assert_eq!(store.data_store_entities(), [Handle(0x2B)].into()); +} From 1e96a3d50c2e955da294a0d2dbf2bdb53c40d9f5 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:13:10 -0300 Subject: [PATCH 025/151] DXF: never let a trail byte 0x5C start an escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Shift-JIS, Big5 and GBK a double-byte character's trail byte can be 0x5C, the backslash (表 95 5C, ソ 83 5C, 許 B3 5C, 乗 81 5C). Written as its bytes right before U+ or M+, the file holds a backslash byte followed by U+, an escape for a reader that looks for escapes before it decodes the code page. CADCraft decodes first, so it read back right, but such a character there is now written as a \U+XXXX escape and the file reads the same either way. Elsewhere it stays as its bytes. ANSI_949 has no such trail byte. Suggested by austin62jake on #1693. --- crates/dxf/src/lib.rs | 23 ++++++++++++++++++++--- crates/dxf/tests/code_page_write.rs | 22 ++++++++++++++++++++++ docs/file-format-parity.md | 2 +- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index ec350adaa..7f40c8f32 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -547,18 +547,21 @@ pub fn is_binary(bytes: &[u8]) -> bool { /// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without /// a code page the result is ASCII, so it is valid in any code page. In every form a backslash /// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back -/// the literal text (#2050). +/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that +/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for +/// escapes, but a reader that looks first would take that byte and the `U+` after it for one. fn push_string(s: &str, strings: Strings, out: &mut Vec) { let chars: Vec = s.chars().collect(); + let escape_at = |j: usize| matches!(chars.get(j), Some('U' | 'M')) && chars.get(j + 1) == Some(&'+'); for (i, &c) in chars.iter().enumerate() { match (c, strings) { ('\r' | '\n', _) => out.push(b' '), - ('\\', _) if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => { + ('\\', _) if escape_at(i + 1) => { out.extend_from_slice(b"\\U+005C"); } (c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()), (c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()), - (c, Strings::Escaped(Some(d))) if codepage::encode_char(c, d, out) => {} + (c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {} (c, Strings::Escaped(_)) => { let mut units = [0u16; 2]; for u in c.encode_utf16(&mut units) { @@ -569,6 +572,20 @@ fn push_string(s: &str, strings: Strings, out: &mut Vec) { } } +/// `c` in the code page `d`, if it has it, unless it is a double-byte character whose trail byte +/// is 0x5C and an escape's `U+` or `M+` follows (`before_escape`): see [`push_string`]. +fn code_page_bytes(c: char, d: codepage::Decoder, before_escape: bool, out: &mut Vec) -> bool { + let start = out.len(); + if !codepage::encode_char(c, d, out) { + return false; + } + if before_escape && out.len() - start > 1 && out.last() == Some(&b'\\') { + out.truncate(start); + return false; + } + true +} + /// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`. #[derive(Clone, Debug, Default)] pub struct Section { diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs index 466a2b2a6..75d647c08 100644 --- a/crates/dxf/tests/code_page_write.rs +++ b/crates/dxf/tests/code_page_write.rs @@ -92,3 +92,25 @@ fn literal_escapes_survive_code_page_text() { } } } + +/// Double-byte code pages whose trail byte can be 0x5C (`\\`): Shift-JIS `表` (95 5C) and `ソ` +/// (83 5C), Big5 `許` (B3 5C), GBK `乗` (81 5C). Right before `U+` or `M+`, such a character is +/// written as an escape, so even a reader that looks for escapes before it decodes the code page +/// reads it right. Elsewhere it stays as its bytes. Both forms read back as written. +#[test] +fn a_trail_byte_backslash_never_starts_an_escape() { + let cases: [(&str, &str, &[u8], &[u8]); 4] = [ + ("ANSI_932", "表U+0041 表示", b"\\U+8868U+0041 \x95\x5C\x8E\xA6", b"\x95\x5CU+"), + ("ANSI_932", "ソM+1A2B", b"\\U+30BDM+1A2B", b"\x83\x5CM+"), + ("ANSI_950", "許U+0041", b"\\U+8A31U+0041", b"\xB3\x5CU+"), + ("ANSI_936", "乗U+0041", b"\\U+4E57U+0041", b"\x81\x5CU+"), + ]; + for (cp, text, written, never) in cases { + let tags = file(cp, text); + for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] { + assert!(contains(&bytes, written), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes)); + assert!(!contains(&bytes, never), "{cp} {what}: a trail byte starts an escape"); + assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}"); + } + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d919cf6ba..eda4a0872 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -66,7 +66,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | 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 | 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 | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page, or ASCII DXF whose code page bytes would also read as UTF-8, gives pure ASCII with escapes (#169) | +| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page, or ASCII DXF whose code page bytes would also read as UTF-8, gives pure ASCII with escapes (#169); a double-byte character whose trail byte is `\` (Shift-JIS, Big5, GBK) is escaped where `U+` or `M+` follows it | | `\U+`/`\M+` escapes | yes | yes (`\M+` since #505; Johab `\M+4` stays literal) | `\U+` yes | ### DXF content: read and write by object type From 0ae424c77109cea202dec02601650e250d13f718 Mon Sep 17 00:00:00 2001 From: assamaral Date: Fri, 30 Oct 2026 14:33:00 -0300 Subject: [PATCH 026/151] fix(io): derive the column count of a dynamic auto-height MTEXT from its widths An R2018 dynamic auto-height embedded object stores 0 in group 72 even with several columns, and the clamp turned it into one. With 72 at zero the count is now (42 + 45) / (44 + 45), the inverse of the total width the writer emits. Co-Authored-By: Claude Sonnet 5.5 --- crates/io/src/dxf_read/mtext_dxf.rs | 10 ++++- crates/io/tests/it/mtext_columns_roundtrip.rs | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/io/src/dxf_read/mtext_dxf.rs b/crates/io/src/dxf_read/mtext_dxf.rs index e7752f711..df22cbe36 100644 --- a/crates/io/src/dxf_read/mtext_dxf.rs +++ b/crates/io/src/dxf_read/mtext_dxf.rs @@ -118,9 +118,17 @@ fn read_embedded_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { // The column height: the entity's own 46 (defined height), else the object's 41 or first 46. let own_46 = own(tags).iter().find(|t| t.code == 46).map(|t| finite(t.f64()).max(0.0)); let defined = [own_46, first(41).map(|t| finite(t.f64())), heights.first().copied()].into_iter().flatten().find(|h| *h > 0.0).unwrap_or(0.0); + // A dynamic auto-height object stores 0 columns in 72: the count follows from the total width + // (42 = n * 44 + (n - 1) * 45). + let [width, gutter, total] = [44, 45, 42].map(|g| first(g).map_or(0.0, |t| finite(t.f64()))); + let count = match first(72).map(Tag::i64) { + Some(n) if n > 0 => n, + Some(_) if width + gutter > 0.0 && total > 0.0 => ((total + gutter) / (width + gutter)).round() as i64, + _ => 1, + }; let c = MTextColumns { kind, - count: u16::try_from(first(72).map(Tag::i64).unwrap_or(1).clamp(1, MAX_COLUMNS as i64)).unwrap_or(1), + count: u16::try_from(count.clamp(1, MAX_COLUMNS as i64)).unwrap_or(1), gutter: first(45).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0), height: if manual { heights.iter().copied().fold(0.0, f64::max) } else { defined }, auto_height, diff --git a/crates/io/tests/it/mtext_columns_roundtrip.rs b/crates/io/tests/it/mtext_columns_roundtrip.rs index 3525c8d29..b147f311e 100644 --- a/crates/io/tests/it/mtext_columns_roundtrip.rs +++ b/crates/io/tests/it/mtext_columns_roundtrip.rs @@ -194,3 +194,41 @@ fn r2018_writes_the_embedded_object_and_older_versions_the_legacy_groups() { assert!(!r2000.contains("Embedded Object"), "R2000 has no embedded object"); assert!(r2000.contains(" 75\r\n1\r\n"), "R2000 keeps the legacy column groups"); } + +#[test] +fn dynamic_auto_height_embedded_count_comes_from_the_total_width() { + // A dynamic auto-height object stores no column count (72 = 0): the count is what 42 (total + // width), 44 (column width) and 45 (gutter) hold, 3 * 50 + 2 * 12.5 = 175. + let mut tags = embedded_tags(1, 50.0, 12.5, &[]); + let at = tags.iter().position(|t| t.code == 101).unwrap(); + for t in &mut tags[at..] { + *t = match t.code { + 42 => cadcraft_dxf::Tag::f(42, 175.0), + 71 => cadcraft_dxf::Tag::i(71, 2), + 72 => cadcraft_dxf::Tag::i(72, 0), + 73 => cadcraft_dxf::Tag::i(73, 1), + _ => t.clone(), + }; + } + let (_, t) = mtext_from_groups(&tags); + let c = t.columns.expect("columns"); + assert_eq!((c.kind, c.auto_height, c.count), (MTextColumnType::Dynamic, true, 3)); + assert_eq!(c.gutter, 12.5); +} + +#[test] +fn dynamic_auto_height_count_comes_from_the_widths_when_72_is_zero() { + // A dynamic auto-height object stores 0 in 72; the count is implied by the total width + // (42 = n * 44 + (n - 1) * 45): (175 + 12.5) / (50 + 12.5) = 3. + let mut tags = embedded_tags(0, 50.0, 12.5, &[]); + let mut set = |code: i32, v: cadcraft_dxf::Tag| { + let at = tags.iter().rposition(|t| t.code == code).unwrap(); + tags[at] = v; + }; + set(71, cadcraft_dxf::Tag::i(71, 2)); + set(73, cadcraft_dxf::Tag::i(73, 1)); + set(42, cadcraft_dxf::Tag::f(42, 175.0)); + let (_, t) = mtext_from_groups(&tags); + let c = t.columns.expect("columns"); + assert_eq!((c.kind, c.auto_height, c.count), (MTextColumnType::Dynamic, true, 3)); +} From 8038de04e4650d1ee3a383ff6bfc8126e9a5a61f Mon Sep 17 00:00:00 2001 From: assamaral Date: Fri, 30 Oct 2026 17:35:29 -0300 Subject: [PATCH 027/151] fix(io): an MTEXT with columns takes its width from the column groups ZWCAD and AutoCAD write the total width of the columns in the entity's group 41 and the column width in the embedded object's 44 (48 of the legacy groups). `mtext_from` took 41 whenever it was positive, so a three-column MTEXT of 50 came back 175 wide and the R2018 object was written with 44 = 175, 42 = 550. With columns and a positive column width, that width is the MTEXT's (it is one column's throughout). Review of #2467. Co-Authored-By: Claude Fable 5.1 --- crates/io/src/dxf_read/text.rs | 4 +++- crates/io/tests/it/mtext_columns_roundtrip.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/io/src/dxf_read/text.rs b/crates/io/src/dxf_read/text.rs index 158015169..492825df7 100644 --- a/crates/io/src/dxf_read/text.rs +++ b/crates/io/src/dxf_read/text.rs @@ -76,8 +76,10 @@ pub(super) fn mtext_from(tags: &[Tag], c: &Common) -> MText { MText { insert: t.p(10), height: t.fd(40, 0.2).max(1e-9), + // An MTEXT's width is one column's: with columns, the column width (48, or 44 of the + // embedded object) rather than 41, which ZWCAD and AutoCAD write as the total width. width: match &columns { - Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w, + Some((_, w)) if *w > 0.0 => *w, _ => t.fd(41, 0.0).max(0.0), }, attach, diff --git a/crates/io/tests/it/mtext_columns_roundtrip.rs b/crates/io/tests/it/mtext_columns_roundtrip.rs index b147f311e..be8ac1088 100644 --- a/crates/io/tests/it/mtext_columns_roundtrip.rs +++ b/crates/io/tests/it/mtext_columns_roundtrip.rs @@ -232,3 +232,22 @@ fn dynamic_auto_height_count_comes_from_the_widths_when_72_is_zero() { let c = t.columns.expect("columns"); assert_eq!((c.kind, c.auto_height, c.count), (MTextColumnType::Dynamic, true, 3)); } + +#[test] +fn embedded_column_width_wins_over_the_entitys_width() { + // ZWCAD (and AutoCAD) write the total width of the columns in the entity's 41 and the column + // width in the object's 44: an MTEXT's `width` is one column's, so 44 is what is read, and + // what the R2018 object gets back (#2467 review). + let mut tags = embedded_tags(3, 50.0, 12.5, &[20.0, 20.0, 20.0]); + let own_41 = tags.iter().position(|t| t.code == 41).unwrap(); + tags[own_41] = cadcraft_dxf::Tag::f(41, 175.0); + for code in [40, 42] { + let at = tags.iter().rposition(|t| t.code == code).unwrap(); + tags[at] = cadcraft_dxf::Tag::f(code, 175.0); + } + let (_, t) = mtext_from_groups(&tags); + assert_eq!(t.width, 50.0, "the column width, not the total"); + let (_, r2018) = dxf_as(&t, Version::R2018); + assert!(r2018.contains(" 42\r\n175.0\r\n"), "total width kept:\n{r2018}"); + assert!(r2018.contains(" 44\r\n50.0\r\n 45\r\n12.5\r\n"), "column width and gutter kept:\n{r2018}"); +} From ba3196e3f737f2fed0d4d8ae9c065b20773dec16 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 20:12:28 -0400 Subject: [PATCH 028/151] fix(flatpak): allow reading launcher version pins (#2515) * fix(flatpak): allow reading launcher version pins * fix(flatpak): allow reading launcher version pins --- packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml | 1 + packaging/linux/flatpak/ai.storyteller.cadcraft.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml index 651b9a358..448ba058d 100644 --- a/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '26.08' sdk: org.freedesktop.Sdk command: cadcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 diff --git a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml index 88a49e136..e4b19cb81 100644 --- a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: cadcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 From 282ad11e60f22d4d7bc93d300eeff985a508df8a Mon Sep 17 00:00:00 2001 From: SBaksa Date: Sat, 31 Oct 2026 12:28:21 +1100 Subject: [PATCH 029/151] Save note: a DWG loses SAT-text solids from 2013 on, and the note says so (#2334) A real DWG write and read-back keeps a SAT-text REGION's groups up to 2010 and returns it without them at 2013 and 2018, so the SAT-text skip in not_saved_as applies before 2013 only. The DWG note now says solids are kept in a DXF only when saved as 2010 or earlier. --- crates/engine/src/cmd/file.rs | 11 ++++++---- crates/engine/tests/save_reports_not_saved.rs | 14 ++++++------- crates/io/tests/it/acis_data_store.rs | 21 +++++++++++++++++++ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0bccbfb4f..683bd26f8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -550,10 +550,10 @@ pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); for e in stores.flat_map(|s| s.iter()) { if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind { - // A DWG writer converts SAT text to its own form at any version, and from 2013 a data - // store record keeps the geometry too: neither loses the solid. + // A DWG before 2013 writes a solid's SAT text as it is; from 2013 the geometry has to be + // in the data store, and SAT text alone is lost (#2334). if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) - && (stored.contains(&e.handle) || cadcraft_doc::foreign::has_sat_text(&u.tags)) + && (stored.contains(&e.handle) || (version < Version::R2013 && cadcraft_doc::foreign::has_sat_text(&u.tags))) { continue; } @@ -570,7 +570,10 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap) -> Op return None; } let list: Vec = lost.iter().map(|(t, n)| format!("{n} {t}")).collect(); - Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", "))) + Some(format!( + "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them, solids only when saved as 2010 or earlier): {}", + list.join(", ") + )) } /// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index d50b1a503..295877d5c 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -29,7 +29,7 @@ fn save_lists_the_entities_the_file_does_not_keep() { let r = s.execute("saveas", &json!({ "path": path.to_string_lossy() })).unwrap(); let _ = std::fs::remove_file(&path); assert_eq!(r["notSaved"], json!({ "3DSOLID": 1, "MLINE": 2, "REGION": 1 })); - let msg = "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): 1 3DSOLID, 2 MLINE, 1 REGION"; + let msg = "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them, solids only when saved as 2010 or earlier): 1 3DSOLID, 2 MLINE, 1 REGION"; assert!(s.log.iter().any(|l| l == msg), "{:?}", s.log); // DXF keeps them. @@ -145,17 +145,17 @@ fn the_note_counts_the_host_drawing_while_a_block_is_edited() { assert_eq!(r["notSaved"], json!({ "REGION": 1 })); } -/// A DWG writer converts a solid's SAT text itself (at any version), so a DWG save doesn't list -/// solids that hold it as left out; ones with neither SAT text nor a data store record are (#2334). +/// A DWG before 2013 writes a solid's SAT text as it is, so a save there doesn't list a solid that +/// holds it; from 2013 the geometry must be in the data store and SAT text alone is lost, so the +/// save lists it. A solid with neither is always listed (#2334). #[test] -fn a_dwg_keeps_solids_that_hold_sat_text() { +fn a_dwg_keeps_sat_text_solids_only_before_2013() { use cadcraft_engine::cmd::file::{Version, not_saved_as}; let tag = |code: i32, value: &str| RawTag { code, value: value.into() }; let solid = |tags: Vec| EntityKind::Unknown(Unknown { dxf_type: "3DSOLID".into(), tags }); let mut d = Drawing::new_imperial(); d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(1, "400 1 1 0")])).unwrap(); d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(70, "1")])).unwrap(); - for v in [Version::R2010, Version::R2018] { - assert_eq!(not_saved_as(&d, v), [("3DSOLID".to_string(), 1)].into_iter().collect(), "{v:?}"); - } + assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 1)].into_iter().collect(), "2010 keeps the SAT-text solid"); + assert_eq!(not_saved_as(&d, Version::R2018), [("3DSOLID".to_string(), 2)].into_iter().collect(), "2018 loses it"); } diff --git a/crates/io/tests/it/acis_data_store.rs b/crates/io/tests/it/acis_data_store.rs index 5bb4bc8ee..f0ad2039c 100644 --- a/crates/io/tests/it/acis_data_store.rs +++ b/crates/io/tests/it/acis_data_store.rs @@ -196,3 +196,24 @@ fn a_data_store_record_stores_only_its_first_entity() { let store = Foreign { data_store: vec![tag(0, "ACDSRECORD"), tag(320, "2B"), tag(320, "2C"), tag(2, "ASM_Data")], ..Foreign::default() }; assert_eq!(store.data_store_entities(), [Handle(0x2B)].into()); } + +/// A real DWG round trip of a SAT-text REGION: the DWG writer keeps the SAT groups up to 2010, and +/// from 2013 they come back empty (the solid's geometry is not in the file), which the DWG save +/// note has to say (#2334). +#[test] +fn a_dwg_keeps_a_sat_text_region_only_before_2013() { + let sat = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n\ + 0\nREGION\n5\n2B\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n70\n1\n1\n400 1 1 0\n3\nEnd-of-ACIS-data\n0\nENDSEC\n0\nEOF\n"; + let d = cadcraft_io::read_dxf(sat.as_bytes()).unwrap(); + let holds_sat = |v: cadcraft_io::Version| { + let dwg = cadcraft_io::write_with(&d, "x.dwg", &cadcraft_io::SaveOptions { version: Some(v), binary: false }).unwrap(); + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + let region = back.model.iter().find_map(|e| match &e.kind { + EntityKind::Unknown(u) if u.dxf_type == "REGION" => Some(u.clone()), + _ => None, + }); + region.is_some_and(|u| cadcraft_doc::foreign::has_sat_text(&u.tags)) + }; + assert!(holds_sat(cadcraft_io::Version::R2010), "2010 keeps the SAT text"); + assert!(!holds_sat(cadcraft_io::Version::R2018), "2018 comes back without it"); +} From 57e6a89d10a8bff40391fcfa0a697d71ea8ab26d Mon Sep 17 00:00:00 2001 From: SBaksa Date: Sat, 31 Oct 2026 12:31:34 +1100 Subject: [PATCH 030/151] Parity checklist: regenerated after merging main --- docs/parity-checklist.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 53ffffc96..f842c51a9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,10 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-30 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-31 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 298 / 491 items (61%)** · 444 registered commands. +**Menu breadth: 298 / 491 items (61%)** · 446 registered commands. | Menu | Items | Covered | % | |---|---|---|---| From 04e74a165c12dd99c51a4feffa179c59cc672e69 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 30 Oct 2026 19:10:52 -0700 Subject: [PATCH 031/151] Panic hunt covers every command, and a panic after a command restores the drawing (#2517) panic_hunt runs every command in a fresh session and fails on real panics. The guard now covers the steps after a command, so a late panic restores the drawing instead of ending the app. --- AGENTS.md | 4 +- CLAUDE.md | 4 +- crates/engine/src/guard.rs | 43 +++++++- crates/engine/src/session/execute.rs | 20 ++++ crates/engine/src/session/interactive.rs | 16 +++ crates/engine/src/tests.rs | 19 ---- crates/engine/tests/panic_hunt.rs | 126 +++++++++++++++++++++++ 7 files changed, 208 insertions(+), 24 deletions(-) create mode 100644 crates/engine/tests/panic_hunt.rs diff --git a/AGENTS.md b/AGENTS.md index e3d7418d7..657e57dec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics. - **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts. - **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`). -- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. -- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`). +- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts. +- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`). - Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables diff --git a/CLAUDE.md b/CLAUDE.md index 8e4a1fdfa..964a00124 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics. - **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts. - **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`). -- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. -- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`). +- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts. +- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`). - Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 8a64e2b74..799b7046d 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -105,10 +105,51 @@ impl Interactive for Guarded { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::Running; + thread_local! { + static PANIC_AFTER_COMMAND: Cell = const { Cell::new(false) }; + } + + /// Called at the start of the passes after a command, which nothing can make panic on demand. + pub(crate) fn panic_if_armed() { + assert!(!PANIC_AFTER_COMMAND.get(), "a pass after the command panicked"); + } + + /// A panic in the passes after a JSON command: an error, and the drawing as it was. + #[test] + fn a_panic_after_a_command_restores_the_drawing() { + let mut s = Session::new(); + s.execute("line", &serde_json::json!({"points": [[0, 0], [1, 0]]})).unwrap(); + let undo = s.state().unwrap().undo.len(); + PANIC_AFTER_COMMAND.set(true); + let r = s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1})); + PANIC_AFTER_COMMAND.set(false); + assert!(matches!(&r, Err(EngineError::Internal(id, m)) if id == "circle" && m.contains("after the command")), "{r:?}"); + assert_eq!(points(&s), 1, "the circle is gone with the failed command"); + assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for it"); + s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1})).unwrap(); + assert_eq!(points(&s), 2, "the session still works"); + } + + /// The same at the end of an interactive command, where nothing returns the error to a caller. + #[test] + fn a_panic_after_an_interactive_command_restores_the_drawing() { + let mut s = Session::new(); + s.cmdline("line 0,0 1,0").unwrap(); + PANIC_AFTER_COMMAND.set(true); + let r = s.input(Input::Enter); + PANIC_AFTER_COMMAND.set(false); + assert!(r.is_ok(), "{r:?}"); + assert!(s.running.is_none()); + assert_eq!(points(&s), 0, "the drawing is restored"); + assert!(s.log.iter().any(|l| l.contains("Internal error in line")), "{:?}", s.log); + s.cmdline("line 0,0 1,0").unwrap(); + assert!(s.running.is_some(), "the session still works"); + } + fn dot(p: Vec2) -> EntityKind { EntityKind::Point(cadcraft_doc::Point { p: p.to3(0.0), angle: 0.0 }) } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 7b0547404..a4c57eec5 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -4,7 +4,27 @@ impl Session { // ---------------- programmatic execution ---------------- /// Run a command with JSON parameters (no dialogs). Records undo when the drawing changes. + /// + /// The command itself is guarded below, where its own panic can be told apart. This guard is + /// for everything around it: the passes after a command (constraints, associativity, groups) + /// are as able to panic as the command, and a panic there restores the drawing the same way. pub fn execute(&mut self, id: &str, params: &Value) -> Result { + let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid)); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.execute_guarded(id, params))) { + Ok(r) => r, + Err(p) => { + if let Some((doc, sel, uid)) = before + && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) + { + st.doc = doc; + st.selection = sel; + } + Err(EngineError::Internal(id.into(), crate::guard::message(&*p))) + } + } + } + + fn execute_guarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; (spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?; if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) { diff --git a/crates/engine/src/session/interactive.rs b/crates/engine/src/session/interactive.rs index 13d944d63..d90c91683 100644 --- a/crates/engine/src/session/interactive.rs +++ b/crates/engine/src/session/interactive.rs @@ -341,7 +341,21 @@ impl Session { } } + /// End a command: every way out of one comes through here. The passes after a command + /// (constraints, associativity, groups) can panic like the command itself; a panic restores + /// the drawing as it was before the command, as a panic in its `input` does. fn finish(&mut self, run: Running, cancelled: bool) { + let (id, before, selection) = (run.id.clone(), run.before.clone(), run.selection_before.clone()); + if let Err(p) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.finish_guarded(run, cancelled))) { + if let Ok(st) = self.state_mut() { + st.doc = before; + st.selection = selection; + } + self.echo(format!("Internal error in {id}: {}", crate::guard::message(&*p))); + } + } + + fn finish_guarded(&mut self, run: Running, cancelled: bool) { // A typed selection mode or SIngle never outlives its command. self.select_mode = None; self.select_single = false; @@ -391,6 +405,8 @@ impl Session { /// started in, so associativity is not applied across a document switch; `ok` is false for a /// failed or non-undoable programmatic command, which skips the constraint pass. pub(super) fn after_command(&mut self, before: Option<&Arc>, uid: Option, ok: bool) { + #[cfg(test)] + crate::guard::tests::panic_if_armed(); cmd::groups::after_command(self, before); cmd::constraints::after_command(self, before, ok); assoc::after_command(self, before, uid); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 427bfe8d4..1f3d0e8e1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -700,25 +700,6 @@ fn cancel_keeps_partial_line_as_one_undo() { assert_eq!(s.doc().unwrap().model.len(), 0); } -#[test] -fn hostile_params_never_panic() { - let mut s = Session::new(); - for c in command_specs() { - for p in [ - json!(null), - json!([]), - json!("x"), - json!({"points": [[1e308, -1e308], ["a"]]}), - json!({"handles": ["zz", 99999]}), - json!({"radius": -1, "center": [0, 0]}), - json!({"gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}), - json!({"points": [[0, 0]], "handles": ["1"], "gradient": "x", "name": "RAINBOW"}), - ] { - let _ = s.execute(c.id, &p); - } - } -} - #[test] fn every_interactive_command_starts_and_cancels() { for c in command_specs().iter().filter(|c| c.interactive.is_some()) { diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs new file mode 100644 index 000000000..a42e48278 --- /dev/null +++ b/crates/engine/tests/panic_hunt.rs @@ -0,0 +1,126 @@ +//! Fuzz every registered command with adversarial params: a command must return `Err`, never panic +//! or hang. Each call gets a fresh, populated session on its own thread with a deadline, so one bad +//! command can't change what the next one sees or wedge the run. +//! +//! `Session::execute` contains a command's panic and returns an ordinary `Err`, so looking at the +//! result can't find one, and neither can `catch_unwind` here. A panic hook counts them instead, +//! on whichever thread they happen. `contained_panics_count_and_graceful_errors_do_not` proves it. + +use cadcraft_engine::{Session, command_specs}; +use serde_json::{Value, json}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, Once, PoisonError, mpsc}; +use std::time::Duration; + +/// A drawing with something to act on, so commands reach their real work: with nothing in it most +/// of them stop at "nothing to do", and with no drawing at all they don't run. +fn fresh() -> Session { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0], [10, 10]]})).unwrap(); + s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap(); + s +} + +/// Wall-clock budget for one command on the small drawing. Real work takes milliseconds, so this +/// only catches hangs and work that grows with a number in the params. +const DEADLINE: Duration = Duration::from_secs(4); + +/// Budget for the self-test, which checks how panics are counted: the hook prints a backtrace +/// under `RUST_BACKTRACE=1`, and the first symbol lookup can be slow on a loaded runner. +const SELF_TEST_DEADLINE: Duration = Duration::from_secs(120); + +/// Panics anywhere in the process, counted by a hook. +static PANICS: AtomicUsize = AtomicUsize::new(0); +/// One run at a time, so a panic is charged to the run that caused it. +static RUN: Mutex<()> = Mutex::new(()); + +/// Run `f` on a worker thread. `None` if it took longer than `deadline`, else whether it finished +/// without panicking, contained or not (returning `Ok` or a graceful `Err` both count). +fn run_watched(deadline: Duration, f: impl FnOnce() + Send + 'static) -> Option { + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let default = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + PANICS.fetch_add(1, Ordering::SeqCst); + default(info); + })); + }); + let _one = RUN.lock().unwrap_or_else(PoisonError::into_inner); + let before = PANICS.load(Ordering::SeqCst); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let r = catch_unwind(AssertUnwindSafe(f)); + let _ = tx.send(r.is_ok()); + }); + rx.recv_timeout(deadline).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before) +} + +fn run_once(id: &str, p: &Value) -> Option { + let (id, p) = (id.to_string(), p.clone()); + run_watched(DEADLINE, move || { + let mut s = fresh(); + let _ = s.execute(&id, &p); + }) +} + +/// `Err` with a reason on a panic or a hang. A real hang reproduces and one-time setup (the first +/// font scan) doesn't, so a timeout counts only if a rerun on a fresh session times out too. +fn run_guarded(id: &str, p: &Value) -> Result<(), String> { + match run_once(id, p).or_else(|| run_once(id, p)) { + Some(true) => Ok(()), + Some(false) => Err("panicked".into()), + None => Err(format!("hung (>{}s, twice)", DEADLINE.as_secs())), + } +} + +/// Shapes and sizes a hostile script, drawing or agent could send. No param names a real path: +/// the hunt must not write files. +fn adversarial() -> Vec { + let long = "x".repeat(100_000); + vec![ + json!(null), + json!({}), + json!([]), + json!("x"), + json!({"points": [[1e308, -1e308], ["a"]], "handles": ["zz", 99999]}), + json!({"points": [], "handles": [], "center": [], "from": [], "to": [], "name": "", "layer": "", "style": "", "text": ""}), + json!({"center": [1e308, 1e308], "radius": 1e308, "angle": 1e308, "scale": 1e308, "height": 1e308, "width": 1e308, "distance": 1e308}), + json!({"center": [0, 0], "radius": -1, "angle": -1e308, "scale": 0, "height": 0, "width": 0, "distance": -1, "factor": 0}), + json!({"count": 1e9, "rows": 1e9, "columns": 1e9, "levels": 1e9, "items": 1e9, "sides": 1e9, "segments": 1e9, "index": 1e9}), + json!({"count": -1, "rows": -1, "columns": -1, "levels": -1, "items": -1, "sides": -1, "segments": -1, "index": -1}), + json!({"name": long, "layer": long, "text": long, "style": long, "mode": long}), + json!({"name": "\u{0}\u{202e}\u{1f4a5}", "text": r"%%%\P{\H0;", "gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}), + ] +} + +#[test] +fn no_command_panics_or_hangs_on_adversarial_params() { + let params = adversarial(); + let mut bad: Vec = Vec::new(); + for spec in command_specs() { + for p in ¶ms { + if let Err(why) = run_guarded(spec.id, p) { + let shown: String = p.to_string().chars().take(120).collect(); + bad.push(format!("{} [{why}] <- {shown}", spec.id)); + break; + } + } + } + assert!(bad.is_empty(), "{} commands panicked or hung:\n{}", bad.len(), bad.join("\n")); +} + +/// A panic the code under test contains still fails the run; a graceful `Err` does not. Without +/// this the hunt could quietly stop detecting anything. +#[test] +fn contained_panics_count_and_graceful_errors_do_not() { + let contained = run_watched(SELF_TEST_DEADLINE, || { + let _ = catch_unwind(|| panic!("contained, as Session::execute does")); + }); + assert_eq!(contained, Some(false)); + let graceful = run_watched(SELF_TEST_DEADLINE, || { + let mut s = fresh(); + assert!(s.execute("no-such-command", &json!({})).is_err()); + }); + assert_eq!(graceful, Some(true)); +} From 3d623fc4d4636da86b793aa77756eb1c85335f48 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 30 Oct 2026 19:10:57 -0700 Subject: [PATCH 032/151] Saving over a DWG asks before it loses content, and saves keep a .bak (#2518) Includes the .bak work from #1003. Co-authored-by: Aldo Zarate --- crates/engine/src/atomic_write.rs | 34 ++- crates/engine/src/autosave.rs | 12 ++ crates/engine/src/cmd/file.rs | 196 +++++++++++++++++- crates/engine/src/sysvars.rs | 3 + crates/engine/tests/save_backup.rs | 296 +++++++++++++++++++++++++++ crates/ui-egui/src/autosave.rs | 9 +- crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/lib.rs | 12 +- crates/ui-egui/src/menus.rs | 12 +- crates/ui-egui/src/options.rs | 12 +- crates/ui-egui/src/options/dialog.rs | 11 + crates/ui-egui/src/save_loss.rs | 154 ++++++++++++++ crates/ui-egui/tests/save_loss.rs | 108 ++++++++++ docs/gaps.md | 9 +- 16 files changed, 860 insertions(+), 15 deletions(-) create mode 100644 crates/engine/tests/save_backup.rs create mode 100644 crates/ui-egui/src/save_loss.rs create mode 100644 crates/ui-egui/tests/save_loss.rs diff --git a/crates/engine/src/atomic_write.rs b/crates/engine/src/atomic_write.rs index ff9e141d3..0d7459b1b 100644 --- a/crates/engine/src/atomic_write.rs +++ b/crates/engine/src/atomic_write.rs @@ -36,13 +36,20 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { Err(e) if e.kind() == io::ErrorKind::NotFound => None, Err(e) => return Err(e), }; + write_atomic_with(path, permissions, |file| file.write_all(bytes)) +} + +/// [`write_atomic`] filling the temporary file through `fill` (streamed rather than one buffer), +/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink +/// at `path` is replaced by the new file, never written through. +pub fn write_atomic_with(path: &Path, permissions: Option, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf); let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?; let write = (|| { if let Some(permissions) = permissions { file.set_permissions(permissions)?; } - file.write_all(bytes)?; + fill(&mut file)?; file.flush()?; file.sync_all() })(); @@ -55,9 +62,22 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { let _ = std::fs::remove_file(&tmp); return Err(e); } + sync_dir(&dir); Ok(()) } +/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a +/// rename in the directory, not in the file). Best effort: some file systems (network shares) +/// refuse to sync a directory, and the file itself is already on disk. +fn sync_dir(dir: &Path) { + #[cfg(unix)] + if let Ok(d) = File::open(dir) { + let _ = d.sync_all(); + } + #[cfg(not(unix))] + let _ = dir; +} + /// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path. /// /// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing @@ -116,6 +136,18 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// A write into a folder whose entries can be listed syncs that folder without failing, and + /// a folder that can't be opened (gone) is no error for the sync itself. + #[test] + fn the_folder_is_synced_after_the_rename() { + let dir = std::env::temp_dir().join(format!("cadcraft-aw-sync-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + write_atomic(&dir.join("a.bin"), b"x").unwrap(); + assert_eq!(std::fs::read(dir.join("a.bin")).unwrap(), b"x"); + sync_dir(&dir.join("missing")); + std::fs::remove_dir_all(&dir).ok(); + } + #[cfg(unix)] #[test] fn does_not_follow_a_planted_temp_symlink() { diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 480d14c3b..c6b41cce4 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -34,6 +34,18 @@ pub const STALE_LOCK_SECS: u64 = 5 * 60; const HEARTBEAT_SECS: f64 = 60.0; static SAVETIME: AtomicU32 = AtomicU32::new(DEFAULT_SAVETIME); +static ISAVEBAK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + +/// ISAVEBAK: whether saving over a drawing keeps the previous file as `.bak` (on by +/// default, as in AutoCAD). A profile setting like SAVETIME. +pub fn isavebak() -> bool { + ISAVEBAK.load(Ordering::Relaxed) +} + +/// Turn ISAVEBAK on or off. +pub fn set_isavebak(on: bool) { + ISAVEBAK.store(on, Ordering::Relaxed); +} static SAVEFILEPATH: Mutex = Mutex::new(String::new()); /// SAVETIME: minutes between autosaves; 0 turns autosave off. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7c26a71f9..8bb4c5c75 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -420,11 +420,20 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result.bak` (ISAVEBAK, +/// #540). fn store(path: &str, bytes: &[u8]) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] { + if is_drawing_file(path) && crate::autosave::isavebak() { + backup(std::path::Path::new(path)) + .map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?; + } // Exclusively create a fresh temporary file and rename it over `path`, so a planted // `.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59). crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}"))) @@ -437,6 +446,178 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> { } } +/// The backup of the drawing file `path`: `.bak` beside it, as AutoCAD names it. +pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf { + path.with_extension("bak") +} + +/// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as +/// the backup. A file that doesn't (truncated, overwritten by another program) never replaces a +/// good `.bak`. +pub fn looks_like_drawing(bytes: &[u8]) -> bool { + // DWG: a version tag like `AC1032` (R12 is `AC1009`). + let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit()); + if dwg || cadcraft_dxf::is_binary(bytes) { + return true; + } + // ASCII DXF: optional comments (999), then group 0 SECTION. + let text = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]); + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + loop { + match (lines.next(), lines.next()) { + (Some("999"), Some(_)) => continue, + (Some("0"), Some(v)) => return v.eq_ignore_ascii_case("SECTION"), + _ => return false, + } + } +} + +/// How much of a drawing's end [`looks_complete`] reads. +const TAIL_BYTES: u64 = 256; + +/// Whether a drawing that [`looks_like_drawing`] (`head`, its first bytes, at least 256 of them +/// for a DWG) is whole, from its end (`tail`, its last bytes) and size `len`, without reading it +/// all: a DXF ends with `0`/`EOF` (a binary one with `EOF` and its NUL); a DWG reaches the end its +/// own header gives ([`dwg_complete`]). `None` when that can't be told (a 2007 DWG), so a file cut +/// off by a failed save or another program doesn't replace a good backup. +pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option { + let dwg = head.len() >= 6 && head.starts_with(b"AC") && head[2..6].iter().all(|b| b.is_ascii_digit()); + if dwg { + // Too short for any DWG's header: cut off, not unknown. + return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) }; + } + if cadcraft_dxf::is_binary(head) { + return Some(tail.ends_with(b"EOF\0")); + } + let text = String::from_utf8_lossy(tail); + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()).rev(); + Some(matches!((lines.next(), lines.next()), (Some(last), Some("0")) if last.eq_ignore_ascii_case("EOF"))) +} + +/// [`completeness`] known to be whole. +pub fn looks_complete(head: &[u8], tail: &[u8], len: u64) -> bool { + completeness(head, tail, len) != Some(false) +} + +/// Whether a DWG of `len` bytes reaches the end its header gives (Open Design Specification for +/// .dwg files): from 2004 on, the copy of the file header that ends the file (its address is in +/// the encrypted header at 0x80, and the copy is 0x6C bytes); up to 2000, the end of every section +/// the locator records give. `None` for another layout (2007, or before R13). +fn dwg_complete(head: &[u8], len: u64) -> Option { + let version = head.get(..6)?; + match version { + b"AC1018" | b"AC1024" | b"AC1027" | b"AC1032" => { + // The header at 0x80 is XORed with the generator the specification gives. + let enc = head.get(0x80..0x80 + 0x6C)?; + let mut seed: u32 = 1; + let h: Vec = enc + .iter() + .map(|b| { + seed = seed.wrapping_mul(0x343FD).wrapping_add(0x0026_9EC3); + b ^ (seed >> 16) as u8 + }) + .collect(); + if h.get(..12) != Some(&b"AcFssFcAJMB\0"[..]) { + return Some(false); + } + let second = u64::from_le_bytes(h.get(0x34..0x3C)?.try_into().ok()?); + Some(second >= 0x100 && second.checked_add(0x6C).is_some_and(|end| len >= end)) + } + b"AC1012" | b"AC1014" | b"AC1015" => { + let n = i32::from_le_bytes(head.get(0x15..0x19)?.try_into().ok()?); + if !(1..=16).contains(&n) { + return Some(false); + } + let mut end = 0u64; + for i in 0..n as usize { + let r = head.get(0x19 + i * 9..0x19 + i * 9 + 9)?; + let seek = u64::from(u32::from_le_bytes(r.get(1..5)?.try_into().ok()?)); + let size = u64::from(u32::from_le_bytes(r.get(5..9)?.try_into().ok()?)); + end = end.max(seek + size); + } + Some(len >= end) + } + _ => None, + } +} + +/// A file's first bytes, last bytes and length, left positioned just after the first bytes. +#[cfg(not(target_arch = "wasm32"))] +fn ends(src: &mut std::fs::File) -> std::io::Result<(Vec, Vec, u64)> { + use std::io::{Read, Seek, SeekFrom}; + let mut head = Vec::with_capacity(4096); + (&mut *src).take(4096).read_to_end(&mut head)?; + // Its end too (a DXF's EOF), then on from where the start left off for the copy. + let len = src.metadata()?.len(); + let mut tail = Vec::new(); + src.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES)))?; + (&mut *src).take(TAIL_BYTES).read_to_end(&mut tail)?; + src.seek(SeekFrom::Start(head.len() as u64))?; + Ok((head, tail, len)) +} + +/// Whether saving replaces the `.bak` with this file. A file known to be cut off never replaces a +/// backup already there. One that can't be told (a 2007 DWG) does: it is most likely fine, and it +/// is about to be replaced. With no backup yet any file is kept, because the checks can be wrong +/// about a real file and this save replaces the only copy. +fn backup_replaced(head: &[u8], tail: &[u8], len: u64, backup_exists: bool) -> bool { + (looks_like_drawing(head) && completeness(head, tail, len) != Some(false)) || (len > 0 && !backup_exists) +} + +/// Whether a save over `path` now would leave the current file in its `.bak`, for a warning that +/// must not promise a backup the save won't write. +#[cfg(not(target_arch = "wasm32"))] +pub fn save_keeps_current(path: &std::path::Path) -> bool { + let Ok(mut src) = std::fs::File::open(path) else { return false }; + crate::autosave::isavebak() && ends(&mut src).is_ok_and(|(head, tail, len)| backup_replaced(&head, &tail, len, backup_path(path).exists())) +} + +#[cfg(target_arch = "wasm32")] +pub fn save_keeps_current(_path: &std::path::Path) -> bool { + false +} + +/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too +/// ([`crate::atomic_write::write_atomic_with`]). Nothing +/// to keep when there is no file yet. A file that doesn't look like a drawing +/// ([`looks_like_drawing`]) is not backed up, and the `.bak` already there is left alone. +#[cfg(not(target_arch = "wasm32"))] +fn backup(path: &std::path::Path) -> std::io::Result<()> { + let mut src = match std::fs::File::open(path) { + Ok(f) if f.metadata()?.is_file() => f, + Ok(_) => return Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e), + }; + let (head, tail, len) = ends(&mut src)?; + let bak = backup_path(path); + if !backup_replaced(&head, &tail, len, bak.exists()) { + log::warn!("save: {} doesn't look like a whole drawing; its backup is left as it was", path.display()); + return Ok(()); + } + // The backup is no more readable than the drawing, nor than the .bak it replaces (a private + // 0600 drawing or .bak stays private). The mode is set as the new file is created, so a + // failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never + // written through. + #[cfg(unix)] + let permissions = { + use std::os::unix::fs::PermissionsExt; + let mut mode = src.metadata()?.permissions().mode() & 0o777; + if let Ok(m) = std::fs::symlink_metadata(&bak) + && m.is_file() + { + mode &= m.permissions().mode(); + } + Some(std::fs::Permissions::from_mode(mode)) + }; + #[cfg(not(unix))] + let permissions = None; + crate::atomic_write::write_atomic_with(&bak, permissions, |out| { + std::io::Write::write_all(out, &head)?; + std::io::copy(&mut src, out).map(|_| ()) + }) +} + /// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps /// its unsaved changes, file name and title. On the web a started download counts as saved: the /// browser owns the file from there (we can't see whether the user keeps it), and the name stays so @@ -564,6 +745,17 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap) -> Op Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", "))) } +/// What saving the active drawing to `path` would leave out, before anything is written: empty +/// when the format keeps everything. The interactive save asks with this; [`save_reporting`] +/// reports the same list afterwards to callers that never get a dialog. +pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap { + if !drops_unknown(path) { + return Default::default(); + } + let version = drawing_version(s, path); + s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default() +} + /// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG /// bridge drops types it doesn't know); DXF writes them back as read. pub fn drops_unknown(path: impl AsRef) -> bool { diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 5c0f3951c..b532f66dd 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -70,6 +70,7 @@ const SESSION_VARS: &[&str] = &[ "FONTFALLBACK", "SAVETIME", "SAVEFILEPATH", + "ISAVEBAK", ]; pub fn get(s: &Session, name: &str) -> Option { @@ -154,6 +155,7 @@ pub fn get(s: &Session, name: &str) -> Option { // Autosave (profile settings too). "SAVETIME" => json!(crate::autosave::savetime()), "SAVEFILEPATH" => json!(crate::autosave::savefilepath()), + "ISAVEBAK" => json!(i32::from(crate::autosave::isavebak())), "CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()), "DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()), "DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)), @@ -366,6 +368,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { as_f64(v).filter(|m| *m >= 0.0 && *m <= f64::from(crate::autosave::MAX_SAVETIME)).ok_or_else(bad)?.round() as u32, ), "SAVEFILEPATH" => crate::autosave::set_savefilepath(v.as_str().ok_or_else(bad)?), + "ISAVEBAK" => crate::autosave::set_isavebak(as_bool(v).ok_or_else(bad)?), // A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line). "ANGBASE" => { let a = match v { diff --git a/crates/engine/tests/save_backup.rs b/crates/engine/tests/save_backup.rs new file mode 100644 index 000000000..afa5a14cb --- /dev/null +++ b/crates/engine/tests/save_backup.rs @@ -0,0 +1,296 @@ +//! Saving over a drawing keeps the previous file as `.bak` (ISAVEBAK, #540), and the +//! save itself replaces the file atomically: no temporary files are left behind. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, backup_path, looks_complete, looks_like_drawing, save_keeps_current, set_io}; +use serde_json::json; + +fn hooks() { + set_io(IoHooks { + read: |b, n| cadcraft_io::read(b, n).map_err(|e| e.to_string()), + write: |d, n| cadcraft_io::write(d, n).map_err(|e| e.to_string()), + plot: None, + }); + // Saves at a chosen version (a DWG 2000), as the app's. + cadcraft_engine::cmd::file::set_write_as(|d, n, o| { + cadcraft_io::write_with(d, n, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) + }); +} + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("cadcraft-bak-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn circles(path: &std::path::Path) -> usize { + let d = cadcraft_io::read(&std::fs::read(path).unwrap(), &*path.to_string_lossy()).unwrap(); + d.model.iter().filter(|e| matches!(e.kind, cadcraft_engine::doc::EntityKind::Circle(_))).count() +} + +/// Only the drawing and its backup are in the folder: atomic saves leave no staging files. +fn files(dir: &std::path::Path) -> Vec { + let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + v.sort(); + v +} + +/// ISAVEBAK is process-wide: the tests that save hold this lock, so one turning it off can't +/// race another's save. Dropping the guard turns it back on, also when a test fails. +static SAVES: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +struct Saving(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); + +impl Drop for Saving { + fn drop(&mut self) { + cadcraft_engine::autosave::set_isavebak(true); + } +} + +fn saving() -> Saving { + let g = SAVES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + cadcraft_engine::autosave::set_isavebak(true); + Saving(g) +} + +fn add_circle(s: &mut Session, x: f64) { + s.execute("circle", &json!({ "center": [x, 0], "radius": 1 })).unwrap(); +} + +#[test] +fn each_save_keeps_the_previous_file_as_bak() { + let _saving = saving(); + hooks(); + let dir = temp_dir("rotate"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + assert_eq!(files(&dir), ["Plan.dxf"], "a first save has nothing to back up"); + + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]); + assert_eq!((circles(&path), circles(&backup_path(&path))), (2, 1), "the .bak is the previous save"); + + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 2)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_damaged_file_never_replaces_a_good_backup_and_isavebak_0_keeps_none() { + let _saving = saving(); + hooks(); + let dir = temp_dir("damaged"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1); + + // Another program truncates the drawing; the next save keeps the good backup. + std::fs::write(&path, b"").unwrap(); + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 1)); + + // Cut off with a drawing's start kept (half a DXF, a DWG's version tag alone): the backup + // stays too. + let whole = std::fs::read(&path).unwrap(); + std::fs::write(&path, &whole[..whole.len() / 2]).unwrap(); + add_circle(&mut s, 11.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "half a DXF"); + std::fs::write(&path, b"AC1032").unwrap(); + add_circle(&mut s, 12.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "a DWG's tag alone"); + + // ISAVEBAK 0: the backup is left as it was. + s.execute("setvar", &json!({ "name": "ISAVEBAK", "value": 0 })).unwrap(); + add_circle(&mut s, 15.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!((circles(&path), circles(&backup_path(&path))), (6, 1)); + assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn drawings_are_recognised_by_their_start() { + assert!(looks_like_drawing(b"AC1032\0\0\0\0\0")); + assert!(looks_like_drawing(b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n")); + assert!(looks_like_drawing(b"999\ndxfrw\n0\nSECTION\n")); + assert!(looks_like_drawing(b"AutoCAD Binary DXF\r\n\x1a\0")); + for junk in [&b""[..], b"\0\0\0\0", b"0\nEOF\n", b"", b"hello"] { + assert!(!looks_like_drawing(junk), "{junk:?}"); + } +} + +/// A private drawing (0600) keeps a private backup: a new .bak, and a .bak already there that +/// others could read, both take the drawing's mode. +#[cfg(unix)] +#[test] +fn the_backup_is_no_more_readable_than_the_drawing() { + use std::os::unix::fs::PermissionsExt; + let _saving = saving(); + hooks(); + let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let dir = temp_dir("mode"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(mode(&backup_path(&path)), 0o600, "a new .bak"); + + std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o644)).unwrap(); + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(mode(&backup_path(&path)), 0o600, "a .bak already there"); + assert_eq!(circles(&backup_path(&path)), 2); + + // A .bak kept more private than the drawing is never widened. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o600)).unwrap(); + add_circle(&mut s, 15.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(mode(&backup_path(&path)), 0o600, "a more private .bak"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A symlink planted at the .bak is replaced by the backup, never written through: the file it +/// pointed at is left as it was. +#[cfg(unix)] +#[test] +fn a_symlink_at_the_backup_is_not_followed() { + let _saving = saving(); + hooks(); + let dir = temp_dir("link"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let other = dir.join("other.txt"); + std::fs::write(&other, b"someone else's file").unwrap(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + std::os::unix::fs::symlink(&other, backup_path(&path)).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(std::fs::read(&other).unwrap(), b"someone else's file"); + assert!(std::fs::symlink_metadata(backup_path(&path)).unwrap().is_file(), "the .bak is a file now"); + assert_eq!(circles(&backup_path(&path)), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A drawing looks whole from its end: an ASCII DXF ends with 0/EOF, a binary one with EOF and +/// its NUL, and a DWG is not a few bytes long. +#[test] +fn whole_drawings_are_told_from_cut_off_ones() { + let ascii = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + assert!(looks_complete(ascii, ascii, ascii.len() as u64)); + let cut = &ascii[..30]; + assert!(!looks_complete(cut, cut, cut.len() as u64)); + let mut binary = b"AutoCAD Binary DXF\r\n\x1a\0".to_vec(); + binary.extend_from_slice(b"\0\0SECTION\0\0\0EOF\0"); + assert!(looks_complete(&binary, &binary, binary.len() as u64)); + assert!(!looks_complete(&binary, &binary[..binary.len() - 5], binary.len() as u64 - 5)); + assert!(!looks_complete(b"AC1032", b"AC1032", 6)); + assert!(!looks_complete(b"AC1032", b"", 30_000), "a DWG tag without its header is not whole"); +} + +/// A DWG reaches the end its own header gives: cut off anywhere (not only right after its start), +/// in a 2018 and a 2000 file, it doesn't replace the good backup; whole, it does. +#[test] +fn a_dwg_cut_off_anywhere_keeps_the_good_backup() { + let _saving = saving(); + hooks(); + for version in [2018, 2000] { + let dir = temp_dir(&format!("dwg{version}")); + let path = dir.join("Plan.dwg"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p, "version": version })).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "{version}: the first save is backed up"); + let whole = std::fs::read(&path).unwrap(); + let head = &whole[..whole.len().min(4096)]; + assert!(looks_complete(head, &whole[whole.len().saturating_sub(256)..], whole.len() as u64), "{version}: whole"); + for keep in [whole.len() / 2, whole.len() * 9 / 10, whole.len() - 1] { + assert!(!looks_complete(head, &[], keep as u64), "{version}: cut to {keep} of {}", whole.len()); + } + // Another program leaves it cut off at 90%: the next save keeps the good backup. + std::fs::write(&path, &whole[..whole.len() * 9 / 10]).unwrap(); + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "{version}: the cut-off file didn't replace it"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// The checks can be wrong about a real file. With no backup yet, a file that doesn't look whole +/// is still kept, because the save is about to replace the only copy of it. +#[test] +fn a_file_that_looks_damaged_is_kept_when_there_is_no_backup_yet() { + let _saving = saving(); + hooks(); + let dir = temp_dir("first"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + let odd = b"AC1032 but not as this header expects it".to_vec(); + std::fs::write(&path, &odd).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(std::fs::read(backup_path(&path)).unwrap(), odd); + let _ = std::fs::remove_dir_all(&dir); +} + +/// What the warning promises and what the save does agree. A 2007 DWG, whose completeness can't +/// be told, replaces an older backup; a file known to be cut off doesn't. +#[test] +fn the_backup_a_save_promises_is_the_one_it_writes() { + let _saving = saving(); + hooks(); + let dir = temp_dir("promise"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + let old = std::fs::read(backup_path(&path)).unwrap(); + + let mut dwg2007 = b"AC1021".to_vec(); + dwg2007.resize(4096, 7); + std::fs::write(&path, &dwg2007).unwrap(); + assert!(save_keeps_current(&path)); + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(std::fs::read(backup_path(&path)).unwrap(), dwg2007, "the promise was kept"); + + std::fs::write(backup_path(&path), &old).unwrap(); + std::fs::write(&path, b"AC1032").unwrap(); + assert!(!save_keeps_current(&path)); + add_circle(&mut s, 15.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(std::fs::read(backup_path(&path)).unwrap(), old, "cut off: nothing was promised"); + + cadcraft_engine::autosave::set_isavebak(false); + assert!(!save_keeps_current(&path), "backups off"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index d062f69e9..012231683 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -1,5 +1,5 @@ //! Autosave and Drawing Recovery in the app. The engine decides what to write and writes it -//! ([`cadcraft_engine::autosave`]); this runs it every frame, keeps SAVETIME and SAVEFILEPATH with +//! ([`cadcraft_engine::autosave`]); this runs it every frame, keeps SAVETIME, SAVEFILEPATH and ISAVEBAK with //! the preferences, and shows the Drawing Recovery list after a crash: each autosaved drawing can //! be opened (as an unsaved copy) or discarded. On the web there is no autosave yet. @@ -13,9 +13,9 @@ use crate::theme::Tokens; /// The host storage key for [`prefs_json`]. pub const PREFS_KEY: &str = "cadcraft.autosave"; -/// SAVETIME and SAVEFILEPATH as JSON for the host's storage. +/// SAVETIME, SAVEFILEPATH and ISAVEBAK as JSON for the host's storage. pub fn prefs_json() -> String { - json!({ "savetime": autosave::savetime(), "saveFilePath": autosave::savefilepath() }).to_string() + json!({ "savetime": autosave::savetime(), "saveFilePath": autosave::savefilepath(), "backup": autosave::isavebak() }).to_string() } /// Restore what [`prefs_json`] saved; malformed values are ignored. @@ -27,6 +27,9 @@ pub fn load_prefs(json: &str) { if let Some(p) = v.get("saveFilePath").and_then(Value::as_str) { autosave::set_savefilepath(p); } + if let Some(b) = v.get("backup").and_then(Value::as_bool) { + autosave::set_isavebak(b); + } } /// What the GPU error notice says (see [`gpu_notice`]). diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7330681db..8d0a39bf4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -500,7 +500,8 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou let Some(path) = s("path") else { return err("missing path") }; wrap(app.run("open", json!({"path": path}))) } - "app.save" => wrap(app.run(if s("path").is_some() { "saveas" } else { "qsave" }, p.clone())), + // Never `null` params: those mean a user's Save, which may pick a file or ask. + "app.save" => wrap(app.run(if s("path").is_some() { "saveas" } else { "qsave" }, if p.is_null() { json!({}) } else { p.clone() })), "app.quit" => { // Programmatic calls never open dialogs: quit without the unsaved-changes prompt. app.quit_confirmed = true; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2dde91eac..b24970979 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -74,6 +74,8 @@ AutoConstrain Restricciones automáticas Automatic save every Guardado automático cada Automatic save file location Ubicación de los archivos de guardado automático + Backup copy Copia de seguridad + Create backup copy with each save Crear una copia de seguridad en cada guardado Axis, End Eje, extremo B N Base Base diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3f2de02df..714ce6c4c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1283,6 +1283,8 @@ Automatic save every Автозбереження кожні min хв Automatic save file location Папка файлів автозбереження + Backup copy Резервна копія + Create backup copy with each save Створювати резервну копію під час кожного збереження Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Креслення, що ще не мають файлу, зберігаються в типовій версії. 0 хвилин вимикає автозбереження. Time-sensitive right-click: a quick click is Enter, holding opens the menu Клік правою кнопкою з урахуванням часу: швидкий клік — Enter, утримання відкриває меню Turned off, a right-click while a command runs always opens the shortcut menu. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 91902017f..0253a2cec 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -56,6 +56,7 @@ pub mod palettes; pub mod parametric; pub mod plotstyles; pub mod quick; +pub mod save_loss; pub mod shortcut_menus; pub mod theme; #[cfg(not(target_arch = "wasm32"))] @@ -290,6 +291,10 @@ pub struct CadApp { pub quit_confirmed: bool, /// A close request waiting for "Save changes?" answers ([`closing`]). pub closing: Option, + /// A save waiting for an answer about the content the file can't keep ([`save_loss`]). + pub save_loss: Option, + /// The loss the user last agreed to, so saving again doesn't ask again ([`save_loss`]). + pub save_loss_accepted: Option, /// Autosave (desktop; `None` until [`Self::enable_autosave`]). pub autosave: Option, /// Drawing Recovery: autosave files a crashed app left behind. @@ -350,6 +355,8 @@ impl CadApp { quit_requested: false, quit_confirmed: false, closing: None, + save_loss: None, + save_loss_accepted: None, autosave: None, recovery: autosave::Recovery::default(), gpu_faults: std::sync::Arc::default(), @@ -691,11 +698,11 @@ impl CadApp { ctx.request_repaint_of(egui::ViewportId::ROOT); } - /// Whether a modal dialog is up: the "Save changes?" prompt or Drawing Recovery. The + /// Whether a modal dialog is up: the "Save changes?" prompt, the lossy-save warning or Drawing Recovery. The /// floating panels' own OS windows take no input meanwhile (egui's `Modal` blocks only the /// main window). The app's other dialogs are ordinary windows that leave the drawing usable. pub fn shows_modal(&self) -> bool { - self.close_prompt().is_some() || !self.recovery.items.is_empty() + self.close_prompt().is_some() || self.save_loss.is_some() || !self.recovery.items.is_empty() } /// Input hook: refresh the raw surface snapshot, put in keys forwarded from floating panels' @@ -875,6 +882,7 @@ impl CadApp { dialogs::show(self, ui.ctx()); palettes::patterns::prompt_picker(self, ui.ctx()); closing::prompt(self, ui.ctx()); + save_loss::prompt(self, ui.ctx()); autosave::show(self, ui.ctx()); autosave::gpu_notice(self, ui.ctx()); self.frame_ms = now_ms() - t0; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 797952edf..906ccb707 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -292,6 +292,16 @@ fn should_pick_file_path(id: &str, params: &Value) -> bool { if id.starts_with("ui.") { params.is_null() || (params.get("path").is_none() && params.get("data").is_none()) } else { params.is_null() } } +/// A user's QSAVE: a drawing with no file yet picks one, and saving over a file that would lose +/// content asks first. `None` lets the engine save. JSON calls carry params and never get here; +/// they report the loss in their result instead. +fn user_qsave(app: &mut CadApp) -> Option> { + if app.session.state().is_ok_and(|s| s.path.is_none()) { + return run_ui_command(app, "ui.saveas", &Value::Null); + } + app.hold_lossy_save().then_some(Ok(Value::Null)) +} + fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option> { if let Some(result) = route_ui_subsystems(app, id, params) { return Some(result); @@ -370,7 +380,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option return run_ui_command(app, "ui.saveas", &Value::Null), + "qsave" if no_path => return user_qsave(app), // 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/options.rs b/crates/ui-egui/src/options.rs index 4c7cf6b1f..dba0814b7 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -15,6 +15,7 @@ //! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in | //! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) | //! | `SAVEFILEPATH` | the folder automatic saves go to | +//! | `ISAVEBAK` | 1: saving over a drawing keeps the previous file as `.bak` (default); 0: no backup | //! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu | //! | `systemCursor` | keep the system cursor visible over the drawing area | //! | `historyLines` | command-line history lines, 0–12 | @@ -23,7 +24,7 @@ //! | `snapMarkerSize` | object snap marker size in points, 5–31 | //! | `interfaceScale` | the interface's size, 0.5–3 (1: the system's own) | //! -//! SAVETIME and SAVEFILEPATH are the engine's autosave settings, kept with the autosave state +//! SAVETIME, SAVEFILEPATH and ISAVEBAK are the engine's save settings, kept with the autosave state //! rather than in the profile. //! //! The profile variables and the interface preferences survive restarts: the host saves @@ -147,6 +148,7 @@ struct UiChanges { save_version: Option, savetime: Option, savefilepath: Option, + isavebak: Option, time_sensitive_right_click: Option, interface_scale: Option, } @@ -203,6 +205,10 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result { let p = v.as_str().map(str::trim).filter(|p| p.chars().count() <= MAX_PATH_CHARS && !p.chars().any(char::is_control)); c.savefilepath = Some(p.ok_or("SAVEFILEPATH must be a folder path")?.to_string()); } + k if k.eq_ignore_ascii_case("ISAVEBAK") => { + let on = v.as_bool().or_else(|| v.as_i64().filter(|n| matches!(n, 0 | 1)).map(|n| n == 1)); + c.isavebak = Some(on.ok_or("ISAVEBAK must be 0 or 1")?); + } _ => return Ok(false), } Ok(true) @@ -226,6 +232,7 @@ fn ui_json(app: &CadApp) -> Map { m.insert("saveVersion".into(), json!(app.ui.save_version)); m.insert("SAVETIME".into(), json!(cadcraft_engine::autosave::savetime())); m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath())); + m.insert("ISAVEBAK".into(), json!(i32::from(cadcraft_engine::autosave::isavebak()))); m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click)); m } @@ -297,6 +304,9 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result { if let Some(p) = &ui.savefilepath { cadcraft_engine::autosave::set_savefilepath(p); } + if let Some(b) = ui.isavebak { + cadcraft_engine::autosave::set_isavebak(b); + } if let Some(b) = ui.time_sensitive_right_click { app.options.time_sensitive_right_click = b; } diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index 93f051fd9..45be0ce67 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -225,6 +225,8 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { ui.end_row(); autosave_row(ui, changes); ui.end_row(); + backup_row(ui, changes); + ui.end_row(); savefile_row(ui, changes); ui.end_row(); }); @@ -285,6 +287,15 @@ fn autosave_row(ui: &mut egui::Ui, changes: &mut Map) { } } +/// ISAVEBAK: keep the previous file as `.bak` when saving over a drawing. +fn backup_row(ui: &mut egui::Ui, changes: &mut Map) { + ui.label(crate::tl!("Backup copy")); + let mut bak = cadcraft_engine::autosave::isavebak(); + if ui.checkbox(&mut bak, crate::tl!("Create backup copy with each save")).changed() { + changes.insert("ISAVEBAK".into(), json!(i32::from(bak))); + } +} + fn savefile_row(ui: &mut egui::Ui, changes: &mut Map) { let savefile_label = ui.label(crate::tl!("Automatic save file location")); // Edited as text; the folder changes when the field loses focus. diff --git a/crates/ui-egui/src/save_loss.rs b/crates/ui-egui/src/save_loss.rs new file mode 100644 index 000000000..50e77978b --- /dev/null +++ b/crates/ui-egui/src/save_loss.rs @@ -0,0 +1,154 @@ +//! Asking before a save throws content away. DWG can't yet hold everything a drawing read from +//! another program keeps (`cadcraft_engine::cmd::file::not_saved`), and QSAVE replaces the file +//! that still has it, so File ▸ Save, its shortcut or typing QSAVE says what would go first: +//! Save Anyway / Save As... / Cancel. Agreeing lasts for that same loss on that same file, so +//! later saves in the session don't ask again; the file no longer holds what was agreed to. +//! +//! Programmatic saves (`engine.execute`, the control channel, MCP, scripts) never ask: they keep +//! reporting the loss in the result's `notSaved` and on the command line. + +use std::collections::BTreeMap; + +use egui::{RichText, vec2}; +use serde_json::{Value, json}; + +use cadcraft_engine::autosave::isavebak; +use cadcraft_engine::cmd::file::{backup_path, loss_on_save, save_keeps_current}; + +use crate::CadApp; +use crate::theme::Tokens; + +/// How many object types the warning lists; a file can name as many as it likes. +const MAX_LISTED: usize = 8; +/// How many characters of a type name the warning shows; a file chooses these too. +const MAX_NAME_CHARS: usize = 40; + +/// The user's answer to the warning. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Choice { + SaveAnyway, + SaveAs, + Cancel, +} + +/// A loss the user agreed to: this drawing, this file, these objects. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Accepted { + uid: u64, + path: String, + lost: BTreeMap, +} + +/// A save held back until the user answers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pending { + loss: Accepted, +} + +impl Pending { + /// The file the save would replace. + pub fn path(&self) -> &str { + &self.loss.path + } + + /// The objects the file wouldn't keep, as "2 MLINE, 1 3DSOLID". + pub fn summary(&self) -> String { + let lost = &self.loss.lost; + let name = |t: &str| t.chars().take(MAX_NAME_CHARS).collect::(); + let mut parts: Vec = lost.iter().take(MAX_LISTED).map(|(t, n)| format!("{n} {}", name(t))).collect(); + if lost.len() > MAX_LISTED { + parts.push(crate::tf!("{count} more types", count = lost.len() - MAX_LISTED)); + } + parts.join(", ") + } +} + +impl CadApp { + /// Hold a user's QSAVE of the active drawing if it would lose content the user hasn't agreed + /// to lose, and ask. Returns whether it was held. + pub fn hold_lossy_save(&mut self) -> bool { + let Ok(st) = self.session.state() else { return false }; + let (uid, Some(path)) = (st.uid, st.path.clone()) else { return false }; + let lost = loss_on_save(&self.session, &path); + if lost.is_empty() { + return false; + } + let loss = Accepted { uid, path, lost }; + if self.save_loss_accepted.as_ref() == Some(&loss) { + return false; + } + self.save_loss = Some(Pending { loss }); + true + } + + /// Answer the open warning (no-op without one). + pub fn answer_save_loss(&mut self, choice: Choice) { + let Some(Pending { loss }) = self.save_loss.take() else { return }; + // The drawing can go away, or stop being the active one, while the warning is up. + let i = self.session.docs.iter().position(|d| d.uid == loss.uid); + match (choice, i) { + (Choice::Cancel, _) | (_, None) => self.session.echo(crate::tf!("Save cancelled: {path} was not changed.", path = loss.path)), + (Choice::SaveAs, Some(i)) => { + self.activate_doc(i); + let _ = self.run("ui.saveas", Value::Null); + } + (Choice::SaveAnyway, Some(i)) => { + self.activate_doc(i); + self.save_loss_accepted = Some(loss); + let _ = self.run("qsave", json!({})); + } + } + } + + fn activate_doc(&mut self, i: usize) { + if self.session.active != i { + let _ = self.run("document.switch", json!({ "index": i })); + } + } +} + +/// The warning, while a save waits for an answer. Cancel is what Escape does. +pub fn prompt(app: &mut CadApp, ctx: &egui::Context) { + let Some(pending) = app.save_loss.clone() else { return }; + let t = Tokens::get(); + let path = std::path::Path::new(pending.path()); + let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| pending.path().to_string()); + let backup = backup_path(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let mut choice = None; + crate::modal::instant(ctx, egui::Id::unique("cc_save_loss_prompt")).show(ctx, |ui| { + ui.set_width(420.0); + ui.label(RichText::new(crate::tf!("Saving {name} will remove content", name = file)).strong()); + ui.add_space(4.0); + ui.label(crate::tf!("This format can't keep these objects yet: {objects}.", objects = pending.summary())); + ui.label(RichText::new(crate::tl!("DXF keeps them. Use Save As to keep them in the drawing.")).color(t.text_dim)); + let on_disk = if save_keeps_current(path) { + crate::tf!("The current file is kept as {name} until the next save.", name = backup) + } else if isavebak() { + crate::tf!("The current file doesn't look whole, so {name} stays as it is and the current file will be replaced.", name = backup) + } else { + crate::tl!("Backup copies are off, so the current file will be replaced.").to_string() + }; + ui.label(RichText::new(on_disk).color(t.text_dim)); + ui.add_space(10.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let size = vec2(96.0, 22.0); + let save_as = ui.add(egui::Button::new(RichText::new(crate::tl!("Save As...")).color(t.text)).fill(t.accent).min_size(size)); + if save_as.clicked() { + choice = Some(Choice::SaveAs); + } + if ui.add(egui::Button::new(crate::tl!("Cancel")).min_size(size)).clicked() { + choice = Some(Choice::Cancel); + } + if ui.add(egui::Button::new(crate::tl!("Save Anyway")).min_size(size)).clicked() { + choice = Some(Choice::SaveAnyway); + } + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + choice = Some(Choice::Cancel); + } + }); + if let Some(c) = choice { + app.answer_save_loss(c); + ctx.request_repaint(); + } +} diff --git a/crates/ui-egui/tests/save_loss.rs b/crates/ui-egui/tests/save_loss.rs new file mode 100644 index 000000000..cdb212f94 --- /dev/null +++ b/crates/ui-egui/tests/save_loss.rs @@ -0,0 +1,108 @@ +//! Saving over a file that would lose content asks first, and only when a user saves. These run +//! in their own test binary because the engine's I/O hooks are set once per process, and the +//! library's unit tests register a writer that always fails. + +use std::path::{Path, PathBuf}; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::{Common, Drawing, EntityKind, RawTag, Space, Unknown}; +use serde_json::json; + +use cadcraft_ui_egui::save_loss::{Choice, Pending}; +use cadcraft_ui_egui::{CadApp, Services, menus}; + +const ORIGINAL: &[u8] = b"original"; + +fn unknown(kind: &str) -> EntityKind { + EntityKind::Unknown(Unknown { dxf_type: kind.into(), tags: vec![RawTag { code: 8, value: "0".into() }] }) +} + +/// A drawing with objects of `types`, which DWG can't keep, opened from a DWG file on disk. +fn opened(tag: &str, types: &[&str]) -> (CadApp, PathBuf) { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let dir = std::env::temp_dir().join(format!("cadcraft-loss-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Plan.dwg"); + std::fs::write(&path, ORIGINAL).unwrap(); + let mut d = Drawing::new_metric(); + for t in types { + d.add(&Space::Model, Common::default(), unknown(t)).unwrap(); + } + let mut s = Session::empty(); + s.open_drawing(d, "Plan.dwg", Some(path.to_string_lossy().into_owned())); + (CadApp::new(s, Services::default()), path) +} + +fn cleanup(path: &Path) { + if let Some(d) = path.parent() { + let _ = std::fs::remove_dir_all(d); + } +} + +#[test] +fn a_users_save_asks_before_the_file_changes_and_cancel_leaves_it() { + let (mut app, path) = opened("cancel", &["3DSOLID", "MLINE", "MLINE"]); + menus::activate(&mut app, "qsave"); + let pending = app.save_loss.clone().expect("the save waits for an answer"); + assert_eq!(pending.summary(), "1 3DSOLID, 2 MLINE"); + assert!(app.shows_modal()); + assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL, "nothing is written before the answer"); + + app.answer_save_loss(Choice::Cancel); + assert!(app.save_loss.is_none()); + assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); + cleanup(&path); +} + +#[test] +fn save_anyway_saves_and_the_same_loss_is_not_asked_again() { + let (mut app, path) = opened("anyway", &["MLINE"]); + menus::activate(&mut app, "qsave"); + app.answer_save_loss(Choice::SaveAnyway); + assert!(app.save_loss.is_none()); + assert_ne!(std::fs::read(&path).unwrap(), ORIGINAL); + + menus::activate(&mut app, "qsave"); + assert!(app.save_loss.is_none(), "already agreed to"); + + // A new kind of loss is a new question. + app.session.doc_mut().unwrap().add(&Space::Model, Common::default(), unknown("3DSOLID")).unwrap(); + menus::activate(&mut app, "qsave"); + assert_eq!(app.save_loss.as_ref().map(Pending::summary).as_deref(), Some("1 3DSOLID, 1 MLINE")); + cleanup(&path); +} + +#[test] +fn a_save_that_loses_nothing_does_not_ask() { + let (mut app, path) = opened("clean", &[]); + menus::activate(&mut app, "qsave"); + assert!(app.save_loss.is_none()); + assert_ne!(std::fs::read(&path).unwrap(), ORIGINAL); + cleanup(&path); +} + +/// Programmatic calls never open dialogs: the loss is in the result instead. +#[test] +fn programmatic_saves_never_ask() { + let (mut app, path) = opened("json", &["MLINE"]); + let r = app.run("qsave", json!({})).unwrap(); + assert!(app.save_loss.is_none()); + assert_eq!(r["notSaved"], json!({ "MLINE": 1 })); + cleanup(&path); +} + +/// Type names come from the file: however many, however long, the warning stays readable. +#[test] +fn a_hostile_list_of_types_is_capped() { + let long = "X".repeat(5000); + let many: Vec = (0..50).map(|i| format!("{long}{i}")).collect(); + let refs: Vec<&str> = many.iter().map(String::as_str).collect(); + let (mut app, path) = opened("hostile", &refs); + menus::activate(&mut app, "qsave"); + let s = app.save_loss.as_ref().unwrap().summary(); + assert!(s.chars().count() < 500, "{}", s.len()); + assert!(s.ends_with("42 more types"), "{s}"); + cleanup(&path); +} diff --git a/docs/gaps.md b/docs/gaps.md index b270525d6..1d6dca7e5 100644 --- 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-28 · **Change:** minor (BEDIT in the Block Editor switches blocks: F3 updated) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (QSAVE asks before losing content and keeps a `.bak`: rows 1, FF1 and F20 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 @@ -13,7 +13,7 @@ do them first. | # | Gap | Why it ranks here | Est. | Doc | |---|---|---|---|---| -| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) | +| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors; a user's QSAVE now asks before replacing the file and keeps the previous one as `.bak`, which reduces the harm but does not close this row) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [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 | **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) | | 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place or clipped** (xrefs, #525, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) | @@ -41,7 +41,7 @@ do them first. | F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 | | F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 | | F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 | -| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 | +| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export Open and Save has the backup copy checkbox (ISAVEBAK). | `ui-egui/src/options/` | Low | 2–4 | | F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data) | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 | | F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 | | F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 | @@ -73,7 +73,7 @@ Detail: [file-format-parity.md](file-format-parity.md). | # | Gap | User impact | Est. | |---|---|---|---| -| 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 | +| 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 A user's QSAVE now asks before such a loss and keeps the previous file as `.bak`; the loss itself remains. | 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 | | 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 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | QSAVE asks before replacing a file with content the DWG writer can't keep, and ISAVEBAK keeps the previous file as `.bak`: row 1 narrowed, FF1 and F20 updated | | 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles | | 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-26 | minor | BEDIT in the Block Editor (typed, Edit Block, or the Edit Block Definition dialog) switches to another block as AutoCAD's does, asking whether to save the changes not saved yet; the JSON form takes `save`: F3 evidence | From 47ed3af26c04d24067d09d05b93924e07ed73449 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 30 Oct 2026 19:11:01 -0700 Subject: [PATCH 033/151] Parity docs are updated by maintainers in batches, not in each PR (#2520) docs/gaps.md conflicts in 153 open PRs and docs/ui-parity.md in 75, because every PR had to edit them. A PR now names its rows in the description. --- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/gaps.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 657e57dec..f805cdddd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. +`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index 964a00124..f5036db11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. +`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/docs/gaps.md b/docs/gaps.md index 1d6dca7e5..a7548c4d2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -3,8 +3,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (QSAVE asks before losing content and keeps a `.bak`: rows 1, FF1 and F20 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 -that closes it. Numbers and weights live in [target-app-parity.md](target-app-parity.md); hours +list: agents pick from here (top of a section first). A PR names the rows it closes in its +description, and maintainers update them in batches. Numbers and weights live in [target-app-parity.md](target-app-parity.md); hours are Opus 5.5 agent-hours, calibrated as explained there. "Est." means a judgement from the cited evidence. Rows marked **[alpha blocker]** are what fails the [alpha gate](roadmap.md#alpha-gate); do them first. From 0ab812c12a66a5b261abe5971a75127ca2252a12 Mon Sep 17 00:00:00 2001 From: assamaral Date: Fri, 30 Oct 2026 23:16:25 -0300 Subject: [PATCH 034/151] fix(io): write $PSTYLEMODE as read, in DXF and DWG (#2400) * test(io): `$PSTYLEMODE` round-trips through DXF and DWG (#2332) Red test: a drawing read from a DXF with `$PSTYLEMODE` 0 (named plot styles) is written back without the variable, so readers default to colour-dependent plot styles. Co-Authored-By: Claude Fable 5.1 * fix(io): write `$PSTYLEMODE` as read, in DXF and DWG (#2332) `HEADER_VARS` did not list `$PSTYLEMODE`, so a drawing read from a named-plot-style file (0) was saved without it; the DWG bridge then defaulted to colour-dependent plot styles (1) and the plot came out with thicker edges. One more entry in the list; a drawing that never had the variable still writes nothing. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: assamaral <217119886+assamaral@users.noreply.github.com> --- crates/io/src/dxf_write/header.rs | 1 + crates/io/tests/it/dwg_header_vars.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/crates/io/src/dxf_write/header.rs b/crates/io/src/dxf_write/header.rs index b628a5c3d..27a9019a5 100644 --- a/crates/io/src/dxf_write/header.rs +++ b/crates/io/src/dxf_write/header.rs @@ -41,6 +41,7 @@ const HEADER_VARS: &[(&str, i32)] = &[ ("PLINEWID", 40), ("CELWEIGHT", 370), ("LWDISPLAY", 290), + ("PSTYLEMODE", 290), ("INSUNITS", 70), ("MEASUREMENT", 70), ("PSLTSCALE", 70), diff --git a/crates/io/tests/it/dwg_header_vars.rs b/crates/io/tests/it/dwg_header_vars.rs index 1bebee75e..644fe375c 100644 --- a/crates/io/tests/it/dwg_header_vars.rs +++ b/crates/io/tests/it/dwg_header_vars.rs @@ -60,6 +60,21 @@ fn header_settings_round_trip_through_dwg() { check("DWG", &r); } +/// `$PSTYLEMODE` (0 = named plot styles, 1 = colour-dependent) is written as read, as DXF and +/// as DWG: left out, readers default to colour-dependent and the drawing plots with the wrong +/// table (#2332). +#[test] +fn plot_style_mode_round_trips() { + let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n9\n$PSTYLEMODE\n290\n0\n0\nENDSEC\n0\nEOF\n"; + let d = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap(); + assert_eq!(d.header.i64("PSTYLEMODE", -1), 0, "read"); + let as_dxf = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(as_dxf.header.i64("PSTYLEMODE", -1), 0, "DXF"); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let as_dwg = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(as_dwg.header.i64("PSTYLEMODE", -1), 0, "DWG"); +} + /// The current layer (`$CLAYER`) survives a DWG save and reopen, ASCII or not. A DWG stores a /// handle to the layer, and the writer used the null handle, so the drawing came back on "0" (#2252). #[test] From 1ecc2a130b15dc3c5c2605b35fd00c825a4de177 Mon Sep 17 00:00:00 2001 From: AI Slopapalooza Date: Fri, 30 Oct 2026 22:16:30 -0400 Subject: [PATCH 035/151] DXF: no CLASS instance counts in R2000 files (AutoCAD LT 2026 hung opening them) (#1060) * DXF: no CLASS instance counts in R2000 files (AutoCAD LT 2026 hung on them) Since #635 every CLASS record carries group 91, the instance count, at every version. The count came with the 2004 format: R2000 CLASS records are 1, 2, 3, 90, 280, 281. AutoCAD LT 2026 stops responding when it opens an R2000 DXF whose CLASS records have a 91, so every R2000 file CADCraft saved locked it up (a drawing opened from an R2000 file is saved as R2000 again). Isolated black-box in AutoCAD LT 2026 with synthetic files: the same R2000 file without the 91 groups opens, as does the same content declared AC1032. The counts are now written only when the file (or the DWG the bridge writes) is 2004 or later; R2000 output drops the group from every CLASS record, classes kept from a newer file included. Co-Authored-By: Claude Opus 5.5 * Test: binary R2000 drops group 91 from a kept foreign CLASS too Review follow-up: the kept-class check covered R2000 only through ASCII saves; it now also saves the fixture as binary R2000 and checks that the ACME_OBJECT CLASS record is 1, 2, 3, 90, 280, 281. Co-Authored-By: Claude Opus 5.5 * Test: binary 2018 keeps a kept foreign CLASS's instance count Review follow-up: the kept-class check now saves the fixture as binary at both R2000 (no group 91) and 2018 (group 91 after the proxy flags, with the class's count), through one helper. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Slopapalooza <307952167+Slopapalooza@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_write.rs | 6 ++-- crates/io/src/dxf_write/sections.rs | 21 +++++++++--- crates/io/tests/it/dxf_versions.rs | 25 ++++++++++++++ crates/io/tests/it/roundtrip_audit.rs | 47 ++++++++++++++++++++++----- docs/file-format-parity.md | 5 +-- 5 files changed, 87 insertions(+), 17 deletions(-) diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 4ec9fc636..a0ae2f95a 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1,7 +1,7 @@ //! Drawing → DXF (AC1015 / R2000 structure, readable by every DXF consumer since 2000). Later -//! versions keep that structure (the groups they add are optional, DXF Reference) and differ in -//! `$ACADVER`, from 2004 in the newer DIMSTYLE groups, and from 2007 in UTF-8 text (the caller -//! picks the text encoding) and MULTILEADER entities (older files get LEADER + MTEXT). +//! versions keep that structure and differ in `$ACADVER`, from 2004 in CLASS instance counts and +//! the newer DIMSTYLE groups, and from 2007 in UTF-8 text (the caller picks the text encoding) and +//! MULTILEADER entities (older files get LEADER + MTEXT). use std::collections::HashMap; diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index fe865515b..9a21ef7a9 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -1117,14 +1117,27 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec= Version::R2013); - add_class_instance_counts(&mut w.t); + add_class_instance_counts(&mut w.t, target); mark_xrefs(&mut w.t, d); w.t } -/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish` -/// restores foreign classes and objects so every class gets its actual instance count. -fn add_class_instance_counts(tags: &mut Vec) { +/// DXF Reference group 91 of CLASS records, the instance count, which came with the 2004 format. +/// From 2004 every CLASS record gets its actual count, counted after `dxf_raw::finish` restores +/// foreign classes and objects (AutoCAD rejects a 2004+ file without it, #635). An R2000 file has +/// none, not even on a class kept from a newer file: AutoCAD LT 2026 stops responding when an +/// R2000 file's CLASS records carry a 91. +fn add_class_instance_counts(tags: &mut Vec, target: Version) { + if target < Version::R2004 { + let mut in_class = false; + tags.retain(|t| { + if t.code == 0 { + in_class = t.str() == "CLASS"; + } + !(in_class && t.code == 91) + }); + return; + } let mut counts: HashMap = HashMap::new(); for t in tags.iter().filter(|t| t.code == 0) { let kind = t.str(); diff --git a/crates/io/tests/it/dxf_versions.rs b/crates/io/tests/it/dxf_versions.rs index 3fc2f1c73..40aaa878c 100644 --- a/crates/io/tests/it/dxf_versions.rs +++ b/crates/io/tests/it/dxf_versions.rs @@ -99,6 +99,31 @@ fn dxf_at_every_version_reads_back_the_same() { } } +/// CLASS records (DXF Reference, CLASSES): group 91, the instance count, came with the 2004 +/// format and follows the proxy flags. AutoCAD LT 2026 stops responding on an R2000 file whose +/// CLASS records carry a 91, and AutoCAD rejects a 2004+ file without it (#635). +#[test] +fn class_records_follow_the_version() { + let d = sample(); + for v in Version::ALL { + for binary in [false, true] { + let tags = cadcraft_dxf::parse(&write_with(&d, "x.dxf", &opts(v, binary)).unwrap()).unwrap(); + let classes: Vec> = cadcraft_dxf::sections(&tags) + .into_iter() + .filter(|s| s.name == "CLASSES") + .flat_map(|s| cadcraft_dxf::records(&s.tags)) + .filter(|(kind, _)| kind == "CLASS") + .map(|(_, t)| t.iter().map(|x| x.code).collect()) + .collect(); + assert!(!classes.is_empty(), "{v} binary={binary}"); + let want: &[i32] = if v >= Version::R2004 { &[1, 2, 3, 90, 91, 280, 281] } else { &[1, 2, 3, 90, 280, 281] }; + for codes in classes { + assert_eq!(codes, want, "{v} binary={binary}"); + } + } + } +} + /// R2000 ASCII is exactly the R2000 DXF CADCraft always wrote. #[test] fn r2000_ascii_is_unchanged() { diff --git a/crates/io/tests/it/roundtrip_audit.rs b/crates/io/tests/it/roundtrip_audit.rs index 10f910ae8..0b15f3181 100644 --- a/crates/io/tests/it/roundtrip_audit.rs +++ b/crates/io/tests/it/roundtrip_audit.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use cadcraft_doc::{Drawing, EntityKind}; use cadcraft_dxf::{Tag, records, sections}; -use cadcraft_io::{read_dxf, write_dxf}; +use cadcraft_io::{SaveOptions, Version, read_dxf, write_dxf, write_with}; /// DXF text from lines of `code value` (the value may be empty). fn dxf(src: &str) -> String { @@ -878,19 +878,50 @@ fn saving_twice_writes_the_same_records() { assert_eq!(a.counts(), b.counts()); } +/// CLASS group 91, the instance count, came with the 2004 format (DXF Reference, CLASSES). A +/// 2004+ save counts the records of every class, generated and kept; an R2000 save has none, +/// even for a kept class whose record had one (AutoCAD LT 2026 stops responding on an R2000 file +/// whose CLASS records carry a 91). #[test] -fn class_records_have_instance_counts_for_generated_and_preserved_records() { - let (_, a, b) = saves(&fixture()); +fn class_records_have_instance_counts_from_2004_only() { + // The fixture's ACME_OBJECT class as a 2004+ writer has it, with its count. + let src = fixture().replacen("ACME_OBJECT\n2\nAcmeObject\n3\nAcmeApp\n90\n0\n", "ACME_OBJECT\n2\nAcmeObject\n3\nAcmeApp\n90\n0\n91\n1\n", 1); + assert_ne!(src, fixture()); + let (d, a, b) = saves(&src); for out in [&a, &b] { - let counts = out.counts(); for class in out.in_section("CLASSES", "CLASS") { - let name = s(class, 1); - let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64); - assert_eq!(instance_count, counts.get(&name).map(|n| *n as i64), "CLASS {name}"); + let codes: Vec = class.iter().map(|t| t.code).collect(); + assert_eq!(codes, [1, 2, 3, 90, 280, 281], "R2000 CLASS {}", s(class, 1)); } assert!(out.in_section("CLASSES", "CLASS").iter().any(|c| s(c, 1) == "ACME_OBJECT")); - assert_eq!(counts.get("ACME_OBJECT"), Some(&1)); } + // Binary files the same way: none at R2000, the count at 2018. + let acme = |version: Version| { + let bytes = write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: true }).expect("save binary"); + let tags = cadcraft_dxf::parse(&bytes).expect("parse binary"); + let class = sections(&tags) + .into_iter() + .filter(|sec| sec.name == "CLASSES") + .flat_map(|sec| records(&sec.tags)) + .find(|(kind, class)| kind == "CLASS" && s(class, 1) == "ACME_OBJECT") + .map(|(_, class)| class) + .expect("ACME_OBJECT CLASS"); + let codes: Vec = class.iter().map(|t| t.code).collect(); + (codes, class.iter().find(|t| t.code == 91).map(Tag::i64)) + }; + assert_eq!(acme(Version::R2000), (vec![1, 2, 3, 90, 280, 281], None), "binary R2000 ACME_OBJECT CLASS"); + assert_eq!(acme(Version::R2018), (vec![1, 2, 3, 90, 91, 280, 281], Some(1)), "binary 2018 ACME_OBJECT CLASS"); + let r2018 = write_with(&d, "x.dxf", &SaveOptions { version: Some(Version::R2018), binary: false }).expect("save 2018"); + let out = Out::new(std::str::from_utf8(&r2018).expect("UTF-8")); + let counts = out.counts(); + for class in out.in_section("CLASSES", "CLASS") { + let name = s(class, 1); + let codes: Vec = class.iter().map(|t| t.code).collect(); + assert_eq!(codes, [1, 2, 3, 90, 91, 280, 281], "2018 CLASS {name}"); + let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64); + assert_eq!(instance_count, Some(counts.get(&name).map_or(0, |n| *n as i64)), "CLASS {name}"); + } + assert_eq!(counts.get("ACME_OBJECT"), Some(&1)); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d2af85803..1bc1529c1 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-28 · **Change:** minor (attribute value edits refresh exported baselines) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (R2000 DXF CLASS records without instance counts, so AutoCAD LT 2026 opens them) · **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 @@ -64,7 +64,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG |---|---|---|---| | 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 | 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, 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 with that `$ACADVER`; UTF-8 text from 2007; CLASS instance counts (91) from 2004 only (#635; with them, AutoCAD LT 2026 stops responding on an R2000 file) | | 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 | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | writes `\U+` escapes (#169), which is correct; declares `ANSI_1252` | | `\U+`/`\M+` escapes | yes | yes (`\M+` since #505; Johab `\M+4` stays literal) | `\U+` yes | @@ -140,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | R2000 DXF: CLASS records written without group 91 (instance count), which came with the 2004 format; AutoCAD LT 2026 stopped responding on every R2000 DXF CADCraft saved (checked black-box in AutoCAD LT 2026) | | 2026-10-26 | minor | CC-046: ATTEDIT and -ATTEDIT value changes invalidate derived justified baselines before DXF/DWG export; preserve alignment anchors, unchanged values and Aligned/Fit endpoints. Review follow-up: baseline recomputation preserves insertion elevation independently of alignment elevation. The shared writer correction also applies to justified TEXT. Independent of attribute editor UI; reverting the follow-up restores elevation loss on recomputation, while reverting the value setter restores stale XY baselines. | | 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) | From 2701983688995047bbfa21a9f2db540043f9310e Mon Sep 17 00:00:00 2001 From: ATARYS-LAB Date: Sat, 31 Oct 2026 03:16:34 +0100 Subject: [PATCH 036/151] Ctrl/Cmd+W closes the drawing, as the File menu shows (#2057) * Ctrl/Cmd+W closes the drawing, as the File menu shows File > Close shows Cmd+W (Ctrl+W on Windows and Linux), but the keyboard table had no entry for it, so the key did nothing outside the macOS native menu. The table moves into builtin_shortcuts() (the shape #1703 also uses), and a test checks that every shortcut a command shows runs that command. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG * Clippy: assign operators in the shortcut test's key parser Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG --------- Co-authored-by: ATARYS-LAB <342661122+ATARYS-LAB@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 60 +++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 906ccb707..d7c1c9078 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -790,18 +790,20 @@ pub fn run_transparent(app: &mut CadApp, id: &str) { } } -/// Keyboard shortcuts and function keys. -pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { +/// CADCraft's keyboard shortcuts and function keys, each with the command it runs. Every shortcut a +/// command shows (its registry `key`, or a UI command's) must be here, or pressing it does nothing. +pub(crate) fn builtin_shortcuts() -> Vec<(egui::KeyboardShortcut, &'static str)> { use egui::{Key, KeyboardShortcut, Modifiers}; let sc = |m: Modifiers, k: Key| KeyboardShortcut::new(m, k); let cmd = Modifiers::COMMAND; let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; - let pairs: &[(KeyboardShortcut, &str)] = &[ + vec![ (sc(cmd_shift, Key::A), "pickstyle"), (sc(cmd_shift, Key::Z), "redo"), (sc(cmd, Key::Z), "u"), (sc(cmd, Key::Y), "redo"), (sc(cmd, Key::N), "new"), + (sc(cmd, Key::W), "close"), (sc(cmd, Key::O), "ui.open"), (sc(cmd_shift, Key::S), "ui.saveas"), (sc(cmd, Key::S), "qsave"), @@ -826,7 +828,15 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { (sc(Modifiers::NONE, Key::F10), "polar"), (sc(Modifiers::NONE, Key::F11), "otrack"), (sc(Modifiers::NONE, Key::F12), "dynmode"), - ]; + ] +} + +/// Keyboard shortcuts and function keys. +pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { + use egui::{Key, KeyboardShortcut, Modifiers}; + let sc = |m: Modifiers, k: Key| KeyboardShortcut::new(m, k); + let cmd = Modifiers::COMMAND; + let pairs = builtin_shortcuts(); crate::clipboard::mirror(app, ctx); if app.closing.is_some() { return; @@ -841,7 +851,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { // Cmd/Ctrl+C, X and V arrive as clipboard events, not key presses. let mut fire = crate::clipboard::shortcut_commands(app, ctx); ctx.input_mut(|i| { - for (s, id) in pairs { + for (s, id) in &pairs { if i.consume_shortcut(s) && !fire.contains(id) { fire.push(*id); } @@ -895,6 +905,46 @@ mod tests { (CadApp::new(Session::new(), services), asked) } + /// A registry shortcut ("Cmd+Shift+Z", "F8") as egui reads it. + fn parse_shortcut(s: &str) -> Option { + let mut m = egui::Modifiers::NONE; + let (mods, key) = s.rsplit_once('+').unwrap_or(("", s)); + for part in mods.split('+').filter(|p| !p.is_empty()) { + match part { + "Cmd" => m |= egui::Modifiers::COMMAND, + "Shift" => m |= egui::Modifiers::SHIFT, + "Alt" => m |= egui::Modifiers::ALT, + _ => return None, + } + } + Some(egui::KeyboardShortcut::new(m, egui::Key::from_name(key)?)) + } + + #[test] + fn every_shown_shortcut_runs_its_command() { + // Handled outside the table: Delete by the command line (it erases the selection only when + // the line is empty), Cmd+8 before it (QuickCalc's own field keeps focus), Cmd+Q by the + // macOS app menu, the only place that shows it. + let elsewhere = [("Delete", "erase.selection"), ("Cmd+8", "ui.toggle.quickcalc"), ("Cmd+Q", "ui.quit")]; + let table = super::builtin_shortcuts(); + let shown = cadcraft_engine::command_specs() + .iter() + .filter_map(|c| Some((c.shortcut?, c.id))) + .chain(super::UI_COMMANDS.iter().filter_map(|(id, _, _, sc)| Some(((*sc)?, *id)))); + let mut missing = Vec::new(); + for (key, id) in shown { + if elsewhere.contains(&(key, id)) { + continue; + } + let sc = parse_shortcut(key).unwrap_or_else(|| panic!("{id}: unreadable shortcut {key}")); + // The table may run a command's UI form (`ui.open` for `open`), which does the same. + if !table.iter().any(|(s, i)| *s == sc && (*i == id || i.strip_prefix("ui.") == Some(id))) { + missing.push(format!("{key} {id}")); + } + } + assert!(missing.is_empty(), "shown but not run: {missing:?}"); + } + #[test] fn plot_and_print_open_the_plot_dialog_and_exportpdf_asks_for_a_file() { set_io(IoHooks { read: |_, _| Err("no reader in tests".into()), write: |_, _| Err("no writer in tests".into()), plot: Some(fake_plot) }); From dc6a0e9ec3c11a904bbe035ea006c1cb61f9f981 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:16:38 +0300 Subject: [PATCH 037/151] engine: renaming a layer repoints every viewport's frozen list and colour overrides (#1264) --- crates/engine/src/cmd/layer.rs | 1 + crates/engine/src/cmd/layer/merge.rs | 33 ++++++++++++++++++++ crates/engine/tests/it/layer_merge_delete.rs | 13 ++++++++ 3 files changed, 47 insertions(+) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 582a93635..806641ef6 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -250,6 +250,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result { repoint(&mut std::sync::Arc::make_mut(block).entities, &old, &nn); } } + merge::rename_in_viewports(d, &old, &nn); if cur.eq_ignore_ascii_case(&old) { d.header.set_str("CLAYER", &nn); } diff --git a/crates/engine/src/cmd/layer/merge.rs b/crates/engine/src/cmd/layer/merge.rs index b2ac19e1f..de8c31800 100644 --- a/crates/engine/src/cmd/layer/merge.rs +++ b/crates/engine/src/cmd/layer/merge.rs @@ -115,6 +115,39 @@ pub(super) fn forget_in_viewports(d: &mut Drawing, set: &HashSet) { } } +/// Point every layout viewport's frozen list and colour overrides at a renamed layer. +pub(super) fn rename_in_viewports(d: &mut Drawing, old: &str, new: &str) { + for layout in &mut d.layouts { + let hs: Vec = layout + .entities + .iter() + .filter(|e| match &e.kind { + EntityKind::Viewport(v) => { + v.frozen_layers.iter().any(|l| l.eq_ignore_ascii_case(old)) || v.layer_colors.iter().any(|(l, _)| l.eq_ignore_ascii_case(old)) + } + _ => false, + }) + .map(|e| e.handle) + .collect(); + for h in hs { + layout.entities.modify(h, |e| { + if let EntityKind::Viewport(v) = &mut e.kind { + for l in &mut v.frozen_layers { + if l.eq_ignore_ascii_case(old) { + *l = new.to_string(); + } + } + for (l, _) in &mut v.layer_colors { + if l.eq_ignore_ascii_case(old) { + *l = new.to_string(); + } + } + } + }); + } + } +} + fn lower_set(names: &[String]) -> HashSet { names.iter().map(|n| n.to_ascii_lowercase()).collect() } diff --git a/crates/engine/tests/it/layer_merge_delete.rs b/crates/engine/tests/it/layer_merge_delete.rs index 4778a4c57..bcbc2ed0f 100644 --- a/crates/engine/tests/it/layer_merge_delete.rs +++ b/crates/engine/tests/it/layer_merge_delete.rs @@ -159,3 +159,16 @@ fn layer_delete_clears_viewport_overrides_for_the_layer() { s.execute("layer.new", &json!({ "name": "C" })).unwrap(); assert!(viewport_lists(&s).iter().all(|(f, _)| !f.contains(&"C".to_string()))); } + +#[test] +fn renaming_a_layer_repoints_viewport_lists() { + let mut s = drawing(); + assert_eq!(viewport_lists(&s), vec![(vec!["A".to_string(), "C".to_string()], vec!["B".to_string()])]); + s.execute("rename", &json!({ "table": "layer", "from": "A", "to": "Murs" })).unwrap(); + assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])]); + // layer.set's newName form (what the Properties palette sends) goes through the same path. + s.execute("layer.set", &json!({ "name": "B", "newName": "Cloisons" })).unwrap(); + assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["Cloisons".to_string()])]); + s.undo().unwrap(); + assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])], "undo brings the old name back"); +} From 9b7149676bbda71a158d68f468baf5225b310ef7 Mon Sep 17 00:00:00 2001 From: assamaral Date: Fri, 30 Oct 2026 23:16:42 -0300 Subject: [PATCH 038/151] fix(io): MTEXT legacy column groups no longer leak into the entity (#2247) (#2416) * test(io): MTEXT legacy column groups stay in the AcDbMText subclass (#2247) `legacy_column_groups_stay_in_the_mtext_subclass` (red: the column width 48 is read as the entity's ltscale, 12.5; the height count 50 as a column height). Co-Authored-By: Claude Sonnet 5.5 * fix(io): MTEXT legacy column groups no longer leak into the entity (#2247) `common()` read the linetype scale (48) and the other AcDbEntity groups from the whole record, so the column width 48 of an MTEXT's legacy column groups became its ltscale. They are now read from the AcDbEntity subclass only. `read_columns` took the height count (the first 50) for a column height and `column_tags` did not write it; both follow the DXF layout now. Co-Authored-By: Claude Sonnet 5.5 * test(engine): the MTEXT entmake column heights carry their count (#2247) Group 50 after the column type is the height count, then the heights. Thanks to austin62jake for the report and the fix. Co-Authored-By: Claude Sonnet 5.5 * test(io): a marker-less MTEXT with an embedded object keeps its own ltscale (#2247) Case from #2276 (Zaxaerith): without subclass markers, the 48 of an embedded object (101) was read as Common.ltscale. Co-Authored-By: Claude Sonnet 5.5 * fix(io): stop the entity's own groups at an embedded object (#2247) Without subclass markers, an embedded object's 48 was read as Common.ltscale. Suggested by austin62jake; case and test from #2276 (Zaxaerith). Co-Authored-By: Claude Sonnet 5.5 * style(io): rustfmt the new MTEXT test Co-Authored-By: Claude Sonnet 5.5 * fix(io): keep layer and colour that follow a subclass marker in a list with no AcDbEntity marker entity_groups ended the common region at the first subclass marker other than AcDbEntity, so an entmake list such as (0 . "LINE") (100 . "AcDbLine") (8 . "Walls") was read on layer 0. The region now ends at a marker only when the record has an AcDbEntity marker (files from R13 on always do); other records are read as a whole up to an embedded object (101), as before the column change. Co-Authored-By: Claude Sonnet 5.5 * fix(io): markers inside an application-defined group do not end the entity's groups A `102 {...102 }` group before AcDbEntity may hold any code, 100 and 101 included: `entity_groups` stopped there and the layer and colour that followed fell back to defaults. The scan now skips the contents of such groups when looking for the AcDbEntity marker and for the end of its groups. Review of #2416. Co-Authored-By: Claude Fable 5.1 * fix(io): keep every column height of files written before the height count CADCraft up to f536bf4 wrote group 76 = n and n group-50 heights, no count. Reading those with the counted format dropped the first height and rewrote 76 as n - 1. The first 50 is a count only when there are 76 + 1 of them. Co-Authored-By: Claude Fable 5.1 * fix(io): MTEXT column heights skip application-defined groups; no count for an empty list A `102 {...}` group inside the MTEXT record may hold a 50 of its own: it counted as a height and made a legacy record (76 = n, n heights, no count) look counted, dropping the first height. `read_columns` now filters the record with `outside_app` (shared with `entity_groups`). `column_tags` wrote a lone `50 = 0` for a dynamic manual column with no heights, which read back as one height of 0; the count is omitted for an empty list, so `heights: []` round trips. ZWCAD 2025 also read that lone 50 as the rotation and zeroed it. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/engine/src/lisp/tests.rs | 2 +- crates/io/src/dxf_read/common.rs | 39 ++++- crates/io/src/dxf_read/mtext_dxf.rs | 17 +- crates/io/tests/it/mtext_columns_roundtrip.rs | 154 +++++++++++++++++- 4 files changed, 199 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index 31e78bd15..0cb31f395 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -405,7 +405,7 @@ fn entity_lists_hold_angles_in_radians() { // MTEXT: 50 before the column type (75) is the rotation, after it a column height. ok( &mut s, - "(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 30.0) '(50 . 20.0)))", + "(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 2.0) '(50 . 30.0) '(50 . 20.0)))", ); let mtext = s.doc().unwrap().model.iter().find_map(|e| match &e.kind { cadcraft_doc::EntityKind::MText(t) => Some((t.rotation, t.columns.as_ref().map(|c| c.heights.clone()))), diff --git a/crates/io/src/dxf_read/common.rs b/crates/io/src/dxf_read/common.rs index 9cba18171..e46f3c09d 100644 --- a/crates/io/src/dxf_read/common.rs +++ b/crates/io/src/dxf_read/common.rs @@ -43,28 +43,53 @@ fn transparency_440(v: i64) -> Transparency { } } +/// The tags outside application-defined groups (`102 {` to `102 }`, whose codes are the +/// application's), with their positions in `tags`. +pub(super) fn outside_app(tags: &[Tag]) -> impl Iterator { + let mut in_app = false; + tags.iter().enumerate().filter(move |(_, t)| { + if t.code == 102 { + in_app = t.str().starts_with('{'); + } + !in_app && t.code != 102 + }) +} + +/// The groups of the AcDbEntity subclass: the record up to the entity's own subclass marker (all +/// of it in files without markers, and in lists with no AcDbEntity marker, whose layer and colour +/// may follow the entity's marker), or up to an embedded object (101). Later groups reuse the +/// codes: an MTEXT's column width is 48. Markers inside an application-defined group (`102 {` +/// to `102 }`, whose codes are the application's) do not count. +fn entity_groups(tags: &[Tag]) -> &[Tag] { + let outside_app = || outside_app(tags); + let marked = outside_app().any(|(_, t)| t.code == 100 && t.str() == "AcDbEntity"); + let end = outside_app().find(|(_, t)| (marked && t.code == 100 && t.str() != "AcDbEntity") || t.code == 101).map_or(tags.len(), |(i, _)| i); + tags.get(..end).unwrap_or(tags) +} + pub(super) fn common(t: &T) -> Common { - let mut c = Common { layer: t.s(8).unwrap_or_else(|| "0".into()), linetype: t.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() }; - if let Some(v) = t.i(62) { + let e = T(entity_groups(t.0)); + let mut c = Common { layer: e.s(8).unwrap_or_else(|| "0".into()), linetype: e.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() }; + if let Some(v) = e.i(62) { c.color = Color::from_aci(v as i16); if v < 0 { c.color = Color::ByLayer; c.visible = true; } } - if let Some(tc) = t.i(420) { + if let Some(tc) = e.i(420) { c.color = Color::True(Rgb::from_u32(tc as u32)); } - if let Some(lw) = t.i(370) { + if let Some(lw) = e.i(370) { c.lineweight = Lineweight::from_dxf(lw as i16); } - if let Some(s) = t.f(48) { + if let Some(s) = e.f(48) { c.ltscale = if s > 0.0 { s } else { 1.0 }; } - if t.i(60) == Some(1) { + if e.i(60) == Some(1) { c.visible = false; } - if let Some(v) = t.i(440) { + if let Some(v) = e.i(440) { c.transparency = transparency_440(v); } c.thickness = t.fd(39, 0.0); diff --git a/crates/io/src/dxf_read/mtext_dxf.rs b/crates/io/src/dxf_read/mtext_dxf.rs index f955603e6..9ab133e44 100644 --- a/crates/io/src/dxf_read/mtext_dxf.rs +++ b/crates/io/src/dxf_read/mtext_dxf.rs @@ -74,16 +74,21 @@ fn finite(v: f64) -> f64 { /// The columns of an MTEXT, and the column width (group 48) when there are columns. pub(crate) fn read_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { - let tags = own(tags); + // An application-defined group in the record may hold any code, 50 included. + let tags: Vec<&Tag> = super::common::outside_app(own(tags)).map(|(_, t)| t).collect(); let start = tags.iter().position(|t| t.code == 75)?; - let kind = match tags.get(start).map(Tag::i64) { + let kind = match tags.get(start).map(|t| t.i64()) { Some(1) => MTextColumnType::Static, Some(2) => MTextColumnType::Dynamic, _ => return None, }; let cols = tags.get(start..).unwrap_or_default(); - let first = |code: i32| cols.iter().find(|t| t.code == code); - let heights: Vec = cols.iter().filter(|t| t.code == 50).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect(); + let first = |code: i32| cols.iter().find(|t| t.code == code).copied(); + // The first 50 is the number of heights that follow, unless the file predates the count + // (CADCraft up to f536bf4 wrote the n heights of group 76 alone). + let fifties = cols.iter().filter(|t| t.code == 50); + let counted = first(76).is_some_and(|t| fifties.clone().count() as i64 - 1 == t.i64()); + let heights: Vec = fifties.skip(usize::from(counted)).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect(); let count = first(76).map(Tag::i64).unwrap_or(heights.len() as i64).clamp(1, MAX_COLUMNS as i64); let auto_height = first(79).map(Tag::i64).unwrap_or(0) != 0; let defined = tags.iter().find(|t| t.code == 46).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0); @@ -115,6 +120,10 @@ pub(crate) fn column_tags(t: &MText) -> Vec { Tag::f(48, t.width), Tag::f(49, c.gutter), ]; + // No count for an empty list: a lone 50 = 0 would read back as one height of 0. + if !heights.is_empty() { + v.push(Tag::i(50, heights.len() as i64)); + } v.extend(heights.into_iter().map(|h| Tag::f(50, h))); v } diff --git a/crates/io/tests/it/mtext_columns_roundtrip.rs b/crates/io/tests/it/mtext_columns_roundtrip.rs index 7db9ed685..4273afa12 100644 --- a/crates/io/tests/it/mtext_columns_roundtrip.rs +++ b/crates/io/tests/it/mtext_columns_roundtrip.rs @@ -2,8 +2,9 @@ //! 75/76/78/79/48/49/50, tabs as `^I`, carets as `^ `. use cadcraft_doc::{Common, Drawing, EntityKind, MText, MTextColumnType, MTextColumns, Space}; +use cadcraft_dxf::Tag; use cadcraft_geom::Vec3; -use cadcraft_io::{read_dxf, write_dxf}; +use cadcraft_io::{entity_from_groups, read_dxf, write_dxf}; fn mtext(contents: &str, rotation: f64, columns: Option) -> MText { MText { @@ -76,6 +77,115 @@ fn paragraph_codes_tabs_and_carets_round_trip() { assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::MText(m) if m.contents == contents))); } +#[test] +fn legacy_column_groups_stay_in_the_mtext_subclass() { + // Columns as other programs write them (#2247): the column width (48) and the height count + // (the first 50) are groups of the AcDbMText subclass, not the entity's linetype scale and a + // column height. + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::MText(mtext("x", 0.0, None))).unwrap(); + let legacy = + " 75\r\n1\r\n 76\r\n3\r\n 78\r\n0\r\n 79\r\n0\r\n 48\r\n12.5\r\n 49\r\n2.5\r\n 50\r\n3\r\n 50\r\n20.0\r\n 50\r\n20.0\r\n 50\r\n20.0\r\n"; + let dxf = write_dxf(&d).replacen(" 44\r\n1.0\r\n", &format!(" 44\r\n1.0\r\n{legacy}"), 1); + assert!(dxf.contains(" 75\r\n"), "legacy groups inserted"); + let back = read_dxf(dxf.as_bytes()).unwrap(); + let Some(e) = back.model.iter().find(|e| matches!(e.kind, EntityKind::MText(_))) else { panic!("no mtext") }; + assert_eq!(e.common.ltscale, 1.0, "48 is the column width, not the linetype scale"); + let EntityKind::MText(t) = &e.kind else { panic!("no mtext") }; + let c = MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 2.5, height: 20.0, ..Default::default() }; + assert_eq!(t.columns, Some(c)); + // Written back as read: the height count before the heights. + let out = write_dxf(&back); + assert!(out.contains(" 50\r\n3\r\n 50\r\n20.0\r\n"), "height count before the heights"); +} + +#[test] +fn heights_written_without_a_count_are_all_kept() { + // Files written before the height count (CADCraft up to f536bf4) have group 76 = n and n + // heights, no count: the first 50 is a height there (#2247 review). + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::MText(mtext("x", 0.0, None))).unwrap(); + let dxf = write_dxf(&d); + for (heights, old) in [ + (vec![7.0], " 75\r\n2\r\n 76\r\n1\r\n 78\r\n0\r\n 79\r\n0\r\n 48\r\n12.5\r\n 49\r\n2.5\r\n 50\r\n7.0\r\n"), + ( + vec![5.0, 3.0, 4.0], + " 75\r\n2\r\n 76\r\n3\r\n 78\r\n0\r\n 79\r\n0\r\n 48\r\n12.5\r\n 49\r\n2.5\r\n 50\r\n5.0\r\n 50\r\n3.0\r\n 50\r\n4.0\r\n", + ), + ] { + let back = read_dxf(dxf.replacen(" 44\r\n1.0\r\n", &format!(" 44\r\n1.0\r\n{old}"), 1).as_bytes()).unwrap(); + let Some(EntityKind::MText(t)) = back.model.iter().map(|e| &e.kind).find(|k| matches!(k, EntityKind::MText(_))) else { panic!("no mtext") }; + let n = heights.len(); + let c = MTextColumns { + kind: MTextColumnType::Dynamic, + count: n as u16, + gutter: 2.5, + height: heights.iter().copied().fold(0.0, f64::max), + auto_height: false, + heights: heights.clone(), + flow_reversed: false, + }; + assert_eq!(t.columns, Some(c), "{n} heights without a count"); + // Written back with the count, and still n columns. + let out = write_dxf(&back); + assert!(out.contains(&format!(" 76\r\n{n}\r\n 78\r\n")), "76 stays {n}: {out}"); + assert!(out.contains(&format!(" 50\r\n{n}\r\n 50\r\n{:.1}\r\n", heights[0])), "count then the first height: {out}"); + } +} + +#[test] +fn heights_inside_an_application_group_are_not_column_heights() { + // An application-defined group (102 {...}) in the record may hold a 50 of its own: it is + // neither a height nor the count that tells a legacy record apart (#2247 review). + let tags = [ + Tag::s(8, "0"), + Tag::f(40, 0.5), + Tag::f(41, 12.5), + Tag::s(1, "x"), + Tag::i(75, 2), + Tag::i(76, 3), + Tag::i(78, 0), + Tag::i(79, 0), + Tag::f(48, 12.5), + Tag::f(49, 2.5), + Tag::f(50, 5.0), + Tag::f(50, 3.0), + Tag::f(50, 4.0), + Tag::s(102, "{ACME_APP"), + Tag::f(50, 8.0), + Tag::s(102, "}"), + ]; + let Some((_, EntityKind::MText(t))) = entity_from_groups("MTEXT", &tags) else { panic!("MTEXT was not read") }; + let c = MTextColumns { + kind: MTextColumnType::Dynamic, + count: 3, + gutter: 2.5, + height: 5.0, + auto_height: false, + heights: vec![5.0, 3.0, 4.0], + flow_reversed: false, + }; + assert_eq!(t.columns, Some(c)); +} + +#[test] +fn empty_manual_heights_round_trip() { + // A dynamic manual column with no heights stays without heights: no count is written for an + // empty list (a lone 50 = 0 would read back as one height of 0) (#2247 review). + let c = MTextColumns { + kind: MTextColumnType::Dynamic, + count: 1, + gutter: 0.5, + height: 0.0, + auto_height: false, + heights: Vec::new(), + flow_reversed: false, + }; + let (t, dxf) = round_trip(mtext("x", 0.0, Some(c.clone()))); + assert_eq!(t.columns, Some(c)); + assert!(!dxf.contains(" 50\r\n0\r\n"), "no height count for an empty list: {dxf}"); +} + #[test] fn hostile_column_groups_do_not_panic() { let mut d = Drawing::new_metric(); @@ -91,3 +201,45 @@ fn hostile_column_groups_do_not_panic() { let _ = read_dxf(bad.as_bytes()); } } + +#[test] +fn embedded_groups_do_not_supply_mtext_common_ltscale() { + // Without subclass markers, an embedded object (101) reuses 48 for its own data (#2247, test from #2276). + let tags = [Tag::s(8, "0"), Tag::f(40, 0.5), Tag::f(41, 12.5), Tag::s(1, "own text"), Tag::s(101, "Embedded Object"), Tag::f(48, 99.0)]; + let Some((common, _)) = entity_from_groups("MTEXT", &tags) else { panic!("MTEXT was not read") }; + assert_eq!(common.ltscale, 1.0, "48 after 101 belongs to the embedded object"); +} + +#[test] +fn layer_after_an_entmake_subclass_marker_is_kept() { + // An entity list built by hand may put a subclass marker before the layer and have no AcDbEntity + // marker at all: the whole list is then the entity's (#2247 review). + let tags = [Tag::s(100, "AcDbLine"), Tag::s(8, "Walls"), Tag::i(62, 3), Tag::f(10, 0.0), Tag::f(20, 0.0), Tag::f(11, 1.0), Tag::f(21, 0.0)]; + let Some((common, _)) = entity_from_groups("LINE", &tags) else { panic!("LINE was not read") }; + assert_eq!(common.layer, "Walls"); + assert_eq!(common.color, cadcraft_color::Color::from_aci(3)); +} + +#[test] +fn markers_inside_an_application_group_do_not_end_the_entity_groups() { + // An application-defined group (102 {...} before AcDbEntity) may hold any code, 100 and 101 + // included: the entity's layer and colour come after it (#2247 review). + let tags = [ + Tag::s(5, "A1"), + Tag::s(102, "{ACME_APP"), + Tag::s(100, "AcmeMarker"), + Tag::s(101, "AcmeObject"), + Tag::s(102, "}"), + Tag::s(100, "AcDbEntity"), + Tag::s(8, "Walls"), + Tag::i(62, 3), + Tag::s(100, "AcDbLine"), + Tag::f(10, 0.0), + Tag::f(20, 0.0), + Tag::f(11, 1.0), + Tag::f(21, 0.0), + ]; + let Some((common, _)) = entity_from_groups("LINE", &tags) else { panic!("LINE was not read") }; + assert_eq!(common.layer, "Walls"); + assert_eq!(common.color, cadcraft_color::Color::from_aci(3)); +} From 85fc3991b1d313f50f73ac204ac07fb7861ffa21 Mon Sep 17 00:00:00 2001 From: assamaral Date: Fri, 30 Oct 2026 23:16:47 -0300 Subject: [PATCH 039/151] fix(io): an attached ATTRIB keeps its Constant mode through DXF and DWG (#2246) (#2418) * test(io): the Constant mode of an attached ATTRIB survives DXF and DWG (#2246) Co-Authored-By: Claude Sonnet 5.5 * fix(io): an attached ATTRIB keeps its Constant mode through DXF and DWG (#2246) Co-Authored-By: Claude Sonnet 5.5 * test(io): attached ATTRIB Constant mode over every save version and encoding The regression covered the default DXF and DWG saves only. It now also saves with each Version::ALL entry as ASCII DXF, binary DXF and DWG. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/io/src/dxf_read/entities.rs | 2 +- crates/io/tests/it/attrib_modes.rs | 32 ++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index aba1bdc4e..fc55fe335 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -539,7 +539,7 @@ pub(super) fn insert_attribs(recs: &[(String, Vec)], i: &mut usize, ins_lay tag: own.s(2).unwrap_or_default(), text, invisible: own.i(70).unwrap_or(0) & 1 != 0, - constant: false, + constant: own.i(70).unwrap_or(0) & 2 != 0, prompt: String::new(), props: attrib_props(&tt, ins_layer), multiline, diff --git a/crates/io/tests/it/attrib_modes.rs b/crates/io/tests/it/attrib_modes.rs index 625ead939..3dfa8e321 100644 --- a/crates/io/tests/it/attrib_modes.rs +++ b/crates/io/tests/it/attrib_modes.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use cadcraft_doc::*; use cadcraft_geom::Vec3; -use cadcraft_io::{read_dxf, write_dxf}; +use cadcraft_io::{SaveOptions, Version, read_dxf, write_dxf, write_with}; fn attrib(tag: &str, modes: AttribModes) -> Attrib { let text = Text { @@ -28,6 +28,10 @@ const LOCKED: AttribModes = AttribModes { verify: false, preset: false, lock_pos /// Block TAG with definitions A (every mode) and B (none), and a reference whose A is locked. fn drawing() -> Drawing { + drawing_c(false) +} + +fn drawing_c(constant: bool) -> Drawing { let mut d = Drawing::new_metric(); let mut b = Block::new("TAG"); for a in [attrib("A", ALL), attrib("B", AttribModes::default())] { @@ -40,7 +44,7 @@ fn drawing() -> Drawing { insert: Vec3::ZERO, scale: Vec3::new(1.0, 1.0, 1.0), rotation: 0.0, - attribs: vec![attrib("A", LOCKED), attrib("B", AttribModes::default())], + attribs: vec![Attrib { constant, ..attrib("A", LOCKED) }, attrib("B", AttribModes::default())], cols: 1, rows: 1, col_spacing: 0.0, @@ -113,3 +117,27 @@ fn a_280_before_the_tag_is_the_version_not_lock_position() { .collect(); assert!(!modes(&read_dxf(text.as_bytes()).expect("read")).0[0].lock_position); } + +/// The Constant mode (flag 2) of an ATTRIB attached to an INSERT, not only of an ATTDEF (#2246). +#[test] +fn constant_attached_attrib_survives_dxf_and_dwg() { + let d = drawing_c(true); + let constants = |d: &Drawing| -> Vec { + d.model + .iter() + .find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.attribs.iter().map(|a| a.constant).collect()) } else { None }) + .unwrap_or_default() + }; + assert_eq!(constants(&d), [true, false]); + let dxf = cadcraft_io::write(&d, "x.dxf").expect("dxf"); + assert_eq!(constants(&cadcraft_io::read(&dxf, "x.dxf").expect("read dxf")), [true, false], "dxf"); + let dwg = cadcraft_io::write(&d, "x.dwg").expect("dwg"); + assert_eq!(constants(&cadcraft_io::read(&dwg, "x.dwg").expect("read dwg")), [true, false], "dwg"); + // Every save version, in both DXF encodings and in DWG. + for version in Version::ALL { + for (name, binary) in [("x.dxf", false), ("x.dxf", true), ("x.dwg", false)] { + let bytes = write_with(&d, name, &SaveOptions { version: Some(version), binary }).expect("write"); + assert_eq!(constants(&cadcraft_io::read(&bytes, name).expect("read")), [true, false], "{version:?} {name} binary={binary}"); + } + } +} From b4b8ae294ae3a87bb518ff3976ad7f02370993f2 Mon Sep 17 00:00:00 2001 From: assamaral Date: Fri, 30 Oct 2026 23:16:51 -0300 Subject: [PATCH 040/151] fix(io): write a LINE's extrusion (210/220/230) (#2241) (#2417) * test(io): a LINE keeps its extrusion through DXF and DWG (#2241) `line_extrusion_roundtrips_with_world_endpoints` (red: group 230 is not written for a LINE with normal (0, 0, -1), so it comes back with (0, 0, 1)). Co-Authored-By: Claude Sonnet 5.5 * fix(io): write a LINE's extrusion (210/220/230) (#2241) A LINE's points are world coordinates and the reader already keeps its extrusion (the direction of its thickness), but the writer left the groups out, so a normal (0, 0, -1) came back as (0, 0, 1) from DXF and from DWG, which is written through DXF. Co-Authored-By: Claude Sonnet 5.5 * test(io): a LINE's extrusion is written as a unit vector or not at all (#2241) Co-Authored-By: Claude Sonnet 5.5 * fix(io): write a LINE's extrusion normalized, nothing for a zero or NaN normal (#2241) Co-Authored-By: Claude Sonnet 5.5 * fix(io): write a tiny extrusion's direction, drop a non-finite one Vec3::normalized returns zero at or below the drawing tolerance, so a LINE with extrusion (0, 0, -1e-10) saved with no 210/220/230 and reopened as +Z, flipping a thickness; an infinite component normalized to NaN and was written as a zero vector. The writer now normalizes by the vector's own length and writes nothing only for a zero or non-finite vector. Co-Authored-By: Claude Sonnet 5.5 * fix(io): keep the direction of a huge finite extrusion The length of (0, -1.5e308, 1.5e308) overflows to infinity, so the normal came out as (0, 0, 0) and was written as such. The vector is scaled by its largest component before it is normalized; the case is in `line_extrusion_is_written_as_a_unit_vector_or_not_at_all`. Review of #2417. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/io/src/dxf_write/entity.rs | 19 +++++++++ crates/io/tests/it/entity_props_roundtrip.rs | 43 ++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index 9c2f12896..fd7c265d8 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -53,6 +53,7 @@ fn write_basic_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: b thickness(w, e); w.p(10, l.a); w.p(11, l.b); + extrusion(w, e); } EntityKind::Point(p) => { w.s(0, "POINT"); @@ -752,6 +753,24 @@ fn thickness(w: &mut W, e: &Entity) { } } +/// The extrusion (210) of an entity whose points are world coordinates, when it is not the Z axis. +fn extrusion(w: &mut W, e: &Entity) { + // Zero and non-finite vectors are not a direction: nothing is written. `Vec3::normalized` would + // also drop a tiny one (its zero threshold is the drawing tolerance) and turn infinity into NaN. + let v = e.common.extrusion; + let max = v.x.abs().max(v.y.abs()).max(v.z.abs()); + if !v.is_finite() || max == 0.0 { + return; + } + // Scaled by the largest component first: the length of a huge one would overflow to infinity. + let v = Vec3::new(v.x / max, v.y / max, v.z / max); + let len = v.len(); + let n = Vec3::new(v.x / len, v.y / len, v.z / len); + if n != Vec3::Z { + w.p(210, n); + } +} + /// Hatch origin and background colour as `CADCRAFT` xdata: `1000 HATCH`, the origin as a /// world position (`1011`, moved with the hatch by other programs) and the background colour /// name (`1000`, empty for none). The DXF Reference has no HATCH groups for either: other diff --git a/crates/io/tests/it/entity_props_roundtrip.rs b/crates/io/tests/it/entity_props_roundtrip.rs index d86d0b4b8..38d325604 100644 --- a/crates/io/tests/it/entity_props_roundtrip.rs +++ b/crates/io/tests/it/entity_props_roundtrip.rs @@ -24,6 +24,49 @@ fn square(size: f64) -> Vec { [(0.0, 0.0), (size, 0.0), (size, size), (0.0, size)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect() } +#[test] +fn line_extrusion_roundtrips_with_world_endpoints() { + // A LINE's endpoints are world coordinates whatever its extrusion, which only directs its + // thickness; a mirrored normal (0, 0, -1) must come back from DXF and DWG (#2241). + let mut d = Drawing::new_metric(); + let common = Common { extrusion: Vec3::new(0.0, 0.0, -1.0), ..Common::default() }; + let line = Line { a: Vec3::new(1.0, 2.0, 0.0), b: Vec3::new(11.0, 7.0, 0.0) }; + let h = d.add(&Space::Model, common, EntityKind::Line(line.clone())).expect("add"); + let dxf = write_dxf(&d); + assert!(dxf.contains("230\r\n-1.0\r\n"), "the extrusion is written"); + let back = roundtrip(&d); + let e = get(&back, h); + assert_eq!(e.common.extrusion, Vec3::new(0.0, 0.0, -1.0)); + assert_eq!(e.kind, EntityKind::Line(line.clone())); + let dwg = cadcraft_io::write(&d, "x.dwg").expect("dwg"); + let back = cadcraft_io::read(&dwg, "x.dwg").expect("reopen dwg"); + let Some(e) = back.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_))) else { panic!("no line") }; + assert_eq!(e.common.extrusion, Vec3::new(0.0, 0.0, -1.0), "through DWG"); + assert_eq!(e.kind, EntityKind::Line(line)); +} + +#[test] +fn line_extrusion_is_written_as_a_unit_vector_or_not_at_all() { + // A normal that is not a direction (zero, NaN) is dropped and one that is not unit length is + // normalized, so a bad 210/220/230 read from a file is not passed on as it is (#2241). + let line = Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }; + let dxf = |extrusion| { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common { extrusion, ..Common::default() }, EntityKind::Line(line.clone())).expect("add"); + write_dxf(&d) + }; + let default = dxf(Vec3::Z); + for bad in [Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0), Vec3::new(f64::INFINITY, 0.0, 0.0), Vec3::new(0.0, 0.0, 5.0)] { + assert_eq!(dxf(bad), default, "{bad:?} writes nothing, like the Z axis"); + } + assert!(dxf(Vec3::new(0.0, 0.0, -5.0)).contains("230\r\n-1.0\r\n")); + // A tiny normal is still a direction: it must not read back as +Z (it would flip a thickness). + assert!(dxf(Vec3::new(0.0, 0.0, -1e-10)).contains("230\r\n-1.0\r\n"), "a tiny normal keeps its direction"); + // A huge finite normal's length overflows to infinity: it is a direction all the same. + let huge = dxf(Vec3::new(0.0, -1.5e308, 1.5e308)); + assert!(huge.contains("220\r\n-0.7071067811865475\r\n230\r\n0.7071067811865475\r\n"), "a huge normal keeps its direction:\n{huge}"); +} + #[test] fn thickness_roundtrips_on_every_entity_that_has_one() { let mut d = Drawing::new_metric(); From 4792ca2941e09813e3973b14e42807d574da071d Mon Sep 17 00:00:00 2001 From: assamaral Date: Fri, 30 Oct 2026 23:16:55 -0300 Subject: [PATCH 041/151] fix(io): a centred ATTDEF keeps its insertion point in a DWG (#2464) (#2466) * test(io): a centred ATTDEF keeps its insertion point in a DWG (#2464) Co-Authored-By: Claude Sonnet 5.5 * fix(io): an ATTDEF with no default value is placed by its tag's width in DWG (#2464) The writer keeps a justified text's stored insertion point only when it is near where the baseline starts for the text's width; an ATTDEF with an empty default value has width 0, so every AutoCAD insertion point further than half a height from the alignment point was replaced by the alignment point. The tag is what is drawn: judge against its width. Co-Authored-By: Claude Sonnet 5.5 * fix(io): keep a stored ATTDEF point that is close to the empty text's start The tag-width baseline check replaced a point read from a drawing whenever the font's width of the tag put the computed start further than the allowance away, e.g. a centred LONGTAG with group 10 at x=99.5 and group 11 at x=100 moved to about 92.17 in DXF and DWG. A stored point close to the empty text's own start is now kept as before; only otherwise is it judged against the tag. Co-Authored-By: Claude Sonnet 5.5 * test(io): store an ATTDEF point that differs from the computed baseline `assert_kept(start)` stored exactly the baseline the tag-width fallback computes, so a write that replaced group 10 with that baseline passed too. The point is now 1 off the start, within the tolerance. Review of #2466. Co-Authored-By: Claude Fable 5.1 * test(io): an empty-value ATTDEF keeps its point with an empty or absent prompt too The tag-width check applies to every ATTDEF with no default value, since the CAD displays the tag whatever the prompt: the guard now says `attdef` instead of reading as a prompt test, and the test covers a prompt, an empty one and none. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/io/src/dxf_write/common.rs | 15 +++++++- crates/io/tests/it/attdef_insert_dwg.rs | 49 +++++++++++++++++++++++++ crates/io/tests/it/main.rs | 1 + 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 crates/io/tests/it/attdef_insert_dwg.rs diff --git a/crates/io/src/dxf_write/common.rs b/crates/io/src/dxf_write/common.rs index 6ba3ce8ef..b09d90410 100644 --- a/crates/io/src/dxf_write/common.rs +++ b/crates/io/src/dxf_write/common.rs @@ -241,8 +241,19 @@ fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 { if q.is_finite() { Vec3::new(q.x, q.y, t.insert.z) } else { t.insert } } +/// `attdef_prompt` is `Some` for an ATTDEF (its prompt, which may be empty) and `None` for an ATTRIB. pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) { - w.p(10, text_insert(d, t)); + let attdef = attdef_prompt.is_some(); + // An attribute definition with no default value is drawn as its tag, whatever its prompt: the + // baseline start AutoCAD stored is judged against the tag's width too. A stored point that is + // close to the empty text's own start is kept as it is, so the font's width of the tag never + // moves it. + let own = text_insert(d, t); + let insert = match attrib { + Some((a, _)) if attdef && t.value.is_empty() && own != t.insert => text_insert(d, &Text { value: a.tag.clone(), ..t.clone() }), + _ => own, + }; + w.p(10, insert); w.f(40, t.height); w.s(1, &t.value); if t.rotation != 0.0 { @@ -277,7 +288,7 @@ pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attri } match attrib { Some((a, target)) => { - w.s(100, if attdef_prompt.is_some() { "AcDbAttributeDefinition" } else { "AcDbAttribute" }); + w.s(100, if attdef { "AcDbAttributeDefinition" } else { "AcDbAttribute" }); if target >= Version::R2010 { w.i(280, 0); } diff --git a/crates/io/tests/it/attdef_insert_dwg.rs b/crates/io/tests/it/attdef_insert_dwg.rs new file mode 100644 index 000000000..9fd1a1a3a --- /dev/null +++ b/crates/io/tests/it/attdef_insert_dwg.rs @@ -0,0 +1,49 @@ +//! A centre-justified ATTDEF keeps its insertion point in a DWG (#2464): AutoCAD puts it where the +//! baseline of the displayed tag starts, which for an attribute with an empty default value is +//! further from the alignment point than half a height. + +use cadcraft_doc::EntityKind; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +/// The `insert` x of the only ATTDEF in `d`. +fn attdef_x(d: &cadcraft_doc::Drawing) -> f64 { + d.model.iter().find_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.text.insert.x) } else { None }).expect("an ATTDEF") +} + +#[test] +fn centred_attdef_with_an_empty_value_keeps_its_insertion_point() { + // The tag "LONGTAG" at height 2.5, centred on x = 100: its baseline starts half a width left. + // The stored point is 1 off that start (within the tolerance), so a write that recomputed + // it from the tag would not pass for keeping it. + let width = cadcraft_fonts::line_width("LONGTAG", 2.5, 1.0); + let start = 100.0 - width / 2.0 - 1.0; + assert!(100.0 - start > 3.0, "the point is well over a height from the alignment point"); + assert_kept(start); +} + +/// A stored point that a tag-width baseline would move is kept too (review of #2466): the check +/// does not rely on the font's width of the tag, so this one is only 0.5 from the alignment point. +#[test] +fn centred_attdef_point_near_the_alignment_point_is_kept() { + assert_kept(99.5); +} + +/// A centred ATTDEF with an empty default value and group 10 at `start`, group 11 at x = 100 is +/// read from DXF and kept by a write as DXF and as DWG (both versions). The displayed text is the +/// tag whatever the prompt: with a prompt, an empty one (group 3 empty) and none (no group 3). +fn assert_kept(start: f64) { + for prompt in ["3\nprompt\n", "3\n\n", ""] { + let dxf = format!( + "0\nSECTION\n2\nENTITIES\n0\nATTDEF\n8\n0\n10\n{start}\n20\n5.0\n40\n2.5\n1\n\n7\nSTANDARD\n72\n1\n11\n100.0\n21\n5.0\n{prompt}2\nLONGTAG\n70\n8\n0\nENDSEC\n0\nEOF\n" + ); + let d = read(dxf.as_bytes(), "t.dxf").unwrap(); + assert!((attdef_x(&d) - start).abs() < 1e-6, "read from DXF"); + let as_dxf = cadcraft_io::write(&d, "t.dxf").unwrap(); + assert!((attdef_x(&read(&as_dxf, "t.dxf").unwrap()) - start).abs() < 1e-6, "DXF, prompt {prompt:?}: insertion point changed"); + for version in [Version::R2000, Version::R2018] { + let dwg = write_with(&d, "t.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let back = read(&dwg, "t.dwg").unwrap(); + assert!((attdef_x(&back) - start).abs() < 1e-6, "{version:?}, prompt {prompt:?}: insertion point {} instead of {start}", attdef_x(&back)); + } + } +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 07b1e8c2e..5d6c343d0 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -5,6 +5,7 @@ mod angle_header; mod annotative_orientation; mod annotative_roundtrip; mod assoc_array_roundtrip; +mod attdef_insert_dwg; mod attrib_modes; mod attrib_props; mod block_expansion; From 75efe07d855388e450895e1928c11eb04ffa1c5e Mon Sep 17 00:00:00 2001 From: Elarionus <29511515+Elarionus@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:16:59 -0700 Subject: [PATCH 042/151] Enter on a partly typed command runs the highlighted suggestion (#2135) Co-authored-by: Ethan Dale --- crates/ui-egui/src/cmdline.rs | 39 ++++++++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 13 +++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 9d8bd9821..40397df99 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -443,7 +443,9 @@ pub fn submit(app: &mut CadApp) { } return; } - app.cmdline(&text); + // A partly typed command name runs the highlighted (first) AutoComplete suggestion. + let completion = if app.session.prompting() { None } else { suggestions(text.trim()).into_iter().next() }; + app.cmdline_or(&text, completion.map(|s| s.id.to_ascii_lowercase()).as_deref()); } fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize { @@ -924,4 +926,39 @@ mod autocomplete_tests { tab(&mut app); assert_eq!(app.cmd.buffer, "RECTANG"); } + + fn app() -> CadApp { + let mut app = CadApp::new(cadcraft_engine::Session::empty(), crate::Services::default()); + app.start("ui.sample"); + app + } + + #[test] + fn enter_on_a_partial_name_runs_the_highlighted_suggestion() { + let mut app = app(); + app.cmd.buffer = "RECT".to_string(); + submit(&mut app); + assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("rectang")); + assert!(!app.session.log.iter().any(|l| l.contains("unknown command")), "{:?}", app.session.log); + } + + #[test] + fn enter_keeps_exact_names_and_reports_names_nothing_matches() { + let mut app = app(); + // An exact alias is not replaced by a longer suggestion. + app.cmd.buffer = "l".to_string(); + submit(&mut app); + assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("line")); + app.session.cancel(); + // A system variable name still acts as SETVAR. + app.cmd.buffer = "orthomode".to_string(); + submit(&mut app); + assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("setvar")); + app.session.cancel(); + // No suggestion: still an unknown command. + app.cmd.buffer = "zzqx".to_string(); + submit(&mut app); + assert!(app.session.running.is_none()); + assert!(app.session.log.iter().any(|l| l.contains("unknown command")), "{:?}", app.session.log); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0253a2cec..97ab2faf4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -422,6 +422,13 @@ impl CadApp { /// Submit a line of command-line text. A refused line is echoed to the history; the error is /// also returned. pub fn cmdline(&mut self, text: &str) -> Option { + self.cmdline_or(text, None) + } + + /// [`CadApp::cmdline`], but a command name nothing knows (no command, alias, UI command, LISP + /// command or system variable) runs `fallback` instead: Enter on a partly typed name accepts + /// the highlighted AutoComplete suggestion. + pub fn cmdline_or(&mut self, text: &str, fallback: Option<&str>) -> Option { if !self.session.prompting() { let first = text.split_whitespace().next().unwrap_or(""); // `cmd {json}` is a programmatic call: the engine runs it with those parameters. @@ -430,7 +437,11 @@ impl CadApp { return None; } } - let e = self.session.cmdline(text).err()?.to_string(); + let e = match (self.session.cmdline(text), fallback) { + (Ok(()), _) => return None, + (Err(cadcraft_engine::EngineError::UnknownCommand(_)), Some(f)) => return self.cmdline(f), + (Err(e), _) => e.to_string(), + }; self.session.echo(e.clone()); Some(e) } From b187af646aca987dbf6c7dae18cddc081e8f8d97 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 30 Oct 2026 19:19:06 -0700 Subject: [PATCH 043/151] Save helpers used only by the file system build stay out of the wasm one (#2521) --- crates/engine/src/cmd/file.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8bb4c5c75..9aaff4631 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -473,6 +473,7 @@ pub fn looks_like_drawing(bytes: &[u8]) -> bool { } /// How much of a drawing's end [`looks_complete`] reads. +#[cfg(not(target_arch = "wasm32"))] const TAIL_BYTES: u64 = 256; /// Whether a drawing that [`looks_like_drawing`] (`head`, its first bytes, at least 256 of them @@ -560,6 +561,7 @@ fn ends(src: &mut std::fs::File) -> std::io::Result<(Vec, Vec, u64)> { /// backup already there. One that can't be told (a 2007 DWG) does: it is most likely fine, and it /// is about to be replaced. With no backup yet any file is kept, because the checks can be wrong /// about a real file and this save replaces the only copy. +#[cfg(not(target_arch = "wasm32"))] fn backup_replaced(head: &[u8], tail: &[u8], len: u64, backup_exists: bool) -> bool { (looks_like_drawing(head) && completeness(head, tail, len) != Some(false)) || (len > 0 && !backup_exists) } From 5bc93ccf18c6700964ef9df6ff4db3514437b641 Mon Sep 17 00:00:00 2001 From: Jonas <80707953+jonascloud@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:24:31 +0100 Subject: [PATCH 044/151] test(io): add independent DXF/DWG conformance and preservation matrices (#2237) * test(dxf): add deterministic codec and drawing round-trip matrices Exercise typed tags and model-space LINE/CIRCLE/TEXT through in-memory ASCII and binary DXF at every save version. Compare both reads with the original model and check independent handwritten byte/entity fixtures. Keep desired-behaviour tests for extrusion and ByBlock plot-style loss explicitly ignored until fixed. No production or docs changes. * test(dxf): independently verify common entity properties * test(io): add a broad DWG preservation matrix with focused pending regressions Share deterministic Drawing cases and exact assertions between DXF and DWG without reducing DXF coverage. Run representable DWG cases at all six save versions, twice in RAM. Test R2000 color/transparency and the single TEXT elevation as explicit format limits. Keep six independently reproduced loss regressions ignored without weakening comparisons. No production or docs changes. * test(io): close binary-width and referenced-linetype coverage gaps Check every integer group range against independently constructed binary payloads, catching shared reader/writer width mistakes. Resolve linetypes with Drawing lookup and compare layer linetypes too. Add missing/changed-reference self-tests and inherited-pattern DXF/DWG round trips. Reproduced the review counterexamples before and after; full CI passes with two nextest threads. Test-only changes. * test(io): expand DXF and DWG matrices across entities and drawing collections Add basic coverage for all 29 modeled entity kinds, block graphs and attributes, all ACI colors, richer properties/XDATA, sparse handles, headers, symbol tables, views, groups, layouts and foreign payloads. Preserve typed fields and authored references through two RAM saves. Add independent DXF groups and native DWG bridge checks, plus explicit refusal-or-preservation tests for unknown entities. Keep confirmed discrepancies and unresolved preservation contracts as focused ignored tests; native linetype angles and silently dropped unknown DWG entities are independently reproduced. Version limits and foreign object handle remapping are explicit contracts, not blanket normalization. Tests only; no production or docs changes. Full CI passes with two nextest threads: 2777 passed, 27 skipped. * test(io): cover bounded property domains and targeted field interactions Independently check all ACI values and transparency percentages, native layer/style flag combinations, and literal large handles. Add shared DXF/DWG cases for alignment with mirrored styles, inherited versus explicit zero properties, repeated nested attributes, and integer-boundary handles. Keep exhausted handle allocation separate: DWG refuses the save; a focused ignored DXF regression reproduces silent loss of all nine model entities. No production or documentation changes. Validation: full cargo xtask ci with NEXTEST_TEST_THREADS=2 passes all seven gates (2792 passed, 28 skipped). The pending DXF regression fails explicitly when run with --ignored. * test(dxf): separate chunk conformance from binary reader tolerance Address PR #2237 review: shared exact-tag round trips only use hex chunks within the documented 127-byte group limit. Independently inspect ASCII and binary writer bytes, reassemble payloads, and verify following-record framing. Keep full 0..255 length-byte read tolerance in handwritten fixtures, not as a claim of conforming writer output. Add two focused ignored regressions for oversized ASCII/binary output, reproduced with 128-byte payloads. They extend existing issue #1025. No production changes. Validation: cargo xtask ci passes all seven gates with nextest 0.9.150 and two test threads (2782 passed, 30 skipped). Both new pending checks fail explicitly when run with --ignored. * test(io): compare DWG dimension blocks through their references Anonymous dimension-block suffixes may change between drawing sessions (Autodesk documentation; ODA DWG section 20.4.52.1). Resolve their names through preserved dimension identities and keep strict checks for cache content, entity handles, block properties and reference integrity. Enable the pending dimensions test across all six DWG versions and two saves. Add a paired-cache fixture and comparator controls rejecting swapped/merged/dangling references, geometry and property changes, stale caches, and named-block renaming. Leave the shared comparator and DXF contract unchanged. Validation: cargo xtask ci (all seven stages; 2786 passed, 29 skipped); roundtrip matrix (211 passed, 26 ignored). Resolves the preservation-contract decision in #2257 (tracker #2238, B4); no production codec fix. * test(io): specify WIPEOUT explicit closure on roundtrip An implicit WIPEOUT boundary gains one explicit closing marker in DXF/DWG. Compare against independently authored explicit boundaries in both winding directions across all six versions and two saves; retain strict field and resave comparisons. Enable the two pending closure checks without changing the shared comparator. Distinguish this valid file representation from the grip-edit defect: the latter is fixed separately in PR #2284, with engine, outline and rendering regressions. No production change in this test-only branch. Validation: cargo xtask ci, all seven stages passed (2788 passed, 27 skipped); active roundtrip matrix 213 passed; remaining ignored checks reproduce 24 drawing failures and two codec failures. One unrelated HTTP control-server test failed transiently, then passed in isolation and in the full rerun. Related #2256 and tracker #2238 B3. * test(io): expose multiline ATTRIB alignment corruption Keep the original origin witness and add distinct nonzero TEXT/MTEXT anchors. An active R2018 ASCII/binary two-save check verifies world-space multiline geometry and value edits; this does not excuse corrupted fallback TEXT fields. Retain the strict pending #2255 check and add comparison of independently downgraded R2000/R2004 geometry. The observed Left -> Center change is a real reader error, not permitted alignment-point canonicalization. The reader hunk from existing PR #1942 makes the extended strict check pass in an isolated experiment; no competing production fix is included here. Validation of this test-only tree: all seven cargo xtask ci stages pass (2789 passed, 27 skipped); active matrix 214 passed; remaining 24 drawing and two codec diagnostics still fail. An unrelated HTTP control-server test failed transiently, then passed alone and in the complete rerun. Related #2255, #1942 and tracker #2238 B2. * test(io): separate ATTRIB value and legacy-placement guarantees Assert exact saved ATTRIB values, read_as and authored embedded MTEXT contents before and after value edits. A mutation test rejects corrupt fields, including a value/read_as pair whose rendered geometry remains unchanged. Make legacy placement an independently runnable pending regression instead of a callback behind the failing field comparator. Compare direct R2000/R2004 exports with exports after one/two R2018 roundtrips, both encodings in each stage, and verify that the primary single-line fallback actually survives. Keep all three contracts local and distinct; remove the old cross-module callback. The new legacy test fails directly on world-space geometry with the unchanged reader. Both pending ATTRIB tests pass with the reader hunk from existing PR #1942 in an isolated experiment (548 IO tests also pass). No production fix is included. Validation: all seven cargo xtask ci stages pass with NEXTEST_TEST_THREADS=1 (2790 passed, 28 skipped). Earlier parallel runs and one isolated retry hit pre-existing HTTP/busy control-server failures; no exclusions or test changes. Active matrix 215 passed; all 25 drawing and two codec pending diagnostics still fail. Addresses review of #2237; related #2255, #1942 and tracker #2238 B2. --------- Co-authored-by: cloudj --- crates/dxf/tests/conformance.rs | 145 +++++++ crates/dxf/tests/hex_chunks.rs | 119 ++++++ crates/dxf/tests/roundtrip.rs | 148 +++++++ crates/io/tests/it/main.rs | 1 + crates/io/tests/it/roundtrip/cases.rs | 308 ++++++++++++++ crates/io/tests/it/roundtrip/cases/blocks.rs | 234 +++++++++++ crates/io/tests/it/roundtrip/cases/complex.rs | 131 ++++++ .../io/tests/it/roundtrip/cases/geometry.rs | 301 +++++++++++++ .../io/tests/it/roundtrip/cases/properties.rs | 351 ++++++++++++++++ crates/io/tests/it/roundtrip/collections.rs | 263 ++++++++++++ crates/io/tests/it/roundtrip/compare.rs | 374 +++++++++++++++++ .../io/tests/it/roundtrip/dwg/conformance.rs | 87 ++++ .../io/tests/it/roundtrip/dwg/dimensions.rs | 210 ++++++++++ crates/io/tests/it/roundtrip/dwg/entities.rs | 171 ++++++++ crates/io/tests/it/roundtrip/dwg/extended.rs | 395 ++++++++++++++++++ crates/io/tests/it/roundtrip/dwg/mod.rs | 75 ++++ .../io/tests/it/roundtrip/dxf/attributes.rs | 163 ++++++++ crates/io/tests/it/roundtrip/dxf/common.rs | 197 +++++++++ .../io/tests/it/roundtrip/dxf/conformance.rs | 185 ++++++++ crates/io/tests/it/roundtrip/dxf/entities.rs | 121 ++++++ crates/io/tests/it/roundtrip/dxf/extended.rs | 334 +++++++++++++++ .../tests/it/roundtrip/dxf/geometry_groups.rs | 138 ++++++ crates/io/tests/it/roundtrip/dxf/mod.rs | 56 +++ .../io/tests/it/roundtrip/dxf/table_flags.rs | 128 ++++++ crates/io/tests/it/roundtrip/handle_limits.rs | 40 ++ crates/io/tests/it/roundtrip/mod.rs | 11 + crates/io/tests/it/roundtrip/unknown.rs | 106 +++++ 27 files changed, 4792 insertions(+) create mode 100644 crates/dxf/tests/conformance.rs create mode 100644 crates/dxf/tests/hex_chunks.rs create mode 100644 crates/dxf/tests/roundtrip.rs create mode 100644 crates/io/tests/it/roundtrip/cases.rs create mode 100644 crates/io/tests/it/roundtrip/cases/blocks.rs create mode 100644 crates/io/tests/it/roundtrip/cases/complex.rs create mode 100644 crates/io/tests/it/roundtrip/cases/geometry.rs create mode 100644 crates/io/tests/it/roundtrip/cases/properties.rs create mode 100644 crates/io/tests/it/roundtrip/collections.rs create mode 100644 crates/io/tests/it/roundtrip/compare.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/conformance.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/dimensions.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/entities.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/extended.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/mod.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/attributes.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/common.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/conformance.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/entities.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/extended.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/geometry_groups.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/mod.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/table_flags.rs create mode 100644 crates/io/tests/it/roundtrip/handle_limits.rs create mode 100644 crates/io/tests/it/roundtrip/mod.rs create mode 100644 crates/io/tests/it/roundtrip/unknown.rs diff --git a/crates/dxf/tests/conformance.rs b/crates/dxf/tests/conformance.rs new file mode 100644 index 000000000..089ed8063 --- /dev/null +++ b/crates/dxf/tests/conformance.rs @@ -0,0 +1,145 @@ +//! Small independent byte fixtures for DXF tag types, text encoding, framing and failures. +//! Handwritten examples, not files from a CAD application or generated by our writer. +//! Background on the tag model and file structure: +//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/dxftags.html +//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/filestructure.html +//! ASCII spacing is our writer's documented convention; binary fixtures specify actual bytes. + +use cadcraft_dxf::{DxfError, Tag, Value, parse, parse_with_limit, write_ascii_as, write_binary}; + +const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0"; + +fn fixtures() -> Vec<(Tag, &'static [u8], &'static [u8])> { + vec![ + (Tag::s(1, " A "), b" 1\r\n A \r\n", b"\x01\0 A \0"), + (Tag::s(1, ""), b" 1\r\n\r\n", b"\x01\0\0"), + (Tag::s(105, "AB12"), b"105\r\nAB12\r\n", b"\x69\0AB12\0"), + (Tag::new(10, Value::Real(-3.25)), b" 10\r\n-3.25\r\n", b"\x0a\0\0\0\0\0\0\0\x0a\xc0"), + (Tag::i(70, -32768), b" 70\r\n-32768\r\n", b"\x46\0\0\x80"), + (Tag::i(90, -2147483648), b" 90\r\n-2147483648\r\n", b"\x5a\0\0\0\0\x80"), + (Tag::i(160, 9223372036854775807), b"160\r\n9223372036854775807\r\n", b"\xa0\0\xff\xff\xff\xff\xff\xff\xff\x7f"), + (Tag::new(290, Value::Bool(false)), b"290\r\n0\r\n", b"\x22\x01\0"), + (Tag::new(299, Value::Bool(true)), b"299\r\n1\r\n", b"\x2b\x01\x01"), + (Tag::new(310, Value::Hex("00FF80".into())), b"310\r\n00FF80\r\n", b"\x36\x01\x03\0\xff\x80"), + (Tag::new(1004, Value::Hex(String::new())), b"1004\r\n\r\n", b"\xec\x03\0"), + (Tag::i(1070, -2), b"1070\r\n-2\r\n", b"\x2e\x04\xfe\xff"), + (Tag::i(1071, 65536), b"1071\r\n65536\r\n", b"\x2f\x04\0\0\x01\0"), + ] +} + +#[test] +fn reads_independently_authored_ascii_and_binary_values() { + for (expected, ascii, binary) in fixtures() { + assert_eq!(parse(ascii).unwrap(), std::slice::from_ref(&expected), "ASCII code {}", expected.code); + let bytes = [SENTINEL, binary].concat(); + assert_eq!(parse(&bytes).unwrap(), std::slice::from_ref(&expected), "binary code {}", expected.code); + } +} + +#[test] +fn writes_independently_specified_ascii_and_binary_bytes() { + for (tag, ascii, binary) in fixtures() { + for utf8 in [false, true] { + let tags = std::slice::from_ref(&tag); + assert_eq!(write_ascii_as(tags, utf8).as_bytes(), ascii, "ASCII code {}", tag.code); + assert_eq!(write_binary(tags, utf8), [SENTINEL, binary].concat(), "binary code {}", tag.code); + } + } +} + +#[test] +fn integer_code_ranges_have_independent_binary_widths() { + // Specification ranges and widths, never kind_of(): a shared reader/writer mistake + // can round-trip every boundary value while still using the wrong number of bytes. + let widths = [ + (vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], 2, i64::from(i16::MIN), i64::from(i16::MAX)), + (vec![90..=99, 420..=429, 440..=459, 1071..=1071], 4, i64::from(i32::MIN), i64::from(i32::MAX)), + (std::iter::once(160..=169).collect(), 8, i64::MIN, i64::MAX), + ]; + for (ranges, width, min, max) in widths { + for code in ranges.into_iter().flatten() { + for value in [min, min + 1, -1, 0, 1, max - 1, max] { + // Binary DXF uses a little-endian 16-bit group code and a fixed-width, + // little-endian two's-complement integer. Encoding is version-independent. + let code_bytes = u16::try_from(code).unwrap().to_le_bytes(); + let value_bytes = value.to_le_bytes(); + let expected = [SENTINEL, &code_bytes, &value_bytes[..width]].concat(); + let tags = [Tag::i(code, value)]; + for utf8 in [false, true] { + assert_eq!(write_binary(&tags, utf8), expected, "code={code} value={value} utf8={utf8}"); + } + assert_eq!(parse(&expected).unwrap(), tags, "code={code} value={value}"); + } + } + } +} + +#[test] +fn text_encoding_has_independent_wire_expectations() { + let tags = [Tag::s(1, "é😀")]; + let escaped = b"\\U+00E9\\U+D83D\\U+DE00"; + let utf8 = b"\xc3\xa9\xf0\x9f\x98\x80"; + for (unicode, text) in [(false, escaped.as_slice()), (true, utf8.as_slice())] { + let ascii = [b" 1\r\n".as_slice(), text, b"\r\n"].concat(); + let binary = [SENTINEL, b"\x01\0", text, b"\0"].concat(); + assert_eq!(write_ascii_as(&tags, unicode).as_bytes(), ascii, "utf8={unicode}"); + assert_eq!(write_binary(&tags, unicode), binary, "utf8={unicode}"); + assert_eq!(parse(&ascii).unwrap(), tags); + assert_eq!(parse(&binary).unwrap(), tags); + } + // Pre-2007 code-page bytes are independent of the writer, which emits Unicode escapes. + let ascii = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1252\n0\nENDSEC\n1\n\x80\xe9\n0\nEOF\n"; + let binary = [ + SENTINEL, + b"\0\0SECTION\0\x02\0HEADER\0\x09\0$ACADVER\0\x01\0AC1015\0\x09\0$DWGCODEPAGE\0\x03\0ANSI_1252\0\0\0ENDSEC\0\x01\0\x80\xe9\0\0\0EOF\0", + ] + .concat(); + for bytes in [ascii.as_slice(), binary.as_slice()] { + let tags = parse(bytes).unwrap(); + assert_eq!(tags[7], Tag::s(1, "€é")); + } +} + +#[test] +fn ascii_accepts_line_endings_padding_and_no_final_newline() { + let expected = [Tag::i(70, -7), Tag::s(1, " text "), Tag::s(0, "EOF")]; + for newline in ["\n", "\r\n"] { + for final_newline in [false, true] { + let mut text = [" 70 ", " -7 ", "1", " text ", "0", "EOF"].join(newline); + if final_newline { + text.push_str(newline); + } + assert_eq!(parse(text.as_bytes()).unwrap(), expected, "newline={newline:?} final={final_newline}"); + } + } +} + +#[test] +fn malformed_and_truncated_values_are_errors_not_panics() { + assert!(matches!(parse(b""), Err(DxfError::NotDxf))); + assert!(matches!(parse(b"oops\nvalue\n"), Err(DxfError::BadCode { line: 1, .. }))); + assert!(matches!(parse(b"10\n"), Err(DxfError::Eof))); + assert!(matches!(parse(SENTINEL), Err(DxfError::NotDxf))); + for (_, _, binary) in fixtures() { + // Every cut inside the first record is an error, unlike cuts between complete records: + // the tag API deliberately accepts streams without an EOF record. + for cut in 1..binary.len() { + let bytes = [SENTINEL, &binary[..cut]].concat(); + assert!(matches!(parse(&bytes), Err(DxfError::Eof)), "binary={binary:?} cut={cut}"); + } + } +} + +#[test] +fn tag_limit_accepts_the_exact_boundary_and_rejects_one_less() { + let ascii = b"1\nA\n70\n-7\n0\nEOF\n"; + let binary = [SENTINEL, b"\x01\0A\0\x46\0\xf9\xff\0\0EOF\0"].concat(); + for bytes in [ascii.as_slice(), binary.as_slice()] { + for limit in 0..3 { + assert!(matches!(parse_with_limit(bytes, limit), Err(DxfError::TooLarge { limit: actual, .. }) if actual == limit)); + } + for limit in [3, 4] { + assert_eq!(parse_with_limit(bytes, limit).unwrap(), [Tag::s(1, "A"), Tag::i(70, -7), Tag::s(0, "EOF")]); + } + } +} diff --git a/crates/dxf/tests/hex_chunks.rs b/crates/dxf/tests/hex_chunks.rs new file mode 100644 index 000000000..e3fdc6948 --- /dev/null +++ b/crates/dxf/tests/hex_chunks.rs @@ -0,0 +1,119 @@ +//! Hex payload conformance is distinct from binary framing capacity: DXF groups 310–319 and +//! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255. +//! Inspect writer bytes without our parser; accept any compliant partition preserving payload, +//! code and following-record framing. Oversized binary read fixtures test tolerance only. +//! Pending writer regressions extend the chunk-size finding in issue #1025. +//! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files: +//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm +//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm + +use cadcraft_dxf::{Tag, Value, parse, write_ascii_as, write_binary}; + +const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0"; +const FOLLOWING_BINARY: &[u8] = b"\x46\0\xf9\xff"; // group 70, signed i16 -7 + +fn payload(length: usize) -> Vec { + (0..length).map(|index| u8::try_from(index * 37 % 256).unwrap()).collect() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02X}")).collect() +} + +fn input(code: i32, bytes: &[u8]) -> [Tag; 2] { + [Tag::new(code, Value::Hex(hex(bytes))), Tag::i(70, -7)] +} + +fn check_ascii(code: i32, bytes: &[u8], utf8: bool) { + let wire = write_ascii_as(&input(code, bytes), utf8); + let lines: Vec<_> = wire.lines().collect(); + let (chunks, following) = lines.split_at(lines.len().checked_sub(2).unwrap()); + assert_eq!(following[0].trim(), "70"); + assert_eq!(following[1], "-7"); + assert!(!chunks.is_empty(), "an empty payload must still have a tag"); + let (records, remainder) = chunks.as_chunks::<2>(); + assert!(remainder.is_empty(), "incomplete group/value pair"); + let context = format!("ASCII code={code} bytes={} utf8={utf8}", bytes.len()); + let mut back = Vec::new(); + for record in records { + assert_eq!(record[0].trim().parse::().unwrap(), code, "{context}"); + let text = record[1]; + assert!(text.len() <= 254, "{context}: {} hex characters exceed 127 payload bytes", text.len()); + assert!(text.bytes().all(|b| b.is_ascii_hexdigit()), "{context}: invalid hex"); + let (pairs, remainder) = text.as_bytes().as_chunks::<2>(); + assert!(remainder.is_empty(), "{context}: partial byte"); + back.extend(pairs.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())); + } + assert_eq!(back, bytes, "{context}: complete payload and byte order"); +} + +fn check_binary(code: i32, bytes: &[u8], utf8: bool) { + let wire = write_binary(&input(code, bytes), utf8); + let mut chunks = wire.strip_prefix(SENTINEL).unwrap().strip_suffix(FOLLOWING_BINARY).unwrap(); + assert!(!chunks.is_empty(), "an empty payload must still have a tag"); + let context = format!("binary code={code} bytes={} utf8={utf8}", bytes.len()); + let mut back = Vec::new(); + while !chunks.is_empty() { + let (&[lo, hi, length], rest) = chunks.split_first_chunk::<3>().unwrap(); + assert_eq!(i32::from(u16::from_le_bytes([lo, hi])), code, "{context}"); + assert!(length <= 127, "{context}: {length} bytes exceed the documented group limit"); + let (chunk, next) = rest.split_at(usize::from(length)); + back.extend_from_slice(chunk); + chunks = next; + } + assert_eq!(back, bytes, "{context}: complete payload and byte order"); +} + +#[test] +fn conforming_payloads_have_independent_ascii_and_binary_wire_checks() { + for code in (310..=319).chain([1004]) { + for length in [0, 1, 2, 126, 127] { + for utf8 in [false, true] { + let bytes = payload(length); + check_ascii(code, &bytes, utf8); + check_binary(code, &bytes, utf8); + } + } + } +} + +#[test] +fn binary_reader_tolerates_the_full_length_byte_domain() { + // This is deliberately permissive input, not an assertion that oversized chunks conform + // or that the writer should produce them. The following record catches framing mistakes. + for code in (310_u16..=319).chain([1004]) { + for length in 0..=u8::MAX { + let bytes = payload(usize::from(length)); + let mut wire = SENTINEL.to_vec(); + wire.extend_from_slice(&code.to_le_bytes()); + wire.push(length); + wire.extend_from_slice(&bytes); + wire.extend_from_slice(FOLLOWING_BINARY); + assert_eq!(parse(&wire).unwrap(), input(i32::from(code), &bytes), "code={code} length={length}"); + } + } +} + +#[test] +#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"] +fn ascii_writer_splits_large_payloads_into_conforming_chunks() { + for code in (310..=319).chain([1004]) { + for length in [128, 254, 255, 256, 1024] { + for utf8 in [false, true] { + check_ascii(code, &payload(length), utf8); + } + } + } +} + +#[test] +#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"] +fn binary_writer_splits_large_payloads_into_conforming_chunks() { + for code in (310..=319).chain([1004]) { + for length in [128, 254, 255, 256, 1024] { + for utf8 in [false, true] { + check_binary(code, &payload(length), utf8); + } + } + } +} diff --git a/crates/dxf/tests/roundtrip.rs b/crates/dxf/tests/roundtrip.rs new file mode 100644 index 000000000..acdcb7a78 --- /dev/null +++ b/crates/dxf/tests/roundtrip.rs @@ -0,0 +1,148 @@ +//! Deterministic tag preservation through the public byte API, in both encodings and every +//! supported version. Inputs have the group code's type and representable values; lossy +//! coercions are not accepted as round-trip equality. Real values must retain their bits. + +use cadcraft_dxf::{Tag, Value, Version, is_binary, parse, write_ascii_as, write_binary}; + +fn check(case: &str, body: &[Tag]) { + for version in Version::ALL { + let mut original = vec![ + Tag::s(0, "SECTION"), + Tag::s(2, "HEADER"), + Tag::s(9, "$ACADVER"), + Tag::s(1, version.code()), + Tag::s(9, "$DWGCODEPAGE"), + Tag::s(3, "ANSI_1252"), + Tag::s(0, "ENDSEC"), + Tag::s(0, "SECTION"), + Tag::s(2, "ENTITIES"), + ]; + original.extend_from_slice(body); + original.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]); + for binary in [false, true] { + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("{case} · {version} · binary={binary} · pass={pass}"); + let bytes = if binary { write_binary(¤t, version.utf8()) } else { write_ascii_as(¤t, version.utf8()).into_bytes() }; + assert_eq!(is_binary(&bytes), binary, "{context}"); + let back = parse(&bytes).unwrap_or_else(|e| panic!("{context}: {e}")); + assert_eq!(back.len(), original.len(), "{context}: tag count"); + for (index, (expected, actual)) in original.iter().zip(&back).enumerate() { + assert_eq!(actual, expected, "{context}: tag[{index}]"); + if let (Value::Real(a), Value::Real(b)) = (&expected.value, &actual.value) { + assert_eq!(b.to_bits(), a.to_bits(), "{context}: tag[{index}] real bits"); + } + } + assert_eq!(back, current, "{context}: no additional changes on resave"); + current = back; + } + } + } +} + +#[test] +fn integer_width_boundaries() { + // Explicit reference ranges, not obtained from the codec's kind_of(). + let widths = [ + (vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], i64::from(i16::MIN), i64::from(i16::MAX)), + (vec![90..=99, 420..=429, 440..=459, 1071..=1071], i64::from(i32::MIN), i64::from(i32::MAX)), + (std::iter::once(160..=169).collect(), i64::MIN, i64::MAX), + ]; + for (ranges, min, max) in widths { + for value in [min, min + 1, -1, 0, 1, max - 1, max] { + let tags: Vec<_> = ranges.iter().cloned().flatten().map(|code| Tag::i(code, value)).collect(); + check(&format!("integer/{min}..{max}/{value}"), &tags); + } + } +} + +#[test] +fn finite_reals_keep_all_bits() { + let values = [ + 0.0, + -0.0, + f64::from_bits(1), + -f64::from_bits(1), + f64::MIN_POSITIVE, + f64::MIN_POSITIVE.next_down(), + 0.1, + -3.25, + 1.0_f64.next_down(), + 1.0, + 1.0_f64.next_up(), + 1e-100, + -1e100, + f64::MAX, + f64::MIN, + ]; + for value in values { + let tags: Vec<_> = + [10..=59, 110..=149, 210..=239, 460..=469, 1010..=1059].into_iter().flatten().map(|code| Tag::new(code, Value::Real(value))).collect(); + check(&format!("real/{:016X}", value.to_bits()), &tags); + } +} + +#[test] +fn booleans() { + for value in [false, true] { + let tags: Vec<_> = (290..=299).map(|code| Tag::new(code, Value::Bool(value))).collect(); + check(&format!("bool/{value}"), &tags); + } +} + +#[test] +fn strings_preserve_whitespace_unicode_and_literal_escapes() { + // No CR/LF or NUL: those are line/string delimiters, not losslessly writable payloads. + let values = [ + ("empty", ""), + ("spaces", " leading and trailing "), + ("tabs", "\tindented\t"), + ("unicode", "Café Ø 图号 Україна 😀 e\u{301}"), + ("literal-escapes", r"\U+0041 \M+183A4 \U+D83D\U+DE00 \\U+005C \P %%c"), + ("malformed-escapes", r"\U+ \U+ZZZZ \U+D800 \U+DC00 \M+9FFFF"), + ]; + for (case, value) in values { + let tags: Vec<_> = + [0..=9, 100..=100, 102..=102, 105..=105, 300..=309, 320..=369, 410..=419, 430..=439, 470..=479, 480..=481, 1000..=1003, 1005..=1005] + .into_iter() + .flatten() + .map(|code| Tag::s(code, value)) + .collect(); + check(case, &tags); + } + for length in [1, 254, 255, 256, 1024] { + check(&format!("string/length={length}"), &[Tag::s(1, "x".repeat(length))]); + } +} + +#[test] +fn hex_chunks_keep_bytes_and_order() { + // DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length + // byte can represent more, but that is reader tolerance, not writer conformance. + // Larger payloads need chunk-size/reassembly checks rather than exact tag equality; + // see hex_chunks.rs for the independent wire checks and pending writer regressions. + for length in [0, 1, 2, 126, 127] { + let hex: String = (0..length).map(|i| format!("{:02X}", (i * 37) % 256)).collect(); + let tags: Vec<_> = (310..=319).chain([1004]).map(|code| Tag::new(code, Value::Hex(hex.clone()))).collect(); + check(&format!("hex/bytes={length}"), &tags); + } +} + +#[test] +fn mixed_values_and_repeated_codes_keep_order() { + check( + "mixed/repeated-codes", + &[ + Tag::s(0, "TEST"), + Tag::f(10, 1.25), + Tag::s(1, "first"), + Tag::f(10, -2.5), + Tag::i(70, -7), + Tag::s(1, "second"), + Tag::new(290, Value::Bool(true)), + Tag::new(310, Value::Hex("00FF80".into())), + Tag::i(160, i64::MAX), + Tag::s(1, ""), + ], + ); +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 5d6c343d0..7f6ad8f20 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -54,6 +54,7 @@ mod plot_layout_scale; mod plot_model_png_scale; mod plot_styles; mod region_roundtrip; +mod roundtrip; mod roundtrip_audit; mod skipped_records; mod sortents; diff --git a/crates/io/tests/it/roundtrip/cases.rs b/crates/io/tests/it/roundtrip/cases.rs new file mode 100644 index 000000000..a6031ec8f --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases.rs @@ -0,0 +1,308 @@ +//! Deterministic drawing inputs shared by DXF and DWG tests, independent of either writer. +//! Each family isolates a property so a known failure need not disable unrelated coverage. +//! Format/version limits and pending failures belong to the callers, not these generators. + +pub(super) mod blocks; +pub(super) mod complex; +pub(super) mod geometry; +pub(super) mod properties; + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{ + AnnoScale, Circle, Common, Drawing, EntityKind, HAlign, Layer, Line, Linetype, Lineweight, RawTag, Space, Text, TextStyle, Transparency, VAlign, +}; +use cadcraft_geom::Vec3; + +pub(super) struct Case { + pub name: String, + pub drawing: Drawing, +} + +pub(super) fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + d.layers.push(Layer { name: "Survey_Ä".into(), color: Color::Index(3), ..Layer::default() }); + d.linetypes.push(Linetype::simple("Dash_test", "Original synthetic dash pattern", &[2.0, -0.75, 0.0, -0.25])); + d.text_styles.push(TextStyle { name: "Notes".into(), ..TextStyle::default() }); + d +} + +pub(super) fn text() -> Text { + Text { + insert: Vec3::new(2.5, -3.75, 4.125), + align_pt: None, + height: 2.25, + value: "CAD Ø".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + } +} + +pub(super) fn entity_case(name: &str, common: Common, kind: EntityKind) -> Case { + let mut drawing = drawing(); + drawing.add(&Space::Model, common, kind).unwrap(); + Case { name: name.into(), drawing } +} + +pub(super) fn common_case(name: &str, common: Common) -> Case { + let mut drawing = drawing(); + for kind in [ + EntityKind::Line(Line { a: Vec3::new(-1.25, 2.5, 3.75), b: Vec3::new(8.125, -13.5, 21.25) }), + EntityKind::Circle(Circle { center: Vec3::new(-7.25, 11.5, 4.75), radius: 3.125 }), + EntityKind::Text(text()), + ] { + drawing.add(&Space::Model, common.clone(), kind).unwrap(); + } + Case { name: name.into(), drawing } +} + +pub(super) fn empty_and_defaults() -> Vec { + vec![Case { name: "empty-model".into(), drawing: drawing() }, common_case("common/default", Common::default())] +} + +pub(super) fn geometry() -> Vec { + let mut cases = Vec::new(); + for (name, a, b) in [ + ("zero-start", Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)), + ("zero-end", Vec3::new(-3.0, -2.0, -1.0), Vec3::ZERO), + ("small", Vec3::new(1e-9, -2e-9, 3e-9), Vec3::new(-4e-9, 5e-9, -6e-9)), + ("survey", Vec3::new(1e7 + 0.125, -2e7 - 0.25, 123.75), Vec3::new(1e7 + 0.5, -2e7 + 0.75, -42.5)), + ] { + for (direction, start, end) in [("forward", a, b), ("reverse", b, a)] { + cases.push(entity_case(&format!("line/{name}/{direction}"), Common::default(), EntityKind::Line(Line { a: start, b: end }))); + } + for radius in [1e-9, 0.125, 3.25, 1e8] { + cases.push(entity_case(&format!("circle/{name}/radius={radius}"), Common::default(), EntityKind::Circle(Circle { center: a, radius }))); + } + } + cases +} + +pub(super) fn layers() -> Vec { + ["0", "Survey_Ä"].map(|layer| common_case(&format!("common/layer={layer}"), Common { layer: layer.into(), ..Common::default() })).into() +} + +pub(super) fn layer_linetypes() -> Vec { + ["0", "Survey_Ä"] + .map(|layer| { + let mut case = common_case(&format!("common/layer={layer}/inherited-linetype"), Common { layer: layer.into(), ..Common::default() }); + case.drawing.layer_mut(layer).unwrap().linetype = "Dash_test".into(); + case + }) + .into() +} + +pub(super) fn colors() -> Vec { + [Color::ByLayer, Color::ByBlock] + .into_iter() + .chain((1..=255).map(Color::Index)) + .chain([Rgb(0, 0, 0), Rgb(255, 255, 255), Rgb(255, 0, 0), Rgb(0, 255, 0), Rgb(0, 0, 255), Rgb(17, 93, 241)].map(Color::True)) + .map(|color| common_case(&format!("common/color={color:?}"), Common { color, ..Common::default() })) + .collect() +} + +pub(super) fn linetypes() -> Vec { + ["ByLayer", "ByBlock", "Continuous", "Dash_test"] + .map(|linetype| common_case(&format!("common/linetype={linetype}"), Common { linetype: linetype.into(), ..Common::default() })) + .into() +} + +pub(super) fn lineweights() -> Vec { + [Lineweight::ByLayer, Lineweight::ByBlock, Lineweight::Default] + .into_iter() + .chain(Lineweight::STANDARD.map(Lineweight::Mm100)) + .map(|lineweight| common_case(&format!("common/lineweight={lineweight:?}"), Common { lineweight, ..Common::default() })) + .collect() +} + +pub(super) fn linetype_scales() -> Vec { + [0.125, 1.0, 3.25, 1e6].map(|ltscale| common_case(&format!("common/ltscale={ltscale}"), Common { ltscale, ..Common::default() })).into() +} + +pub(super) fn transparencies() -> Vec { + [Transparency::ByLayer, Transparency::ByBlock] + .into_iter() + .chain((0..=90).map(Transparency::Percent)) + .map(|transparency| common_case(&format!("common/transparency={transparency:?}"), Common { transparency, ..Common::default() })) + .collect() +} + +pub(super) fn visibility() -> Vec { + [false, true].map(|visible| common_case(&format!("common/visible={visible}"), Common { visible, ..Common::default() })).into() +} + +pub(super) fn thicknesses() -> Vec { + [-2.5, 0.0, 3.75].map(|thickness| common_case(&format!("common/thickness={thickness}"), Common { thickness, ..Common::default() })).into() +} + +pub(super) fn plot_styles() -> Vec { + ["ByLayer", "Review"] + .map(|plot_style| common_case(&format!("common/plot_style={plot_style}"), Common { plot_style: plot_style.into(), ..Common::default() })) + .into() +} + +pub(super) fn byblock_plot_style() -> Vec { + vec![common_case("common/plot_style=ByBlock", Common { plot_style: "ByBlock".into(), ..Common::default() })] +} + +pub(super) fn extrusions() -> Vec { + [Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 1.0, 0.0)] + .map(|extrusion| common_case(&format!("common/extrusion={extrusion:?}"), Common { extrusion, ..Common::default() })) + .into() +} + +pub(super) fn foreign_xdata() -> Vec { + let xdata = vec![ + RawTag { code: 1001, value: "ROUNDTRIP_TEST".into() }, + RawTag { code: 1000, value: "foreign text Ø 😀".into() }, + RawTag { code: 1070, value: "-17".into() }, + RawTag { code: 1040, value: "3.25".into() }, + RawTag { code: 1004, value: "00FF80".into() }, + ]; + vec![common_case("common/foreign-xdata", Common { xdata, ..Common::default() })] +} + +pub(super) fn common_interactions(plot_style: &str) -> Vec { + // Full local product; retain the original DXF case with a named plot style as well as + // an inherited-style counterpart, so a named-style loss does not hide other interactions. + let mut cases = Vec::new(); + for layer in ["0", "Survey_Ä"] { + for color in [Color::ByLayer, Color::ByBlock, Color::True(Rgb(21, 89, 233))] { + for visible in [false, true] { + let common = Common { + layer: layer.into(), + color, + visible, + linetype: "Dash_test".into(), + lineweight: Lineweight::Mm100(53), + ltscale: 2.75, + transparency: Transparency::Percent(37), + thickness: -1.25, + plot_style: plot_style.into(), + ..Common::default() + }; + cases.push(common_case(&format!("common/product/{layer}/{color:?}/visible={visible}/plot_style={plot_style}"), common)); + } + } + } + cases +} + +pub(super) fn text_values() -> Vec { + [("empty", ""), ("whitespace", " a\tb "), ("unicode", "Café 图号 😀 e\u{301}")] + .map(|(name, value)| entity_case(&format!("text/value/{name}"), Common::default(), EntityKind::Text(Text { value: value.into(), ..text() }))) + .into() +} + +pub(super) fn text_literal_escapes() -> Vec { + vec![entity_case("text/value/literal-escapes", Common::default(), EntityKind::Text(Text { value: r"\U+0041 \M+183A4 \P %%c".into(), ..text() }))] +} + +pub(super) fn text_heights() -> Vec { + [1e-9, 0.125, 2.25, 1e5] + .map(|height| entity_case(&format!("text/height={height}"), Common::default(), EntityKind::Text(Text { height, ..text() }))) + .into() +} + +pub(super) fn text_rotations() -> Vec { + [0.0, -0.7, std::f64::consts::FRAC_PI_2, 5.9] + .map(|rotation| entity_case(&format!("text/rotation={rotation}"), Common::default(), EntityKind::Text(Text { rotation, ..text() }))) + .into() +} + +pub(super) fn text_widths() -> Vec { + [0.125, 1.0, 2.75] + .map(|width_factor| { + entity_case(&format!("text/width_factor={width_factor}"), Common::default(), EntityKind::Text(Text { width_factor, ..text() })) + }) + .into() +} + +pub(super) fn text_obliques() -> Vec { + [-0.3, 0.0, 0.4] + .map(|oblique| entity_case(&format!("text/oblique={oblique}"), Common::default(), EntityKind::Text(Text { oblique, ..text() }))) + .into() +} + +pub(super) fn text_styles() -> Vec { + ["Standard", "Notes"] + .map(|style| entity_case(&format!("text/style={style}"), Common::default(), EntityKind::Text(Text { style: style.into(), ..text() }))) + .into() +} + +pub(super) fn annotation_scales() -> Vec { + [vec![], vec![AnnoScale::unit()], vec![AnnoScale::unit(), AnnoScale::new("1:50", 1.0, 50.0)]] + .map(|scales| { + entity_case(&format!("text/anno_scales={scales:?}"), Common { anno_scales: scales, ..Common::default() }, EntityKind::Text(text())) + }) + .into() +} + +pub(super) fn coplanar_text_alignment() -> Vec { + // Construct additional valid single-plane inputs before serialization. Keep the original + // differing-elevation cases separately: DXF can store both XYZ points, DWG TEXT has one Z. + let mut cases = text_alignment(true); + for case in &mut cases { + case.name.push_str("/coplanar"); + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + for handle in handles { + case.drawing.model.modify(handle, |e| { + if let EntityKind::Text(t) = &mut e.kind { + t.align_pt = t.align_pt.map(|p| Vec3::new(p.x, p.y, t.insert.z)); + } + }); + } + } + cases +} + +pub(super) fn noncoplanar_text_alignment() -> Vec { + text_alignment(true) + .into_iter() + .filter(|case| case.drawing.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.align_pt.is_some_and(|p| p.z != t.insert.z)))) + .collect() +} + +pub(super) fn text_alignment(with_point: bool) -> Vec { + let mut cases = Vec::new(); + let points = if with_point { vec![Some(Vec3::ZERO), Some(Vec3::new(17.25, -23.5, 6.75))] } else { vec![None] }; + for halign in [HAlign::Left, HAlign::Center, HAlign::Right, HAlign::Aligned, HAlign::Middle, HAlign::Fit] { + for valign in [VAlign::Baseline, VAlign::Bottom, VAlign::Middle, VAlign::Top] { + if matches!(halign, HAlign::Aligned | HAlign::Middle | HAlign::Fit) && valign != VAlign::Baseline { + continue; + } + for &align_pt in &points { + // The unused second point of left-baseline TEXT has a separate DXF read fixture. + if halign == HAlign::Left && valign == VAlign::Baseline && align_pt.is_some() { + continue; + } + let mut t = Text { halign, valign, align_pt, rotation: 0.7, oblique: -0.2, width_factor: 1.25, style: "Notes".into(), ..text() }; + // Build a consistent baseline before serialization. No writer-derived expected + // data: the font layout API determines a valid input; both points compare exactly. + if let Some(anchor) = align_pt + && !matches!(halign, HAlign::Aligned | HAlign::Fit) + { + let (h, v) = cadcraft_render::text_align(halign, valign); + let params = cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: Some(anchor.xy()), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: false, + upside_down: false, + }; + t.insert = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::Stroke, &t.value, ¶ms).to3(anchor.z); + } + cases.push(entity_case(&format!("text/alignment/{halign:?}/{valign:?}/point={align_pt:?}"), Common::default(), EntityKind::Text(t))); + } + } + } + cases +} diff --git a/crates/io/tests/it/roundtrip/cases/blocks.rs b/crates/io/tests/it/roundtrip/cases/blocks.rs new file mode 100644 index 000000000..a1af6218a --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/blocks.rs @@ -0,0 +1,234 @@ +//! Stored block graphs and references, never expanded geometry. Definitions have independent +//! handles; forward, repeated and cyclic references are small bounded synthetic graphs. + +use std::sync::Arc; + +use cadcraft_color::Color; +use cadcraft_doc::{Attrib, AttribModes, AttribProps, Block, Common, Entity, EntityKind, Insert, Line, Lineweight, Space, Text}; +use cadcraft_geom::Vec3; + +use super::{Case, drawing, text}; + +pub(crate) fn insert(block: &str) -> Insert { + Insert { + block: block.into(), + insert: Vec3::new(1.25, -2.5, 3.75), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + } +} + +fn base(name: &str, reference: Insert) -> Case { + let mut drawing = drawing(); + let mut leaf = Block::new("Z_Leaf"); + leaf.entities.push(Entity::new(drawing.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.0, 3.0, 4.0) }))); + drawing.blocks.insert(leaf.name.clone(), Arc::new(leaf)); + drawing.add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap(); + Case { name: name.into(), drawing } +} + +pub(crate) fn transformations() -> Vec { + let mut cases = Vec::new(); + for scale in [Vec3::new(1.0, 1.0, 1.0), Vec3::new(2.0, 0.5, 3.0), Vec3::new(-1.0, 2.0, 1.0)] { + for rotation in [0.0, 0.7, 5.9] { + cases.push(base(&format!("insert/scale={scale:?}/rotation={rotation}"), Insert { scale, rotation, ..insert("Z_Leaf") })); + } + } + cases +} + +pub(crate) fn arrays() -> Vec { + [(1, 3), (4, 1), (3, 2)] + .map(|(cols, rows)| { + base(&format!("minsert/{cols}x{rows}"), Insert { cols, rows, col_spacing: 5.25, row_spacing: -3.75, rotation: 0.7, ..insert("Z_Leaf") }) + }) + .into() +} + +pub(crate) fn metadata() -> Vec { + let mut case = base("block/metadata", insert("Z_Leaf")); + let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap()); + block.base = Vec3::new(4.0, -3.0, 2.0); + block.description = "Synthetic Ø 图".into(); + block.explodable = false; + block.units = 4; + vec![case] +} + +pub(crate) fn nested(cyclic: bool) -> Vec { + let mut case = base(&format!("block/nested/cyclic={cyclic}"), insert("A_Parent")); + let mut parent = Block::new("A_Parent"); + for x in [2.0, 5.0] { + parent + .entities + .push(Entity::new(case.drawing.new_handle(), EntityKind::Insert(Insert { insert: Vec3::new(x, 0.0, 0.0), ..insert("Z_Leaf") }))); + } + if cyclic { + parent.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::Insert(insert("A_Parent")))); + } + case.drawing.blocks.insert(parent.name.clone(), Arc::new(parent)); + vec![case] +} + +fn attribute(tag: &str) -> Attrib { + Attrib { + tag: tag.into(), + text: Text { insert: Vec3::ZERO, height: 1.0, value: tag.into(), ..text() }, + invisible: false, + constant: false, + prompt: String::new(), + props: AttribProps::default(), + multiline: None, + modes: AttribModes::default(), + } +} + +pub(crate) fn attributes(family: &str) -> Vec { + let mut cases = Vec::new(); + let variants = match family { + "flags" => 0..2, + "constant" => 2..4, + _ => 0..4, + }; + for variant in variants { + let mut definition = attribute("ID"); + definition.prompt = "Synthetic identifier".into(); + let mut value = attribute("ID"); + value.text.value = "Part Ø 图".into(); + match family { + "flags" | "constant" => { + definition.invisible = variant & 1 != 0; + definition.constant = variant & 2 != 0; + value.invisible = definition.invisible; + value.constant = definition.constant; + } + "modes" => { + definition.modes.verify = variant & 1 != 0; + definition.modes.preset = variant & 2 != 0; + } + "lock" => { + definition.modes.lock_position = variant & 1 != 0; + value.modes.lock_position = definition.modes.lock_position; + } + "overrides" => { + value.props = match variant { + 0 => AttribProps { layer: Some("Survey_Ä".into()), ..Default::default() }, + 1 => AttribProps { color: Some(Color::Index(3)), ..Default::default() }, + 2 => AttribProps { linetype: Some("Dash_test".into()), ..Default::default() }, + _ => AttribProps { lineweight: Some(Lineweight::Mm100(53)), ..Default::default() }, + }; + } + _ => panic!("unknown attribute family"), + } + let mut case = base(&format!("attributes/{family}/{variant}"), Insert { attribs: vec![value, attribute("SECOND")], ..insert("Z_Leaf") }); + let handle = case.drawing.new_handle(); + let second = case.drawing.new_handle(); + let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap()); + block.entities.push(Entity::new(handle, EntityKind::AttDef(definition))); + block.entities.push(Entity::new(second, EntityKind::AttDef(attribute("SECOND")))); + cases.push(case); + } + cases +} + +/// Repeated nested instances must retain their own ordered values, including empty text and +/// explicit zero overrides. Depth changes the stored owner chain, not attribute identity. +pub(crate) fn nested_attributes() -> Vec { + [1, 3] + .map(|depth| { + let mut case = attributes("flags").remove(0); + case.name = format!("block/nested-attributes/depth={depth}"); + let mut child = "Z_Leaf".to_owned(); + for level in 0..depth { + let mut parent = Block::new(&format!("Parent_{level}")); + for instance in 0..2 { + let mut value = attribute("ID"); + value.text.value = if instance == 0 { String::new() } else { format!("level={level}/instance={instance} Ø") }; + value.props = if instance == 0 { + AttribProps::default() + } else { + AttribProps { + layer: Some("Survey_Ä".into()), + color: Some(Color::ByBlock), + linetype: Some("Continuous".into()), + lineweight: Some(Lineweight::Mm100(0)), + } + }; + parent.entities.push(Entity::new( + case.drawing.new_handle(), + EntityKind::Insert(Insert { + insert: Vec3::new(f64::from(instance) * 10.0, f64::from(level), 0.0), + attribs: vec![value, attribute("SECOND")], + ..insert(&child) + }), + )); + } + // Each definition owns its own ATTDEF handles, even when its tags are repeated. + for tag in ["ID", "SECOND"] { + parent.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::AttDef(attribute(tag)))); + } + child.clone_from(&parent.name); + case.drawing.blocks.insert(parent.name.clone(), Arc::new(parent)); + } + let handle = case.drawing.model.iter().next().unwrap().handle; + case.drawing.model.modify(handle, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") }; + i.block = child; + }); + case + }) + .into() +} + +pub(crate) fn multiline_attributes() -> Vec { + // Keep the original origin witness and distinguish the fallback TEXT's point from MTEXT's. + [(Vec3::ZERO, Vec3::ZERO), (Vec3::new(12.0, 7.0, 0.0), Vec3::new(20.0, 8.0, 0.0))] + .into_iter() + .map(|(text_point, mtext_point)| { + let mut case = attributes("flags").remove(0); + case.name = format!("attributes/multiline/text-point={text_point:?}"); + let decorate = |a: &mut Attrib| { + a.text.insert = text_point; + a.text.value = "first\nsecond".into(); + a.multiline = Some(cadcraft_doc::MultilineAttrib { + mtext: cadcraft_doc::MText { + insert: mtext_point, + height: 1.0, + width: 6.0, + contents: "first\\Psecond".into(), + ..super::geometry::mtext() + }, + read_as: "first\nsecond".into(), + }); + }; + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + for handle in handles { + case.drawing.model.modify(handle, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") }; + decorate(i.attribs.first_mut().unwrap()); + }); + } + let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap()); + let handle = block.entities.iter().find(|e| matches!(&e.kind, EntityKind::AttDef(a) if a.tag == "ID")).unwrap().handle; + block.entities.modify(handle, |e| { + let EntityKind::AttDef(a) = &mut e.kind else { panic!("expected ATTDEF") }; + decorate(a); + }); + case + }) + .collect() +} + +pub(crate) fn unreferenced() -> Vec { + let mut case = base("block/unreferenced", insert("Z_Leaf")); + case.drawing.blocks.insert("Empty".into(), Arc::new(Block::new("Empty"))); + let mut unused = Block::new("Unused"); + unused.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 6.0, 7.0) }))); + case.drawing.blocks.insert(unused.name.clone(), Arc::new(unused)); + vec![case] +} diff --git a/crates/io/tests/it/roundtrip/cases/complex.rs b/crates/io/tests/it/roundtrip/cases/complex.rs new file mode 100644 index 000000000..ab13f4800 --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/complex.rs @@ -0,0 +1,131 @@ +//! Compound and reference-bearing entities. Geometry and cached values are authored explicitly; +//! unsupported versions are selected by callers rather than normalized after reading. + +use super::{Case, entity_case, geometry}; +use cadcraft_doc::{ + Block, Common, DimKind, DimMarks, Dimension, Entity, EntityKind, Helix, HelixConstrain, Line, MLeader, MLine, MLineJust, MLineStyle, Region, + Tolerance, +}; +use cadcraft_geom::{PolyVertex, Vec2, Vec3}; +use std::sync::Arc; + +pub(crate) fn helices() -> Vec { + [false, true] + .into_iter() + .flat_map(|ccw| { + [HelixConstrain::Height, HelixConstrain::Turns, HelixConstrain::TurnHeight].map(|constrain| { + entity_case( + &format!("helix/{ccw}/{constrain:?}"), + Common::default(), + EntityKind::Helix(Helix { + center: Vec3::new(1.25, -2.5, 3.75), + base_radius: 3.0, + top_radius: 1.5, + height: -7.5, + turns: 2.5, + start_angle: 0.0, + ccw, + constrain, + }), + ) + }) + }) + .collect() +} + +pub(crate) fn mlines() -> Vec { + [false, true] + .into_iter() + .flat_map(|closed| { + [MLineJust::Top, MLineJust::Zero, MLineJust::Bottom].map(|just| { + entity_case( + &format!("mline/{closed}/{just:?}"), + Common::default(), + EntityKind::MLine(MLine::new(&MLineStyle::default(), &[Vec2::ZERO, Vec2::new(5.0, 0.0), Vec2::new(5.0, 4.0)], closed, just, 2.5)), + ) + }) + }) + .collect() +} + +pub(crate) fn mleaders() -> Vec { + [false, true] + .map(|spline| { + entity_case( + &format!("mleader/spline={spline}"), + Common::default(), + EntityKind::MLeader(MLeader { + leaders: vec![vec![Vec3::ZERO, Vec3::new(2.0, 3.0, 0.0)], vec![Vec3::new(1.0, -2.0, 0.0)]], + landing: Vec3::new(5.0, 4.0, 0.0), + dogleg: 0.75, + text: Some(cadcraft_doc::MText { insert: Vec3::new(5.9, 4.0, 0.0), rotation: 0.0, ..geometry::mtext() }), + style: "Standard".into(), + arrow_size: 0.3, + spline, + block: None, + }), + ) + }) + .into() +} + +pub(crate) fn regions() -> Vec { + vec![entity_case( + "region/bulged-loop", + Common::default(), + EntityKind::Region(Region::from_loop(vec![ + PolyVertex::with_bulge(Vec2::ZERO, 0.5), + PolyVertex::new(Vec2::new(5.0, 0.0)), + PolyVertex::new(Vec2::new(5.0, 4.0)), + PolyVertex::new(Vec2::new(0.0, 4.0)), + ])), + )] +} + +pub(crate) fn tolerances() -> Vec { + vec![entity_case( + "tolerance/frame", + Common::default(), + EntityKind::Tolerance(Tolerance { + insert: Vec3::new(1.25, -2.5, 3.75), + dir: Vec3::new(0.0, 1.0, 0.0), + style: "ISO-25".into(), + text: "0.125%%vA%%vB".into(), + }), + )] +} + +pub(crate) fn dimensions() -> Vec { + [DimKind::Linear { rotation: 0.0 }, DimKind::Aligned] + .map(|kind| { + let mut case = entity_case( + &format!("dimension/{kind:?}"), + Common::default(), + EntityKind::Dimension(Dimension { + kind, + defpt: Vec3::new(0.0, 3.0, 0.0), + text_mid: Vec3::new(5.0, 4.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: "<> mm".into(), + style: "ISO-25".into(), + measurement: 10.0, + text_rotation: 0.0, + user_text_pos: true, + block: Some("*D42".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: DimMarks::default(), + }), + ); + let mut block = Block::new("*D42"); + block + .entities + .push(Entity::new(case.drawing.new_handle(), EntityKind::Line(Line { a: Vec3::new(0.0, 3.0, 0.0), b: Vec3::new(10.0, 3.0, 0.0) }))); + case.drawing.blocks.insert(block.name.clone(), Arc::new(block)); + case + }) + .into() +} diff --git a/crates/io/tests/it/roundtrip/cases/geometry.rs b/crates/io/tests/it/roundtrip/cases/geometry.rs new file mode 100644 index 000000000..35a2cc9dc --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/geometry.rs @@ -0,0 +1,301 @@ +//! Additional entity families with authored geometry and optional fields. No serializer output +//! is used to build an input. New failures are isolated by the format-specific test registrations. + +use cadcraft_doc::{ + Arc, Common, Ellipse, EntityKind, Face3d, Hatch, HatchLoop, Image, Leader, LwPolyline, MText, MTextColumnType, MTextColumns, Point, Polyline3d, + RayLine, Solid, Table, TableCell, Wipeout, +}; +use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3}; + +use super::{Case, entity_case}; + +fn case(name: &str, kind: EntityKind) -> Case { + entity_case(name, Common::default(), kind) +} + +pub(crate) fn arcs() -> Vec { + let mut cases = Vec::new(); + for radius in [1e-9, 0.125, 3.25, 1e8] { + for (start, end) in [(0.0, std::f64::consts::FRAC_PI_2), (0.7, 5.9), (5.9, 0.7)] { + cases.push(case( + &format!("arc/r={radius}/{start}..{end}"), + EntityKind::Arc(Arc { center: Vec3::new(1.25, -2.5, 3.75), radius, start, end }), + )); + } + } + cases +} + +pub(crate) fn ellipses() -> Vec { + [0.125, 0.5, 1.0] + .into_iter() + .flat_map(|ratio| { + [(0.0, std::f64::consts::TAU), (0.7, 5.9), (5.9, 0.7)].map(|(start, end)| { + case( + &format!("ellipse/{ratio}/{start}..{end}"), + EntityKind::Ellipse(Ellipse { center: Vec3::new(1.25, -2.5, 3.75), major: Vec3::new(3.0, 4.0, 0.0), ratio, start, end }), + ) + }) + }) + .collect() +} + +pub(crate) fn polylines(variable_width: bool) -> Vec { + let mut cases = Vec::new(); + for closed in [false, true] { + for plinegen in [false, true] { + for bulge in [-1.0, 0.0, 0.5, 1.0] { + cases.push(case( + &format!("lwpolyline/closed={closed}/plinegen={plinegen}/bulge={bulge}/widths={variable_width}"), + EntityKind::LwPolyline(LwPolyline { + vertices: vec![ + PolyVertex { + p: Vec2::new(1.25, -2.5), + bulge, + start_width: if variable_width { 0.125 } else { 0.0 }, + end_width: if variable_width { 0.75 } else { 0.0 }, + }, + PolyVertex::with_bulge(Vec2::new(5.0, 3.0), -bulge), + PolyVertex::new(Vec2::new(-2.0, 4.0)), + ], + closed, + plinegen, + const_width: if variable_width { 0.0 } else { 0.25 }, + elevation: -3.75, + }), + )); + } + } + } + cases +} + +pub(crate) fn polylines_3d() -> Vec { + [false, true] + .map(|closed| { + case( + &format!("polyline3d/closed={closed}"), + EntityKind::Polyline3d(Polyline3d { + points: vec![Vec3::new(1.25, -2.5, 3.75), Vec3::new(5.0, 6.0, -7.0), Vec3::new(-3.0, 4.0, 9.0)], + closed, + }), + ) + }) + .into() +} + +pub(crate) fn splines(rational: bool) -> Vec { + [1, 2, 3] + .map(|degree| { + let mut spline = Spline::from_control(vec![Vec2::ZERO, Vec2::new(2.0, 3.0), Vec2::new(5.0, -1.0), Vec2::new(8.0, 2.0)], degree); + if rational { + spline.weights = vec![1.0, 0.5, 2.0, 1.0]; + } + case(&format!("spline/degree={degree}/rational={rational}"), EntityKind::Spline(spline)) + }) + .into() +} + +pub(crate) fn spline_fit(closed: bool) -> Vec { + vec![case( + &format!("spline/fit/closed={closed}"), + EntityKind::Spline(Spline::from_fit(&[Vec2::ZERO, Vec2::new(2.0, 3.0), Vec2::new(5.0, -1.0), Vec2::new(8.0, 2.0)], closed)), + )] +} + +pub(crate) fn points_and_rays() -> Vec { + let base = Vec3::new(1.25, -2.5, 3.75); + vec![ + case("point", EntityKind::Point(Point { p: base, angle: 0.0 })), + case("ray", EntityKind::Ray(RayLine { base, dir: Vec3::new(0.0, 0.0, 1.0) })), + case("xline", EntityKind::XLine(RayLine { base, dir: Vec3::new(0.0, 1.0, 0.0) })), + ] +} + +pub(crate) fn faces() -> Vec { + let corners = [Vec3::new(1.0, 2.0, 3.0), Vec3::new(4.0, 2.0, 3.0), Vec3::new(1.0, 5.0, 3.0), Vec3::new(4.0, 5.0, 3.0)]; + let mut cases = vec![case("solid", EntityKind::Solid(Solid { corners })), case("trace", EntityKind::Trace(Solid { corners }))]; + for hidden_edges in 0..16 { + cases.push(case(&format!("face3d/hidden={hidden_edges}"), EntityKind::Face3d(Face3d { corners, hidden_edges }))); + } + cases +} + +pub(crate) fn mtext() -> MText { + MText { + insert: Vec3::new(1.25, -2.5, 3.75), + height: 2.25, + width: 12.5, + attach: 1, + rotation: 0.0, + style: "Notes".into(), + contents: "first\\Psecond Ø 图 😀".into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + } +} + +pub(crate) fn mtext_placement() -> Vec { + (1..=9) + .flat_map(|attach| { + [0.0, 0.7, 5.9] + .map(|rotation| case(&format!("mtext/attach={attach}/rotation={rotation}"), EntityKind::MText(MText { attach, rotation, ..mtext() }))) + }) + .collect() +} + +pub(crate) fn mtext_spacing() -> Vec { + [0.25, 1.0, 1.75, 4.0] + .into_iter() + .flat_map(|line_spacing| { + [false, true].map(|line_spacing_exact| { + case( + &format!("mtext/spacing={line_spacing}/exact={line_spacing_exact}"), + EntityKind::MText(MText { line_spacing, line_spacing_exact, ..mtext() }), + ) + }) + }) + .collect() +} + +pub(crate) fn mtext_contents() -> Vec { + ["", " a\tb ", "{\\H0.7x;\\S1/2;}\\Pnext", "Unicode Ø 图 😀 e\u{301}"] + .map(|contents| case(&format!("mtext/contents={contents:?}"), EntityKind::MText(MText { contents: contents.into(), ..mtext() }))) + .into() +} + +pub(crate) fn mtext_carets() -> Vec { + vec![case("mtext/caret-space", EntityKind::MText(MText { contents: "\\pxi-3,l3,t3;1.\tA\\P2.\tB ^ C".into(), ..mtext() }))] +} + +pub(crate) fn mtext_long() -> Vec { + [249, 250, 251, 255, 256, 1024] + .map(|n| case(&format!("mtext/long={n}"), EntityKind::MText(MText { contents: "Ø😀".repeat(n), ..mtext() }))) + .into() +} + +pub(crate) fn mtext_columns() -> Vec { + [ + MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 0.5, height: 7.5, ..Default::default() }, + MTextColumns { + kind: MTextColumnType::Dynamic, + count: 3, + gutter: 0.5, + height: 5.0, + heights: vec![5.0, 3.0, 4.0], + flow_reversed: true, + ..Default::default() + }, + MTextColumns { kind: MTextColumnType::Dynamic, count: 1, gutter: 0.5, height: 4.0, auto_height: true, ..Default::default() }, + ] + .into_iter() + .map(|columns| case(&format!("mtext/columns={columns:?}"), EntityKind::MText(MText { columns: Some(columns), ..mtext() }))) + .collect() +} + +pub(crate) fn leaders(elevated: bool) -> Vec { + [false, true] + .into_iter() + .flat_map(|arrow| { + [false, true].map(|spline| { + case( + &format!("leader/arrow={arrow}/spline={spline}/elevated={elevated}"), + EntityKind::Leader(Leader { + vertices: vec![ + Vec3::ZERO, + Vec3::new(2.0, 3.0, if elevated { 4.0 } else { 0.0 }), + Vec3::new(5.0, 6.0, if elevated { 7.0 } else { 0.0 }), + ], + arrow, + spline, + style: "ISO-25".into(), + }), + ) + }) + }) + .collect() +} + +pub(crate) fn hatches() -> Vec { + let vertices = [Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(0.0, 8.0)].map(PolyVertex::new).to_vec(); + (0..=2) + .map(|style| { + case( + &format!("hatch/solid/style={style}"), + EntityKind::Hatch(Hatch { + pattern: "SOLID".into(), + solid: true, + loops: vec![HatchLoop { vertices: vertices.clone(), outer: true, sources: Vec::new() }], + scale: 1.0, + angle: 0.0, + associative: false, + style, + elevation: 3.75, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: Vec::new(), + }), + ) + }) + .collect() +} + +pub(crate) fn tables() -> Vec { + vec![case( + "table/cells", + EntityKind::Table(Table { + insert: Vec3::new(1.25, -2.5, 0.0), + col_widths: vec![5.0, 7.5], + row_heights: vec![2.0, 3.0], + cells: vec![ + vec![TableCell { text: "Ø".into(), ..Default::default() }, TableCell { text: "图".into(), ..Default::default() }], + vec![TableCell::default(), TableCell { text: "3.25".into(), ..Default::default() }], + ], + style: "Standard".into(), + text_height: 1.25, + title: false, + header: false, + row_styles: Vec::new(), + breaks: None, + data_link: None, + }), + )] +} + +pub(crate) fn images() -> Vec { + vec![case( + "image/reference-only", + EntityKind::Image(Image { + path: "synthetic-not-loaded.png".into(), + name: "Synthetic".into(), + insert: Vec3::new(1.0, 2.0, 3.0), + u: Vec3::new(0.5, 0.0, 0.0), + v: Vec3::new(0.0, 0.25, 0.0), + size: Vec2::new(32.0, 16.0), + clip: vec![Vec2::new(2.0, 3.0), Vec2::new(20.0, 12.0)], + clipping: true, + brightness: 37, + contrast: 61, + fade: 25, + ..Image::default() + }), + )] +} + +pub(crate) fn wipeouts(explicit_closure: bool) -> Vec { + [false, true] + .into_iter() + .map(|reversed| { + let mut boundary = vec![Vec2::new(1.0, 2.0), Vec2::new(7.0, 3.0), Vec2::new(4.0, 9.0)]; + if reversed { + boundary.reverse(); + } + if explicit_closure { + boundary.push(boundary[0]); + } + case(&format!("wipeout/triangle/explicit-closure={explicit_closure}/reversed={reversed}"), EntityKind::Wipeout(Wipeout { boundary })) + }) + .collect() +} diff --git a/crates/io/tests/it/roundtrip/cases/properties.rs b/crates/io/tests/it/roundtrip/cases/properties.rs new file mode 100644 index 000000000..eab95e8ac --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/properties.rs @@ -0,0 +1,351 @@ +//! Property, symbol-table and identity inputs. Values are authored before serialization; +//! each family can fail independently without suppressing unrelated preservation checks. + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, DashElement, Entity, EntityKind, HAlign, Handle, Layer, Line, Lineweight, RawTag, Text, TextStyle, Transparency, VAlign}; +use cadcraft_geom::{Vec2, Vec3}; + +use super::{Case, common_case, drawing, entity_case, text}; + +pub(crate) fn mixed_case_references(kind: &str) -> Vec { + let mut common = Common::default(); + let mut t = text(); + match kind { + "layer" => common.layer = "sURVEY_Ä".into(), + "linetype" => common.linetype = "dASH_TEST".into(), + "style" => t.style = "nOTES".into(), + _ => panic!("unknown reference family"), + } + vec![entity_case(&format!("reference/{kind}/mixed-case"), common, EntityKind::Text(t))] +} + +fn layer_case(name: &str, layer: Layer) -> Case { + let mut case = common_case(name, Common { layer: "Survey_Ä".into(), ..Common::default() }); + *case.drawing.layer_mut("Survey_Ä").unwrap() = Layer { name: "Survey_Ä".into(), ..layer }; + case +} + +pub(crate) fn layer_flags() -> Vec { + (0..32) + .map(|bits| { + layer_case( + &format!("layer/flags={bits}"), + Layer { + on: bits & 1 == 0, + frozen: bits & 2 != 0, + locked: bits & 4 != 0, + plot: bits & 8 == 0, + vp_freeze_new: bits & 16 != 0, + ..Layer::default() + }, + ) + }) + .collect() +} + +pub(crate) fn layer_appearance() -> Vec { + [Color::Index(1), Color::Index(255), Color::True(Rgb(17, 93, 241))] + .into_iter() + .flat_map(|color| { + [0, 37, 90].map(|transparency| { + layer_case( + &format!("layer/{color:?}/alpha={transparency}"), + Layer { + color, + transparency, + linetype: "Dash_test".into(), + lineweight: Lineweight::Mm100(53), + description: "Synthetic survey Ø 图".into(), + ..Layer::default() + }, + ) + }) + }) + .collect() +} + +pub(crate) fn layer_plot_styles() -> Vec { + ["Normal", "Review"] + .map(|plot_style| layer_case(&format!("layer/plot-style={plot_style}"), Layer { plot_style: plot_style.into(), ..Layer::default() })) + .into() +} + +fn style_case(name: &str, style: TextStyle) -> Case { + let mut case = entity_case(name, Common::default(), EntityKind::Text(Text { style: "Notes".into(), ..text() })); + *case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap() = TextStyle { name: "Notes".into(), ..style }; + case +} + +pub(crate) fn text_style_metrics() -> Vec { + [0.0, 0.125, 3.5] + .into_iter() + .flat_map(|height| { + [0.5, 1.0, 2.75].map(|width_factor| { + style_case( + &format!("style/height={height}/width={width_factor}"), + TextStyle { height, width_factor, oblique: std::f64::consts::FRAC_PI_4, ..TextStyle::default() }, + ) + }) + }) + .collect() +} + +pub(crate) fn text_style_flags() -> Vec { + (0..16) + .map(|bits| { + style_case( + &format!("style/flags={bits}"), + TextStyle { + backwards: bits & 1 != 0, + upside_down: bits & 2 != 0, + vertical: bits & 4 != 0, + annotative: bits & 8 != 0, + ..TextStyle::default() + }, + ) + }) + .collect() +} + +pub(crate) fn text_style_fonts() -> Vec { + // Names only; no font files are read or required. + [("CADCraft Stroke", ""), ("SyntheticSans.ttf", ""), ("synthetic.shx", "synthetic-big.shx")] + .map(|(font, big_font)| { + style_case( + &format!("style/font={font}/big={big_font}"), + TextStyle { font: font.into(), big_font: big_font.into(), ..TextStyle::default() }, + ) + }) + .into() +} + +pub(crate) fn complex_linetypes(shape: bool) -> Vec { + [false, true] + .map(|absolute| { + let mut case = + common_case(&format!("linetype/shape={shape}/absolute={absolute}"), Common { linetype: "Dash_test".into(), ..Common::default() }); + if shape { + case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap().font = "synthetic.shx".into(); + } + let l = case.drawing.linetypes.iter_mut().find(|l| l.name == "Dash_test").unwrap(); + l.pattern.push(DashElement { + text: (!shape).then(|| "GAS Ø".into()), + shape: shape.then_some(7), + style: Some("Notes".into()), + scale: 0.75, + rotation: std::f64::consts::FRAC_PI_2, + offset: Vec2::new(0.125, -0.25), + absolute, + ..DashElement::dash(-1.0) + }); + case + }) + .into() +} + +fn xdata_case(name: &str, tags: Vec<(i32, String)>) -> Case { + common_case(name, Common { xdata: tags.into_iter().map(|(code, value)| RawTag { code, value }).collect(), ..Common::default() }) +} + +pub(crate) fn xdata_lists() -> Vec { + vec![xdata_case( + "xdata/nested-and-multiple-apps", + [ + (1001, "ROUNDTRIP_A"), + (1002, "{"), + (1000, "outer"), + (1002, "{"), + (1070, "-32768"), + (1002, "}"), + (1071, "2147483647"), + (1002, "}"), + (1001, "ROUNDTRIP_B"), + (1000, ""), + (1000, "repeated Ø"), + (1004, "00FF80"), + (1041, "0.125"), + (1042, "2.75"), + ] + .map(|(c, v)| (c, v.into())) + .into(), + )] +} + +pub(crate) fn xdata_points() -> Vec { + (1010..=1013) + .map(|code| { + xdata_case( + &format!("xdata/point={code}"), + vec![(1001, "ROUNDTRIP_POINTS".into()), (code, "1.25".into()), (code + 10, "-2.5".into()), (code + 20, "3.75".into())], + ) + }) + .collect() +} + +pub(crate) fn xdata_handles() -> Vec { + let mut case = common_case("xdata/handle-reference", Common::default()); + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + for &handle in &handles { + case.drawing.model.modify(handle, |e| { + e.common.xdata = + vec![RawTag { code: 1001, value: "ROUNDTRIP_HANDLES".into() }, RawTag { code: 1005, value: handles.last().unwrap().hex() }] + }); + } + vec![case] +} + +pub(crate) fn handles_and_order() -> Vec { + sparse_handles(&[0x1_0000, 0x1_0000_0000]) +} + +pub(crate) fn handles_near_integer_limits() -> Vec { + // Cross f64's exact-integer limit and signed i64's limit; leave room below u64::MAX + // for generated table/object handles rather than testing allocator exhaustion here. + sparse_handles(&[(1 << 53) - 0x80, (1 << 63) - 0x80, u64::MAX - 0x1_0000]) +} + +pub(crate) fn exhausted_handles() -> Case { + // The seed and writer's additional 0x1000 reservation leave no allocation room. + sparse_handles(&[u64::MAX - 0x2000]).remove(0) +} + +fn sparse_handles(bases: &[u64]) -> Vec { + bases + .iter() + .map(|&base| { + let mut drawing = drawing(); + for index in [5, 1, 9, 3, 2, 8, 4, 7, 6] { + let kind = if index % 2 == 0 { + EntityKind::Text(Text { value: format!("entity-{index}"), ..text() }) + } else { + EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(index as f64, 2.0, 3.0) }) + }; + drawing.model.push(Entity::new(Handle(base + index * 17), kind)); + } + drawing.handseed = base + 0x1000; + Case { name: format!("handles/sparse/base={base:X}"), drawing } + }) + .collect() +} + +pub(crate) fn degenerate_geometry() -> Vec { + [Vec3::ZERO, Vec3::new(1e9, -1e9, 1e-9)] + .map(|p| entity_case(&format!("line/coincident={p:?}"), Common::default(), EntityKind::Line(Line { a: p, b: p }))) + .into() +} + +pub(crate) fn text_interactions() -> Vec { + let mut cases = Vec::new(); + for rotation in [-0.7, 0.0, 5.9] { + for width_factor in [0.125, 2.75] { + for oblique in [-0.3, 0.4] { + cases.push(entity_case( + &format!("text/product/{rotation}/{width_factor}/{oblique}"), + Common::default(), + EntityKind::Text(Text { + rotation, + width_factor, + oblique, + height: 0.125, + style: "Notes".into(), + value: " Ø 图 😀 ".into(), + ..text() + }), + )); + } + } + } + cases +} + +/// Four independently overridden properties on a deliberately non-default layer. The 16 masks +/// cover inheritance/override pairs without multiplying by every possible property value. +pub(crate) fn layer_inheritance_overrides() -> Vec { + (0..16) + .map(|mask| { + let common = Common { + layer: "Survey_Ä".into(), + color: if mask & 1 == 0 { Color::ByLayer } else { Color::True(Rgb(0, 0, 0)) }, + transparency: if mask & 2 == 0 { Transparency::ByLayer } else { Transparency::Percent(0) }, + lineweight: if mask & 4 == 0 { Lineweight::ByLayer } else { Lineweight::Mm100(0) }, + linetype: if mask & 8 == 0 { "ByLayer" } else { "Continuous" }.into(), + ..Common::default() + }; + let mut case = common_case(&format!("layer/inheritance-overrides={mask:04b}"), common); + *case.drawing.layer_mut("Survey_Ä").unwrap() = Layer { + name: "Survey_Ä".into(), + color: Color::Index(3), + transparency: 67, + lineweight: Lineweight::Mm100(53), + linetype: "Dash_test".into(), + ..Layer::default() + }; + case + }) + .collect() +} + +/// Mirrored styles affect baseline placement; their metrics must not overwrite the entity's +/// own height/width/oblique. Font layout constructs consistent inputs, never expected output. +pub(crate) fn alignment_style_interactions() -> Vec { + let mut cases = Vec::new(); + for (halign, valign) in [ + (HAlign::Left, VAlign::Top), + (HAlign::Center, VAlign::Middle), + (HAlign::Right, VAlign::Baseline), + (HAlign::Aligned, VAlign::Baseline), + (HAlign::Fit, VAlign::Baseline), + ] { + for flags in 0..4 { + let style = TextStyle { + name: "Notes".into(), + height: 7.0, + width_factor: 2.0, + oblique: 0.25, + backwards: flags & 1 != 0, + upside_down: flags & 2 != 0, + ..TextStyle::default() + }; + let mut t = Text { + insert: Vec3::new(-5.0, 3.0, 0.0), + align_pt: Some(Vec3::ZERO), + style: style.name.clone(), + halign, + valign, + height: 1.5, + width_factor: 0.75, + oblique: -0.125, + rotation: 0.7, + ..text() + }; + if !matches!(halign, HAlign::Aligned | HAlign::Fit) { + let (h, v) = cadcraft_render::text_align(halign, valign); + let params = cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: Some(Vec2::ZERO), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: style.backwards, + upside_down: style.upside_down, + }; + t.insert = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::Stroke, &t.value, ¶ms).to3(0.0); + } + let mut case = entity_case(&format!("text/alignment-style/{halign:?}/{valign:?}/flags={flags}"), Common::default(), EntityKind::Text(t)); + *case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap() = style; + cases.push(case); + } + } + cases +} + +pub(crate) fn long_text() -> Vec { + [254, 255, 256, 1024] + .map(|length| { + entity_case(&format!("text/length={length}"), Common::default(), EntityKind::Text(Text { value: "Ø".repeat(length), ..text() })) + }) + .into() +} diff --git a/crates/io/tests/it/roundtrip/collections.rs b/crates/io/tests/it/roundtrip/collections.rs new file mode 100644 index 000000000..9b9acf36b --- /dev/null +++ b/crates/io/tests/it/roundtrip/collections.rs @@ -0,0 +1,263 @@ +//! Drawing-collection contracts beyond model space. Assertions cover explicitly authored data, +//! not generated timestamps/extents, allocation seeds or extra implementation-owned objects. +//! Named tables are compared by identity; paper entities retain order and handles. These are +//! focused collection contracts, not a claim that every Drawing/session field is persistent. + +use super::{ + cases::{self, Case}, + compare, +}; +use cadcraft_color::Color; +use cadcraft_doc::{ + Common, Drawing, EntityKind, Group, HVal, Layer, LayerState, Layout, Line, Linetype, NamedView, Space, TextStyle, Ucs, Viewport, VportConfig, + VportTile, +}; +use cadcraft_geom::{Vec2, Vec3}; + +pub(super) fn foreign_records() -> Case { + use cadcraft_doc::{Handle, RawTag, foreign::ForeignObject}; + let mut drawing = cases::drawing(); + let foreign = std::sync::Arc::make_mut(&mut drawing.foreign); + foreign.dictionary.push(("SYNTHETIC_PAYLOAD".into(), Handle(0xA000))); + foreign.targets.insert(Handle(0xA001), ("NOD".into(), String::new())); + foreign.objects.push(ForeignObject { + handle: Handle(0xA000), + kind: "XRECORD".into(), + tags: [ + (5, "A000"), + (330, "A001"), + (100, "AcDbXrecord"), + (280, "1"), + (1, "Original Ø payload"), + (90, "-7"), + (310, "00FF80"), + (10, "1.25"), + (20, "-2.5"), + (30, "3.75"), + ] + .map(|(code, value)| RawTag { code, value: value.into() }) + .into(), + }); + drawing.handseed = 0xA100; + Case { name: "drawing/foreign-xrecord".into(), drawing } +} + +pub(super) fn assert_foreign_records(a: &Drawing, b: &Drawing, context: &str) { + // Rereads may add implementation-owned dictionaries; this case owns one explicit key. + let key = "SYNTHETIC_PAYLOAD"; + let (_, handle) = a.foreign.dictionary.iter().find(|(name, _)| name == key).expect("authored dictionary entry"); + let (_, target) = b.foreign.dictionary.iter().find(|(name, _)| name == key).unwrap_or_else(|| panic!("{context}.dictionary[{key}]: missing key")); + let source = a.foreign.objects.iter().find(|o| o.handle == *handle).unwrap(); + let back = b.foreign.objects.iter().find(|o| o.handle == *target).expect("dictionary target must resolve"); + assert_eq!(back.kind, source.kind, "{context}.object.kind"); + let owner = back.tags.iter().find(|t| t.code == 330).and_then(|t| cadcraft_doc::Handle::parse_hex(&t.value)).expect("XRECORD owner"); + assert_eq!(b.foreign.targets.get(&owner), Some(&("NOD".into(), String::new())), "{context}.object.owner"); + // Foreign's documented contract reallocates object handles and remaps references. + // Identity here is the NOD key and its resolved target, not the numeric allocation. + // Preserve every payload tag (including its order) after the subclass marker. + fn payload(tags: &[cadcraft_doc::RawTag]) -> &[cadcraft_doc::RawTag] { + let start = + tags.iter().position(|t| t.code == 100 && t.value == "AcDbXrecord").unwrap_or_else(|| panic!("missing XRECORD subclass: {tags:?}")); + &tags[start + 1..] + } + assert_eq!(payload(&back.tags), payload(&source.tags), "{context}.object.payload"); +} + +const HEADER_KEYS: &[&str] = &["LTSCALE", "CELTSCALE", "LUPREC", "AUPREC", "INSBASE", "TEXTSIZE", "CLAYER", "TEXTSTYLE"]; + +pub(super) fn headers() -> Case { + let mut drawing = cases::drawing(); + drawing.header.set_f64("LTSCALE", 2.75); + drawing.header.set_f64("CELTSCALE", 0.125); + drawing.header.set_i64("LUPREC", 7); + drawing.header.set_i64("AUPREC", 3); + drawing.header.set("INSBASE", HVal::Point(Vec3::new(1.25, -2.5, 3.75))); + drawing.header.set_f64("TEXTSIZE", 3.25); + + Case { name: "drawing/header-variables".into(), drawing } +} + +pub(super) fn named_header_reference(key: &str) -> Case { + let mut case = headers(); + case.name = format!("drawing/header-reference/{key}"); + case.drawing.header.set_str( + key, + match key { + "CLAYER" => "Survey_Ä", + "TEXTSTYLE" => "Notes", + _ => panic!("unknown header reference"), + }, + ); + case +} + +pub(super) fn assert_headers(a: &Drawing, b: &Drawing, context: &str) { + for key in HEADER_KEYS { + assert_eq!(b.header.get(key), a.header.get(key), "{context}.header.{key}"); + } +} + +pub(super) fn symbol_tables() -> Case { + let mut drawing = cases::drawing(); + drawing.layers.push(Layer { + name: "UnusedLayer".into(), + color: Color::Index(5), + locked: true, + description: "Unused Ø".into(), + ..Layer::default() + }); + drawing.linetypes.push(Linetype::simple("UnusedDash", "Unused synthetic", &[3.0, -0.5, 0.0, -0.25])); + drawing.text_styles.push(TextStyle { name: "UnusedStyle".into(), font: "SyntheticSans.ttf".into(), width_factor: 1.25, ..TextStyle::default() }); + Case { name: "drawing/unreferenced-tables".into(), drawing } +} + +pub(super) fn assert_symbol_tables(a: &Drawing, b: &Drawing, context: &str) { + for layer in &a.layers { + assert_eq!(b.layer(&layer.name), Some(layer), "{context}.layer[{}]", layer.name); + } + for linetype in &a.linetypes { + assert_eq!(b.linetype(&linetype.name), Some(linetype), "{context}.linetype[{}]", linetype.name); + } + for style in &a.text_styles { + assert_eq!(b.text_style(&style.name), Some(style), "{context}.style[{}]", style.name); + } +} + +pub(super) fn views_and_ucs() -> Case { + let mut drawing = cases::drawing(); + drawing.views.push(NamedView { name: "SurveyView".into(), center: Vec2::new(1.25, -2.5), height: 25.0, width: 40.0, ..NamedView::default() }); + drawing.ucss.push(Ucs { + name: "RotatedUCS".into(), + origin: Vec3::new(3.0, 4.0, 5.0), + x_axis: Vec3::new(0.0, 1.0, 0.0), + y_axis: Vec3::new(-1.0, 0.0, 0.0), + }); + Case { name: "drawing/views-and-ucs".into(), drawing } +} + +pub(super) fn assert_views_and_ucs(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.views, a.views, "{context}.views"); + assert_eq!(b.ucss, a.ucss, "{context}.ucss"); +} + +pub(super) fn vports() -> Case { + let mut drawing = cases::drawing(); + drawing.vports.push(VportConfig { + name: "Split".into(), + tiles: vec![ + VportTile { min: Vec2::ZERO, max: Vec2::new(0.5, 1.0), center: Vec2::new(2.0, 3.0), height: 20.0, aspect: 0.75 }, + VportTile { min: Vec2::new(0.5, 0.0), max: Vec2::new(1.0, 1.0), center: Vec2::new(7.0, 8.0), height: 30.0, aspect: 0.75 }, + ], + }); + Case { name: "drawing/tiled-viewports".into(), drawing } +} + +pub(super) fn assert_vports(a: &Drawing, b: &Drawing, context: &str) { + for viewport in &a.vports { + assert_eq!(b.vports.iter().find(|v| v.name == viewport.name), Some(viewport), "{context}.vport[{}]", viewport.name); + } +} + +pub(super) fn groups() -> Case { + let mut case = cases::common_case("drawing/groups", Common::default()); + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + case.drawing.groups.push(Group { + name: "Synthetic".into(), + description: "Ordered overlapping membership Ø".into(), + selectable: true, + members: vec![handles[2], handles[0]], + }); + case.drawing.groups.push(Group { name: "Other".into(), description: String::new(), selectable: false, members: vec![handles[1], handles[2]] }); + case +} + +pub(super) fn assert_groups(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.groups.len(), a.groups.len(), "{context}.groups.len"); + for group in &a.groups { + assert_eq!(b.groups.iter().find(|g| g.name == group.name), Some(group), "{context}.group[{}]", group.name); + } +} + +pub(super) fn layer_states() -> Case { + let mut drawing = cases::drawing(); + let mut layers = drawing.layers.clone(); + for layer in &mut layers { + layer.on = false; + layer.locked = true; + } + drawing.layer_states.push(LayerState { name: "Saved".into(), layers }); + Case { name: "drawing/layer-state".into(), drawing } +} + +pub(super) fn assert_layer_states(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.layer_states, a.layer_states, "{context}.layer_states"); +} + +pub(super) fn layouts() -> Case { + let mut drawing = cases::drawing(); + drawing.layouts = vec![Layout::new("Sheet_A", 1), Layout::new("Sheet_B", 2)]; + for (index, layout) in drawing.layouts.iter_mut().enumerate() { + layout.page.paper_mm = true; + // PageSetup stores portrait dimensions; landscape is a separate orientation flag. + layout.page.width_mm = 297.0; + layout.page.height_mm = 420.0; + layout.page.scale = 0.5; + layout.page.plot_area = "window".into(); + layout.page.plot_window = [1.0, 2.0, 201.0, 142.0]; + layout.view = Some((Vec2::new(10.0, 20.0), 100.0 + index as f64)); + } + drawing.active_layout = Some("Sheet_B".into()); + for name in ["Sheet_A", "Sheet_B"] { + drawing.add(&Space::Paper(name.into()), Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 20.0, 0.0) })).unwrap(); + drawing + .add( + &Space::Paper(name.into()), + Common::default(), + EntityKind::Viewport(Viewport { + center: Vec3::new(50.0, 60.0, 0.0), + width: 80.0, + height: 50.0, + view_center: Vec2::new(5.0, 7.0), + view_height: 25.0, + id: 2, + locked: true, + frozen_layers: vec!["Survey_Ä".into()], + ..Viewport::default() + }), + ) + .unwrap(); + } + Case { name: "drawing/layouts-and-paper-entities".into(), drawing } +} + +#[test] +fn collection_assertions_reject_lost_authored_data() { + type Check = fn(&Drawing, &Drawing, &str); + type Mutate = fn(&mut Drawing); + let cases: [(Case, Check, Mutate); 6] = [ + (headers(), assert_headers, |d| d.header.set_f64("TEXTSIZE", 9.0)), + (symbol_tables(), assert_symbol_tables, |d| d.layers.retain(|l| l.name != "UnusedLayer")), + (views_and_ucs(), assert_views_and_ucs, |d| d.ucss.clear()), + (groups(), assert_groups, |d| d.groups.first_mut().unwrap().members.reverse()), + (layouts(), assert_layouts, |d| d.layouts.first_mut().unwrap().page.scale = 2.0), + (foreign_records(), assert_foreign_records, |d| std::sync::Arc::make_mut(&mut d.foreign).objects.clear()), + ]; + for (case, verify, mutate) in cases { + verify(&case.drawing, &case.drawing, &case.name); + let mut changed = case.drawing.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| verify(&case.drawing, &changed, &case.name)).is_err(), "{}: lost data must fail", case.name); + } +} + +pub(super) fn assert_layouts(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.layouts.len(), a.layouts.len(), "{context}.layouts.len"); + assert_eq!(b.active_layout, a.active_layout, "{context}.active_layout"); + for layout in &a.layouts { + let back = b.layouts.iter().find(|l| l.name == layout.name).expect("layout must survive"); + assert_eq!(back.tab_order, layout.tab_order, "{context}.{}.tab_order", layout.name); + assert_eq!(back.page, layout.page, "{context}.{}.page", layout.name); + assert_eq!(back.view, layout.view, "{context}.{}.view", layout.name); + compare::entities(a, b, &layout.entities, &back.entities, &format!("{context}.layout[{}]", layout.name)); + } +} diff --git a/crates/io/tests/it/roundtrip/compare.rs b/crates/io/tests/it/roundtrip/compare.rs new file mode 100644 index 000000000..d4bd1541a --- /dev/null +++ b/crates/io/tests/it/roundtrip/compare.rs @@ -0,0 +1,374 @@ +//! Shared preservation assertions for ordered entities, authored blocks and referenced tables. +//! Every field of the explicitly supported entities is checked; converted angles alone allow +//! floating-point roundoff, never wrapping or geometric approximation. Blocks are visited once +//! as stored definitions, not recursively expanded, so cyclic references remain bounded. +//! Header defaults, generated objects and other drawing collections are outside this slice. + +use std::fmt::Debug; + +use cadcraft_doc::{Arc, Attrib, Block, Circle, Common, Drawing, Entity, EntityKind, EntityStore, Insert, Line, MText, Text}; + +fn equal(expected: &T, actual: &T, context: &str, field: &str) { + assert_eq!(actual, expected, "{context}.{field}"); +} + +fn angle(expected: f64, actual: f64, context: &str, field: &str) { + // Two floating-point unit conversions, not a geometric approximation or angle wrapping. + let tolerance = 8.0 * f64::EPSILON * expected.abs().max(1.0); + assert!( + expected.is_finite() && actual.is_finite() && (actual - expected).abs() <= tolerance, + "{context}.{field}: expected {expected:?}, actual {actual:?}, tolerance {tolerance:e}" + ); +} + +pub(super) fn model(expected: &Drawing, actual: &Drawing, context: &str) { + entities(expected, actual, &expected.model, &actual.model, &format!("{context}.model")); + for (name, block) in &expected.blocks { + let path = format!("{context}.block[{name}]"); + let back = actual.block(name).unwrap_or_else(|| panic!("{path}: missing block")); + let Block { + name, + annotative, + base, + entities: contents, + description, + xref_overlay, + xref_status, + anonymous, + xref_path, + explodable, + units, + array, + } = block.as_ref(); + equal(name, &back.name, &path, "name"); + equal(annotative, &back.annotative, &path, "annotative"); + equal(base, &back.base, &path, "base"); + equal(description, &back.description, &path, "description"); + equal(xref_overlay, &back.xref_overlay, &path, "xref_overlay"); + equal(xref_status, &back.xref_status, &path, "xref_status"); + equal(anonymous, &back.anonymous, &path, "anonymous"); + equal(xref_path, &back.xref_path, &path, "xref_path"); + equal(explodable, &back.explodable, &path, "explodable"); + equal(units, &back.units, &path, "units"); + equal(array, &back.array, &path, "array"); + entities(expected, actual, contents, &back.entities, &path); + } +} + +pub(super) fn entities(expected: &Drawing, actual: &Drawing, source: &EntityStore, back: &EntityStore, context: &str) { + equal(&source.len(), &back.len(), context, "len"); + for (index, (a, b)) in source.iter().zip(back.iter()).enumerate() { + let path = format!("{context}.entity[{index}]"); + entity(a, b, &path); + layer_reference(expected, actual, &a.common.layer, &path); + linetype_reference(expected, actual, &a.common.linetype, &path); + let dimension_style = match &a.kind { + EntityKind::Dimension(d) => Some(d.style.as_str()), + EntityKind::Leader(l) => Some(l.style.as_str()), + EntityKind::Tolerance(t) => Some(t.style.as_str()), + _ => None, + }; + if let Some(name) = dimension_style { + equal(&expected.dim_style(name), &actual.dim_style(name), &path, "referenced_dimension_style"); + if let Some(style) = expected.dim_style(name) { + equal(&expected.text_style(&style.text_style), &actual.text_style(&style.text_style), &path, "dimension_text_style"); + } + } + let style = match &a.kind { + EntityKind::Text(t) => Some(t.style.as_str()), + EntityKind::MText(t) => Some(t.style.as_str()), + EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.style.as_str()), + _ => None, + }; + if let Some(style) = style { + equal(&expected.text_style(style), &actual.text_style(style), &path, "referenced_text_style"); + } + let attributes: &[Attrib] = match &a.kind { + EntityKind::AttDef(a) => std::slice::from_ref(a), + EntityKind::Insert(i) => &i.attribs, + EntityKind::MLeader(m) => m.block.as_ref().map_or(&[], |i| i.attribs.as_slice()), + _ => &[], + }; + for (index, a) in attributes.iter().enumerate() { + let path = format!("{path}.attrib[{index}]"); + if let Some(name) = &a.props.layer { + layer_reference(expected, actual, name, &path); + } + if let Some(name) = &a.props.linetype { + linetype_reference(expected, actual, name, &path); + } + for style in std::iter::once(a.text.style.as_str()).chain(a.multiline.as_ref().map(|m| m.mtext.style.as_str())) { + equal(&expected.text_style(style), &actual.text_style(style), &path, "referenced_text_style"); + } + } + } +} + +fn layer_reference(expected: &Drawing, actual: &Drawing, name: &str, context: &str) { + let layer = expected.layer(name); + equal(&layer, &actual.layer(name), context, "referenced_layer"); + if let Some(layer) = layer { + linetype_reference(expected, actual, &layer.linetype, context); + } +} + +fn linetype_reference(expected: &Drawing, actual: &Drawing, name: &str, context: &str) { + linetype(expected.linetype(name), actual.linetype(name), &format!("{context}.linetype[{name}]")); + if let Some(linetype) = expected.linetype(name) { + for element in &linetype.pattern { + if let Some(style) = &element.style { + equal(&expected.text_style(style), &actual.text_style(style), context, "linetype_text_style"); + } + } + } +} + +fn linetype(expected: Option<&cadcraft_doc::Linetype>, actual: Option<&cadcraft_doc::Linetype>, context: &str) { + let (Some(a), Some(b)) = (expected, actual) else { + equal(&expected, &actual, context, "presence"); + return; + }; + let cadcraft_doc::Linetype { name, description, pattern } = a; + equal(name, &b.name, context, "name"); + equal(description, &b.description, context, "description"); + equal(&pattern.len(), &b.pattern.len(), context, "pattern.len"); + for (index, (a, b)) in pattern.iter().zip(&b.pattern).enumerate() { + let path = format!("{context}.element[{index}]"); + let cadcraft_doc::DashElement { length, text, shape, style, scale, rotation, offset, absolute } = a; + equal(length, &b.length, &path, "length"); + equal(text, &b.text, &path, "text"); + equal(shape, &b.shape, &path, "shape"); + equal(style, &b.style, &path, "style"); + equal(scale, &b.scale, &path, "scale"); + angle(*rotation, b.rotation, &path, "rotation"); + equal(offset, &b.offset, &path, "offset"); + equal(absolute, &b.absolute, &path, "absolute"); + } +} + +fn entity(expected: &Entity, actual: &Entity, context: &str) { + let Entity { handle, common: properties, kind } = expected; + equal(handle, &actual.handle, context, "handle"); + common(properties, &actual.common, &format!("{context}.common")); + match (kind, &actual.kind) { + (EntityKind::Line(Line { a, b }), EntityKind::Line(other)) => { + equal(a, &other.a, context, "line.a"); + equal(b, &other.b, context, "line.b"); + } + (EntityKind::Circle(Circle { center, radius }), EntityKind::Circle(other)) => { + equal(center, &other.center, context, "circle.center"); + equal(radius, &other.radius, context, "circle.radius"); + } + (EntityKind::Text(a), EntityKind::Text(b)) => text(a, b, &format!("{context}.text")), + (EntityKind::Arc(Arc { center, radius, start, end }), EntityKind::Arc(b)) => { + equal(center, &b.center, context, "arc.center"); + equal(radius, &b.radius, context, "arc.radius"); + angle(*start, b.start, context, "arc.start"); + angle(*end, b.end, context, "arc.end"); + } + (EntityKind::MText(a), EntityKind::MText(b)) => mtext(a, b, context), + (EntityKind::AttDef(a), EntityKind::AttDef(b)) => attrib(a, b, context), + (EntityKind::Insert(a), EntityKind::Insert(b)) => insert(a, b, context), + ( + EntityKind::Point(_) + | EntityKind::Ellipse(_) + | EntityKind::LwPolyline(_) + | EntityKind::Polyline3d(_) + | EntityKind::Spline(_) + | EntityKind::Ray(_) + | EntityKind::XLine(_) + | EntityKind::Solid(_) + | EntityKind::Trace(_) + | EntityKind::Face3d(_) + | EntityKind::Leader(_) + | EntityKind::Hatch(_) + | EntityKind::Table(_) + | EntityKind::Image(_) + | EntityKind::Wipeout(_) + | EntityKind::Helix(_) + | EntityKind::MLine(_) + | EntityKind::MLeader(_) + | EntityKind::Region(_) + | EntityKind::Dimension(_) + | EntityKind::Tolerance(_) + | EntityKind::Viewport(_), + _, + ) => equal(kind, &actual.kind, context, "kind"), + _ => panic!("{context}.kind: unsupported or changed type: expected {kind:?}, actual {:?}", actual.kind), + } +} + +fn common(expected: &Common, actual: &Common, context: &str) { + // No `..`: adding a field requires deciding its preservation contract here. + let Common { layer, color, linetype, lineweight, ltscale, transparency, visible, thickness, extrusion, plot_style, anno_scales, xdata } = + expected; + equal(layer, &actual.layer, context, "layer"); + equal(color, &actual.color, context, "color"); + equal(linetype, &actual.linetype, context, "linetype"); + equal(lineweight, &actual.lineweight, context, "lineweight"); + equal(ltscale, &actual.ltscale, context, "ltscale"); + equal(transparency, &actual.transparency, context, "transparency"); + equal(visible, &actual.visible, context, "visible"); + equal(thickness, &actual.thickness, context, "thickness"); + equal(extrusion, &actual.extrusion, context, "extrusion"); + equal(plot_style, &actual.plot_style, context, "plot_style"); + equal(anno_scales, &actual.anno_scales, context, "anno_scales"); + equal(xdata, &actual.xdata, context, "xdata"); +} + +fn text(expected: &Text, actual: &Text, context: &str) { + let Text { insert, align_pt, height, value, rotation, width_factor, oblique, style, halign, valign } = expected; + equal(insert, &actual.insert, context, "insert"); + equal(align_pt, &actual.align_pt, context, "align_pt"); + equal(height, &actual.height, context, "height"); + equal(value, &actual.value, context, "value"); + angle(*rotation, actual.rotation, context, "rotation"); + equal(width_factor, &actual.width_factor, context, "width_factor"); + angle(*oblique, actual.oblique, context, "oblique"); + equal(style, &actual.style, context, "style"); + equal(halign, &actual.halign, context, "halign"); + equal(valign, &actual.valign, context, "valign"); +} + +fn mtext(expected: &MText, actual: &MText, context: &str) { + let MText { insert, height, width, attach, rotation, style, contents, line_spacing, line_spacing_exact, columns } = expected; + equal(insert, &actual.insert, context, "mtext.insert"); + equal(height, &actual.height, context, "mtext.height"); + equal(width, &actual.width, context, "mtext.width"); + equal(attach, &actual.attach, context, "mtext.attach"); + angle(*rotation, actual.rotation, context, "mtext.rotation"); + equal(style, &actual.style, context, "mtext.style"); + equal(contents, &actual.contents, context, "mtext.contents"); + equal(line_spacing, &actual.line_spacing, context, "mtext.line_spacing"); + equal(line_spacing_exact, &actual.line_spacing_exact, context, "mtext.line_spacing_exact"); + equal(columns, &actual.columns, context, "mtext.columns"); +} + +fn attrib(expected: &Attrib, actual: &Attrib, context: &str) { + let Attrib { tag, text: t, invisible, constant, prompt, props, multiline, modes } = expected; + equal(tag, &actual.tag, context, "attrib.tag"); + text(t, &actual.text, context); + equal(invisible, &actual.invisible, context, "attrib.invisible"); + equal(constant, &actual.constant, context, "attrib.constant"); + equal(prompt, &actual.prompt, context, "attrib.prompt"); + equal(props, &actual.props, context, "attrib.props"); + equal(modes, &actual.modes, context, "attrib.modes"); + match (multiline, &actual.multiline) { + (Some(a), Some(b)) => { + equal(&a.read_as, &b.read_as, context, "attrib.read_as"); + mtext(&a.mtext, &b.mtext, context); + } + _ => equal(multiline, &actual.multiline, context, "attrib.multiline"), + } +} + +fn insert(expected: &Insert, actual: &Insert, context: &str) { + let Insert { block, insert, scale, rotation, attribs, cols, rows, col_spacing, row_spacing } = expected; + equal(block, &actual.block, context, "insert.block"); + equal(insert, &actual.insert, context, "insert.insert"); + equal(scale, &actual.scale, context, "insert.scale"); + angle(*rotation, actual.rotation, context, "insert.rotation"); + equal(cols, &actual.cols, context, "insert.cols"); + equal(rows, &actual.rows, context, "insert.rows"); + equal(col_spacing, &actual.col_spacing, context, "insert.col_spacing"); + equal(row_spacing, &actual.row_spacing, context, "insert.row_spacing"); + equal(&attribs.len(), &actual.attribs.len(), context, "insert.attribs.len"); + for (index, (a, b)) in attribs.iter().zip(&actual.attribs).enumerate() { + attrib(a, b, &format!("{context}.attrib[{index}]")); + } +} + +#[test] +fn comparison_rejects_lost_fields_and_changed_handles() { + use cadcraft_doc::Handle; + use cadcraft_geom::Vec3; + + let original = Entity::new(Handle(0x123), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.0, 3.0, 4.0) })); + let mutations: [fn(&mut Entity); 4] = [ + |e| e.handle = Handle(0x456), + |e| e.common.visible = false, + |e| e.common.xdata.push(cadcraft_doc::RawTag { code: 1000, value: "unexpected".into() }), + |e| e.kind = EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 2.0 }), + ]; + for mutate in mutations { + let mut changed = original.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| entity(&original, &changed, "comparison self-test")).is_err()); + } +} + +#[test] +fn comparison_rejects_lost_or_changed_referenced_linetypes() { + use cadcraft_doc::{Linetype, Space}; + use cadcraft_geom::Vec3; + + for (name, entity_linetype, layer_linetype) in [ + ("direct", "ReviewDashed", "Continuous"), + ("mixed-case", "reviewdashed", "Continuous"), + ("inherited", "ByLayer", "ReviewDashed"), + ("inherited-mixed-case", "ByLayer", "reviewdashed"), + ] { + let mut original = Drawing::new_metric(); + original.linetypes.push(Linetype::simple("ReviewDashed", "Synthetic", &[2.0, -1.0])); + original.layer_mut("0").unwrap().linetype = layer_linetype.into(); + original + .add( + &Space::Model, + Common { linetype: entity_linetype.into(), ..Common::default() }, + EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 2.0, 3.0) }), + ) + .unwrap(); + let referenced = if entity_linetype == "ByLayer" { layer_linetype } else { entity_linetype }; + assert!(original.linetype(referenced).is_some(), "{name}: valid reference"); + model(&original, &original, name); + let mutations: [fn(&mut Drawing); 2] = [ + |d| d.linetypes.retain(|l| l.name != "ReviewDashed"), + |d| d.linetypes.iter_mut().find(|l| l.name == "ReviewDashed").unwrap().pattern.clear(), + ]; + for (index, mutate) in mutations.into_iter().enumerate() { + let mut changed = original.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| model(&original, &changed, name)).is_err(), "{name}: mutation {index} must be rejected"); + } + } +} + +#[test] +fn comparison_rejects_block_and_attribute_dependency_losses() { + use super::cases::{blocks, properties}; + let original = blocks::attributes("overrides").remove(0).drawing; + let mutations: [fn(&mut Drawing); 4] = [ + |d| { + d.blocks.remove("Z_Leaf"); + }, + |d| std::sync::Arc::make_mut(d.blocks.get_mut("Z_Leaf").unwrap()).base.x += 1.0, + |d| d.layer_mut("Survey_Ä").unwrap().color = cadcraft_color::Color::Index(5), + |d| { + let handle = d.model.iter().next().unwrap().handle; + d.model.modify(handle, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") }; + i.attribs.reverse(); + }); + }, + ]; + model(&original, &original, "unchanged block and attributes"); + for mutate in mutations { + let mut changed = original.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| model(&original, &changed, "block dependency self-test")).is_err()); + } + let original = properties::complex_linetypes(false).remove(0).drawing; + let mut changed = original.clone(); + changed.text_styles.retain(|s| s.name != "Notes"); + assert!(std::panic::catch_unwind(|| model(&original, &changed, "linetype style self-test")).is_err()); + let cyclic = blocks::nested(true).remove(0).drawing; + model(&cyclic, &cyclic, "cycles compare without expansion"); +} + +#[test] +fn angle_tolerance_does_not_hide_real_changes_or_nonfinite_values() { + angle(0.7, 0.7_f64.to_degrees().to_radians(), "self-test", "angle"); + for actual in [0.700001, f64::NAN, f64::INFINITY] { + assert!(std::panic::catch_unwind(|| angle(0.7, actual, "self-test", "angle")).is_err()); + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/conformance.rs b/crates/io/tests/it/roundtrip/dwg/conformance.rs new file mode 100644 index 000000000..ff58850ce --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/conformance.rs @@ -0,0 +1,87 @@ +//! Independent bridge expectations, not an independent DWG codec. Writer checks inspect native +//! objects without CADCraft's reader; reader fixtures originate in native objects without our +//! DXF writer. acadrust still supplies the binary codec in both directions, so these tests do +//! not establish interoperability with another DWG implementation. All fixtures are synthetic. + +use super::super::cases; +use acadrust::types::Vector3; +use acadrust::{CadDocument, DwgReader, DwgWriter, DxfVersion, EntityType}; +use cadcraft_doc::{Circle, Common, EntityKind, Line, Text}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; +use std::io::Cursor; + +#[test] +fn writer_populates_native_geometry_without_our_reader() { + let source = cases::common_case("native-writer", Common::default()); + for version in Version::ALL { + let bytes = write_with(&source.drawing, "memory.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let native = DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let entities: Vec<_> = native.entities().collect(); + assert_eq!(entities.len(), 3, "{version}"); + let EntityType::Line(line) = entities[0] else { panic!("expected native LINE") }; + assert_eq!(line.start, Vector3::new(-1.25, 2.5, 3.75), "{version}"); + assert_eq!(line.end, Vector3::new(8.125, -13.5, 21.25), "{version}"); + let EntityType::Circle(circle) = entities[1] else { panic!("expected native CIRCLE") }; + assert_eq!(circle.center, Vector3::new(-7.25, 11.5, 4.75), "{version}"); + assert_eq!(circle.radius, 3.125, "{version}"); + let EntityType::Text(text) = entities[2] else { panic!("expected native TEXT") }; + assert_eq!(text.value, "CAD Ø", "{version}"); + assert_eq!(text.insertion_point, Vector3::new(2.5, -3.75, 4.125), "{version}"); + assert_eq!(text.height, 2.25, "{version}"); + assert_eq!(text.width_factor, 1.0, "{version}"); + assert_eq!(text.rotation, 0.0, "{version}"); + } +} + +#[test] +#[ignore = "Confirmed native DWG mismatch: a pi/2 linetype rotation is stored as pi/2 degrees; DXF unit interpretation needs investigation"] +fn complex_linetype_rotation_is_correct_in_native_objects() { + // Both document APIs specify radians. Do not infer correctness from a DXF round trip: + // reader and writer can share a unit error. Public DXF references disagree about LTYPE + // group 50, so this asserts the end-to-end native angle, not a guessed DXF encoding. + for shape in [false, true] { + for case in cases::properties::complex_linetypes(shape) { + for version in Version::ALL { + let bytes = write_with(&case.drawing, "memory.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let native = DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let line = native.line_types.iter().find(|l| l.name == "Dash_test").unwrap(); + let elements: Vec<_> = line.elements.iter().filter_map(|e| e.complex.as_ref()).collect(); + assert_eq!(elements.len(), 1, "{} {version}", case.name); + let rotation = elements[0].rotation; + assert!( + (rotation - std::f64::consts::FRAC_PI_2).abs() <= 8.0 * f64::EPSILON, + "{} {version}: native rotation {rotation} radians, expected pi/2", + case.name + ); + } + } + } +} + +#[test] +fn reader_accepts_native_fixtures_without_our_writer() { + for version in [DxfVersion::AC1015, DxfVersion::AC1018, DxfVersion::AC1021, DxfVersion::AC1024, DxfVersion::AC1027, DxfVersion::AC1032] { + let mut native = CadDocument::with_version(version); + let handles = [ + native.add_entity(EntityType::Line(acadrust::entities::Line::from_coords(1.25, -2.5, 3.75, -4.5, 5.25, -6.75))).unwrap(), + native.add_entity(EntityType::Circle(acadrust::entities::Circle::from_coords(7.25, -8.5, 9.75, 2.125))).unwrap(), + native.add_entity(EntityType::Text(acadrust::entities::Text::with_value("Independent Ø", Vector3::new(-3.25, 4.5, 1.75)))).unwrap(), + ]; + let expected = [ + EntityKind::Line(Line { a: Vec3::new(1.25, -2.5, 3.75), b: Vec3::new(-4.5, 5.25, -6.75) }), + EntityKind::Circle(Circle { center: Vec3::new(7.25, -8.5, 9.75), radius: 2.125 }), + EntityKind::Text(Text { insert: Vec3::new(-3.25, 4.5, 1.75), height: 1.0, value: "Independent Ø".into(), ..cases::text() }), + ]; + let bytes = DwgWriter::write_to_vec(&native).unwrap(); + let back = read(&bytes, "native-fixture.dwg").unwrap(); + assert!(back.read_notes.is_empty(), "{:?}", back.read_notes); + assert!(back.skipped_records.is_empty()); + assert_eq!(back.model.len(), expected.len()); + for ((entity, expected), handle) in back.model.iter().zip(&expected).zip(handles) { + assert_eq!(entity.handle.0, u64::from(handle), "{version:?}"); + assert_eq!(&entity.kind, expected, "{version:?}"); + assert_eq!(entity.common, Common::default(), "{version:?}"); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/dimensions.rs b/crates/io/tests/it/roundtrip/dwg/dimensions.rs new file mode 100644 index 000000000..3599d43f5 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/dimensions.rs @@ -0,0 +1,210 @@ +//! DWG anonymous dimension-block suffixes are not persistent identities (#2257). Match blocks +//! through dimensions with preserved entity handles, then compare authored content and properties +//! strictly. Only anonymous *D names may change; missing references and merged blocks fail. +//! This verifier covers the model-space dimension fixtures, not arbitrary drawing graph remapping. +//! +//! Autodesk documents that anonymous block numbers may change between drawing sessions: +//! https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-AutoLISP/files/GUID-EB9BFDAA-487A-4586-A556-1F5F84BCF2F3.htm +//! ODA's DWG specification §20.4.52.1 shows a BLOCK_HEADER named *D without a suffix; +//! DIMENSION records reference their block by handle, not by its reconstructed name: +//! https://www.opendesign.com/files/guestdownloads/OpenDesign_Specification_for_.dwg_files.pdf + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use cadcraft_doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::Version; + +use super::super::{ + cases::{self, Case, complex}, + compare, +}; + +pub(super) fn check() { + for case in complex::dimensions().into_iter().chain([paired_dimensions(["*D42", "*D7"])]) { + for version in Version::ALL { + let mut expected = case.drawing.clone(); + // DIMFXL has no native field before R2007 (same limit as other dimension-style cases). + if version < Version::R2007 { + for style in &mut expected.dim_styles { + style.fixed_ext_length = 0.0; + } + } + super::check_version_using(&case.name, &case.drawing, &expected, version, equivalent); + } + } +} + +// Two independent cache blocks with different authored geometry, plus an ordinary named block. +// Names are supplied explicitly so comparator controls do not learn expectations from a save. +fn paired_dimensions(names: [&str; 2]) -> Case { + let mut drawing = cases::drawing(); + for (index, (case, name)) in complex::dimensions().into_iter().zip(names).enumerate() { + let entity = case.drawing.model.iter().next().unwrap(); + let mut kind = entity.kind.clone(); + let EntityKind::Dimension(dim) = &mut kind else { panic!("fixture dimension") }; + dim.block = Some(name.into()); + drawing.add(&Space::Model, entity.common.clone(), kind).unwrap(); + let mut block = Block::new(name); + for entity in case.drawing.block("*D42").unwrap().entities.iter() { + block.entities.push(Entity { handle: drawing.new_handle(), ..entity.as_ref().clone() }); + } + // This circle is authored cache content, not something dimension regeneration produces. + block + .entities + .push(Entity::new(drawing.new_handle(), EntityKind::Circle(Circle { center: Vec3::new(5.0, 3.0, 0.0), radius: 0.25 + index as f64 }))); + drawing.blocks.insert(name.into(), Arc::new(block)); + } + let mut named = Block::new("UserPart"); + named.entities.push(Entity::new(drawing.new_handle(), EntityKind::Circle(Circle { center: Vec3::new(1.0, 2.0, 0.0), radius: 2.0 }))); + drawing.blocks.insert(named.name.clone(), Arc::new(named)); + Case { name: "dimension/distinct-anonymous-caches-and-named-block".into(), drawing } +} + +fn anonymous_dimension_name(name: &str) -> bool { + name.strip_prefix("*D").is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|c| c.is_ascii_digit())) +} + +fn equivalent(expected: &Drawing, actual: &Drawing, context: &str) { + let mut names = BTreeMap::new(); + for entity in expected.model.iter() { + let EntityKind::Dimension(dim) = &entity.kind else { continue }; + let before = dim.block.as_deref().expect("fixture has a dimension block"); + let back = actual.entity(entity.handle).unwrap_or_else(|| panic!("{context}: missing dimension {}", entity.handle.hex())); + let EntityKind::Dimension(dim) = &back.kind else { panic!("{context}: dimension changed type") }; + let after = dim.block.as_deref().unwrap_or_else(|| panic!("{context}: dimension lost its block reference")); + let source = expected.block(before).expect("fixture block exists"); + let target = actual.block(after).unwrap_or_else(|| panic!("{context}: dangling dimension block reference {after}")); + if before != after { + assert!(source.anonymous && target.anonymous, "{context}: only anonymous dimension blocks may be renamed"); + assert!( + anonymous_dimension_name(before) && anonymous_dimension_name(after), + "{context}: unexpected block name mapping {before} -> {after}" + ); + } + if let Some(previous) = names.insert(before.to_string(), after.to_string()) { + assert_eq!(previous, after, "{context}: one dimension block split into different references"); + } + } + assert!(!names.is_empty(), "{context}: fixture must exercise dimension block references"); + assert_eq!(names.values().collect::>().len(), names.len(), "{context}: distinct dimension blocks were merged"); + assert_eq!(actual.blocks.len(), expected.blocks.len(), "{context}: block lost or extra cache generated"); + + // Adapt only the validated names, never content or properties learned from the output. + // Rebuild the map to handle renumberings that reuse another source block's old name. + let mut renamed = expected.clone(); + renamed.blocks.clear(); + for (name, block) in &expected.blocks { + let mapped = names.get(name).unwrap_or(name); + let mut block = block.clone(); + Arc::make_mut(&mut block).name = mapped.clone(); + assert!(renamed.blocks.insert(mapped.clone(), block).is_none(), "{context}: block name collision"); + } + for entity in expected.model.iter() { + if let EntityKind::Dimension(dim) = &entity.kind { + let mapped = names.get(dim.block.as_ref().expect("fixture block")).expect("mapped reference"); + renamed.model.modify(entity.handle, |e| { + let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") }; + dim.block = Some(mapped.clone()); + }); + } + } + compare::model(&renamed, actual, context); +} + +#[test] +fn comparison_accepts_only_anonymous_dimension_renumbering() { + let original = paired_dimensions(["*D42", "*D7"]).drawing; + equivalent(&original, &original, "unchanged"); + for names in [["*D1", "*D2"], ["*D7", "*D42"]] { + let renamed = paired_dimensions(names).drawing; + equivalent(&original, &renamed, "renumbered, including reused source names"); + // The general comparator must still reject renaming outside this DWG-specific check. + assert!(std::panic::catch_unwind(|| compare::model(&original, &renamed, "strict comparison")).is_err()); + } +} + +fn dimension_handles(d: &Drawing) -> Vec { + d.model.iter().filter(|e| matches!(e.kind, EntityKind::Dimension(_))).map(|e| e.handle).collect() +} + +fn set_reference(d: &mut Drawing, handle: Handle, name: Option<&str>) { + assert!(d.model.modify(handle, |e| { + let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") }; + dim.block = name.map(str::to_string); + })); +} + +#[test] +fn comparison_rejects_broken_references_content_and_block_properties() { + let original = paired_dimensions(["*D42", "*D7"]).drawing; + let renamed = paired_dimensions(["*D1", "*D2"]).drawing; + equivalent(&original, &renamed, "valid control"); + let mutations: [fn(&mut Drawing); 14] = [ + |d| set_reference(d, dimension_handles(d)[0], None), + |d| set_reference(d, dimension_handles(d)[0], Some("*D999")), + |d| set_reference(d, dimension_handles(d)[0], Some("*D2")), + |d| { + let hs = dimension_handles(d); + set_reference(d, hs[0], Some("*D2")); + set_reference(d, hs[1], Some("*D1")); + }, + |d| { + d.blocks.remove("*D1"); + }, + |d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).entities = Default::default(), + |d| { + let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap()); + let h = block.entities.iter().next().unwrap().handle; + block.entities.modify(h, |e| e.common.visible = false); + }, + |d| { + let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap()); + let h = block.entities.iter().last().unwrap().handle; + block.entities.modify(h, |e| { + let EntityKind::Circle(circle) = &mut e.kind else { panic!("fixture circle") }; + circle.radius += 1.0; + }); + }, + |d| { + let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap()); + let h = block.entities.iter().next().unwrap().handle; + block.entities.modify(h, |e| e.handle = Handle(0xFFFF)); + }, + |d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).base.x += 1.0, + |d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).anonymous = false, + |d| { + d.blocks.insert("*D999".into(), Arc::new(Block::new("*D999"))); + }, + |d| { + let mut block = d.blocks.remove("UserPart").unwrap(); + Arc::make_mut(&mut block).name = "RenamedPart".into(); + d.blocks.insert("RenamedPart".into(), block); + }, + |d| { + let h = dimension_handles(d)[0]; + d.model.modify(h, |e| { + let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") }; + dim.p14.x += 1.0; + }); + }, + ]; + for (index, mutate) in mutations.into_iter().enumerate() { + let mut changed = renamed.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "mutation")).is_err(), "mutation {index} must fail"); + } +} + +#[test] +fn comparison_rejects_changed_prefixes_and_named_dimension_cache_renaming() { + let original = paired_dimensions(["*D42", "*D7"]).drawing; + for name in ["*U1", "*D", "*Dcustom", "UserCache"] { + let changed = paired_dimensions([name, "*D2"]).drawing; + assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "wrong name kind")).is_err()); + } + let original = paired_dimensions(["AuthoredCache", "*D7"]).drawing; + let changed = paired_dimensions(["RenamedCache", "*D2"]).drawing; + assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "named cache")).is_err()); +} diff --git a/crates/io/tests/it/roundtrip/dwg/entities.rs b/crates/io/tests/it/roundtrip/dwg/entities.rs new file mode 100644 index 000000000..58f97d92f --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/entities.rs @@ -0,0 +1,171 @@ +//! DWG property families stay separate: a failing escape, plot-style or alignment regression +//! must not disable unrelated TEXT or Common tests. Pending cases assert the original model, +//! never today's lossy output. Only confirmed failures receive an explicit ignore reason. + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, EntityKind, Transparency}; +use cadcraft_io::Version; + +use super::super::cases; +use super::{check, check_version}; + +#[test] +fn empty_model_and_defaults() { + check(cases::empty_and_defaults()); +} +#[test] +fn line_endpoints_and_circle_centers_radii() { + check(cases::geometry()); +} +#[test] +fn layers() { + check(cases::layers()); +} +#[test] +fn layer_inherited_linetypes() { + check(cases::layer_linetypes()); +} +#[test] +fn colors() { + check(cases::colors()); +} +#[test] +fn linetypes() { + check(cases::linetypes()); +} +#[test] +fn lineweights() { + check(cases::lineweights()); +} +#[test] +fn linetype_scales() { + check(cases::linetype_scales()); +} +#[test] +fn transparencies() { + check(cases::transparencies()); +} +#[test] +fn visibility() { + check(cases::visibility()); +} +#[test] +fn thicknesses() { + check(cases::thicknesses()); +} +#[test] +#[ignore = "Confirmed DWG bridge loss: named plot style Review becomes ByLayer, including R2018"] +fn named_plot_style_is_preserved() { + check(cases::plot_styles()); +} +#[test] +fn foreign_xdata() { + check(cases::foreign_xdata()); +} +#[test] +fn common_property_interactions() { + check(cases::common_interactions("ByLayer")); +} +#[test] +#[ignore = "Confirmed DWG bridge loss: named plot style is lost in combination with other properties"] +fn common_property_interactions_with_named_plot_style() { + check(cases::common_interactions("Review")); +} +#[test] +fn text_values() { + check(cases::text_values()); +} +#[test] +#[ignore = "Confirmed DWG bridge loss: literal Unicode/MIF escape text is decoded again, including R2018"] +fn text_literal_escapes() { + check(cases::text_literal_escapes()); +} +#[test] +fn text_heights() { + check(cases::text_heights()); +} +#[test] +fn text_rotations() { + check(cases::text_rotations()); +} +#[test] +fn text_widths() { + check(cases::text_widths()); +} +#[test] +fn text_obliques() { + check(cases::text_obliques()); +} +#[test] +fn text_styles() { + check(cases::text_styles()); +} +#[test] +fn annotation_scales() { + check(cases::annotation_scales()); +} +#[test] +fn text_alignment_with_points() { + check(cases::coplanar_text_alignment()); +} +#[test] +#[ignore = "Confirmed change: missing justified TEXT align_pt becomes (0,0,elevation), moving text"] +fn text_alignment_without_points() { + check(cases::text_alignment(false)); +} +#[test] +#[ignore = "Confirmed loss on the shared DXF path: ByBlock plot style becomes ByLayer"] +fn plot_style_byblock_is_preserved() { + check(cases::byblock_plot_style()); +} +#[test] +#[ignore = "Confirmed loss on the shared DXF path: non-default extrusion becomes (0,0,1)"] +fn nondefault_extrusion_is_preserved() { + check(cases::extrusions()); +} + +#[test] +fn text_points_share_one_elevation_in_dwg() { + // Public ODA DWG specification, TEXT (1): insertion and alignment points contain XY; + // a single elevation supplies both Z coordinates. This is a representational limit, not + // a bug or a comparison tolerance. DXF's original XYZ cases are still checked unchanged. + // https://www.opendesign.com/files/guestdownloads/OpenDesign_Specification_for_.dwg_files.pdf + let cases = cases::noncoplanar_text_alignment(); + assert!(!cases.is_empty()); + for case in cases { + let mut expected = case.drawing.clone(); + let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect(); + for handle in handles { + expected.model.modify(handle, |e| { + let EntityKind::Text(t) = &mut e.kind else { panic!("expected TEXT") }; + let point = t.align_pt.as_mut().expect("second point"); + point.z = t.insert.z; + }); + } + for version in Version::ALL { + check_version(&case.name, &case.drawing, &expected, version); + } + } +} + +#[test] +fn r2000_explicit_color_and_transparency_downgrade() { + // R2000 has no true-color/alpha fields. An exact red RGB maps to ACI 1; neither a fixed + // alpha nor transparency ByBlock is representable. Expected values are authored here, + // not learnt from an initial save, and are checked again after the second save. + for transparency in [Transparency::ByBlock, Transparency::Percent(0), Transparency::Percent(40), Transparency::Percent(90)] { + let case = cases::common_case( + &format!("legacy/true-red/transparency={transparency:?}"), + Common { color: Color::True(Rgb(255, 0, 0)), transparency, ..Common::default() }, + ); + let mut expected = case.drawing.clone(); + let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect(); + for handle in handles { + expected.model.modify(handle, |e| { + e.common.color = Color::Index(1); + e.common.transparency = Transparency::ByLayer; + }); + } + check_version(&case.name, &case.drawing, &expected, Version::R2000); + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/extended.rs b/crates/io/tests/it/roundtrip/dwg/extended.rs new file mode 100644 index 000000000..001712d28 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/extended.rs @@ -0,0 +1,395 @@ +//! Native DWG extensions to the shared preservation matrix. Version limits (native columns, +//! attributes, HELIX/MULTILEADER and table fields) are explicit, as are named-reference +//! resolution and legacy defaults. Other inputs use the full save-version domain. + +use super::super::cases::{Case, blocks, complex, geometry, properties}; +use super::super::collections; +use super::check; +use cadcraft_io::Version; + +// DWG references a table record by handle, not by an independently spelled name. These +// explicit expected spellings test resolution, not a blanket case-folding comparator. +fn canonical_reference(kind: &str, name: &str) { + for case in properties::mixed_case_references(kind) { + let mut expected = case.drawing.clone(); + let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect(); + for handle in handles { + expected.model.modify(handle, |e| match kind { + "layer" => e.common.layer = name.into(), + "linetype" => e.common.linetype = name.into(), + "style" => { + let cadcraft_doc::EntityKind::Text(t) = &mut e.kind else { panic!("expected TEXT") }; + t.style = name.into(); + } + _ => panic!("unknown reference family"), + }); + } + for version in Version::ALL { + super::check_version(&case.name, &case.drawing, &expected, version); + } + } +} + +#[test] +fn legacy_block_metadata() { + // DWG BLOCK_HEADER gains insertion units and explodability in R2007 (ODA specification). + for case in blocks::metadata() { + let mut expected = case.drawing.clone(); + let block = std::sync::Arc::make_mut(expected.blocks.get_mut("Z_Leaf").unwrap()); + block.explodable = true; + block.units = 0; + for version in [Version::R2000, Version::R2004] { + super::check_version(&case.name, &case.drawing, &expected, version); + } + } +} + +fn since_2007(cases: Vec) { + assert!(!cases.is_empty()); + for case in cases { + for version in Version::ALL.into_iter().filter(|v| *v >= Version::R2007) { + super::check_version(&case.name, &case.drawing, &case.drawing, version); + } + } +} + +#[test] +fn mixed_case_layers() { + canonical_reference("layer", "Survey_Ä"); +} +#[test] +fn mixed_case_linetypes() { + canonical_reference("linetype", "Dash_test"); +} +#[test] +fn mixed_case_styles() { + canonical_reference("style", "Notes"); +} +#[test] +fn layer_flags() { + check(properties::layer_flags()); +} +#[test] +fn layer_appearance() { + check(properties::layer_appearance()); +} +#[test] +#[ignore = "Confirmed DWG loss: named layer plot style Review becomes Normal"] +fn layer_plot_styles() { + check(properties::layer_plot_styles()); +} +#[test] +fn text_style_metrics() { + check(properties::text_style_metrics()); +} +#[test] +fn text_style_flags() { + check(properties::text_style_flags()); +} +#[test] +fn text_style_fonts() { + check(properties::text_style_fonts()); +} +#[test] +fn linetype_text() { + check(properties::complex_linetypes(false)); +} +#[test] +fn linetype_shapes() { + check(properties::complex_linetypes(true)); +} +#[test] +fn xdata_lists() { + check(properties::xdata_lists()); +} +#[test] +fn xdata_points() { + check(properties::xdata_points()); +} +#[test] +fn xdata_handles() { + check(properties::xdata_handles()); +} +#[test] +fn handles_and_order() { + check(properties::handles_and_order()); +} +#[test] +fn handles_near_integer_limits() { + check(properties::handles_near_integer_limits()); +} +#[test] +fn degenerate_lines() { + check(properties::degenerate_geometry()); +} +#[test] +fn layer_inheritance_overrides() { + check(properties::layer_inheritance_overrides()); +} +#[test] +fn alignment_style_interactions() { + check(properties::alignment_style_interactions()); +} +#[test] +fn nested_attributes() { + check(blocks::nested_attributes()); +} +#[test] +fn text_interactions() { + check(properties::text_interactions()); +} +#[test] +fn long_text() { + check(properties::long_text()); +} +#[test] +fn arcs() { + check(geometry::arcs()); +} +#[test] +fn ellipses() { + check(geometry::ellipses()); +} +#[test] +fn polyline_constant_widths() { + check(geometry::polylines(false)); +} +#[test] +fn polyline_variable_widths() { + check(geometry::polylines(true)); +} +#[test] +fn polylines_3d() { + check(geometry::polylines_3d()); +} +#[test] +fn splines() { + check(geometry::splines(false)); +} +#[test] +fn rational_splines() { + check(geometry::splines(true)); +} +#[test] +fn fit_splines() { + check(geometry::spline_fit(false)); +} +#[test] +#[ignore = "Confirmed DWG loss: periodic closed fit spline reopens as an open spline"] +fn closed_fit_splines() { + check(geometry::spline_fit(true)); +} +#[test] +fn points_and_rays() { + check(geometry::points_and_rays()); +} +#[test] +fn faces() { + check(geometry::faces()); +} +#[test] +fn mtext_placement() { + check(geometry::mtext_placement()); +} +#[test] +fn mtext_spacing() { + check(geometry::mtext_spacing()); +} +#[test] +fn mtext_contents() { + check(geometry::mtext_contents()); +} +#[test] +fn mtext_long() { + check(geometry::mtext_long()); +} +#[test] +#[ignore = "Confirmed DWG loss: MTEXT columns disappear"] +fn mtext_columns() { + since_2007(geometry::mtext_columns()); +} +#[test] +fn leaders() { + with_dimension_styles(geometry::leaders(false)); +} +#[test] +fn hatches() { + check(geometry::hatches()); +} +#[test] +fn tables() { + check(geometry::tables()); +} +#[test] +fn images() { + check(geometry::images()); +} +#[test] +fn wipeouts() { + check(geometry::wipeouts(true)); +} +#[test] +fn wipeout_implicit_closure() { + // The format closes the stored boundary explicitly; this is not an extra editable corner. + // Keep grip behavior separate from this serialization contract (#2256). + for (case, expected) in geometry::wipeouts(false).into_iter().zip(geometry::wipeouts(true)) { + for version in Version::ALL { + super::check_version(&case.name, &case.drawing, &expected.drawing, version); + } + } +} +#[test] +#[ignore = "Confirmed shared DXF loss: LEADER vertex Z coordinates become zero"] +fn leaders_3d() { + check(geometry::leaders(true)); +} +#[test] +#[ignore = "Confirmed shared DXF loss: ATTRIB constant flag becomes false"] +fn constant_attributes() { + check(blocks::attributes("constant")); +} +#[test] +#[ignore = "Confirmed DWG loss: a space following an MTEXT caret is consumed"] +fn mtext_carets() { + check(geometry::mtext_carets()); +} +#[test] +fn block_transformations() { + check(blocks::transformations()); +} +#[test] +fn block_arrays() { + check(blocks::arrays()); +} +#[test] +fn block_metadata() { + since_2007(blocks::metadata()); +} +#[test] +fn nested_blocks() { + check(blocks::nested(false)); +} +#[test] +fn cyclic_blocks() { + check(blocks::nested(true)); +} +#[test] +fn unreferenced_blocks() { + check(blocks::unreferenced()); +} +#[test] +fn attribute_flags() { + check(blocks::attributes("flags")); +} +#[test] +fn attribute_modes() { + check(blocks::attributes("modes")); +} +#[test] +fn attribute_lock() { + since_2007(blocks::attributes("lock")); +} +#[test] +fn multiline_attributes() { + for case in blocks::multiline_attributes() { + super::check_version(&case.name, &case.drawing, &case.drawing, Version::R2018); + } +} +#[test] +fn attribute_overrides() { + check(blocks::attributes("overrides")); +} +#[test] +fn helices() { + since_2007(complex::helices()); +} +#[test] +fn mlines() { + check(complex::mlines()); +} +#[test] +fn mleaders() { + since_2007(complex::mleaders()); +} +#[test] +fn regions() { + check(complex::regions()); +} +#[test] +fn tolerances() { + with_dimension_styles(complex::tolerances()); +} +#[test] +fn dimensions() { + super::dimensions::check(); +} + +fn collection(case: Case, verify: fn(&cadcraft_doc::Drawing, &cadcraft_doc::Drawing, &str)) { + for version in Version::ALL { + super::check_version_with(&case.name, &case.drawing, &case.drawing, version, verify); + } +} +#[test] +fn header_variables() { + collection(collections::headers(), collections::assert_headers); +} +#[test] +#[ignore = "Confirmed DWG loss: current layer CLAYER becomes 0"] +fn header_current_layer() { + collection(collections::named_header_reference("CLAYER"), collections::assert_headers); +} +#[test] +fn header_text_style() { + collection(collections::named_header_reference("TEXTSTYLE"), collections::assert_headers); +} +#[test] +fn unreferenced_tables() { + collection(collections::symbol_tables(), collections::assert_symbol_tables); +} +#[test] +fn foreign_dictionary_records() { + collection(collections::foreign_records(), collections::assert_foreign_records); +} +#[test] +#[ignore = "Confirmed DWG data loss: saving an unregistered entity succeeds but drops it instead of preserving it or returning an error"] +fn unknown_entities_are_preserved_or_explicitly_refused() { + for version in Version::ALL { + super::super::unknown::check("memory.dwg", version, false); + } +} +#[test] +fn views_and_ucs() { + collection(collections::views_and_ucs(), collections::assert_views_and_ucs); +} +#[test] +fn tiled_viewports() { + collection(collections::vports(), collections::assert_vports); +} +#[test] +fn groups() { + collection(collections::groups(), collections::assert_groups); +} +#[test] +fn layer_states() { + collection(collections::layer_states(), collections::assert_layer_states); +} +#[test] +fn layouts_and_paperspace() { + collection(collections::layouts(), collections::assert_layouts); +} + +fn with_dimension_styles(cases: Vec) { + // Fixed-length extension lines (DIMFXL) were added in 2007. Older DWG records have no + // slot for the inactive default length, and the bridge supplies zero on read. + for case in cases { + for version in Version::ALL { + let mut expected = case.drawing.clone(); + if version < Version::R2007 { + for style in &mut expected.dim_styles { + style.fixed_ext_length = 0.0; + } + } + super::check_version(&case.name, &case.drawing, &expected, version); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/mod.rs b/crates/io/tests/it/roundtrip/dwg/mod.rs new file mode 100644 index 000000000..6f9c90e1d --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/mod.rs @@ -0,0 +1,75 @@ +//! Native DWG Drawing → bytes → Drawing preservation, using the same cases and assertions as +//! DXF. Every representable case runs at all six save versions and through two saves. R2000's +//! lack of true color/transparency is a format limit, tested separately with explicit expected +//! downgrades; it is not a normalization of modern output. TEXT's single elevation is also +//! checked separately for non-coplanar inputs. Extended cases also specify handle-based name +//! resolution, anonymous dimension-block names and legacy block/dimension-style limits +//! explicitly. Other differences fail. +//! Native-object conformance checks bypass one bridge direction, not the underlying codec. +//! +//! Run `cargo test -p cadcraft-io --test it roundtrip::dwg`; add `-- --ignored` to reproduce +//! pending defects. No DWG bytes are imported from another application or kept as fixtures. + +mod conformance; +mod dimensions; +mod entities; +mod extended; + +use cadcraft_color::Color; +use cadcraft_doc::{Drawing, EntityKind, Transparency}; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +use super::{cases, compare}; + +fn check(cases: Vec) { + for case in cases { + assert!( + !case.drawing.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.align_pt.is_some_and(|p| p.z != t.insert.z))), + "{}: non-coplanar TEXT belongs in the explicit single-elevation test", + case.name + ); + let mut tested = 0; + for version in Version::ALL { + if version == Version::R2000 && needs_color_or_transparency(&case.drawing) { + continue; + } + check_version(&case.name, &case.drawing, &case.drawing, version); + tested += 1; + } + assert!(tested > 0, "{}: the version domain must not exclude a whole case", case.name); + } +} + +fn needs_color_or_transparency(d: &Drawing) -> bool { + d.model.iter().any(|e| matches!(e.common.color, Color::True(_)) || e.common.transparency != Transparency::ByLayer) + || d.layers.iter().any(|l| matches!(l.color, Color::True(_)) || l.transparency != 0) +} + +fn check_version(case: &str, original: &Drawing, expected: &Drawing, version: Version) { + check_version_with(case, original, expected, version, |_, _, _| {}); +} + +fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, version: Version, extra: impl Fn(&Drawing, &Drawing, &str)) { + check_version_using(case, original, expected, version, |a, b, context| { + compare::model(a, b, context); + extra(a, b, context); + }); +} + +fn check_version_using(case: &str, original: &Drawing, expected: &Drawing, version: Version, verify: impl Fn(&Drawing, &Drawing, &str)) { + let options = SaveOptions { version: Some(version), binary: false }; + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("{case} · DWG {version} · pass={pass}"); + let bytes = write_with(¤t, "memory.dwg", &options).unwrap_or_else(|e| panic!("{context}: write: {e}")); + assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version"); + let back = read(&bytes, "memory.dwg").unwrap_or_else(|e| panic!("{context}: read: {e}")); + assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records); + verify(expected, &back, &context); + if pass == 2 { + verify(¤t, &back, &format!("{context} · resave stability")); + } + current = back; + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/attributes.rs b/crates/io/tests/it/roundtrip/dxf/attributes.rs new file mode 100644 index 000000000..0962f7bc9 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/attributes.rs @@ -0,0 +1,163 @@ +//! Check multiline ATTRIB values, embedded geometry and legacy placement independently. +//! Unchanged MTEXT rendering does not excuse corrupted attribute values or fallback TEXT +//! justification (#2255). Full field preservation remains in `extended::multiline_attributes`; +//! the legacy-placement test must reach its geometry assertion even while that check fails. +//! ODA's DWG specification §20.4.4 maps the R2018+ embedded object to DXF group 101. + +use super::super::cases::blocks; +use cadcraft_doc::{Attrib, Drawing, EntityKind, Space}; +use cadcraft_io::{SaveOptions, Version}; +use cadcraft_render::{DisplayList, Options, build}; + +const EDITED_VALUE: &str = "edited {value}\nthird line"; +const EDITED_CONTENTS: &str = "edited \\{value\\}\\Pthird line"; + +/// The fixture's primary attribute, not its unrelated single-line control or its ATTDEF. +fn primary(drawing: &Drawing) -> &Attrib { + let mut attributes = drawing + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.attribs.iter()), + _ => None, + }) + .flatten() + .filter(|a| a.tag == "ID"); + let attribute = attributes.next().expect("primary ATTRIB must survive"); + assert!(attributes.next().is_none(), "primary ATTRIB must not be duplicated"); + attribute +} + +fn edit_primary(drawing: &mut Drawing, edit: impl Fn(&mut Attrib)) { + let handles: Vec<_> = drawing.model.iter().map(|e| e.handle).collect(); + let mut changed = 0; + for handle in handles { + drawing.model.modify(handle, |e| { + if let EntityKind::Insert(i) = &mut e.kind { + for a in i.attribs.iter_mut().filter(|a| a.tag == "ID") { + edit(a); + changed += 1; + } + } + }); + } + assert_eq!(changed, 1, "edit exactly the primary ATTRIB"); +} + +fn reopen(drawing: &Drawing, version: Version, binary: bool, context: &str) -> Drawing { + let options = SaveOptions { version: Some(version), binary }; + let bytes = cadcraft_io::write_with(drawing, "attributes.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}")); + assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding"); + let back = cadcraft_io::read(&bytes, "attributes.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}")); + assert_eq!(cadcraft_io::save_version(&back), version, "{context}: version"); + assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records); + back +} + +fn geometry(drawing: &Drawing) -> DisplayList { + build(drawing, &Space::Model, &Options::default()) +} + +fn same_geometry(expected: &DisplayList, actual: &DisplayList, context: &str) { + assert!(!expected.prims.is_empty(), "{context}: nonempty fixture"); + assert_eq!(expected.prims.len(), actual.prims.len(), "{context}: primitive count"); + for (a, b) in expected.prims.iter().zip(&actual.prims) { + assert_eq!(a.kind, b.kind, "{context}: primitive kind"); + assert_eq!(expected.points(a), actual.points(b), "{context}: world-space geometry"); + } +} + +/// Check saved data, not only the effective text drawn by `multiline_text()`. That method can +/// hide a corrupt value/read_as pair or replace stale embedded contents from the plain value. +fn saved_text(drawing: &Drawing, value: &str, contents: &str, context: &str) { + let a = primary(drawing); + assert_eq!(a.text.value, value, "{context}: attribute value"); + let m = a.multiline.as_ref().expect("embedded MTEXT must survive"); + assert_eq!(m.read_as, value, "{context}: read_as"); + assert_eq!(m.mtext.contents, contents, "{context}: raw embedded contents"); +} + +#[test] +fn multiline_geometry_and_value_edits_survive_two_saves() { + for case in blocks::multiline_attributes() { + let expected = geometry(&case.drawing); + let mut edited = case.drawing.clone(); + edit_primary(&mut edited, |a| a.text.value = EDITED_VALUE.into()); + let edited_expected = geometry(&edited); + assert_ne!(expected.verts, edited_expected.verts, "value edit must actually change the drawn text"); + for binary in [false, true] { + let mut current = case.drawing.clone(); + for pass in 1..=2 { + let context = format!("{} · R2018 · binary={binary} · pass={pass}", case.name); + current = reopen(¤t, Version::R2018, binary, &context); + saved_text(¤t, "first\nsecond", "first\\Psecond", &context); + same_geometry(&expected, &geometry(¤t), &context); + let mut edited = current.clone(); + edit_primary(&mut edited, |a| a.text.value = EDITED_VALUE.into()); + same_geometry(&edited_expected, &geometry(&edited), &format!("{context} · edit after reopening")); + let context = format!("{context} · save after editing"); + let back = reopen(&edited, Version::R2018, binary, &context); + saved_text(&back, EDITED_VALUE, EDITED_CONTENTS, &context); + same_geometry(&edited_expected, &geometry(&back), &context); + } + } + } +} + +#[test] +fn saved_text_checks_reject_corruption_hidden_by_rendering() { + let mut source = blocks::multiline_attributes().remove(0).drawing; + edit_primary(&mut source, |a| a.text.value = EDITED_VALUE.into()); + let back = reopen(&source, Version::R2018, true, "mutation control"); + saved_text(&back, EDITED_VALUE, EDITED_CONTENTS, "mutation control"); + for field in ["value", "read_as", "contents", "value-and-read_as"] { + let mut corrupt = back.clone(); + edit_primary(&mut corrupt, |a| { + let m = a.multiline.as_mut().unwrap(); + match field { + "value" => a.text.value = "CORRUPTED VALUE".into(), + "read_as" => m.read_as = "CORRUPTED VALUE".into(), + "contents" => m.mtext.contents = "CORRUPTED CONTENTS".into(), + "value-and-read_as" => { + a.text.value = "CORRUPTED VALUE".into(); + m.read_as.clone_from(&a.text.value); + } + _ => unreachable!(), + } + }); + if field == "value-and-read_as" { + same_geometry(&geometry(&back), &geometry(&corrupt), "corruption invisible to geometry"); + } + assert!(std::panic::catch_unwind(|| saved_text(&corrupt, EDITED_VALUE, EDITED_CONTENTS, field)).is_err(), "must reject {field}"); + } +} + +#[test] +#[ignore = "Confirmed DXF placement loss (#2255): R2018 save changes subsequent legacy ATTRIB placement; existing reader fix #1942"] +fn legacy_placement_is_independent_of_an_intermediate_modern_save() { + for case in blocks::multiline_attributes() { + for version in [Version::R2000, Version::R2004] { + for binary in [false, true] { + let legacy_context = format!("{} · legacy={version} · binary={binary}", case.name); + let direct = reopen(&case.drawing, version, binary, &legacy_context); + let a = primary(&direct); + assert!(a.multiline.is_none(), "{legacy_context}: single-line fallback required"); + assert_eq!(a.text.value, "first second", "{legacy_context}: fallback value"); + let expected = geometry(&direct); + for modern_binary in [false, true] { + let mut current = case.drawing.clone(); + for pass in 1..=2 { + let context = format!("{legacy_context} · intermediate=R2018 · binary={modern_binary} · pass={pass}"); + current = reopen(¤t, Version::R2018, modern_binary, &context); + let indirect = reopen(¤t, version, binary, &context); + let a = primary(&indirect); + assert!(a.multiline.is_none(), "{context}: single-line fallback required"); + assert_eq!(a.text.value, "first second", "{context}: fallback value"); + same_geometry(&expected, &geometry(&indirect), &context); + } + } + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/common.rs b/crates/io/tests/it/roundtrip/dxf/common.rs new file mode 100644 index 000000000..6e40977c9 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/common.rs @@ -0,0 +1,197 @@ +//! Independent input and output contracts for common LINE/CIRCLE/TEXT properties. Read fixtures +//! are handwritten DXF, never writer output; write expectations are literal groups, never a +//! reread Drawing. Absence, explicit zero and inheritance are distinct. Pending tests localize +//! the existing extrusion and ByBlock plot-style losses without accepting them as correct. +//! References: https://ezdxf.readthedocs.io/en/stable/dxfentities/dxfgfx.html +//! https://ezdxf.readthedocs.io/en/stable/concepts/true_color.html +//! https://ezdxf.readthedocs.io/en/stable/concepts/transparency.html + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, Drawing, EntityKind, Layer, Lineweight, Transparency}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +fn fixture(properties: &str) -> Drawing { + let records = [ + ("LINE", "AcDbLine", "10\n1\n20\n2\n30\n3\n11\n4\n21\n5\n31\n6\n"), + ("CIRCLE", "AcDbCircle", "10\n7\n20\n8\n30\n9\n40\n2.5\n"), + ("TEXT", "AcDbText", "10\n3\n20\n4\n40\n2.5\n1\nfixture\n"), + ]; + let mut bytes = String::from("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n"); + for (kind, subclass, geometry) in records { + bytes.push_str(&format!("0\n{kind}\n100\nAcDbEntity\n{properties}100\n{subclass}\n{geometry}")); + } + bytes.push_str("0\nENDSEC\n0\nEOF\n"); + read(bytes.as_bytes(), "fixture.dxf").unwrap() +} + +fn assert_read(properties: &str, expected: &Common) { + let drawing = fixture(properties); + assert_eq!(drawing.model.len(), 3); + assert!(drawing.skipped_records.is_empty()); + for entity in drawing.model.iter() { + assert_eq!(&entity.common, expected, "{} with groups {properties:?}", entity.kind.type_name()); + } +} + +fn output(common: &Common, version: Version, binary: bool) -> Vec<(String, Vec)> { + let mut drawing = super::conformance::model(); + if drawing.layer(&common.layer).is_none() { + drawing.layers.push(Layer::new(&common.layer)); + } + let handles: Vec<_> = drawing.model.iter().map(|e| e.handle).collect(); + for handle in handles { + drawing.model.modify(handle, |e| e.common = common.clone()); + } + let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(entities.iter().map(|(kind, _)| kind.as_str()).collect::>(), ["LINE", "CIRCLE", "TEXT"]); + entities +} + +fn assert_write(common: Common, present: &[Tag], absent: &[i32]) { + for version in Version::ALL { + for binary in [false, true] { + for (kind, groups) in output(&common, version, binary) { + let context = format!("{version} binary={binary} {kind} {common:?}"); + for tag in present { + assert_eq!(groups.iter().filter(|t| t.code == tag.code).collect::>(), [tag], "{context}: group {}", tag.code); + } + for code in absent { + assert!(!groups.iter().any(|t| t.code == *code), "{context}: group {code} should be omitted"); + } + } + } + } +} + +#[test] +fn missing_optional_groups_and_explicit_defaults() { + assert_read("", &Common::default()); + assert_read("8\n0\n6\nByLayer\n62\n256\n370\n-1\n48\n1\n60\n0\n39\n0\n210\n0\n220\n0\n230\n1\n", &Common::default()); + // Omission is this writer's convention, not a requirement that other DXF writers omit them. + assert_write(Common::default(), &[Tag::s(8, "0")], &[6, 62, 370, 48, 60, 39, 420, 440, 210, 220, 230]); +} + +#[test] +fn layer_linetype_scale_visibility_and_thickness_have_independent_groups() { + let common = + Common { layer: "Fixture".into(), linetype: "Continuous".into(), ltscale: 2.75, visible: false, thickness: -1.25, ..Common::default() }; + assert_read("8\nFixture\n6\nContinuous\n48\n2.75\n60\n1\n39\n-1.25\n", &common); + assert_write(common, &[Tag::s(8, "Fixture"), Tag::s(6, "Continuous"), Tag::f(48, 2.75), Tag::i(60, 1), Tag::f(39, -1.25)], &[62, 420, 440]); + assert_read("6\nByBlock\n", &Common { linetype: "ByBlock".into(), ..Common::default() }); + assert_write(Common { linetype: "ByBlock".into(), ..Common::default() }, &[Tag::s(6, "ByBlock")], &[]); +} + +#[test] +fn lineweight_distinguishes_logical_modes_and_explicit_zero() { + for (value, lineweight) in [ + (-1, Lineweight::ByLayer), + (-2, Lineweight::ByBlock), + (-3, Lineweight::Default), + (0, Lineweight::Mm100(0)), + (53, Lineweight::Mm100(53)), + (211, Lineweight::Mm100(211)), + ] { + let common = Common { lineweight, ..Common::default() }; + assert_read(&format!("370\n{value}\n"), &common); + if value == -1 { + assert_write(common, &[], &[370]); + } else { + assert_write(common, &[Tag::i(370, value)], &[]); + } + } +} + +#[test] +fn aci_color_distinguishes_inheritance_and_explicit_color() { + for (value, color) in + [(256, Color::ByLayer), (0, Color::ByBlock)].into_iter().chain((1..=255).map(|index| (i64::from(index), Color::Index(index)))) + { + let common = Common { color, ..Common::default() }; + assert_read(&format!("62\n{value}\n"), &common); + if value == 256 { + assert_write(common, &[], &[62, 420]); + } else { + assert_write(common, &[Tag::i(62, value)], &[420]); + } + } +} + +#[test] +fn true_color_overrides_aci_in_either_tag_order_including_black() { + // 0x12_34_56 = 1,193,046. The black value 0 must not be mistaken for an absent group. + for (value, rgb) in [(1_193_046, Rgb(0x12, 0x34, 0x56)), (0, Rgb(0, 0, 0))] { + let common = Common { color: Color::True(rgb), ..Common::default() }; + for aci in [0, 1, 256] { + assert_read(&format!("62\n{aci}\n420\n{value}\n"), &common); + assert_read(&format!("420\n{value}\n62\n{aci}\n"), &common); + } + assert_write(common, &[Tag::i(420, value)], &[]); + } +} + +#[test] +fn transparency_distinguishes_inheritance_and_explicit_opacity() { + assert_read("", &Common::default()); + assert_write(Common::default(), &[], &[440]); + // High byte: 01 = ByBlock, 02 = explicit alpha. Low byte: opacity, not transparency. + // Independent integer oracle: floor(255 * (1 - p/100)) = 255 - ceil(255*p/100). + // No production alpha conversion is used. Check every supported integer percentage. + for (raw, transparency) in std::iter::once((0x0100_0000, Transparency::ByBlock)).chain((0..=90).map(|percent| { + let opacity = 255 - (255 * i64::from(percent) + 99) / 100; + (0x0200_0000 + opacity, Transparency::Percent(percent)) + })) { + let common = Common { transparency, ..Common::default() }; + assert_read(&format!("440\n{raw}\n"), &common); + assert_write(common, &[Tag::i(440, raw)], &[]); + } + // Another writer may round up instead; both neighboring alpha values map to the same + // integer percentage. This is a read expectation, not normalization of writer output. + assert_read("440\n33554560\n", &Common { transparency: Transparency::Percent(50), ..Common::default() }); + assert_read("440\n33554458\n", &Common { transparency: Transparency::Percent(90), ..Common::default() }); +} + +#[test] +fn reads_line_extrusion_without_changing_world_space_endpoints() { + for (groups, expected) in [("210\n0\n220\n0\n230\n-1\n", Vec3::new(0.0, 0.0, -1.0)), ("220\n1\n230\n0\n", Vec3::new(0.0, 1.0, 0.0))] { + let drawing = fixture(groups); + let line = drawing.model.iter().next().unwrap(); + assert_eq!(line.common.extrusion, expected); + let EntityKind::Line(geometry) = &line.kind else { panic!("expected LINE") }; + assert_eq!(geometry.a, Vec3::new(1.0, 2.0, 3.0)); + assert_eq!(geometry.b, Vec3::new(4.0, 5.0, 6.0)); + } +} + +#[test] +#[ignore = "Known writer loss: a non-default extrusion is omitted; the independent LINE read test passes"] +fn writes_nondefault_extrusion_groups() { + // X/Y may be omitted at zero, but Z=-1 must not disappear into the default Z=1. + assert_write(Common { extrusion: Vec3::new(0.0, 0.0, -1.0), ..Common::default() }, &[Tag::f(230, -1.0)], &[]); +} + +#[test] +#[ignore = "Known writer loss: ByLayer and ByBlock plot styles produce identical files"] +fn writer_must_distinguish_bylayer_from_byblock_plot_style() { + // The available group-380 HEADER enum is not sufficient evidence for a universal entity + // encoding; do not prescribe an unverified tag here. Identical complete outputs prove the + // loss is already in the writer, independently of any reader or representation choice. + let by_layer = super::conformance::model(); + let mut by_block = by_layer.clone(); + let handles: Vec<_> = by_block.model.iter().map(|e| e.handle).collect(); + for handle in handles { + by_block.model.modify(handle, |e| e.common.plot_style = "ByBlock".into()); + } + for version in Version::ALL { + for binary in [false, true] { + let options = SaveOptions { version: Some(version), binary }; + let a = write_with(&by_layer, "memory.dxf", &options).unwrap(); + let b = write_with(&by_block, "memory.dxf", &options).unwrap(); + assert!(a != b, "{version} binary={binary}: plot style is not encoded anywhere in the file"); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/conformance.rs b/crates/io/tests/it/roundtrip/dxf/conformance.rs new file mode 100644 index 000000000..3f036be2c --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/conformance.rs @@ -0,0 +1,185 @@ +//! Independent synthetic entity records prevent matching reader/writer mistakes from passing +//! the round-trip suite. Only public DXF group meanings are used; no reference-app files or assets. +//! Entity semantics: https://ezdxf.readthedocs.io/en/stable/dxfentities/text.html +//! and https://ezdxf.readthedocs.io/en/stable/dxfentities/line.html + +use cadcraft_color::Color; +use cadcraft_doc::{Circle, Common, Drawing, Entity, EntityKind, HAlign, Handle, Line, Space, Text, VAlign}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +use super::compare; + +const ENTITIES: &[u8] = b"0\nSECTION\n2\nENTITIES\n\ +0\nLINE\n5\n100\n8\n0\n62\n3\n60\n1\n39\n-2.5\n10\n1.25\n20\n-2.5\n30\n3.75\n11\n-4.5\n21\n5.25\n31\n-6.75\n\ +0\nCIRCLE\n5\n101\n8\n0\n10\n7.25\n20\n-8.5\n30\n9.75\n40\n2.125\n\ +0\nTEXT\n5\n102\n8\n0\n10\n-3.25\n20\n4.5\n30\n1.75\n40\n2.5\n1\nfixture text\n50\n30\n41\n1.25\n51\n-15\n7\nStandard\n\ +0\nENDSEC\n0\nEOF\n"; + +pub(super) fn model() -> Drawing { + let mut d = Drawing::new_metric(); + d.add( + &Space::Model, + Common { color: Color::Index(3), visible: false, thickness: -2.5, ..Common::default() }, + EntityKind::Line(Line { a: Vec3::new(1.25, -2.5, 3.75), b: Vec3::new(-4.5, 5.25, -6.75) }), + ) + .unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(7.25, -8.5, 9.75), radius: 2.125 })).unwrap(); + d.add( + &Space::Model, + Common::default(), + EntityKind::Text(Text { + insert: Vec3::new(-3.25, 4.5, 1.75), + align_pt: None, + height: 2.5, + value: "fixture text".into(), + rotation: 30_f64.to_radians(), + width_factor: 1.25, + oblique: -15_f64.to_radians(), + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + }), + ) + .unwrap(); + d +} + +#[test] +fn reads_independent_line_circle_and_text_records() { + let back = read(ENTITIES, "fixture.dxf").unwrap(); + compare::model(&model(), &back, "independent entity fixture"); +} + +#[test] +fn writes_standard_entity_groups_not_just_self_readable_data() { + let expected = [ + ( + "LINE", + vec![ + Tag::s(5, "100"), + Tag::i(62, 3), + Tag::i(60, 1), + Tag::f(39, -2.5), + Tag::f(10, 1.25), + Tag::f(20, -2.5), + Tag::f(30, 3.75), + Tag::f(11, -4.5), + Tag::f(21, 5.25), + Tag::f(31, -6.75), + ], + ), + ("CIRCLE", vec![Tag::s(5, "101"), Tag::f(10, 7.25), Tag::f(20, -8.5), Tag::f(30, 9.75), Tag::f(40, 2.125)]), + ( + "TEXT", + vec![ + Tag::s(5, "102"), + Tag::f(10, -3.25), + Tag::f(20, 4.5), + Tag::f(30, 1.75), + Tag::f(40, 2.5), + Tag::s(1, "fixture text"), + Tag::f(41, 1.25), + Tag::s(7, "Standard"), + ], + ), + ]; + for version in Version::ALL { + for binary in [false, true] { + let bytes = write_with(&model(), "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(entities.len(), expected.len()); + for ((kind, groups), (expected_kind, expected_groups)) in entities.iter().zip(&expected) { + let context = format!("{version} binary={binary} {expected_kind}"); + assert_eq!(kind, expected_kind, "{context}"); + for tag in expected_groups { + let values: Vec<_> = groups.iter().filter(|t| t.code == tag.code).collect(); + assert_eq!(values, [tag], "{context}: group {}", tag.code); + } + if kind == "TEXT" { + for (code, degrees) in [(50, 30.0), (51, -15.0)] { + let values: Vec<_> = groups.iter().filter(|t| t.code == code).collect(); + assert_eq!(values.len(), 1, "{context}: group {code}"); + assert!((values[0].f64() - degrees).abs() < 1e-13, "{context}: group {code} is in degrees"); + } + } + } + } + } +} + +#[test] +fn reads_text_alignment_codes_independently_of_the_writer() { + let horizontal = [(0, HAlign::Left), (1, HAlign::Center), (2, HAlign::Right), (3, HAlign::Aligned), (4, HAlign::Middle), (5, HAlign::Fit)]; + let vertical = [(0, VAlign::Baseline), (1, VAlign::Bottom), (2, VAlign::Middle), (3, VAlign::Top)]; + for (hcode, halign) in horizontal { + for (vcode, valign) in vertical { + if hcode >= 3 && vcode != 0 { + continue; + } + let bytes = format!( + "0\nSECTION\n2\nENTITIES\n0\nTEXT\n10\n3\n20\n4\n30\n5\n40\n2.5\n1\nA\n72\n{hcode}\n73\n{vcode}\n11\n6\n21\n7\n31\n8\n0\nENDSEC\n0\nEOF\n" + ); + let back = read(bytes.as_bytes(), "fixture.dxf").unwrap(); + let EntityKind::Text(text) = &back.model.iter().next().unwrap().kind else { panic!("expected TEXT") }; + assert_eq!((text.halign, text.valign), (halign, valign), "72={hcode}, 73={vcode}"); + assert_eq!(text.insert, Vec3::new(3.0, 4.0, 5.0)); + let expected_point = if hcode == 0 && vcode == 0 { None } else { Some(Vec3::new(6.0, 7.0, 8.0)) }; + assert_eq!(text.align_pt, expected_point, "72={hcode}, 73={vcode}"); + } + } +} + +// Odd values above 2^53, above i64::MAX and near u64::MAX expose floating-point coercion, +// signed conversion and truncation. Literal hex and numeric values are independent expectations. +const LARGE_HANDLES: [(u64, &str); 3] = + [(9_007_199_254_740_993, "20000000000001"), (9_223_372_036_854_775_809, "8000000000000001"), (18_446_744_073_709_486_081, "FFFFFFFFFFFF0001")]; + +#[test] +fn reads_large_literal_hex_handles_without_numeric_coercion() { + let mut fixture = String::from("0\nSECTION\n2\nENTITIES\n"); + for (_, hex) in LARGE_HANDLES { + fixture.push_str(&format!("0\nLINE\n5\n{hex}\n8\n0\n10\n0\n20\n0\n11\n1\n21\n2\n")); + } + fixture.push_str("0\nENDSEC\n0\nEOF\n"); + let back = read(fixture.as_bytes(), "fixture.dxf").unwrap(); + assert!(back.read_notes.is_empty()); + assert!(back.skipped_records.is_empty()); + assert_eq!(back.model.iter().map(|e| e.handle.0).collect::>(), LARGE_HANDLES.map(|(numeric, _)| numeric)); +} + +#[test] +fn writes_large_handles_as_exact_hex_groups() { + let mut drawing = Drawing::new_metric(); + for (numeric, _) in LARGE_HANDLES { + drawing.model.push(Entity::new(Handle(numeric), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 2.0, 0.0) }))); + } + drawing.handseed = 0xffff_ffff_ffff_1000; + for version in Version::ALL { + for binary in [false, true] { + let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(entities.len(), LARGE_HANDLES.len()); + for ((kind, groups), (_, hex)) in entities.iter().zip(LARGE_HANDLES) { + assert_eq!(kind, "LINE"); + assert_eq!(groups.iter().filter(|t| t.code == 5).collect::>(), [&Tag::s(5, hex)], "{version} binary={binary} handle={hex}"); + } + } + } +} + +#[test] +fn left_baseline_text_ignores_the_unused_second_alignment_point() { + // DXF TEXT groups 11/21/31 are meaningful only when 72 or 73 is nonzero. The model + // intentionally represents the unused point as None, not Some(Vec3::ZERO). + let bytes = b"0\nSECTION\n2\nENTITIES\n0\nTEXT\n10\n3\n20\n4\n40\n2.5\n1\nA\n72\n0\n73\n0\n11\n0\n21\n0\n31\n0\n0\nENDSEC\n0\nEOF\n"; + let back = read(bytes, "fixture.dxf").unwrap(); + let EntityKind::Text(text) = &back.model.iter().next().unwrap().kind else { panic!("expected TEXT") }; + assert_eq!(text.align_pt, None); + assert_eq!(text.insert, Vec3::new(3.0, 4.0, 0.0)); +} diff --git a/crates/io/tests/it/roundtrip/dxf/entities.rs b/crates/io/tests/it/roundtrip/dxf/entities.rs new file mode 100644 index 000000000..5816d5017 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/entities.rs @@ -0,0 +1,121 @@ +//! DXF runs every shared input family in both encodings and all writable versions. Keep known +//! failures isolated from other property sweeps; the comparison contract is never weakened. + +use super::super::cases; + +fn check(cases: Vec) { + for case in cases { + super::check(&case.name, &case.drawing); + } +} + +#[test] +fn empty_model_and_defaults() { + check(cases::empty_and_defaults()); +} +#[test] +fn line_endpoints_and_circle_centers_radii() { + check(cases::geometry()); +} +#[test] +fn layers() { + check(cases::layers()); +} +#[test] +fn layer_inherited_linetypes() { + check(cases::layer_linetypes()); +} +#[test] +fn colors() { + check(cases::colors()); +} +#[test] +fn linetypes() { + check(cases::linetypes()); +} +#[test] +fn lineweights() { + check(cases::lineweights()); +} +#[test] +fn linetype_scales() { + check(cases::linetype_scales()); +} +#[test] +fn transparencies() { + check(cases::transparencies()); +} +#[test] +fn visibility() { + check(cases::visibility()); +} +#[test] +fn thicknesses() { + check(cases::thicknesses()); +} +#[test] +fn plot_styles() { + check(cases::plot_styles()); +} +#[test] +fn foreign_xdata() { + check(cases::foreign_xdata()); +} +#[test] +fn common_property_interactions() { + check(cases::common_interactions("ByLayer")); + check(cases::common_interactions("Review")); +} +#[test] +fn text_values() { + check(cases::text_values()); +} +#[test] +fn text_literal_escapes() { + check(cases::text_literal_escapes()); +} +#[test] +fn text_heights() { + check(cases::text_heights()); +} +#[test] +fn text_rotations() { + check(cases::text_rotations()); +} +#[test] +fn text_widths() { + check(cases::text_widths()); +} +#[test] +fn text_obliques() { + check(cases::text_obliques()); +} +#[test] +fn text_styles() { + check(cases::text_styles()); +} +#[test] +fn annotation_scales() { + check(cases::annotation_scales()); +} +#[test] +fn text_alignment_with_points() { + check(cases::coplanar_text_alignment()); + check(cases::noncoplanar_text_alignment()); +} +#[test] +fn text_alignment_without_points() { + check(cases::text_alignment(false)); +} + +#[test] +#[ignore = "Known writer loss: ByBlock plot style becomes ByLayer on the first R2000 ASCII save"] +fn plot_style_byblock_is_preserved() { + check(cases::byblock_plot_style()); +} + +#[test] +#[ignore = "Known writer loss: groups 210/220/230 are omitted; a LINE's normal becomes (0,0,1)"] +fn nondefault_extrusion_is_preserved() { + check(cases::extrusions()); +} diff --git a/crates/io/tests/it/roundtrip/dxf/extended.rs b/crates/io/tests/it/roundtrip/dxf/extended.rs new file mode 100644 index 000000000..4e8415337 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/extended.rs @@ -0,0 +1,334 @@ +//! Extended DXF property families. Pending losses stay separate from passing families; +//! version domains are explicit for Lock position, HELIX/MULTILEADER (2007) and the native +//! multiline-attribute representation (2018). + +use super::super::cases::{Case, blocks, complex, geometry, properties}; +use super::super::collections; +use cadcraft_io::Version; + +fn check(cases: Vec) { + assert!(!cases.is_empty()); + for case in cases { + super::check(&case.name, &case.drawing); + } +} +fn since_2007(cases: Vec) { + assert!(!cases.is_empty()); + for case in cases { + for version in Version::ALL.into_iter().filter(|v| *v >= Version::R2007) { + super::check_version(&case.name, &case.drawing, &case.drawing, version); + } + } +} + +#[test] +fn mixed_case_layers() { + check(properties::mixed_case_references("layer")); +} +#[test] +fn mixed_case_linetypes() { + check(properties::mixed_case_references("linetype")); +} +#[test] +fn mixed_case_styles() { + check(properties::mixed_case_references("style")); +} +#[test] +fn layer_flags() { + check(properties::layer_flags()); +} +#[test] +fn layer_appearance() { + check(properties::layer_appearance()); +} +#[test] +fn layer_plot_styles() { + check(properties::layer_plot_styles()); +} +#[test] +fn text_style_metrics() { + check(properties::text_style_metrics()); +} +#[test] +fn text_style_flags() { + check(properties::text_style_flags()); +} +#[test] +fn text_style_fonts() { + check(properties::text_style_fonts()); +} +#[test] +fn linetype_text() { + check(properties::complex_linetypes(false)); +} +#[test] +fn linetype_shapes() { + check(properties::complex_linetypes(true)); +} +#[test] +fn xdata_lists() { + check(properties::xdata_lists()); +} +#[test] +fn xdata_points() { + check(properties::xdata_points()); +} +#[test] +fn xdata_handles() { + check(properties::xdata_handles()); +} +#[test] +fn handles_and_order() { + check(properties::handles_and_order()); +} +#[test] +fn handles_near_integer_limits() { + check(properties::handles_near_integer_limits()); +} +#[test] +fn degenerate_lines() { + check(properties::degenerate_geometry()); +} +#[test] +fn layer_inheritance_overrides() { + check(properties::layer_inheritance_overrides()); +} +#[test] +fn alignment_style_interactions() { + check(properties::alignment_style_interactions()); +} +#[test] +fn nested_attributes() { + check(blocks::nested_attributes()); +} +#[test] +fn text_interactions() { + check(properties::text_interactions()); +} +#[test] +fn long_text() { + check(properties::long_text()); +} +#[test] +fn arcs() { + check(geometry::arcs()); +} +#[test] +fn ellipses() { + check(geometry::ellipses()); +} +#[test] +fn polyline_constant_widths() { + check(geometry::polylines(false)); +} +#[test] +fn polyline_variable_widths() { + check(geometry::polylines(true)); +} +#[test] +fn polylines_3d() { + check(geometry::polylines_3d()); +} +#[test] +fn splines() { + check(geometry::splines(false)); +} +#[test] +fn rational_splines() { + check(geometry::splines(true)); +} +#[test] +fn fit_splines() { + check(geometry::spline_fit(false)); + check(geometry::spline_fit(true)); +} +#[test] +fn points_and_rays() { + check(geometry::points_and_rays()); +} +#[test] +fn faces() { + check(geometry::faces()); +} +#[test] +fn mtext_placement() { + check(geometry::mtext_placement()); +} +#[test] +fn mtext_spacing() { + check(geometry::mtext_spacing()); +} +#[test] +fn mtext_contents() { + check(geometry::mtext_contents()); + check(geometry::mtext_carets()); +} +#[test] +fn mtext_long() { + check(geometry::mtext_long()); +} +#[test] +#[ignore = "Confirmed DXF loss: MTEXT column group 48 is also read as Common.ltscale"] +fn mtext_columns() { + check(geometry::mtext_columns()); +} +#[test] +fn leaders() { + check(geometry::leaders(false)); +} +#[test] +fn hatches() { + check(geometry::hatches()); +} +#[test] +fn tables() { + check(geometry::tables()); +} +#[test] +fn images() { + check(geometry::images()); +} +#[test] +fn wipeouts() { + check(geometry::wipeouts(true)); +} +#[test] +fn wipeout_implicit_closure() { + // The format closes the stored boundary explicitly; this is not an extra editable corner. + // Keep grip behavior separate from this serialization contract (#2256). + for (case, expected) in geometry::wipeouts(false).into_iter().zip(geometry::wipeouts(true)) { + for version in Version::ALL { + super::check_version(&case.name, &case.drawing, &expected.drawing, version); + } + } +} +#[test] +#[ignore = "Confirmed DXF loss: LEADER vertex Z coordinates become zero"] +fn leaders_3d() { + check(geometry::leaders(true)); +} +#[test] +#[ignore = "Confirmed DXF loss: ATTRIB constant flag becomes false"] +fn constant_attributes() { + check(blocks::attributes("constant")); +} +#[test] +fn block_transformations() { + check(blocks::transformations()); +} +#[test] +fn block_arrays() { + check(blocks::arrays()); +} +#[test] +fn block_metadata() { + check(blocks::metadata()); +} +#[test] +fn nested_blocks() { + check(blocks::nested(false)); +} +#[test] +fn cyclic_blocks() { + check(blocks::nested(true)); +} +#[test] +fn unreferenced_blocks() { + check(blocks::unreferenced()); +} +#[test] +fn attribute_flags() { + check(blocks::attributes("flags")); +} +#[test] +fn attribute_modes() { + check(blocks::attributes("modes")); +} +#[test] +fn attribute_lock() { + since_2007(blocks::attributes("lock")); +} +#[test] +#[ignore = "Confirmed DXF read loss (#2255): multiline header changes Left to Center and synthesizes align_pt; existing fix #1942"] +fn multiline_attributes() { + for case in blocks::multiline_attributes() { + super::check_version(&case.name, &case.drawing, &case.drawing, Version::R2018); + } +} +#[test] +fn attribute_overrides() { + check(blocks::attributes("overrides")); +} +#[test] +fn helices() { + since_2007(complex::helices()); +} +#[test] +fn mlines() { + check(complex::mlines()); +} +#[test] +fn mleaders() { + since_2007(complex::mleaders()); +} +#[test] +fn regions() { + check(complex::regions()); +} +#[test] +fn tolerances() { + check(complex::tolerances()); +} +#[test] +fn dimensions() { + check(complex::dimensions()); +} + +fn collection(case: Case, verify: fn(&cadcraft_doc::Drawing, &cadcraft_doc::Drawing, &str)) { + for version in Version::ALL { + super::check_version_with(&case.name, &case.drawing, &case.drawing, version, verify); + } +} +#[test] +fn header_variables() { + collection(collections::headers(), collections::assert_headers); + for key in ["CLAYER", "TEXTSTYLE"] { + collection(collections::named_header_reference(key), collections::assert_headers); + } +} +#[test] +fn unreferenced_tables() { + collection(collections::symbol_tables(), collections::assert_symbol_tables); +} +#[test] +fn foreign_dictionary_records() { + collection(collections::foreign_records(), collections::assert_foreign_records); +} +#[test] +fn unknown_entity_payload_and_references() { + for version in Version::ALL { + for binary in [false, true] { + super::super::unknown::check("memory.dxf", version, binary); + } + } +} +#[test] +fn views_and_ucs() { + collection(collections::views_and_ucs(), collections::assert_views_and_ucs); +} +#[test] +fn tiled_viewports() { + collection(collections::vports(), collections::assert_vports); +} +#[test] +fn groups() { + collection(collections::groups(), collections::assert_groups); +} +#[test] +fn layer_states() { + collection(collections::layer_states(), collections::assert_layer_states); +} +#[test] +fn layouts_and_paperspace() { + collection(collections::layouts(), collections::assert_layouts); +} diff --git a/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs b/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs new file mode 100644 index 000000000..b447638eb --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs @@ -0,0 +1,138 @@ +//! Independent synthetic DXF records for the extended geometry slice. Literal group streams +//! specify both reader input and writer expectations; neither side obtains its oracle from +//! the other. Group meanings: https://ezdxf.readthedocs.io/en/stable/dxfentities/index.html + +use super::super::{cases, compare}; +use cadcraft_doc::{Arc, Common, Ellipse, EntityKind, LwPolyline, MText, Point, RayLine}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::{PolyVertex, Vec2, Vec3}; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +struct Fixture { + wire_type: &'static str, + kind: EntityKind, + groups: &'static str, + output: Vec, +} + +fn fixtures() -> Vec { + vec![ + Fixture { + wire_type: "ARC", + kind: EntityKind::Arc(Arc { center: Vec3::new(1.25, -2.5, 3.75), radius: 2.5, start: 0.0, end: std::f64::consts::FRAC_PI_2 }), + groups: "10\n1.25\n20\n-2.5\n30\n3.75\n40\n2.5\n50\n0\n51\n90\n", + output: vec![Tag::f(10, 1.25), Tag::f(20, -2.5), Tag::f(30, 3.75), Tag::f(40, 2.5), Tag::f(50, 0.0), Tag::f(51, 90.0)], + }, + Fixture { + wire_type: "ELLIPSE", + kind: EntityKind::Ellipse(Ellipse { + center: Vec3::new(1.0, 2.0, 3.0), + major: Vec3::new(3.0, 4.0, 0.0), + ratio: 0.5, + start: 0.0, + end: std::f64::consts::TAU, + }), + groups: "10\n1\n20\n2\n30\n3\n11\n3\n21\n4\n31\n0\n40\n0.5\n41\n0\n42\n6.283185307179586\n", + output: vec![Tag::f(11, 3.0), Tag::f(21, 4.0), Tag::f(31, 0.0), Tag::f(40, 0.5), Tag::f(41, 0.0), Tag::f(42, std::f64::consts::TAU)], + }, + Fixture { + wire_type: "LWPOLYLINE", + kind: EntityKind::LwPolyline(LwPolyline { + vertices: vec![ + PolyVertex { p: Vec2::new(1.25, -2.5), bulge: 0.5, start_width: 0.125, end_width: 0.75 }, + PolyVertex::new(Vec2::new(5.0, 3.0)), + ], + closed: true, + plinegen: true, + const_width: 0.0, + elevation: -3.75, + }), + groups: "90\n2\n70\n129\n38\n-3.75\n10\n1.25\n20\n-2.5\n40\n0.125\n41\n0.75\n42\n0.5\n10\n5\n20\n3\n", + output: vec![ + Tag::i(90, 2), + Tag::i(70, 129), + Tag::f(38, -3.75), + Tag::f(10, 1.25), + Tag::f(10, 5.0), + Tag::f(20, -2.5), + Tag::f(20, 3.0), + Tag::f(40, 0.125), + Tag::f(41, 0.75), + Tag::f(42, 0.5), + ], + }, + Fixture { + wire_type: "MTEXT", + kind: EntityKind::MText(MText { + insert: Vec3::new(1.25, -2.5, 3.75), + height: 2.25, + width: 12.5, + attach: 5, + rotation: std::f64::consts::FRAC_PI_2, + style: "Standard".into(), + contents: "first\\Psecond".into(), + line_spacing: 1.75, + line_spacing_exact: true, + columns: None, + }), + groups: "10\n1.25\n20\n-2.5\n30\n3.75\n40\n2.25\n41\n12.5\n71\n5\n50\n90\n7\nStandard\n1\nfirst\\Psecond\n73\n2\n44\n1.75\n", + output: vec![ + Tag::f(40, 2.25), + Tag::f(41, 12.5), + Tag::i(71, 5), + Tag::f(50, 90.0), + Tag::s(7, "Standard"), + Tag::s(1, "first\\Psecond"), + Tag::i(73, 2), + Tag::f(44, 1.75), + ], + }, + Fixture { + wire_type: "POINT", + kind: EntityKind::Point(Point { p: Vec3::new(1.0, 2.0, 3.0), angle: 0.0 }), + groups: "10\n1\n20\n2\n30\n3\n", + output: vec![Tag::f(10, 1.0), Tag::f(20, 2.0), Tag::f(30, 3.0)], + }, + Fixture { + wire_type: "RAY", + kind: EntityKind::Ray(RayLine { base: Vec3::new(1.0, 2.0, 3.0), dir: Vec3::new(0.0, 1.0, 0.0) }), + groups: "10\n1\n20\n2\n30\n3\n11\n0\n21\n1\n31\n0\n", + output: vec![Tag::f(11, 0.0), Tag::f(21, 1.0), Tag::f(31, 0.0)], + }, + ] +} + +#[test] +fn reads_extended_entities_from_independent_groups() { + for fixture in fixtures() { + let name = fixture.wire_type; + let bytes = format!("0\nSECTION\n2\nENTITIES\n0\n{name}\n5\n100\n{}0\nENDSEC\n0\nEOF\n", fixture.groups); + let expected = cases::entity_case(name, Common::default(), fixture.kind.clone()); + let back = read(bytes.as_bytes(), "fixture.dxf").unwrap(); + compare::model(&expected.drawing, &back, name); + } +} + +#[test] +fn writes_extended_entities_with_independent_groups() { + for fixture in fixtures() { + let name = fixture.wire_type; + let source = cases::entity_case(name, Common::default(), fixture.kind.clone()); + for version in Version::ALL { + for binary in [false, true] { + let bytes = write_with(&source.drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let records = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(records.len(), 1); + let (kind, groups) = &records[0]; + assert_eq!(kind, name); + for code in fixture.output.iter().map(|t| t.code) { + let expected: Vec<_> = fixture.output.iter().filter(|t| t.code == code).collect(); + let actual: Vec<_> = groups.iter().filter(|t| t.code == code).collect(); + assert_eq!(actual, expected, "{name} {version} binary={binary} group={code}"); + } + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/mod.rs b/crates/io/tests/it/roundtrip/dxf/mod.rs new file mode 100644 index 000000000..929c1a483 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/mod.rs @@ -0,0 +1,56 @@ +//! DXF preservation through public Drawing → bytes → Drawing APIs, without disk or UI. +//! Covers modeled entity families, stored block graphs, references and selected drawing +//! collections, not whole-drawing/session equivalence. Each case runs in ASCII and binary, +//! twice, at every representable save version; expectations never come from writer output. +//! WIPEOUT's explicit closing marker is an authored format expectation, not a new corner. +//! +//! Run: `cargo test -p cadcraft-io --test it roundtrip::dxf`. +//! Add `-- --ignored` to reproduce the pending preservation cases. Those tests +//! assert desired preservation, not today's wrong result; remove `ignore` when fixing them. +//! Independent fixtures cover the core entity groups; deep compound-entity interoperability +//! still needs separate format checks beyond the self-round-trip matrix. + +mod attributes; +mod common; +mod conformance; +mod entities; +mod extended; +mod geometry_groups; +mod table_flags; + +use super::compare; +use cadcraft_doc::Drawing; +use cadcraft_io::{SaveOptions, Version, read, save_version, write_with}; + +fn check(case: &str, original: &Drawing) { + for version in Version::ALL { + check_version(case, original, original, version); + } +} + +fn check_version(case: &str, original: &Drawing, expected: &Drawing, version: Version) { + check_version_with(case, original, expected, version, |_, _, _| {}); +} + +fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, version: Version, extra: impl Fn(&Drawing, &Drawing, &str)) { + for binary in [false, true] { + let options = SaveOptions { version: Some(version), binary }; + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("{case} · {version} · binary={binary} · pass={pass}"); + let bytes = write_with(¤t, "memory.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}")); + assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding"); + let back = read(&bytes, "memory.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}")); + assert_eq!(save_version(&back), version, "{context}: version"); + assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records); + compare::model(expected, &back, &context); + extra(expected, &back, &context); + if pass == 2 { + compare::model(¤t, &back, &format!("{context} · resave stability")); + extra(¤t, &back, &format!("{context} · resave stability")); + } + current = back; + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/table_flags.rs b/crates/io/tests/it/roundtrip/dxf/table_flags.rs new file mode 100644 index 000000000..cefc28bb1 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/table_flags.rs @@ -0,0 +1,128 @@ +//! Independent standard LAYER/STYLE flag contracts. Literal wire-to-field mappings cover the +//! small modeled flag domains, including off-vs-frozen and vertical-vs-mirrored distinctions. +//! Reader fixtures are handwritten records; writer checks inspect tags, not our Drawing reader. +//! Annotative STYLE xdata is covered separately by the round-trip tests, not this native-bit test. +//! References: Autodesk DXF Reference, LAYER and STYLE symbol table entries. + +use cadcraft_color::Color; +use cadcraft_doc::{Drawing, Layer, TextStyle}; +use cadcraft_dxf::{Tag, Value, records, sections}; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +fn layers() -> Vec<(Layer, i64)> { + let mut cases = Vec::new(); + // (group 70, frozen, frozen in new viewports, locked). Off is the sign of 62, + // not another flag; plot is group 290 and must not accidentally change any of these. + for (flags, frozen, vp_freeze_new, locked) in [ + (0, false, false, false), + (1, true, false, false), + (2, false, true, false), + (3, true, true, false), + (4, false, false, true), + (5, true, false, true), + (6, false, true, true), + (7, true, true, true), + ] { + for on in [false, true] { + for plot in [false, true] { + cases.push(( + Layer { name: "Flags".into(), color: Color::Index(3), frozen, vp_freeze_new, locked, on, plot, ..Layer::default() }, + flags, + )); + } + } + } + cases +} + +fn styles() -> Vec<(TextStyle, i64, i64)> { + let mut cases = Vec::new(); + for (generation, backwards, upside_down) in [(0, false, false), (2, true, false), (4, false, true), (6, true, true)] { + for (flags, vertical) in [(0, false), (4, true)] { + cases.push(( + TextStyle { name: "Flags".into(), font: "Synthetic.ttf".into(), backwards, upside_down, vertical, ..TextStyle::default() }, + flags, + generation, + )); + } + } + cases +} + +fn fixture(table: &str, body: &str) -> Drawing { + let bytes = format!("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\n{table}\n70\n1\n0\n{table}\n2\nFlags\n{body}0\nENDTAB\n0\nENDSEC\n0\nEOF\n"); + let drawing = read(bytes.as_bytes(), "fixture.dxf").unwrap(); + assert!(drawing.read_notes.is_empty(), "{:?}", drawing.read_notes); + assert!(drawing.skipped_records.is_empty()); + drawing +} + +fn output(drawing: &Drawing, table: &str, version: Version, binary: bool) -> Vec { + let bytes = write_with(drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags); + let matching: Vec<_> = tables.into_iter().filter(|(kind, groups)| kind == table && groups.contains(&Tag::s(2, "Flags"))).collect(); + assert_eq!(matching.len(), 1, "{version} binary={binary} {table}"); + matching.into_iter().next().unwrap().1 +} + +fn group(tags: &[Tag], expected: Tag, context: &str) { + assert_eq!(tags.iter().filter(|t| t.code == expected.code).collect::>(), [&expected], "{context}"); +} + +#[test] +fn reads_all_layer_flag_combinations() { + for (expected, flags) in layers() { + let aci = if expected.on { 3 } else { -3 }; + let plot = i64::from(expected.plot); + let drawing = fixture("LAYER", &format!("70\n{flags}\n62\n{aci}\n6\nContinuous\n290\n{plot}\n")); + assert_eq!(drawing.layer("Flags"), Some(&expected), "70={flags} 62={aci} 290={plot}"); + } +} + +#[test] +fn writes_all_layer_flag_combinations() { + for (layer, flags) in layers() { + let mut drawing = Drawing::new_metric(); + drawing.layers.push(layer.clone()); + for version in Version::ALL { + for binary in [false, true] { + let tags = output(&drawing, "LAYER", version, binary); + let context = format!("{version} binary={binary} {layer:?}"); + group(&tags, Tag::i(70, flags), &context); + group(&tags, Tag::i(62, if layer.on { 3 } else { -3 }), &context); + if layer.plot { + // This writer omits the default true; the fixture above tests explicit true. + assert!(!tags.iter().any(|t| t.code == 290), "{context}"); + } else { + group(&tags, Tag::new(290, Value::Bool(false)), &context); + } + } + } + } +} + +#[test] +fn reads_all_native_text_style_flags() { + for (expected, flags, generation) in styles() { + let drawing = fixture("STYLE", &format!("70\n{flags}\n71\n{generation}\n40\n0\n41\n1\n50\n0\n3\nSynthetic.ttf\n4\n\n")); + assert_eq!(drawing.text_style("Flags"), Some(&expected), "70={flags} 71={generation}"); + } +} + +#[test] +fn writes_all_native_text_style_flags() { + for (style, flags, generation) in styles() { + let mut drawing = Drawing::new_metric(); + drawing.text_styles.push(style.clone()); + for version in Version::ALL { + for binary in [false, true] { + let tags = output(&drawing, "STYLE", version, binary); + let context = format!("{version} binary={binary} {style:?}"); + group(&tags, Tag::i(70, flags), &context); + group(&tags, Tag::i(71, generation), &context); + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/handle_limits.rs b/crates/io/tests/it/roundtrip/handle_limits.rs new file mode 100644 index 000000000..3cff2b3e4 --- /dev/null +++ b/crates/io/tests/it/roundtrip/handle_limits.rs @@ -0,0 +1,40 @@ +//! Exhausting generated-handle space may be refused, but must not silently lose authored +//! entities. This is separate from the preservation matrix's large handles with free capacity. +//! Neither a particular allocator strategy nor a successful save is required at exhaustion. + +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +use super::{cases::properties, compare}; + +fn preserves_or_refuses(path: &str, binary: bool) { + let original = properties::exhausted_handles().drawing; + for version in Version::ALL { + let options = SaveOptions { version: Some(version), binary }; + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("exhausted handles · {path} {version} binary={binary} pass={pass}"); + let Ok(bytes) = write_with(¤t, path, &options) else { + break; // An explicit save error is safe; a nominal success must preserve the data. + }; + let back = read(&bytes, path).unwrap_or_else(|e| panic!("{context}: successful save is unreadable: {e}")); + assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records); + compare::model(&original, &back, &context); + current = back; + } + } +} + +#[test] +#[ignore = "Confirmed DXF data loss: exhausted generated handles produce a successful save that reopens with no model entities"] +fn dxf_exhausted_handles_are_preserved_or_refused() { + for binary in [false, true] { + preserves_or_refuses("memory.dxf", binary); + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn dwg_exhausted_handles_are_preserved_or_refused() { + preserves_or_refuses("memory.dwg", false); +} diff --git a/crates/io/tests/it/roundtrip/mod.rs b/crates/io/tests/it/roundtrip/mod.rs new file mode 100644 index 000000000..d38f84eb1 --- /dev/null +++ b/crates/io/tests/it/roundtrip/mod.rs @@ -0,0 +1,11 @@ +//! In-memory preservation tests grouped by file format. DXF and DWG use the same drawing cases +//! and strict assertions; each format owns its version domain and explicitly pending regressions. + +mod cases; +mod collections; +mod compare; +#[cfg(not(target_arch = "wasm32"))] +mod dwg; +mod dxf; +mod handle_limits; +mod unknown; diff --git a/crates/io/tests/it/roundtrip/unknown.rs b/crates/io/tests/it/roundtrip/unknown.rs new file mode 100644 index 000000000..5883a078c --- /dev/null +++ b/crates/io/tests/it/roundtrip/unknown.rs @@ -0,0 +1,106 @@ +//! Unknown-entity preservation is payload- and reference-aware: the entity's handle, Common +//! fields, model-space membership and every application payload tag must survive. Transport +//! owner handles may be reallocated, but the DXF owner must still resolve to model space. +//! DWG may explicitly refuse an unregistered class; a successful save must not silently drop it. + +use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Line, RawTag, Unknown}; +use cadcraft_dxf::{records, sections}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +fn drawing() -> Drawing { + let mut d = super::cases::drawing(); + let handle = d.new_handle(); + let target = d.new_handle(); + let tags = vec![ + RawTag { code: 5, value: handle.hex() }, + // The store supplies the owner when saved, as it does for every modeled entity. + RawTag { code: 330, value: "0".into() }, + RawTag { code: 100, value: "AcDbEntity".into() }, + RawTag { code: 8, value: "0".into() }, + RawTag { code: 100, value: "SyntheticWidget".into() }, + RawTag { code: 1, value: "Original Ø payload".into() }, + RawTag { code: 90, value: "-7".into() }, + RawTag { code: 310, value: "00FF80".into() }, + RawTag { code: 10, value: "1.25".into() }, + RawTag { code: 20, value: "-2.5".into() }, + RawTag { code: 30, value: "3.75".into() }, + RawTag { code: 330, value: target.hex() }, + ]; + d.model.push(Entity::new(handle, EntityKind::Unknown(Unknown { dxf_type: "SYNTHETIC_WIDGET".into(), tags }))); + d.model.push(Entity::new(target, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 6.0, 7.0) }))); + d +} + +fn payload(tags: &[RawTag]) -> &[RawTag] { + let start = tags.iter().position(|t| t.code == 100 && t.value == "SyntheticWidget").expect("application subclass must survive"); + &tags[start..] +} + +fn preserved(original: &Drawing, back: &Drawing, context: &str) { + assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records); + assert_eq!(back.model.len(), original.model.len(), "{context}: entity count"); + for (a, b) in original.model.iter().zip(back.model.iter()) { + assert_eq!(b.handle, a.handle, "{context}: handle"); + assert_eq!(b.common, a.common, "{context}: Common"); + match (&a.kind, &b.kind) { + (EntityKind::Unknown(a), EntityKind::Unknown(b)) => { + assert_eq!(b.dxf_type, a.dxf_type, "{context}: application entity type"); + assert_eq!(payload(&b.tags), payload(&a.tags), "{context}: full payload including handle reference"); + } + _ => assert_eq!(b.kind, a.kind, "{context}: geometry"), + } + } +} + +fn dxf_owner(bytes: &[u8]) { + let tags = cadcraft_dxf::parse(bytes).unwrap(); + let sections = sections(&tags); + let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags); + let (_, model) = tables + .iter() + .find(|(kind, tags)| kind == "BLOCK_RECORD" && tags.iter().any(|t| t.code == 2 && t.str().eq_ignore_ascii_case("*Model_Space"))) + .unwrap(); + let model_handle = model.iter().find(|t| t.code == 5).unwrap().str(); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + let (_, widget) = entities.iter().find(|(kind, _)| kind == "SYNTHETIC_WIDGET").unwrap(); + assert_eq!(widget.iter().find(|t| t.code == 330).unwrap().str(), model_handle, "unknown entity belongs to the model-space block"); +} + +pub(super) fn check(path: &str, version: Version, binary: bool) { + let original = drawing(); + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("unknown entity {path} {version} binary={binary} pass={pass}"); + let bytes = match write_with(¤t, path, &SaveOptions { version: Some(version), binary }) { + Ok(bytes) => bytes, + Err(_) if path.ends_with(".dwg") && pass == 1 => return, + Err(error) => panic!("{context}: {error}"), + }; + if path.ends_with(".dxf") { + assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding"); + dxf_owner(&bytes); + } else { + assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version"); + } + let back = read(&bytes, path).unwrap(); + preserved(&original, &back, &context); + preserved(¤t, &back, &context); + current = back; + } +} + +#[test] +fn unknown_payload_assertion_rejects_lost_references() { + let original = drawing(); + preserved(&original, &original, "unchanged unknown entity"); + let mut changed = original.clone(); + let handle = changed.model.iter().next().unwrap().handle; + changed.model.modify(handle, |e| { + let EntityKind::Unknown(u) = &mut e.kind else { panic!("expected unknown entity") }; + u.tags.pop(); + }); + assert_eq!(original.model.iter().next().unwrap().common, Common::default()); + assert!(std::panic::catch_unwind(|| preserved(&original, &changed, "missing application reference")).is_err()); +} From 822f61b73a1b51ea4549675a5973bc1b56fa604f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:22:50 -0400 Subject: [PATCH 045/151] Release 0.15.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51693203d..0541ed60d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -643,7 +643,7 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -659,14 +659,14 @@ dependencies = [ [[package]] name = "cadcraft-color" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -676,7 +676,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -687,14 +687,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.14.0" +version = "0.15.0" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.14.0" +version = "0.15.0" dependencies = [ "encoding_rs", "log", @@ -703,7 +703,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -724,7 +724,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-geom", "harfrust 0.14.0", @@ -734,14 +734,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.14.0" +version = "0.15.0" dependencies = [ "acadrust", "cadcraft-color", @@ -762,7 +762,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -775,7 +775,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -789,7 +789,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -5894,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index da6bca3b4..2e2e45f5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.14.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From d4eab70e63f45db03159d1ae0dd781772ba0416f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:46:09 -0400 Subject: [PATCH 046/151] DWG bridge: build without acadrust on FreeBSD aarch64, where 0.6.6 doesn't compile Co-Authored-By: Claude Opus 5.5 --- crates/dwg/Cargo.toml | 5 +++- crates/dwg/build.rs | 12 +++++++++ crates/dwg/src/lib.rs | 60 +++++++++++++++++++++++-------------------- 3 files changed, 48 insertions(+), 29 deletions(-) create mode 100644 crates/dwg/build.rs diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml index a409e5b90..0be9fdf40 100644 --- a/crates/dwg/Cargo.toml +++ b/crates/dwg/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true [lints] workspace = true -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64, +# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname. +# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800). +[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies] # MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE). acadrust = "0.6.5" diff --git a/crates/dwg/build.rs b/crates/dwg/build.rs new file mode 100644 index 000000000..e97782ab4 --- /dev/null +++ b/crates/dwg/build.rs @@ -0,0 +1,12 @@ +//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions +//! there return an error that says to use DXF. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)"); + let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") { + println!("cargo::rustc-cfg=no_dwg_bridge"); + } +} diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index f7bf13dd6..037aa26b5 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -58,13 +58,13 @@ impl Limits { } /// A byte count in megabytes for messages ("9.9 MB", "1024 MB"). -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +#[cfg_attr(no_dwg_bridge, allow(dead_code))] fn mb(n: u64) -> String { let m = n as f64 / f64::from(1u32 << 20); if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(no_dwg_bridge))] mod native { use std::io::{Cursor, Write}; use std::panic::AssertUnwindSafe; @@ -741,18 +741,18 @@ mod native { } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(no_dwg_bridge))] pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as}; // Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The // stage then panics on entry, so a test through the public API shows whether it is guarded. -#[cfg(all(test, not(target_arch = "wasm32")))] +#[cfg(all(test, not(no_dwg_bridge)))] thread_local! { static PANIC_IN: std::cell::Cell> = const { std::cell::Cell::new(None) }; } /// Panics when a test asked for `stage` to fail (see [`PANIC_IN`]); does nothing otherwise. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(no_dwg_bridge))] #[allow(clippy::panic)] fn fail_in_test(_stage: &str) { #[cfg(test)] @@ -762,31 +762,35 @@ fn fail_in_test(_stage: &str) { } /// The error for a DWG version CADCraft can't write. -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +#[cfg_attr(no_dwg_bridge, allow(dead_code))] fn unsupported(version: &str) -> String { let list: Vec<&str> = VERSIONS.iter().map(|(_, year)| *year).collect(); format!("DWG version `{version}` can't be written (CADCraft writes {})", list.join(", ")) } -#[cfg(target_arch = "wasm32")] +/// Which build has no DWG bridge, for the stubs' errors. +#[cfg(no_dwg_bridge)] +const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" }; + +#[cfg(no_dwg_bridge)] pub fn dwg_to_dxf(_bytes: &[u8]) -> Result, String> { - Err("DWG files can't be opened in the web build yet; save as DXF".into()) + Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) } -#[cfg(target_arch = "wasm32")] +#[cfg(no_dwg_bridge)] pub fn dwg_to_dxf_noted(_bytes: &[u8], _limits: Limits) -> Result<(Vec, Vec), String> { - Err("DWG files can't be opened in the web build yet; save as DXF".into()) + Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) } -#[cfg(target_arch = "wasm32")] +#[cfg(no_dwg_bridge)] pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result, String> { - Err("DWG files can't be opened in the web build yet; save as DXF".into()) + Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) } -#[cfg(target_arch = "wasm32")] +#[cfg(no_dwg_bridge)] pub fn dxf_to_dwg(_dxf: &[u8]) -> Result, String> { - Err("DWG files can't be written in the web build yet; save as DXF".into()) + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) } -#[cfg(target_arch = "wasm32")] +#[cfg(no_dwg_bridge)] pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result, String> { - Err("DWG files can't be written in the web build yet; save as DXF".into()) + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) } #[cfg(test)] @@ -794,14 +798,14 @@ mod tests { use super::*; /// The DXF of an empty drawing, enough for the bridge to make a small DWG. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] const MINIMAL_DXF: &str = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nEOF\n"; /// Makes `stage` panic on the current thread until dropped. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] struct PanicIn; - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] impl PanicIn { fn at(stage: &'static str) -> Self { PANIC_IN.with(|p| p.set(Some(stage))); @@ -809,7 +813,7 @@ mod tests { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] impl Drop for PanicIn { fn drop(&mut self) { PANIC_IN.with(|p| p.set(None)); @@ -818,7 +822,7 @@ mod tests { /// The public read reports a panic in the post-read fix-ups as an error: this fails if the /// `guarded` call around `post_process` is taken out (#2402). - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] #[test] fn reading_a_dwg_survives_a_panic_in_post_processing() { let dwg = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap(); @@ -829,7 +833,7 @@ mod tests { } /// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402). - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] #[test] fn converting_to_dwg_survives_a_panic_in_the_fix_ups() { let _panic = PanicIn::at("convert"); @@ -846,7 +850,7 @@ mod tests { /// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not /// Standard: the DXF names the style, and the handle read with it was Standard's (#1650). - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] #[test] fn current_text_style_reaches_the_dwg() { let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n"; @@ -866,7 +870,7 @@ mod tests { assert!(dwg_to_dxf(b"").is_err()); } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] #[test] fn dxf_to_dwg_rejects_garbage_without_crashing() { // The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures @@ -876,7 +880,7 @@ mod tests { } /// A DXF with one DIMENSION per (text, group 70, subclass, points) row. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String { let mut s = String::from(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n"); for (text, flags, sub, pts) in dims { @@ -893,7 +897,7 @@ mod tests { /// the text is at its default position), and bit 32 in bit 1 (ODA DWG specification, common /// dimension data). The bytes are read back raw, so a reader that inverts bit 0 the same way /// as the writer can't hide a wrong byte. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] #[test] fn dimension_text_position_flags_reach_the_dwg_as_autocad_reads_them() { let linear: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [100.0, 0.0])]; @@ -940,7 +944,7 @@ mod tests { } /// The handles a DXF text refers to or gives its records (groups 5, 105 and 330–369). - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] fn dxf_handles(dxf: &str) -> std::collections::HashSet { let lines: Vec<&str> = dxf.lines().collect(); lines @@ -957,7 +961,7 @@ mod tests { /// 1.2 GB DXF rendition over the limit, so it didn't open). They're left out with a note, and /// their dictionary entries with them; the definition and every component it reaches — /// through compound and point components, around a cycle — are kept. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] #[test] fn orphaned_dgn_line_style_components_are_left_out() { use acadrust::objects::*; From 660e15c9f494ce647e87de10adbb01a8786e0901 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:46:11 -0400 Subject: [PATCH 047/151] Release 0.15.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0541ed60d..1d51978e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -643,7 +643,7 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -659,14 +659,14 @@ dependencies = [ [[package]] name = "cadcraft-color" -version = "0.15.0" +version = "0.15.1" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -676,7 +676,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -687,14 +687,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.15.0" +version = "0.15.1" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.15.0" +version = "0.15.1" dependencies = [ "encoding_rs", "log", @@ -703,7 +703,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -724,7 +724,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-geom", "harfrust 0.14.0", @@ -734,14 +734,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.15.0" +version = "0.15.1" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.15.0" +version = "0.15.1" dependencies = [ "acadrust", "cadcraft-color", @@ -762,7 +762,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -775,7 +775,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -789,7 +789,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.15.0" +version = "0.15.1" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -5894,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2e2e45f5e..ee2dc2fae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.15.0" +version = "0.15.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From ee715f4636c4588741cfa3474d23239d5ff60f69 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:17:25 +0000 Subject: [PATCH 048/151] docs: tidy agent, contributor and asset docs --- AGENTS.md | 13 ++++--------- ATTRIBUTION.md | 16 ++++++++-------- CLAUDE.md | 6 ++---- ROADMAP.md | 4 ++-- contributors/people.toml | 7 ++----- crates/ui-egui/build.rs | 7 +++---- crates/ui-egui/src/credits.rs | 2 +- docs/contributors.md | 25 ++++--------------------- docs/localization-parity.md | 2 +- docs/releasing.md | 2 +- docs/roadmap.md | 3 +-- docs/target-app-parity.md | 2 +- 12 files changed, 30 insertions(+), 59 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f805cdddd..309f3f0ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,6 @@ CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). -Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name). 2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons). @@ -12,7 +10,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule `plan/` is gitignored (local only). ## Never crash -People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics. - **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts. @@ -47,25 +45,22 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. +`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. The assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. ## Contributor credits (About window) - About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for themselves). See `docs/contributors.md`. - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 31ea4b474..20b3dcc6e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -48,14 +48,14 @@ Generated-in-code assets are original and have no file to list: | `docs/images/ui-apartment.png` | CADCraft contributors | screenshot of CADCraft itself (examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI | | `docs/images/ui-layout.png` | CADCraft contributors | screenshot of CADCraft itself (a layout of examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI | | `docs/images/ui-bracket.png` | CADCraft contributors | screenshot of CADCraft itself (sample drawing generated in code) | MIT OR Apache-2.0 | Original; no Autodesk UI | -| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-logo.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-logo.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | ## Interface translations diff --git a/CLAUDE.md b/CLAUDE.md index f5036db11..659cc8c2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,6 @@ CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). -Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name). 2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons). @@ -12,7 +10,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule `plan/` is gitignored (local only). ## Never crash -People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics. - **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts. @@ -46,7 +44,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. +`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. The assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ROADMAP.md b/ROADMAP.md index 239e10ac7..12c6983bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,8 +125,8 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md) | 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) | | 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. | | 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. | -| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. | -| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the craftrules progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. | +| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. | +| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. | | 2026-10-10 | About 60 community PRs merged in a day: DXF fidelity (transparency, LWPOLYLINE elevation, frozen VP layers, dimension text rotation, arc-length dims, non-ASCII escapes), CJK font fallback, Save/Don't Save on close, Light/System themes, F2 history, trackpad pan, Linux XWayland drag-and-drop, correctness fixes across draw/modify/constraints. Release v0.4.0. | | 2026-10-08 | Releases v0.1.0–v0.3.0: Flatpak, AppImage updates, signed macOS universal, Windows x64/x86/arm64, Linux, FreeBSD and web builds. | | 2026-10-07 | Old assessment: menu breadth 233/491 (47%), ≈ 29% weighted parity, 2D drafting ≈ 55%, ≈ 570 h remaining; milestones as reported then: M1 ~85%, M2 ~65%, M3 ~75%, M4 ~50%, M5 ~35%, M6 ~55%, M7 ~55%, M8 ~20%, M9 ~60%, M10 ~70%, M11 0%, M12 ~25%, M13 ~10%. | diff --git a/contributors/people.toml b/contributors/people.toml index 28564415e..cd1925e36 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb2..3116b01db 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml +//! entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 28d08269a..6ebcb362e 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -1,6 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! build.rs from contributors/contributors.json; nothing is //! read at run time. use std::cmp::Ordering; diff --git a/docs/contributors.md b/docs/contributors.md index acda57b24..13f693149 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to CADCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -32,20 +30,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a6bcb5079..c6f43ee4a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -46,7 +46,7 @@ shaping and right-to-left text through TrueType, vertical SHX text styles. ## Languages -Order and columns from craftrules' progress-docs standard. Every UI percentage is **measured** +Every UI percentage is **measured** from the catalogs (0 rows for every language without a catalog file). Hours are **estimated**: about 3–5 h per language for an Opus 5.5 agent to translate the ~990 catalog rows and fix layout, plus native review by a human; extending the catalog to the remaining English-only strings diff --git a/docs/releasing.md b/docs/releasing.md index 85c7c7cbd..3595c6b68 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ # Releasing CADCraft -The canonical recipe is craftrules' [`release/playbook.md`](https://github.com/storytold/craftrules/blob/main/release/playbook.md); this file covers only what's CADCraft-specific. +This file describes how CADCraft is released. - **Version:** `[workspace.package] version` in the root `Cargo.toml` is the single source. `cargo xtask version` prints it; `cargo xtask version set X.Y.Z[-pre]` changes it. The app shows it in About, `cadcraft --version` and `cadcraft-cli --version`; release builds also embed the commit (`CADCRAFT_BUILD_SHA`). - **Cut a release:** bump the version on `main`, then push `main` to `release` (`git push origin main:release`). Every push to `release` runs `.github/workflows/release.yml` and creates or updates the draft GitHub Release `CADCraft v`. A `workflow_dispatch` run with `version: X.Y.Z-rc.N` makes a test build. A dispatch on any other branch is a dry run: the unsigned jobs (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) build, the `release` environment refuses the signing jobs (macOS, Windows), and no release is drafted. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3d46be9df..025a43f54 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,8 +26,7 @@ Items 2–5 and 7 are the [alpha gate](#alpha-gate) rows. ## Alpha gate -The core workflows a typical AutoCAD professional does every day (craftrules progress-docs -standard). CADCraft is **pre-alpha** because four of the five fail the gate; the failing rows are +The core workflows a typical AutoCAD professional does every day. CADCraft is **pre-alpha** because four of the five fail the gate; the failing rows are the alpha checklist. Main platform: macOS. | Core workflow | Works end to end? | Evidence | Hours to pass | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6b5407d60..a981d0c62 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -215,7 +215,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of | Date | Feature breadth | Weighted parity | Remaining | Note | |---|---|---|---|---| | 2026-10-10 (merge) | unchanged (233/491 re-scanned) | ready ≈ 38% (37.7%), mainstream ≈ 28%, essentials ≈ 46% | unchanged within rounding | main landed dynamic input pointer boxes (#60), the Ukrainian catalog (#36), WIPEOUT/gradient DXF fixes (#147, #162): UI/UX 40% → 43%, localization 3% → 5% | -| 2026-10-10 (later) | unchanged | ready for real work ≈ 37% (unchanged: already built from the written dimension weights, no hidden judgement); mainstream practitioner ≈ 27%; essentials user ≈ 45% | unchanged | mainstream and essentials numbers added (craftrules standard) | +| 2026-10-10 (later) | unchanged | ready for real work ≈ 37% (unchanged: already built from the written dimension weights, no hidden judgement); mainstream practitioner ≈ 27%; essentials user ≈ 45% | unchanged | mainstream and essentials numbers added | | 2026-10-10 | 245/491 (50%), engine-only 233/491 | features ≈ 41%, ready for real work ≈ 37% | 1,300–2,100 h | full re-measure in this format (this file) | | 2026-10-07 | 233/491 (47%) | ≈ 29% overall, 2D ≈ 55% | ≈ 570 h | old ROADMAP: milestone percentages reported by the building agents | From 9b32882065070cf7671873b809eafeba29c3944f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:25:44 +0000 Subject: [PATCH 049/151] docs: tidy wording --- ROADMAP.md | 4 ++-- docs/gaps.md | 2 +- docs/target-app-parity.md | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 12c6983bb..48bd33032 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,7 +126,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md) | 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. | | 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. | | 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. | -| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. | +| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. | | 2026-10-10 | About 60 community PRs merged in a day: DXF fidelity (transparency, LWPOLYLINE elevation, frozen VP layers, dimension text rotation, arc-length dims, non-ASCII escapes), CJK font fallback, Save/Don't Save on close, Light/System themes, F2 history, trackpad pan, Linux XWayland drag-and-drop, correctness fixes across draw/modify/constraints. Release v0.4.0. | | 2026-10-08 | Releases v0.1.0–v0.3.0: Flatpak, AppImage updates, signed macOS universal, Windows x64/x86/arm64, Linux, FreeBSD and web builds. | | 2026-10-07 | Old assessment: menu breadth 233/491 (47%), ≈ 29% weighted parity, 2D drafting ≈ 55%, ≈ 570 h remaining; milestones as reported then: M1 ~85%, M2 ~65%, M3 ~75%, M4 ~50%, M5 ~35%, M6 ~55%, M7 ~55%, M8 ~20%, M9 ~60%, M10 ~70%, M11 0%, M12 ~25%, M13 ~10%. | @@ -144,7 +144,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md) | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number | | 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers | | 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; banner, why, headline row | -| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md | +| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md | | 2026-10-07 | major | Alpha checklist, milestone table, ≈ 29% weighted parity estimate | ## Shared UI workspace integration diff --git a/docs/gaps.md b/docs/gaps.md index a7548c4d2..f8ddac941 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -107,7 +107,7 @@ Detail: [localization-parity.md](localization-parity.md). | # | Gap | Est. | |---|---|---| | L1 | Catalog covers the UI (989 rows, #36) but not engine diagnostics, CLI/MCP replies or command history | 4–6 | -| L2 | AutoCAD for Mac's other languages (zh-Hans, es, fr, ja, ko, it; de landed 2026-10-11) plus pt, hi, ar, id, vi from the standard's list | 35–55 + native review | +| L2 | AutoCAD for Mac's other languages (zh-Hans, es, fr, ja, ko, it; de landed 2026-10-11) plus pt, hi, ar, id, vi | 35–55 + native review | | L3 | Complex-script shaping for Devanagari and other Indic scripts in drawing text; RTL (mirrored) UI. Arabic-script shaping and bidi in drawing text landed (#287) | 20–30 | | L4 | IME fixes on every platform (PRs #110–#119); vertical text styles | 6–10 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a981d0c62..fc36dea7d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -65,8 +65,7 @@ mainstream and essentials numbers use multiplicative discounts. ## Mainstream practitioner The typical AutoCAD professional is a 2D drafter or detailer: draw, modify, dimension, use -blocks, manage layers, plot sheets, and exchange DWGs with colleagues, every week. Excluded per -the standard: 3D, AutoLISP and plug-ins, cloud/AI (Autodesk Assistant, Smart Blocks, Traces, +blocks, manage layers, plot sheets, and exchange DWGs with colleagues, every week. Excluded: 3D, AutoLISP and plug-ins, cloud/AI (Autodesk Assistant, Smart Blocks, Traces, Share), Sheet Set Manager administration, specialist hardware, languages other than English. | Area (weekly use) | Weight | Depth | Weighted | From 11ce7eb52c6a72e81fd809b48ce734facfdab31e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:42:07 +0000 Subject: [PATCH 050/151] updates: show check results with craft-update's CheckResultDialog --- Cargo.lock | 18 ++++++++-------- Cargo.toml | 2 +- crates/ui-egui/src/updates_ui.rs | 35 +++++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d51978e1..c45ba071c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,7 +268,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", @@ -1643,7 +1643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3435,7 +3435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4030,7 +4030,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4087,7 +4087,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4562,10 +4562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5471,7 +5471,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ee2dc2fae..8e16721a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,7 +66,7 @@ egui-wgpu = "0.37" # Monotonic time on native and web (performance.now on wasm). web-time = "1.1" # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } rfd = "0.17" arboard = { version = "3.6", default-features = false } diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index ffc625767..ce8f53a3f 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -3,6 +3,7 @@ //! `craft_update::updater::Updater`. use craft_update::updater::{Action, Status, Updater}; +use craft_update::{CheckResultDialog, CheckResultResponse}; use crate::CadApp; @@ -16,11 +17,15 @@ pub struct Runtime { enabled: bool, /// Help ▸ Check for Updates… was chosen; the next frame's `tick` starts it with the context. check_requested: bool, + /// The check-result dialog is open (from a requested check until it is closed). + result_open: bool, + /// The current result was already written to the command history. + result_echoed: bool, } impl Default for Runtime { fn default() -> Self { - Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false } + Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false, result_echoed: false } } } @@ -31,9 +36,11 @@ impl CadApp { } } -/// Help ▸ Check for Updates…: check now, and say so when there is nothing newer. +/// Help ▸ Check for Updates…: check now, and show the result when there is nothing newer. pub fn check_now(app: &mut CadApp) { app.updates_rt.check_requested = true; + app.updates_rt.result_open = true; + app.updates_rt.result_echoed = false; } /// Start the checks and collect the answers. @@ -50,8 +57,16 @@ pub fn tick(app: &mut CadApp, ctx: &egui::Context) { Status::Failed(_) => Some(crate::tl!("Could not check for updates.")), Status::Idle | Status::Checking => None, }; - if let Some(message) = outcome { + let Some(message) = outcome else { + // A check that found an update leaves `Idle`; the prompt takes over from the dialog. + if rt.result_open && matches!(rt.updater.status(), Status::Idle) { + rt.result_open = false; + } + return; + }; + if !rt.result_open { rt.updater.dismiss_status(); + } else if !std::mem::replace(&mut rt.result_echoed, true) { app.session.echo(message); } } @@ -60,6 +75,20 @@ pub fn tick(app: &mut CadApp, ctx: &egui::Context) { pub fn show(app: &mut CadApp, ctx: &egui::Context) { // The restart helper is scheduled before the app closes, so no update starts over unsaved work. let clean = !app.session.docs.iter().any(|d| d.is_dirty()); + let rt = &mut app.updates_rt; + if rt.result_open { + match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) { + CheckResultResponse::Close => { + rt.result_open = false; + rt.updater.dismiss_status(); + } + CheckResultResponse::CheckAgain => { + rt.result_echoed = false; + rt.check_requested = true; + } + CheckResultResponse::None => {} + } + } match app.updates_rt.updater.show_if(ctx, clean) { Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), // The guarded close path asks about unsaved changes; the restart helper relaunches once the From c6e49b177cd6bc103a0042eadfa5dee96a59aed1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 00:44:27 -0400 Subject: [PATCH 051/151] test(autosave): fix a flake: wait for the background write to finish, not just for its file Since #2518 the write syncs the folder after the rename, so the autosave file exists while the write is still running. Until it finishes the drawing is busy and the next change isn't planned, so the test's tick() returned None. Autosaver::writing() says whether a write is still running. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/autosave.rs | 7 +++++++ crates/engine/tests/autosave.rs | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index c6b41cce4..9ff7eab0b 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -416,6 +416,13 @@ mod native { writes } + /// Whether a background write hasn't finished yet. Its file can exist before it finishes + /// (the folder is synced after the rename), and until [`Autosaver::reap`] takes it in, the + /// drawing is left out of planning. + pub fn writing(&self) -> bool { + self.running.iter().any(|(_, h)| !h.is_finished()) + } + /// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing /// whose write failed is no longer counted as autosaved: the next plan restarts its clock, /// so it is tried again SAVETIME later even if it doesn't change. diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs index da03c0d7d..f44be5231 100644 --- a/crates/engine/tests/autosave.rs +++ b/crates/engine/tests/autosave.rs @@ -185,6 +185,14 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() { std::thread::sleep(Duration::from_millis(10)); } assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n")); + // The file exists before the write finishes (the folder sync comes after the rename); until + // it has, the drawing is busy and the change drawn below would not be planned. + for _ in 0..500 { + if !a.writing() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } // While the app runs, its lock keeps its files out of recovery. SAVETIME and SAVEFILEPATH // are system variables (process-wide, so set in this test only). assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVETIME"), Some(json!(10))); From b35f00847a76485b271956dc17685fbc88c58760 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:45:52 +0100 Subject: [PATCH 052/151] Validate new linetype names in the rename command (#2519) * Validate new linetype names in the rename command Found while testing crates/engine/src/cmd/props.rs:1154-1200 (run_rename, "linetype" branch) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * rename: a linetype's new name is stored trimmed, as -RENAME types it (CodeRabbit) --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/props.rs | 18 ++++++++++++++++++ crates/engine/src/cmd/props/rename.rs | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index daf963f67..43f2aca38 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1181,6 +1181,11 @@ fn run_rename(s: &mut Session, p: &Value) -> Result { run_dimstyle_rename(s, &json!({ "from": from, "to": to }))?; } "linetype" | "ltype" => { + // Stored trimmed, as -RENAME types it: lookups compare names untrimmed. + let to = to.trim().to_string(); + if !rename::valid_name(&to) { + return Err(bad("rename", format!("`{to}` is not a valid linetype name"))); + } let d = s.doc_mut()?; if ["ByBlock", "ByLayer", "Continuous"].iter().any(|n| from.eq_ignore_ascii_case(n)) { return Err(bad("rename", format!("the {from} linetype cannot be renamed"))); @@ -1246,6 +1251,19 @@ mod tests { assert_eq!(col(&s), Color::ByBlock); } + #[test] + fn rename_linetype_rejects_invalid_names() { + let mut s = Session::new(); + s.execute("linetype", &json!({ "load": "DASHED" })).unwrap(); + for to in ["", " ", "A;B", "A|B", "*X"] { + let r = s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": to })); + assert!(r.is_err(), "renamed DASHED to {to:?}: {r:?}"); + assert!(s.doc().unwrap().linetype("DASHED").is_some()); + } + s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": " DASH2 " })).unwrap(); + assert!(s.doc().unwrap().linetype("DASH2").is_some()); + } + #[test] fn properties_set_rejects_unloaded_linetype() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/props/rename.rs b/crates/engine/src/cmd/props/rename.rs index fe2e289ce..3513c1254 100644 --- a/crates/engine/src/cmd/props/rename.rs +++ b/crates/engine/src/cmd/props/rename.rs @@ -8,7 +8,7 @@ use serde_json::json; use super::super::bad; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; -fn valid_name(n: &str) -> bool { +pub(super) fn valid_name(n: &str) -> bool { !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) } From 42be8540a98d3046206d3f7be90a999dd861adde Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Sat, 31 Oct 2026 00:26:50 -0700 Subject: [PATCH 053/151] Backups of drawings sharing a name no longer overwrite each other (#2537) plan.dwg and plan.dxf in one folder shared plan.bak. A DWG keeps .bak, as AutoCAD names it, and any other drawing appends .bak. The naming comes from #1006. Co-authored-by: Filippo Reimer <49290121+filforce95@users.noreply.github.com> --- crates/engine/src/cmd/file.rs | 15 +++++++++++++-- crates/engine/tests/save_backup.rs | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9aaff4631..1c3fbd5c8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -446,9 +446,20 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> { } } -/// The backup of the drawing file `path`: `.bak` beside it, as AutoCAD names it. +/// The backup of the drawing file `path`, beside it. A DWG's is `.bak`, as AutoCAD names it, +/// in the extension's letter case (`plan.DWG` to `plan.BAK`); any other drawing appends `.bak` +/// (`plan.dxf.bak`), so `plan.dwg` and `plan.dxf` in one folder never overwrite each other's backup. pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf { - path.with_extension("bak") + match path.extension().map(|e| e.to_string_lossy()) { + Some(e) if e.eq_ignore_ascii_case("dwg") => path.with_extension( + e.chars().zip("bak".chars()).map(|(d, b)| if d.is_ascii_uppercase() { b.to_ascii_uppercase() } else { b }).collect::(), + ), + _ => { + let mut name = path.as_os_str().to_os_string(); + name.push(".bak"); + name.into() + } + } } /// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as diff --git a/crates/engine/tests/save_backup.rs b/crates/engine/tests/save_backup.rs index afa5a14cb..d84aaedd3 100644 --- a/crates/engine/tests/save_backup.rs +++ b/crates/engine/tests/save_backup.rs @@ -72,7 +72,7 @@ fn each_save_keeps_the_previous_file_as_bak() { add_circle(&mut s, 5.0); s.execute("qsave", &json!({})).unwrap(); - assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]); + assert_eq!(files(&dir), ["Plan.dxf", "Plan.dxf.bak"]); assert_eq!((circles(&path), circles(&backup_path(&path))), (2, 1), "the .bak is the previous save"); add_circle(&mut s, 10.0); @@ -118,7 +118,7 @@ fn a_damaged_file_never_replaces_a_good_backup_and_isavebak_0_keeps_none() { add_circle(&mut s, 15.0); s.execute("qsave", &json!({})).unwrap(); assert_eq!((circles(&path), circles(&backup_path(&path))), (6, 1)); - assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]); + assert_eq!(files(&dir), ["Plan.dxf", "Plan.dxf.bak"]); let _ = std::fs::remove_dir_all(&dir); } @@ -294,3 +294,13 @@ fn the_backup_a_save_promises_is_the_one_it_writes() { assert!(!save_keeps_current(&path), "backups off"); let _ = std::fs::remove_dir_all(&dir); } + +/// `plan.dwg` and `plan.dxf` in one folder keep their own backups, in the DWG's letter case. +#[test] +fn drawings_sharing_a_name_keep_their_own_backups() { + let name = |p: &str| backup_path(std::path::Path::new(p)).to_string_lossy().into_owned(); + assert_eq!(name("x/plan.dwg"), "x/plan.bak"); + assert_eq!(name("x/plan.DWG"), "x/plan.BAK"); + assert_eq!(name("x/plan.dxf"), "x/plan.dxf.bak"); + assert_eq!(name("x/plan.dwt"), "x/plan.dwt.bak"); +} From e60d546ce4907d126981c38b544043977e7cd1b2 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Sat, 31 Oct 2026 00:34:14 -0700 Subject: [PATCH 054/151] Picking after an edit no longer ends the app on an rstar panic (#2539) rstar 0.13 can panic when inserting after removals in a bulk-loaded tree, and picking runs outside the command guard. The patch now runs on a copy under catch_unwind and falls back to a bulk load. The web build can't catch a panic, so it always bulk-loads. Diagnosis, reproduction and fix from #2468. Fixes #2468 Co-authored-by: octopoulpos <32617732+octopoulpos@users.noreply.github.com> --- crates/engine/src/spatial.rs | 78 ++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index ca15bd4c4..dd2e0d593 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -94,6 +94,28 @@ fn hidden_layers(d: &Drawing) -> impl Iterator { d.layers.iter().filter(|l| !l.visible()).map(|l| l.name.as_str()) } +/// A copy of `tree` with `removed` taken out and `inserted` added, or `None` when the caller must +/// bulk-load instead. rstar 0.13 can panic ("This is a bug in rstar.") when inserting after removals +/// in a bulk-loaded tree (#2468), and picking is outside the command guard, so that panic would end +/// the app. The web build can't catch a panic, so it always bulk-loads. +fn patch_tree(tree: &RTree, removed: impl Iterator, inserted: impl Iterator) -> Option> { + if cfg!(target_arch = "wasm32") { + return None; + } + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut tree = tree.clone(); + for it in removed { + tree.remove(&it); + } + for it in inserted { + tree.insert(it); + } + tree + })) + .map_err(|_| log::warn!("spatial index: rstar failed to patch the tree; rebuilding it")) + .ok() +} + impl SpatialIndex { /// Build an index over a store (no caching). pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex { @@ -127,23 +149,11 @@ impl SpatialIndex { offset = offset.saturating_add(n); } // Patch the previous tree when most of it survives; otherwise bulk-load. - let tree = match prev { - Some(p) if kept.len() * 2 >= chunks.len().max(1) => { - let mut tree = p.tree.clone(); - for (_, c) in p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)) { - for it in c.items() { - tree.remove(&it); - } - } - for c in &fresh { - for it in c.items() { - tree.insert(it); - } - } - tree - } - _ => RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect()), - }; + let patched = prev.filter(|_| kept.len() * 2 >= chunks.len().max(1)).and_then(|p| { + let removed = p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)).flat_map(|(_, c)| c.items()); + patch_tree(&p.tree, removed, fresh.iter().flat_map(|c| c.items())) + }); + let tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect())); let offsets = chunks.iter().map(|(off, c)| (c.serial, *off)).collect(); let infinite = chunks.iter().flat_map(|(off, c)| c.infinite.iter().map(move |i| off + i)).collect(); SpatialIndex { @@ -367,6 +377,40 @@ mod tests { Some(Arc::new(SpatialIndex::build(d, &d.model))) } + /// The edits of #2468, on rstar alone: its insert panics for seed 8. A failed patch falls back + /// to a bulk load, and every tree holds exactly the live items. + #[test] + fn rstar_patch_failures_fall_back_to_a_rebuild() { + fn item(r: &mut Rng, s: u32, i: u32) -> Item { + let (x, y, w, h) = (r.f() * 1000.0, r.f() * 1000.0, r.f() * 50.0, r.f() * 50.0); + GeomWithData::new(Rectangle::from_corners([x, y], [x + w, y + h]), (s, i)) + } + for seed in 1..=10u64 { + let mut r = Rng(seed * 7919); + let n_chunks = 4 + (r.f() * 20.0) as u32; + let mut chunks: Vec> = (0..n_chunks) + .map(|s| { + let n = 1 + (r.f() * 60.0) as u32; + (0..n).map(|i| item(&mut r, s, i)).collect() + }) + .collect(); + let mut tree = RTree::bulk_load(chunks.iter().flatten().cloned().collect()); + for serial in (n_chunks..).take(300) { + let c = (r.f() * chunks.len() as f64) as usize; + let n = (chunks[c].len() as i64 + if r.f() < 0.5 { -1 } else { 1 }).max(0) as u32; + let fresh: Vec = (0..n).map(|i| item(&mut r, serial, i)).collect(); + let patched = patch_tree(&tree, chunks[c].iter().cloned(), fresh.iter().cloned()); + chunks[c] = fresh; + tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flatten().cloned().collect())); + let mut held: Vec<(u32, u32)> = tree.iter().map(|it| it.data).collect(); + let mut live: Vec<(u32, u32)> = chunks.iter().flatten().map(|it| it.data).collect(); + held.sort_unstable(); + live.sort_unstable(); + assert_eq!(held, live, "seed {seed}, serial {serial}"); + } + } + } + #[test] fn pick_parity_with_scan() { for seed in [3, 11, 97] { From 4552b3568895a890c5c164027aba56c0f5a50b47 Mon Sep 17 00:00:00 2001 From: austin62jake Date: Sat, 31 Oct 2026 02:34:18 -0500 Subject: [PATCH 055/151] fix(ui): dash only the visible part of a highlight, so zooming far in doesn't freeze (#2299) (#2454) * fix(ui): dash only the visible part of a highlight, so zooming far in doesn't freeze (#2299) With the pointer on an object, its rollover highlight is dashed, and draw_highlight dashed the object's whole outline in screen space, off-screen parts included: one shape per 10 points of it. Zoomed far in, a short line is billions of points long, so the app froze and then ran out of memory, which is the freeze and crash #2299 reports. Wheel-zooming a circle and two lines with the pointer on them reached 7.9 million shapes in one frame at a view height of 0.001 before a 1 GB allocation failed. The highlight now clips each outline to the view, plus a margin, in world coordinates first (precise when zoomed far in), and dashes only what is left. Each visible piece starts its dash pattern at its distance along the whole outline, so dashes don't crawl along a line that runs off screen while the view pans. The two dashed rectangles drawn in screen space, a layout's printable area and the crossing window, clip each edge to the painter's clip rect for the same reason. The same zoom now stays under 1,300 shapes per frame down to the zoom limit. Co-Authored-By: Claude Opus 5.5 * fix(ui): a clipped rectangle edge keeps its dashes where the whole edge puts them dashed_rect clipped each edge to the screen and then started its dashes at the clip boundary, so a printable area or crossing window running off screen had its dashes move with the view, and its bottom and left edges were drawn in reverse. Each edge now keeps its direction and its dashes start at their distance along the whole edge, as the highlight's outlines do. The zoomed-far-in app test also checks that the line really is hovered, so it can't pass with no highlight drawn. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/canvas/draw.rs | 201 ++++++++++++++++++++++- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/zoomed_far_in.rs | 40 +++++ 4 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/it/zoomed_far_in.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fcc464be3..39040c366 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -26,7 +26,7 @@ mod widgets; use cursor::{draw_crosshair, draw_snap_marker, draw_tracking, effective_point, override_cursor}; use cycle::{CycleList, cycle_badge, cycle_click, cycle_list, over_canvas_menu, shift_space_cycle}; pub(crate) use draw::{Look, draw_highlight, draw_list}; -use draw::{draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill}; +use draw::{dashed_rect, draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill}; use dyn_boxes::dynamic_input; pub(crate) use dyn_boxes::length_units; use grid::draw_grid; @@ -982,8 +982,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) { xf_paper.to_screen(Vec2::new(sh.printable.min.x, sh.printable.max.y)), xf_paper.to_screen(Vec2::new(sh.printable.max.x, sh.printable.min.y)), ); - let pts = [pa.left_top(), pa.right_top(), pa.right_bottom(), pa.left_bottom(), pa.left_top()]; - painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0)); + painter.extend(dashed_rect(painter.clip_rect(), pa, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0)); (Rgb(t.paper.r(), t.paper.g(), t.paper.b()), Some(paper)) } None => { @@ -1112,8 +1111,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) { } let st = Stroke::new(1.0, if crossing { t.crossing_stroke } else { t.window_stroke }); if crossing { - let pts = vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; - painter.extend(Shape::dashed_line(&pts, st, 5.0, 3.0)); + painter.extend(dashed_rect(painter.clip_rect(), r, st, 5.0, 3.0)); } else { painter.rect_stroke(r, 0.0, st, egui::StrokeKind::Middle); } diff --git a/crates/ui-egui/src/canvas/draw.rs b/crates/ui-egui/src/canvas/draw.rs index 9ca1d9314..146466b24 100644 --- a/crates/ui-egui/src/canvas/draw.rs +++ b/crates/ui-egui/src/canvas/draw.rs @@ -19,6 +19,64 @@ fn clip_infinite(xf: &Xf, base: Vec2, dir: Vec2, ray: bool) -> Option<[Pos2; 2]> Some([xf.to_screen(a), xf.to_screen(b)]) } +/// Points past the view's edge that a dashed outline is still drawn to: more than one dash +/// period, so a dash cut at the clip edge never shows. +const CLIP_MARGIN: f32 = 16.0; + +/// The parts of a world-space polyline within `xf`'s view, as screen points, each with how far +/// along the whole polyline it starts (in points). Clipped in world coordinates, so an outline far +/// larger than the screen costs only what can show, and its points stay precise when zoomed far +/// in: dashing a whole line made billions of dashes (#2299). +fn visible_runs(xf: &Xf, pts: &[Vec2]) -> Vec<(Vec, f64)> { + let vis = xf.world_bounds().expand(f64::from(CLIP_MARGIN) / xf.scale); + let mut runs: Vec<(Vec, f64)> = Vec::new(); + // World length before the current segment; whether the last run reaches its start. + let (mut along, mut open) = (0.0, false); + for w in pts.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + match cadcraft_render::clip::clip_segment(a, b, &vis) { + Some((p, q)) => { + match runs.last_mut() { + Some((run, _)) if open && p == a => run.push(xf.to_screen(q)), + _ => runs.push((vec![xf.to_screen(p), xf.to_screen(q)], (along + a.dist(p)) * xf.scale)), + } + open = q == b; + } + None => open = false, + } + along += a.dist(b); + } + runs +} + +/// Dashes along a screen polyline that starts `along` points into the outline it is part of, so +/// they sit where they would on the whole outline. +fn dashes(run: &[Pos2], along: f64, stroke: Stroke, dash: f32, gap: f32, out: &mut Vec) { + let period = f64::from(dash + gap); + let offset = (period - along.rem_euclid(period)) % period; + out.extend(Shape::dashed_line_with_offset(run, stroke, &[dash], &[gap], offset as f32)); +} + +/// A dashed rectangle outline, each edge clipped to `clip` first: a rectangle zoomed far larger +/// than the screen (a layout's printable area, a selection window) gets only the dashes that can +/// show (#2299). Each edge keeps its direction and its dashes start where they would on the whole +/// edge, so they don't crawl as the rectangle or the view moves. +pub(super) fn dashed_rect(clip: Rect, r: Rect, stroke: Stroke, dash: f32, gap: f32) -> Vec { + let v = |p: Pos2| Vec2::new(f64::from(p.x), f64::from(p.y)); + let c = clip.expand(CLIP_MARGIN); + let c = Bounds2::new(v(c.min), v(c.max)); + let mut out = Vec::new(); + let edges = + [(r.left_top(), r.right_top()), (r.right_top(), r.right_bottom()), (r.right_bottom(), r.left_bottom()), (r.left_bottom(), r.left_top())]; + for (a, b) in edges { + if let Some((p, q)) = cadcraft_render::clip::clip_segment(v(a), v(b), &c) { + let pos = |w: Vec2| Pos2::new(w.x as f32, w.y as f32); + dashes(&[pos(p), pos(q)], v(a).dist(p), stroke, dash, gap, &mut out); + } + } + out +} + /// Draw a display list. pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: bool) { let vis = xf.world_bounds(); @@ -279,25 +337,31 @@ impl Look { /// outlined, since filling it would hide the drawing under it. pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize], color: Color32, look: Look) { let stroke = Stroke::new(look.width, color); - let line = |pts: Vec, out: &mut Vec| { + let screen_line = |pts: Vec, along: f64, out: &mut Vec| { if look.dashed { - out.extend(Shape::dashed_line(&pts, stroke, 6.0, 4.0)); + dashes(&pts, along, stroke, 6.0, 4.0, out); } else { out.push(Shape::line(pts, stroke)); } }; + // Only the part of an outline in view (#2299). + let line = |pts: &[Vec2], out: &mut Vec| { + for (run, along) in visible_runs(xf, pts) { + screen_line(run, along, out); + } + }; let mut shapes = Vec::new(); let mut fill = egui::Mesh::default(); let fill_color = color.gamma_multiply(crate::gpu::HIGHLIGHT_FILL_ALPHA); for prim in prims.iter().filter_map(|&i| list.prims.get(i)) { let pts = list.points(prim); match prim.kind { - Kind::Polyline => line(pts.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes), + Kind::Polyline => line(pts, &mut shapes), Kind::Infinite { ray } => { if let (Some(b), Some(d)) = (pts.first(), pts.get(1)) && let Some(seg) = clip_infinite(xf, *b, *d, ray) { - line(seg.to_vec(), &mut shapes); + screen_line(seg.to_vec(), 0.0, &mut shapes); } } Kind::Point => { @@ -314,7 +378,7 @@ pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, pri } Kind::Tris | Kind::Mask => { for outline in list.fill_outlines(prim) { - line(outline.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes); + line(&outline, &mut shapes); } } } @@ -422,3 +486,130 @@ mod highlight_stability_tests { } } } + +#[cfg(test)] +mod zoomed_highlight_tests { + use super::*; + use egui::pos2; + + /// A one-segment polyline from `a` to `b`. + fn line(a: Vec2, b: Vec2) -> DisplayList { + let mut list = DisplayList { verts: vec![a, b], ..Default::default() }; + list.prims.push(cadcraft_render::DPrim { + handle: Handle(256), + color: Rgb(255, 255, 255), + aci7: true, + lw: 0.0, + transparency: 0, + kind: Kind::Polyline, + start: 0, + len: 2, + }); + list + } + + const SCREEN: Rect = Rect { min: Pos2::ZERO, max: Pos2 { x: 600.0, y: 400.0 } }; + + /// The dash segments `draw_highlight` makes for `list` in `xf`. + fn dashes_of(list: &DisplayList, xf: &Xf) -> Vec<[Pos2; 2]> { + let ctx = egui::Context::default(); + let input = egui::RawInput { screen_rect: Some(SCREEN), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| draw_highlight(ui.painter(), xf, list, &[0], Color32::LIGHT_BLUE, Look { width: 1.0, dashed: true })); + output.textures_delta.clear(); + output + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::LineSegment { points, .. } => Some(*points), + _ => None, + }) + .collect() + } + + /// Zoomed far in on a line, the highlight dashes only what shows. Dashing the whole line made + /// one shape per 10 points of it: billions at the zoom limit, so the app froze and then ran out + /// of memory (#2299). + #[test] + fn a_zoomed_in_highlight_dashes_only_what_shows() { + let list = line(Vec2::new(-1000.0, 0.0), Vec2::new(1000.0, 0.0)); + // The smaller zoom first, so a regression fails here instead of filling memory at 1e9. + for scale in [1e3, 1e9] { + let n = dashes_of(&list, &Xf { rect: SCREEN, center: Vec2::new(0.3, 0.0), scale }).len(); + assert!((50..=70).contains(&n), "scale {scale}: {n} dashes for 600 points of line"); + } + } + + /// A line running off screen keeps its dashes where they are on the whole line, so they don't + /// crawl along it when the view pans. + #[test] + fn clipped_dashes_stay_where_the_whole_line_puts_them() { + let list = line(Vec2::new(0.0, 0.0), Vec2::new(1000.0, 0.0)); + for x in [500.3, 512.9, 731.0] { + let xf = Xf { rect: SCREEN, center: Vec2::new(x, 0.0), scale: 1.0 }; + let whole = Shape::dashed_line( + &[xf.to_screen(Vec2::ZERO), xf.to_screen(Vec2::new(1000.0, 0.0))], + Stroke::new(1.0, Color32::LIGHT_BLUE), + 6.0, + 4.0, + ); + let on_screen = |p: &[Pos2; 2]| p.iter().all(|q| SCREEN.contains(*q)); + let expected: Vec<[Pos2; 2]> = whole + .iter() + .filter_map(|s| match s { + Shape::LineSegment { points, .. } => Some(*points).filter(on_screen), + _ => None, + }) + .collect(); + let got: Vec<[Pos2; 2]> = dashes_of(&list, &xf).into_iter().filter(on_screen).collect(); + assert!(!expected.is_empty()); + assert_eq!(got.len(), expected.len(), "centre {x}"); + for (g, e) in got.iter().zip(&expected) { + assert!(g.iter().zip(e).all(|(a, b)| a.distance(*b) < 1e-3), "centre {x}: {g:?} vs {e:?}"); + } + } + } + + /// A rectangle edge running off screen keeps its dashes where they are on the whole edge. + #[test] + fn clipped_rectangle_edges_keep_their_dashes() { + let stroke = Stroke::new(1.0, Color32::GRAY); + let segments = |shapes: Vec| -> Vec<[Pos2; 2]> { + shapes + .into_iter() + .filter_map(|s| match s { + // The top edge's dashes that are wholly on screen. + Shape::LineSegment { points, .. } if points.iter().all(|q| SCREEN.contains(*q) && q.y == 100.0) => Some(points), + _ => None, + }) + .collect() + }; + for left in [-500.3, -517.9, -1200.0] { + let r = Rect::from_min_max(pos2(left, 100.0), pos2(400.0, 300.0)); + let expected = segments(Shape::dashed_line(&[r.left_top(), r.right_top()], stroke, 4.0, 4.0)); + let got = segments(dashed_rect(SCREEN, r, stroke, 4.0, 4.0)); + assert!(!expected.is_empty()); + assert_eq!(got.len(), expected.len(), "left edge at {left}"); + for (g, e) in got.iter().zip(&expected) { + assert!(g.iter().zip(e).all(|(a, b)| a.distance(*b) < 1e-3), "left edge at {left}: {g:?} vs {e:?}"); + } + } + } + + /// A dashed rectangle far larger than the screen gets dashes only along the edges that show. + #[test] + fn a_huge_dashed_rectangle_dashes_only_what_shows() { + let stroke = Stroke::new(1.0, Color32::GRAY); + // Its left edge runs through the screen; the other three are far off it. The smaller one + // first, so a regression fails here instead of filling memory with the larger one. + for far in [1e6, 1e9] { + let huge = Rect::from_min_max(pos2(300.0, -far), pos2(far, far)); + let n = dashed_rect(SCREEN, huge, stroke, 4.0, 4.0).len(); + assert!((50..=60).contains(&n), "{far}: {n} dashes along 432 points of edge"); + } + // A rectangle inside the screen keeps all four edges. + let small = Rect::from_min_max(pos2(100.0, 100.0), pos2(180.0, 140.0)); + assert_eq!(dashed_rect(SCREEN, small, stroke, 4.0, 4.0).len(), 10 + 5 + 10 + 5); + // Off screen, nothing. + assert!(dashed_rect(SCREEN, small.translate(egui::vec2(5000.0, 0.0)), stroke, 4.0, 4.0).is_empty()); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index c32012e1a..b80614182 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -37,3 +37,4 @@ mod toolsets_gear; mod toolsets_parametric; mod viewport_double_click_selection; mod workspace_focus; +mod zoomed_far_in; diff --git a/crates/ui-egui/tests/it/zoomed_far_in.rs b/crates/ui-egui/tests/it/zoomed_far_in.rs new file mode 100644 index 000000000..5f6a73a21 --- /dev/null +++ b/crates/ui-egui/tests/it/zoomed_far_in.rs @@ -0,0 +1,40 @@ +//! Zooming far in with the pointer on an object (#2299): its rollover highlight is dashed, and +//! dashing the whole object made one shape per 10 points of it on screen. Zoomed to the limit +//! that was billions of shapes, so the app froze and then ran out of memory. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services}; +use serde_json::json; + +/// One frame of the app; the number of shapes it painted. +fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec) -> usize { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.shapes.len() +} + +#[test] +fn zooming_far_in_on_a_hovered_line_paints_what_shows() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("circle", json!({"center": [50, 50], "radius": 20})).unwrap(); + app.run("line", json!({"points": [[0, 0], [100, 100]]})).unwrap(); + app.run("line", json!({"points": [[0, 100], [100, 0]]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, Vec::new()); + let over = app.canvas.rect.unwrap().center(); + // The smaller zoom first, so a regression fails there instead of filling memory at the limit. + for height in [1e-2, 1e-9] { + // Centred on a point of the first line, which the pointer, at the canvas centre, hovers. + app.run("view.set", json!({"center": [25, 25], "height": height})).unwrap(); + let shapes = (0..3).map(|_| frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)])).max().unwrap_or(0); + // Without a hovered object there is no dashed highlight, and the count says nothing. + assert!(app.canvas.hover.is_some(), "view height {height}: the line is hovered"); + assert!(shapes < 5_000, "view height {height}: {shapes} shapes painted"); + assert_eq!(app.session.state().unwrap().view().height, height); + } +} From a83af92eb26bfc6dad969531a4f1384bac260295 Mon Sep 17 00:00:00 2001 From: Capri <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:34:23 +0100 Subject: [PATCH 056/151] wblock: write the source drawing's $INSUNITS and $MEASUREMENT (#2002) (#2023) * wblock: write the source drawing's $INSUNITS and $MEASUREMENT run_wblock built the written drawing from Drawing::new_imperial() and never copied the source's units, so every WBLOCK file declared inches ($INSUNITS 1) and imperial measurement ($MEASUREMENT 0) whatever the source: a block written from a metric drawing came back at 0.0254 of its size in any program that honours $INSUNITS on insert. The written drawing now takes the source's values for both variables, as AutoCAD's -WBLOCK does. A unitless source ($INSUNITS 0, kept by the reader per #1209) writes unitless. Co-Authored-By: GLM-5.3 * wblock: start from the source drawing's header and styles Clone the source header (resetting $INSBASE as before) and copy the multileader, table and multiline styles and the annotation scale list along with the other tables, so a block written from a metric drawing keeps its text size, current dimension style, limits and scales, not the imperial template's (review on #2023). Co-Authored-By: Claude Opus 5.5 * wblock_units: each WBLOCK call writes into its own TempDir The two tests shared one temp path and raced when cargo ran them in parallel (review on #2023). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: GLM-5.3 Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 8 +++ crates/engine/tests/wblock_units.rs | 87 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 crates/engine/tests/wblock_units.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 8cc248685..760b6921c 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -365,10 +365,18 @@ fn run_wblock(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string(); let src = s.doc()?; let mut d = cadcraft_doc::Drawing::new_imperial(); + // The written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its + // units ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches + // and scaled on insert, and its defaults (text size, current styles, annotation scales). + d.header = src.header.clone(); d.layers = src.layers.clone(); d.linetypes = src.linetypes.clone(); d.text_styles = src.text_styles.clone(); d.dim_styles = src.dim_styles.clone(); + d.mleader_styles = src.mleader_styles.clone(); + d.table_styles = src.table_styles.clone(); + d.mline_styles = src.mline_styles.clone(); + d.scales = src.scales.clone(); d.blocks = src.blocks.clone(); // The base point becomes the written drawing's origin, its insertion base: by default 0,0 for // objects and the block's base point for a block (`base` is then in block coordinates). diff --git a/crates/engine/tests/wblock_units.rs b/crates/engine/tests/wblock_units.rs new file mode 100644 index 000000000..8ba2dffad --- /dev/null +++ b/crates/engine/tests/wblock_units.rs @@ -0,0 +1,87 @@ +//! WBLOCK writes the source drawing's units: `$INSUNITS` and `$MEASUREMENT` of the written file +//! are the source's, for selected objects and for a block, as AutoCAD's `-WBLOCK` does. So are its +//! other settings and styles: a metric drawing's text size, dimension style and scale list. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use serde_json::json; + +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +/// Writes "INSUNITS;MEASUREMENT" and, on a second line, the settings a metric drawing changes, so +/// the test can read them from the file that would be saved. +fn write(d: &Drawing, _path: &str) -> Result, String> { + Ok(format!("{};{}\n{}", d.header.i64("INSUNITS", -1), d.header.i64("MEASUREMENT", -1), settings(d)).into_bytes()) +} + +fn settings(d: &Drawing) -> String { + let mleader = d.mleader_styles.first().map(|m| format!("{m:?}")).unwrap_or_default(); + let table = d.table_styles.first().map(|t| format!("{t:?}")).unwrap_or_default(); + let limmax = d.header.point("LIMMAX"); + format!("{} {} {limmax:?} {} {mleader} {table}", d.header.str("DIMSTYLE", ""), d.header.f64("TEXTSIZE", -1.0), d.scales.len()) +} + +/// The file WBLOCK would save: line `n` of what [`write`] wrote. Each call writes into a folder +/// of its own, so tests running in parallel never read or delete each other's file. +fn wblock(s: &mut Session, p: serde_json::Value, n: usize) -> String { + let dir = TempDir::new("wblock-units"); + let path = dir.path_str("out.dxf"); + let mut p = p; + p["path"] = json!(path); + s.execute("wblock", &p).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + text.lines().nth(n).unwrap_or("").to_string() +} + +fn written(s: &mut Session, p: serde_json::Value) -> String { + wblock(s, p, 0) +} + +fn written_settings(s: &mut Session, p: serde_json::Value) -> String { + wblock(s, p, 1) +} + +fn line(s: &mut Session) -> String { + s.execute("line", &json!({ "points": [[0, 0], [2, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string() +} + +#[test] +fn wblock_keeps_the_source_drawing_units() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + // A metric drawing in metres, like the issue's metres.dxf (`--metric` then `insunits 6`). + s.new_drawing(true); + s.execute("units", &json!({ "insunits": 6 })).unwrap(); + let b = line(&mut s); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [b], "keep": "delete" })).unwrap(); + let c = line(&mut s); + + // Selected objects and a block by name both keep the source's units, not inches. + assert_eq!(written(&mut s, json!({ "handles": [c] })), "6;1"); + assert_eq!(written(&mut s, json!({ "name": "B" })), "6;1"); + + // The metric default (millimetres) and an imperial drawing keep their units too. + s.new_drawing(true); + let m = line(&mut s); + assert_eq!(written(&mut s, json!({ "handles": [m] })), "4;1"); + s.new_drawing(false); + let i = line(&mut s); + assert_eq!(written(&mut s, json!({ "handles": [i] })), "1;0"); +} + +#[test] +fn wblock_keeps_the_source_drawing_settings_and_styles() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + // A metric drawing: ISO-25, 2.5 mm text, A3 limits, metric multileader/table styles and scales. + s.new_drawing(true); + let source = settings(s.doc().unwrap()); + let l = line(&mut s); + assert_eq!(written_settings(&mut s, json!({ "handles": [l] })), source); + s.new_drawing(false); + assert_ne!(settings(s.doc().unwrap()), source, "the imperial template differs, so the check means something"); +} From 7cee28580d1a459856b8b65c1e8890514ac35243 Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Sat, 31 Oct 2026 15:34:27 +0800 Subject: [PATCH 057/151] fix(dxf): preserve 3D LEADER vertex elevations (#2245) (#2397) Co-authored-by: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com> --- crates/io/src/dxf_read/entities.rs | 12 +- crates/io/tests/it/leader_vertex_elevation.rs | 106 ++++++++++++++++++ crates/io/tests/it/main.rs | 1 + 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 crates/io/tests/it/leader_vertex_elevation.rs diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index fc55fe335..12abae7da 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -201,7 +201,17 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef }) } "LEADER" => EntityKind::Leader(Leader { - vertices: t.pts(10).into_iter().map(|p| p.to3(0.0)).collect(), + // Each group 10 starts a vertex. Scope Y/Z to it so an omitted Z cannot + // shift a later vertex's elevation onto this one. + vertices: t + .all_f(10) + .into_iter() + .zip(tags.split(|tag| tag.code == 10).skip(1)) + .map(|(x, vertex)| { + let v = T(vertex); + Vec3::new(x, v.fd(20, 0.0), v.fd(30, 0.0)) + }) + .collect(), arrow: t.i(71).unwrap_or(1) != 0, spline: t.i(72).unwrap_or(0) != 0, style: t.s(3).unwrap_or_else(|| "Standard".into()), diff --git a/crates/io/tests/it/leader_vertex_elevation.rs b/crates/io/tests/it/leader_vertex_elevation.rs new file mode 100644 index 000000000..2566a861b --- /dev/null +++ b/crates/io/tests/it/leader_vertex_elevation.rs @@ -0,0 +1,106 @@ +//! LEADER vertex elevations survive the shared DXF read path and DWG bridge (#2245). + +use cadcraft_color::Color; +use cadcraft_doc::{Common, DimStyle, Drawing, EntityKind, Handle, Leader, Lineweight, Space}; +use cadcraft_dxf::Tag; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +type TestResult = Result>; + +fn drawing() -> TestResult { + let mut d = Drawing::new_metric(); + d.dim_styles.push(DimStyle { name: "Elevation".into(), ..DimStyle::default() }); + let common = Common { color: Color::Index(3), lineweight: Lineweight::Mm100(25), ltscale: 2.5, visible: false, ..Common::default() }; + for vertices in [ + vec![Vec3::new(1.0, 2.0, 4.0), Vec3::new(5.0, 6.0, 7.0), Vec3::new(9.0, 10.0, -3.0)], + vec![Vec3::new(1.0, 2.0, 0.0), Vec3::new(5.0, 6.0, 0.0), Vec3::new(9.0, 10.0, 0.0)], + ] { + for arrow in [false, true] { + for spline in [false, true] { + let leader = Leader { vertices: vertices.clone(), arrow, spline, style: "Elevation".into() }; + d.add(&Space::Model, common.clone(), EntityKind::Leader(leader))?; + } + } + } + Ok(d) +} + +fn check_round_trips(name: &str, binary: bool) -> TestResult { + let original = drawing()?; + for version in Version::ALL { + let mut d = original.clone(); + for pass in 1..=2 { + let bytes = write_with(&d, name, &SaveOptions { version: Some(version), binary })?; + d = read(&bytes, name)?; + assert_eq!(d.model.len(), original.model.len(), "{name} {version:?}, pass={pass}"); + for expected in original.model.iter() { + let actual = d.entity(expected.handle).ok_or("LEADER handle lost")?; + let (EntityKind::Leader(want), EntityKind::Leader(got)) = (&expected.kind, &actual.kind) else { + return Err("LEADER entity type changed".into()); + }; + assert_eq!(got.vertices, want.vertices, "XYZ/order: {name} {version:?}, pass={pass}, {:?}", expected.handle); + assert_eq!((got.arrow, got.spline, &got.style), (want.arrow, want.spline, &want.style), "LEADER properties"); + assert_eq!(actual.common, expected.common, "Common properties: {name} {version:?}, pass={pass}"); + } + } + } + Ok(()) +} + +#[test] +fn dxf_preserves_leader_vertex_elevations() -> TestResult { + for binary in [false, true] { + check_round_trips("leader.dxf", binary)?; + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dwg_preserves_leader_vertex_elevations() -> TestResult { + check_round_trips("leader.dwg", false) +} + +#[test] +fn missing_z_defaults_only_the_corresponding_vertex() -> TestResult { + let vertices = [Vec3::new(1.0, 2.0, 4.0), Vec3::new(5.0, 6.0, 7.0), Vec3::new(9.0, 10.0, -3.0)]; + // All eight presence patterns include wholly 2D input and omitted first/middle/last Zs. + // Author the groups directly, independently of CADCraft's entity writer. + for z_mask in 0..8 { + let mut tags = vec![ + Tag::s(0, "SECTION"), + Tag::s(2, "ENTITIES"), + Tag::s(0, "LEADER"), + Tag::s(5, "10"), + Tag::s(100, "AcDbEntity"), + Tag::s(8, "0"), + Tag::s(100, "AcDbLeader"), + Tag::s(3, "Standard"), + Tag::i(71, 0), + Tag::i(72, 1), + Tag::i(76, 3), + ]; + let mut expected = Vec::new(); + for (i, p) in vertices.iter().enumerate() { + tags.push(Tag::f(10, p.x)); + let has_z = z_mask & (1 << i) != 0; + if has_z { + // Y and Z can appear in either order within this vertex. + tags.push(Tag::f(30, p.z)); + } + tags.push(Tag::f(20, p.y)); + expected.push(Vec3::new(p.x, p.y, if has_z { p.z } else { 0.0 })); + } + tags.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]); + let dxf = cadcraft_dxf::write_ascii(&tags); + let d = read(dxf.as_bytes(), "missing-z.dxf")?; + let e = d.entity(Handle(0x10)).ok_or("LEADER missing")?; + let EntityKind::Leader(l) = &e.kind else { return Err("expected LEADER".into()) }; + assert_eq!(l.vertices, expected, "Z presence mask {z_mask:03b}"); + assert!(!l.arrow && l.spline); + assert_eq!(l.style, "Standard"); + assert_eq!(e.common, Common::default()); + } + Ok(()) +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 7f6ad8f20..52f37068a 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -33,6 +33,7 @@ mod hatch_pattern_def; mod helix_roundtrip; mod layout_paper_units; mod layout_plot_window; +mod leader_vertex_elevation; #[cfg(not(target_arch = "wasm32"))] mod low_handseed; mod mleader_content; From b0b2aba23e0244819715ae91ce804a71aa6abdee Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:32:24 +0100 Subject: [PATCH 058/151] Reject non-finite arguments in CAL min() and max() (#2547) Found while testing crates/engine/src/cmd/utility/calc.rs:~355-360 (call, "min" | "max" branch using f64::min/f64::max) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/utility/calc.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/engine/src/cmd/utility/calc.rs b/crates/engine/src/cmd/utility/calc.rs index 05c6fc52e..0c0d29b32 100644 --- a/crates/engine/src/cmd/utility/calc.rs +++ b/crates/engine/src/cmd/utility/calc.rs @@ -333,6 +333,9 @@ fn call(name: &str, args: &[Val]) -> Result { "min" | "max" => { arity(1, MAX_ARGS)?; let vs = (0..args.len()).map(n).collect::, _>>()?; + if vs.iter().any(|v| !v.is_finite()) { + return Err("result is not a finite number".into()); + } let f = if name == "min" { f64::min } else { f64::max }; Ok(Val::Num(vs.into_iter().reduce(f).unwrap_or(0.0))) } @@ -597,6 +600,10 @@ mod tests { assert!(ev("[1").is_err()); assert!(ev(&"[".repeat(5000)).is_err()); assert_eq!(ev("min(3,1,2)"), Ok(Val::Num(1.0))); + assert!(ev("min(sqrt(-1), 5)").is_err()); + assert!(ev("max(5, ln(-1))").is_err()); + assert!(ev("min(exp(1000), 5)").is_err()); + assert!(ev("max(exp(1000), 5)").is_err()); assert_eq!(ev("1e-3*2"), Ok(Val::Num(0.002))); assert!(ev("max(1,2,3,4,5,6,7,8,9)").is_err()); } From c254c51474e859377b2ab1e0ae2376c67ca12b47 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:32:28 +0100 Subject: [PATCH 059/151] Reject nan and inf text values in SETVAR for real variables (#2546) Found while testing crates/engine/src/sysvars.rs:~404-425 (generic fallthrough in set: String branch, then the `Some(HVal::Real(_))` arm) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/sysvars.rs | 2 +- crates/engine/src/tests.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index b532f66dd..60af821d7 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -424,7 +424,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { // Keep the header type stable for known numeric vars. match d.header.get(&n) { Some(cadcraft_doc::HVal::Real(_)) => { - d.header.set_f64(&n, val.as_f64().ok_or_else(bad)?); + d.header.set_f64(&n, val.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?); return Ok(()); } Some(cadcraft_doc::HVal::Int(_)) => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1f3d0e8e1..75cfd468e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1465,3 +1465,16 @@ fn array_counts_reject_invalid_values() { } s.execute("arraypolar", &json!({"center": [0, 0], "count": 1, "associative": false})).unwrap(); } + +#[test] +fn setvar_rejects_non_finite_text_for_real_variables() { + let mut s = Session::new(); + for name in ["LTSCALE", "TEXTSIZE", "SNAPANG"] { + let before = s.execute("getvar", &json!({"name": name})).unwrap(); + for text in ["nan", "inf", "-inf"] { + let r = s.execute("setvar", &json!({"name": name, "value": text})); + assert!(r.is_err(), "SETVAR {name} {text} was accepted"); + assert_eq!(s.execute("getvar", &json!({"name": name})).unwrap(), before); + } + } +} From add1fd3528aa7b31651e5815b72f0ea4308d10e1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:32:32 +0100 Subject: [PATCH 060/151] Fix LISP atof returning a wrong value for out-of-range exponents (#2533) Found while testing crates/engine/src/lisp/builtins/strings.rs:64-72 (atof) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/lisp/builtins/strings.rs | 45 ++++++++++++++++------ crates/engine/src/lisp/tests.rs | 9 +++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/lisp/builtins/strings.rs b/crates/engine/src/lisp/builtins/strings.rs index 46d985bf1..853e0656f 100644 --- a/crates/engine/src/lisp/builtins/strings.rs +++ b/crates/engine/src/lisp/builtins/strings.rs @@ -60,20 +60,43 @@ pub(super) fn atoi(a: &[Value]) -> R { Ok(Value::Int(t.get(..end).and_then(|n| n.parse::().ok()).map(|n| n as i32).unwrap_or(0))) } -/// C's `atof`: the longest number at the start; 0.0 when there is none. +/// C's `atof`: the number token at the start; 0.0 when there is none. +/// An overflowing token clamps to the largest finite value of its sign. pub(super) fn atof(a: &[Value]) -> R { arity(a, 1, 1)?; let s = string(&a[0])?; - // The longest prefix (of the first 64 characters) that reads as a finite number. - let t: String = s.trim_start().chars().take(64).collect(); - let ends: Vec = t.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(t.len())).collect(); - let v = ends.iter().rev().find_map(|&e| t.get(..e).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite() && !p_is_word(&t[..e]))); - Ok(Value::Real(v.unwrap_or(0.0))) -} - -/// Words Rust reads as numbers that C's `atof` doesn't ("inf", "nan", "infinity"). -fn p_is_word(p: &str) -> bool { - p.chars().any(|c| c.is_ascii_alphabetic() && !matches!(c, 'e' | 'E')) + let b = s.trim_start().as_bytes(); + let digits = |mut i: usize| { + while b.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + i + }; + let mut i = usize::from(matches!(b.first(), Some(b'-' | b'+'))); + let int_end = digits(i); + let mut end = int_end; + let mut mantissa = int_end > i; + if b.get(end) == Some(&b'.') { + let frac_end = digits(end + 1); + if mantissa || frac_end > end + 1 { + mantissa = true; + end = frac_end; + } + } + if mantissa && matches!(b.get(end), Some(b'e' | b'E')) { + i = end + 1 + usize::from(matches!(b.get(end + 1), Some(b'-' | b'+'))); + let exp_end = digits(i); + if exp_end > i { + end = exp_end; + } + } + let v = if mantissa { b.get(..end).and_then(|t| std::str::from_utf8(t).ok()).and_then(|t| t.parse::().ok()) } else { None }; + let v = match v { + Some(v) if v.is_finite() => v, + Some(v) if v.is_infinite() => f64::MAX.copysign(v), + _ => 0.0, + }; + Ok(Value::Real(v)) } pub(super) fn chr(a: &[Value]) -> R { diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index 0cb31f395..0ed4b22fd 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -1039,3 +1039,12 @@ fn a_lisp_value_at_a_prompt_is_not_evaluated_again() { }); assert_eq!(r, Some(4.0)); } + +#[test] +fn atof_out_of_range_exponent_reads_whole_token() { + let mut s = Session::new(); + // Overflow must not fall back to a shorter prefix such as "1e40". + assert_eq!(ok(&mut s, "(> (atof \"1e400\") 1e300)"), "T"); + assert_eq!(ok(&mut s, "(< (atof \"-1e400\") -1e300)"), "T"); + assert_eq!(ok(&mut s, "(= (atof \"1e-400\") 0.0)"), "T"); +} From e28ed2018ad46cbe0c5ff6440efbd515df3d6e9a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:32:35 +0100 Subject: [PATCH 061/151] Clamp Bounds2 width/height instead of overflowing to infinity (#2532) Found while testing crates/geom/src/bounds.rs:49-54 (width, height) Co-authored-by: Claude Haiku 5.5 --- crates/geom/src/bounds.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/bounds.rs b/crates/geom/src/bounds.rs index c52efdef6..ba5b7175d 100644 --- a/crates/geom/src/bounds.rs +++ b/crates/geom/src/bounds.rs @@ -9,6 +9,12 @@ pub struct Bounds2 { pub max: Vec2, } +/// `max - min`, clamped to `f64::MAX` when finite endpoints make it overflow. +fn span(min: f64, max: f64) -> f64 { + let d = max - min; + if d.is_infinite() && min.is_finite() && max.is_finite() { f64::MAX } else { d } +} + impl Default for Bounds2 { fn default() -> Self { Bounds2::EMPTY @@ -47,10 +53,10 @@ impl Bounds2 { Bounds2 { min: self.min.min(o.min), max: self.max.max(o.max) } } pub fn width(&self) -> f64 { - if self.is_empty() { 0.0 } else { self.max.x - self.min.x } + if self.is_empty() { 0.0 } else { span(self.min.x, self.max.x) } } pub fn height(&self) -> f64 { - if self.is_empty() { 0.0 } else { self.max.y - self.min.y } + if self.is_empty() { 0.0 } else { span(self.min.y, self.max.y) } } pub fn center(&self) -> Vec2 { if self.is_empty() { @@ -98,4 +104,14 @@ mod tests { assert_eq!(b.center().y, 5.0); assert_eq!(Bounds2::EMPTY.center(), Vec2::ZERO); } + + #[test] + fn width_and_height_stay_finite_for_opposite_large_coordinates() { + let b = Bounds2::new(Vec2::new(-1e308, -1e308), Vec2::new(1e308, 1e308)); + assert_eq!(b.width(), f64::MAX); + assert_eq!(b.height(), f64::MAX); + let b = Bounds2::new(Vec2::new(1e308, 0.0), Vec2::new(1.6e308, 10.0)); + assert!((b.width() / 1e308 - 0.6).abs() < 1e-12); + assert_eq!(b.height(), 10.0); + } } From 0575a7629157568e71f0370e8f5e2c5e685261fa Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:32:39 +0100 Subject: [PATCH 062/151] Reject out-of-range OSMODE values in SETVAR instead of wrapping (#2531) Found while testing crates/engine/src/sysvars.rs:282 (set, "OSMODE" branch) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/sysvars.rs | 4 +++- crates/engine/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 60af821d7..930a9bad1 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -281,7 +281,9 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { let bad = || EngineError::BadParams { cmd: "setvar".into(), msg: format!("invalid value for {n}") }; let st = &mut s.settings; match n.as_str() { - "OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff, + "OSMODE" => { + st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|x| *x <= 0x7fff).ok_or_else(bad)? as u32 + } // The `color` command's input: ByLayer, ByBlock, a name, an index or a true colour. "CECOLOR" => crate::cmd::set_current_color_value(s, v).ok_or_else(bad)?, "OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 75cfd468e..fec3fbb18 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -653,6 +653,18 @@ fn setvar_rejects_out_of_range_units() { assert_ne!(s.doc().unwrap().header.i64("LUNITS", 2), 9); } +#[test] +fn setvar_rejects_out_of_range_osmode() { + let mut s = Session::new(); + s.execute("setvar", &json!({"name": "OSMODE", "value": 4133})).unwrap(); + for v in [4294967296u64, 65536, 32768] { + assert!(s.execute("setvar", &json!({"name": "OSMODE", "value": v})).is_err(), "{v}"); + assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(4133)), "{v}"); + } + s.execute("setvar", &json!({"name": "OSMODE", "value": 0x7fff})).unwrap(); + assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(0x7fff))); +} + #[test] fn dynamic_input_pointer_settings() { let mut s = Session::new(); From fc9d0565762d59b64a327590d15bc0b4a3303e39 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:32:42 +0100 Subject: [PATCH 063/151] Refuse mirror lines whose points are closer than the geometry EPS (#2555) * Refuse mirror lines whose points are closer than the geometry EPS Found while testing crates/engine/src/cmd/modify/transform.rs:run_mirror (coincidence check `a.near(b, 1e-12)`), with crates/geom/src/mat.rs:43-47 (Mat3::mirror) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * mirror: the interactive prompt also refuses points closer than the geometry EPS Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/modify/select_then.rs | 2 +- crates/engine/src/cmd/modify/transform.rs | 2 +- crates/engine/tests/it/main.rs | 1 + .../engine/tests/it/mirror_degenerate_line.rs | 17 +++++++++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/it/mirror_degenerate_line.rs diff --git a/crates/engine/src/cmd/modify/select_then.rs b/crates/engine/src/cmd/modify/select_then.rs index 10bf08399..f9a970ebd 100644 --- a/crates/engine/src/cmd/modify/select_then.rs +++ b/crates/engine/src/cmd/modify/select_then.rs @@ -445,7 +445,7 @@ impl Interactive for SelectThen { Ok(Step::Done) } (Op::Mirror, 1, Input::Point(p)) => { - if p.near(self.pts[0], 1e-12) { + if p.near(self.pts[0], cadcraft_geom::EPS) { return Err(EngineError::Other("Mirror line points must differ.".into())); } self.pts.push(p); diff --git a/crates/engine/src/cmd/modify/transform.rs b/crates/engine/src/cmd/modify/transform.rs index 5a86d9e07..18ff4866c 100644 --- a/crates/engine/src/cmd/modify/transform.rs +++ b/crates/engine/src/cmd/modify/transform.rs @@ -100,7 +100,7 @@ pub(super) fn run_mirror(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let a = point_req("mirror", p, "p1")?; let b = point_req("mirror", p, "p2")?; - if a.near(b, 1e-12) { + if a.near(b, cadcraft_geom::EPS) { return Err(bad("mirror", "mirror line points coincide")); } let erase_src = bool_or(p, "erase", false); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 5c0a37f45..8009cf4d3 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -71,6 +71,7 @@ mod malformed_handles; mod matchprop; mod measure_distance_snapping; mod metric_linetypes; +mod mirror_degenerate_line; mod mleader_content; mod mleader_remember; mod mline; diff --git a/crates/engine/tests/it/mirror_degenerate_line.rs b/crates/engine/tests/it/mirror_degenerate_line.rs new file mode 100644 index 000000000..afa7a32a1 --- /dev/null +++ b/crates/engine/tests/it/mirror_degenerate_line.rs @@ -0,0 +1,17 @@ +//! MIRROR refuses a mirror line whose points are closer than the geometry tolerance. + +use cadcraft_engine::Session; +use serde_json::json; + +#[test] +fn nearly_coincident_mirror_points_are_refused() { + let mut s = Session::new(); + s.execute("line", &json!({ "points": [[0, 5], [10, 5]] })).unwrap(); + let before = s.doc().unwrap().model.iter().count(); + for erase in [false, true] { + let r = s.execute("mirror", &json!({ "handles": ["100"], "p1": [0, 0], "p2": [5e-10, 0], "erase": erase })); + let err = r.expect_err("mirror line is degenerate").to_string(); + assert!(err.contains("mirror line points coincide"), "{err}"); + } + assert_eq!(s.doc().unwrap().model.iter().count(), before); +} From f18cdcbcac29627f25cf4bd7797358896f000089 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:32:46 +0700 Subject: [PATCH 064/151] Add -UNITS; show due surveyor bearings as one letter (#2562) * Add -UNITS; show due surveyor bearings as one letter -UNITS asks for the length format and precision, the angle format and precision, the direction of angle 0 and whether angles turn clockwise, with AutoCAD 2024's prompts, listings, defaults and messages. As in AutoCAD, each answer is kept as it is given, and choosing another format resets its precision to 4. The direction is typed in the chosen angle format and turns as ANGDIR says. Surveyor's units show a bearing that is due north, south, east or west as that letter ("E"), except to whole degrees ("N 90d E"), as AutoCAD's ANGTOS does. Co-Authored-By: Claude Opus 5.5 * units: read a due bearing typed as one letter (E, N, W, S) in surveyor's units Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/props.rs | 2 + crates/engine/src/cmd/props/dash_units.rs | 220 +++++++++++++++++++++ crates/engine/src/units.rs | 60 +++++- crates/engine/tests/it/dash_units.rs | 223 ++++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 5 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/props/dash_units.rs create mode 100644 crates/engine/tests/it/dash_units.rs diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 43f2aca38..45199b12c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -2,6 +2,7 @@ //! RENAME. mod chprop; +mod dash_units; mod matchprop; mod rename; @@ -38,6 +39,7 @@ pub fn specs() -> Vec { CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"), CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))), CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"), + CommandSpec::new("-units", "Units", run_units).alias(&["-un"]).params("(as UNITS)").interactive(|_| Ok(Box::new(dash_units::DashUnits::default()))), CommandSpec::new("thickness", "Thickness", run_thickness) .menu(&["Format", "Thickness"]) .params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}") diff --git a/crates/engine/src/cmd/props/dash_units.rs b/crates/engine/src/cmd/props/dash_units.rs new file mode 100644 index 000000000..a10153f56 --- /dev/null +++ b/crates/engine/src/cmd/props/dash_units.rs @@ -0,0 +1,220 @@ +//! -UNITS: the drawing's units at the command line, with AutoCAD 2024's prompts and listings. + +use std::f64::consts::TAU; + +use crate::units::{AngleSettings, format_angle}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +const REPORT_FORMATS: [&str; 10] = [ + "Report formats: (Examples)", + " 1. Scientific 1.55E+01", + " 2. Decimal 15.50", + " 3. Engineering 1'-3.50\"", + " 4. Architectural 1'-3 1/2\"", + " 5. Fractional 15 1/2", + "With the exception of Engineering and Architectural formats,", + "these formats can be used with any basic unit of measurement.", + "For example, Decimal mode is perfect for metric units as well", + "as decimal English units.", +]; + +const ANGLE_SYSTEMS: [&str; 6] = [ + "Systems of angle measure: (Examples)", + " 1. Decimal degrees 45.0000", + " 2. Degrees/minutes/seconds 45d0'0\"", + " 3. Grads 50.0000g", + " 4. Radians 0.7854r", + " 5. Surveyor's units N 45d0'0\" E", +]; + +const DENOMINATOR: &str = "Enter denominator of smallest fraction to display"; + +/// The drawing's units, as -UNITS shows and asks them. +struct Units { + lunits: i64, + luprec: i64, + aunits: i64, + auprec: i64, + angbase: f64, + clockwise: bool, +} + +impl Units { + fn of(s: &Session) -> Units { + let Ok(d) = s.doc() else { return Units { lunits: 2, luprec: 4, aunits: 0, auprec: 0, angbase: 0.0, clockwise: false } }; + let h = &d.header; + let angbase = h.f64("ANGBASE", 0.0); + Units { + lunits: h.i64("LUNITS", 2).clamp(1, 5), + luprec: h.i64("LUPREC", 4).clamp(0, 8), + aunits: h.i64("AUNITS", 0).clamp(0, 4), + auprec: h.i64("AUPREC", 0).clamp(0, 8), + angbase: if angbase.is_finite() { angbase } else { 0.0 }, + clockwise: h.i64("ANGDIR", 0) != 0, + } + } + + /// Architectural and fractional units give their precision as a fraction's denominator. + fn fractional(&self) -> bool { + matches!(self.lunits, 4 | 5) + } + + /// Direction `a` (radians counterclockwise from +X) in the angle format, measured as the + /// drawing measures angles but from +X: -UNITS shows north as 270 when angles turn clockwise. + fn direction_text(&self, a: f64) -> String { + format_angle(if self.clockwise { -a } else { a }, self.aunits, self.auprec) + } + + /// A typed direction of angle 0, in radians counterclockwise from +X: an amount in the angle + /// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, and in surveyor's + /// units N, E, S or W), measured from +X as the drawing measures angles. + fn direction(&self, t: &str) -> Option { + let cardinal = match t.trim().to_ascii_lowercase().as_str() { + "e" if self.aunits == 4 => Some(0.0), + "n" if self.aunits == 4 => Some(90f64.to_radians()), + "w" if self.aunits == 4 => Some(180f64.to_radians()), + "s" if self.aunits == 4 => Some(270f64.to_radians()), + _ => None, + }; + let a = cardinal.or_else(|| AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t))?; + let a = if self.clockwise { -a } else { a }; + a.is_finite().then(|| a.rem_euclid(TAU)) + } +} + +/// An integer answer in `lo..=hi`, or AutoCAD's message for one that isn't. +fn integer(t: &str, lo: i64, hi: i64) -> Result { + let v = t.trim().parse::().map_err(|_| EngineError::Other("Requires an integer value.".into()))?; + if (lo..=hi).contains(&v) { Ok(v) } else { Err(EngineError::Other(format!("Requires an integer between {lo} and {hi}."))) } +} + +#[derive(Clone, Copy, Default)] +enum Ask { + #[default] + LengthFormat, + LengthPrecision, + AngleFormat, + AnglePrecision, + Direction, + Clockwise, +} + +/// -UNITS: the length format and precision, the angle format and precision, the direction of +/// angle 0 and whether angles turn clockwise, asked in that order. As in AutoCAD, each answer is +/// kept as it is given (Esc keeps the ones before it), and choosing another format resets its +/// precision to 4, the precision then offered. +#[derive(Default)] +pub(super) struct DashUnits { + ask: Ask, +} + +impl Interactive for DashUnits { + fn name(&self) -> &'static str { + "-UNITS" + } + + fn begin(&mut self, s: &mut Session) -> Result { + for l in REPORT_FORMATS { + s.echo(l); + } + Ok(Step::Continue) + } + + fn prompt(&self, s: &Session) -> Prompt { + let u = Units::of(s); + match self.ask { + Ask::LengthFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default(u.lunits.to_string()), + Ask::LengthPrecision if u.fractional() => { + Prompt::new("(1, 2, 4, 8, 16, 32, 64, 128, or 256)", Accept::NUMBER).default((1_i64 << u.luprec).to_string()) + } + Ask::LengthPrecision => { + Prompt::new("Enter number of digits to right of decimal point (0 to 8)", Accept::NUMBER).default(u.luprec.to_string()) + } + Ask::AngleFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default((u.aunits + 1).to_string()), + Ask::AnglePrecision => { + Prompt::new("Enter number of fractional places for display of angles (0 to 8)", Accept::NUMBER).default(u.auprec.to_string()) + } + Ask::Direction => { + Prompt::new(format!("Enter direction for angle {}", u.direction_text(0.0)), Accept::TEXT).default(u.direction_text(u.angbase)) + } + Ask::Clockwise => Prompt::new("Measure angles clockwise?", Accept::TEXT).kw(&["Yes", "No"]).default(if u.clockwise { "Y" } else { "N" }), + } + } + + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let u = Units::of(s); + let text = match &i { + Input::Text(t) => Some(t.as_str()), + Input::Enter => None, + Input::Keyword(k) => Some(k.as_str()), + _ => return Ok(Step::Continue), + }; + let h = &mut s.doc_mut()?.header; + match self.ask { + Ask::LengthFormat => { + let l = text.map_or(Ok(u.lunits), |t| integer(t, 1, 5))?; + if l != u.lunits { + h.set_i64("LUNITS", l); + h.set_i64("LUPREC", 4); + } + self.ask = Ask::LengthPrecision; + if matches!(l, 4 | 5) { + s.echo(DENOMINATOR); + } + } + Ask::LengthPrecision => { + let prec = if u.fractional() { + let den = text.map_or(Some(1 << u.luprec), |t| t.trim().parse::().ok()); + let Some(p) = den.and_then(|d| (0..=8).find(|p| 1_i64 << p == d)) else { + s.echo("Invalid choice."); + s.echo(DENOMINATOR); + return Ok(Step::Continue); + }; + p + } else { + text.map_or(Ok(u.luprec), |t| integer(t, 0, 8))? + }; + h.set_i64("LUPREC", prec); + self.ask = Ask::AngleFormat; + for l in ANGLE_SYSTEMS { + s.echo(l); + } + } + Ask::AngleFormat => { + let a = text.map_or(Ok(u.aunits + 1), |t| integer(t, 1, 5))? - 1; + if a != u.aunits { + h.set_i64("AUNITS", a); + h.set_i64("AUPREC", 4); + } + self.ask = Ask::AnglePrecision; + } + Ask::AnglePrecision => { + h.set_i64("AUPREC", text.map_or(Ok(u.auprec), |t| integer(t, 0, 8))?); + self.ask = Ask::Direction; + let u = Units::of(s); + s.echo(format!("Direction for angle {}:", u.direction_text(0.0))); + for (name, deg) in [("East 3", 0.0), ("North 12", 90.0), ("West 9", 180.0), ("South 6", 270.0)] { + s.echo(format!(" {name} o'clock = {}", u.direction_text(f64::to_radians(deg)))); + } + } + Ask::Direction => { + if let Some(t) = text { + let a = u.direction(t).ok_or_else(|| EngineError::Other("Requires valid numeric angle or two points.".into()))?; + h.set_f64("ANGBASE", a); + } + self.ask = Ask::Clockwise; + } + Ask::Clockwise => { + let clockwise = match text.map(|t| t.trim().trim_start_matches('_').to_ascii_lowercase()) { + None => u.clockwise, + Some(t) if t == "y" || t == "yes" => true, + Some(t) if t == "n" || t == "no" => false, + Some(_) => return Err(EngineError::Other("Invalid option keyword.".into())), + }; + h.set_i64("ANGDIR", i64::from(clockwise)); + return Ok(Step::Done); + } + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index 3c6f3456e..560551f1c 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -172,7 +172,16 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String { } else { ("S", "E", deg - 270.0) }; - format!("{ns} {} {ew}", format_dms(ang, prec)) + // A bearing shown as due north, south, east or west is that letter, as AutoCAD 2024 + // shows it ("E" for 0), except to whole degrees ("N 90d E"). + let dms = format_dms(ang, prec); + if prec > 0 && dms == format_dms(0.0, prec) { + ns.to_string() + } else if prec > 0 && dms == format_dms(90.0, prec) { + ew.to_string() + } else { + format!("{ns} {dms} {ew}") + } } _ => format!("{:.*}", prec, wrap_full_turn(deg, 360.0, prec)), } @@ -332,6 +341,9 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> { ((num(g)? * 0.9).to_radians(), false) } else if let Some(b) = bearing(&t) { (b, true) + } else if let Some(b) = (aunits == 4).then(|| cardinal(&t)).flatten() { + // A due bearing as surveyor's units show it: one letter. + (b, true) } else if let Some(v) = num(&t) { let a = match aunits { 2 => (v * 0.9).to_radians(), @@ -345,6 +357,17 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> { v.0.is_finite().then_some(v) } +/// A due bearing (`n`, `e`, `s`, `w`, lower case) as radians counterclockwise from +X. +fn cardinal(t: &str) -> Option { + match t { + "e" => Some(0.0), + "n" => Some(PI / 2.0), + "w" => Some(PI), + "s" => Some(1.5 * PI), + _ => None, + } +} + /// A surveyor's bearing (`n45d30'e`, lower case) as radians counterclockwise from +X. fn bearing(t: &str) -> Option { let north = t.starts_with('n'); @@ -542,6 +565,41 @@ mod tests { } } + #[test] + fn surveyors_units_read_a_due_bearing_typed_as_one_letter() { + // What surveyor's units show for a due bearing can be typed back, e.g. `@10 Session { + let mut s = Session::new(); + let h = &mut s.doc_mut().unwrap().header; + h.set_i64("LUNITS", u.0); + h.set_i64("LUPREC", u.1); + h.set_i64("AUNITS", u.2); + h.set_i64("AUPREC", u.3); + h.set_f64("ANGBASE", u.4); + h.set_i64("ANGDIR", u.5); + s +} + +fn units(s: &Session) -> Units { + let h = &s.doc().unwrap().header; + (h.i64("LUNITS", 2), h.i64("LUPREC", 4), h.i64("AUNITS", 0), h.i64("AUPREC", 0), h.f64("ANGBASE", 0.0), h.i64("ANGDIR", 0)) +} + +fn same(a: Units, b: Units) -> bool { + (a.0, a.1, a.2, a.3, a.5) == (b.0, b.1, b.2, b.3, b.5) && (a.4 - b.4).abs() <= 1e-12 +} + +fn run(s: &mut Session, answers: &str) { + s.cmdline(&format!("(command \"_.-UNITS\" {answers})")).unwrap(); + assert!(s.running.is_none(), "{answers}: {}", s.prompt_text()); +} + +#[test] +fn the_measured_scripts_set_the_units() { + let cases: [(Units, &str, Units); 21] = [ + (DEFAULT, "4 16 1 0 0 \"_N\"", (4, 4, 0, 0, 0.0, 0)), + (DEFAULT, "3 2 2 4 90 \"_Y\"", (3, 2, 1, 4, FRAC_PI_2, 1)), + (DEFAULT, "2 3 3 2 100 \"_N\"", (2, 3, 2, 2, FRAC_PI_2, 0)), + (DEFAULT, "5 8 5 2 \"N\" \"_N\"", (5, 3, 4, 2, FRAC_PI_2, 0)), + ((4, 3, 1, 2, 0.5, 1), "\"\" \"\" \"\" \"\" \"\" \"\"", (4, 3, 1, 2, 0.5, 1)), + ((2, 4, 0, 0, FRAC_PI_2, 1), "2 4 1 0 45 \"_N\"", (2, 4, 0, 0, 5.497787143782137, 0)), + (DEFAULT, "1 2 4 3 30 \"_N\"", (1, 2, 3, 3, 4.867258771281656, 0)), + ((2, 2, 0, 2, 0.0, 0), "3 \"\" 2 \"\" \"\" \"\"", (3, 4, 1, 4, 0.0, 0)), + ((2, 2, 0, 2, 0.0, 0), "2 \"\" 1 \"\" \"\" \"\"", (2, 2, 0, 2, 0.0, 0)), + (DEFAULT, "4 3 \"\" \"\" \"\" \"\" \"\"", (4, 4, 0, 0, 0.0, 0)), + ((2, 4, 0, 0, 0.0, 1), "2 4 5 4 \"N45dE\" \"\"", (2, 4, 4, 4, 5.497787143782137, 1)), + (DEFAULT, "6 \"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 0, 0, 0.0, 0)), + ((2, 2, 0, 0, 0.0, 0), "4 \"\" \"\" \"\" \"\" \"\"", (4, 4, 0, 0, 0.0, 0)), + ((5, 6, 0, 0, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (5, 6, 0, 0, 0.0, 0)), + ((2, 4, 0, 0, 0.0, 1), "\"\" \"\" 5 \"\" \"N\" \"\"", (2, 4, 4, 4, 4.712388980384689, 1)), + ((2, 4, 3, 4, 0.0, 0), "\"\" \"\" \"\" \"\" \"45d\" \"\"", (2, 4, 3, 4, 0.7853981633974482, 0)), + (DEFAULT, "5 1 \"\" \"\" \"\" \"\"", (5, 0, 0, 0, 0.0, 0)), + ((1, 7, 0, 7, 0.0, 0), "3 \"\" 1 \"\" \"\" \"\"", (3, 4, 0, 7, 0.0, 0)), + (DEFAULT, "\"\" \"\" \"\" \"\" \"\" \"Yes\"", (2, 4, 0, 0, 0.0, 1)), + ((2, 4, 2, 6, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 2, 6, 0.0, 0)), + ((2, 4, 4, 2, 0.0, 1), "\"\" \"\" \"\" \"\" \"S45dE\" \"\"", (2, 4, 4, 2, 0.7853981633974482, 1)), + ]; + for (from, answers, to) in cases { + let mut s = session(from); + run(&mut s, answers); + assert!(same(units(&s), to), "{answers}: {:?}, AutoCAD {to:?}", units(&s)); + } +} + +/// The prompt, after the command's name. +fn asks(s: &Session) -> String { + let p = s.prompt_text(); + p.strip_prefix("-UNITS ").unwrap_or(&p).to_string() +} + +fn ends_with(s: &Session, lines: &[&str]) -> bool { + s.log.len() >= lines.len() && s.log[s.log.len() - lines.len()..].iter().zip(lines).all(|(a, b)| a == b) +} + +#[test] +fn prompts_and_listings_show_the_drawings_units() { + // Architectural to 1/8, degrees/minutes/seconds to 2 places, angle 0 at 0.5 radians, clockwise. + let mut s = session((4, 3, 1, 2, 0.5, 1)); + s.cmdline("-units").unwrap(); + assert!(s.log.iter().any(|l| l == " 4. Architectural 1'-3 1/2\""), "{:?}", s.log); + assert_eq!(asks(&s), "Enter choice, 1 to 5 <4>:"); + s.cmdline("").unwrap(); + assert!(ends_with(&s, &["Enter denominator of smallest fraction to display"]), "{:?}", s.log); + assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <8>:"); + s.cmdline("").unwrap(); + assert!(ends_with(&s, &[" 4. Radians 0.7854r", " 5. Surveyor's units N 45d0'0\" E"]), "{:?}", s.log); + assert_eq!(asks(&s), "Enter choice, 1 to 5 <2>:"); + s.cmdline("").unwrap(); + assert_eq!(asks(&s), "Enter number of fractional places for display of angles (0 to 8) <2>:"); + s.cmdline("").unwrap(); + let directions = [ + "Direction for angle 0d0':", + " East 3 o'clock = 0d0'", + " North 12 o'clock = 270d0'", + " West 9 o'clock = 180d0'", + " South 6 o'clock = 90d0'", + ]; + assert!(ends_with(&s, &directions), "{:?}", s.log); + assert_eq!(asks(&s), "Enter direction for angle 0d0' <331d21'>:"); + s.cmdline("").unwrap(); + assert_eq!(asks(&s), "Measure angles clockwise? [Yes/No] :"); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert!(same(units(&s), (4, 3, 1, 2, 0.5, 1)), "{:?}", units(&s)); + + // Surveyor's units, clockwise: north is listed as S, as AutoCAD lists it. + let mut s = session((2, 4, 0, 0, 0.0, 1)); + s.script( + "-units + + +5 + +", + ) + .unwrap(); + let directions = [ + "Direction for angle E:", + " East 3 o'clock = E", + " North 12 o'clock = S", + " West 9 o'clock = W", + " South 6 o'clock = N", + ]; + assert!(ends_with(&s, &directions), "{:?}", s.log); + assert_eq!(asks(&s), "Enter direction for angle E :"); + + // The listing shows directions to the precision just given. + let mut s = session(DEFAULT); + s.script( + "-units + + +2 +2 +", + ) + .unwrap(); + let directions = [ + "Direction for angle 0d0':", + " East 3 o'clock = 0d0'", + " North 12 o'clock = 90d0'", + " West 9 o'clock = 180d0'", + " South 6 o'clock = 270d0'", + ]; + assert!(ends_with(&s, &directions), "{:?}", s.log); + assert_eq!(asks(&s), "Enter direction for angle 0d0' <0d0'>:"); +} + +#[test] +fn a_rejected_answer_is_asked_again() { + let mut s = session(DEFAULT); + s.cmdline("-units").unwrap(); + s.cmdline("6").unwrap(); + assert!(ends_with(&s, &["Requires an integer between 1 and 5."]), "{:?}", s.log); + s.cmdline("2.5").unwrap(); + assert!(ends_with(&s, &["Requires an integer value."]), "{:?}", s.log); + assert_eq!(asks(&s), "Enter choice, 1 to 5 <2>:"); + s.cmdline("4").unwrap(); + s.cmdline("3").unwrap(); + assert!(ends_with(&s, &["Invalid choice.", "Enter denominator of smallest fraction to display"]), "{:?}", s.log); + assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <16>:"); + s.script( + " + +9 +", + ) + .unwrap(); + assert!(ends_with(&s, &["Requires an integer between 0 and 8."]), "{:?}", s.log); + s.script( + " +abc +", + ) + .unwrap(); + assert!(ends_with(&s, &["Requires valid numeric angle or two points."]), "{:?}", s.log); + s.script( + " + +", + ) + .unwrap(); + assert!(s.running.is_none()); + assert!(same(units(&s), (4, 4, 0, 0, 0.0, 0)), "{:?}", units(&s)); +} + +#[test] +fn each_answer_is_kept_as_it_is_given() { + // Esc after the angle format: the answers before it are kept, and the new angle format's + // precision is reset to 4. + let mut s = session(DEFAULT); + s.script( + "-units +4 +16 +2 +", + ) + .unwrap(); + s.cancel(); + assert!(s.running.is_none()); + assert!(same(units(&s), (4, 4, 1, 4, 0.0, 0)), "{:?}", units(&s)); + let mut s = session(DEFAULT); + s.script( + "-units +3 +2 +3 +5 +90 +", + ) + .unwrap(); + s.cancel(); + assert!(same(units(&s), (3, 2, 2, 5, 1.413716694115407, 0)), "{:?}", units(&s)); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 8009cf4d3..ee06666c5 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -26,6 +26,7 @@ mod constraint_infer; mod copy_count; mod count; mod dash_block; +mod dash_units; mod data_extraction; mod dim_baseline_spacing; mod dim_chain; From 81e93f2caf0bb7fbd7ae3f00c58e01492213d01b Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:32:49 +0700 Subject: [PATCH 065/151] Scale heights with SCALE and keep them with OFFSET (#2558) AutoCAD 2024's SCALE is a uniform 3D scale about the base point: every Z and elevation scales about the base point's Z (the current ELEVATION for a point given without one) and every thickness by the factor. cadcraft scaled X and Y only. The JSON form also takes a Z with the base point. OFFSET's copy now keeps the source's height: a line's end Zs, a circle's or arc's centre Z, a polyline's elevation. A circle's or arc's copy has no thickness and a polyline's keeps it, as AutoCAD makes them. Grip scaling, ALIGN's scale option and MOCORO stay 2D: their base point is often a grip or snapped point whose Z cadcraft doesn't keep. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/entity.rs | 85 +++++++++ crates/engine/src/cmd/modify.rs | 2 +- crates/engine/src/cmd/modify/offset.rs | 27 ++- crates/engine/src/cmd/modify/select_then.rs | 6 +- crates/engine/src/cmd/modify/transform.rs | 36 +++- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/scale_offset_z.rs | 184 ++++++++++++++++++++ 7 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/it/scale_offset_z.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index d13b82722..b19d10961 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -465,6 +465,91 @@ impl EntityKind { } } + /// Scale every height of the object by `f` about the height `base_z`: the Z of its points, + /// its elevation. With [`EntityKind::transform`] scaling about the base point's XY this is a + /// uniform 3D scale about the base point, as AutoCAD 2024's SCALE makes it. Sizes `transform` + /// already scales (text and helix heights, an insert's Z scale) are left alone; splines, + /// regions and wipeouts lie in the XY plane. + pub fn scale_heights(&mut self, base_z: f64, f: f64) { + let scale = |h: &mut f64| *h = base_z + (*h - base_z) * f; + let z = |p: &mut Vec3| scale(&mut p.z); + let text_z = |t: &mut Text| { + z(&mut t.insert); + if let Some(a) = &mut t.align_pt { + z(a); + } + }; + let attribs_z = |attribs: &mut Vec| { + for a in attribs { + text_z(&mut a.text); + if let Some(ml) = &mut a.multiline { + z(&mut ml.mtext.insert); + } + } + }; + match self { + EntityKind::Line(l) => { + z(&mut l.a); + z(&mut l.b); + } + EntityKind::Point(p) => z(&mut p.p), + EntityKind::Circle(c) => z(&mut c.center), + EntityKind::Arc(a) => z(&mut a.center), + EntityKind::Ellipse(e) => z(&mut e.center), + EntityKind::LwPolyline(p) => scale(&mut p.elevation), + EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(z), + EntityKind::Helix(h) => z(&mut h.center), + EntityKind::Ray(r) | EntityKind::XLine(r) => z(&mut r.base), + EntityKind::Text(t) => text_z(t), + EntityKind::AttDef(a) => { + text_z(&mut a.text); + if let Some(ml) = &mut a.multiline { + z(&mut ml.mtext.insert); + } + } + EntityKind::MText(t) => z(&mut t.insert), + EntityKind::Insert(i) => { + z(&mut i.insert); + attribs_z(&mut i.attribs); + } + EntityKind::Dimension(d) => { + for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] { + z(p); + } + if let Some(j) = &mut d.marks.jog_line { + z(j); + } + if let Some(j) = &mut d.marks.jogged { + z(&mut j.center); + z(&mut j.jog); + } + d.marks.breaks.iter_mut().for_each(|b| z(&mut b.at)); + d.block = None; + } + EntityKind::MLine(ml) => ml.vertices.iter_mut().for_each(|v| z(&mut v.p)), + EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z), + EntityKind::MLeader(ml) => { + ml.leaders.iter_mut().flatten().for_each(z); + z(&mut ml.landing); + if let Some(t) = &mut ml.text { + z(&mut t.insert); + } + if let Some(b) = &mut ml.block { + z(&mut b.insert); + attribs_z(&mut b.attribs); + } + } + EntityKind::Hatch(h) => scale(&mut h.elevation), + EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z), + EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z), + EntityKind::Viewport(v) => z(&mut v.center), + EntityKind::Image(i) => z(&mut i.insert), + EntityKind::Table(t) => z(&mut t.insert), + EntityKind::Tolerance(t) => z(&mut t.insert), + EntityKind::Spline(_) | EntityKind::Region(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {} + } + } + /// Grip points (editable defining points), in display order. pub fn grips(&self) -> Vec { // A broken table has a grip for each part. diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index bbcd25267..22126f900 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { CommandSpec::new("scale", "Scale", run_scale) .menu(&["Modify", "Scale"]) .alias(&["sc"]) - .params("{handles?, base: [x,y], factor, copy?: bool}") + .params("{handles?, base: [x,y] or [x,y,z] (default Z: ELEVATION), factor, copy?: bool}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Scale)))), CommandSpec::new("mirror", "Mirror", run_mirror) .menu(&["Modify", "Mirror"]) diff --git a/crates/engine/src/cmd/modify/offset.rs b/crates/engine/src/cmd/modify/offset.rs index 17cd15b09..74dc0fb79 100644 --- a/crates/engine/src/cmd/modify/offset.rs +++ b/crates/engine/src/cmd/modify/offset.rs @@ -212,12 +212,35 @@ pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handle": nh.hex() })) } +/// The copy at the source's height, as AutoCAD 2024's OFFSET makes it: a line's end Zs, an arc's +/// or circle's centre Z, a polyline's elevation (an ellipse's offset, a polyline, at its centre Z). +fn keep_z(src: &EntityKind, k: &mut EntityKind) { + match (src, k) { + (EntityKind::Line(s), EntityKind::Line(l)) => { + l.a.z = s.a.z; + l.b.z = s.b.z; + } + (EntityKind::Circle(s), EntityKind::Circle(c)) => c.center.z = s.center.z, + (EntityKind::Arc(s), EntityKind::Arc(a)) => a.center.z = s.center.z, + (EntityKind::LwPolyline(s), EntityKind::LwPolyline(p)) => p.elevation = s.elevation, + (EntityKind::Ellipse(s), EntityKind::LwPolyline(p)) => p.elevation = s.center.z, + (EntityKind::Ray(s) | EntityKind::XLine(s), EntityKind::Ray(r) | EntityKind::XLine(r)) => r.base.z = s.base.z, + _ => {} + } +} + /// Add the offset copy `k` of `src` (on the current layer instead of the source's when -/// `current_layer`), then erase the source when `erase` and its layer is not locked. -fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result { +/// `current_layer`), then erase the source when `erase` and its layer is not locked. The copy is +/// at the source's height ([`keep_z`]); a circle's or arc's copy has no thickness, a polyline's +/// keeps it, as AutoCAD 2024 makes them. +fn add_offset(s: &mut Session, src: &Entity, mut k: EntityKind, current_layer: bool, erase: bool) -> Result { + keep_z(&src.kind, &mut k); let space = s.space(); let d = s.doc_mut()?; let mut common = src.common.clone(); + if matches!(k, EntityKind::Circle(_) | EntityKind::Arc(_)) { + common.thickness = 0.0; + } if current_layer { common.layer = d.header.str("CLAYER", "0"); } diff --git a/crates/engine/src/cmd/modify/select_then.rs b/crates/engine/src/cmd/modify/select_then.rs index f9a970ebd..009a95281 100644 --- a/crates/engine/src/cmd/modify/select_then.rs +++ b/crates/engine/src/cmd/modify/select_then.rs @@ -1,6 +1,6 @@ use super::break_join::join; use super::explode::explode; -use super::transform::{erase, transform_entities}; +use super::transform::{at_elevation, erase, scale_entities, transform_entities}; use super::{ Accept, EngineError, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Vec2, line, number, }; @@ -242,7 +242,7 @@ impl SelectThen { let f = require_length(Some(f))?; // The next SCALE offers this factor. s.last_used.scale_factor = f; - transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; + scale_entities(s, &self.objs, at_elevation(s, base), f, self.copy_mode)?; s.set_selection(Vec::new()); Ok(Step::Done) } @@ -439,7 +439,7 @@ impl Interactive for SelectThen { }; if f > 1e-12 && f.is_finite() { s.last_used.scale_factor = f; - transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; + scale_entities(s, &self.objs, at_elevation(s, self.pts[0]), f, self.copy_mode)?; } s.set_selection(Vec::new()); Ok(Step::Done) diff --git a/crates/engine/src/cmd/modify/transform.rs b/crates/engine/src/cmd/modify/transform.rs index 18ff4866c..9adc54ec1 100644 --- a/crates/engine/src/cmd/modify/transform.rs +++ b/crates/engine/src/cmd/modify/transform.rs @@ -1,8 +1,33 @@ +use cadcraft_geom::Vec3; + use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets}; // ---------------- core operations ---------------- pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result> { + edit_entities(s, hs, copy, |e| e.kind.transform(m)) +} + +/// Scale `hs` by `f` about `base` in 3D, as AutoCAD 2024's SCALE does: about the base point's XY, +/// and every height of the objects about its Z, their thickness too. +pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64, copy: bool) -> Result> { + let m = Mat3::scale_about(base.xy(), f); + edit_entities(s, hs, copy, |e| { + e.kind.transform(&m); + e.kind.scale_heights(base.z, f); + e.common.thickness *= f; + }) +} + +/// `p` at the current elevation (ELEVATION), the Z AutoCAD gives a point entered without one. +pub(crate) fn at_elevation(s: &Session, p: Vec2) -> Vec3 { + let z = s.doc().map(|d| d.header.f64("ELEVATION", 0.0)).ok().filter(|z| z.is_finite()); + Vec3::new(p.x, p.y, z.unwrap_or(0.0)) +} + +/// Apply `edit` to each of `hs` still in the drawing, or to a copy of each when `copy`; an object +/// on a locked layer is left alone unless it is copied. The handles edited or made. +fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut Entity)) -> Result> { let d = s.doc_mut()?; let mut out = Vec::new(); for h in hs { @@ -11,7 +36,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; let nh = d.new_handle(); let mut ne = Entity { handle: nh, ..e }; - ne.kind.transform(m); + edit(&mut ne); if let Some(st) = d.space_mut(&sp) { st.push(ne); out.push(nh); @@ -21,7 +46,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: if locked { continue; } - d.modify_entity(*h, |e| e.kind.transform(m))?; + d.modify_entity(*h, &edit)?; out.push(*h); } } @@ -92,7 +117,12 @@ pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result { if f <= 0.0 { return Err(bad("scale", "factor must be positive")); } - let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?; + // A Z given with the base point, as AutoCAD takes one; without one, the current elevation. + let base = match p.get("base").and_then(|b| b.get(2)) { + Some(z) => z.as_f64().map(|z| Vec3::new(base.x, base.y, z)).ok_or_else(|| bad("scale", "`base` Z must be a number"))?, + None => at_elevation(s, base), + }; + let r = scale_entities(s, &hs, base, f, bool_or(p, "copy", false))?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index ee06666c5..137e423a7 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -113,6 +113,7 @@ mod refedit; mod region; mod rename_cmdline; mod rotate_reference; +mod scale_offset_z; mod select_keywords; mod select_similar; mod select_underscore_keywords; diff --git a/crates/engine/tests/it/scale_offset_z.rs b/crates/engine/tests/it/scale_offset_z.rs new file mode 100644 index 000000000..a32cea081 --- /dev/null +++ b/crates/engine/tests/it/scale_offset_z.rs @@ -0,0 +1,184 @@ +//! SCALE scales heights too and OFFSET keeps them, as AutoCAD 2024 does (measured with +//! accoreconsole, running these scripts on these drawings): SCALE scales every Z and elevation +//! about the base point's Z (the current elevation for a point given without one) and every +//! thickness by the factor; OFFSET's copy is at the source's height, and a circle's or arc's copy +//! has no thickness while a polyline's keeps it. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point}; +use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3}; +use serde_json::json; + +fn add(s: &mut Session, kind: EntityKind, thickness: f64) -> Handle { + let space = s.space(); + s.doc_mut().unwrap().add(&space, Common { thickness, ..Common::default() }, kind).unwrap() +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); + assert!(s.running.is_none(), "{expr}: {}", s.prompt_text()); +} + +fn get(s: &Session, h: Handle) -> (EntityKind, f64) { + let e = s.doc().unwrap().entity(h).unwrap().clone(); + (e.kind.clone(), e.common.thickness) +} + +fn last(s: &Session) -> (EntityKind, f64) { + let e = s.doc().unwrap().model.iter().last().unwrap().clone(); + (e.kind.clone(), e.common.thickness) +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * (1.0 + b.abs()) +} + +fn near3(a: Vec3, b: Vec3) -> bool { + close(a.x, b.x) && close(a.y, b.y) && close(a.z, b.z) +} + +fn scale(s: &mut Session, h: Handle, base: &str) { + lisp(s, &format!("(command \"_.SCALE\" (handent \"{}\") \"\" '({base}) 1.7)", h.hex())); +} + +#[test] +fn scale_scales_a_lines_z() { + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0); + scale(&mut s, h, "2.0 3.0"); + let EntityKind::Line(l) = get(&s, h).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(-1.4, -2.1, 11.9)) && near3(l.b, Vec3::new(66.6, -2.1, 11.9)), "{l:?}"); + // A sloped line at survey coordinates. + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(612345.678, 2398765.432, 3.0), b: Vec3::new(612375.678, 2398775.432, 8.0) }), 0.0); + scale(&mut s, h, "612347.678 2398768.432"); + let EntityKind::Line(l) = get(&s, h).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(612344.278, 2398763.332, 5.1)) && near3(l.b, Vec3::new(612395.278, 2398780.332, 13.6)), "{l:?}"); +} + +#[test] +fn scale_scales_the_centre_z_and_thickness_of_circles_and_arcs() { + let mut s = Session::new(); + let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.5, 0.25, 0.005), radius: 0.01 }), 0.002); + scale(&mut s, c, "0.502 0.253"); + let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() }; + assert!(near3(k.center, Vec3::new(0.4986, 0.2479, 0.0085)) && close(k.radius, 0.017) && close(t, 0.0034), "{k:?} {t}"); + let mut s = Session::new(); + let arc = Arc { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0, start: 30f64.to_radians(), end: 200f64.to_radians() }; + let a = add(&mut s, EntityKind::Arc(arc), 1.5); + scale(&mut s, a, "2.0 3.0"); + let (EntityKind::Arc(k), t) = get(&s, a) else { panic!() }; + assert!(near3(k.center, Vec3::new(-1.4, -2.1, 7.65)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}"); +} + +#[test] +fn scale_scales_a_polylines_elevation_and_thickness_and_a_points_z() { + let mut s = Session::new(); + let vs = [(612345.678, 2398765.432, 0.0), (612365.678, 2398765.432, 0.4), (612375.678, 2398780.432, 0.0), (612350.678, 2398785.432, 0.0)]; + let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect(); + let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0); + scale(&mut s, p, "612347.678 2398768.432"); + let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() }; + assert!(close(k.elevation, 21.25) && close(t, 1.7), "{} {t}", k.elevation); + let first = k.vertices.first().unwrap().p; + assert!(close(first.x, 612344.278) && close(first.y, 2398763.332), "{first:?}"); + let pt = add(&mut s, EntityKind::Point(Point { p: Vec3::new(5.0, 5.0, 6.0), angle: 0.0 }), 0.0); + scale(&mut s, pt, "2.0 3.0"); + let EntityKind::Point(k) = get(&s, pt).0 else { panic!() }; + assert!(near3(k.p, Vec3::new(7.1, 6.4, 10.2)), "{k:?}"); +} + +#[test] +fn scale_scales_heights_about_the_current_elevation() { + // ELEVATION 5: the base point, given without a Z, is at Z = 5. + let mut s = Session::new(); + s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0); + scale(&mut s, h, "2.0 3.0"); + let EntityKind::Line(l) = get(&s, h).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(-1.4, -2.1, 8.4)) && near3(l.b, Vec3::new(66.6, -2.1, 8.4)), "{l:?}"); + let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0 }), 1.5); + scale(&mut s, c, "2.0 3.0"); + let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() }; + assert!(near3(k.center, Vec3::new(-1.4, -2.1, 4.15)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}"); + let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0)]; + let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect(); + let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0); + scale(&mut s, p, "2.0 3.0"); + let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() }; + assert!(close(k.elevation, 17.75) && close(t, 1.7), "{} {t}", k.elevation); + let last = k.vertices.last().unwrap().p; + assert!(close(last.x, 49.6) && close(last.y, 23.4), "{last:?}"); +} + +#[test] +fn json_scale_takes_the_base_points_z() { + let line = || EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }); + let z = |s: &Session, h: Handle| { + let EntityKind::Line(l) = get(s, h).0 else { panic!() }; + (l.a.z, l.b.z) + }; + let mut s = Session::new(); + let h = add(&mut s, line(), 0.0); + s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, 1.0], "factor": 1.7 })).unwrap(); + let (a, b) = z(&s, h); + assert!(close(a, 11.2) && close(b, 11.2), "{a} {b}"); + // Without one, the current elevation (not measured through JSON: the same base point as the + // command line's). + let mut s = Session::new(); + s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0); + let h = add(&mut s, line(), 0.0); + s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0], "factor": 1.7 })).unwrap(); + let (a, b) = z(&s, h); + assert!(close(a, 8.4) && close(b, 8.4), "{a} {b}"); + // A Z that isn't a number is refused, the drawing left as it was. + assert!(s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, "1"], "factor": 1.7 })).is_err()); + assert_eq!(z(&s, h), (a, b)); +} + +#[test] +fn move_and_rotate_leave_heights_alone() { + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 2.0); + lisp(&mut s, &format!("(command \"_.ROTATE\" (handent \"{}\") \"\" '(0.0 0.0) 90)", h.hex())); + lisp(&mut s, &format!("(command \"_.MOVE\" (handent \"{}\") \"\" '(0.0 0.0) '(5.0 5.0))", h.hex())); + let (EntityKind::Line(l), t) = get(&s, h) else { panic!() }; + assert!(close(l.a.z, 7.0) && close(l.b.z, 7.0) && close(t, 2.0), "{l:?} {t}"); +} + +#[test] +fn offset_keeps_a_lines_z() { + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(20.0 0.0)) '(10.0 5.0) \"\")", h.hex())); + let EntityKind::Line(l) = last(&s).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(0.0, 3.0, 7.0)) && near3(l.b, Vec3::new(40.0, 3.0, 7.0)), "{l:?}"); +} + +#[test] +fn offset_keeps_the_centre_z_of_circles_and_arcs_but_not_their_thickness() { + let mut s = Session::new(); + let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(612345.678, 2398765.432, 5.0), radius: 10.0 }), 2.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(612355.678 2398765.432)) '(612345.678 2398780.432) \"\")", c.hex())); + let (EntityKind::Circle(k), t) = last(&s) else { panic!() }; + assert!(close(k.center.z, 5.0) && close(k.radius, 13.0) && t == 0.0, "{k:?} {t}"); + let mut s = Session::new(); + let arc = Arc { center: Vec3::new(-35000.0, 81000.0, 225.0), radius: 500.0, start: 30f64.to_radians(), end: 200f64.to_radians() }; + let a = add(&mut s, EntityKind::Arc(arc), 75.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 150.0 (list (handent \"{}\") '(-35000.0 81500.0)) '(-35000.0 81750.0) \"\")", a.hex())); + let (EntityKind::Arc(k), t) = last(&s) else { panic!() }; + assert!(close(k.center.z, 225.0) && close(k.radius, 650.0) && t == 0.0, "{k:?} {t}"); +} + +#[test] +fn offset_keeps_a_polylines_elevation_and_thickness() { + let mut s = Session::new(); + let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0), (5.0, 20.0, 0.0)]; + let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect(); + let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(10.0 0.0)) '(12.0 6.0) \"\")", p.hex())); + let (EntityKind::LwPolyline(k), t) = last(&s) else { panic!() }; + assert!(close(k.elevation, 12.5) && close(t, 1.0), "{} {t}", k.elevation); + let first = k.vertices.first().unwrap().p; + assert!(close(first.x, 0.0) && close(first.y, 3.0), "{first:?}"); +} From a626b7128466074ce60989d24e948b0809967179 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:32:53 +0700 Subject: [PATCH 066/151] fix(draw): RECTANG and POLYGON vertices in AutoCAD's order; RECTANG remembers its modes (#2554) RECTANG's polyline now starts at the first corner and runs along X to the other corner, as AutoCAD 2024 draws it; each chamfer cuts the edge into its corner by the first distance and the edge out by the second, and fillet arcs start on the edge into their corner (clockwise outlines get negative bulges). RECTANG keeps its chamfer, fillet, width, elevation, thickness and rotation for the next rectangle, lists them as "Current rectangle modes:" and offers the rotation as "<50.50>"; chamfer and fillet clear each other. A circumscribed POLYGON starts at the end of the picked edge counter-clockwise from its midpoint. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw2/rect.rs | 78 ++++++- crates/engine/src/cmd/draw2/tests.rs | 3 +- crates/engine/src/cmd/helpers.rs | 62 +++-- crates/engine/src/last_used.rs | 17 ++ crates/engine/tests/it/main.rs | 1 + .../engine/tests/it/rectang_polygon_order.rs | 215 ++++++++++++++++++ 6 files changed, 335 insertions(+), 41 deletions(-) create mode 100644 crates/engine/tests/it/rectang_polygon_order.rs diff --git a/crates/engine/src/cmd/draw2/rect.rs b/crates/engine/src/cmd/draw2/rect.rs index 4b19dc703..245ef24f8 100644 --- a/crates/engine/src/cmd/draw2/rect.rs +++ b/crates/engine/src/cmd/draw2/rect.rs @@ -2,6 +2,7 @@ use super::{ Accept, EntityKind, Input, Interactive, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, added, bad, bool_or, curves, f64_or, lwpoly, number, other, point_param, point_req, rect_with_corners, size_param, }; +use crate::last_used::RectangModes; // ===================================================================================== // RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness @@ -115,11 +116,58 @@ pub(crate) struct RectM2 { asking: Option, } +/// A rectangle's rotation as AutoCAD 2024 shows it: degrees from 0 to 360, two decimals. +fn rotation_text(rotation: f64) -> String { + let t = format!("{:.2}", rotation.to_degrees().rem_euclid(360.0)); + if t == "360.00" { "0.00".into() } else { t } +} + +/// AutoCAD 2024's line listing the RECTANG modes that are set, `None` when none is ("Current +/// rectangle modes: Chamfer=2.2518 x 3.7663 Rotation=50.50"): distances in the drawing's +/// units, the rotation as [`rotation_text`] shows it. +fn modes_line(s: &Session, m: &RectangModes) -> Option { + let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4)); + let d = |v: f64| crate::units::format_distance(v, u, p); + let mut parts = Vec::new(); + if m.chamfer.0 > 0.0 || m.chamfer.1 > 0.0 { + parts.push(format!("Chamfer={} x {}", d(m.chamfer.0), d(m.chamfer.1))); + } + if m.fillet > 0.0 { + parts.push(format!("Fillet={}", d(m.fillet))); + } + if m.elevation != 0.0 { + parts.push(format!("Elevation={}", d(m.elevation))); + } + if m.thickness != 0.0 { + parts.push(format!("Thickness={}", d(m.thickness))); + } + if m.width > 0.0 { + parts.push(format!("Width={}", d(m.width))); + } + let rot = rotation_text(m.rotation); + if rot != "0.00" { + parts.push(format!("Rotation={rot}")); + } + (!parts.is_empty()).then(|| format!("Current rectangle modes: {}", parts.join(" "))) +} + impl RectM2 { + /// The modes the next rectangle starts from. + fn modes(&self) -> RectangModes { + RectangModes { + chamfer: self.chamfer, + fillet: self.fillet, + width: self.width, + elevation: self.elevation, + thickness: self.thickness, + rotation: self.rotation, + } + } /// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from /// the centre when centre mode is on, then applies the requested thickness. fn make(&self, s: &mut Session, w: f64, h: f64) -> Result { let Some(a) = self.first else { return Ok(Step::Cancel) }; + s.last_used.rectang = self.modes(); if w.abs() < 1e-12 || h.abs() < 1e-12 { return Err(other("The rectangle has no area.")); } @@ -155,6 +203,20 @@ impl Interactive for RectM2 { fn name(&self) -> &'static str { "RECTANG" } + /// Starts from the modes the last rectangle left, and lists the ones that are set. + fn begin(&mut self, s: &mut Session) -> Result { + let m = s.last_used.rectang; + self.chamfer = m.chamfer; + self.fillet = m.fillet; + self.width = m.width; + self.elevation = m.elevation; + self.thickness = m.thickness; + self.rotation = m.rotation; + if let Some(line) = modes_line(s, &m) { + s.echo(line); + } + Ok(Step::Continue) + } /// Asks for the pending option's value, or for the first and second points; in centre mode /// the first prompt asks for the centre instead of a corner. fn prompt(&self, _s: &Session) -> Prompt { @@ -173,9 +235,9 @@ impl Interactive for RectM2 { RAsk::AreaWidth => num("Enter rectangle width", 10.0), RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)), RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)), - RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER) + RAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER) .kw(&["Pick points"]) - .default(format!("{:.0}", self.rotation.to_degrees())) + .default(rotation_text(self.rotation)) .base_opt(self.first), RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT), RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b), @@ -207,8 +269,14 @@ impl Interactive for RectM2 { self.chamfer.1 = self.chamfer.0; self.asking = Some(RAsk::Chamfer2); } - RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0), - RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0), + RAsk::Chamfer2 => { + self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0); + self.fillet = 0.0; + } + RAsk::Fillet => { + self.fillet = val(&i, self.fillet)?.max(0.0); + self.chamfer = (0.0, 0.0); + } RAsk::Width => self.width = val(&i, self.width)?.max(0.0), RAsk::Elevation => self.elevation = val(&i, self.elevation)?, RAsk::Thickness => self.thickness = val(&i, self.thickness)?, @@ -260,6 +328,8 @@ impl Interactive for RectM2 { } } } + // A mode is kept as soon as it is set, even if no rectangle follows. + s.last_used.rectang = self.modes(); return Ok(Step::Continue); } match i { diff --git a/crates/engine/src/cmd/draw2/tests.rs b/crates/engine/src/cmd/draw2/tests.rs index 8eedd3772..efad025ca 100644 --- a/crates/engine/src/cmd/draw2/tests.rs +++ b/crates/engine/src/cmd/draw2/tests.rs @@ -996,7 +996,8 @@ fn rectang_options_command_line() { s.cmdline("rectang 0,0 r p 0,0 1,1 d 2 2 5,5").unwrap(); let pts = rect_pts(&s); assert!(pts.iter().any(|p| near(*p, 0.0, 8f64.sqrt())), "{pts:?}"); - s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 4,4").unwrap(); + // RECTANG keeps the rotation of the rectangle before (45 degrees), as AutoCAD does: back to 0. + s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 r 0 4,4").unwrap(); match last(&s) { EntityKind::LwPolyline(p) => assert!(p.const_width == 0.5 && p.vertices.len() == 8), k => panic!("{k:?}"), diff --git a/crates/engine/src/cmd/helpers.rs b/crates/engine/src/cmd/helpers.rs index 54c5ffdaf..ed5891203 100644 --- a/crates/engine/src/cmd/helpers.rs +++ b/crates/engine/src/cmd/helpers.rs @@ -34,7 +34,9 @@ pub fn polygon_vertices(center: Vec2, sides: usize, radius_pt: Vec2, inscribed: let r = d.len(); let a0 = d.angle(); let step = cadcraft_geom::TAU / n as f64; - let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 - step / 2.0) }; + // Circumscribed: the picked point is an edge's midpoint and the first vertex is the end of + // that edge counter-clockwise from it, as AutoCAD 2024 numbers them. + let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 + step / 2.0) }; (0..n).map(|i| PolyVertex::new(Vec2::polar(center, r, a0 + step * i as f64))).collect() } @@ -62,43 +64,31 @@ pub fn polygon_from_edge(p1: Vec2, p2: Vec2, sides: usize) -> Vec { pts.into_iter().map(PolyVertex::new).collect() } -/// Rectangle vertices with fillet radius or chamfer distances at every corner. +/// Rectangle vertices from corner `a` to the opposite corner `b`, with a fillet radius or +/// chamfer distances at every corner, in AutoCAD 2024's order: from `a` along X to `b`'s X, then +/// `b`, then back along Y. A chamfer cuts the edge into each corner by its first distance and the +/// edge out of it by its second; each fillet arc starts on the edge into its corner. A fillet or +/// chamfer that doesn't fit leaves the corners square. pub fn rect_with_corners(a: Vec2, b: Vec2, fillet: f64, chamfer: (f64, f64)) -> Vec { - let (x0, x1) = (a.x.min(b.x), a.x.max(b.x)); - let (y0, y1) = (a.y.min(b.y), a.y.max(b.y)); - let w = x1 - x0; - let h = y1 - y0; - if fillet > 0.0 && fillet * 2.0 <= w.min(h) + 1e-12 { - let r = fillet; - let bul = cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2); - return vec![ - PolyVertex::new(Vec2::new(x0 + r, y0)), - PolyVertex::with_bulge(Vec2::new(x1 - r, y0), bul), - PolyVertex::new(Vec2::new(x1, y0 + r)), - PolyVertex::with_bulge(Vec2::new(x1, y1 - r), bul), - PolyVertex::new(Vec2::new(x1 - r, y1)), - PolyVertex::with_bulge(Vec2::new(x0 + r, y1), bul), - PolyVertex::new(Vec2::new(x0, y1 - r)), - PolyVertex::with_bulge(Vec2::new(x0, y0 + r), bul), - ]; + let corners = [a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]; + let short = (b.x - a.x).abs().min((b.y - a.y).abs()); + let (d_in, d_out, bulge) = if fillet > 0.0 && fillet * 2.0 <= short + 1e-12 { + // Counter-clockwise round the corners (a positive bulge) when `b` is up and right of `a`, or + // down and left. + let sign = if (b.x - a.x) * (b.y - a.y) >= 0.0 { 1.0 } else { -1.0 }; + (fillet, fillet, sign * cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2)) + } else if chamfer.0 > 0.0 && chamfer.1 > 0.0 && chamfer.0 + chamfer.1 <= short + 1e-12 { + (chamfer.0, chamfer.1, 0.0) + } else { + return corners.into_iter().map(PolyVertex::new).collect(); + }; + let mut out = Vec::with_capacity(8); + for (i, cur) in corners.iter().enumerate() { + let (Some(prev), Some(next)) = (corners.get((i + 3) % 4), corners.get((i + 1) % 4)) else { continue }; + out.push(PolyVertex::with_bulge(*cur + (*prev - *cur).normalized() * d_in, bulge)); + out.push(PolyVertex::new(*cur + (*next - *cur).normalized() * d_out)); } - let (c1, c2) = chamfer; - if c1 > 0.0 && c2 > 0.0 && c1 + c1 <= w + 1e-12 && c2 + c2 <= h + 1e-12 { - return [ - Vec2::new(x0 + c1, y0), - Vec2::new(x1 - c1, y0), - Vec2::new(x1, y0 + c2), - Vec2::new(x1, y1 - c2), - Vec2::new(x1 - c1, y1), - Vec2::new(x0 + c1, y1), - Vec2::new(x0, y1 - c2), - Vec2::new(x0, y0 + c2), - ] - .into_iter() - .map(PolyVertex::new) - .collect(); - } - rect_vertices(Vec2::new(x0, y0), Vec2::new(x1, y1)) + out } // ---------- the user's support files ---------- diff --git a/crates/engine/src/last_used.rs b/crates/engine/src/last_used.rs index 73c610329..88a1d4f59 100644 --- a/crates/engine/src/last_used.rs +++ b/crates/engine/src/last_used.rs @@ -25,6 +25,8 @@ pub struct LastUsed { pub mleader_block: String, /// BREAKLINE's Block, Size and Extension. pub breakline: cmd::BreaklineOptions, + /// RECTANG's chamfer, fillet, width, elevation, thickness and rotation. + pub rectang: RectangModes, } impl Default for LastUsed { @@ -39,10 +41,25 @@ impl Default for LastUsed { mleader: MLeaderOptions::default(), mleader_block: String::new(), breakline: cmd::BreaklineOptions::default(), + rectang: RectangModes::default(), } } } +/// The modes RECTANG keeps from one rectangle to the next, as AutoCAD 2024 does. Chamfer and +/// fillet exclude each other: setting one clears the other. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RectangModes { + /// First and second chamfer distances. + pub chamfer: (f64, f64), + pub fillet: f64, + pub width: f64, + pub elevation: f64, + pub thickness: f64, + /// Radians. + pub rotation: f64, +} + /// Which part of a multileader MLEADER places first. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum LeaderOrder { diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 137e423a7..e36e30b35 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -109,6 +109,7 @@ mod query_entities; mod quickcalc; mod recent_commands; mod rectang_ortho; +mod rectang_polygon_order; mod refedit; mod region; mod rename_cmdline; diff --git a/crates/engine/tests/it/rectang_polygon_order.rs b/crates/engine/tests/it/rectang_polygon_order.rs new file mode 100644 index 000000000..e58f0a6e9 --- /dev/null +++ b/crates/engine/tests/it/rectang_polygon_order.rs @@ -0,0 +1,215 @@ +//! RECTANG and POLYGON vertices in AutoCAD 2024's order, and the RECTANG modes it remembers from +//! one rectangle to the next. The scripts and numbers were measured with accoreconsole; the +//! RECTANG scripts reset Chamfer, Fillet, Width, Elevation and Thickness first, but not Rotation. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +type Vertex = (f64, f64, f64); + +const RESET: &str = "\"_C\" 0 0 \"_F\" 0 \"_W\" 0 \"_E\" 0 \"_T\" 0"; + +/// The last polyline's vertices (x, y, bulge). +fn last_polyline(s: &Session) -> Vec { + let d = s.doc().unwrap(); + match &d.model.iter().last().unwrap().kind { + EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| (v.p.x, v.p.y, v.bulge)).collect(), + k => panic!("not a polyline: {k:?}"), + } +} + +fn expect(got: &[Vertex], want: &[Vertex]) { + assert_eq!(got.len(), want.len(), "{got:?}"); + for (g, w) in got.iter().zip(want) { + let tol = 1e-9 * (1.0 + w.0.abs().max(w.1.abs())); + assert!((g.0 - w.0).abs() < tol && (g.1 - w.1).abs() < tol && (g.2 - w.2).abs() < 1e-9, "{got:?}\n!= {want:?}"); + } +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); + assert!(s.running.is_none(), "{expr}: {}", s.prompt_text()); +} + +fn rectang(s: &mut Session, rest: &str) { + lisp(s, &format!("(command \"_.RECTANG\" {RESET} {rest})")); +} + +#[test] +fn a_rectangle_starts_at_its_first_corner_and_runs_along_x_first() { + let mut s = Session::new(); + rectang(&mut s, "'(-2.2557479240466947 -9.488221049171962) '(-31.36203661716106 -47.886714708394166)"); + expect( + &last_polyline(&s), + &[ + (-2.255747924046694, -9.488221049171962, 0.0), + (-31.36203661716105, -9.488221049171962, 0.0), + (-31.36203661716105, -47.88671470839417, 0.0), + (-2.255747924046694, -47.88671470839417, 0.0), + ], + ); +} + +#[test] +fn chamfers_cut_the_edge_into_each_corner_by_the_first_distance() { + let mut s = Session::new(); + rectang(&mut s, "\"_C\" 2.2518104656366162 3.766337568886034 '(4.8089419289768 29.66648885915597) '(28.114797694689763 43.85884965263156)"); + expect( + &last_polyline(&s), + &[ + (4.8089419289768, 31.91829932479258, 0.0), + (8.575279497862834, 29.66648885915597, 0.0), + (25.86298722905314, 29.66648885915597, 0.0), + (28.11479769468976, 33.432826428042, 0.0), + (28.11479769468976, 41.60703918699494, 0.0), + (24.34846012580373, 43.85884965263156, 0.0), + (7.060752394613416, 43.85884965263156, 0.0), + (4.8089419289768, 40.09251208374553, 0.0), + ], + ); +} + +#[test] +fn fillet_arcs_start_on_the_edge_into_each_corner() { + let mut s = Session::new(); + rectang(&mut s, "\"_F\" 0.6042729201106594 '(-13.457476758444333 -29.884431038180395) '(14.673991980287038 27.14695046607133)"); + let b = 0.414213562373095; + expect( + &last_polyline(&s), + &[ + (-13.45747675844433, -29.28015811806973, b), + (-12.85320383833367, -29.88443103818039, 0.0), + (14.06971906017638, -29.88443103818039, b), + (14.67399198028703, -29.28015811806973, 0.0), + (14.67399198028703, 26.54267754596067, b), + (14.06971906017638, 27.14695046607133, 0.0), + (-12.85320383833367, 27.14695046607133, b), + (-13.45747675844433, 26.54267754596067, 0.0), + ], + ); +} + +#[test] +fn fillet_arcs_turn_clockwise_when_the_second_corner_is_below_the_first() { + // The second corner down and right: the outline runs clockwise and the bulges are negative + // (AutoCAD gave -0.414213562373095 for such a rectangle, there also rotated). + let mut s = Session::new(); + rectang(&mut s, "\"_F\" 1.0 '(0.0 0.0) '(10.0 -5.0)"); + let b = -0.414213562373095; + expect( + &last_polyline(&s), + &[(0.0, -1.0, b), (1.0, 0.0, 0.0), (9.0, 0.0, b), (10.0, -1.0, 0.0), (10.0, -4.0, b), (9.0, -5.0, 0.0), (1.0, -5.0, b), (0.0, -4.0, 0.0)], + ); +} + +#[test] +fn the_next_rectangle_keeps_the_rotation_and_lists_the_modes() { + let mut s = Session::new(); + rectang(&mut s, "'(27.647422687394844 28.925709913163516) \"_R\" 50.50299224048081 '(46.86449612780332 4.979921576806525)"); + expect( + &last_polyline(&s), + &[ + (27.64742268739484, 28.92570991316351, 0.0), + (23.66889931823719, 24.09886156025833, 0.0), + (46.86449612780331, 4.979921576806525, 0.0), + (50.84301949696097, 9.80676992971171, 0.0), + ], + ); + // No Rotation option this time: the rectangle still turns by 50.50 degrees. + rectang(&mut s, "'(14.3947813348612 23.649728466870016) \"_D\" 19.35761675393126 49.775239210761946 '(-4.9628354190700605 -26.12551074389193)"); + assert!(s.log.iter().any(|l| l == "Current rectangle modes: Rotation=50.50"), "{:?}", s.log); + // The next Rotation prompt offers it as its default, as AutoCAD shows it. + s.cmdline("RECTANG 0,0 R").unwrap(); + assert!(s.prompt_text().ends_with("Specify rotation angle or [Pick points] <50.50>:"), "{}", s.prompt_text()); + s.cancel(); + expect( + &last_polyline(&s), + &[ + (14.3947813348612, 23.64972846687001, 0.0), + (2.082603004810238, 8.712272486646885, 0.0), + (40.4920546468795, -22.94666721518322, 0.0), + (52.80423297693047, -8.009211234960095, 0.0), + ], + ); +} + +#[test] +fn elevation_thickness_and_a_picked_rotation_carry_over_together() { + let mut s = Session::new(); + rectang( + &mut s, + "\"_E\" 7.5 \"_T\" 2.25 '(-18.42481827666694 -20.50343704206707) \"_R\" \"_P\" '(0.0 0.0) '(10.0 6.0) '(10.197290578707722 25.5222006119985)", + ); + expect( + &last_polyline(&s), + &[ + (-18.42481827666694, -20.50343704206706, 0.0), + (22.92627837613747, 4.307220949615576, 0.0), + (10.19729057870772, 25.5222006119985, 0.0), + (-31.15380607409669, 0.7115426203158534, 0.0), + ], + ); + let d = s.doc().unwrap(); + let e = d.model.iter().last().unwrap(); + let EntityKind::LwPolyline(p) = &e.kind else { panic!("not a polyline") }; + assert_eq!((p.elevation, e.common.thickness), (7.5, 2.25)); + s.cmdline("RECTANG").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Elevation=7.5000 Thickness=2.2500 Rotation=30.96")); + s.cancel(); +} + +#[test] +fn chamfer_and_fillet_are_remembered_one_at_a_time() { + let mut s = Session::new(); + rectang(&mut s, "\"_C\" 2.2518104656366162 3.766337568886034 '(0.0 0.0) '(40.0 30.0)"); + rectang(&mut s, "\"_F\" 0.6042729201106594 '(0.0 0.0) '(40.0 30.0)"); + let logs: Vec<&String> = s.log.iter().filter(|l| l.starts_with("Current rectangle modes:")).collect(); + assert_eq!(logs, ["Current rectangle modes: Chamfer=2.2518 x 3.7663"]); + s.cmdline("RECTANG").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Fillet=0.6043")); + s.cancel(); +} + +#[test] +fn setting_a_fillet_clears_the_chamfer_and_back() { + // A chamfer set after a fillet makes a chamfered rectangle, and the other way round. + let mut s = Session::new(); + s.script("RECTANG F 1 C 2 3 0,0 40,30\n").unwrap(); + assert!(last_polyline(&s).iter().all(|v| v.2 == 0.0), "{:?}", last_polyline(&s)); + assert_eq!(last_polyline(&s).first().map(|v| (v.0, v.1)), Some((0.0, 2.0))); + s.script("RECTANG C 2 3 F 1 0,0 40,30\n").unwrap(); + assert!(s.log.iter().any(|l| l == "Current rectangle modes: Chamfer=2.0000 x 3.0000"), "{:?}", s.log); + expect(&last_polyline(&s)[..1], &[(0.0, 1.0, 0.414213562373095)]); + s.cmdline("RECTANG").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Fillet=1.0000")); + s.cancel(); +} + +#[test] +fn the_json_form_ignores_the_remembered_modes() { + let mut s = Session::new(); + rectang(&mut s, "'(0.0 0.0) \"_R\" 30 '(10.0 10.0)"); + s.execute("rectang", &json!({ "p1": [100, 0], "p2": [110, 10] })).unwrap(); + expect(&last_polyline(&s), &[(100.0, 0.0, 0.0), (110.0, 0.0, 0.0), (110.0, 10.0, 0.0), (100.0, 10.0, 0.0)]); +} + +#[test] +fn a_circumscribed_polygon_starts_counter_clockwise_from_the_picked_edge() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.POLYGON\" 4 '(-13.660465775726184 6.051830973104977) \"_C\" '(-33.469717497592136 8.913096552751803))"); + expect( + &last_polyline(&s), + &[ + (-36.33098307723896, -10.89615516911414, 0.0), + (3.287520366492948, -16.61868632840779, 0.0), + (9.01005152578659, 22.9998171153241, 0.0), + (-30.60845191794531, 28.72234827461775, 0.0), + ], + ); + lisp(&mut s, "(command \"_.POLYGON\" 3 '(-4.866064513702604 -0.5675999154110016) \"_C\" '(-33.004110114447 14.119144448101377))"); + expect( + &last_polyline(&s), + &[(-58.44229754982628, -34.61738015807783, 0.0), (51.41002668778618, -29.94108864243579, 0.0), (-7.565922679067647, 62.85566905428061, 0.0)], + ); +} From ac886483d194b182c19a8c5455ddaa549bc576e9 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:32:56 +0700 Subject: [PATCH 067/151] -INSERT: ATTREQ, Preset and Verify attributes as AutoCAD asks them (#2535) * -INSERT: ATTREQ, Preset and Verify attributes as AutoCAD asks them ATTREQ 0 inserts blocks with their attributes' default values without asking (a session setting, as AutoCAD keeps it in the registry). Preset attributes are never asked for, "Enter attribute values" heads the questions, and Verify attributes are asked again after "Verify attribute values", each with the value typed. Co-Authored-By: Claude Opus 5.5 * -INSERT: a click doesn't answer a Verify attribute Only typed text or Enter answers "Verify attribute values"; anything else asks again (CodeRabbit). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 53 ++++++++++-- crates/engine/src/settings.rs | 4 + crates/engine/src/sysvars.rs | 1 + crates/engine/src/sysvars/table.rs | 7 +- .../tests/it/insert_attribute_prompts.rs | 82 +++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 6 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/it/insert_attribute_prompts.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 760b6921c..19543141e 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -603,6 +603,8 @@ enum InsStage { ZScale, Rotation, Attribs, + /// A Verify attribute asked again. + Verify, } #[derive(Default)] @@ -623,8 +625,11 @@ struct InsertM { /// Where the block was dropped to pick a Basepoint on it. dropped: Option, rotation: Option, - attdefs: Vec<(String, String, String)>, + /// The attributes asked for: tag, prompt, default value, and Verify (asked again). + attdefs: Vec<(String, String, String, bool)>, values: serde_json::Map, + /// Verify attributes asked again so far. + verified: usize, /// `*name`: insert the block's objects (one uniform scale factor, no attribute values). explode: bool, /// The file waiting for the Redefine answer. @@ -641,6 +646,17 @@ impl InsertM { } else { InsStage::Attribs }; + if self.stage == InsStage::Attribs && self.values.is_empty() && !self.attdefs.is_empty() { + s.echo("Enter attribute values"); + } + // Then the Verify attributes are asked again, with the values typed. + if self.stage == InsStage::Attribs && self.values.len() >= self.attdefs.len() && self.verified < self.verifying().len() { + if self.verified == 0 { + s.echo("Verify attribute values"); + } + self.stage = InsStage::Verify; + return Ok(Step::Continue); + } if self.stage == InsStage::Attribs && self.values.len() >= self.attdefs.len() && let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation) @@ -654,20 +670,26 @@ impl InsertM { Ok(Step::Continue) } - /// Block `name` chosen: collect its attribute prompts and ask for the insertion point. + /// The Verify attributes asked for. + fn verifying(&self) -> Vec<&(String, String, String, bool)> { + self.attdefs.iter().filter(|a| a.3).collect() + } + + /// Block `name` chosen: collect its attribute prompts and ask for the insertion point. Constant + /// and Preset attributes take their values without asking, and so do all of them with ATTREQ 0. fn choose(&mut self, s: &mut Session, name: &str) -> Result<()> { let Some(b) = s.doc()?.block(name).cloned() else { s.echo(format!("Block \"{name}\" not found.")); return Ok(()); }; - self.attdefs = if self.explode { + self.attdefs = if self.explode || !s.settings.attreq { Vec::new() } else { b.entities .iter() .filter_map(|e| { if let EntityKind::AttDef(a) = &e.kind { - (!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone())) + (!a.constant && !a.modes.preset).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone(), a.modes.verify)) } else { None } @@ -752,9 +774,14 @@ impl Interactive for InsertM { InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"), InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at), InsStage::Attribs => { - let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); + let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default) } + InsStage::Verify => { + let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default(); + let typed = self.values.get(&tag).and_then(Value::as_str).unwrap_or_default().to_string(); + Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(typed) + } } } fn input(&mut self, s: &mut Session, i: Input) -> Result { @@ -913,7 +940,7 @@ impl Interactive for InsertM { return self.advance(s); } InsStage::Attribs => { - let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); + let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); let v = match i { Input::Text(t) => t, _ => default, @@ -921,6 +948,20 @@ impl Interactive for InsertM { self.values.insert(tag, Value::String(v)); return self.advance(s); } + // Enter keeps the value typed. + InsStage::Verify => { + let tag = self.verifying().get(self.verified).map(|a| a.0.clone()).unwrap_or_default(); + match i { + Input::Text(t) => { + self.values.insert(tag, Value::String(t)); + } + Input::Enter => {} + // A click or anything else is no answer: the value is asked for again. + _ => return Ok(Step::Continue), + } + self.verified += 1; + return self.advance(s); + } } Ok(Step::Continue) } diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 32507162f..736cec91c 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -127,6 +127,9 @@ pub struct Settings { pub polardist: f64, /// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0). pub auditctl: bool, + /// ATTREQ: 0 inserts blocks with their attributes' default values instead of asking for them + /// (default: 1). + pub attreq: bool, } impl Default for Settings { @@ -191,6 +194,7 @@ impl Default for Settings { polarsnap: false, polardist: 0.0, auditctl: false, + attreq: true, } } } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 930a9bad1..3fe47c052 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -8,6 +8,7 @@ mod table; const SESSION_VARS: &[&str] = &[ "AUDITCTL", + "ATTREQ", "OSMODE", "OSNAPHATCH", "ANNOAUTOSCALE", diff --git a/crates/engine/src/sysvars/table.rs b/crates/engine/src/sysvars/table.rs index fa319d3f1..910068f5c 100644 --- a/crates/engine/src/sysvars/table.rs +++ b/crates/engine/src/sysvars/table.rs @@ -81,8 +81,11 @@ const LINEWEIGHTS: [i64; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, /// On/off session settings: name, read, write. type Toggle = (&'static str, fn(&Settings) -> bool, fn(&mut Settings, bool)); -const SETTINGS: &[Toggle] = - &[("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v), ("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v)]; +const SETTINGS: &[Toggle] = &[ + ("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v), + ("ATTREQ", |s| s.attreq, |s, v| s.attreq = v), + ("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v), +]; fn header_var(name: &str) -> Option<&'static (&'static str, Kind, Def)> { HEADER.iter().find(|(n, _, _)| *n == name) diff --git a/crates/engine/tests/it/insert_attribute_prompts.rs b/crates/engine/tests/it/insert_attribute_prompts.rs new file mode 100644 index 000000000..97df80386 --- /dev/null +++ b/crates/engine/tests/it/insert_attribute_prompts.rs @@ -0,0 +1,82 @@ +//! INSERT's attribute questions as AutoCAD 2024 asks them from AutoLISP's `command` (accoreconsole, +//! the attributes read back with `entget`). + +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{Input, Session}; + +/// A session with block K: a circle and attributes (tag, DXF flags, default value), each with the +/// prompt "P" + tag. +fn with_block(defs: &[(&str, i32, &str)]) -> Session { + // `entmake` goes through the DXF codec, as in the app. + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + let mut s = Session::new(); + let mut code = + String::from("(entmake '((0 . \"BLOCK\") (2 . \"K\") (70 . 2) (10 0.0 0.0 0.0))) (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 3.0)))"); + for (i, (tag, flags, default)) in defs.iter().enumerate() { + let y = 5.0 + 3.0 * i as f64; + code += &format!( + " (entmake '((0 . \"ATTDEF\") (10 0.0 {y:.1} 0.0) (40 . 2.5) (1 . \"{default}\") (3 . \"P{tag}\") (2 . \"{tag}\") (70 . {flags})))" + ); + } + code += " (entmake '((0 . \"ENDBLK\")))"; + s.cmdline(&format!("(progn {code})")).unwrap(); + s.log.clear(); + s +} + +/// The attribute values of the last block reference, by tag. +fn values(s: &Session) -> Vec<(String, String)> { + let d = s.doc().unwrap(); + let Some(EntityKind::Insert(i)) = d.model.iter().last().map(|e| &e.kind) else { panic!("no block reference") }; + i.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect() +} + +fn said(s: &Session, line: &str) -> usize { + s.log.iter().filter(|l| l.as_str() == line).count() +} + +fn pair(tag: &str, value: &str) -> (String, String) { + (tag.to_string(), value.to_string()) +} + +#[test] +fn attreq_0_inserts_the_default_values_without_asking() { + let mut s = with_block(&[("T1", 0, "V1"), ("T2", 0, "V2")]); + s.cmdline("(progn (setvar \"ATTREQ\" 0) (command \"_.-INSERT\" \"K\" '(100.0 0.0) \"\" \"\" 0))").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(values(&s), [pair("T1", "V1"), pair("T2", "V2")]); + assert_eq!(said(&s, "Enter attribute values"), 0, "{:?}", s.log); + s.cmdline("(setvar \"ATTREQ\" 1)").unwrap(); + s.cmdline("(princ (getvar \"ATTREQ\"))").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("1"), "{:?}", s.log); +} + +#[test] +fn a_preset_attribute_is_not_asked() { + let mut s = with_block(&[("T1", 8, "V1"), ("T2", 0, "V2")]); + s.cmdline("(command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"X\")").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(values(&s), [pair("T1", "V1"), pair("T2", "X")]); + assert_eq!(said(&s, "Enter attribute values"), 1, "{:?}", s.log); +} + +#[test] +fn a_verify_attribute_is_asked_again_with_the_value_typed() { + let mut s = with_block(&[("T1", 4, "V1")]); + s.cmdline("(command \"_.-INSERT\" \"K\" '(300.0 0.0) \"\" \"\" 0 \"X\")").unwrap(); + assert_eq!(said(&s, "Verify attribute values"), 1, "{:?}", s.log); + assert!(s.prompt_text().contains("PT1 "), "{}", s.prompt_text()); + // A click is no answer. + s.input(Input::Point(Vec2::new(1.0, 1.0))).unwrap(); + assert!(s.prompt_text().contains("PT1 "), "{}", s.prompt_text()); + // Typed at the prompt, the whole line is the value. + s.cmdline("Y").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(values(&s), [pair("T1", "Y")]); + // Enter keeps the value typed. + let mut s = with_block(&[("T1", 4, "V1")]); + s.cmdline("(command \"_.-INSERT\" \"K\" '(300.0 0.0) \"\" \"\" 0 \"X\" \"\")").unwrap(); + assert_eq!(values(&s), [pair("T1", "X")]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index e36e30b35..2e60c9df1 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -58,6 +58,7 @@ mod hatch_options; mod helix; mod inquiry_keywords; mod inquiry_z; +mod insert_attribute_prompts; mod insert_prompts; mod isometric; mod join_close; From 87f8ce22f6307b30440037c071daeb7fc1e09eb0 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:33:00 +0700 Subject: [PATCH 068/151] CHANGE: change objects by a change point or their properties, as AutoCAD does (#2534) * Add CHANGE, changing objects by a change point or their properties as AutoCAD does A change point moves each line's nearer end to it (level or plumb under ORTHOMODE), passes a circle through it, and places a text, attribute definition or block reference by it when that is the one object selected; otherwise "Change point ignored" and the object's questions follow, in selection order. Properties is CHPROP's loop with Elev, applied as the command ends. Objects on locked layers are left out, through selection helpers now shared with CHPROP. Co-Authored-By: Claude Opus 5.5 * CHANGE: refuse a JSON call without a readable point or elevation A `point` or `elevation` that can't be read is an error, and so is a call with neither, instead of changing nothing. Co-Authored-By: Claude Opus 5.5 * CHANGE: Elev on every kind AutoCAD sets it for; Aligned and Fit text; rotation default Measured in AutoCAD 2024: Elev also sets the z of ellipses, multiline texts, solids and flat 3D polylines (a 3D polyline between heights keeps them), and of hatches, which stop being associative ("Hatch boundary associativity removed."). Aligned text isn't asked its height or rotation and Fit text its rotation, their two points setting them. The rotation default shows as the drawing measures angles (ANGBASE 90 shows a text at 0 as 270.00). Shared z is compared to rounding, and the radius and height prompts say what they need. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/hatch.rs | 1 + crates/engine/src/cmd/hatch/assoc.rs | 2 +- crates/engine/src/cmd/props.rs | 5 + crates/engine/src/cmd/props/change.rs | 544 +++++++++++++++++++++++ crates/engine/src/cmd/props/chprop.rs | 104 +++-- crates/engine/tests/it/change_command.rs | 321 +++++++++++++ crates/engine/tests/it/main.rs | 1 + 7 files changed, 952 insertions(+), 26 deletions(-) create mode 100644 crates/engine/src/cmd/props/change.rs create mode 100644 crates/engine/tests/it/change_command.rs diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index ca7defed8..a362a3502 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -15,6 +15,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, point_param, points_param, size_p use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step}; mod assoc; +pub(crate) use assoc::detach; mod edit; mod pick; use super::has_doc; diff --git a/crates/engine/src/cmd/hatch/assoc.rs b/crates/engine/src/cmd/hatch/assoc.rs index b653aa451..e67782938 100644 --- a/crates/engine/src/cmd/hatch/assoc.rs +++ b/crates/engine/src/cmd/hatch/assoc.rs @@ -121,7 +121,7 @@ pub(super) fn same_shape(a: &[PolyVertex], b: &[PolyVertex]) -> bool { ba.min.dist(bb.min) <= eps && ba.max.dist(bb.max) <= eps && (shoelace(&pa).abs() - shoelace(&pb).abs()).abs() <= eps * (1.0 + size) } -pub(super) fn detach(h: &mut Hatch) { +pub(crate) fn detach(h: &mut Hatch) { h.associative = false; h.loops.iter_mut().for_each(|l| l.sources.clear()); } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 45199b12c..8afaddb3d 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1,6 +1,7 @@ //! Properties: per-object property edits, current properties, CHPROP, MATCHPROP, linetypes, units, //! RENAME. +mod change; mod chprop; mod dash_units; mod matchprop; @@ -24,6 +25,10 @@ pub fn specs() -> Vec { CommandSpec::new("chprop", "Change Properties", chprop::run_chprop) .params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)") .interactive(|_| Ok(Box::new(chprop::ChpropM::new()))), + CommandSpec::new("change", "Change", change::run_change) + .alias(&["-ch"]) + .params("{handles?, point?: [x, y], elevation?} → {changed, ignored, differingZ, detachedHatches, locked}: the point moves the nearer end of each line to it (level or plumb with the other end under ORTHOMODE) and, when it is the one object, passes a circle through it or places a text, attribute definition or block reference at it (`ignored` otherwise); `elevation` sets the z of objects whose points share one (`differingZ` otherwise); objects on locked layers are left out (the command line asks Specify change point or [Properties], then about each circle, text, attribute definition and block reference in turn)") + .interactive(|_| Ok(Box::new(change::ChangeM::new()))), CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop) .menu(&["Modify", "Match Properties"]) .alias(&["ma", "painter"]) diff --git a/crates/engine/src/cmd/props/change.rs b/crates/engine/src/cmd/props/change.rs new file mode 100644 index 000000000..64ff4b1ac --- /dev/null +++ b/crates/engine/src/cmd/props/change.rs @@ -0,0 +1,544 @@ +//! CHANGE: change objects by a change point, or change their properties, as AutoCAD 2024 does. +//! +//! "Specify change point or [Properties]" follows "Select objects", which leaves objects on locked +//! layers out as CHPROP does. Properties is CHPROP's loop with Elev added: the elevation is set +//! when the command ends, and objects whose points differ in z keep theirs ("Cannot change +//! elevation of objects with differing Z coordinates."). A change point, or Enter, goes through the +//! objects in the order they were selected. Measured from AutoLISP's `command` (accoreconsole, +//! every object read back with `entget`): +//! - each line moves its end nearer the point to it: (0,0)–(50,0) with (60,10) gives +//! (0,0)–(60,10), with (−5,5) gives (−5,5)–(50,0). Under ORTHOMODE the moved end stays level or +//! plumb with the other one ((60,0)). Enter leaves lines alone; +//! - a circle passes through the point (centre (100,0) and (115,0): radius 15), or asks "Specify +//! new circle radius "; +//! - a text is placed by the point (the alignment point of justified text), or asks "Specify new +//! text insertion point "; then "Enter new text style", "Specify new height" (not +//! with a style of fixed height, nor for Aligned text), "Specify new rotation angle" (not for +//! Aligned or Fit text, whose two points set it) and "Enter new text". An +//! attribute definition asks "Enter new tag", "Enter new prompt" and "Enter new default value" in +//! place of the text. A style that does not exist is refused: `Style "5" not defined - use STYLE +//! command.`; +//! - a block reference is placed by the point, its attributes moving and turning with it, or asks +//! "Specify new block insertion point "; then "Specify new block rotation angle"; +//! - the point places a circle, text or block reference only when it is the one object selected: +//! with others, "Change point ignored" comes before that object's questions, which are asked as +//! for Enter; +//! - other objects (arcs, polylines…) are left alone, and with nothing to change it says "No +//! changeable object selected". +//! +//! Elev sets the z of lines, 3D polylines and solids whose points share one, and of points, +//! circles, arcs, ellipses, polylines, hatches (no longer associative: "Hatch boundary +//! associativity removed."), texts, multiline texts and block references. + +use std::collections::{HashSet, VecDeque}; + +use cadcraft_doc::{Attrib, EntityKind, HAlign, Handle, Text}; +use cadcraft_geom::{Mat3, Vec2, Vec3}; +use serde_json::{Value, json}; + +use super::super::machines::{SelOutcome, SelectPhase, number}; +use super::super::{bad, f64_req, point_req, targets}; +use super::chprop::{ChpropM, begin_unlocked, feed_unlocked, locked}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// A question CHANGE asks about one object. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ask { + /// Not a question: "Change point ignored" as the object comes up. + Ignored, + Radius, + TextPoint, + Style, + Height, + Rotation, + Value, + Tag, + AttPrompt, + Default, + BlockPoint, + BlockRotation, +} + +enum Stage { + Select, + Point, + Properties(Box), + /// The questions left, in order, each about one object. + Asking(VecDeque<(Handle, Ask)>), +} + +pub(super) struct ChangeM { + select: SelectPhase, + handles: Vec, + stage: Stage, +} + +impl ChangeM { + pub(super) fn new() -> Self { + ChangeM { select: SelectPhase::default(), handles: Vec::new(), stage: Stage::Select } + } + + /// Apply the change point `p` (`None`: Enter) and line up the questions it leaves. + fn change_point(&mut self, s: &mut Session, p: Option) -> Result { + let alone = self.handles.len() == 1; + let ortho = s.drafting_settings().orthomode; + let mut asks = VecDeque::new(); + let mut changeable = false; + for &h in &self.handles { + let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue }; + if matches!(kind, EntityKind::Line(_)) { + if let Some(p) = p { + move_end(s, h, p, ortho)?; + changeable = true; + } + continue; + } + let Some(questions) = questions(&kind) else { continue }; + changeable = true; + let placed = p.filter(|_| alone); + if p.is_some() && !alone { + asks.push_back((h, Ask::Ignored)); + } + if let Some(q) = placed { + place(s, h, q)?; + } + // The point answers the first question: the radius or the insertion point. + asks.extend(questions.iter().skip(usize::from(placed.is_some())).map(|a| (h, *a))); + } + if !changeable { + s.echo("No changeable object selected"); + return Ok(Step::Done); + } + self.stage = Stage::Asking(asks); + self.advance(s) + } + + /// Pass over what needs no answer: say "Change point ignored", and skip what [`skipped`] + /// says isn't asked. Done when nothing is left to ask. + fn advance(&mut self, s: &mut Session) -> Result { + let Stage::Asking(asks) = &mut self.stage else { return Ok(Step::Continue) }; + while let Some(&(h, ask)) = asks.front() { + match ask { + Ask::Ignored => s.echo("Change point ignored"), + a if skipped(s, h, a) => {} + _ => return Ok(Step::Continue), + } + asks.pop_front(); + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +/// The questions about an object of `kind`, the first answered by the change point when it is +/// the one object; `None` when the change point leaves it alone. +fn questions(kind: &EntityKind) -> Option<&'static [Ask]> { + Some(match kind { + EntityKind::Circle(_) => &[Ask::Radius], + EntityKind::Text(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Value], + EntityKind::AttDef(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Tag, Ask::AttPrompt, Ask::Default], + EntityKind::Insert(_) => &[Ask::BlockPoint, Ask::BlockRotation], + _ => return None, + }) +} + +fn text_of(kind: &EntityKind) -> Option<&Text> { + match kind { + EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t), + _ => None, + } +} + +fn text_of_mut(kind: &mut EntityKind) -> Option<&mut Text> { + match kind { + EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t), + _ => None, + } +} + +/// Where a circle, text or block reference is measured from: its centre, the point it is placed by +/// (the alignment point of justified text) or its insertion point. +fn anchor(kind: &EntityKind) -> Option { + match kind { + EntityKind::Circle(c) => Some(c.center.xy()), + EntityKind::Insert(i) => Some(i.insert.xy()), + k => text_of(k).map(Text::justify_point), + } +} + +/// Whether CHANGE passes over `ask` for text `h`: the height of a text whose style has a fixed +/// one or that is Aligned, and the rotation of Aligned or Fit text (their two points set it). +fn skipped(s: &Session, h: Handle, ask: Ask) -> bool { + let Ok(d) = s.doc() else { return false }; + let Some(t) = d.entity(h).and_then(|e| text_of(&e.kind)) else { return false }; + match ask { + Ask::Height => t.halign == HAlign::Aligned || d.text_style(&t.style).is_some_and(|st| st.height > 0.0), + Ask::Rotation => matches!(t.halign, HAlign::Aligned | HAlign::Fit), + _ => false, + } +} + +/// Move the end of line `h` nearer `p` to it; under ortho, level or plumb with the other end. +fn move_end(s: &mut Session, h: Handle, p: Vec2, ortho: bool) -> Result<()> { + let settings = s.drafting_settings(); + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Line(l) = &mut e.kind { + let (end, other) = if l.a.xy().dist(p) <= l.b.xy().dist(p) { (&mut l.a, l.b) } else { (&mut l.b, l.a) }; + let q = if ortho { crate::snap::iso::constrain(&settings, other.xy(), p) } else { p }; + end.x = q.x; + end.y = q.y; + } + })?; + Ok(()) +} + +/// Place circle, text or block reference `h` by `q`: a circle passes through it, a text or block +/// reference moves there (with its attributes). +fn place(s: &mut Session, h: Handle, q: Vec2) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Circle(c) = &mut e.kind { + let r = c.center.xy().dist(q); + if r > 0.0 && r.is_finite() { + c.radius = r; + } + } else if let Some(at) = anchor(&e.kind) { + e.kind.transform(&Mat3::translate(q - at)); + } + })?; + Ok(()) +} + +/// Set a circle's radius or a text's height. +fn set_length(s: &mut Session, h: Handle, v: f64) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| match &mut e.kind { + EntityKind::Circle(c) => c.radius = v, + k => { + if let Some(t) = text_of_mut(k) { + t.height = v; + } + } + })?; + Ok(()) +} + +/// Set a text's rotation, or turn a block reference (and its attributes) to `angle` about its +/// insertion point. +fn set_rotation(s: &mut Session, h: Handle, angle: f64) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Insert(i) = &e.kind { + let m = Mat3::rotate_about(i.insert.xy(), angle - i.rotation); + e.kind.transform(&m); + } else if let Some(t) = text_of_mut(&mut e.kind) { + t.rotation = cadcraft_geom::norm_angle(angle); + } + })?; + Ok(()) +} + +/// Take an answer to `ask` about `h`; `false` when it was refused (the question comes again). +fn answer(s: &mut Session, h: Handle, ask: Ask, i: Input) -> Result { + let at = s.doc()?.entity(h).and_then(|e| anchor(&e.kind)).unwrap_or(Vec2::ZERO); + match (ask, i.clone()) { + // Enter keeps what there is. + (_, Input::Enter) => {} + (Ask::TextPoint | Ask::BlockPoint, Input::Point(q)) => place(s, h, q)?, + (Ask::TextPoint | Ask::BlockPoint, _) => return Err(EngineError::Other("Point or option keyword required.".into())), + (Ask::Radius | Ask::Height, Input::Point(_) | Input::Text(_)) => { + let v = match &i { + Input::Point(q) => q.dist(at), + Input::Text(t) => number(t).ok_or_else(|| EngineError::Other("Requires numeric distance or second point.".into()))?, + _ => 0.0, + }; + if !(v > 0.0 && v.is_finite()) { + return Err(EngineError::Other("Value must be positive and nonzero.".into())); + } + set_length(s, h, v)?; + } + (Ask::Rotation | Ask::BlockRotation, Input::Point(q)) => set_rotation(s, h, at.angle_to(q))?, + (Ask::Rotation | Ask::BlockRotation, Input::Text(t)) => { + let a = s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?; + set_rotation(s, h, a)?; + } + (Ask::Style, Input::Text(t) | Input::Keyword(t)) => { + let name = t.trim(); + let Some(style) = s.doc()?.text_style(name).map(|st| st.name.clone()) else { + s.echo(format!("Style \"{name}\" not defined - use STYLE command.")); + return Ok(false); + }; + s.doc_mut()?.modify_entity(h, |e| { + if let Some(t) = text_of_mut(&mut e.kind) { + t.style = style; + } + })?; + } + (Ask::Value | Ask::Tag | Ask::AttPrompt | Ask::Default, Input::Text(t) | Input::Keyword(t)) => { + s.doc_mut()?.modify_entity(h, |e| match (&mut e.kind, ask) { + (EntityKind::AttDef(a), Ask::Tag) => a.tag = t, + (EntityKind::AttDef(a), Ask::AttPrompt) => a.prompt = t, + (k, _) => { + if let Some(x) = text_of_mut(k) { + x.value = t; + } + } + })?; + } + _ => return Ok(false), + } + Ok(true) +} + +/// The question `ask` about `h`. +fn ask_prompt(s: &Session, h: Handle, ask: Ask) -> Prompt { + let d = s.doc().ok(); + let kind = d.and_then(|d| d.entity(h)).map(|e| &e.kind); + let at = kind.and_then(anchor); + let text = kind.and_then(text_of); + // As the drawing shows directions: from ANGBASE in the ANGDIR sense (ANGBASE 90° shows a text + // at 0° as 270.00). + let deg = |a: f64| format!("{:.2}", s.angle_settings().shown(a).to_degrees()); + match ask { + Ask::Radius => Prompt::new("Specify new circle radius", Accept::POINT_OR_NUMBER).default("no change").base_opt(at).distance(), + Ask::TextPoint => Prompt::new("Specify new text insertion point", Accept::POINT).default("no change"), + Ask::BlockPoint => Prompt::new("Specify new block insertion point", Accept::POINT).default("no change"), + Ask::Style => Prompt::new("Enter new text style", Accept::TEXT).default(text.map(|t| t.style.clone()).unwrap_or_default()), + Ask::Height => Prompt::new("Specify new height", Accept::POINT_OR_NUMBER) + .default(format!("{:.4}", text.map_or(0.0, |t| t.height))) + .base_opt(at) + .distance(), + Ask::Rotation => { + Prompt::new("Specify new rotation angle", Accept::POINT_OR_NUMBER).default(deg(text.map_or(0.0, |t| t.rotation))).base_opt(at) + } + Ask::BlockRotation => { + let r = match kind { + Some(EntityKind::Insert(i)) => i.rotation, + _ => 0.0, + }; + Prompt::new("Specify new block rotation angle", Accept::POINT_OR_NUMBER).default(deg(r)).base_opt(at) + } + Ask::Value => Prompt::new("Enter new text", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()), + Ask::Default => Prompt::new("Enter new default value", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()), + Ask::Tag | Ask::AttPrompt => { + let (msg, v) = match (kind, ask) { + (Some(EntityKind::AttDef(a)), Ask::Tag) => ("Enter new tag", a.tag.clone()), + (Some(EntityKind::AttDef(a)), _) => ("Enter new prompt", a.prompt.clone()), + (_, Ask::Tag) => ("Enter new tag", String::new()), + _ => ("Enter new prompt", String::new()), + }; + Prompt::new(msg, Accept::TEXT).default(v) + } + Ask::Ignored => Prompt::new("", Accept::TEXT), + } +} + +/// The one z of `zs`, to rounding; `None` when they differ. +fn shared_z(zs: impl IntoIterator) -> Option { + let mut zs = zs.into_iter(); + let first = zs.next().unwrap_or(0.0); + zs.all(|z| (z - first).abs() <= 1e-9 * (1.0 + first.abs())).then_some(first) +} + +/// The elevation of an object Elev sets: `Some(None)` when its points differ in z (a line between +/// two heights), `None` for the kinds Elev leaves alone. +pub(super) fn elevation_of(kind: &EntityKind) -> Option> { + match kind { + EntityKind::Line(l) => Some(shared_z([l.a.z, l.b.z])), + EntityKind::Polyline3d(p) => Some(shared_z(p.points.iter().map(|q| q.z))), + EntityKind::Solid(so) | EntityKind::Trace(so) => Some(shared_z(so.corners.iter().map(|c| c.z))), + EntityKind::Point(p) => Some(Some(p.p.z)), + EntityKind::Circle(c) => Some(Some(c.center.z)), + EntityKind::Arc(a) => Some(Some(a.center.z)), + EntityKind::Ellipse(e) => Some(Some(e.center.z)), + EntityKind::LwPolyline(p) => Some(Some(p.elevation)), + EntityKind::Hatch(h) => Some(Some(h.elevation)), + EntityKind::MText(t) => Some(Some(t.insert.z)), + EntityKind::Insert(i) => Some(Some(i.insert.z)), + k => text_of(k).map(|t| Some(t.insert.z)), + } +} + +/// Move `text` up by `dz`. +fn raise_text(text: &mut Text, dz: f64) { + for p in std::iter::once(&mut text.insert).chain(text.align_pt.as_mut()) { + p.z += dz; + } +} + +fn set_elevation(kind: &mut EntityKind, z: f64) { + let set = |p: &mut Vec3| p.z = z; + match kind { + EntityKind::Line(l) => { + set(&mut l.a); + set(&mut l.b); + } + EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(set), + EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(set), + EntityKind::Point(p) => set(&mut p.p), + EntityKind::Circle(c) => set(&mut c.center), + EntityKind::Arc(a) => set(&mut a.center), + EntityKind::Ellipse(e) => set(&mut e.center), + EntityKind::LwPolyline(p) => p.elevation = z, + EntityKind::Hatch(h) => { + h.elevation = z; + crate::cmd::hatch::detach(h); + } + EntityKind::MText(t) => set(&mut t.insert), + // A block reference's attributes go up with it. + EntityKind::Insert(i) => { + let dz = z - i.insert.z; + set(&mut i.insert); + for a in &mut i.attribs { + raise_text(&mut a.text, dz); + if let Some(ml) = &mut a.multiline { + ml.mtext.insert.z += dz; + } + } + } + k => { + if let Some(t) = text_of_mut(k) { + let dz = z - t.insert.z; + raise_text(t, dz); + } + } + } +} + +/// What [`elevate`] did. +pub(super) struct Elevated { + /// The objects whose elevation was set. + pub(super) set: Vec, + /// Objects whose points differ in z, which keep theirs. + pub(super) differing: usize, + /// Associative hatches, which no longer are. + pub(super) detached: usize, +} + +/// Set the elevation of the objects among `hs` whose points share one. +pub(super) fn elevate(s: &mut Session, hs: &[Handle], z: f64) -> Result { + let d = s.doc_mut()?; + let mut r = Elevated { set: Vec::new(), differing: 0, detached: 0 }; + for &h in hs { + let Some(e) = d.entity(h) else { continue }; + match elevation_of(&e.kind) { + Some(Some(_)) => { + r.detached += usize::from(matches!(&e.kind, EntityKind::Hatch(hatch) if hatch.associative)); + d.modify_entity(h, |e| set_elevation(&mut e.kind, z))?; + r.set.push(h); + } + Some(None) => r.differing += 1, + None => {} + } + } + Ok(r) +} + +/// Properties ▸ Elev as the command ends: set the elevation, and say what was left alone or +/// changed besides. +pub(super) fn elevate_and_say(s: &mut Session, hs: &[Handle], z: f64) -> Result<()> { + let r = elevate(s, hs, z)?; + if r.differing > 0 { + s.echo("Cannot change elevation of objects with differing Z coordinates."); + } + if r.detached > 0 { + s.echo("Hatch boundary associativity removed."); + } + Ok(()) +} + +/// CHANGE's JSON form: `point` changes the objects as the change point does (`ignored`: circles, +/// texts and block references it leaves alone because others were selected too), `elevation` as +/// Properties ▸ Elev does (`differingZ`: objects whose points differ in z; `detachedHatches`: +/// associative hatches that no longer are); objects on locked layers are left out (`locked`). +pub(super) fn run_change(s: &mut Session, p: &Value) -> Result { + // A point or an elevation that can't be read is refused, and so is a call with neither. + let point = p.get("point").map(|_| point_req("change", p, "point")).transpose()?; + let elevation = p.get("elevation").map(|_| f64_req("change", p, "elevation")).transpose()?; + if point.is_none() && elevation.is_none() { + return Err(bad("change", "`point` or `elevation` is required")); + } + let hs = targets(s, p)?; + let locked = locked(s, &hs); + let free: Vec = hs.into_iter().filter(|h| !locked.contains(h)).collect(); + let (mut changed, mut ignored, mut differing, mut detached) = (HashSet::new(), 0, 0, 0); + if let Some(q) = point { + let ortho = s.drafting_settings().orthomode; + for &h in &free { + let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue }; + if matches!(kind, EntityKind::Line(_)) { + move_end(s, h, q, ortho)?; + changed.insert(h); + } else if questions(&kind).is_some() { + if free.len() == 1 { + place(s, h, q)?; + changed.insert(h); + } else { + ignored += 1; + } + } + } + } + if let Some(z) = elevation { + let r = elevate(s, &free, z)?; + changed.extend(r.set); + (differing, detached) = (r.differing, r.detached); + } + Ok(json!({ "changed": changed.len(), "ignored": ignored, "differingZ": differing, "detachedHatches": detached, "locked": locked.len() })) +} + +impl Interactive for ChangeM { + fn name(&self) -> &'static str { + "CHANGE" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.select = begin_unlocked(s); + if self.select.done { + self.handles = self.select.picked.clone(); + self.stage = Stage::Point; + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match &self.stage { + Stage::Select => self.select.prompt(), + Stage::Point => Prompt::new("Specify change point", Accept::POINT).kw(&["Properties"]), + Stage::Properties(m) => m.prompt(s), + Stage::Asking(asks) => asks.front().map_or_else(|| Prompt::new("", Accept::TEXT), |&(h, ask)| ask_prompt(s, h, ask)), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match &mut self.stage { + Stage::Select => { + return Ok(match feed_unlocked(&mut self.select, s, i)? { + SelOutcome::More => Step::Continue, + SelOutcome::Done(hs) => { + self.handles = hs; + self.stage = Stage::Point; + Step::Continue + } + SelOutcome::Empty => { + s.set_selection(Vec::new()); + Step::Done + } + }); + } + Stage::Properties(m) => return m.input(s, i), + Stage::Asking(asks) => { + let Some(&(h, ask)) = asks.front() else { return Ok(Step::Done) }; + if !answer(s, h, ask, i)? { + return Ok(Step::Continue); + } + asks.pop_front(); + return self.advance(s); + } + Stage::Point => {} + } + match i { + Input::Keyword(k) if k == "Properties" => { + self.stage = Stage::Properties(Box::new(ChpropM::change_properties(self.handles.clone()))); + Ok(Step::Continue) + } + Input::Point(p) => self.change_point(s, Some(p)), + Input::Enter => self.change_point(s, None), + _ => Err(EngineError::Other("Point or option keyword required.".into())), + } + } +} diff --git a/crates/engine/src/cmd/props/chprop.rs b/crates/engine/src/cmd/props/chprop.rs index 0949abee9..fac500ecd 100644 --- a/crates/engine/src/cmd/props/chprop.rs +++ b/crates/engine/src/cmd/props/chprop.rs @@ -28,6 +28,9 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]; +/// CHANGE's Properties: CHPROP's list with Elev. +const CHANGE_PROPERTIES: [&str; 8] = ["Color", "Elev", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]; + /// What CHPROP asks for. #[derive(Clone, Copy, Debug, PartialEq)] enum Ask { @@ -40,6 +43,7 @@ enum Ask { Lineweight, Thickness, Transparency, + Elevation, } pub(super) struct ChpropM { @@ -47,11 +51,35 @@ pub(super) struct ChpropM { handles: Vec, /// `None` while objects are selected. ask: Option, + /// CHANGE's Properties: Elev is on the list. + change: bool, + /// The elevation typed at Elev, set when the command ends. + elevation: Option, } impl ChpropM { pub(super) fn new() -> Self { - ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None } + ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None, change: false, elevation: None } + } + + /// CHANGE's Properties option on `handles`. + pub(super) fn change_properties(handles: Vec) -> Self { + ChpropM { select: SelectPhase::default(), handles, ask: Some(Ask::Property), change: true, elevation: None } + } + + fn keywords(&self) -> &'static [&'static str] { + if self.change { &CHANGE_PROPERTIES } else { &PROPERTIES } + } + + /// The selection's common elevation, or "varies" (as a line between two heights does alone). + fn common_elevation(&self, s: &Session) -> String { + let Ok(d) = s.doc() else { return String::new() }; + let zs: Vec> = self.handles.iter().filter_map(|h| d.entity(*h)).filter_map(|e| super::change::elevation_of(&e.kind)).collect(); + match zs.first() { + None => format!("{:.4}", 0.0), + Some(Some(z)) if zs.iter().all(|o| *o == Some(*z)) => format!("{z:.4}"), + Some(_) => "varies".into(), + } } /// The selection's common value of a property, or "varies". @@ -151,6 +179,13 @@ impl ChpropM { return Ok(false); } } + Ask::Elevation => match number(word).filter(|v| v.is_finite()) { + Some(z) => self.elevation = Some(z), + None => { + s.echo("Requires a number."); + return Ok(false); + } + }, Ask::Property => {} } Ok(true) @@ -158,7 +193,7 @@ impl ChpropM { } /// The objects among `hs` on locked layers. -fn locked(s: &Session, hs: &[Handle]) -> HashSet { +pub(super) fn locked(s: &Session, hs: &[Handle]) -> HashSet { hs.iter().copied().filter(|h| is_locked(s, *h)).collect() } @@ -170,6 +205,38 @@ fn say_locked(s: &mut Session, n: usize) { } } +/// Start selecting objects, leaving those on locked layers out of the pickfirst selection (and +/// saying how many); `done` when some of it is left. +pub(super) fn begin_unlocked(s: &mut Session) -> SelectPhase { + let mut select = SelectPhase::begin(s); + if select.done { + let locked = locked(s, &select.picked); + if !locked.is_empty() { + select.picked.retain(|h| !locked.contains(h)); + say_locked(s, locked.len()); + s.set_selection(select.picked.clone()); + // Nothing left of the pickfirst selection: ask for objects instead. + select.done = !select.picked.is_empty(); + } + } + select +} + +/// Feed `i` to `select`, leaving picked objects on locked layers out and saying how many after +/// what was found. +pub(super) fn feed_unlocked(select: &mut SelectPhase, s: &mut Session, i: Input) -> Result { + let (i, n) = match i { + Input::Pick(hs) if !select.removing => { + let locked = locked(s, &hs); + (Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len()) + } + i => (i, 0), + }; + let outcome = select.feed(s, &i)?; + say_locked(s, n); + Ok(outcome) +} + /// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the /// command leaves out too; "locked" says how many were. pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result { @@ -242,17 +309,7 @@ impl Interactive for ChpropM { "CHPROP" } fn begin(&mut self, s: &mut Session) -> Result { - self.select = SelectPhase::begin(s); - if self.select.done { - let locked = locked(s, &self.select.picked); - if !locked.is_empty() { - self.select.picked.retain(|h| !locked.contains(h)); - say_locked(s, locked.len()); - s.set_selection(self.select.picked.clone()); - // Nothing left of the pickfirst selection: ask for objects instead. - self.select.done = !self.select.picked.is_empty(); - } - } + self.select = begin_unlocked(s); if self.select.done { self.handles = self.select.picked.clone(); self.ask = Some(Ask::Property); @@ -262,7 +319,7 @@ impl Interactive for ChpropM { fn prompt(&self, s: &Session) -> Prompt { let Some(ask) = self.ask else { return self.select.prompt() }; match ask { - Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(&PROPERTIES), + Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(self.keywords()), Ask::Color => { Prompt::new("New color", Accept::TEXT).kw(&["Truecolor"]).default(self.current(s, |e| e.common.color.name().to_ascii_uppercase())) } @@ -275,21 +332,13 @@ impl Interactive for ChpropM { Ask::Lineweight => Prompt::new("Enter new lineweight", Accept::TEXT).default(self.current(s, |e| lineweight_name(e.common.lineweight))), Ask::Thickness => Prompt::new("Specify new thickness", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.thickness))), Ask::Transparency => Prompt::new("Enter transparency value", KW).default(self.current(s, |e| e.common.transparency.name())), + Ask::Elevation => Prompt::new("Specify new elevation", Accept::NUMBER).default(self.common_elevation(s)), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { let Some(ask) = self.ask else { // Objects on locked layers are left out, and said after what was found. - let (i, n) = match i { - Input::Pick(hs) if !self.select.removing => { - let locked = locked(s, &hs); - (Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len()) - } - i => (i, 0), - }; - let outcome = self.select.feed(s, &i)?; - say_locked(s, n); - return Ok(match outcome { + return Ok(match feed_unlocked(&mut self.select, s, i)? { SelOutcome::More => Step::Continue, SelOutcome::Done(hs) => { self.handles = hs; @@ -304,6 +353,10 @@ impl Interactive for ChpropM { }; match (ask, i) { (Ask::Property, Input::Enter) => { + // CHANGE sets the elevation as it ends. + if let Some(z) = self.elevation { + super::change::elevate_and_say(s, &self.handles, z)?; + } s.set_selection(Vec::new()); return Ok(Step::Done); } @@ -315,12 +368,13 @@ impl Interactive for ChpropM { "ltScale" => Ask::LtScale, "LWeight" => Ask::Lineweight, "Thickness" => Ask::Thickness, + "Elev" => Ask::Elevation, _ => Ask::Transparency, }); } (Ask::Property, Input::Text(t)) => { let t = t.trim().trim_start_matches('_').to_ascii_lowercase(); - let n = PROPERTIES.iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count(); + let n = self.keywords().iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count(); s.echo(if n > 1 { "Ambiguous response, please clarify..." } else { "Invalid option keyword." }); } (Ask::Color, Input::Keyword(k)) if k == "Truecolor" => self.ask = Some(Ask::TrueColor), diff --git a/crates/engine/tests/it/change_command.rs b/crates/engine/tests/it/change_command.rs new file mode 100644 index 000000000..f497112c5 --- /dev/null +++ b/crates/engine/tests/it/change_command.rs @@ -0,0 +1,321 @@ +//! CHANGE as AutoCAD 2024 runs it from AutoLISP's `command` (accoreconsole, every object read back +//! with `entget`). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::doc::color::Color; +use serde_json::json; + +/// The measured drawing, its objects named in LISP: lines `a` (0,0)–(50,0) and `b` (0,20)–(50,30) +/// on layer L1, circle `c` (centre (100,0), radius 10), text `t` "ABC" at (0,50) 2.5 high, circle +/// `e` ((200,0), 3), arc `r` ((150,0), 10, 0°–90°) and polyline `p` (0,100)–(50,100)–(50,120); then +/// `code`. +fn run(code: &str) -> Session { + // `entmake` and `entget` go through the DXF codec, as in the app. + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + let mut s = Session::new(); + s.doc_mut().unwrap().ensure_layer("L1"); + for (v, made) in [ + ("a", "(0 . \"LINE\") (10 0.0 0.0 0.0) (11 50.0 0.0 0.0)"), + ("b", "(0 . \"LINE\") (8 . \"L1\") (10 0.0 20.0 0.0) (11 50.0 30.0 0.0)"), + ("c", "(0 . \"CIRCLE\") (10 100.0 0.0 0.0) (40 . 10.0)"), + ("t", "(0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\")"), + ("e", "(0 . \"CIRCLE\") (10 200.0 0.0 0.0) (40 . 3.0)"), + ("r", "(0 . \"ARC\") (10 150.0 0.0 0.0) (40 . 10.0) (50 . 0.0) (51 . 1.5707963267948966)"), + ("p", "(0 . \"LWPOLYLINE\") (90 . 3) (70 . 0) (10 0.0 100.0) (10 50.0 100.0) (10 50.0 120.0)"), + ] { + s.cmdline(&format!("(setq {v} (progn (entmake '({made})) (entlast)))")).unwrap(); + } + s.log.clear(); + s.cmdline(&format!("(progn {code})")).unwrap(); + s +} + +/// The `i`th object drawn. +fn kind(s: &Session, i: usize) -> EntityKind { + s.doc().unwrap().model.iter().nth(i).unwrap().kind.clone() +} + +fn line(s: &Session, i: usize) -> [[f64; 3]; 2] { + let EntityKind::Line(l) = kind(s, i) else { panic!("not a line") }; + [[l.a.x, l.a.y, l.a.z], [l.b.x, l.b.y, l.b.z]] +} + +fn radius(s: &Session, i: usize) -> f64 { + let EntityKind::Circle(c) = kind(s, i) else { panic!("not a circle") }; + c.radius +} + +fn text(s: &Session, i: usize) -> cadcraft_engine::doc::Text { + match kind(s, i) { + EntityKind::Text(t) => t, + EntityKind::AttDef(a) => a.text, + _ => panic!("not a text"), + } +} + +fn said(s: &Session, line: &str) -> usize { + s.log.iter().filter(|l| l.as_str() == line).count() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +#[test] +fn the_change_point_moves_the_nearer_end_of_each_line() { + let s = run("(command \"_.CHANGE\" a \"\" '(60.0 10.0))"); + assert_eq!(line(&s, 0), [[0.0, 0.0, 0.0], [60.0, 10.0, 0.0]]); + let s = run("(command \"_.CHANGE\" a \"\" '(-5.0 5.0))"); + assert_eq!(line(&s, 0), [[-5.0, 5.0, 0.0], [50.0, 0.0, 0.0]]); + let s = run("(command \"_.CHANGE\" a b \"\" '(60.0 10.0))"); + assert_eq!((line(&s, 0)[1], line(&s, 1)), ([60.0, 10.0, 0.0], [[0.0, 20.0, 0.0], [60.0, 10.0, 0.0]])); + // Under ortho the moved end stays level or plumb with the other one. + let s = run("(setvar \"ORTHOMODE\" 1) (command \"_.CHANGE\" a b \"\" '(60.0 10.0))"); + assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 0.0, 0.0], [60.0, 20.0, 0.0])); + // Arcs and polylines are left alone: with a line the point still moves it, without one there + // is nothing to change. Enter leaves lines alone too. + let s = run("(command \"_.CHANGE\" a r p \"\" '(60.0 10.0))"); + assert_eq!(line(&s, 0)[1], [60.0, 10.0, 0.0]); + assert_eq!(said(&s, "No changeable object selected"), 0, "{:?}", s.log); + for code in ["(command \"_.CHANGE\" r \"\" '(160.0 10.0))", "(command \"_.CHANGE\" p \"\" '(60.0 130.0))", "(command \"_.CHANGE\" a \"\" \"\")"] { + let s = run(code); + assert_eq!(said(&s, "No changeable object selected"), 1, "{code}: {:?}", s.log); + assert!(s.running.is_none(), "{code}"); + assert_eq!(line(&s, 0)[1], [50.0, 0.0, 0.0], "{code}"); + } +} + +#[test] +fn a_circle_passes_through_the_point_or_takes_a_radius() { + let s = run("(command \"_.CHANGE\" c \"\" '(115.0 0.0))"); + assert!(near(radius(&s, 2), 15.0)); + let s = run("(command \"_.CHANGE\" c \"\" \"\" 20.0)"); + assert!(near(radius(&s, 2), 20.0)); + // With other objects the point is ignored, said as each circle comes up. + let s = run("(command \"_.CHANGE\" c e \"\" '(110.0 10.0) 8.0)"); + assert_eq!((radius(&s, 2), radius(&s, 4)), (8.0, 3.0)); + assert_eq!(said(&s, "Change point ignored"), 2, "{:?}", s.log); + assert!(s.prompt_text().contains("Specify new circle radius "), "{}", s.prompt_text()); + let s = run("(command \"_.CHANGE\" a c \"\" '(60.0 10.0) 12.0)"); + assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 12.0)); + assert_eq!(said(&s, "Change point ignored"), 1, "{:?}", s.log); +} + +#[test] +fn a_text_is_placed_by_the_point_then_asked_the_rest() { + let s = run("(command \"_.CHANGE\" t \"\" '(10.0 60.0) \"\" 4.0 \"\" \"\")"); + let t = text(&s, 3); + assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (10.0, 60.0, 4.0, "ABC")); + // Enter asks for the insertion point first. + let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"\" 5.0 30 \"XYZ\")"); + let t = text(&s, 3); + assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (0.0, 50.0, 5.0, "XYZ")); + assert!(near(t.rotation.to_degrees(), 30.0)); + // A style that does not exist is refused, and asked for again. + let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"5\")"); + assert_eq!(said(&s, "Style \"5\" not defined - use STYLE command."), 1, "{:?}", s.log); + assert!(s.prompt_text().contains("Enter new text style "), "{}", s.prompt_text()); + // Justified text is placed by its alignment point, with the point or after Enter. + for (justify, code) in [("(72 . 1) (73 . 2)", "'(10.0 60.0) \"\" \"\" \"\" \"\""), ("(72 . 2)", "\"\" '(10.0 60.0) \"\" \"\" \"\" \"\"")] { + let s = run(&format!( + "(entmake '((0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\") {justify} (11 0.0 50.0 0.0))) (command \"_.CHANGE\" (entlast) \"\" {code})" + )); + let at = text(&s, 7).align_pt.unwrap(); + assert_eq!((at.x, at.y), (10.0, 60.0), "{justify}"); + } +} + +#[test] +fn a_style_of_fixed_height_is_not_asked_for_a_height() { + let mut s = run(""); + s.execute("style", &json!({ "name": "FIXH", "height": 5.0 })).unwrap(); + s.cmdline("(entmake '((0 . \"TEXT\") (10 0.0 80.0 0.0) (40 . 5.0) (1 . \"FIX\") (7 . \"FIXH\")))").unwrap(); + s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 30 \"Q\")").unwrap(); + let t = text(&s, 7); + assert_eq!((t.height, t.value.as_str()), (5.0, "Q")); + assert!(near(t.rotation.to_degrees(), 30.0)); + assert!(s.running.is_none()); +} + +#[test] +fn objects_are_asked_about_in_the_order_selected() { + let s = run( + "(setq d (progn (entmake '((0 . \"TEXT\") (10 0.0 70.0 0.0) (40 . 2.5) (1 . \"DEF\"))) (entlast))) (command \"_.CHANGE\" t d \"\" \"\" \"\" \"\" \"\" \"\" \"X1\" \"\" \"\" \"\" \"\" \"X2\")", + ); + assert_eq!((text(&s, 3).value.as_str(), text(&s, 7).value.as_str()), ("X1", "X2")); + let s = run("(command \"_.CHANGE\" t c \"\" \"\" \"\" \"\" \"\" \"\" \"TT\" 7.0)"); + assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0)); + let s = run("(command \"_.CHANGE\" c t \"\" \"\" 7.0 \"\" \"\" \"\" \"\" \"TT\")"); + assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0)); +} + +/// Block K: a circle and attribute TAG1 (default V) 5 above its base point, inserted at (200,0) +/// with the default value. +const BLOCK: &str = "(entmake '((0 . \"BLOCK\") (2 . \"K\") (70 . 2) (10 0.0 0.0 0.0))) (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 3.0))) (entmake '((0 . \"ATTDEF\") (10 0.0 5.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (entmake '((0 . \"ENDBLK\"))) (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")"; + +#[test] +fn a_block_reference_is_placed_and_turned_with_its_attributes() { + let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" '(250.0 50.0) 90)")); + let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") }; + assert_eq!((i.insert.x, i.insert.y), (250.0, 50.0)); + assert!(near(i.rotation.to_degrees(), 90.0)); + let a = &i.attribs.first().expect("the attribute").text; + assert!(near(a.insert.x, 245.0) && near(a.insert.y, 50.0), "{:?}", a.insert); + assert!(near(a.rotation.to_degrees(), 90.0)); + let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" \"\" '(220.0 30.0) 90)")); + let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") }; + assert_eq!((i.insert.x, i.insert.y), (220.0, 30.0)); + assert!(near(i.rotation.to_degrees(), 90.0)); +} + +#[test] +fn an_attribute_definition_is_asked_its_tag_prompt_and_default() { + let s = run( + "(entmake '((0 . \"ATTDEF\") (10 300.0 0.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (command \"_.CHANGE\" (entlast) \"\" '(310.0 10.0) \"\" \"\" \"\" \"T2\" \"P2\" \"V2\")", + ); + let EntityKind::AttDef(a) = kind(&s, 7) else { panic!("not an attribute definition") }; + assert_eq!((a.text.insert.x, a.text.insert.y), (310.0, 10.0)); + assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.text.value.as_str()), ("T2", "P2", "V2")); +} + +#[test] +fn objects_on_locked_layers_are_left_out() { + let s = run("(command \"_.-LAYER\" \"_LO\" \"L1\" \"\") (command \"_.CHANGE\" a b \"\" '(60.0 10.0))"); + assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 10.0, 0.0], [50.0, 30.0, 0.0])); + assert_eq!(said(&s, "1 was on a locked layer."), 1, "{:?}", s.log); +} + +#[test] +fn properties_add_elev_which_is_set_as_the_command_ends() { + let s = run("(command \"_.CHANGE\" a \"\" \"_P\" \"_C\" 2 \"_E\" 10.0 \"\")"); + let d = s.doc().unwrap(); + assert_eq!(d.model.iter().next().unwrap().common.color, Color::Index(2)); + assert_eq!(line(&s, 0), [[0.0, 0.0, 10.0], [50.0, 0.0, 10.0]]); + let s = run("(command \"_.CHANGE\" a c \"\" \"_P\" \"_LA\" \"L1\" \"_T\" 3.0 \"\")"); + for e in s.doc().unwrap().model.iter().take(3).step_by(2) { + assert_eq!((e.common.layer.as_str(), e.common.thickness), ("L1", 3.0)); + } + let s = run("(command \"_.CHANGE\" c t r p \"\" \"_P\" \"_E\" 5.0 \"\")"); + let EntityKind::Circle(c) = kind(&s, 2) else { panic!() }; + let EntityKind::Arc(r) = kind(&s, 5) else { panic!() }; + let EntityKind::LwPolyline(p) = kind(&s, 6) else { panic!() }; + assert_eq!((c.center.z, text(&s, 3).insert.z, r.center.z, p.elevation), (5.0, 5.0, 5.0, 5.0)); + // A line between two heights keeps them, said as the command ends; the elevation asked shows + // it varies. + let mut s = run( + "(setq l (progn (entmake '((0 . \"LINE\") (10 0.0 200.0 0.0) (11 50.0 200.0 10.0))) (entlast))) (command \"_.CHANGE\" l \"\" \"_P\" \"_E\")", + ); + assert!(s.prompt_text().contains("Specify new elevation "), "{}", s.prompt_text()); + s.cmdline("(command 5.0 \"\")").unwrap(); + assert_eq!(line(&s, 7), [[0.0, 200.0, 0.0], [50.0, 200.0, 10.0]]); + assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log); +} + +#[test] +fn change_by_json() { + let mut s = run(""); + let h: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + let r = s.execute("change", &json!({ "handles": [h[0], h[2]], "point": [60, 10] })).unwrap(); + assert_eq!((r["changed"].as_u64(), r["ignored"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 10.0)); + s.execute("change", &json!({ "handles": [h[2]], "point": [115, 0] })).unwrap(); + assert!(near(radius(&s, 2), 15.0)); + s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap(); + let r = s.execute("change", &json!({ "handles": [h[0], h[1]], "elevation": 4.0 })).unwrap(); + assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!((line(&s, 0)[0][2], line(&s, 1)[0][2]), (4.0, 0.0)); + // Neither a point nor an elevation, or one that can't be read, is refused. + for bad in [json!({ "handles": [h[0]] }), json!({ "handles": [h[0]], "point": "nowhere" }), json!({ "handles": [h[0]], "elevation": "high" })] { + assert!(s.execute("change", &bad).is_err(), "{bad}"); + } + assert_eq!(line(&s, 0), [[0.0, 0.0, 4.0], [60.0, 10.0, 4.0]]); +} + +/// Elev on the other kinds AutoCAD 2024 sets it for, each alone: the z of a point, a block +/// reference with its attributes, a multiline text, an ellipse, a solid and a flat 3D polyline; +/// a 3D polyline between heights keeps them, and a hatch loses its associativity. +#[test] +fn elev_sets_the_z_of_every_kind_with_one() { + let elev = |made: &str| { + let mut s = run(made); + s.log.clear(); + s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap(); + let k = s.doc().unwrap().model.iter().last().unwrap().kind.clone(); + (k, s) + }; + let (k, _) = elev("(entmake '((0 . \"POINT\") (10 5.0 5.0 0.0)))"); + assert!(matches!(k, EntityKind::Point(p) if p.p.z == 4.0)); + let (k, _) = elev("(entmake '((0 . \"MTEXT\") (10 0.0 150.0 0.0) (40 . 2.5) (1 . \"AB\")))"); + assert!(matches!(k, EntityKind::MText(t) if t.insert.z == 4.0)); + let (k, _) = elev("(entmake '((0 . \"ELLIPSE\") (10 50.0 150.0 0.0) (11 20.0 0.0 0.0) (40 . 0.5) (41 . 0.0) (42 . 6.283185307179586)))"); + assert!(matches!(k, EntityKind::Ellipse(e) if e.center.z == 4.0)); + let (k, _) = elev("(entmake '((0 . \"SOLID\") (10 0.0 170.0 0.0) (11 10.0 170.0 0.0) (12 0.0 180.0 0.0) (13 10.0 180.0 0.0)))"); + assert!(matches!(k, EntityKind::Solid(so) if so.corners.iter().all(|c| c.z == 4.0))); + // 3D polylines (made with their z, which a point given to a command doesn't carry). + let poly3d = |pts: serde_json::Value| { + let mut s = run(""); + s.execute("3dpoly", &json!({ "points": pts })).unwrap(); + s.log.clear(); + s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap(); + let EntityKind::Polyline3d(p) = s.doc().unwrap().model.iter().last().unwrap().kind.clone() else { panic!("not a 3D polyline") }; + (p.points.iter().map(|q| q.z).collect::>(), s) + }; + let (zs, _) = poly3d(json!([[0, 220, 2], [10, 220, 2], [20, 230, 2]])); + assert_eq!(zs, [4.0, 4.0, 4.0]); + let (zs, s) = poly3d(json!([[0, 200, 0], [10, 200, 5], [20, 210, 0]])); + assert_eq!(zs, [0.0, 5.0, 0.0]); + assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log); + let (k, _) = elev(&format!("{BLOCK} (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")")); + let EntityKind::Insert(i) = k else { panic!("not a block reference") }; + assert_eq!((i.insert.z, i.attribs[0].text.insert.z), (4.0, 4.0)); + // A hatch: its elevation, and no longer associative. + let mut s = run("(entmake '((0 . \"LWPOLYLINE\") (90 . 4) (70 . 1) (10 100.0 150.0) (10 120.0 150.0) (10 120.0 170.0) (10 100.0 170.0)))"); + let poly = s.doc().unwrap().model.iter().last().unwrap().handle.hex(); + s.execute("hatch", &json!({ "handles": [poly], "associative": true })).unwrap(); + // The hatch is drawn behind its boundary, so it isn't the last object in the list. + let hatch = |s: &Session| { + let d = s.doc().unwrap(); + d.model.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).map(|e| (e.handle.hex(), e.kind.clone())).expect("a hatch") + }; + let (hex, k) = hatch(&s); + assert!(matches!(&k, EntityKind::Hatch(h) if h.associative)); + s.log.clear(); + s.cmdline(&format!("(command \"_.CHANGE\" (handent \"{hex}\") \"\" \"_P\" \"_E\" 4.0 \"\")")).unwrap(); + let (_, EntityKind::Hatch(h)) = hatch(&s) else { panic!("not a hatch") }; + assert_eq!((h.elevation, h.associative), (4.0, false)); + assert_eq!(said(&s, "Hatch boundary associativity removed."), 1, "{:?}", s.log); +} + +#[test] +fn the_rotation_asked_shows_as_the_drawing_measures_angles() { + // ANGBASE 90°: a text at 0° shows as 270.00. + let s = run("(setvar \"ANGBASE\" (/ pi 2)) (command \"_.CHANGE\" t \"\" \"\" \"\" \"\" \"\")"); + assert!(s.prompt_text().contains("Specify new rotation angle <270.00>"), "{}", s.prompt_text()); +} + +#[test] +fn aligned_and_fit_texts_are_set_by_their_two_points() { + // Aligned: neither height nor rotation is asked; Fit: no rotation. + let s = run( + "(entmake '((0 . \"TEXT\") (10 0.0 300.0 0.0) (11 40.0 300.0 0.0) (40 . 2.5) (1 . \"AL\") (72 . 3))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" \"NEW\")", + ); + let t = text(&s, 7); + assert_eq!((t.height, t.rotation, t.value.as_str()), (2.5, 0.0, "NEW")); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let s = run( + "(entmake '((0 . \"TEXT\") (10 0.0 320.0 0.0) (11 40.0 320.0 0.0) (40 . 2.5) (1 . \"FI\") (72 . 5))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 5.0 \"FN\")", + ); + let t = text(&s, 7); + assert_eq!((t.height, t.rotation, t.value.as_str()), (5.0, 0.0, "FN")); + assert!(s.running.is_none(), "{}", s.prompt_text()); +} + +#[test] +fn the_json_point_follows_ortho() { + let mut s = run("(setvar \"ORTHOMODE\" 1)"); + let a = s.doc().unwrap().model.iter().next().unwrap().handle.hex(); + s.execute("change", &json!({ "handles": [a], "point": [60, 10] })).unwrap(); + assert_eq!(line(&s, 0)[1], [60.0, 0.0, 0.0]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 2e60c9df1..9b0907e24 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -19,6 +19,7 @@ mod break_closed_polyline; mod break_join_locked_layers; mod break_keeps_z; mod burst; +mod change_command; mod chprop_command; mod compare_export; mod constraint_geometry; From fa060b0998d174eca9529bd3d0332f0678deee9c Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:33:03 +0700 Subject: [PATCH 069/151] Add Vietnamese (vi) to the interface languages (#2541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Vietnamese (vi) to the interface languages vi.tsv covers every key of the other catalogs and the three strings none of them has yet, with one plural form. Its tone-marked letters (U+1EA0-U+1EF9) aren't in egui's bundled faces, so the Vietnamese interface takes the first system interface font that has them (Segoe UI, Arial, SF, DejaVu Sans, Noto Sans) and otherwise stays English and says why, as Korean does without Hangul. AI-assisted; native-speaker review pending. Co-Authored-By: Claude Opus 5.5 * Vietnamese: the terminology Vietnamese AutoCAD users speak Polyline, Pan, Offset, Spline and Grip stay in English as they are said in the office; Modify is Hiệu chỉnh, LINE draws an Đoạn thẳng, Explode is Phân rã, Linetype Loại nét, the plot style table Bảng nét in, Polar and Object Snap Tracking Dò theo góc / Dò theo điểm bắt, Settings Cài đặt; the snap modes Endpoint, Midpoint and Nearest are Đầu mút, Điểm giữa and Điểm gần nhất. Co-Authored-By: Claude Opus 5.5 * Vietnamese: name the language by the font loaded; Arabic's own message The picker writes Tiếng Việt only when the interface's loaded system font has its letters (another interface loads the first installed candidate, which may lack them). The font choice is one function with a unit test, gated off wasm with its callers. Choosing Arabic without an Arabic font no longer gets the Chinese message. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + crates/ui-egui/src/i18n.rs | 23 +- crates/ui-egui/src/i18n/tests.rs | 27 +- crates/ui-egui/src/i18n/vi.tsv | 1453 ++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 13 +- crates/ui-egui/src/options.rs | 2 +- crates/ui-egui/src/theme.rs | 110 ++- 7 files changed, 1605 insertions(+), 24 deletions(-) create mode 100644 crates/ui-egui/src/i18n/vi.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 20b3dcc6e..22550ffaf 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -77,3 +77,4 @@ Generated-in-code assets are original and have no file to list: | `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | | `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker | | `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending | +| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending | diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7bb00ec17..af289ff56 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -47,10 +47,12 @@ pub enum Preference { Turkish, #[serde(rename = "bs")] Bosnian, + #[serde(rename = "vi")] + Vietnamese, } impl Preference { - pub const ALL: [Self; 18] = [ + pub const ALL: [Self; 19] = [ Self::Auto, Self::English, Self::Greek, @@ -69,6 +71,7 @@ impl Preference { Self::Hungarian, Self::Turkish, Self::Bosnian, + Self::Vietnamese, ]; /// Every preference in the order the Interface Language dialog lists them: Auto, then English @@ -117,6 +120,7 @@ impl Preference { Self::Hungarian => "hu", Self::Turkish => "tr", Self::Bosnian => "bs", + Self::Vietnamese => "vi", } } @@ -146,6 +150,14 @@ impl Preference { Self::Hungarian => "Magyar", Self::Turkish => "Türkçe", Self::Bosnian => "Bosanski", + // Its letters need a font egui doesn't bundle ([`crate::theme::vietnamese_font`]). + Self::Vietnamese => { + if crate::theme::shows_vietnamese(CURRENT.with(Cell::get)) { + "Tiếng Việt" + } else { + "Vietnamese" + } + } } } @@ -177,6 +189,7 @@ impl Preference { Self::Hungarian => "hu", Self::Turkish => "tr", Self::Bosnian => "bs", + Self::Vietnamese => "vi", Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"), } } @@ -193,7 +206,7 @@ pub struct Language { catalog: OnceLock>, } -pub static LANGUAGES: [Language; 17] = [ +pub static LANGUAGES: [Language; 18] = [ Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, // Greek: one for exactly 1; other for zero and all other integer counts. Language { @@ -243,6 +256,10 @@ pub static LANGUAGES: [Language; 17] = [ // Bosnian (Latin script, Ijekavian): one (1, 21), few (2–4, 22–24, not 12–14), other — the // Ukrainian rule for integer counts. Language { code: "bs", name: "Bosanski", source: include_str!("i18n/bs.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() }, + // Vietnamese doesn't inflect a noun for number ("3 commit"), so like Korean it has one form. + Language { + code: "vi", name: "Tiếng Việt", source: include_str!("i18n/vi.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() + }, ]; /// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`. @@ -447,6 +464,8 @@ mod picker_order_tests { assert!(pos(Preference::Italian) < pos(Preference::Hungarian), "Magyar sorts under M"); assert!(pos(Preference::Turkish) < pos(Preference::Greek), "Latin names come before Greek"); assert!(pos(Preference::Bosnian) < pos(Preference::Czech), "Bosanski sorts under B"); + assert!(pos(Preference::Swedish) < pos(Preference::Vietnamese), "Tiếng Việt sorts under T"); + assert!(pos(Preference::Vietnamese) < pos(Preference::Turkish), "Tiếng comes before Türkçe"); assert_eq!(name_sort_key("Türkçe"), "turkce"); } } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 6bf85f2c3..dc4e5322b 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -187,6 +187,7 @@ fn assert_additional_locale_aliases() { ("hu", &["hu", "hu-HU", "hu_HU.UTF-8", "HU-hu", " hu-HU "]), ("tr", &["tr", "tr-TR", "tr_TR.UTF-8", "TR-tr", " tr-TR ", "tr-CY"]), ("bs", &["bs", "bs-BA", "bs_BA.UTF-8", "BS-ba", " bs-BA ", "bs-Latn-BA"]), + ("vi", &["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn", " vi-VN "]), ("el", &["el", "el-GR", "el_GR.UTF-8", "EL-cy"]), ("zh", &["zh", "zh-CN"]), ] { @@ -432,12 +433,13 @@ fn dialogs_render_in_every_language_and_keep_their_source_ids() { } /// The letters of every catalog have glyphs in both UI families. Latin, Cyrillic and Greek come -/// from egui's bundled fonts alone, as in the web build; the scripts they lack need a font that -/// `install_fonts` finds, and are checked only where one is there. +/// from egui's bundled fonts alone, as in the web build; the scripts they lack (and Vietnamese's +/// tone-marked letters) need a font that `install_fonts` finds, and are checked only where one is +/// there. #[test] fn interface_fonts_cover_every_letter_in_each_catalog() { for language in translated() { - let extra_font = ["ko", "zh", "ar"].contains(&language.code); + let extra_font = ["ko", "zh", "ar", "vi"].contains(&language.code); if extra_font && !crate::theme::can_show(language.code) { continue; } @@ -566,3 +568,22 @@ fn bosnian_counts_take_one_few_other() { assert_eq!(Preference::parse("bs"), Some(Preference::Bosnian)); assert_eq!(Preference::Bosnian.native_name(), "Bosanski"); } + +/// Vietnamese has one form for every count, and shows only with an interface font that has its +/// tone-marked letters; without one it stays English and says why. +#[test] +fn vietnamese_takes_one_form_and_needs_a_font_with_its_letters() { + assert_eq!(Preference::parse("vi"), Some(Preference::Vietnamese)); + assert_eq!(Preference::Vietnamese.native_name(), "Tiếng Việt"); + let vi = LANGUAGES.iter().find(|l| l.code == "vi").unwrap(); + assert_eq!(vi.plural_forms, 1); + assert!([0, 1, 2, 5, 21, 111].into_iter().all(|n| (vi.plural)(n) == 0)); + let mut app = CadApp::new(Session::new(), Services::default()); + let r = app.run("ui.language", json!({"lang": "vi"})).unwrap(); + let shown = crate::theme::can_show("vi"); + assert_eq!(r["shown"], if shown { "vi" } else { "en" }); + if !shown { + assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("Vietnamese letters"))); + assert_eq!(Preference::Vietnamese.name(), "Vietnamese"); + } +} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv new file mode 100644 index 000000000..9b1493031 --- /dev/null +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -0,0 +1,1453 @@ +# CADCraft Vietnamese interface catalog — original translations by @2ez4gcx with Claude Code (AI-assisted). +# SPDX-License-Identifier: MIT OR Apache-2.0 +# Written from the meaning of CADCraft English strings; no proprietary translations copied. +# AutoCAD command names, command-line keywords and system variables stay in English. +# UTF-8: contextEnglish sourceVietnamese translation. +# @plural = one form for every integer count (Vietnamese doesn't inflect a noun for number). + Apply Áp dụng + Attach DWG... Đính kèm DWG... + Bind Gắn kết + Detach Tách + Loaded Đã nạp + Make it a block of this drawing, keeping its layer names Biến thành block của bản vẽ này, giữ nguyên tên layer + Make it a block of this drawing; its layers become NAME$0$LAYER Biến thành block của bản vẽ này; các layer của nó trở thành NAME$0$LAYER + No external references are attached. Không có tham chiếu ngoài nào được đính kèm. + Not Found Không tìm thấy + Not loaded Chưa nạp + Open Mở + Overlay Chồng lên + Reference Manager Trình quản lý tham chiếu + Reference Name Tên tham chiếu + References: {n} Tham chiếu: {n} + Reload Tải lại + Reload All Tải lại tất cả + Saved Path Đường dẫn đã lưu + Unload Bỏ nạp + Unloaded Đã bỏ nạp + Unreferenced Không được tham chiếu + Unresolved Chưa phân giải + Cancel Hủy + OK OK + or [ hoặc [ + % of all commits % của tất cả commit + * Wildcard Match * Khớp ký tự đại diện + *VARIES* *KHÁC NHAU* + + | Top | 2D Wireframe + | Trên | Khung dây 2D + +Bin +Bin + +LOC +LOC + 1 Viewport 1 khung nhìn + 2 Points 2 điểm + 2 Viewports 2 khung nhìn + 2D Solid Solid 2D + 3 Points 3 điểm + 3 Viewports 3 khung nhìn + 3D Face Mặt 3D + 3D Operations Thao tác 3D + 3D Polyline Polyline 3D + 4 Viewports 4 khung nhìn + < Less than < Nhỏ hơn + <> Not Equal <> Không bằng + = Equals = Bằng + > Greater than > Lớn hơn + A variable name starts with a letter and has only letters, digits and _ Tên biến bắt đầu bằng một chữ cái và chỉ gồm chữ cái, chữ số và _ + About Giới thiệu + About CADCraft Giới thiệu CADCraft + Absolute Tuyệt đối + Add Leader Thêm đường dẫn ghi chú + Additional angles Các góc bổ sung + Align Căn chỉnh + Align Text Căn chỉnh chữ + Aligned Thẳng hàng + All Tất cả + All Annotation Objects Tất cả đối tượng chú thích + All Used Layers Tất cả layer đã dùng + All files Tất cả tệp + Angle Góc + Angular Góc + Annotation Visibility Hiển thị chú thích + Annotation scale Tỉ lệ chú thích + Apparent Intersection Giao biểu kiến + Append to current selection set Thêm vào tập chọn hiện hành + Apply to: Áp dụng cho: + Apply true color Áp dụng màu thực + Arc Cung tròn + Arc Length Chiều dài cung + Area Diện tích + Arrange Panel Sắp xếp bảng + Array Sao chép theo dãy + Attach Đính kèm + Attribute Attribute + Attribute Definition Định nghĩa attribute + Attribute Display Hiển thị attribute + Audit Kiểm tra lỗi + Auto (system language) Tự động (ngôn ngữ hệ thống) + Auto Constrain Ràng buộc tự động + AutoConstrain AutoConstrain + Axis, End Trục, Điểm cuối + Base Gốc + Baseline Đường cơ sở + Batch Publish Xuất bản hàng loạt + Binary assets added Đã thêm tài nguyên nhị phân + Binary assets removed Đã xóa tài nguyên nhị phân + Bind... Gắn kết... + Blend Pha trộn + Block Block + Block Attribute Manager... Trình quản lý attribute của block... + Block Description... Mô tả block... + Block Editor Trình sửa block + Block Reference Tham chiếu block + Block... Block... + Blocks Block + Blue Xanh dương + Bold Đậm + Boolean Boolean + Boundary Biên + Boundary... Biên... + Box Hộp + Break Ngắt + Break At Point Ngắt tại điểm + Bring Above Objects Đưa lên trên các đối tượng + Bring Annotations to Front Đưa chú thích lên trước + Bring to Front Đưa lên trước + Bulleted list Danh sách có dấu đầu dòng + ByBlock ByBlock + ByColor ByColor + ByLayer ByLayer + CADCraft Help Trợ giúp CADCraft + Cancel Hủy + Cartesian (x, y) Đề-các (x, y) + Center Tâm + Center Line Đường tâm + Center Mark Dấu tâm + Center X Tâm X + Center Y Tâm Y + Center Z Tâm Z + Center, Diameter Tâm, Đường kính + Center, Radius Tâm, Bán kính + Center, Start, Angle Tâm, Điểm đầu, Góc + Center, Start, End Tâm, Điểm đầu, Điểm cuối + Center, Start, Length Tâm, Điểm đầu, Chiều dài + Chamfer Vát góc + Change Space Đổi không gian + Change to Current Layer Đổi sang layer hiện hành + Changed: {n} Đã thay đổi: {n} + Circle Đường tròn + Circumference Chu vi + Clear History Xóa lịch sử + Clear Xóa + Clip Xén + Clip Viewport Xén khung nhìn + Clipboard Bảng tạm + Close Đóng + Close All Đóng tất cả + Close All Other Drawings Đóng tất cả bản vẽ khác + Closed Khép kín + Close Comparison Đóng so sánh + Export Comparison Xuất so sánh + Coincident Trùng khớp + Collapse Tool Sets Thu gọn bộ công cụ + Collinear Cùng đường thẳng + Color Màu + Color 8 Màu 8 + Color 9 Màu 9 + Color Index Chỉ số màu + Color... Màu... + Command Aliases Bí danh lệnh + Command History Lịch sử lệnh + Clear Command History Xóa lịch sử lệnh + Command Line Dòng lệnh + Command Line History Lines Số dòng lịch sử dòng lệnh + Command Reference Tham chiếu lệnh + Commits Commit + Company Công ty + Compare Drawings... So sánh bản vẽ... + Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Thiết kế và vẽ có sự hỗ trợ của máy tính: ứng dụng CAD mã nguồn mở, được viết hoàn toàn bằng Rust theo kiểu clean-room. + Concentric Đồng tâm + Cone Hình nón + Constraint Bars Thanh ràng buộc + Constraint Settings Cài đặt ràng buộc + Construction Line Đường dựng + Contents Nội dung + Continue Tiếp tục + Contributors Người đóng góp + Control Vertices Đỉnh điều khiển + Convert Chuyển đổi + Copy Sao chép + Copy Full File Path Sao chép đường dẫn đầy đủ của tệp + Copy History Sao chép lịch sử + Copy Layout Sao chép layout + Copy Nested Objects Sao chép đối tượng lồng nhau + Copy Selection Sao chép vùng chọn + Copy with Base Point Sao chép với điểm gốc + Count Đếm + Count List Danh sách đếm + Count Table Bảng đếm + Create Block Tạo block + Creates a user parameter Tạo một tham số người dùng + Ctrl+Enter to finish Ctrl+Enter để kết thúc + Current layer Layer hiện hành + Current selection Tập chọn hiện hành + Current: {color} Hiện tại: {color} + Cut Cắt + Cut with Base Point Cắt với điểm gốc + Cyan Lục lam + Cylinder Hình trụ + DWG DWG + DWG Reference... Tham chiếu DWG... + DXF DXF + Dark Tối + Data Extraction... Trích xuất dữ liệu... + Default Mặc định + Data Link... Liên kết dữ liệu... + Default Save Format Định dạng lưu mặc định + Default Save Version Phiên bản lưu mặc định + Define Attributes Định nghĩa attribute + Define Attributes... Định nghĩa attribute... + Defined width Bề rộng đã định nghĩa + Degree Độ + Degrees of Freedom Bậc tự do + Degrees of freedom: {n} Bậc tự do: {n} + Delete Xóa + Delete Column Xóa cột + Delete Constraints Xóa ràng buộc + Delete Dimension Style Xóa kiểu kích thước + Delete Duplicate Objects Xóa đối tượng trùng lặp + Delete Layer Xóa layer + Delete Layer State Xóa trạng thái layer + Delete Layout Xóa layout + Delete Row Xóa hàng + Delete Text Style Xóa kiểu chữ + Delta X Chênh lệch X + Delta Y Chênh lệch Y + Description Mô tả + Deselect All Bỏ chọn tất cả + Diameter Đường kính + Dim style Kiểu kích thước + Dimension Break Ngắt kích thước + Dimension Edit Sửa kích thước + Dimension Kích thước + Dimension Objects Only Chỉ đối tượng kích thước + Dimension Space Khoảng cách kích thước + Dimension Style Override Ghi đè kiểu kích thước + Dimension Style... Kiểu kích thước... + Dimension Text Edit Sửa chữ kích thước + Dimension style Kiểu kích thước + Dimensional Constraint Ràng buộc kích thước + Dimensional Constraint Parameters Tham số ràng buộc kích thước + Dimensional Constraints Ràng buộc kích thước + Disassociate Dimensions Tách liên kết kích thước + Display name Tên hiển thị + Distance Khoảng cách + Divide Chia + Do not plot (click: plot) Không in (nhấp để in) + Donut Vành tròn + Double-click to edit the description Nhấp đúp để sửa mô tả + Down Xuống + Drafting Vẽ kỹ thuật + Drafting Settings Cài đặt hỗ trợ vẽ + Drafting Settings... Cài đặt hỗ trợ vẽ... + Draw Vẽ + Draw Order Thứ tự vẽ + Drawing (DWG) Bản vẽ (DWG) + Drawing (DXF) Bản vẽ (DXF) + Drawing Limits Giới hạn bản vẽ + Drawing Template (DWT) Mẫu bản vẽ (DWT) + Drawing Utilities Tiện ích bản vẽ + Drawings (DWG, DXF) Bản vẽ (DWG, DXF) + Dynamic Động + Dynamic Dimensions Kích thước động + Dynamic Input Dynamic input + Dynamic Input (F12) Dynamic input (F12) + E E + Edit Sửa + Edit Attributes... Sửa attribute... + Edit Text... Sửa chữ... + Edit Table Cell Sửa ô bảng + Elevation Cao độ + Ellipse Elip + Elliptical Arc Cung elip + Enable Dynamic Input (F12) Bật dynamic input (F12) + End Object Isolation Kết thúc cô lập đối tượng + End Width Bề rộng cuối + End X Điểm cuối X + End Y Điểm cuối Y + End angle Góc cuối + Endpoint Đầu mút + Enter Enter + Enter Top, Zero or Bottom. Nhập Top, Zero hoặc Bottom. + Enter a fit data option Nhập tùy chọn dữ liệu khớp + Enter a pattern name Nhập tên mẫu + Enter a vertex editing option Nhập tùy chọn sửa đỉnh + Enter an option Nhập một tùy chọn + Enter angle of xline Nhập góc của đường dựng + Enter angle of xline (0) or Nhập góc của đường dựng (0) hoặc + Enter area of rectangle in current units Nhập diện tích hình chữ nhật theo đơn vị hiện hành + Enter attribute prompt Nhập lời nhắc attribute + Enter attribute tag name Nhập tên thẻ attribute + Enter attribute value Nhập giá trị attribute + Enter block name Nhập tên block + Enter constraint type Nhập loại ràng buộc + Enter continuity Nhập tính liên tục + Enter default attribute value Nhập giá trị attribute mặc định + Enter degree of spline Nhập bậc của spline + Enter delta angle Nhập góc chênh lệch + Enter delta length or Nhập chiều dài chênh lệch hoặc + Enter dimension text Nhập chữ kích thước + Enter dimension text (<> = measurement) Nhập chữ kích thước (<> = số đo) + Enter dimension value or [name=expression] Nhập giá trị kích thước hoặc [name=expression] + Enter dimension variable name to override Nhập tên biến kích thước để ghi đè + Enter justification type Nhập kiểu căn lề + Enter leader text Nhập chữ đường dẫn ghi chú + Enter mline scale Nhập tỉ lệ mline + Enter mode Nhập chế độ + Enter name of block to insert Nhập tên block cần chèn + Enter new text Nhập chữ mới + Enter next point Nhập điểm tiếp theo + Enter number of columns Nhập số cột + Enter number of data rows Nhập số hàng dữ liệu + Enter number of items Nhập số mục + Enter number of items along path Nhập số mục dọc theo đường dẫn + Enter number of sides Nhập số cạnh + Enter number of turns Nhập số vòng + Enter percentage length Nhập chiều dài theo phần trăm + Enter rectangle length Nhập chiều dài hình chữ nhật + Enter rectangle width Nhập chiều rộng hình chữ nhật + Enter scale factor Nhập hệ số tỉ lệ + Enter text Nhập chữ + Enter text (use \P for new paragraphs) Nhập chữ (dùng \P để xuống đoạn mới) + Enter the number of segments Nhập số đoạn + Enter twist direction of helix Nhập chiều xoắn của đường xoắn ốc + Entire drawing Toàn bộ bản vẽ + Equal Bằng nhau + Erase Xóa + Evaluate Tính giá trị + Exclude from new selection set Loại khỏi tập chọn mới + Explode Phân rã + Export Xuất + Export Table Xuất bảng + Export to PDF... Xuất ra PDF... + Expression Biểu thức + Extend Kéo dài + Extension Phần kéo dài + Extents Phạm vi + External Reference Tham chiếu ngoài + External References Tham chiếu ngoài + Extrude Đùn + File Tệp + File Tab Thẻ tệp + Fillet Bo góc + Filter Bộ lọc + Filters Bộ lọc + Find and Replace Tìm và thay thế + Find... Tìm... + First Đầu tiên + First commit Commit đầu tiên + Fit Points Điểm khớp + Fix Cố định + Flatten Objects Làm phẳng đối tượng + Format Định dạng + Freehand Tự do + Freeze Đóng băng + Freeze Layer Đóng băng layer + Freeze in new viewports Đóng băng trong khung nhìn mới + Freeze/Thaw Đóng băng/Tan băng + Freeze/Thaw in all viewports Đóng băng/Tan băng trong tất cả khung nhìn + Freeze/Thaw in the current viewport Đóng băng/Tan băng trong khung nhìn hiện hành + From Từ + From Template... Từ mẫu... + General Chung + Geometric Center Tâm hình học + Geometric Constraint Ràng buộc hình học + Geometric Constraints Ràng buộc hình học + Geometry Hình học + Get from the Drawing Lấy từ bản vẽ + Get Variable Lấy biến + Get View Lấy góc nhìn + Global Toàn cục + Global width Bề rộng toàn cục + Grab bag Tổng hợp + Gradient Gradient + Gradient... Gradient... + Green Xanh lục + Grid (F7) Lưới (F7) + Grid Display Hiển thị lưới + Grid On (F7) Bật lưới (F7) + Grid snap Snap lưới + Grid spacing Khoảng cách lưới + Grip Edit Chỉnh sửa grip + Grip Mirror Grip đối xứng + Grip Option Tùy chọn grip + Grip Rotate Grip xoay + Grip Scale Grip tỉ lệ + Grip Stretch Grip kéo giãn + Group Nhóm + Group Edit Chỉnh sửa nhóm + Group Options Tùy chọn nhóm + Group Selection Chọn nhóm + Hatch Hatch + Hatch Edit Chỉnh sửa hatch + Hatch Patterns Mẫu hatch + Hatch... Hatch... + Height Chiều cao + Height: {height} Chiều cao: {height} + Helix Đường xoắn ốc + Help Trợ giúp + Hide All Ẩn tất cả + Hide All Constraint Bars Ẩn tất cả thanh ràng buộc + Hide All Dynamic Dimensions Ẩn tất cả kích thước động + Hide Layer List Ẩn danh sách layer + Hide Objects Ẩn đối tượng + Hide Palettes Ẩn các bảng + History Lịch sử + Home Trang chủ + Horizontal Ngang + HorizontalDistance Khoảng cách ngang + How to apply: Cách áp dụng: + ID Point Điểm ID + Import Nhập + In Phóng to + In-window Menu Bar Thanh menu trong cửa sổ + Include in new selection set Đưa vào tập chọn mới + Increment angle Góc tăng dần + Index Color Màu chỉ số + Index color: {index} Màu chỉ số: {index} + Infer Constraints Suy luận ràng buộc + Inquiry Truy vấn + Insert Chèn + Insert Block Chèn block + Insert Column Chèn cột + Insert Row Chèn hàng + Insert tab Thẻ chèn + Insert this block Chèn block này + Insert Table Formula Chèn công thức bảng + Insertion Điểm chèn + Insertion X Điểm chèn X + Insertion Y Điểm chèn Y + Insertion Z Điểm chèn Z + Inspect Constraints Kiểm tra ràng buộc + Inspect Drawing Kiểm tra bản vẽ + Inspection Kiểm tra + Interface Language Ngôn ngữ giao diện + Interface Theme Chủ đề giao diện + Intersect Giao + Intersection Giao điểm + Isolate Cô lập + Isolate Layer Cô lập layer + Isolate Objects Cô lập đối tượng + Isometric Drafting Vẽ đẳng trắc + Jogged Gãy khúc + Jogged Linear Gãy khúc tuyến tính + Join Nối + Join the ArtCraft Discord… Tham gia Discord ArtCraft… + Join us on Discord Tham gia cùng chúng tôi trên Discord + Isometric Plane Mặt phẳng đẳng trắc + Isometric snap Snap đẳng trắc + Language Ngôn ngữ + Last Cuối cùng + Last commit Commit gần nhất + Layer Layer + Layer Delete Xóa layer + Layer Match Khớp layer + Layer Merge Gộp layer + Layer Off Tắt layer + Layer Properties Thuộc tính layer + Layer Properties Manager Quản lý layer + Layer Properties Manager — current viewport Quản lý layer — khung nhìn hiện hành + Layer State Trạng thái layer + Layer States Manager... Trình quản lý trạng thái layer... + Layer Tools Công cụ layer + Layer contains objects (double-click to make current) Layer có chứa đối tượng (nhấp đúp để đặt làm hiện hành) + Layer is empty (double-click to make current) Layer trống (nhấp đúp để đặt làm hiện hành) + Layers Layer + Layers Palette Bảng layer + Hide Palette Ẩn bảng + Layers displayed: {shown} of {total}. Current layer: {layer} Đang hiển thị {shown} trên {total} layer. Layer hiện hành: {layer} + Layout Layout + Layout list Danh sách layout + Leader Đường dẫn ghi chú + Leaders Only Chỉ đường dẫn ghi chú + Left Trái + Length Chiều dài + Length X Chiều dài X + Length Y Chiều dài Y + Lengthen Đổi chiều dài + Light Sáng + Line Đoạn thẳng + Line delta Chênh lệch dòng + Linear Tuyến tính + Lines +/− Dòng +/− + Lines added Dòng đã thêm + Lines deleted Dòng đã xóa + Lines of Command History Số dòng lịch sử lệnh + Linetype Loại nét + Linetype Scale Tỉ lệ loại nét + Linetype scale Tỉ lệ loại nét + Ltscale Ltscale + Linetype... Loại nét... + Lineweight Bề dày nét + Lineweight... Bề dày nét... + List Liệt kê + List Blocks Liệt kê block + List Dimension Styles Liệt kê kiểu kích thước + List Groups Liệt kê nhóm + List Layer States Liệt kê trạng thái layer + List Layouts Liệt kê layout + List System Variables Liệt kê biến hệ thống + List Text Styles Liệt kê kiểu chữ + Load all Tải tất cả + Load... Tải... + Loaded linetypes Loại nét đã tải + Lock Khóa + Lock Layer Khóa layer + Lock viewport Khóa khung nhìn + Lock/Unlock Khóa/Mở khóa + Loft Loft + MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT OR Apache-2.0. Không liên kết với Autodesk, Inc. + MODEL MODEL + MText Chữ nhiều dòng + Magenta Magenta + Major line every Đường chính mỗi + Make Current Đặt làm hiện hành + Make Object's Layer Current Đặt layer của đối tượng làm hiện hành + Make... Tạo... + Match Layer Khớp layer + Match Properties Sao chép thuộc tính + Measure Đo + Measure Distance Đo khoảng cách + Measure Geometry Đo hình học + Merge Cells Gộp ô + Merged PRs PR đã gộp + Mid Between 2 Points Điểm giữa 2 điểm + Midpoint Điểm giữa + Mirror Lấy đối xứng + Model Model + Model Space (in viewport) Không gian mô hình (trong khung nhìn) + Modeling Mô hình hóa + Models Model + Modify Hiệu chỉnh + Move Di chuyển + Multileader Multileader + Multileader Style... Kiểu multileader... + Multileader style Kiểu multileader + Multiline Multiline + Multiline Text Chữ nhiều dòng + Multiline Text... Chữ nhiều dòng... + Multiple Nhiều + Multiple Point Nhiều điểm + My Của tôi + My properties: not available yet Thuộc tính của tôi: chưa khả dụng + N N + Name Tên + Name (A–Z) Tên (A–Z) + Named Viewports... Khung nhìn đã đặt tên... + Named Views... Góc nhìn đã đặt tên... + Nearest Điểm gần nhất + New Mới + New (metric) Mới (hệ mét) + New Drawing Bản vẽ mới + New Drawing (⌘N) Bản vẽ mới (⌘N) + New Drawing... Bản vẽ mới... + New Layer Layer mới + New Layout Layout mới + New VP Freeze Đóng băng VP mới + New Viewports... Khung nhìn mới... + New from Template... Tạo từ bản mẫu... + New layout Layout mới + New state name Tên trạng thái mới + Next Change Thay đổi tiếp theo + No Không + No blocks defined in this drawing. Chưa định nghĩa block nào trong bản vẽ này. + No contributor data was built into this copy. Bản này không chứa dữ liệu người đóng góp. + No model credits were built into this copy. Bản này không chứa thông tin ghi nhận mô hình. + No saved layer states Chưa lưu trạng thái layer nào + No selection Chưa có lựa chọn + Node Điểm nút + None Không có + Normal Bình thường + Not available Không khả dụng + Not available yet Chưa khả dụng + Numbered list Danh sách đánh số + OK OK + Object Đối tượng + Object Snap Bắt điểm + Object Snap (F3) Bắt điểm (F3) + Object Snap On (F3) Bật bắt điểm (F3) + Object Snap Tracking Dò theo điểm bắt + Object Snap Tracking (F11) Dò theo điểm bắt (F11) + Object Snap Tracking Settings Cài đặt dò theo điểm bắt + Object type: Loại đối tượng: + Oblique Nghiêng + Off Tắt + Offset Offset + On Bật + On/Off Bật/Tắt + One of the Crafting Apps by the ArtCraft team and community. Một trong các ứng dụng Crafting của nhóm và cộng đồng ArtCraft. + Only in this drawing: {n} Chỉ có trong bản vẽ này: {n} + Only in {name}: {n} Chỉ có trong {name}: {n} + Open (⌘O) Mở (⌘O) + Open Sample Drawing Mở bản vẽ mẫu + Open sample drawing Mở bản vẽ mẫu + Open Xref Mở xref + Open... Mở... + Operator: Toán tử: + Orbit Xoay quanh + Ordinate Kích thước tọa độ + Ortho (F8) Ortho (F8) + Ortho Mode Chế độ Ortho + Ortho Mode (F8) Chế độ Ortho (F8) + Osnap Settings... Cài đặt bắt điểm... + Out Thu nhỏ + Over-constrained Thừa ràng buộc + Override Ghi đè + PAPER PAPER + Percent Phần trăm + Polar (distance < angle) Cực (khoảng cách < góc) + PRs PR + Page Setup Manager Trình quản lý thiết lập trang + Page Setup Manager... Trình quản lý thiết lập trang... + Pan Pan + Pan Down Pan xuống + Pan Left Pan sang trái + Pan Right Pan sang phải + Pan Up Pan lên + Paper Space Không gian giấy + Parallel Song song + Parameters Manager Trình quản lý tham số + Parametric Tham số + Paste Dán + Paste as Block Dán thành block + Paste to Command Line Dán vào dòng lệnh + Paste to Original Coordinates Dán theo tọa độ gốc + Path Array Mảng theo đường dẫn + Pattern Mẫu + Perpendicular Vuông góc + Plot In + Plot (click: do not plot) In (nhấp để không in) + Plot Preview Xem trước bản in + Plot Window Cửa sổ in + Plot style Kiểu in + Plot style attached to Kiểu in được gắn vào + Plot style table Bảng nét in + Plot Style... Kiểu in... + Plot Style Manager Trình quản lý kiểu in + Plot Style Manager... Trình quản lý kiểu in... + Plot Styles... Kiểu in... + Plotter Manager Trình quản lý máy in + Plotter Manager... Trình quản lý máy in... + New CTB CTB mới + New STB STB mới + New style Kiểu mới + Reset Panel Layout Đặt lại bố cục bảng điều khiển + Send Hatches to Back Đưa hatch xuống dưới cùng + Send Under Objects Đưa xuống dưới đối tượng + Text Objects Only Chỉ đối tượng chữ + Use for this layout Dùng cho layout này + Display plot styles Hiển thị kiểu in + No plot style tables. Không có bảng nét in nào. + A built-in table: editing it keeps an edited copy in the drawing. Bảng dựng sẵn: khi chỉnh sửa, một bản sao đã sửa sẽ được giữ trong bản vẽ. + Keep an editable copy of this table in the drawing Giữ một bản sao có thể chỉnh sửa của bảng này trong bản vẽ + Layout {name}: {table} Layout {name}: {table} + Grayscale Thang xám + Screening Tỉ lệ phủ + Use object linetype Dùng loại nét của đối tượng + Use object lineweight Dùng bề dày nét của đối tượng + Remove Gỡ bỏ + Add Thêm + Open a drawing first. Hãy mở một bản vẽ trước. + Plot table type Loại bảng nét in + Color-dependent Phụ thuộc màu + Named Theo tên + Plus/minus Cộng/trừ + Point Điểm + Point Filters Bộ lọc điểm + Point Style... Kiểu điểm... + Polar Array Mảng tròn + Polar Tracking Dò theo góc + Polar Tracking (F10) Dò theo góc (F10) + Polar Tracking On (F10) Bật dò theo góc (F10) + Polar angle measurement Đo góc cực + Polar distance Khoảng cách cực + Polar: {distance} < {angle}° Cực: {distance} < {angle}° + PolarSnap PolarSnap + Polygon Đa giác + Polygonal Đa giác + Polygonal Viewport Khung nhìn đa giác + Polyline Polyline + Polyline Edit Chỉnh sửa polyline + Position X Vị trí X + Position Y Vị trí Y + Position Z Vị trí Z + Previous Trước + Previous Change Thay đổi trước + Previous Layer Layer trước + Primitives Khối cơ bản + Print In ra máy in + Print... In ra máy in... + Properties Thuộc tính + Properties Inspector Trình kiểm tra thuộc tính + Properties: Thuộc tính: + Proxy Proxy + Purge Dọn dẹp + Pyramid Hình chóp + Quadrant Góc phần tư + Query Entities Truy vấn đối tượng + Quick Nhanh + Quick Dimension Kích thước nhanh + Quick Properties Thuộc tính nhanh + Quick Select Chọn nhanh + Quick Select Properties Thuộc tính chọn nhanh + Quick Select... Chọn nhanh... + QuickCalc QuickCalc + QuickCalc Pick Chọn QuickCalc + Quit CADCraft Thoát CADCraft + Radius Bán kính + Raster Image Ảnh raster + Ray Tia + Read-only Chỉ đọc + Real name Tên thực + Realtime Thời gian thực + Reassociate Dimensions Liên kết lại kích thước + Recent Gần đây + Recent Commands Lệnh gần đây + Recent Input Đầu vào gần đây + Recover... Khôi phục... + Rectangle Hình chữ nhật + Recalculate Table Formulas Tính lại công thức bảng + Rectangular Hình chữ nhật + Rectangular snap Snap hình chữ nhật + Rectangular Array Mảng chữ nhật + Red Đỏ + Redo Làm lại + Redo (⇧⌘Z) Làm lại (⇧⌘Z) + Redraw Vẽ lại + Regen Tái tạo hình + Regen All Tái tạo hình toàn bộ + Region Miền + Region/Mass Properties Thuộc tính miền/khối + Reinitialize Khởi tạo lại + Relative Tương đối + Relative to last segment Tương đối với đoạn cuối + Remove Leader Xóa đường dẫn ghi chú + Remove the viewport override Xóa ghi đè khung nhìn + Rename Dimension Style Đổi tên kiểu kích thước + Rename Layer Đổi tên layer + Rename Layer State Đổi tên trạng thái layer + Rename Layout Đổi tên layout + Rename Text Style Đổi tên kiểu chữ + Rename... Đổi tên... + Repeat {command} Lặp lại {command} + Replace Block... Thay thế block... + Requires a point (x,y, @dx,dy or @d Chỉ định điểm thứ hai hoặc + Specify second source point Chỉ định điểm nguồn thứ hai + Specify side to offset Chỉ định phía offset + Specify start angle Chỉ định góc bắt đầu + Specify start point Chỉ định điểm bắt đầu + Specify start point of arc Chỉ định điểm bắt đầu của cung + Specify start point of polyline Chỉ định điểm bắt đầu của polyline + Specify start point of text Chỉ định điểm bắt đầu của chữ + Specify starting width Chỉ định bề rộng bắt đầu + Specify starting width for next segment Chỉ định bề rộng bắt đầu cho đoạn tiếp theo + Specify tangent direction for the start point of arc Chỉ định hướng tiếp tuyến tại điểm bắt đầu của cung + Specify the elevation for rectangles Chỉ định cao độ cho hình chữ nhật + Specify thickness for rectangles Chỉ định độ dày cho hình chữ nhật + Specify third point on circle Chỉ định điểm thứ ba trên đường tròn + Specify third point on circle: _tan to Chỉ định điểm thứ ba trên đường tròn: _tan to + Specify third source point or Chỉ định điểm nguồn thứ ba hoặc + Specify through point Chỉ định điểm đi qua + Specify top diameter Chỉ định đường kính trên + Specify top radius or Chỉ định bán kính trên hoặc + Specify total angle Chỉ định tổng góc + Specify total length or Chỉ định tổng chiều dài hoặc + Specify width for rectangles Chỉ định bề rộng cho hình chữ nhật + Sphere Hình cầu + Spline Spline + Spline Edit Chỉnh sửa spline + Stack (type 1/2 then select) Xếp chồng (nhập 1/2 rồi chọn) + Standard Standard + Start Bắt đầu + Start Width Bề rộng bắt đầu + Start X X bắt đầu + Start Y Y bắt đầu + Start angle Góc đầu + Start, Center, Angle Điểm đầu, tâm, góc + Start, Center, End Điểm đầu, tâm, điểm cuối + Start, Center, Length Điểm đầu, tâm, chiều dài + Start, End, Angle Điểm đầu, điểm cuối, góc + Start, End, Direction Điểm đầu, điểm cuối, hướng + Start, End, Radius Điểm đầu, điểm cuối, bán kính + Status Trạng thái + Status Bar Thanh trạng thái + Stretch Kéo giãn + Style: Standard Kiểu: Standard + Subtract Trừ + Sweep Quét + Switch Drawing Chuyển bản vẽ + Switch Layout Chuyển layout + Switch between model space in a viewport and paper space Chuyển giữa không gian mô hình trong khung nhìn và không gian giấy + Symmetric Đối xứng + Synchronize Attributes Đồng bộ attribute + To Đến + Toggle PICKADD (off: each pick replaces the selection) Bật/tắt PICKADD (tắt: mỗi lần chọn sẽ thay thế tập chọn) + Toggle PICKADD (on: picks add to the selection) Bật/tắt PICKADD (bật: mỗi lần chọn sẽ thêm vào tập chọn) + TOP TOP + Table Bảng + Table Breaks Ngắt bảng + Table Cell Style Kiểu ô bảng + Table Style... Kiểu bảng... + Table style Kiểu bảng + Table... Bảng... + Tan, Tan, Radius Tiếp tuyến, tiếp tuyến, bán kính + Tan, Tan, Tan Tiếp tuyến, tiếp tuyến, tiếp tuyến + Tangent Tiếp tuyến + Temporary track point Điểm dò tạm thời + Text Chữ + Text Editor Trình soạn thảo chữ + Text Height Chiều cao chữ + Text Style... Kiểu chữ... + Text height Chiều cao chữ + Text override Chữ đè + Text style Kiểu chữ + Thaw All Layers Tan băng tất cả layer + Thaw Layer Tan băng layer + There is nothing here yet. Chưa có gì ở đây. + Thickness Độ dày + Time Thời gian + To get started, create or open a drawing. Để bắt đầu, hãy tạo hoặc mở một bản vẽ. + Tool Bar Thanh công cụ + Tool Sets Bộ công cụ + Tools Công cụ + Torus Xuyến + Trace Nét rộng + Track orthogonally only Chỉ dò theo phương ngang và dọc + Track using all polar angle settings Dò theo mọi góc cực đã đặt + Transparency Độ trong suốt + Trim Cắt xén + True Color Màu thực + Turn All Layers On Bật tất cả layer + Turn Off Layer Tắt layer + Turn On Layer Bật layer + Turn Quick Properties off (QPMODE) Tắt thuộc tính nhanh (QPMODE) + Type Loại + Type a command Nhập lệnh + Type text; Enter starts a new paragraph Nhập chữ; Enter để bắt đầu đoạn mới + UCS Icon Biểu tượng UCS + Undo Hoàn tác + Undo (⌘Z) Hoàn tác (⌘Z) + Ungroup Hủy nhóm + Union Hợp + Unisolate Layer Hủy cô lập layer + Units Conversion Chuyển đổi đơn vị + Units... Đơn vị... + Unlock Layer Mở khóa layer + Unlock viewport Mở khóa khung nhìn + Unmerge Cells Tách ô + Unsaved Layer State Trạng thái layer chưa lưu + Up Lên + Update Data Links Cập nhật liên kết dữ liệu + Update Cập nhật + Use System Setting Dùng cài đặt hệ thống + User Người dùng + User Parameters Tham số người dùng + Username Tên người dùng + Volume Thể tích + VP Color Màu VP + VP Freeze Đóng băng VP + Value Giá trị + Value: Giá trị: + Variables Biến + Version Phiên bản + Version 2000 Phiên bản 2000 + Version 2004 Phiên bản 2004 + Version 2007 Phiên bản 2007 + Version 2010 Phiên bản 2010 + Version 2013 Phiên bản 2013 + Version 2018 Phiên bản 2018 + Version {version} Phiên bản {version} + Vertical Dọc + VerticalDistance Khoảng cách dọc + View Góc nhìn + ViewCube ViewCube + Viewport Khung nhìn + Viewport Layer Freeze Đóng băng layer khung nhìn + Viewport Properties Thuộc tính khung nhìn + Viewport is locked Khung nhìn đã bị khóa + Viewport scale Tỉ lệ khung nhìn + Viewport {id} Khung nhìn {id} + Viewports Khung nhìn + W W + WCS WCS + White Trắng + Width factor Hệ số rộng + Window Cửa sổ + Wipeout Che phủ + Workspace Switching Chuyển đổi không gian làm việc + Write Block Ghi block + Xline Đường thẳng vô hạn + Yellow Vàng + Yes Có + Zoom Thu phóng + Zoom All Thu phóng tất cả + Zoom Dynamic Thu phóng động + Zoom Extents Thu phóng toàn bộ + Zoom In Phóng to + Zoom Object Thu phóng đối tượng + Zoom Out Thu nhỏ + Zoom Previous Thu phóng trước đó + Zoom Realtime Thu phóng thời gian thực + Zoom Window Thu phóng cửa sổ + error lỗi + {contributors} · {commits} {contributors} · {commits} + {kind} (constraint {id}) {kind} (ràng buộc {id}) + {kind} constraint {name} = {expression} (double-click to edit) {kind} ràng buộc {name} = {expression} (nhấp đúp để sửa) + {name}: {distance} < {angle}° {name}: {distance} < {angle}° + ƒx New User Parameter ƒx Tham số người dùng mới + ΔLOC ΔLOC + −Bin −Bin + −LOC −LOC + ⌘↩ to finish ⌘↩ để kết thúc +@plural {n} commit {n} commit +@plural {n} contributor {n} người đóng góp + Drawing as bytes Bản vẽ dưới dạng byte + Services Dịch vụ + Hide CADCraft Ẩn CADCraft + Hide Others Ẩn các ứng dụng khác + Accessibility Trợ năng + Show System Cursor Hiển thị con trỏ hệ thống + Animation Effects Hiệu ứng hoạt hình + Animation effects Hiệu ứng hoạt hình + Import... Nhập... + Export... Xuất... + Batch Publish... Xuất bản hàng loạt... + Scale List... Danh sách tỉ lệ... + Annotative Object Scale Tỉ lệ đối tượng chú thích + Add Current Scale Thêm tỉ lệ hiện hành + Delete Current Scale Xóa tỉ lệ hiện hành + Add/Delete Scales... Thêm/Xóa tỉ lệ... + Synchronize Multiple-scale Positions Đồng bộ vị trí đa tỉ lệ + Annotative Update Cập nhật chú thích + Show Annotative Objects Hiển thị đối tượng chú thích + Current Scale Only Chỉ tỉ lệ hiện hành + All Scales Tất cả tỉ lệ + Drawing Recovery Manager... Trình quản lý khôi phục bản vẽ... + Tolerance... Dung sai... + Add Vertex Thêm đỉnh + Remove Vertex Xóa đỉnh + Convert to Arc Chuyển thành cung tròn + Convert to Line Chuyển thành đoạn thẳng + Stretch Vertex Kéo giãn đỉnh + Edit Array Sửa mảng + Close Array Editing Đóng chỉnh sửa mảng + Multiline... Multiline... + Multiline Style... Kiểu multiline... + Solid Editing Chỉnh sửa solid + X X + Y Y + B B + CADCraft CADCraft + More toolbar commands Thêm lệnh thanh công cụ +menu View Xem +menu Draw Vẽ +menu Dimension Kích thước +menu Modify Hiệu chỉnh + Hatch Pattern Mẫu hatch + Search for pattern Tìm mẫu + Not a number or distance: {text} Không phải số hoặc khoảng cách: {text} + Query Texts Truy vấn chữ + Analyze Shapes Phân tích hình dạng + Check for Updates… Kiểm tra cập nhật… + CADCraft is up to date. CADCraft đã là phiên bản mới nhất. + Could not check for updates. Không thể kiểm tra cập nhật. + 3D Mouse Chuột 3D + 3D Mouse Navigation Điều hướng chuột 3D + 3D Mouse... Chuột 3D... + 3D mouse busy, possibly 3DxWare or missing device permissions Chuột 3D đang bận, có thể do 3DxWare hoặc thiếu quyền thiết bị + 3D mouse connected Đã kết nối chuột 3D + 3D mouse disconnected Đã ngắt kết nối chuột 3D + 3D mouse input could not be started Không thể khởi động đầu vào chuột 3D + 3D mouse input is unavailable on this platform Đầu vào chuột 3D không khả dụng trên nền tảng này + Automatic save every Tự động lưu mỗi + Automatic save file location Vị trí tệp tự động lưu + Calibrate Hiệu chỉnh + Calibrate 3D Mouse Hiệu chỉnh chuột 3D + Collapse Palettes Thu gọn bảng + Default save version Phiên bản lưu mặc định + Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Bản vẽ chưa có tệp sẽ được lưu theo phiên bản mặc định. 0 phút sẽ tắt tự động lưu. + Enable 3D mouse navigation Bật điều hướng chuột 3D + Left device button: zoom extents. Right device button: calibrate. Nút trái của thiết bị: thu phóng toàn bộ. Nút phải của thiết bị: hiệu chỉnh. + Move sideways to pan; push/pull or twist to zoom. Di chuyển ngang để pan; đẩy/kéo hoặc xoay để thu phóng. + No supported 3D mouse found Không tìm thấy chuột 3D được hỗ trợ + Pan sensitivity Độ nhạy pan + Release the 3D mouse to calibrate its resting position Thả chuột 3D để hiệu chỉnh vị trí nghỉ của nó + Reverse zoom direction Đảo chiều thu phóng + Run Script... Chạy script... + SVG drawings (SVG, SVGZ) Bản vẽ SVG (SVG, SVGZ) + Show and hide the tools of this category Hiện và ẩn các công cụ trong danh mục này + Show in Tool Sets Hiển thị trong bộ công cụ + Time-sensitive right-click: a quick click is Enter, holding opens the menu Nhấp phải theo thời gian: nhấp nhanh là Enter, giữ lâu để mở menu + Turned off, a right-click while a command runs always opens the shortcut menu. Khi tắt, nhấp phải trong lúc lệnh đang chạy luôn mở menu ngữ cảnh. + Zoom sensitivity Độ nhạy thu phóng + min phút + Align Multileaders Căn chỉnh multileader + Collect Gom + Collect Multileaders Gom multileader + Edit Multileader Sửa multileader + Italic Nghiêng + Underline Gạch dưới + Overline Gạch trên + Justify Căn lề + Text color Màu chữ + Click the ruler to add a tab stop, right-click to remove one Nhấp vào thước để thêm điểm dừng tab, nhấp phải để xóa + Add: Pick points Thêm: chọn điểm + Add: Select objects Thêm: chọn đối tượng + Associative Liên kết + Block to create or edit Block để tạo hoặc sửa + Bottom left Dưới trái + Bottom right Dưới phải + Boundaries Đường bao + Boundaries picked: {n} Đã chọn đường bao: {n} + Centered Căn giữa + Clear Recent Files Xóa tệp gần đây + Click to set new origin Nhấp để đặt gốc mới + Close Block Editor Đóng trình sửa block + Color 1 Màu 1 + Color 2 Màu 2 + contains authoring elements. Open in Block Editor? chứa các phần tử tạo lập. Mở trong trình sửa block? + Create separate hatches Tạo hatch riêng biệt + Crossing selection color: Màu vùng chọn cắt: + Curved Cong + Custom Tùy chỉnh + Default to boundary extents Mặc định theo phạm vi đường bao + Drag Dynamic Grip Kéo grip động + Dynamic Block Data Dữ liệu block động + Dynamic Block Grips Grip block động + Dynamic Block Reference Tham chiếu block động + Edit Block Definition Sửa định nghĩa block + Evaluate LISP Tính giá trị LISP + Exclude: Loại trừ: + Grips Grip + Groups Nhóm + Hatch and Gradient Hatch và gradient + Hatch origin Gốc hatch + Hatch Pick Chọn hatch + Hatches Hatch + Hemispherical Bán cầu + Ignore Bỏ qua + Indicate selection area Chỉ định vùng chọn + Inverted Đảo ngược + Island detection Phát hiện đảo + Load Application... Tải ứng dụng... + Multiline text Chữ nhiều dòng + New block Block mới + Object grouping Gom nhóm đối tượng + Object selection limit for display of grips Giới hạn chọn đối tượng để hiển thị grip + Objects on locked layers Đối tượng trên layer bị khóa + Open in Block Editor Mở trong trình sửa block + Open Recent Mở gần đây + Origin: {x}, {y} Gốc: {x}, {y} + Outer Ngoài + Preview Xem trước + Save Block Lưu block + Save Block As Lưu block thành + Selection Area Effect Hiệu ứng vùng chọn + Selection area opacity: Độ mờ vùng chọn: + Selection effect color Màu hiệu ứng chọn + Selection modes Chế độ chọn + Selection preview Xem trước vùng chọn + Selection Preview Filter Bộ lọc xem trước vùng chọn + Set Dynamic Property Đặt thuộc tính động + Shape Hình dạng + Sheet Books to PDF... Xuất sổ tờ vẽ sang PDF... + Show grips Hiển thị grip + Specified origin Gốc đã chỉ định + Spherical Hình cầu + Tables Bảng + This drawing Bản vẽ này + Top left Trên trái + Top right Trên phải + Unsaved changes Thay đổi chưa lưu + Use current origin Dùng gốc hiện hành + Visual Effect Settings Cài đặt hiệu ứng hình ảnh + Visual Effect Settings... Cài đặt hiệu ứng hình ảnh... + When a command is active Khi có lệnh đang hoạt động + When no command is active Khi không có lệnh nào đang hoạt động + Window selection color: Màu vùng chọn cửa sổ: + Xrefs Xref + Arrow size Kích thước mũi tên + Dim scale overall Tỉ lệ kích thước tổng thể + Precision Độ chính xác + Break-line Symbol Ký hiệu ngắt đường + Convert Text to Mtext Chuyển chữ một dòng thành chữ nhiều dòng + Explode Attributes to Text Phân rã attribute thành chữ + Express Tools Express Tools + Move/Copy/Rotate Di chuyển/Sao chép/Xoay + Type an alias name. Nhập tên bí danh. + Choose the command the alias starts. Chọn lệnh mà bí danh sẽ chạy. + {command} is an unknown command. {command} là lệnh không xác định. + {alias} is already defined; select it and use Edit. {alias} đã được định nghĩa; chọn nó và dùng nút Sửa. + More than {n} aliases. Hơn {n} bí danh. + Alias file: {file} Tệp bí danh: {file} + No alias file loaded. REINIT replaces these aliases with a file's. Chưa tải tệp bí danh nào. REINIT sẽ thay các bí danh này bằng bí danh trong tệp. + Alias Bí danh + Command Lệnh + overrides built-in ghi đè lệnh tích hợp + built-in tích hợp + Add… Thêm… + Edit… Sửa… + Save alias Lưu bí danh + Discard Bỏ + Customize Tùy chỉnh + Edit Command Aliases (PGP)... Sửa bí danh lệnh (PGP)... + Interface scale Tỉ lệ giao diện + Select boundaries to remove Chọn đường bao cần xóa + Island Đảo + Select using two clicks only Chỉ chọn bằng hai lần nhấp + Define each corner of the selection window with a separate click. Xác định từng góc của cửa sổ chọn bằng một lần nhấp riêng. + Apply to Layout Áp dụng cho layout + Center the plot Căn giữa bản in + Enter block names to extract Nhập tên block cần trích xuất + Enter geometric characteristic Nhập đặc tính hình học + Enter name of LISP file to load Nhập tên tệp LISP cần tải + First corner Góc thứ nhất + Fit to paper Vừa khổ giấy + Landscape Ngang + Limits Giới hạn + Opposite corner Góc đối diện + Orientation Hướng + Paper size Khổ giấy + Plot area Vùng in + Plot object lineweights In bề dày nét đối tượng + Plot offset Độ lệch khi in + Plot transparency In độ trong suốt + Portrait Dọc + Printer/plotter Máy in/máy vẽ + Window < Cửa sổ < + X offset (mm) Độ lệch X (mm) + Y offset (mm) Độ lệch Y (mm) + AutoLISP (LSP) AutoLISP (LSP) + Units of new drawings Đơn vị của bản vẽ mới + Imperial (inches) Hệ Anh (inch) + Metric (millimetres) Hệ mét (milimét) + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Bản vẽ mới và bản vẽ đầu tiên khi khởi động dùng các đơn vị này, trừ khi đã đặt mẫu mặc định (QNEWTEMPLATE). + Analyze Usage Phân tích sử dụng + Implied Loops Vòng kín ngầm định + Angstroms Angstrom + Architectural Kiến trúc + Astronomical Units Đơn vị thiên văn + Centimeters Centimét + Decameters Decamét + Decimal Thập phân + Decimal Degrees Độ thập phân + Decimeters Decimét + Deg/Min/Sec Độ/Phút/Giây + Don't Save Không lưu + Drawing Units Đơn vị bản vẽ + Engineering Kỹ thuật + Feet Feet + Fractional Phân số + Gigameters Gigamét + Grads Grad + Hectometers Hectomét + Inches Inch + Insertion scale Tỉ lệ chèn + Kilometers Kilomét + Light Years Năm ánh sáng + Meters Mét + Microinches Microinch + Microns Micron + Miles Dặm + Millimeters Milimét + Mils Mil + Nanometers Nanomét + Parsecs Parsec + Radians Radian + Sample output Đầu ra mẫu + Save changes to {name}? Lưu thay đổi cho {name}? + Scientific Khoa học + Surveyor's Units Đơn vị trắc địa + Unitless Không đơn vị + Units to scale inserted content Đơn vị để co giãn nội dung được chèn + US Survey Feet Feet khảo sát Mỹ + US Survey Inch Inch khảo sát Mỹ + US Survey Mile Dặm khảo sát Mỹ + US Survey Yard Yard khảo sát Mỹ + Yards Yard + Your changes will be lost if you don't save them. Các thay đổi của bạn sẽ bị mất nếu bạn không lưu chúng. + Save changes to block "{name}"? Lưu thay đổi cho block "{name}"? + Layouts Layout + Associate Liên kết + Backup copy Bản sao lưu + Create backup copy with each save Tạo bản sao lưu mỗi khi lưu + Change Properties Đổi thuộc tính + Font Phông chữ + Save Layout as Template Lưu layout thành mẫu diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d7c1c9078..bf0468d08 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -265,10 +265,15 @@ fn run_language_command(app: &mut CadApp, id: &str, params: &Value) -> Option { + "No font covers the Korean interface, so it stays in English. Install a Hangul font (Malgun Gothic or Nanum Gothic), or build with a Hang/Kore craft-fonts face." + } + "vi" => { + "No installed interface font has the Vietnamese letters, so the interface stays in English. Install one that does (Segoe UI, Arial, DejaVu Sans or Noto Sans) to use Vietnamese." + } + "ar" => "No Arabic font is installed, so the interface stays in English. Install one (Noto Sans Arabic, for example) to use Arabic.", + _ => "No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese.", }); } Some(Ok(json!({"interfaceLanguage": preference.code(), "shown": shown}))) diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index dba0814b7..27f778735 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -10,7 +10,7 @@ //! |---|---| //! | `theme` | `"system"`, `"light"` or `"dark"` | //! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling | -//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"` or `"el"` | +//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"`, `"vi"` or `"el"` | //! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings | //! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in | //! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) | diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 9aeac31a1..17409f3ed 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -326,19 +326,11 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { let chinese = language.starts_with("zh"); #[cfg(not(target_arch = "wasm32"))] { - let candidates: &[&str] = if cfg!(target_os = "macos") { - &["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf"] - } else if cfg!(windows) { - &["C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf"] - } else { - &[ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/TTF/DejaVuSans.ttf", - "/usr/share/fonts/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", - ] - }; - for path in candidates { + for path in system_ui_candidates() { + // A Vietnamese interface takes the first one with its letters. + if language == "vi" && vietnamese_font() != Some(std::path::Path::new(path)) { + continue; + } if let Ok(bytes) = std::fs::read(path) { fonts.font_data.insert("system-ui".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes))); if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) { @@ -409,6 +401,82 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { ctx.set_fonts(fonts); } +/// The system interface fonts [`install_fonts`] looks for, in order; it loads the first installed +/// one ahead of egui's bundled faces. +#[cfg(not(target_arch = "wasm32"))] +fn system_ui_candidates() -> &'static [&'static str] { + if cfg!(target_os = "macos") { + &["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf"] + } else if cfg!(windows) { + &["C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf"] + } else { + &[ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + ] + } +} + +/// The first of `candidates` that is `installed` and, for the Vietnamese interface, has the +/// Vietnamese letters: the system interface font [`install_fonts`] loads ahead of egui's faces. +#[cfg(not(target_arch = "wasm32"))] +fn ui_font_choice<'a>( + candidates: &[&'a str], + vietnamese: bool, + installed: impl Fn(&str) -> bool, + has_vietnamese: impl Fn(&str) -> bool, +) -> Option<&'a str> { + candidates.iter().copied().find(|p| installed(p) && (!vietnamese || has_vietnamese(p))) +} + +/// Whether `face` has every letter Vietnamese adds to Latin: the tone-marked vowels of Latin +/// Extended Additional (U+1EA0–U+1EF9), and ơ, ư, đ. +#[cfg(not(target_arch = "wasm32"))] +fn covers_vietnamese(face: &cadcraft_fonts::ttf::Face) -> bool { + ('\u{1ea0}'..='\u{1ef9}').chain(['ơ', 'Ơ', 'ư', 'Ư', 'đ', 'Đ']).all(|c| face.has_glyph(c)) +} + +/// The system interface font the Vietnamese interface takes: the first of +/// [`system_ui_candidates`] whose first face (the one egui uses) has every Vietnamese letter. +/// Segoe UI, Arial, SF, DejaVu Sans and Noto Sans do; egui's bundled faces stop at Latin +/// Extended-A, so without one the interface stays English (as the web build does). Checked once. +pub fn vietnamese_font() -> Option<&'static std::path::Path> { + #[cfg(not(target_arch = "wasm32"))] + { + static FOUND: std::sync::OnceLock> = std::sync::OnceLock::new(); + FOUND + .get_or_init(|| { + let has_vietnamese = |p: &str| { + read_capped(std::path::Path::new(p), MAX_UI_FONT_BYTES) + .and_then(|bytes| cadcraft_fonts::ttf::faces(&std::sync::Arc::new(bytes)).into_iter().next()) + .is_some_and(|face| covers_vietnamese(&face)) + }; + ui_font_choice(system_ui_candidates(), true, |p| std::path::Path::new(p).is_file(), has_vietnamese).map(std::path::PathBuf::from) + }) + .as_deref() + } + #[cfg(target_arch = "wasm32")] + None +} + +/// Whether the interface in `language` shows the Vietnamese letters (Tiếng Việt in the language +/// picker): the Vietnamese interface with [`vietnamese_font`], any other when the system interface +/// font it loads is that one. +pub fn shows_vietnamese(language: &str) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + let loaded = || ui_font_choice(system_ui_candidates(), false, |p| std::path::Path::new(p).is_file(), |_| true); + vietnamese_font().is_some_and(|v| language == "vi" || loaded().is_some_and(|p| std::path::Path::new(p) == v)) + } + #[cfg(target_arch = "wasm32")] + { + let _ = language; + false + } +} + /// The faces `build.rs` embedded from craft-fonts that the interface loads, in order: every CJK /// face, because file, layout and layer names can be Japanese or Chinese whatever the interface /// shows (#513), with the interface's own face leading. A sans-serif face reads best, and a @@ -659,10 +727,12 @@ pub fn shows_hangul(language: &str) -> bool { /// Whether the interface can show `language` without missing-glyph boxes: Chinese needs a font /// with its glyphs (installed, or the CJK face this build embedded from craft-fonts), Korean a -/// font with Hangul, العربية an installed Arabic font or the embedded Arabic face. +/// font with Hangul, العربية an installed Arabic font or the embedded Arabic face, Tiếng Việt a +/// system interface font with its tone-marked letters ([`vietnamese_font`]). pub fn can_show(language: &str) -> bool { match language { "ko" => shows_hangul("ko"), + "vi" => vietnamese_font().is_some(), "ar" => arabic_font().is_some() || embedded_script("Arab"), l if l.starts_with("zh") => cjk_font().is_some() || embedded_script("Hans") || embedded_script("Hant"), _ => true, @@ -737,6 +807,18 @@ pub fn apply(ctx: &egui::Context, theme: Theme, follow_system: bool) { #[cfg(test)] mod tests { + /// The system interface font loaded: the first installed one, and for Vietnamese the first + /// installed one with its letters (a later one when the first lacks them). + #[test] + fn the_vietnamese_interface_takes_the_first_installed_font_with_its_letters() { + let candidates = ["a.ttf", "b.ttf", "c.ttf", "d.ttf"]; + let installed = |p: &str| p != "a.ttf"; + let has_vietnamese = |p: &str| p == "c.ttf" || p == "d.ttf"; + assert_eq!(super::ui_font_choice(&candidates, false, installed, has_vietnamese), Some("b.ttf")); + assert_eq!(super::ui_font_choice(&candidates, true, installed, has_vietnamese), Some("c.ttf")); + assert_eq!(super::ui_font_choice(&candidates, true, installed, |_| false), None); + } + use cadcraft_fonts::CraftFont; /// A face shaped like `build.rs` writes them, with no font file (as in `cadcraft-fonts`). From 931c0c711aecf8aa1766c3157f0c87f5bd52b090 Mon Sep 17 00:00:00 2001 From: Tolga Date: Sat, 31 Oct 2026 17:33:07 +0300 Subject: [PATCH 070/151] Turkish catalog: fix wrong registers and resolve term collisions (#2536) * Turkish catalog: fix wrong registers and resolve term collisions Proofreading by a native speaker. 32 rows, wording only: - From/To were e-mail words (Kimden/Kime) -> Kaynak/Hedef - ViewCube Home was Giris (login) -> Baslangic - untranslated "or" in the zoom scale prompt -> veya - BREAK vs Cut both "Kes" -> BREAK family uses "Kir" - TRIM vs CLIP both "Kirp" -> TRIM uses "Buda" - spline vs curve both "egri" -> spline stays "spline" - plot style/preview/window said "Cizim X" (= drawing) -> "Cizdirme X" - Elevation vs Height both "Yukseklik" -> Elevation is "Kot" Co-Authored-By: Claude Fable 5 * Add GULERTOLGA to contributors Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- contributors/people.toml | 3 ++ crates/ui-egui/src/i18n/tr.tsv | 64 +++++++++++++++++----------------- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index cd1925e36..fc3f7a2a1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -96,3 +96,6 @@ sync_github_name = false [people.dylanmtaylor] sync_github_name = true + +[people.GULERTOLGA] +sync_github_name = true diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 11369e751..2bf979f85 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -130,8 +130,8 @@ Boundary Sınır Boundary... Sınır... Box Kutu - Break Kes - Break At Point Noktadan Kes + Break Kır + Break At Point Noktadan Kır Bring Above Objects Nesnelerin Üstüne Getir Bring Annotations to Front Açıklamaları Öne Getir Bring to Front En Öne Getir @@ -281,7 +281,7 @@ Diameter Çap Dim style Ölçü stili Dimension Ölçü - Dimension Break Ölçü Kesme + Dimension Break Ölçü Kırma Dimension Edit Ölçü Düzenleme Dimension Objects Only Yalnızca Ölçülendirme Nesneleri Dimension Space Ölçü Aralığı @@ -295,7 +295,7 @@ Disassociate Dimensions Ölçülerin İlişkisini Kaldır Display Görüntü Display name Görünen ad - Display plot styles Çizim stillerini göster + Display plot styles Çizdirme stillerini göster Distance Uzaklık Divide Böl Do not plot (click: plot) Çizdirme (tıklayın: çizdir) @@ -333,7 +333,7 @@ Edit Reference In-Place Referansı Yerinde Düzenle Edit Table Cell Tablo Hücresini Düzenle Edit Text... Metni Düzenle... - Elevation Yükseklik + Elevation Kot Ellipse Elips Elliptical Arc Eliptik Yay Enable 3D mouse navigation 3B fare ile gezinmeyi etkinleştir @@ -360,7 +360,7 @@ Enter constraint type Kısıtlama türünü girin Enter continuity Süreklilik girin Enter default attribute value Varsayılan öznitelik değerini girin - Enter degree of spline Eğrinin derecesini girin + Enter degree of spline Spline derecesini girin Enter delta angle Açı farkını girin Enter delta length or Uzunluk farkını girin veya Enter dimension text Ölçü metnini girin @@ -427,7 +427,7 @@ Freeze/Thaw Dondur/Çöz Freeze/Thaw in all viewports Tüm görünüm pencerelerinde dondur/çöz Freeze/Thaw in the current viewport Geçerli görünüm penceresinde dondur/çöz - From Kimden + From Kaynak From Template... Şablondan... General Genel Geometric Center Geometrik Merkez @@ -487,7 +487,7 @@ Hide Palettes Paletleri Gizle History Geçmiş Hold Shift to add to the selection Seçime eklemek için Shift'i basılı tutun - Home Giriş + Home Başlangıç Horizontal Yatay HorizontalDistance Yatay Uzaklık How to apply: Uygulama biçimi: @@ -677,7 +677,7 @@ No contributor data was built into this copy. Bu kopyaya katkıda bulunan verisi eklenmemiş. No external references are attached. İliştirilmiş dış referans yok. No model credits were built into this copy. Bu kopyaya model emeği bilgisi eklenmemiş. - No plot style tables. Çizim stili tablosu yok. + No plot style tables. Çizdirme stili tablosu yok. No saved layer states Kayıtlı katman durumu yok No selection Seçim yok No supported 3D mouse found Desteklenen 3B fare bulunamadı @@ -763,16 +763,16 @@ Pickbox size Seçim kutusu boyutu Plot Çizdir Plot (click: do not plot) Çizdir (tıklayın: çizdirme) - Plot Preview Çizim Önizlemesi - Plot Style Manager Çizim Stili Yöneticisi - Plot Style Manager... Çizim Stili Yöneticisi... - Plot Style... Çizim Stili... - Plot Styles... Çizim Stilleri... - Plot Window Çizim Penceresi - Plot style Çizim stili - Plot style attached to Çizim stilinin bağlı olduğu yer - Plot style table Çizim stili tablosu - Plot table type Çizim tablosu türü + Plot Preview Çizdirme Önizlemesi + Plot Style Manager Çizdirme Stili Yöneticisi + Plot Style Manager... Çizdirme Stili Yöneticisi... + Plot Style... Çizdirme Stili... + Plot Styles... Çizdirme Stilleri... + Plot Window Çizdirme Penceresi + Plot style Çizdirme stili + Plot style attached to Çizdirme stilinin bağlı olduğu yer + Plot style table Çizdirme stili tablosu + Plot table type Çizdirme tablosu türü Plotter Manager Çizici Yöneticisi Plotter Manager... Çizici Yöneticisi... Plus/minus Artı/eksi @@ -913,7 +913,7 @@ Select a block reference. Bir blok referansı seçin. Select a circle or arc. Bir daire veya yay seçin. Select a closed polyline Kapalı bir çoklu çizgi seçin - Select a closed polyline, circle, ellipse or closed spline. Kapalı bir çoklu çizgi, daire, elips veya kapalı eğri seçin. + Select a closed polyline, circle, ellipse or closed spline. Kapalı bir çoklu çizgi, daire, elips veya kapalı spline seçin. Select a closed polyline. Kapalı bir çoklu çizgi seçin. Select a curve as the path. Yol olarak bir eğri seçin. Select a dimension constraint type Bir ölçü kısıtlaması türü seçin @@ -922,7 +922,7 @@ Select a line or an arc. Bir çizgi veya yay seçin. Select a line or polyline segment Bir çizgi veya çoklu çizgi parçası seçin Select a line, arc or open polyline. Bir çizgi, yay veya açık çoklu çizgi seçin. - Select a line, arc, circle, polyline, ellipse or spline as the path. Yol olarak bir çizgi, yay, daire, çoklu çizgi, elips veya eğri seçin. + Select a line, arc, circle, polyline, ellipse or spline as the path. Yol olarak bir çizgi, yay, daire, çoklu çizgi, elips veya spline seçin. Select a line, circle, arc or polyline segment. Bir çizgi, daire, yay veya çoklu çizgi parçası seçin. Select a line. Bir çizgi seçin. Select a polyline. Bir çoklu çizgi seçin. @@ -935,7 +935,7 @@ Select an object to measure or Ölçülecek nesneyi seçin veya Select an object. Bir nesne seçin. Select an open curve. Açık bir eğri seçin. - Select an open line, arc, polyline, ellipse arc or spline. Açık bir çizgi, yay, çoklu çizgi, elips yayı veya eğri seçin. + Select an open line, arc, polyline, ellipse arc or spline. Açık bir çizgi, yay, çoklu çizgi, elips yayı veya spline seçin. Select arc or circle Yay veya daire seçin Select arc or polyline arc segment Yay veya çoklu çizgi yay parçası seçin Select arc style Yay stilini seçin @@ -952,10 +952,10 @@ Select object Nesne seçin Select object to dimension Ölçülendirilecek nesneyi seçin Select object to divide Bölünecek nesneyi seçin - Select object to extend or shift-select to trim Uzatılacak nesneyi seçin ya da kırpmak için Shift ile seçin + Select object to extend or shift-select to trim Uzatılacak nesneyi seçin ya da budamak için Shift ile seçin Select object to measure Ölçülecek nesneyi seçin Select object to offset Ötelenecek nesneyi seçin - Select object to trim or shift-select to extend Kırpılacak nesneyi seçin ya da uzatmak için Shift ile seçin + Select object to trim or shift-select to extend Budanacak nesneyi seçin ya da uzatmak için Shift ile seçin Select objects Nesneleri seçin Select objects (crossing window first corner) Nesneleri seçin (kesişen pencerenin ilk köşesi) Select objects first, then choose a command Önce nesneleri seçin, sonra bir komut belirleyin @@ -971,7 +971,7 @@ Select second object İkinci nesneyi seçin Select second object or shift-select to apply corner İkinci nesneyi seçin ya da köşe uygulamak için Shift ile seçin Select second point İkinci noktayı seçin - Select spline Eğri seçin + Select spline Spline seçin Select symmetry line Simetri çizgisini seçin Select two points İki nokta seçin Selection Seçim @@ -1057,7 +1057,7 @@ Specify center point of array Dizinin merkez noktasını belirtin Specify center point of base Tabanın merkez noktasını belirtin Specify corner of viewport Görünüm penceresinin köşesini belirtin - Specify corner of window, enter a scale factor (nX or nXP) Pencerenin köşesini belirtin, bir ölçek faktörü girin (nX or nXP) + Specify corner of window, enter a scale factor (nX or nXP) Pencerenin köşesini belirtin, bir ölçek faktörü girin (nX veya nXP) Specify diameter of circle Dairenin çapını belirtin Specify dimension arc line location Ölçü yayı çizgisinin konumunu belirtin Specify dimension line location Ölçü çizgisinin konumunu belirtin @@ -1125,7 +1125,7 @@ Specify rotation angle or Döndürme açısını belirtin veya Specify scale factor Ölçek faktörünü belirtin Specify second angle endpoint Açının ikinci uç noktasını belirtin - Specify second break point İkinci kesme noktasını belirtin + Specify second break point İkinci kırma noktasını belirtin Specify second chamfer distance for rectangles Dikdörtgenler için ikinci pah uzaklığını belirtin Specify second constraint point İkinci kısıtlama noktasını belirtin Specify second destination point İkinci hedef noktayı belirtin @@ -1148,7 +1148,7 @@ Specify starting width Başlangıç genişliğini belirtin Specify starting width for next segment Sonraki parçanın başlangıç genişliğini belirtin Specify tangent direction for the start point of arc Yayın başlangıç noktası için teğet yönünü belirtin - Specify the elevation for rectangles Dikdörtgenler için yüksekliği belirtin + Specify the elevation for rectangles Dikdörtgenler için kotu belirtin Specify thickness for rectangles Dikdörtgenler için kalınlığı belirtin Specify third point on circle Daire üzerindeki üçüncü noktayı belirtin Specify third point on circle: _tan to Daire üzerindeki üçüncü noktayı belirtin: _tan to @@ -1161,8 +1161,8 @@ Specify width for rectangles Dikdörtgenler için genişliği belirtin Sphere Küre Spherical Küresel - Spline Eğri - Spline Edit Eğri Düzenle + Spline Spline + Spline Edit Spline Düzenle Stack (type 1/2 then select) Yığ (1/2 yazıp seçin) Standard Standart Start Başlangıç @@ -1218,7 +1218,7 @@ This drawing Bu çizim Time Zaman Time-sensitive right-click: a quick click is Enter, holding opens the menu Süreye bağlı sağ tıklama: kısa tıklama Enter'dır, basılı tutmak menüyü açar - To Kime + To Hedef To get started, create or open a drawing. Başlamak için bir çizim oluşturun veya açın. Toggle PICKADD (off: each pick replaces the selection) PICKADD'i değiştir (kapalı: her seçim öncekinin yerini alır) Toggle PICKADD (on: picks add to the selection) PICKADD'i değiştir (açık: seçimler mevcut seçime eklenir) @@ -1233,7 +1233,7 @@ Track orthogonally only Yalnızca dik açılarla izle Track using all polar angle settings Tüm kutupsal açı ayarlarını kullanarak izle Transparency Saydamlık - Trim Kırp + Trim Buda True Color Gerçek Renk Turn All Layers On Tüm Katmanları Aç Turn Off Layer Katmanı Kapat From b6331c8603f9cc51b8044237f4517f79da1932b6 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:34:44 +0700 Subject: [PATCH 071/151] -ARRAY: the classic command-line array, as AutoCAD lays it out (#2538) * Add -ARRAY, the classic command-line array, as AutoCAD lays it out Rectangular arrays move the copy in row r and column c by (c * column distance, r * row distance), asking for a distance only when there is more than one row or column; polar arrays space n items 360/n apart around a full circle and fill/(n - 1) otherwise, turning the copies or moving them with the group 10 point of the object selected last. The copies are separate objects; the type asked defaults to the one used last. Co-Authored-By: Claude Opus 5.5 * -ARRAY: refuse an unknown type and oversized arrays before making transforms The JSON form errors on a type other than rectangular or polar, and checks MAXARRAY before the copies' transforms are made; the command line bounds the columns by the rows (CodeRabbit). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/array.rs | 7 +- crates/engine/src/cmd/array/classic.rs | 255 ++++++++++++++++++++++++ crates/engine/src/last_used.rs | 3 + crates/engine/src/session.rs | 2 + crates/engine/src/session/picking.rs | 2 +- crates/engine/tests/it/classic_array.rs | 194 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 7 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/cmd/array/classic.rs create mode 100644 crates/engine/tests/it/classic_array.rs diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 9d4a2fd9c..255555a28 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -1,4 +1,5 @@ -//! Array commands: ARRAY, ARRAYRECT and ARRAYPOLAR (ARRAYPATH lives in `modify2`). +//! Array commands: ARRAY, ARRAYRECT, ARRAYPOLAR and the classic -ARRAY (ARRAYPATH lives in +//! `modify2`). //! //! Arrays are associative by default (ARRAYASSOCIATIVITY): the source objects move into an //! anonymous source block and the array becomes one block reference to an anonymous array block @@ -21,6 +22,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +mod classic; mod edit; /// Most preview entities drawn while the array is edited. @@ -48,6 +50,9 @@ pub fn specs() -> Vec { "{handles?, center, count, angle? (degrees, default 360), rotate?: bool, rows? (1), rowSpacing?, rowElevation?, levels? (1), levelSpacing?, base? ([x, y], default the first object's centre), associative?: bool (default ARRAYASSOCIATIVITY, on)} → {handle (the array, when associative), created (copies)}", ) .interactive(|_| Ok(Box::new(PolarM::default()))), + CommandSpec::new("-array", "Array", classic::run_classic) + .params("{handles?, type?: \"rectangular\" (default) | \"polar\", rows? (1), cols? (1), rowSpacing?, colSpacing?, center, count, angle? (degrees, default 360), rotate?: bool (default true), base? ([x, y], default the DXF group 10 point of the last object)} → {created}: separate copies, as the classic -ARRAY makes them") + .interactive(|_| Ok(Box::new(classic::ClassicM::default()))), ] .into_iter() .chain(edit::specs()) diff --git a/crates/engine/src/cmd/array/classic.rs b/crates/engine/src/cmd/array/classic.rs new file mode 100644 index 000000000..5fecefa2a --- /dev/null +++ b/crates/engine/src/cmd/array/classic.rs @@ -0,0 +1,255 @@ +//! -ARRAY: the classic command-line array, as AutoCAD 2024 runs it from AutoLISP's `command` +//! (accoreconsole, every copy read back with `entget`). It makes separate copies, never an +//! associative array: +//! - "Enter the type of array [Rectangular/Polar] ": the default is the type used last; +//! - Rectangular asks "Enter the number of rows (---) <1>" and "Enter the number of columns (|||) +//! <1>", then "Enter the distance between rows or specify unit cell (---)" only for more than one +//! row (two corners give both distances) and "Specify the distance between columns (|||)" only for +//! more than one column. The copy in row r and column c moves by (c·columns, r·rows): 2 rows and +//! 2 columns at −12.57 and −18.01 put the copies below and to the left; +//! - Polar asks "Specify center point of array or [Base]", "Enter the number of items in the +//! array", "Specify the angle to fill (+=ccw, -=cw) <360>" and "Rotate arrayed objects? +//! [Yes/No] ". Items are 360/n apart around a full circle, else fill/(n − 1) apart (6 items in +//! 270°: 54°; −90° turns clockwise). Not rotated, each copy moves as the DXF group 10 point of the +//! object selected last turns about the centre (a polyline's first vertex, an arc's centre), or +//! as the Base point does. + +use cadcraft_doc::Handle; +use cadcraft_geom::{Mat3, Vec2}; +use serde_json::{Value, json}; + +use super::super::machines::{SelOutcome, SelectPhase, number}; +use super::super::{bad, bool_or, f64_or, point_param, point_req, str_param, targets}; +use super::{KEYWORDS, angle, check_size, copy_all, count, count_or, max_count, other, yes_no}; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Stage { + #[default] + Select, + Kind, + Rows, + Columns, + RowDistance, + /// The unit cell's other corner, after the first one. + Corner(Vec2), + ColumnDistance, + Center, + Base, + Items, + Fill, + Rotate, +} + +#[derive(Default)] +pub(super) struct ClassicM { + sel: SelectPhase, + objs: Vec, + stage: Stage, + rows: u64, + columns: u64, + row_distance: f64, + center: Vec2, + base: Option, + items: u64, + fill: f64, +} + +/// The moves of a rectangular array's copies (the objects themselves stay in row 0, column 0). +fn rect_mats(rows: u64, columns: u64, row_distance: f64, column_distance: f64) -> Vec { + (0..rows) + .flat_map(|r| (0..columns).map(move |c| (r, c))) + .filter(|rc| *rc != (0, 0)) + .map(|(r, c)| Mat3::translate(Vec2::new(c as f64 * column_distance, r as f64 * row_distance))) + .collect() +} + +/// The transforms of a polar array's copies: `items` around `center` filling `fill` degrees, +/// turned with it or moved as `base` turns. +fn polar_mats(center: Vec2, items: u64, fill: f64, rotate: bool, base: Vec2) -> Vec { + let full = (fill.abs() - 360.0).abs() < 1e-9; + let gaps = if full { items } else { items.saturating_sub(1) }.max(1); + let step = fill.to_radians() / gaps as f64; + (1..items) + .map(|k| { + let a = step * k as f64; + if rotate { Mat3::rotate_about(center, a) } else { Mat3::translate(base.rotate_about(center, a) - base) } + }) + .collect() +} + +/// The DXF group 10 point of the last of `hs`, which a polar array that doesn't rotate its copies +/// moves around the centre. +fn last_point(s: &Session, hs: &[Handle]) -> Option { + let d = s.doc().ok()?; + hs.last().and_then(|h| d.entity(*h)).and_then(|e| crate::session::group_10(d, e)) +} + +/// -ARRAY's JSON form. +pub(super) fn run_classic(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let polar = match str_param(p, "type").map(str::to_ascii_lowercase).as_deref() { + None | Some("rectangular") => false, + Some("polar") => true, + Some(_) => return Err(bad("-array", "type must be \"rectangular\" or \"polar\"")), + }; + // The size is checked before the copies' transforms are made. + let mats = if polar { + let center = point_req("-array", p, "center")?; + let items = count_or("-array", p, "count", 1)?; + check_size(s, items, hs.len())?; + let base = point_param(p, "base").or_else(|| last_point(s, &hs)).unwrap_or(center); + polar_mats(center, items, f64_or(p, "angle", 360.0), bool_or(p, "rotate", true), base) + } else { + let rows = count_or("-array", p, "rows", 1)?; + let columns = count_or("-array", p, "cols", 1)?; + check_size(s, rows.saturating_mul(columns), hs.len())?; + rect_mats(rows, columns, f64_or(p, "rowSpacing", 0.0), f64_or(p, "colSpacing", 0.0)) + }; + let created = copy_all(s, &hs, mats.into_iter())?; + Ok(json!({ "created": created })) +} + +impl ClassicM { + /// Make the copies. + fn finish(&mut self, s: &mut Session, mats: Vec) -> Result { + check_size(s, mats.len() as u64 + 1, self.objs.len())?; + copy_all(s, &self.objs, mats.into_iter())?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + + /// After the rows and columns: the distances still needed, or the copies. + fn after_counts(&mut self, s: &mut Session) -> Result { + if self.rows > 1 { + self.stage = Stage::RowDistance; + } else if self.columns > 1 { + self.stage = Stage::ColumnDistance; + } else { + return self.finish(s, Vec::new()); + } + Ok(Step::Continue) + } + + fn rect(&mut self, s: &mut Session, column_distance: f64) -> Result { + let mats = rect_mats(self.rows, self.columns, self.row_distance, column_distance); + self.finish(s, mats) + } +} + +impl Interactive for ClassicM { + fn name(&self) -> &'static str { + "-ARRAY" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + self.stage = Stage::Kind; + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match self.stage { + Stage::Select => self.sel.prompt(), + Stage::Kind => Prompt::new("Enter the type of array", KEYWORDS) + .kw(&["Rectangular", "Polar"]) + .default(if s.last_used.classic_array_polar { "P" } else { "R" }), + Stage::Rows => Prompt::new("Enter the number of rows (---)", Accept::NUMBER).default("1"), + Stage::Columns => Prompt::new("Enter the number of columns (|||)", Accept::NUMBER).default("1"), + Stage::RowDistance => Prompt::new("Enter the distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER), + Stage::Corner(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a), + Stage::ColumnDistance => Prompt::new("Specify the distance between columns (|||)", Accept::NUMBER), + Stage::Center => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base"]), + Stage::Base => Prompt::new("Specify base point of objects", Accept::POINT), + Stage::Items => Prompt::new("Enter the number of items in the array", Accept::NUMBER), + Stage::Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default("360"), + Stage::Rotate => Prompt::new("Rotate arrayed objects?", KEYWORDS).kw(&["Yes", "No"]).default("Y"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let max = max_count(s, 1, self.objs.len()); + match (self.stage, i) { + (Stage::Select, i) => match self.sel.feed(s, &i)? { + SelOutcome::More => {} + SelOutcome::Done(hs) => { + self.objs = hs; + self.stage = Stage::Kind; + } + SelOutcome::Empty => return Ok(Step::Done), + }, + (Stage::Kind, i) => { + let polar = match &i { + Input::Enter => s.last_used.classic_array_polar, + Input::Keyword(k) | Input::Text(k) => match k.trim().trim_start_matches('_').to_ascii_lowercase().chars().next() { + Some('p') => true, + Some('r') => false, + _ => return Err(other("Invalid option keyword.")), + }, + _ => return Ok(Step::Continue), + }; + s.last_used.classic_array_polar = polar; + self.stage = if polar { Stage::Center } else { Stage::Rows }; + } + (Stage::Rows, i) => { + if let Some(n) = count(&i, 1, max)? { + self.rows = n; + self.stage = Stage::Columns; + } + } + // No more columns than MAXARRAY allows with the rows. + (Stage::Columns, i) => { + if let Some(n) = count(&i, 1, max_count(s, self.rows, self.objs.len()))? { + self.columns = n; + return self.after_counts(s); + } + } + (Stage::RowDistance, Input::Point(a)) => self.stage = Stage::Corner(a), + (Stage::RowDistance, Input::Text(t)) => { + self.row_distance = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?; + if self.columns <= 1 { + return self.rect(s, 0.0); + } + self.stage = Stage::ColumnDistance; + } + // A unit cell gives both distances. + (Stage::Corner(a), Input::Point(b)) => { + self.row_distance = b.y - a.y; + return self.rect(s, b.x - a.x); + } + (Stage::ColumnDistance, Input::Text(t)) => { + let d = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?; + return self.rect(s, d); + } + (Stage::Center, Input::Point(c)) => { + self.center = c; + self.stage = Stage::Items; + } + (Stage::Center, Input::Keyword(k)) if k == "Base" => self.stage = Stage::Base, + (Stage::Base, Input::Point(b)) => { + self.base = Some(b); + self.stage = Stage::Center; + } + (Stage::Items, i @ Input::Text(_)) => { + if let Some(n) = count(&i, 1, max)? { + self.items = n; + self.stage = Stage::Fill; + } + } + (Stage::Fill, i) => { + if let Some(a) = angle(&i, 360.0, &s.angle_settings())? { + self.fill = a; + self.stage = Stage::Rotate; + } + } + (Stage::Rotate, i) => { + let Some(rotate) = yes_no(&i, true) else { return Err(other("Invalid option keyword.")) }; + let base = self.base.or_else(|| last_point(s, &self.objs)).unwrap_or(self.center); + let mats = polar_mats(self.center, self.items, self.fill, rotate, base); + return self.finish(s, mats); + } + _ => {} + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/last_used.rs b/crates/engine/src/last_used.rs index 88a1d4f59..e884caf1b 100644 --- a/crates/engine/src/last_used.rs +++ b/crates/engine/src/last_used.rs @@ -25,6 +25,8 @@ pub struct LastUsed { pub mleader_block: String, /// BREAKLINE's Block, Size and Extension. pub breakline: cmd::BreaklineOptions, + /// -ARRAY's type: Polar instead of Rectangular. + pub classic_array_polar: bool, /// RECTANG's chamfer, fillet, width, elevation, thickness and rotation. pub rectang: RectangModes, } @@ -41,6 +43,7 @@ impl Default for LastUsed { mleader: MLeaderOptions::default(), mleader_block: String::new(), breakline: cmd::BreaklineOptions::default(), + classic_array_polar: false, rectang: RectangModes::default(), } } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index d1ae67ee1..f465b7a55 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -13,3 +13,5 @@ mod files; mod interactive; mod picking; mod zoom; + +pub(crate) use picking::group_10; diff --git a/crates/engine/src/session/picking.rs b/crates/engine/src/session/picking.rs index a9d146571..1ab773a00 100644 --- a/crates/engine/src/session/picking.rs +++ b/crates/engine/src/session/picking.rs @@ -162,7 +162,7 @@ impl Session { /// An object's DXF group 10 point: the first point of its entity list, or its first grip where /// entity lists are not built in. -fn group_10(d: &Drawing, e: &Entity) -> Option { +pub(crate) fn group_10(d: &Drawing, e: &Entity) -> Option { let Some(codec) = cmd::file::entity_codec() else { return e.kind.grips().first().copied() }; let (_, tags) = (codec.groups)(d, e); let i = tags.iter().position(|t| t.code == 10)?; diff --git a/crates/engine/tests/it/classic_array.rs b/crates/engine/tests/it/classic_array.rs new file mode 100644 index 000000000..8f74f8f48 --- /dev/null +++ b/crates/engine/tests/it/classic_array.rs @@ -0,0 +1,194 @@ +//! -ARRAY, the classic command-line array, as AutoCAD 2024 runs it from AutoLISP's `command` +//! (accoreconsole, every copy read back with `entget`). + +use std::f64::consts::TAU; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +/// A session with the object `made` (an entity list) as `a` in LISP, after `code`. +fn run(made: &str, code: &str) -> Session { + // `entmake` goes through the DXF codec, as in the app. + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + let mut s = Session::new(); + s.cmdline(&format!("(progn (setq a (progn (entmake '({made})) (entlast))) {code})")).unwrap(); + s +} + +/// Where each object is: a line's start, a circle's or arc's centre, a polyline's first vertex, a +/// text's insertion point. +fn points(s: &Session) -> Vec<[f64; 2]> { + s.doc() + .unwrap() + .model + .iter() + .map(|e| match &e.kind { + EntityKind::Line(l) => [l.a.x, l.a.y], + EntityKind::Circle(c) => [c.center.x, c.center.y], + EntityKind::Arc(a) => [a.center.x, a.center.y], + EntityKind::LwPolyline(p) => [p.vertices[0].p.x, p.vertices[0].p.y], + EntityKind::Text(t) => [t.insert.x, t.insert.y], + k => panic!("unexpected {k:?}"), + }) + .collect() +} + +/// `got` holds each of `want` to `tol`, and nothing else. +fn same(got: &[[f64; 2]], want: &[[f64; 2]], tol: f64) -> bool { + got.len() == want.len() && want.iter().all(|w| got.iter().any(|g| (g[0] - w[0]).abs() <= tol && (g[1] - w[1]).abs() <= tol)) +} + +fn line(a: [f64; 2], b: [f64; 2]) -> String { + format!("(0 . \"LINE\") (10 {} {} 0.0) (11 {} {} 0.0)", a[0], a[1], b[0], b[1]) +} + +fn circle(c: [f64; 2], r: f64) -> String { + format!("(0 . \"CIRCLE\") (10 {} {} 0.0) (40 . {r})", c[0], c[1]) +} + +fn arc(c: [f64; 2], r: f64, a1: f64, a2: f64) -> String { + format!("(0 . \"ARC\") (10 {} {} 0.0) (40 . {r}) (50 . {}) (51 . {})", c[0], c[1], a1.to_radians(), a2.to_radians()) +} + +/// The measured polyline: four vertices with bulges 0.3, 0, −0.7, 0, from `at`, `scale` × the +/// drawn size, `width` wide. +fn polyline(at: [f64; 2], scale: f64, width: f64) -> String { + let v = [(0.0, 0.0, 0.3), (20.0, 5.0, 0.0), (35.0, -10.0, -0.7), (50.0, 0.0, 0.0)]; + let vs: String = v.iter().map(|(x, y, b)| format!(" (10 {} {}) (42 . {b})", at[0] + x * scale, at[1] + y * scale)).collect(); + format!("(0 . \"LWPOLYLINE\") (90 . 4) (70 . 0) (43 . {width}){vs}") +} + +#[test] +fn rectangular_copies_move_by_rows_and_columns() { + let s = run( + &line([-1.1929712440222175, 9.67888030686079], [44.51297824124873, 11.673136668914886]), + "(command \"_.-ARRAY\" a \"\" \"_R\" 2 2 -12.570757111654931 -18.0084920904546)", + ); + let want = [ + [-19.20146333447681, -2.891876804794138], + [-19.20146333447681, 9.67888030686079], + [-1.192971244022217, -2.891876804794138], + [-1.192971244022217, 9.67888030686079], + ]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); + // One row or one column: that distance isn't asked for. + let s = run(&circle([5.801226536663419, -5.877116775641122], 23.343762414841144), "(command \"_.-ARRAY\" a \"\" \"_R\" 1 3 -26.90504352387681)"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let want = [[-48.0088605110902, -5.877116775641122], [-21.10381698721339, -5.877116775641122], [5.801226536663419, -5.877116775641122]]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); + let s = run(&polyline([0.5, 0.25], 0.001, 0.0005), "(command \"_.-ARRAY\" a \"\" \"_R\" 3 1 -0.010095474538067209)"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(same(&points(&s), &[[0.5, 0.2298090509238659], [0.5, 0.239904525461933], [0.5, 0.25]], 1e-12), "{:?}", points(&s)); + let s = run( + "(0 . \"TEXT\") (10 5.4185272544331635 4.443702697220587 0.0) (40 . 2.5) (1 . \"AB12\") (50 . 0.20972899)", + "(command \"_.-ARRAY\" a \"\" \"_R\" 1 4 26.03962020976745)", + ); + let want = [ + [83.53738788373552, 4.443702697220587], + [57.49776767396807, 4.443702697220587], + [31.45814746420061, 4.443702697220587], + [5.418527254433163, 4.443702697220587], + ]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); +} + +#[test] +fn polar_copies_turn_about_the_centre() { + // Five items around a full circle: 72° apart. + let s = run( + &arc([8.737608057965836, 8.018370437941456], 25.76749991913238, 246.54512163341687, 4.3030133419271355), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(10.631794214160564 -5.07952370466397) 5 360 \"_Y\")", + ); + let want = [ + [22.50329607545565, 0.769426262850251], + [19.86297201814329, -14.56256796884801], + [4.465473991920418, -16.78931734426366], + [-2.410379072682379, -2.833529910999876], + [8.737608057965836, 8.018370437941456], + ]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); + let starts: Vec = + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Arc(a) = &e.kind { Some(a.start) } else { None }).collect(); + for want in [3.046387065797107, 1.789750004361188, 0.5331129429252733, 5.559661188668941, 4.303024127233024] { + assert!(starts.iter().any(|a| ((a - want).rem_euclid(TAU) + 1e-9).rem_euclid(TAU) < 2e-9), "{want}: {starts:?}"); + } + // Six items in 270°: 54° apart. + let s = run( + &line([14.349623930607876, -18.237839127538827], [57.5895083326729, 8.515990272720988]), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(9.968618268950948 -10.513614962917108) 6 270 \"_Y\")", + ); + let want = [ + [2.244394104329227, -14.89462062457403], + [1.884125186851836, -6.839676863508278], + [8.18895082173591, -1.813636035924282], + [15.96098679213407, -3.960114445242588], + [18.79273740462331, -11.50949197954958], + [14.34962393060787, -18.23783912753882], + ]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); +} + +#[test] +fn polar_copies_not_rotated_move_with_the_group_10_point() { + // A polyline moves with its first vertex: three items in 270°. + let s = run( + &polyline([612345.678, 2398765.432], 1.0, 0.5), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(612362.9804954464 2398768.822523769) 3 270 \"_N\")", + ); + let want = [[612359.5899716774, 2398786.125019215], [612377.612669657, 2398758.985274256], [612345.678, 2398765.432]]; + assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s)); + // A circle with its centre, five items in −90° (clockwise). + let s = run( + &circle([612340.715727557, 2398755.830080357], 26.60109393014347), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(612346.260090821 2398771.804712544) 5 -90 \"_N\")", + ); + let want = [ + [612330.2854586337, 2398777.349075808], + [612329.3797191395, 2398770.813809208], + [612331.0438632132, 2398764.429398659], + [612335.0245400043, 2398759.16781279], + [612340.715727557, 2398755.830080357], + ]; + assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s)); + // An arc with its centre, its angles unchanged. + let s = run( + &arc([612339.185864084, 2398766.23253733], 13.970352776505141, 238.67958380809327, 80.40871664680844), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(612358.7800675274 2398749.633182222) 7 360 \"_N\")", + ); + let want = [ + [612359.541179917, 2398775.302075975], + [612379.3233625971, 2398765.042414074], + [612383.6360250826, 2398743.179286296], + [612369.2316445602, 2398726.176073787], + [612346.957013359, 2398726.836542366], + [612333.585383093, 2398744.663345723], + [612339.185864084, 2398766.23253733], + ]; + assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s)); + assert!(s.doc().unwrap().model.iter().all(|e| matches!(&e.kind, EntityKind::Arc(a) if (a.start - 4.165744594740973).abs() < 1e-9))); +} + +#[test] +fn the_type_asked_defaults_to_the_one_used_last() { + let mut s = run(&circle([0.0, 0.0], 1.0), "(command \"_.-ARRAY\" a \"\" \"_P\" '(10.0 0.0) 3 360 \"_Y\")"); + s.cmdline("(command \"_.-ARRAY\" a \"\")").unwrap(); + assert!(s.prompt_text().contains("[Rectangular/Polar]

"), "{}", s.prompt_text()); +} + +#[test] +fn classic_array_by_json() { + let mut s = run(&circle([0.0, 0.0], 1.0), ""); + let first = s.doc().unwrap().model.iter().next().unwrap().handle.hex(); + let r = s.execute("-array", &json!({ "handles": [first], "type": "polar", "center": [10, 0], "count": 4, "rotate": false })).unwrap(); + assert_eq!(r["created"].as_u64(), Some(3), "{r}"); + assert!(same(&points(&s), &[[0.0, 0.0], [10.0, -10.0], [20.0, 0.0], [10.0, 10.0]], 1e-9), "{:?}", points(&s)); + let r = s.execute("-array", &json!({ "handles": [first], "rows": 2, "cols": 3, "rowSpacing": 5, "colSpacing": 7 })).unwrap(); + assert_eq!(r["created"].as_u64(), Some(5), "{r}"); + // An unknown type, or an array past MAXARRAY, is refused before anything is made. + assert!(s.execute("-array", &json!({ "handles": [first], "type": "path" })).is_err()); + assert!(s.execute("-array", &json!({ "handles": [first], "rows": 4294967296u64, "cols": 4294967296u64 })).is_err()); + assert!(s.execute("-array", &json!({ "handles": [first], "type": "polar", "center": [0, 0], "count": u64::MAX })).is_err()); + assert_eq!(s.doc().unwrap().model.iter().count(), 4 + 5); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 9b0907e24..0763ee6eb 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -21,6 +21,7 @@ mod break_keeps_z; mod burst; mod change_command; mod chprop_command; +mod classic_array; mod compare_export; mod constraint_geometry; mod constraint_infer; From 5caaf73e3b065856f38e9cb58571e68edfbd2a59 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 31 Oct 2026 18:52:40 -0400 Subject: [PATCH 072/151] packaging(flatpak): add OCI image build and GHCR publishing helper (#2577) --- packaging/linux/flatpak-bundle.sh | 8 ++++ packaging/linux/flatpak-oci.sh | 64 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100755 packaging/linux/flatpak-oci.sh diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 5154fe076..5c84b8470 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -20,10 +20,12 @@ APP_ID=ai.storyteller.cadcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak" # --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 000000000..d2d2e0f25 --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build cadcraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/cadcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="cadcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/cadcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi From 9c84141e3899bd17a5747e578b11bcbf91ad28e1 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:56:59 +0300 Subject: [PATCH 073/151] style: rustfmt the write_atomic map_err in render --- crates/mcp/src/backend.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index d27012418..4ed805979 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -395,8 +395,7 @@ impl Backend for Headless { let png = cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()).ok_or("render failed")?; if let Some(path) = str_p("path") { - cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(&path), &png) - .map_err(|e| format!("{path}: {e}"))?; + cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(&path), &png).map_err(|e| format!("{path}: {e}"))?; } Ok(json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h})) } From d7ef7da872fbd4158a5b2fc493f2da191f461d03 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 31 Oct 2026 23:43:11 -0400 Subject: [PATCH 074/151] ci: grant packages write permission to release workflow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 368d4f322..dc9c462db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,7 @@ jobs: permissions: # The reusable release workflow tags and uploads the draft release. contents: write + packages: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: From 258c89fbb021fd22cf25ef519e5caf018b692093 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Sun, 1 Nov 2026 18:11:08 +0600 Subject: [PATCH 075/151] Window opens centred; the Chinese menus have no English entries left (#1129) (#2510) * Window opens centred; the Chinese menus have no English entries left (#1129) The main window opened where the system cascades new windows, off to the top-left on wide screens; it now opens centred. 26 menu and command labels the Simplified Chinese update missed (Multileader > Collect, Block Editor, dynamic blocks, Express Tools, Customize, ...) are translated, and a test keeps every menu, command, tool and snap label translated in Chinese. The start page question in the issue is left to the owner. Co-Authored-By: Claude Opus 5.5 * File > Open Recent's Clear Recent Files is translated in Chinese (#1129) The Open Recent submenu is built at run time, so the Chinese completeness test never saw its labels, and Clear Recent Files showed in English. The label is now menus::CLEAR_RECENT, the test collects it with OPEN_RECENT, and zh.tsv translates it. The other runtime-built menu labels (context and tab menus) are all in zh.tsv already. Co-Authored-By: Claude Opus 5.5 * i18n(zh): translate Change and Units, which landed after this branch (#1129) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- apps/cadcraft/src/main.rs | 52 +++++++++++++++++++++----------- crates/ui-egui/src/i18n/tests.rs | 9 ++++++ crates/ui-egui/src/i18n/zh.tsv | 31 +++++++++++++++++++ crates/ui-egui/src/menus.rs | 4 ++- 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 4d7bdc39b..c591c10b3 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -194,6 +194,31 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] { filters } +/// The main window: its size, title bar, placement and rendering needs. +fn window_options() -> eframe::NativeOptions { + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("CADCraft") + .with_inner_size([1600.0, 1000.0]) + .with_min_inner_size([900.0, 560.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + .with_app_id(APP_ID), + persist_window: false, + // The window opens centred on its screen, not cascaded from the top-left corner (#1129). + // Wayland compositors place windows themselves. + centered: true, + // CADCraft installs its own capped font chain (`theme::install_fonts`); never run + // system-font discovery as a hidden fallback. + system_font_fallback: false, + // A stencil buffer clips the contents of clipped layout viewports on the GPU. + stencil_buffer: STENCIL_BITS, + ..Default::default() + } +} + const APP_ID: &str = "ai.storyteller.cadcraft"; /// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`). @@ -256,22 +281,7 @@ fn main() -> eframe::Result { // The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11 // swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync). wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface), - viewport: egui::ViewportBuilder::default() - .with_title("CADCraft") - .with_inner_size([1600.0, 1000.0]) - .with_min_inner_size([900.0, 560.0]) - .with_drag_and_drop(true) - .with_fullsize_content_view(true) - .with_titlebar_shown(false) - .with_title_shown(false) - .with_app_id(APP_ID), - persist_window: false, - // CADCraft installs its own capped font chain (`theme::install_fonts`); never run - // system-font discovery as a hidden fallback. - system_font_fallback: false, - // A stencil buffer clips the contents of clipped layout viewports on the GPU. - stencil_buffer: STENCIL_BITS, - ..Default::default() + ..window_options() }; if let Some(icon) = app_icon() { options.viewport = options.viewport.with_icon(icon); @@ -383,7 +393,7 @@ fn main() -> eframe::Result { #[cfg(test)] mod tests { - use super::{filters_for_save, follow_desktop}; + use super::{filters_for_save, follow_desktop, window_options}; #[test] fn save_dialog_offers_dwg_with_the_drawing_format_first() { @@ -401,6 +411,14 @@ mod tests { } } + /// #1129: the window opens centred, not cascaded from the top-left corner. + #[test] + fn the_window_opens_centred() { + let o = window_options(); + assert!(o.centered, "centred on launch"); + assert!(!o.persist_window, "no saved position to restore instead"); + } + /// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in; /// without one (or where winit answers and the desktop doesn't) the input is left alone. #[test] diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index dc4e5322b..034f261e1 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -63,6 +63,8 @@ fn missing_commands_menus_tools_and_snap_labels(language: &str) -> Vec { let catalog = catalog(language).unwrap(); let mut sources = BTreeSet::new(); sources.extend(crate::menus::MENUS.iter().copied()); + // File ▸ Open Recent is built at run time, not from a command's menu path. + sources.extend([crate::menus::OPEN_RECENT, crate::menus::CLEAR_RECENT]); for c in cadcraft_engine::command_specs() { sources.insert(c.label); sources.extend(c.menu.iter().copied()); @@ -560,6 +562,13 @@ fn production_font_chain_covers_latin_and_shortcut_symbols() { }); } +/// #1129: in Chinese every menu, submenu, command, tool palette and object-snap label is +/// translated, so no menu shows English entries. +#[test] +fn chinese_menus_and_commands_are_fully_translated() { + assert_eq!(missing_commands_menus_tools_and_snap_labels("zh"), Vec::::new()); +} + /// Bosnian counts: 1, 21 `one`; 2–4, 22–24 (not 12–14) `few`; everything else `other`. #[test] fn bosnian_counts_take_one_few_other() { diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 4d8da1698..2663105a2 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -1349,3 +1349,34 @@ Check for Updates… 检查更新… CADCraft is up to date. CADCraft 已是最新版本。 Could not check for updates. 无法检查更新。 +# Menu and command labels the October 2026 update missed (#1129). + Align Multileaders 对齐多重引线 + Analyze Usage 分析使用情况 + Break-line Symbol 折断线符号 + Change 更改 + Change Properties 修改特性 + Clear Recent Files 清除最近的文件 + Close Block Editor 关闭块编辑器 + Collect 合并 + Collect Multileaders 合并多重引线 + Convert Text to Mtext 将文字转换为多行文字 + Customize 自定义 + Drag Dynamic Grip 拖动动态夹点 + Dynamic Block Data 动态块数据 + Dynamic Block Grips 动态块夹点 + Dynamic Block Reference 动态块参照 + Edit Command Aliases (PGP)... 编辑命令别名 (PGP)... + Edit Multileader 编辑多重引线 + Evaluate LISP 求值 LISP + Explode Attributes to Text 将属性分解为文字 + Express Tools 扩展工具 + Groups 编组 + Hatch Pick 拾取图案填充 + Implied Loops 隐含环 + Justify 对正 + Move/Copy/Rotate 移动/复制/旋转 + Save Block 保存块 + Save Block As 块另存为 + Save Layout as Template 将布局另存为样板 + Set Dynamic Property 设置动态特性 + Units 单位 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bf0468d08..55d058811 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -10,6 +10,8 @@ pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools" /// File ▸ Open Recent: one `ui.recent.` item per recent file, then Clear Recent Files. pub const OPEN_RECENT: &str = "Open Recent"; +/// The last item of File ▸ Open Recent. +pub const CLEAR_RECENT: &str = "Clear Recent Files"; const RECENT_ITEM: &str = "ui.recent."; const RECENT_CLEAR: &str = "ui.recent.clear"; @@ -634,7 +636,7 @@ pub fn recent_entries(app: &CadApp) -> Vec { .map(|(i, path)| Entry::Item { label: file_name(path), id: format!("{RECENT_ITEM}{i}"), shortcut: None, enabled: true }) .collect(); let enabled = !app.ui.recent_files.is_empty(); - entries.push(Entry::Item { label: "Clear Recent Files".into(), id: RECENT_CLEAR.into(), shortcut: None, enabled }); + entries.push(Entry::Item { label: CLEAR_RECENT.into(), id: RECENT_CLEAR.into(), shortcut: None, enabled }); entries } From 7400119120a667d662a3f475372403f97d67b75d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 12:30:06 -0500 Subject: [PATCH 076/151] docs: sync Craft App Suite list with ArtCraft Launcher --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c45b619dc..a24b33980 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,15 @@ stand on its own. | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | -| | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. From 4b4ec8043ec6e378e1cbe238a56b7bab793ae5d0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 1 Nov 2026 13:59:24 -0500 Subject: [PATCH 077/151] Release 0.16.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c45ba071c..aa4e1961c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -643,7 +643,7 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -659,14 +659,14 @@ dependencies = [ [[package]] name = "cadcraft-color" -version = "0.15.1" +version = "0.16.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -676,7 +676,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -687,14 +687,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.15.1" +version = "0.16.0" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.15.1" +version = "0.16.0" dependencies = [ "encoding_rs", "log", @@ -703,7 +703,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -724,7 +724,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-geom", "harfrust 0.14.0", @@ -734,14 +734,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.15.1" +version = "0.16.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.15.1" +version = "0.16.0" dependencies = [ "acadrust", "cadcraft-color", @@ -762,7 +762,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -775,7 +775,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -789,7 +789,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.15.1" +version = "0.16.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -5894,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.15.1" +version = "0.16.0" dependencies = [ "chrono", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8e16721a3..c6b9590fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.15.1" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 520394858c679b992038b83087e61316f4eb0a12 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:37:49 +0700 Subject: [PATCH 078/151] -ATTDEF at the command line: attribute modes, AFLAGS, and the text's prompts (#2597) * -ATTDEF at the command line: attribute modes, AFLAGS, and the text's prompts -ATTDEF asked for the tag first. A script's mode answer ("_V", "_C", "_P") became the tag; the attribute kept no mode; AFLAGS never changed. The definition was made at the start point, so the height and rotation a script gives next ran as commands. It now asks as AutoCAD 2024 does: - "Current attribute modes:" from AFLAGS, then "Enter an option to change [Invisible/Constant/Verify/Preset/Lock position/Annotative/ Multiple lines] ", each answer toggling a mode, kept in AFLAGS. - The tag (spaces taken, upper-cased; an empty one gets "Tag cannot be null"), the prompt and default value, or a constant attribute's value. - "Current text style: ...", the point with Justify and Style, the height and the rotation. The definition takes Invisible, Constant, Verify, Preset and Lock position from the modes; a constant attribute stays in its block. Co-Authored-By: Claude Opus 5.5 * -ATTDEF: a typed height is a distance, as TEXT takes it The height was read with str::parse, so 1'6" (or any distance TEXT takes) was refused as "Requires a positive height." It now goes through the shared distance parser, `number`. Co-Authored-By: Claude Opus 5.5 * -ATTDEF: a wrong answer at Justify is asked again A wrong answer at "Enter an option [Left/Center/...]", or Align or Fit (not available here), went on to the point prompt with the justification unchanged, so a script's next answers landed one prompt off. Only a justification goes on to its point now; anything else is asked again. Co-Authored-By: Claude Opus 5.5 * -ATTDEF test: the modes start from AFLAGS, not from the default The other cases start from AFLAGS 16, which is also the default, so reading the modes from the drawing went untested. This starts from 0. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 49 +--- crates/engine/src/cmd/blocks/attdef.rs | 293 +++++++++++++++++++++++ crates/engine/src/cmd/draw.rs | 1 + crates/engine/tests/it/attdef_command.rs | 186 ++++++++++++++ crates/engine/tests/it/main.rs | 1 + 5 files changed, 483 insertions(+), 47 deletions(-) create mode 100644 crates/engine/src/cmd/blocks/attdef.rs create mode 100644 crates/engine/tests/it/attdef_command.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 19543141e..c25b7c913 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -17,6 +17,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, po use super::has_doc; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +mod attdef; mod attedit; mod burst; mod description; @@ -45,7 +46,7 @@ pub fn specs() -> Vec { .menu(&["Draw", "Block", "Define Attributes..."]) .alias(&["att", "-attdef"]) .params("{tag, prompt?, default?, at, height?, invisible?}") - .interactive(|_| Ok(Box::new(AttdefM::default()))), + .interactive(|_| Ok(Box::new(attdef::AttdefM::default()))), attedit::attedit_spec(), attedit::dash_attedit_spec(), CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"), @@ -983,52 +984,6 @@ impl Interactive for InsertM { } } -#[derive(Default)] -struct AttdefM { - tag: Option, - prompt: Option, - default: Option, -} - -impl Interactive for AttdefM { - fn name(&self) -> &'static str { - "ATTDEF" - } - fn prompt(&self, _s: &Session) -> Prompt { - match (&self.tag, &self.prompt, &self.default) { - (None, ..) => Prompt::new("Enter attribute tag name", Accept::TEXT), - (Some(_), None, _) => Prompt::new("Enter attribute prompt", Accept::TEXT), - (Some(_), Some(_), None) => Prompt::new("Enter default attribute value", Accept::TEXT), - _ => Prompt::new("Specify start point of text", Accept::POINT), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - let text = match &i { - Input::Text(t) => Some(t.clone()), - Input::Enter => Some(String::new()), - _ => None, - }; - match (&self.tag, &self.prompt, &self.default) { - (None, ..) => { - if let Some(t) = text.filter(|t| !t.trim().is_empty() && !t.contains(' ')) { - self.tag = Some(t.to_ascii_uppercase()); - } - } - (Some(_), None, _) => self.prompt = text, - (Some(_), Some(_), None) => self.default = text, - (Some(tag), Some(pr), Some(def)) => { - if let Input::Point(p) = i { - let h = s.doc()?.header.f64("TEXTSIZE", 0.2); - let k = attdef_kind(s, tag, pr, def, p, h, false); - s.add_entity(k)?; - return Ok(Step::Done); - } - } - } - Ok(Step::Continue) - } -} - mod attribs; mod battman; mod blockreplace; diff --git a/crates/engine/src/cmd/blocks/attdef.rs b/crates/engine/src/cmd/blocks/attdef.rs new file mode 100644 index 000000000..0803cfe95 --- /dev/null +++ b/crates/engine/src/cmd/blocks/attdef.rs @@ -0,0 +1,293 @@ +//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole): the attribute modes +//! (kept in AFLAGS), the tag, prompt and default value (a constant attribute's value instead), +//! then the text's point, justification, style, height and rotation. + +use cadcraft_doc::{AttribModes, HAlign, VAlign}; + +use super::{Accept, Attrib, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Text, Vec2, number, v3}; +use crate::cmd::curves::KW; +use crate::cmd::draw::justify; + +/// AFLAGS bits, as AutoCAD numbers the modes. +const INVISIBLE: i64 = 1; +const CONSTANT: i64 = 2; +const VERIFY: i64 = 4; +const PRESET: i64 = 8; +const LOCK: i64 = 16; +const MULTILINE: i64 = 32; + +const MODES: [&str; 7] = ["Invisible", "Constant", "Verify", "Preset", "Lock position", "Annotative", "Multiple lines"]; +const JUSTIFY: [&str; 15] = ["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]; + +/// Where -ATTDEF is in its prompts. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Ask { + #[default] + Modes, + Tag, + Prompt, + Value, + Point, + Justify, + Style, + Height, + Rotation, +} + +pub(super) struct AttdefM { + ask: Ask, + flags: i64, + tag: String, + prompt: String, + value: String, + justify: (HAlign, VAlign), + style: String, + at: Vec2, + height: f64, +} + +impl Default for AttdefM { + fn default() -> Self { + AttdefM { + ask: Ask::Modes, + flags: LOCK, + tag: String::new(), + prompt: String::new(), + value: String::new(), + justify: (HAlign::Left, VAlign::Baseline), + style: "Standard".into(), + at: Vec2::ZERO, + height: 0.0, + } + } +} + +impl AttdefM { + fn echo_modes(&self, s: &mut Session) { + let yn = |bit: i64| if self.flags & bit != 0 { "Y" } else { "N" }; + s.echo("Current attribute modes:"); + s.echo(format!( + "Invisible={} Constant={} Verify={} Preset={} Lock position={} Annotative=N Multiple line={}", + yn(INVISIBLE), + yn(CONSTANT), + yn(VERIFY), + yn(PRESET), + yn(LOCK), + yn(MULTILINE) + )); + } + + fn echo_style(&self, s: &mut Session) -> Result<()> { + let th = s.doc()?.header.f64("TEXTSIZE", 0.2); + s.echo(format!("Current text style: \"{}\" Text height: {th:.4} Justify: {}", self.style, justify_name(self.justify))); + Ok(()) + } + + fn point_prompt(&self) -> &'static str { + match self.justify { + (HAlign::Center, VAlign::Baseline) => "Specify center point of text", + (HAlign::Right, VAlign::Baseline) => "Specify right endpoint of text baseline", + (HAlign::Middle, _) | (HAlign::Center, VAlign::Middle) => "Specify middle point of text", + (HAlign::Left, VAlign::Top) => "Specify top-left point of text", + (HAlign::Center, VAlign::Top) => "Specify top-center point of text", + (HAlign::Right, VAlign::Top) => "Specify top-right point of text", + (HAlign::Left, VAlign::Middle) => "Specify middle-left point of text", + (HAlign::Right, VAlign::Middle) => "Specify middle-right point of text", + (HAlign::Left, VAlign::Bottom) => "Specify bottom-left point of text", + (HAlign::Center, VAlign::Bottom) => "Specify bottom-center point of text", + (HAlign::Right, VAlign::Bottom) => "Specify bottom-right point of text", + _ => "Specify start point of text", + } + } + + fn finish(&mut self, s: &mut Session, rotation: f64) -> Result { + let (h, v) = self.justify; + let left = h == HAlign::Left && v == VAlign::Baseline; + s.add_entity(EntityKind::AttDef(Attrib { + tag: self.tag.to_uppercase(), + text: Text { + insert: v3(self.at), + align_pt: if left { None } else { Some(v3(self.at)) }, + height: self.height, + value: std::mem::take(&mut self.value), + rotation, + width_factor: 1.0, + oblique: 0.0, + style: self.style.clone(), + halign: h, + valign: v, + }, + invisible: self.flags & INVISIBLE != 0, + constant: self.flags & CONSTANT != 0, + prompt: std::mem::take(&mut self.prompt), + props: Default::default(), + multiline: None, + modes: AttribModes { verify: self.flags & VERIFY != 0, preset: self.flags & PRESET != 0, lock_position: self.flags & LOCK != 0 }, + }))?; + Ok(Step::Done) + } +} + +/// The justification's name in "Current text style: … Justify: …". +fn justify_name(j: (HAlign, VAlign)) -> &'static str { + JUSTIFY.into_iter().find(|k| justify(k) == Some(j)).unwrap_or("Left") +} + +impl Interactive for AttdefM { + fn name(&self) -> &'static str { + "ATTDEF" + } + fn begin(&mut self, s: &mut Session) -> Result { + let d = s.doc()?; + self.flags = d.header.i64("AFLAGS", LOCK); + self.style = d.header.str("TEXTSTYLE", "Standard"); + self.echo_modes(s); + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match self.ask { + Ask::Modes => Prompt::new("Enter an option to change", KW).kw(&MODES).default("done"), + Ask::Tag => Prompt::new("Enter attribute tag name", Accept::TEXT), + Ask::Prompt => Prompt::new("Enter attribute prompt", Accept::TEXT), + Ask::Value if self.flags & CONSTANT != 0 => Prompt::new("Enter attribute value", Accept::TEXT), + Ask::Value => Prompt::new("Enter default attribute value", Accept::TEXT), + // Once a justification is chosen AutoCAD asks for its point alone. + Ask::Point if self.justify == (HAlign::Left, VAlign::Baseline) => { + Prompt::new(self.point_prompt(), Accept::POINT).kw(&["Justify", "Style"]) + } + Ask::Point => Prompt::new(self.point_prompt(), Accept::POINT), + Ask::Justify => Prompt::new("Enter an option", Accept::TEXT).kw(&JUSTIFY), + Ask::Style => Prompt::new("Enter style name", Accept::TEXT).kw(&["?"]).default(self.style.clone()), + Ask::Height => { + let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2); + Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(self.at) + } + Ask::Rotation => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(self.at), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.ask { + Ask::Modes => match &i { + Input::Keyword(k) => { + let bit = match k.as_str() { + "Invisible" => INVISIBLE, + "Constant" => CONSTANT, + "Verify" => VERIFY, + "Preset" => PRESET, + "Lock position" => LOCK, + _ => { + s.echo(format!("{k} attributes are not available yet.")); + return Ok(Step::Continue); + } + }; + self.flags ^= bit; + s.doc_mut()?.header.set_i64("AFLAGS", self.flags); + self.echo_modes(s); + } + Input::Enter => self.ask = Ask::Tag, + Input::Text(_) => s.echo("Invalid option keyword."), + _ => {} + }, + Ask::Tag => match &i { + // AutoCAD takes spaces in a tag here, and upper-cases it. + Input::Text(t) if !t.trim().is_empty() => { + self.tag = t.trim().to_string(); + self.ask = if self.flags & CONSTANT != 0 { Ask::Value } else { Ask::Prompt }; + } + Input::Text(_) | Input::Enter => s.echo("Tag cannot be null"), + _ => {} + }, + Ask::Prompt | Ask::Value => { + let t = match i { + Input::Text(t) => t, + Input::Enter => String::new(), + _ => return Ok(Step::Continue), + }; + if self.ask == Ask::Prompt { + self.prompt = t; + self.ask = Ask::Value; + } else { + self.value = t; + self.echo_style(s)?; + self.ask = Ask::Point; + } + } + Ask::Point => match i { + Input::Point(p) => { + self.at = p; + self.ask = Ask::Height; + } + Input::Keyword(k) if k == "Justify" => self.ask = Ask::Justify, + Input::Keyword(_) => self.ask = Ask::Style, + _ => {} + }, + Ask::Justify => { + // A justification goes on to its point; anything else is asked again. + if let Input::Keyword(k) | Input::Text(k) = &i { + match justify(k) { + // Aligned and fitted text take two points: not here yet. + Some((HAlign::Aligned | HAlign::Fit, _)) => s.echo("Align and Fit are not available here yet."), + Some(j) => { + self.justify = j; + self.ask = Ask::Point; + } + None => s.echo("Invalid option keyword."), + } + } + } + Ask::Style => { + let name = match i { + Input::Enter => self.style.clone(), + Input::Text(t) | Input::Keyword(t) => t.trim().to_string(), + _ => return Ok(Step::Continue), + }; + if name == "?" { + let names: Vec = s.doc()?.text_styles.iter().map(|st| st.name.clone()).collect(); + s.echo(format!("Text styles: {}", names.join(", "))); + return Ok(Step::Continue); + } + match s.doc()?.text_style(&name).map(|st| st.name.clone()) { + Some(n) => { + self.style = n; + self.echo_style(s)?; + self.ask = Ask::Point; + } + None => s.echo(format!("Cannot find text style \"{name}\".")), + } + } + Ask::Height => { + let th = s.doc()?.header.f64("TEXTSIZE", 0.2); + let h = match &i { + Input::Enter => th, + Input::Point(p) => self.at.dist(*p), + // A distance, as TEXT takes its height (1'6" in feet and inches, too). + Input::Text(t) => number(t).unwrap_or(0.0), + _ => return Ok(Step::Continue), + }; + if !(h.is_finite() && h > 0.0) { + s.echo("Requires a positive height."); + return Ok(Step::Continue); + } + self.height = h; + s.doc_mut()?.header.set_f64("TEXTSIZE", h); + self.ask = Ask::Rotation; + } + Ask::Rotation => { + let r = match &i { + Input::Enter => 0.0, + Input::Point(p) => self.at.angle_to(*p), + Input::Text(t) => match s.angle_settings().direction(t) { + Some(a) => a, + None => { + s.echo("Requires an angle."); + return Ok(Step::Continue); + } + }, + _ => return Ok(Step::Continue), + }; + return self.finish(s, r); + } + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 1b0e0c1cf..cebc44e6f 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -33,6 +33,7 @@ use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray}; use polygon::{PolygonM, run_polygon}; use solid::{SolidM, run_solid}; use spline::{SplineM, run_spline}; +pub(crate) use text::justify; use text::{MTextM, TextM, run_mtext, run_text}; /// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt diff --git a/crates/engine/tests/it/attdef_command.rs b/crates/engine/tests/it/attdef_command.rs new file mode 100644 index 000000000..7bbec7c37 --- /dev/null +++ b/crates/engine/tests/it/attdef_command.rs @@ -0,0 +1,186 @@ +//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts with +//! TEXTSIZE 2.5 and AFLAGS 16, the drawing and AFLAGS read back afterwards). + +use cadcraft_engine::doc::{Attrib, EntityKind, HAlign, VAlign}; +use cadcraft_engine::{Session, sysvars}; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "TEXTSIZE", "value": 2.5 })).unwrap(); + s.execute("setvar", &json!({ "name": "AFLAGS", "value": 16 })).unwrap(); + s +} + +fn attdefs(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.clone()) } else { None }).collect() +} + +fn aflags(s: &Session) -> i64 { + sysvars::get(s, "AFLAGS").and_then(|v| v.as_f64()).unwrap() as i64 +} + +fn run(script: &str) -> Session { + let mut s = session(); + s.cmdline(script).unwrap(); + assert!(s.running.is_none(), "{script}: {}", s.prompt_text()); + s +} + +#[test] +fn modes_are_toggled_kept_in_aflags_and_stored_on_the_definition() { + // (script, AFLAGS after, invisible, constant, verify, preset, lock position, tag, prompt, value) + type Case = (&'static str, i64, [bool; 5], &'static str, &'static str, &'static str); + let cases: [Case; 5] = [ + // ATTDEF-000: the modes as they are. + ( + "(command \"_.-ATTDEF\" \"\" \"T1\" \"Nhap ten\" \"MAC\" '(5.0 10.0) 2.5 0)", + 16, + [false, false, false, false, true], + "T1", + "Nhap ten", + "MAC", + ), + // ATTDEF-001: Invisible and Verify. + ( + "(command \"_.-ATTDEF\" \"_I\" \"_V\" \"\" \"T2\" \"Hoi hai\" \"D2\" '(5.0 10.0) 3.0 30)", + 21, + [true, false, true, false, true], + "T2", + "Hoi hai", + "D2", + ), + // ATTDEF-002: Constant asks for the value, not a prompt and a default. + ("(command \"_.-ATTDEF\" \"_C\" \"\" \"T3\" \"GIATRI\" '(5.0 10.0) 2.5 0)", 18, [false, true, false, false, true], "T3", "", "GIATRI"), + // ATTDEF-003: Preset. + ( + "(command \"_.-ATTDEF\" \"_P\" \"\" \"T4\" \"Hoi bon\" \"D4\" '(612350.678 2398775.432) 2.5 0)", + 24, + [false, false, false, true, true], + "T4", + "Hoi bon", + "D4", + ), + // ATTDEF-004: Lock position off. + ( + "(command \"_.-ATTDEF\" \"_L\" \"\" \"T5\" \"Hoi nam\" \"D5\" '(5.0 10.0) 2.5 0)", + 0, + [false, false, false, false, false], + "T5", + "Hoi nam", + "D5", + ), + ]; + for (script, flags, [inv, con, ver, pre, lock], tag, prompt, value) in cases { + let s = run(script); + assert_eq!(aflags(&s), flags, "{script}"); + let a = attdefs(&s).remove(0); + assert_eq!((a.invisible, a.constant, a.modes.verify, a.modes.preset, a.modes.lock_position), (inv, con, ver, pre, lock), "{script}"); + assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.text.value.as_str()), (tag, prompt, value), "{script}"); + } + // ATTDEF-001's height 3 and 30 degrees. + let a = attdefs(&run(cases[1].0)).remove(0); + assert!((a.text.height - 3.0).abs() < 1e-12 && (a.text.rotation - 30f64.to_radians()).abs() < 1e-12, "{a:?}"); +} + +#[test] +fn tags_justification_and_style_as_autocad_takes_them() { + // ATTDEF-021, ATTDEF-022: a tag is upper-cased, spaces and all. + for (typed, tag) in [("maso", "MASO"), ("ten ban ve", "TEN BAN VE")] { + let s = run(&format!("(command \"_.-ATTDEF\" \"\" \"{typed}\" \"Hoi\" \"D\" '(5.0 10.0) 2.5 0)")); + assert_eq!(attdefs(&s)[0].tag, tag); + } + // ATTDEF-023: an empty prompt stays empty. + let s = run("(command \"_.-ATTDEF\" \"\" \"T10\" \"\" \"D10\" '(612350.678 2398775.432) 2.5 0)"); + assert_eq!(attdefs(&s)[0].prompt, ""); + // ATTDEF-025: an empty tag is refused and asked again. + let s = run("(command \"_.-ATTDEF\" \"\" \"\" \"T11\" \"Hoi muoi mot\" \"D11\" '(5.0 10.0) 2.5 0)"); + assert!(s.log.iter().any(|l| l == "Tag cannot be null"), "{:?}", s.log); + assert_eq!(attdefs(&s)[0].tag, "T11"); + // ATTDEF-005: Justify MC at the middle point. + let s = run("(command \"_.-ATTDEF\" \"\" \"T6\" \"Hoi sau\" \"D6\" \"_J\" \"_MC\" '(5.0 10.0) 2.5 0)"); + let a = attdefs(&s).remove(0); + assert_eq!((a.text.halign, a.text.valign), (HAlign::Center, VAlign::Middle)); + assert!(a.text.align_pt.is_some_and(|p| p.x == 5.0 && p.y == 10.0), "{a:?}"); + // ATTDEF-006: Style, then the point. + let s = run("(command \"_.-ATTDEF\" \"\" \"T7\" \"Hoi bay\" \"D7\" \"_S\" \"Standard\" '(0.505 0.26) 2.5 0)"); + let a = attdefs(&s).remove(0); + assert!(a.text.style == "Standard" && a.text.insert.x == 0.505 && a.text.insert.y == 0.26, "{a:?}"); +} + +#[test] +fn the_prompts_are_autocads() { + let mut s = session(); + s.cmdline("-attdef").unwrap(); + assert!(s.log.iter().any(|l| l == "Current attribute modes:"), "{:?}", s.log); + assert_eq!(s.log.last().map(String::as_str), Some("Invisible=N Constant=N Verify=N Preset=N Lock position=Y Annotative=N Multiple line=N")); + assert!(s.prompt_text().contains("[Invisible/Constant/Verify/Preset/Lock position/Annotative/Multiple lines] :"), "{}", s.prompt_text()); + s.cmdline("X").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword.")); + s.cmdline("_C").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Invisible=N Constant=Y Verify=N Preset=N Lock position=Y Annotative=N Multiple line=N")); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("Enter attribute tag name:"), "{}", s.prompt_text()); + s.cmdline("T3").unwrap(); + assert!(s.prompt_text().ends_with("Enter attribute value:"), "{}", s.prompt_text()); + s.cmdline("GIATRI").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Current text style: \"Standard\" Text height: 2.5000 Justify: Left")); + assert!(s.prompt_text().ends_with("Specify start point of text or [Justify/Style]:"), "{}", s.prompt_text()); + s.cmdline("_J").unwrap(); + // A wrong answer, or a justification not available here, is asked again. + for typed in ["X", "_A"] { + s.cmdline(typed).unwrap(); + assert!(s.prompt_text().contains("[Left/Center/Right/Align/Middle/Fit/TL/"), "{typed}: {}", s.prompt_text()); + } + s.cmdline("_MC").unwrap(); + assert!(s.prompt_text().ends_with("Specify middle point of text:"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + assert!(s.prompt_text().ends_with("Specify height <2.5000>:"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("Specify rotation angle of text <0>:"), "{}", s.prompt_text()); + s.cancel(); +} + +#[test] +fn a_constant_attribute_stays_in_the_block() { + // INSERTATT-011: the constant TC is no attribute of the reference; the other one, TN, is. + let mut s = session(); + s.execute("line", &json!({ "points": [[612345.678, 2398765.432], [612355.678, 2398765.432]] })).unwrap(); + s.cmdline("(setq hl_A (entlast))").unwrap(); + s.cmdline("(progn (command \"_.-ATTDEF\" \"_C\" \"\" \"TC\" \"HANG\" '(612350.678 2398775.432) 2.5 0) (setq hl_a1 (entlast)))").unwrap(); + s.cmdline("(setvar \"AFLAGS\" 16)").unwrap(); + s.cmdline("(progn (command \"_.-ATTDEF\" \"\" \"TN\" \"Hoi N\" \"MN\" '(612350.678 2398785.432) 2.5 0) (setq hl_a2 (entlast)))").unwrap(); + s.cmdline("(command \"_.-BLOCK\" \"B11\" '(612345.678 2398765.432) hl_a1 hl_a2 hl_A \"\")").unwrap(); + s.cmdline("(command \"_.-INSERT\" \"B11\" '(612445.678 2398815.432) 1 1 0 \"XN\")").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let ins = s.doc().unwrap().model.iter().find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).unwrap(); + let tags: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect(); + assert_eq!(tags, [("TN".to_string(), "XN".to_string())]); +} + +#[test] +fn a_typed_height_is_a_distance() { + let mut s = session(); + for typed in ["-attdef", "", "T1", "P", "V", "5,10", "x"] { + s.cmdline(typed).unwrap(); + } + assert_eq!(s.log.last().map(String::as_str), Some("Requires a positive height.")); + // Feet and inches, as TEXT takes them. + for typed in ["1'6\"", "0"] { + s.cmdline(typed).unwrap(); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(attdefs(&s)[0].text.height, 18.0); +} + +#[test] +fn the_modes_start_from_aflags() { + // AFLAGS 0, not the default 16: Invisible toggled on gives 1, and nothing else. + let mut s = session(); + s.execute("setvar", &json!({ "name": "AFLAGS", "value": 0 })).unwrap(); + s.cmdline("(command \"_.-ATTDEF\" \"_I\" \"\" \"T1\" \"Hoi\" \"D\" '(5.0 10.0) 2.5 0)").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(aflags(&s), 1); + let a = attdefs(&s).remove(0); + assert_eq!((a.invisible, a.modes.lock_position), (true, false)); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 0763ee6eb..88cbefe31 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -5,6 +5,7 @@ mod annotative; mod arc_ctrl_direction; mod array_commands; mod assoc_array; +mod attdef_command; mod attdisp_attsync; mod attedit_prompts; mod attrib_modes; From 210cfc2a1d20317c6f35c587d93afd0fbe68f9aa Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:37:52 +0700 Subject: [PATCH 079/151] -WBLOCK at the command line, as AutoCAD runs it (#2594) * -WBLOCK at the command line, as AutoCAD runs it WBLOCK had only its JSON form, so a script's -WBLOCK did nothing: the objects it should write and erase stayed in the drawing. This adds the command-line command, with AutoCAD 2024's prompts: - "Current settings: Object conversion=Delete", then the output file. A name without .dwg or .dxf gets .dwg. A DXF file asks for its decimal places first. A file that exists asks "... already exists, do you want to replace it? [Yes/No] "; No ends the command. - An existing block by name, "=" (the block named as the file), "*" (the whole drawing), or Enter for objects. A name that isn't a block is answered Block "NAME" not found. and asked again. - For objects: the base point (with mOde), then the objects, which are written and erased. AutoCAD erases them whatever mOde says, and keeps Delete as the setting. Nothing selected writes nothing. Its JSON form, -wblock, erases what it writes. WBLOCK's JSON form moves into the new module unchanged, sharing the writer. The web build refuses -WBLOCK rather than erase objects it can't save. Co-Authored-By: Claude Opus 5.5 * -WBLOCK's JSON form: nothing to write writes no file With no handles, an empty selection or only handles of objects that aren't there, the JSON form wrote an empty drawing. It now refuses with "no objects selected" before writing or erasing anything, as the command line writes nothing when nothing is selected. Co-Authored-By: Claude Opus 5.5 * -WBLOCK: only Yes replaces an existing file At "... already exists, do you want to replace it? [Yes/No] ", any text starting with "y" counted as Yes, so "yodel" replaced the file, and any other text ended the command. Now only the Yes keyword replaces it, No and Enter end the command, and anything else gets "Invalid option keyword." and the prompt again. Co-Authored-By: Claude Opus 5.5 * WBLOCK and -WBLOCK write their file through a temporary file Replacing an existing file truncated it first, so a write that failed part way lost it. The file now goes through atomic_write::write_atomic, as SAVE, EXPORT and autosave write theirs. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 61 +--- crates/engine/src/cmd/blocks/insert_file.rs | 2 +- crates/engine/src/cmd/blocks/wblock.rs | 323 ++++++++++++++++++++ crates/engine/tests/dash_wblock.rs | 244 +++++++++++++++ 4 files changed, 572 insertions(+), 58 deletions(-) create mode 100644 crates/engine/src/cmd/blocks/wblock.rs create mode 100644 crates/engine/tests/dash_wblock.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index c25b7c913..81732a3f8 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -7,7 +7,7 @@ pub(in crate::cmd) mod purge; use std::sync::Arc; use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign}; -use cadcraft_geom::{Mat3, Vec2, Vec3}; +use cadcraft_geom::{Vec2, Vec3}; use serde_json::{Value, json}; use super::helpers::v3; @@ -24,6 +24,7 @@ mod description; mod dyngrip; mod extract; pub(crate) mod refedit; +mod wblock; pub fn specs() -> Vec { vec![ @@ -49,7 +50,8 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(attdef::AttdefM::default()))), attedit::attedit_spec(), attedit::dash_attedit_spec(), - CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"), + wblock::wblock_spec(), + wblock::dash_wblock_spec(), CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"), CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic) .params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)") @@ -362,61 +364,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handle": s.add_entity(k)?.hex() })) } -fn run_wblock(s: &mut Session, p: &Value) -> Result { - let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string(); - let src = s.doc()?; - let mut d = cadcraft_doc::Drawing::new_imperial(); - // The written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its - // units ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches - // and scaled on insert, and its defaults (text size, current styles, annotation scales). - d.header = src.header.clone(); - d.layers = src.layers.clone(); - d.linetypes = src.linetypes.clone(); - d.text_styles = src.text_styles.clone(); - d.dim_styles = src.dim_styles.clone(); - d.mleader_styles = src.mleader_styles.clone(); - d.table_styles = src.table_styles.clone(); - d.mline_styles = src.mline_styles.clone(); - d.scales = src.scales.clone(); - d.blocks = src.blocks.clone(); - // The base point becomes the written drawing's origin, its insertion base: by default 0,0 for - // objects and the block's base point for a block (`base` is then in block coordinates). - let base = point_param(p, "base"); - d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO)); - let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO)); - let ents: Vec = if let Some(name) = str_param(p, "name") { - let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?; - let m = Mat3::translate(-base.unwrap_or(b.base.xy())); - b.entities - .iter() - .map(|e| { - let mut e = (**e).clone(); - e.kind.transform(&m); - e - }) - .collect() - } else { - targets(s, p)? - .iter() - .filter_map(|h| src.entity(*h)) - .map(|e| { - let mut e = (**e).clone(); - e.kind.transform(&m); - e - }) - .collect() - }; - for mut e in ents { - e.handle = d.new_handle(); - d.model.push(e); - } - let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?; - let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?; - #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?; - Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() })) -} - fn run_base(s: &mut Session, p: &Value) -> Result { let at = point_req("base", p, "at")?; s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at))); diff --git a/crates/engine/src/cmd/blocks/insert_file.rs b/crates/engine/src/cmd/blocks/insert_file.rs index 4d0af93b3..1a90502b5 100644 --- a/crates/engine/src/cmd/blocks/insert_file.rs +++ b/crates/engine/src/cmd/blocks/insert_file.rs @@ -59,7 +59,7 @@ pub(super) fn file_ref(s: &Session, t: &str) -> Option { } /// The file name without folders and extension (either separator, so Windows paths work anywhere). -fn stem(path: &str) -> String { +pub(super) fn stem(path: &str) -> String { let file = path.rsplit(['/', '\\']).next().unwrap_or(path); match file.rsplit_once('.') { Some((stem, _)) if !stem.is_empty() => stem.to_string(), diff --git a/crates/engine/src/cmd/blocks/wblock.rs b/crates/engine/src/cmd/blocks/wblock.rs new file mode 100644 index 000000000..03356269e --- /dev/null +++ b/crates/engine/src/cmd/blocks/wblock.rs @@ -0,0 +1,323 @@ +//! WBLOCK (its JSON form) and -WBLOCK at the command line: objects, a block or the whole drawing +//! written to a drawing file. + +use cadcraft_doc::{Drawing, Entity, Handle}; +use cadcraft_geom::{Mat3, Vec2, Vec3}; +use serde_json::{Value, json}; + +use super::super::curves::KW; +use super::super::machines::{SelOutcome, SelectPhase}; +use super::super::{CommandSpec, bad, point_param, str_param, targets}; +use super::insert_file::stem; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +pub(super) fn wblock_spec() -> CommandSpec { + CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}") +} + +/// -WBLOCK erases the objects it writes, whatever its mOde option says: AutoCAD 2024 does, and +/// keeps "Object conversion=Delete" as the setting. +pub(super) fn dash_wblock_spec() -> CommandSpec { + CommandSpec::new("-wblock", "Write Block", run_dash_wblock) + .params("{path, name? | handles?, base?} (the objects written are erased)") + .interactive(|_| Ok(Box::new(DashWblockM::default()))) +} + +/// The web build has no file system: -WBLOCK would erase objects it can't save. +const NO_FILES: &str = "files can't be written in the web build"; + +/// What WBLOCK writes. +enum Source<'a> { + /// These objects, `base` becoming the written drawing's origin. + Objects(&'a [Handle], Vec2), + /// A block's objects, from its base point or `base` (in block coordinates). + Block(&'a str, Option), + /// The whole drawing. + Whole, +} + +fn run_wblock(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string(); + let base = point_param(p, "base"); + let (entities, bytes) = match str_param(p, "name") { + Some(name) => write(s, "wblock", &path, Source::Block(name, base))?, + None => write(s, "wblock", &path, Source::Objects(&targets(s, p)?, base.unwrap_or(Vec2::ZERO)))?, + }; + Ok(json!({ "path": path, "entities": entities, "bytes": bytes })) +} + +fn run_dash_wblock(s: &mut Session, p: &Value) -> Result { + if cfg!(target_arch = "wasm32") { + return Err(bad("-wblock", NO_FILES)); + } + let path = str_param(p, "path").ok_or_else(|| bad("-wblock", "`path` is required"))?.to_string(); + let base = point_param(p, "base"); + let (entities, bytes) = match str_param(p, "name") { + Some(name) => write(s, "-wblock", &path, Source::Block(name, base))?, + None => { + let hs = targets(s, p)?; + // As at the command line, nothing selected writes no file. + if !hs.iter().any(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())) { + return Err(bad("-wblock", "no objects selected")); + } + let written = write(s, "-wblock", &path, Source::Objects(&hs, base.unwrap_or(Vec2::ZERO)))?; + erase(s, &hs)?; + written + } + }; + Ok(json!({ "path": path, "entities": entities, "bytes": bytes })) +} + +/// Write `what` to `path` through the io hooks; the objects written and the file's size. The +/// written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its units +/// ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches and +/// scaled on insert, and its defaults (text size, current styles, annotation scales). +fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usize)> { + let src = s.doc()?; + // The base point becomes the written drawing's origin: by default 0,0 for objects and the + // block's base point for a block. + let d = match what { + Source::Whole => src.clone(), + Source::Block(name, base) => { + let b = src.block(name).ok_or_else(|| bad(cmd, format!("no block `{name}`")))?; + let m = Mat3::translate(-base.unwrap_or(b.base.xy())); + with_objects(src, b.entities.iter().map(|e| moved(e, &m))) + } + Source::Objects(hs, base) => { + let m = Mat3::translate(-base); + with_objects(src, hs.iter().filter_map(|h| src.entity(*h)).map(|e| moved(e, &m))) + } + }; + let hooks = super::super::file::io().ok_or_else(|| bad(cmd, "file formats are not available in this build"))?; + let bytes = (hooks.write)(&d, path).map_err(|e| bad(cmd, e))?; + // Through a temporary file, as SAVE writes, so a failed write keeps the file it replaces. + #[cfg(not(target_arch = "wasm32"))] + crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + Ok((d.model.len(), bytes.len())) +} + +/// A new drawing of `ents`, with `src`'s settings, styles and blocks; its insertion base 0,0. +fn with_objects(src: &Drawing, ents: impl Iterator) -> Drawing { + let mut d = Drawing::new_imperial(); + d.header = src.header.clone(); + d.layers = src.layers.clone(); + d.linetypes = src.linetypes.clone(); + d.text_styles = src.text_styles.clone(); + d.dim_styles = src.dim_styles.clone(); + d.mleader_styles = src.mleader_styles.clone(); + d.table_styles = src.table_styles.clone(); + d.mline_styles = src.mline_styles.clone(); + d.scales = src.scales.clone(); + d.blocks = src.blocks.clone(); + d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO)); + for mut e in ents { + e.handle = d.new_handle(); + d.model.push(e); + } + d +} + +fn moved(e: &Entity, m: &Mat3) -> Entity { + let mut e = e.clone(); + e.kind.transform(m); + e +} + +fn erase(s: &mut Session, hs: &[Handle]) -> Result<()> { + let doc = s.doc_mut()?; + for h in hs { + doc.remove_entity(*h); + } + s.set_selection(Vec::new()); + Ok(()) +} + +/// The file -WBLOCK writes for a typed name: as named when it ends in .dwg or .dxf, else with +/// .dwg added, as AutoCAD names a drawing. +fn output_path(typed: &str) -> String { + let t = typed.trim().trim_matches('"'); + let lower = t.to_ascii_lowercase(); + if lower.ends_with(".dwg") || lower.ends_with(".dxf") { t.to_string() } else { format!("{t}.dwg") } +} + +/// Whether a file is there to replace; the web has no file system. +fn exists(path: &str) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + std::path::Path::new(path).exists() + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + false + } +} + +/// Where -WBLOCK is in its prompts, as AutoCAD 2024 asks them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Ask { + #[default] + File, + /// The file exists: "… already exists, do you want to replace it? [Yes/No] ". + Replace, + /// A DXF file's decimal places of accuracy. + Precision, + /// An existing block, `=` (the block named as the file), `*` (the whole drawing), or Enter + /// for objects. + Block, + Base, + /// mOde at the base point prompt: asked, but the objects are erased whatever it says. + Mode, + Objects, +} + +const SAME_NAME: &str = "= (block=output file)"; +const WHOLE: &str = "* (whole drawing)"; + +#[derive(Default)] +struct DashWblockM { + ask: Ask, + path: String, + base: Vec2, + sel: SelectPhase, +} + +impl Interactive for DashWblockM { + fn name(&self) -> &'static str { + "WBLOCK" + } + fn begin(&mut self, s: &mut Session) -> Result { + if cfg!(target_arch = "wasm32") { + s.echo(format!("-WBLOCK: {NO_FILES}.")); + return Ok(Step::Cancel); + } + s.echo("Current settings: Object conversion=Delete"); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.ask { + Ask::File => Prompt::new("Enter name of output file", Accept::TEXT), + Ask::Replace => Prompt::new(format!("{} already exists, do you want to replace it?", self.path), KW).kw(&["Yes", "No"]).default("N"), + Ask::Precision => Prompt::new("Enter decimal places of accuracy (0 to 16)", Accept::NUMBER).kw(&["Binary", "Preview"]).default("16"), + Ask::Block => Prompt::new("Enter name of existing block", Accept::TEXT).kw(&[SAME_NAME, WHOLE]).default("define new drawing"), + Ask::Base => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["mOde"]), + Ask::Mode => { + Prompt::new("Choose mode for selected object conversion", KW).kw(&["Convert to block", "Retain", "Delete"]).default("Delete") + } + Ask::Objects => self.sel.prompt(), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.ask { + Ask::File => { + if let Input::Text(t) = &i + && !t.trim().trim_matches('"').is_empty() + { + self.path = output_path(t); + self.ask = if exists(&self.path) { Ask::Replace } else { self.after_file(s) }; + } + Ok(Step::Continue) + } + Ask::Replace => match &i { + // Yes goes on; No, the default, ends the command; anything else is asked again. + Input::Keyword(k) if k == "Yes" => { + self.ask = self.after_file(s); + Ok(Step::Continue) + } + Input::Keyword(_) | Input::Enter => Ok(Step::Done), + _ => { + s.echo("Invalid option keyword."); + Ok(Step::Continue) + } + }, + Ask::Precision => { + match &i { + Input::Enter => self.ask = Ask::Block, + // The file is written at full precision whatever is asked. + Input::Text(t) if t.trim().parse::().is_ok_and(|n| n <= 16) => self.ask = Ask::Block, + Input::Keyword(k) if k == "Binary" => s.echo("Binary DXF output is not available."), + Input::Keyword(_) => {} + _ => s.echo("Requires an integer between 0 and 16."), + } + Ok(Step::Continue) + } + Ask::Block => { + let name = match &i { + Input::Enter => { + self.ask = Ask::Base; + return Ok(Step::Continue); + } + Input::Keyword(k) if k == WHOLE => return self.write(s, Source::Whole), + Input::Keyword(k) if k == SAME_NAME => stem(&self.path), + Input::Text(t) => t.trim().to_string(), + // A point (from a LISP list) is a name too: AutoCAD answers Block "0,0" not found. + Input::Point(p) => format!("{},{}", p.x, p.y), + _ => return Ok(Step::Continue), + }; + if s.doc()?.block(&name).is_none() { + s.echo(format!("Block \"{name}\" not found.")); + return Ok(Step::Continue); + } + self.write(s, Source::Block(&name, None)) + } + Ask::Base => { + match i { + Input::Keyword(_) => self.ask = Ask::Mode, + Input::Enter | Input::Text(_) => s.echo("Point or option keyword required."), + Input::Point(p) => { + self.base = p; + self.sel = SelectPhase::begin(s); + if self.sel.done { + let picked = self.sel.picked.clone(); + return self.write_objects(s, &picked); + } + self.ask = Ask::Objects; + } + _ => {} + } + Ok(Step::Continue) + } + Ask::Mode => { + // Any answer, Enter included, goes back to the base point. + self.ask = Ask::Base; + Ok(Step::Continue) + } + Ask::Objects => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + // Nothing selected: nothing is written. + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => self.write_objects(s, &hs), + }, + } + } +} + +impl DashWblockM { + /// The prompt after the file name: a DXF file's precision, else the block. + fn after_file(&self, s: &mut Session) -> Ask { + if self.path.to_ascii_lowercase().ends_with(".dxf") { + s.echo("Current DXF settings: Precision= 16 Format= ASCII Preview= No"); + Ask::Precision + } else { + Ask::Block + } + } + + fn write(&self, s: &mut Session, what: Source) -> Result { + if let Err(e) = write(s, "-wblock", &self.path, what) { + s.echo(e.to_string()); + } + Ok(Step::Done) + } + + fn write_objects(&self, s: &mut Session, hs: &[Handle]) -> Result { + if hs.is_empty() { + return Ok(Step::Done); + } + match write(s, "-wblock", &self.path, Source::Objects(hs, self.base)) { + Ok(_) => erase(s, hs)?, + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Done) + } +} diff --git a/crates/engine/tests/dash_wblock.rs b/crates/engine/tests/dash_wblock.rs new file mode 100644 index 000000000..348cde45a --- /dev/null +++ b/crates/engine/tests/dash_wblock.rs @@ -0,0 +1,244 @@ +//! -WBLOCK at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts, the +//! drawing read back afterwards). It writes objects from a base point and erases them, whatever +//! its mOde option says; a block by name, `=` (the block named as the file) or `*` (the whole +//! drawing); asks before replacing a file; and writes nothing when nothing is selected. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::{Drawing, EntityKind, Handle}; +use serde_json::json; + +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +/// One line per object written: a line's start (where the base point puts it), else its type. +fn write(d: &Drawing, _path: &str) -> Result, String> { + let mut out = String::new(); + for e in d.model.iter() { + match &e.kind { + EntityKind::Line(l) => out.push_str(&format!("LINE {},{}\n", l.a.x, l.a.y)), + k => out.push_str(&format!("{}\n", k.dxf_name())), + } + } + Ok(out.into_bytes()) +} + +/// A session writing through [`write`], with the drawing the measured scripts start from: line A +/// (0,0)-(10,0), circle B at (5,5) radius 3, line C (0,20)-(10,20). +fn drawing() -> (Session, Handle, Handle, Handle) { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + let handle = |v: serde_json::Value| Handle::parse_hex(v["handles"][0].as_str().or(v["handle"].as_str()).unwrap()).unwrap(); + let a = handle(s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap()); + let b = handle(s.execute("circle", &json!({ "center": [5, 5], "radius": 3 })).unwrap()); + let c = handle(s.execute("line", &json!({ "points": [[0, 20], [10, 20]] })).unwrap()); + (s, a, b, c) +} + +/// A file in a test's own folder, with forward slashes for a LISP string. +fn file(dir: &TempDir, name: &str) -> String { + dir.path_str(name).replace('\\', "/") +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); +} + +fn ent(h: Handle) -> String { + format!("(handent \"{}\")", h.hex()) +} + +fn alive(s: &Session, h: Handle) -> bool { + s.doc().unwrap().entity(h).is_some() +} + +fn read(path: &str) -> Vec { + std::fs::read_to_string(path).unwrap().lines().map(str::to_string).collect() +} + +#[test] +fn objects_are_written_from_the_base_point_and_erased() { + // WBLOCK-000: A and B from (5,5); AutoCAD erases both and keeps C. + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + let path = file(&dir, "WB00.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(5.0 5.0) {} {} \"\")", ent(a), ent(b))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]); + assert!(!alive(&s, a) && !alive(&s, b) && alive(&s, c)); + assert!(s.log.iter().any(|l| l == "Current settings: Object conversion=Delete"), "{:?}", s.log); +} + +#[test] +fn the_prompts_are_autocads() { + let (mut s, a, _, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.cmdline("-wblock").unwrap(); + assert!(s.prompt_text().ends_with("Enter name of output file:"), "{}", s.prompt_text()); + s.cmdline(&file(&dir, "P.dwg")).unwrap(); + assert!( + s.prompt_text().ends_with("Enter name of existing block or [= (block=output file)/* (whole drawing)] :"), + "{}", + s.prompt_text() + ); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text()); + // WBLOCK-008: anything but a point or mOde there. + s.cmdline("").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Point or option keyword required.")); + s.cmdline("_O").unwrap(); + assert!(s.prompt_text().ends_with("[Convert to block/Retain/Delete] :"), "{}", s.prompt_text()); + s.cmdline("_R").unwrap(); + assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + assert!(s.prompt_text().ends_with("Select objects:"), "{}", s.prompt_text()); + lisp(&mut s, &ent(a)); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); +} + +#[test] +fn mode_is_asked_but_the_objects_are_erased_whatever_it_says() { + // WBM-01, WBM-02: Retain and Convert to block are taken, yet A is erased (no block reference + // takes its place), and the next -WBLOCK still says "Object conversion=Delete". + for mode in ["_R", "_C"] { + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + let first = file(&dir, "M01a.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{first}\" \"\" \"_O\" \"{mode}\" '(0.0 0.0) {} \"\")", ent(a))); + assert!(s.running.is_none(), "{mode}: {}", s.prompt_text()); + assert_eq!(read(&first), ["LINE 0,0"]); + assert!(!alive(&s, a) && alive(&s, b) && alive(&s, c), "{mode}"); + assert_eq!(s.doc().unwrap().model.len(), 2, "{mode}: nothing takes A's place"); + let before = s.log.len(); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "M01b.dwg"), ent(b))); + assert!(s.log[before..].iter().any(|l| l == "Current settings: Object conversion=Delete"), "{mode}: {:?}", s.log); + assert!(!alive(&s, b) && alive(&s, c), "{mode}"); + } +} + +#[test] +fn nothing_selected_writes_nothing() { + // WBM-05: the command ends; no file, nothing erased. + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + let path = file(&dir, "M05.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(0.0 0.0) \"\")")); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(!std::path::Path::new(&path).exists()); + assert!(alive(&s, a) && alive(&s, b) && alive(&s, c)); +} + +#[test] +fn an_existing_file_is_replaced_only_on_yes() { + // WBM-06: No (the default) ends the command; WBM-07: Yes goes on to the block prompt. + let (mut s, a, b, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + let path = file(&dir, "M06.dwg"); + std::fs::write(&path, "old").unwrap(); + s.cmdline("-wblock").unwrap(); + s.cmdline(&path).unwrap(); + assert!(s.prompt_text().ends_with(&format!("{path} already exists, do you want to replace it? [Yes/No] :")), "{}", s.prompt_text()); + // Only Yes replaces it: other text is asked again. + s.cmdline("yodel").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword.")); + assert!(s.prompt_text().ends_with("[Yes/No] :"), "{}", s.prompt_text()); + s.cmdline("_N").unwrap(); + assert!(s.running.is_none()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old"); + // Enter is No too. + s.cmdline("-wblock").unwrap(); + s.cmdline(&path).unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"_Y\" \"\" '(0.0 0.0) {} \"\")", ent(b))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["CIRCLE"]); + assert!(alive(&s, a) && !alive(&s, b)); +} + +#[test] +fn a_block_is_written_by_name_by_equals_or_the_whole_drawing_by_star() { + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.execute("block", &json!({ "name": "WB01K", "base": [5, 5], "handles": [a.hex(), b.hex()], "keep": "delete" })).unwrap(); + // WBLOCK-001: a block by name, from its base point; nothing is erased. + let path = file(&dir, "WB01.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"WB01K\")")); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]); + assert!(alive(&s, c)); + // WBLOCK-002: "=" is the block named as the file. + let path = file(&dir, "WB01K.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"=\")")); + assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]); + // WBLOCK-003: "*" is the whole drawing. + let path = file(&dir, "WB03.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"*\")")); + assert_eq!(read(&path), ["LINE 0,20"]); + assert!(alive(&s, c)); + // WBLOCK-005: a point given there is a name too. + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" '(0.0 0.0))", file(&dir, "WB05.dwg"))); + assert!(s.log.iter().any(|l| l == "Block \"0,0\" not found."), "{:?}", s.log); + s.cancel(); + // WBLOCK-008, WBLOCK-015: a name that isn't a block, typed or by "=", is refused and asked again. + for (answer, name) in [("WB08ZZ", "WB08ZZ"), ("=", "WB15")] { + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"{answer}\")", file(&dir, "WB15.dwg"))); + assert!(s.log.iter().any(|l| *l == format!("Block \"{name}\" not found.")), "{answer}: {:?}", s.log); + assert!(s.prompt_text().contains("Enter name of existing block"), "{}", s.prompt_text()); + s.cancel(); + } + assert!(!std::path::Path::new(&file(&dir, "WB15.dwg")).exists()); +} + +#[test] +fn a_name_without_extension_is_a_dwg_and_a_dxf_asks_for_its_precision() { + // WBLOCK-004: no extension. + let (mut s, a, b, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "WB04"), ent(a))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&file(&dir, "WB04.dwg")), ["LINE 0,0"]); + // WBLOCK-010: a DXF file asks for its decimal places first; Enter takes 16. + let path = file(&dir, "WB10.dxf"); + s.cmdline("-wblock").unwrap(); + s.cmdline(&path).unwrap(); + assert!(s.log.iter().any(|l| l.starts_with("Current DXF settings: Precision= 16 Format= ASCII")), "{:?}", s.log); + assert!(s.prompt_text().ends_with("Enter decimal places of accuracy (0 to 16) or [Binary/Preview] <16>:"), "{}", s.prompt_text()); + s.cancel(); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" \"\" '(0.0 0.0) {} \"\")", ent(b))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["CIRCLE"]); + assert!(!alive(&s, b)); +} + +#[test] +fn undo_brings_the_written_objects_back() { + // WBM-09: U after -WBLOCK. + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(5.0 5.0) {} {} \"\")", file(&dir, "M09.dwg"), ent(a), ent(b))); + assert!(!alive(&s, a) && !alive(&s, b)); + s.cmdline("u").unwrap(); + assert!(alive(&s, a) && alive(&s, b) && alive(&s, c)); +} + +#[test] +fn the_json_form_of_dash_wblock_erases_what_it_writes() { + let (mut s, a, b, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.execute("wblock", &json!({ "path": file(&dir, "keep.dwg"), "handles": [a.hex()] })).unwrap(); + assert!(alive(&s, a), "WBLOCK's JSON form keeps them"); + s.execute("-wblock", &json!({ "path": file(&dir, "erase.dwg"), "handles": [b.hex()], "base": [5, 5] })).unwrap(); + assert_eq!(read(&file(&dir, "erase.dwg")), ["CIRCLE"]); + assert!(!alive(&s, b)); + // Nothing selected writes no file, as at the command line. + let none = file(&dir, "none.dwg"); + assert!(s.execute("-wblock", &json!({ "path": none, "handles": [] })).is_err()); + assert!(s.execute("-wblock", &json!({ "path": none, "handles": [b.hex()] })).is_err(), "erased already"); + assert!(s.execute("-wblock", &json!({ "path": none })).is_err()); + assert!(!std::path::Path::new(&none).exists()); +} From 0e2bff3b2b22c922b03ffa811cd3fc18f1d92d17 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:37:56 +0700 Subject: [PATCH 080/151] LEADER at the command line, as AutoCAD runs it (#2596) LEADER had only its JSON form, so a script's LEADER drew nothing. This adds the command-line command with AutoCAD 2024's prompts: the points, Format (Spline/STraight/Arrow/None) and Undo, then annotation lines, or the options None, Block, Copy, and Mtext and Tolerance (which come back to the first line at the command line, as in accoreconsole). Measured, and followed: - A hook line, a horizontal last segment DIMASZ long, is added when the last segment is straight and more than 15 degrees off horizontal and text follows: 14.9 and 15 degrees keep the end, 15.1 hooks. A spline, no annotation and a block take none. - The text sits DIMGAP past the end on the side the leader runs to: above it, bottom-attached, with DIMTAD on; centred on it with DIMTAD 0. Sizes are DIMSCALE times the style's, with SETVAR overrides. - A copied text keeps its contents, height and width, is centred on its line, and with DIMTAD on the leader ends half the text height lower. The JSON form now draws through the same code. The drawing model has no link from a leader to its annotation, so the two are separate objects. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 6 +- crates/engine/src/cmd/annotate/leader.rs | 330 +++++++++++++++++++++ crates/engine/src/cmd/annotate/mleader.rs | 30 +- crates/engine/tests/it/leader_command.rs | 340 ++++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 5 files changed, 678 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/cmd/annotate/leader.rs create mode 100644 crates/engine/tests/it/leader_command.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 08f87fc4b..d983ce25d 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -16,6 +16,7 @@ use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions mod angular; mod common; mod edit; +mod leader; mod linear; mod mleader; mod radial; @@ -88,7 +89,10 @@ pub fn specs() -> Vec { .alias(&["mld"]) .params("{points: [[arrow], ..., [landing]] ([location] with leaderType none), text?, leaderType?: straight|spline|none, block?: name, blockAttach?: center|insertion}") .interactive(|s| Ok(Box::new(MLeaderM::new(s)))), - CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"), + CommandSpec::new("leader", "Leader", run_leader) + .alias(&["lead"]) + .params("{points: [[x,y]...], text?}") + .interactive(|_| Ok(Box::new(leader::LeaderM::default()))), CommandSpec::new("dimstyle.update", "Update", run_update) .menu(&["Dimension", "Update"]) .params("{handles?}") diff --git a/crates/engine/src/cmd/annotate/leader.rs b/crates/engine/src/cmd/annotate/leader.rs new file mode 100644 index 000000000..1c5ce7276 --- /dev/null +++ b/crates/engine/src/cmd/annotate/leader.rs @@ -0,0 +1,330 @@ +//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole, synthetic drawings): the +//! points, Format and Undo, then annotation text, no annotation, or a block. +//! +//! The leader takes a hook line, a horizontal last segment DIMASZ long, when its last segment is +//! straight and more than 15° off horizontal and text follows it; the text sits DIMGAP past the +//! leader's end, above it (DIMTAD on) or centred on it (DIMTAD 0), on the side the leader runs to. +//! All sizes are scaled by DIMSCALE. The text and the leader are separate objects here: the +//! drawing model has no link from a leader to its annotation. + +use super::common::style; +use super::{Accept, EntityKind, Handle, Input, Interactive, MText, Prompt, Result, Session, Step, Vec2, v3}; +use crate::cmd::curves::KW; + +/// Where LEADER is in its prompts. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Ask { + #[default] + Points, + Format, + FirstLine, + NextLine, + Option, + /// Copy: the text or multiline text to copy. + Copy, + BlockName, + BlockPoint, + BlockX, + BlockY, + BlockRotation, +} + +#[derive(Default)] +pub(super) struct LeaderM { + ask: Ask, + pts: Vec, + spline: bool, + no_arrow: bool, + lines: Vec, + block: String, + block_at: Vec2, + block_scale: Vec2, +} + +/// What follows the leader. +enum Annotation { + Text(Vec), + /// A copy of a text or multiline text: its contents, height, width and style. + Copy(MText), + None, + Block { + name: String, + at: Vec2, + scale: Vec2, + rotation: f64, + }, +} + +impl Interactive for LeaderM { + fn name(&self) -> &'static str { + "LEADER" + } + fn prompt(&self, _s: &Session) -> Prompt { + let last = self.pts.last().copied(); + match self.ask { + Ask::Points => match self.pts.len() { + 0 => Prompt::new("Specify leader start point", Accept::POINT), + 1 => Prompt::new("Specify next point", Accept::POINT).base_opt(last), + _ => Prompt::new("Specify next point", Accept::POINT).kw(&["Annotation", "Format", "Undo"]).default("Annotation").base_opt(last), + }, + Ask::Format => Prompt::new("Enter leader format option", KW).kw(&["Spline", "STraight", "Arrow", "None"]).default("Exit"), + // "or ": the default is the options, not a keyword list. + Ask::FirstLine => Prompt::new("Enter first line of annotation text or", Accept::TEXT).default("options"), + Ask::NextLine => Prompt::new("Enter next line of annotation text", Accept::TEXT), + Ask::Copy => Prompt::new("Select an object to copy", Accept::SELECT), + Ask::Option => Prompt::new("Enter an annotation option", KW).kw(&["Tolerance", "Copy", "Block", "None", "Mtext"]).default("Mtext"), + Ask::BlockName => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]), + Ask::BlockPoint => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]), + Ask::BlockX => Prompt::new("Enter X scale factor, specify opposite corner", Accept::NUMBER).kw(&["Corner", "XYZ"]).default("1"), + Ask::BlockY => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"), + Ask::BlockRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.ask { + Ask::Points => match i { + Input::Point(p) => self.pts.push(p), + // Enter after one point: no leader. + Input::Enter if self.pts.len() < 2 => return Ok(Step::Done), + Input::Enter => self.ask = Ask::FirstLine, + Input::Keyword(k) => match k.as_str() { + "Annotation" => self.ask = Ask::FirstLine, + "Format" => self.ask = Ask::Format, + _ => { + self.pts.pop(); + } + }, + _ => {} + }, + Ask::Format => { + // Enter, or Exit typed, changes nothing; either way back to the points. + if let Input::Keyword(k) = &i { + match k.as_str() { + "Spline" => self.spline = true, + "STraight" => self.spline = false, + "Arrow" => self.no_arrow = false, + _ => self.no_arrow = true, + } + } + self.ask = Ask::Points; + } + Ask::FirstLine | Ask::NextLine => match i { + Input::Text(t) if !t.is_empty() => { + self.lines.push(t); + self.ask = Ask::NextLine; + } + _ if self.ask == Ask::NextLine => { + let lines = std::mem::take(&mut self.lines); + return self.finish(s, Annotation::Text(lines)); + } + _ => self.ask = Ask::Option, + }, + Ask::Option => match &i { + Input::Keyword(k) if k == "None" => return self.finish(s, Annotation::None), + Input::Keyword(k) if k == "Block" => self.ask = Ask::BlockName, + Input::Keyword(k) if k == "Copy" => self.ask = Ask::Copy, + Input::Text(_) => s.echo("Invalid option keyword."), + // Mtext (the default) and Tolerance open an editor or a dialog in AutoCAD; at the + // command line, as in accoreconsole, they come back to the first line. + _ => self.ask = Ask::FirstLine, + }, + Ask::Copy => { + let picked = match &i { + Input::Pick(hs) => hs.first().copied(), + Input::Point(p) => crate::select::pick(s.doc()?, &s.state()?.edit_space(), *p, s.settings.pickbox.max(1.0) * s.pixel_size()), + _ => None, + }; + let text = picked.and_then(|h| s.doc().ok()?.entity(h).cloned()).and_then(|e| match &e.kind { + EntityKind::Text(t) => Some(MText { + insert: t.insert, + height: t.height, + width: 0.0, + attach: 4, + rotation: 0.0, + style: t.style.clone(), + contents: t.value.clone(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }), + EntityKind::MText(m) => Some(m.clone()), + _ => None, + }); + match text { + Some(m) => return self.finish(s, Annotation::Copy(m)), + None if picked.is_some() => s.echo("The object is not text."), + None => {} + } + } + Ask::BlockName => { + let name = match i { + Input::Text(t) => t.trim().to_string(), + Input::Keyword(k) => k, + _ => return Ok(Step::Continue), + }; + if name == "?" { + let names: Vec = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect(); + s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") })); + } else if s.doc()?.block(&name).is_none() { + s.echo(format!("Block \"{name}\" not found.")); + } else { + self.block = name; + self.ask = Ask::BlockPoint; + } + } + Ask::BlockPoint => match i { + Input::Point(p) => { + self.block_at = p; + self.ask = Ask::BlockX; + } + Input::Keyword(_) => s.echo("This option is not available here yet."), + _ => {} + }, + Ask::BlockX => match scale(&i) { + Some(x) => { + self.block_scale = Vec2::new(x, x); + self.ask = Ask::BlockY; + } + None => s.echo("Requires a nonzero number."), + }, + Ask::BlockY => { + match scale(&i) { + Some(y) if !matches!(i, Input::Enter) => self.block_scale.y = y, + Some(_) => {} + None => { + s.echo("Requires a nonzero number."); + return Ok(Step::Continue); + } + } + self.ask = Ask::BlockRotation; + } + Ask::BlockRotation => { + let rotation = match &i { + Input::Enter => 0.0, + Input::Text(t) => match s.angle_settings().direction(t) { + Some(a) => a, + None => { + s.echo("Requires a valid angle."); + return Ok(Step::Continue); + } + }, + _ => return Ok(Step::Continue), + }; + let a = Annotation::Block { name: self.block.clone(), at: self.block_at, scale: self.block_scale, rotation }; + return self.finish(s, a); + } + } + Ok(Step::Continue) + } +} + +/// A scale factor typed, or 1 for Enter; `None` for zero or anything that isn't a number. +fn scale(i: &Input) -> Option { + match i { + Input::Enter => Some(1.0), + Input::Text(t) => t.trim().parse::().ok().filter(|v| v.is_finite() && *v != 0.0), + _ => None, + } +} + +impl LeaderM { + fn finish(&mut self, s: &mut Session, ann: Annotation) -> Result { + let pts = std::mem::take(&mut self.pts); + add_leader(s, &pts, self.spline, !self.no_arrow, ann)?; + Ok(Step::Done) + } +} + +/// The dimension settings LEADER draws with, from the current dimension style and DIMSCALE. +struct Sizes { + arrow: f64, + gap: f64, + text: f64, + above: bool, + text_style: String, +} + +fn sizes(s: &Session) -> Sizes { + // The current style with the drawing's DIM* overrides (what SETVAR DIMTXT… sets). + let st = s.doc().ok().and_then(|d| d.dim_style(&style(s)).map(|st| st.with_overrides(&d.dim_overrides()))).unwrap_or_default(); + let k = if st.scale > 0.0 { st.scale } else { 1.0 }; + let text_style = if st.text_style.is_empty() { "Standard".to_string() } else { st.text_style.clone() }; + Sizes { arrow: st.arrow_size * k, gap: st.text_gap.abs() * k, text: st.text_height * k, above: st.text_above != 0, text_style } +} + +/// LEADER's JSON form: a straight leader with an arrow through `pts`, and `text` (lines split at +/// `\n`) placed as the command line places it. The handles of the leader and its text. +pub(super) fn json_leader(s: &mut Session, pts: &[Vec2], text: Option<&str>) -> Result> { + let ann = match text { + Some(t) if !t.is_empty() => Annotation::Text(t.split('\n').map(str::to_string).collect()), + _ => Annotation::None, + }; + add_leader(s, pts, false, true, ann) +} + +/// Draw the leader through `pts` and its annotation, as AutoCAD 2024 places them; their handles. +fn add_leader(s: &mut Session, pts: &[Vec2], spline: bool, arrow: bool, ann: Annotation) -> Result> { + let (Some(&end), Some(&before)) = (pts.last(), pts.len().checked_sub(2).and_then(|i| pts.get(i))) else { return Ok(Vec::new()) }; + let z = sizes(s); + // The side the leader runs to: right unless its last segment runs left. + let dir = if end.x < before.x { -1.0 } else { 1.0 }; + let mut vertices = pts.to_vec(); + if let Annotation::Text(_) | Annotation::Copy(_) = ann { + let d = end - before; + // A copied text is centred on its line, and with DIMTAD on that line is above the leader: + // the leader ends half the text height lower. + if let (Annotation::Copy(_), true, Some(last)) = (&ann, z.above, vertices.last_mut()) { + last.y -= z.text / 2.0; + } + let end = vertices.last().copied().unwrap_or(end); + // More than 15° off horizontal: a hook line DIMASZ long into the end. + if !spline && d.y.abs() > d.x.abs() * 15f64.to_radians().tan() { + let at = vertices.len() - 1; + vertices.insert(at, Vec2::new(end.x - dir * z.arrow, end.y)); + } + } + let leader = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader { + vertices: vertices.iter().map(|p| v3(*p)).collect(), + arrow, + spline, + style: style(s), + }))?; + let annotation = match ann { + Annotation::Text(lines) => { + let (attach, lift) = match (z.above, dir > 0.0) { + (true, true) => (7, z.gap), + (true, false) => (9, z.gap), + (false, true) => (4, 0.0), + (false, false) => (6, 0.0), + }; + Some(s.add_entity(EntityKind::MText(MText { + insert: v3(end + Vec2::new(dir * z.gap, lift)), + height: z.text, + width: 0.0, + attach, + rotation: 0.0, + style: z.text_style, + contents: lines.join("\\P"), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }))?) + } + Annotation::Copy(m) => { + let lift = if z.above { z.gap } else { 0.0 }; + let attach = if dir > 0.0 { 4 } else { 6 }; + Some(s.add_entity(EntityKind::MText(MText { insert: v3(end + Vec2::new(dir * z.gap, lift)), attach, rotation: 0.0, ..m }))?) + } + Annotation::Block { name, at, scale, rotation } => { + match crate::cmd::blocks::insert_with(s, &name, at, scale, rotation, None, &serde_json::Map::new()) { + Ok(h) => Some(h), + Err(e) => { + s.doc_mut()?.remove_entity(leader); + return Err(e); + } + } + } + Annotation::None => None, + }; + Ok(std::iter::once(leader).chain(annotation).collect()) +} diff --git a/crates/engine/src/cmd/annotate/mleader.rs b/crates/engine/src/cmd/annotate/mleader.rs index 536e83b8c..9b4e74128 100644 --- a/crates/engine/src/cmd/annotate/mleader.rs +++ b/crates/engine/src/cmd/annotate/mleader.rs @@ -1,4 +1,3 @@ -use super::common::style; use super::{ Accept, EngineError, EntityKind, Input, Interactive, LeaderOrder, MLeader, MLeaderOptions, MText, Prompt, Result, Session, Step, Value, Vec2, bad, json, points_param, str_param, v3, @@ -57,33 +56,8 @@ pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result { if pts.len() < 2 { return Err(bad("leader", "need 2+ points")); } - let st = style(s); - let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader { - vertices: pts.iter().map(|q| v3(*q)).collect(), - arrow: true, - spline: false, - style: st, - }))?; - let mut out = vec![h.hex()]; - if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) { - let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18); - out.push( - s.add_entity(EntityKind::MText(MText { - insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)), - height: th, - width: 0.0, - attach: 4, - rotation: 0.0, - style: "Standard".into(), - contents: t.into(), - line_spacing: 1.0, - line_spacing_exact: false, - columns: None, - }))? - .hex(), - ); - } - Ok(json!({ "handles": out })) + let hs = super::leader::json_leader(s, &pts, str_param(p, "text"))?; + Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::>() })) } /// The MLEADER Options prompt being answered. diff --git a/crates/engine/tests/it/leader_command.rs b/crates/engine/tests/it/leader_command.rs new file mode 100644 index 000000000..f71c99462 --- /dev/null +++ b/crates/engine/tests/it/leader_command.rs @@ -0,0 +1,340 @@ +//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts, with +//! DIMTAD 1, DIMGAP 0.625, DIMASZ 2.5, DIMSCALE 1 and DIMTXT 2.5 unless a case sets another, and +//! the drawing read back afterwards). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Insert, Leader, MText}; +use serde_json::json; + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0) +} + +/// A drawing as the measured scripts start from: the dimension settings, and a text "XYZ" 2.5 +/// high at (100,40) that the Copy cases copy (`(entlast)` after it). +fn session(setup: &str) -> Session { + let mut s = Session::new(); + for (name, v) in [("DIMTAD", 1.0), ("DIMGAP", 0.625), ("DIMASZ", 2.5), ("DIMSCALE", 1.0), ("DIMTXT", 2.5)] { + s.execute("setvar", &json!({ "name": name, "value": v })).unwrap(); + } + s.execute("text", &json!({ "at": [100, 40], "text": "XYZ", "height": 2.5 })).unwrap(); + s.cmdline("(setq hl_T (entlast))").unwrap(); + if !setup.is_empty() { + s.cmdline(setup).unwrap(); + } + s +} + +fn run(setup: &str, script: &str) -> Session { + let mut s = session(setup); + s.cmdline(script).unwrap(); + assert!(s.running.is_none(), "{script}: {}", s.prompt_text()); + s +} + +fn leaders(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Leader(l) = &e.kind { Some(l.clone()) } else { None }).collect() +} + +fn mtexts(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.clone()) } else { None }).collect() +} + +fn inserts(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect() +} + +fn vertices(l: &Leader) -> Vec<[f64; 2]> { + l.vertices.iter().map(|v| [v.x, v.y]).collect() +} + +fn same_points(got: &[[f64; 2]], want: &[[f64; 2]]) -> bool { + got.len() == want.len() && got.iter().zip(want).all(|(g, w)| near(g[0], w[0]) && near(g[1], w[1])) +} + +/// (case, setup, script, leader vertices, text insertion point, attachment, height, contents) +type Case = (&'static str, &'static str, &'static str, &'static [[f64; 2]], [f64; 2], u8, f64, &'static str); + +#[test] +fn leaders_and_their_text_are_where_autocad_puts_them() { + let cases: [Case; 17] = [ + // A horizontal last segment: no hook line; the text DIMGAP right of and above the end. + ( + "000", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]], + [40.625, 15.625], + 7, + 2.5, + "ABC", + ), + // More than 15° off horizontal: a hook line DIMASZ long into the end. + ( + "001", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [32.5, 30.0], [35.0, 30.0]], + [35.625, 30.625], + 7, + 2.5, + "ABC", + ), + ( + "002", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"AB\" \"CD\" \"\")", + &[[0.0, 0.0], [17.5, 15.0], [20.0, 15.0]], + [20.625, 15.625], + 7, + 2.5, + "AB\\PCD", + ), + // To the left: the text right-attached, DIMGAP left of the end. + ( + "004", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(-20.0 15.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [-17.5, 15.0], [-20.0, 15.0]], + [-20.625, 15.625], + 9, + 2.5, + "ABC", + ), + // A vertical last segment hooks to the right. + ( + "007-VN", + "", + "(command \"_.LEADER\" '(612345.678 2398765.432) '(612345.678 2398795.432) \"\" \"ABC\" \"\")", + &[[612345.678, 2398765.432], [612343.178, 2398795.432], [612345.678, 2398795.432]], + [612346.303, 2398796.057], + 7, + 2.5, + "ABC", + ), + // 14.9° and 15° keep the end; 15.1° hooks. + ( + "100", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.32158853384029) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 20.32158853384029]], + [40.625, 20.94658853384029], + 7, + 2.5, + "ABC", + ), + ( + "101", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.358983848622454) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 20.358983848622454]], + [40.625, 20.983983848622454], + 7, + 2.5, + "ABC", + ), + ( + "102", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.39641415635812) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [37.5, 20.39641415635812], [40.0, 20.39641415635812]], + [40.625, 21.02141415635812], + 7, + 2.5, + "ABC", + ), + // DIMTAD 0: centred on the end, left- or right-attached. + ( + "020", + "(setvar \"DIMTAD\" 0)", + "(command \"_.LEADER\" '(0.5 0.25) '(0.52 0.265) '(0.54 0.265) \"\" \"ABC\" \"\")", + &[[0.5, 0.25], [0.52, 0.265], [0.54, 0.265]], + [1.165, 0.265], + 4, + 2.5, + "ABC", + ), + ( + "103-VN", + "(setvar \"DIMTAD\" 0)", + "(command \"_.LEADER\" '(612345.678 2398765.432) '(612325.678 2398780.432) '(612305.678 2398780.432) \"\" \"ABC\" \"\")", + &[[612345.678, 2398765.432], [612325.678, 2398780.432], [612305.678, 2398780.432]], + [612305.053, 2398780.432], + 6, + 2.5, + "ABC", + ), + // DIMGAP, DIMSCALE (hook, gap and height) and DIMTXT. + ( + "021", + "(setvar \"DIMGAP\" 2.0)", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]], + [42.0, 17.0], + 7, + 2.5, + "ABC", + ), + ( + "022", + "(setvar \"DIMSCALE\" 2.0)", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [30.0, 30.0], [35.0, 30.0]], + [36.25, 31.25], + 7, + 5.0, + "ABC", + ), + ( + "024", + "(setvar \"DIMTXT\" 5.0)", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"AB\" \"CD\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]], + [40.625, 15.625], + 7, + 5.0, + "AB\\PCD", + ), + // A spline takes no hook line. + ( + "026", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_S\" '(35.0 30.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [35.0, 30.0]], + [35.625, 30.625], + 7, + 2.5, + "ABC", + ), + // Undo takes the last point back. + ( + "014", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"_U\" '(40.0 15.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]], + [40.625, 15.625], + 7, + 2.5, + "ABC", + ), + // Annotation options: Mtext, at the command line, comes back to the first line. + ( + "106", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_M\" \"ABC\" \"\")", + &[[0.0, 0.0], [17.5, 15.0], [20.0, 15.0]], + [20.625, 15.625], + 7, + 2.5, + "ABC", + ), + // Copy: centred on its line; with DIMTAD on the leader ends half the text height lower. + ( + "016", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_C\" hl_T)", + &[[0.0, 0.0], [17.5, 13.75], [20.0, 13.75]], + [20.625, 15.625], + 4, + 2.5, + "XYZ", + ), + ]; + for (case, setup, script, want_vertices, at, attach, height, contents) in cases { + let s = run(setup, script); + let ls = leaders(&s); + assert_eq!(ls.len(), 1, "{case}"); + assert!(same_points(&vertices(&ls[0]), want_vertices), "{case}: {:?}", vertices(&ls[0])); + let ms = mtexts(&s); + assert_eq!(ms.len(), 1, "{case}"); + let m = &ms[0]; + assert!(near(m.insert.x, at[0]) && near(m.insert.y, at[1]), "{case}: {:?}", m.insert); + assert_eq!((m.attach, m.contents.as_str()), (attach, contents), "{case}"); + assert!(near(m.height, height) && m.width == 0.0, "{case}: {} {}", m.height, m.width); + } +} + +#[test] +fn format_sets_the_path_and_the_arrow() { + // LEADER-011 Format None; LEADER-012 None then Arrow; LEADER-013 Spline then STraight. + for (script, spline, arrow) in [ + ("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_N\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, false), + ("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_N\" \"_F\" \"_A\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true), + ("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_S\" \"_F\" \"_ST\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true), + ("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true), + ] { + let l = leaders(&run("", script)).remove(0); + assert_eq!((l.spline, l.arrow), (spline, arrow), "{script}"); + } +} + +#[test] +fn no_annotation_a_block_or_a_copied_multiline_text() { + // LEADER-015: None leaves the leader alone, without a hook line. + let s = run("", "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_N\")"); + assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0]])); + assert!(mtexts(&s).is_empty()); + // LEADER-107: a block, scale 2 (Y as X), 30°, where it is placed; no hook line. + let mut s = session(""); + s.execute("line", &json!({ "points": [[100, 0], [110, 0]] })).unwrap(); + s.cmdline("(command \"_.-BLOCK\" \"LB107\" '(100.0 0.0) (entlast) \"\")").unwrap(); + s.cmdline("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_B\" \"LB107\" '(50.0 20.0) 2 \"\" 30)").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0]])); + let i = inserts(&s).remove(0); + assert!(i.block == "LB107" && near(i.insert.x, 50.0) && near(i.insert.y, 20.0), "{i:?}"); + assert!(near(i.scale.x, 2.0) && near(i.scale.y, 2.0) && near(i.rotation, 30f64.to_radians()), "{i:?}"); + // LEADER-105: a copied multiline text keeps its width; LEADER-137 to the left. + let mut s = session(""); + s.cmdline("(command \"_.MTEXT\" '(60.0 40.0) '(90.0 30.0) \"MT\" \"\")").unwrap(); + s.cmdline("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_C\" (entlast))").unwrap(); + let copy = mtexts(&s).into_iter().find(|m| near(m.insert.x, 20.625)).unwrap(); + assert!(near(copy.width, 30.0) && copy.attach == 4 && copy.contents == "MT", "{copy:?}"); + let s = run("", "(command \"_.LEADER\" '(0.0 0.0) '(-20.0 15.0) \"\" \"\" \"_C\" hl_T)"); + assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [-17.5, 13.75], [-20.0, 13.75]])); + let m = mtexts(&s).into_iter().next().unwrap(); + assert!(near(m.insert.x, -20.625) && near(m.insert.y, 15.625) && m.attach == 6, "{m:?}"); +} + +#[test] +fn the_json_form_places_its_text_as_the_command_line_does() { + let mut s = session(""); + let v = s.execute("leader", &json!({ "points": [[0, 0], [20, 15], [35, 30]], "text": "ABC" })).unwrap(); + assert_eq!(v["handles"].as_array().map(Vec::len), Some(2)); + assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0], [32.5, 30.0], [35.0, 30.0]])); + let m = mtexts(&s).remove(0); + assert!(near(m.insert.x, 35.625) && near(m.insert.y, 30.625) && m.attach == 7 && near(m.height, 2.5), "{m:?}"); +} + +#[test] +fn the_prompts_are_autocads_and_one_point_draws_nothing() { + let mut s = session(""); + s.cmdline("leader").unwrap(); + assert!(s.prompt_text().ends_with("Specify leader start point:"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + assert!(s.prompt_text().ends_with("Specify next point:"), "{}", s.prompt_text()); + s.cmdline("20,15").unwrap(); + assert!(s.prompt_text().ends_with("Specify next point or [Annotation/Format/Undo] :"), "{}", s.prompt_text()); + s.cmdline("_F").unwrap(); + assert!(s.prompt_text().ends_with("[Spline/STraight/Arrow/None] :"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("Enter first line of annotation text or :"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("[Tolerance/Copy/Block/None/Mtext] :"), "{}", s.prompt_text()); + // LEADER-019: Tolerance, at the command line, comes back to the first line. + s.cmdline("_T").unwrap(); + assert!(s.prompt_text().ends_with("Enter first line of annotation text or :"), "{}", s.prompt_text()); + // An answer that isn't an option is refused; `?` at the block name lists the blocks. + s.cmdline("").unwrap(); + s.cmdline("X").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword.")); + s.cmdline("_B").unwrap(); + assert!(s.prompt_text().ends_with("Enter block name or [?]:"), "{}", s.prompt_text()); + s.cmdline("?").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Defined blocks: (none)")); + s.cancel(); + // LEADER-139: one point and Enter. + let s = run("", "(command \"_.LEADER\" '(0.0 0.0) \"\")"); + assert!(leaders(&s).is_empty() && mtexts(&s).is_empty()); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 88cbefe31..2aae7c117 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -70,6 +70,7 @@ mod layer_cmdline; mod layer_merge_delete; mod layoff_repeat; mod layout_prompts; +mod leader_command; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod lisp_corpus; mod malformed_handles; From 4a9371ad2cacd067837d30ff03ec592820fcad91 Mon Sep 17 00:00:00 2001 From: NateMarida Date: Mon, 2 Nov 2026 02:38:00 +0700 Subject: [PATCH 081/151] Thai text: stacked marks, Thai fonts first, MTEXT word wrap (#2591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Thai text: stacked marks, Thai fonts first, MTEXT word wrap, Windows fonts under WSL Thai text in DWG/DXF files decoded correctly but laid out badly. Vowels and tone marks stacked on one consonant collided (ดิ์), because only right-to-left text went through the shaper. - Thai lines go through the harfrust shaper (complex::needed). Shaped left-to-right glyphs keep their characters (TextGlyph) for PDF text. - Thai fallback fonts: Cordia, Angsana and Browallia first (Thai drawings are laid out with their widths), then IBM Plex Sans Thai, Noto Sans/Serif Thai and the fonts each OS ships. - The web build embeds the craft-fonts Thai regular faces. - MTEXT wraps a run of Thai between words when the run is too wide for the column by itself. Text that fits at its spaces keeps those line breaks, so existing drawings lay out as before. Word breaks come from icu_segmenter's LSTM model (about 410 KB). ICU's dictionary segmenter was not used: after loanwords it breaks inside words, even between a consonant and its tone mark. Breaks inside a character cluster are dropped, and results are cached. - Under WSL the font scan also reads /mnt/c/Windows/Fonts, at the lowest priority, so drawings find the Windows fonts they name (Cordia New). The scan reads entry types from the directory listing instead of a stat per file. * Thai fallback: match older Angsana and Browallia file names; document touched functions Today's Windows ships Angsana New and Browallia New as angsana.ttc and browalia.ttc. Older Windows used single faces (angsa.ttf, angsab.ttf, browa.ttf, ...), which still turn up when fonts are copied to Linux machines. The Thai fallback list now matches the shorter stems `angsa` and `browa`, which cover both. Review feedback on #2591. Doc comments for scan, plain_lines and the paragraph layout's word_width and place. * MTEXT: wrap a Thai run after a first-line indent or a tab A Thai run that fit a line at the left indent, but not the room left after a first-line indent or a tab stop, wasn't split between its words: the first line of a paragraph can't wrap, so it ran out of the column. An empty line now measures the run from where it starts. Review feedback on #2591. Also restores the space in `image = {` in Cargo.toml. * Font scan: take the WSL font folder out of this change Thai text doesn't depend on it: with no Windows or system fonts, the craft-fonts Thai faces (Noto Sans Thai, IBM Plex Sans Thai) stack marks and wrap MTEXT the same way. Reading the Windows host's /mnt/c/Windows/Fonts under WSL is a separate decision, so the scan is back to main's: Linux system and user font folders only. --- Cargo.lock | 33 +++++ Cargo.toml | 3 + crates/fonts/Cargo.toml | 1 + crates/fonts/build.rs | 11 +- crates/fonts/src/complex.rs | 173 +++++++++++++++++++++++++- crates/fonts/src/craft_fonts.rs | 5 +- crates/fonts/src/lib.rs | 8 +- crates/fonts/src/linebreak.rs | 205 +++++++++++++++++++++++++++++++ crates/fonts/src/mtext.rs | 209 ++++++++++++++++++++++++++++---- crates/fonts/src/mtext/para.rs | 30 ++++- crates/fonts/src/ttf.rs | 102 +++++++++++++--- 11 files changed, 726 insertions(+), 54 deletions(-) create mode 100644 crates/fonts/src/linebreak.rs diff --git a/Cargo.lock b/Cargo.lock index aa4e1961c..81a772f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -728,6 +728,7 @@ version = "0.16.0" dependencies = [ "cadcraft-geom", "harfrust 0.14.0", + "icu_segmenter", "skrifa 0.48.0", "unicode-bidi", ] @@ -1115,6 +1116,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2435,6 +2445,29 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "core_maths", + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + [[package]] name = "idna" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index c6b9590fa..b84651eee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,9 @@ skrifa = "0.48" # so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text. harfrust = "0.14" unicode-bidi = "0.3.18" +# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between +# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries). +icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } encoding_rs = "0.8.35" # XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones) diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index d2bdddf88..0fc676c7b 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -13,3 +13,4 @@ cadcraft-geom = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } unicode-bidi = { workspace = true } +icu_segmenter = { workspace = true } diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 6df86f7eb..8f46dcc44 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -3,9 +3,10 @@ //! empty, `CRAFT_FONTS` is empty and the build is unchanged. It only reads the local checkout //! (no network). A bad checkout is a warning, or an error with `CRAFT_FONTS_REQUIRED=1`. //! -//! On wasm32 it embeds only BIZ UDPGothic Regular and the `Arab` faces (about 5.5 MB), to keep the -//! web build within hosting limits (Cloudflare Pages: 25 MiB per file). The web build has no -//! system fonts, so these are its only fallback for Japanese and Arabic-script text. +//! On wasm32 it embeds only BIZ UDPGothic Regular, the `Arab` faces and the `Thai` Regular faces +//! (about 5.9 MB), to keep the web build within hosting limits (Cloudflare Pages: 25 MiB per +//! file). The web build has no system fonts, so these are its only fallback for Japanese, +//! Arabic-script and Thai text. use std::fmt::Write as _; use std::path::PathBuf; @@ -54,8 +55,8 @@ fn craft_fonts(dir: &std::path::Path) -> Result { println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)"); continue; } - let arabic = scripts.split(',').any(|s| s.trim() == "Arab"); - let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") || arabic; + let has = |script: &str| scripts.split(',').any(|s| s.trim() == script); + let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") || has("Arab") || (has("Thai") && *style == "Regular"); if wasm && !web_face { continue; } diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index a8dd367c3..4a4ac461f 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -1,10 +1,15 @@ -//! Lines with right-to-left text: the line is split into bidi runs in display order +//! Lines with right-to-left text or Thai: the line is split into bidi runs in display order //! ([`crate::bidi::visual_runs`]), each run into pieces by the font that draws them (the text's //! font, a fallback font for characters it lacks, or the stroke font), and each font piece is //! shaped with `harfrust` (OpenType GSUB/GPOS: Arabic contextual forms, lam-alef and other //! ligatures, mark positioning, mirroring) before its glyphs are placed. Shaping happens per //! piece in the font that draws it, so Arabic in a stroke-font style is joined by the fallback //! font. Fonts without OpenType Arabic features get Unicode presentation forms instead. +//! +//! Thai is left to right but needs the shaper too: a consonant carries up to two stacked marks +//! above (a vowel, then a tone mark) and one below, placed by the font's `mark`/`mkmk` +//! positioning; letters with a descender (ฐ, ญ) lose it under a below vowel (`ccmp`); and sara +//! am (ำ) splits into nikhahit and sara aa with the nikhahit moved before any tone mark. use std::ops::Range; use std::panic::{AssertUnwindSafe, catch_unwind}; @@ -17,6 +22,17 @@ use crate::{Shaped, bidi, stroke}; /// Longest line (characters) laid out this way; longer lines keep one glyph per character. const MAX_CHARS: usize = 10_000; +/// Whether a line is laid out here rather than one glyph per character: it has right-to-left +/// text, or Thai (see the module notes). +pub(crate) fn needed(s: &str) -> bool { + s.chars().any(|c| bidi::is_rtl(c) || is_thai(c)) +} + +/// Whether `c` is in the Thai block. +pub(crate) fn is_thai(c: char) -> bool { + matches!(c, '\u{0E00}'..='\u{0E7F}') +} + /// What draws a piece of a run. #[derive(Clone)] enum Src { @@ -100,7 +116,11 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64 } } Src::Face(face) => { - for g in shape_run(face, &cs, r, rtl) { + let glyphs = shape_run(face, &cs, r.clone(), rtl); + // Right-to-left runs stay outlines only: their glyphs come in display order + // and include ligatures (lam-alef) that stand for several characters. + let shows = if rtl { Vec::new() } else { glyph_chars(face, &cs, &glyphs, r.end) }; + for (n, g) in glyphs.iter().enumerate() { let outline = ttf::glyph_by_id(face, g.gid); let k = h / outline.cap * wf; let dy = g.dy * h / outline.cap; @@ -108,7 +128,14 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64 for p in contours.iter_mut().flatten() { *p += Vec2::new(dy * shear, dy); } - out.push_glyph(contours, None); + let origin = Vec2::new(x + g.dx * k + dy * shear, dy); + let text = shows + .get(n) + .copied() + .flatten() + .filter(|c| !c.is_whitespace()) + .and_then(|c| ttf::text_glyph_at(&outline, c, origin, h / outline.cap, wf, shear)); + out.push_glyph(contours, text); } let adv = g.advance * k; let (u, o) = flags(g.cluster); @@ -216,6 +243,44 @@ fn shape_run(face: &Face, cs: &[char], r: Range, rtl: bool) -> Vec .collect() } +/// Longest cluster whose glyphs are matched to its characters by glyph id; the glyphs of a +/// longer one (a hostile run of thousands of stacked marks) take its characters in order. +const MAX_MATCHED_CLUSTER: usize = 32; + +/// The character each glyph of a left-to-right run shows, for output that keeps text as text +/// ([`crate::TextGlyph`]). The shaper reorders, splits and substitutes glyphs inside a cluster +/// (a letter with its marks), so within each cluster a glyph that is a character's own glyph +/// takes that character first, then the other glyphs take the cluster's remaining characters in +/// order. Each character is shown once: a glyph left over (the second half of a split Thai sara +/// am) shows none and is drawn as an outline only. +fn glyph_chars(face: &Face, cs: &[char], glyphs: &[Glyph], end: usize) -> Vec> { + let mut out: Vec> = vec![None; glyphs.len()]; + let mut i = 0; + while let Some(cluster) = glyphs.get(i).map(|g| g.cluster) { + let j = glyphs.iter().skip(i).position(|g| g.cluster != cluster).map_or(glyphs.len(), |n| i + n); + // Clusters ascend in a left-to-right run: this one's characters end where the next starts. + let to = glyphs.get(j).map_or(end, |g| g.cluster).clamp(cluster, end.max(cluster)); + let chars = cs.get(cluster..to).unwrap_or_default(); + let mut free: Vec> = chars.iter().copied().map(Some).collect(); + if chars.len() <= MAX_MATCHED_CLUSTER { + let ids = face.glyph_ids(chars); + for n in i..j { + let gid = glyphs.get(n).map(|g| g.gid); + let own = ids.iter().zip(&free).position(|(id, f)| f.is_some() && *id == gid); + if let (Some(p), Some(o)) = (own, out.get_mut(n)) { + *o = free.get_mut(p).and_then(Option::take); + } + } + } + let mut rest = free.into_iter().flatten(); + for o in out.iter_mut().take(j).skip(i).filter(|o| o.is_none()) { + *o = rest.next(); + } + i = j; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -372,6 +437,108 @@ mod tests { assert!(matches!((adv, hmtx), (Some(a), Some(h)) if (a - f64::from(h)).abs() < 1e-3), "{adv:?} vs {hmtx:?}"); } + #[test] + fn thai_lines_are_shaped_and_latin_lines_are_not() { + assert!(needed("ระดับดินเดิม") && needed("ขนาด 50x50 mm.") && needed("\u{0E4C}")); + assert!(needed("abc سلام")); + assert!(!needed("50x50 mm.") && !needed("Ünïcödé %%c25") && !needed("")); + } + + fn glyph(cluster: usize) -> Glyph { + Glyph { gid: 0, cluster, advance: 500.0, dx: 0.0, dy: 0.0 } + } + + /// Without installed fonts: a face that maps no character, so glyphs take their cluster's + /// characters in order. + #[test] + fn glyphs_show_their_cluster_characters_once() { + let face = empty_glyph_face(); + let cs: Vec = "xabcd".chars().collect(); + // A run from index 1: cluster 1 has "ab" (two glyphs), cluster 3 has "cd" and a split + // character's extra glyph, which shows nothing. + let gs = Vec::from([1, 1, 3, 3, 3].map(glyph)); + assert_eq!(glyph_chars(&face, &cs, &gs, 5), vec![Some('a'), Some('b'), Some('c'), Some('d'), None]); + // Clusters out of order, past the run or past the line show nothing and don't panic. + let gs = Vec::from([3, 1, usize::MAX, 9].map(glyph)); + assert_eq!(glyph_chars(&face, &cs, &gs, 5), vec![None, Some('a'), None, None]); + assert!(glyph_chars(&face, &cs, &[], 5).is_empty()); + // A cluster longer than the matching limit still shows each character once, in order. + let long: Vec = std::iter::once('ก').chain(std::iter::repeat_n('\u{0E48}', MAX_CHARS)).collect(); + let gs: Vec = (0..long.len()).map(|_| glyph(0)).collect(); + let shows = glyph_chars(&face, &long, &gs, long.len()); + assert_eq!(shows.first(), Some(&Some('ก'))); + assert!(shows.iter().all(Option::is_some)); + } + + /// An installed font with Thai, or `None` (the tests then skip). + fn thai_face() -> Option { + ttf::fallback_for('\u{0E01}').filter(|f| ['\u{0E14}', '\u{0E34}', '\u{0E4C}', '\u{0E33}', '\u{0E10}'].iter().all(|c| f.has_glyph(*c))) + } + + /// Lowest point of the glyph that shows `c`. + fn bottom_of(sh: &Shaped, c: char) -> Option { + let t = sh.text.iter().find(|t| t.ch == c)?; + sh.glyphs.get(t.outline)?.iter().flatten().map(|p| p.y).reduce(f64::min) + } + + #[test] + fn thai_marks_stack_in_an_installed_font() { + let Some(face) = thai_face() else { return }; + let shape = |s: &str| ttf::shape(&face, s, 1.0, 1.0, 0.0).expect("the face parses"); + // Thanthakhat over sara i (ดิ์) sits above the vowel, higher than over the bare consonant + // (ด์): the font's mark-to-mark positioning. One glyph per character drew both marks at + // the same height, on top of each other. + let stacked = shape("ดิ์"); + let alone = shape("ด์"); + let (Some(over_vowel), Some(over_letter)) = (bottom_of(&stacked, '\u{0E4C}'), bottom_of(&alone, '\u{0E4C}')) else { + panic!("thanthakhat drawn as text: {:?}", stacked.text.iter().map(|t| t.ch).collect::()) + }; + assert!(over_vowel > over_letter + 0.05, "stacked {over_vowel} vs alone {over_letter}"); + // Every character is written as text exactly once, however the shaper reorders, splits + // (sara am) or substitutes (ฐ without its tail under sara u) the glyphs. + for s in ["ดิ์", "น้ำ", "ฐุ", "ปั้น", "จีรศักดิ์"] { + let mut shown: Vec = shape(s).text.iter().map(|t| t.ch).collect(); + let mut typed: Vec = s.chars().collect(); + shown.sort_unstable(); + typed.sort_unstable(); + assert_eq!(shown, typed, "{s}"); + } + // Measured and laid-out widths agree, in the face and in a stroke-font style. + let s = "ทาสีขาว 50x50 mm."; + assert!((ttf::width(&face, s, 1.0, 1.0).unwrap_or_default() - shape(s).width).abs() < 1e-9); + assert!((crate::line_width(s, 1.0, 1.0) - crate::shape_stroke(s, 1.0, 1.0, 0.0).width).abs() < 1e-9); + // In a stroke-font style Thai comes from the fallback font as text, without + // missing-glyph boxes, and the Latin stays in the stroke font. + let sh = crate::shape_stroke("ระดับดินเดิม +0.00", 1.0, 1.0, 0.0); + assert!(!sh.strokes.iter().any(|s| s.len() == 5 && s.first() == s.last()), "no missing-glyph boxes"); + assert_eq!(sh.text.len(), "ระดับดินเดิม".chars().count()); + assert!(!sh.strokes.is_empty(), "\"+0.00\" in the stroke font"); + } + + #[test] + fn hostile_thai_lines_never_panic() { + for s in [ + "\u{0E33}\u{0E33}\u{0E33}", + "\u{0E4C}\u{0E49}\u{0E34}", + "ก\u{0E49}\u{0E49}\u{0E49}\u{0E33}\u{0E33}", + "%%u\u{0E34}%%o", + "\u{0E01}\u{200D}\u{0E33}\u{10FFFF}", + ] { + let _ = crate::shape_stroke(s, f64::NAN, -1.0, f64::INFINITY); + let _ = crate::line_width(s, 0.0, 0.0); + if let Some(face) = thai_face() { + let _ = ttf::shape(&face, s, f64::NAN, 0.0, f64::NAN); + let _ = ttf::width(&face, s, -1.0, f64::INFINITY); + } + } + // One consonant under thousands of tone marks: a single huge cluster. + let marks = format!("ก{}", "\u{0E48}".repeat(MAX_CHARS - 1)); + assert!(crate::shape_stroke(&marks, 1.0, 1.0, 0.0).width > 0.0); + let long = "ก".repeat(MAX_CHARS + 1); + assert!(shape(None, &long, 1.0, 1.0, 0.0).is_none()); + assert!(crate::shape_stroke(&long, 1.0, 1.0, 0.0).width > 0.0); + } + #[test] fn hostile_rtl_lines_never_panic() { for s in ["\u{202E}%%u\u{05D0}%%", "\u{2067}\u{0644}\u{0627}\u{2069}%%o", "\u{064E}\u{064E}\u{05D0}", "\u{05D0}\u{E000}\u{10FFFF}"] { diff --git a/crates/fonts/src/craft_fonts.rs b/crates/fonts/src/craft_fonts.rs index fb290775f..801b84027 100644 --- a/crates/fonts/src/craft_fonts.rs +++ b/crates/fonts/src/craft_fonts.rs @@ -1,8 +1,8 @@ //! Fonts from the optional craft-fonts build input (). //! //! Built with `CRAFT_FONTS_DIR=`, `build.rs` embeds the fonts listed in its -//! `fonts/manifest.txt` (web builds: only BIZ UDPGothic Regular and the Arabic faces). -//! Without it [`CRAFT_FONTS`] is empty and nothing changes. The font table ([`crate::ttf`]) +//! `fonts/manifest.txt` (web builds: only BIZ UDPGothic Regular, the Arabic faces and the Thai +//! Regular faces). Without it [`CRAFT_FONTS`] is empty and nothing changes. The font table ([`crate::ttf`]) //! lists the embedded fonts with the installed ones, under [`CraftFont::stem`]. A text style //! can name one (`BIZ UDPGothic`), and they are fallbacks for characters a text's font lacks. //! They are copied out of the binary only when first used. @@ -83,6 +83,7 @@ mod tests { let sample = match f.scripts.first().copied() { Some("Jpan") => '\u{3042}', Some("Arab") => '\u{0628}', + Some("Thai") => '\u{0E01}', Some("Hang" | "Kore") => '가', _ => 'A', }; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 5bbae6883..dd5c37597 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -9,12 +9,14 @@ //! Right-to-left lines (Persian, Arabic, Urdu, Hebrew) go through [`bidi`] (the Unicode //! Bidirectional Algorithm, Arabic joining) and `complex` (OpenType shaping with `harfrust`): //! each run is shaped in the font that draws it before glyphs are placed, so the fallback font -//! drawing Arabic in a stroke-font style joins its letters too. +//! drawing Arabic in a stroke-font style joins its letters too. Thai lines are shaped the same +//! way, which stacks their vowels and tone marks above and below each consonant. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod bidi; mod complex; +mod linebreak; mod mtext; mod stroke; pub mod subset; @@ -153,7 +155,7 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh let mut under_start: Option = None; let mut over_start: Option = None; let mut gap = stroke::GAP * sc * wf; - if bidi::has_rtl(s) + if complex::needed(s) && let Some(sh) = complex::shape(None, s, h, wf, oblique) { return sh; @@ -244,7 +246,7 @@ pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run /// Width of a line without building strokes. pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 { - if bidi::has_rtl(s) { + if complex::needed(s) { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; if let Some(sh) = complex::shape(None, s, h, wf, 0.0) { diff --git a/crates/fonts/src/linebreak.rs b/crates/fonts/src/linebreak.rs new file mode 100644 index 000000000..5669db121 --- /dev/null +++ b/crates/fonts/src/linebreak.rs @@ -0,0 +1,205 @@ +//! Line-break opportunities inside Thai text, for MTEXT word wrap. +//! +//! Thai is written without spaces between words (a space ends a phrase or sentence), so a run of +//! Thai can only wrap where one word ends and the next begins. Those places come from the Unicode +//! line breaking algorithm (UAX #14), which leaves Thai to a word segmenter: `icu_segmenter`'s +//! compiled-in LSTM model for Southeast Asian scripts. Only breaks between two Thai characters +//! are used, so text in other scripts wraps at spaces exactly as before. +//! +//! The model rather than ICU's Thai dictionary: after a word the dictionary lacks (names, +//! loanwords such as เมทัลชีท) the dictionary segmenter loses its place and breaks inside the +//! words that follow, even between a consonant and its tone mark (ทแผ|่|นเร|ียบ), where the model +//! breaks at word boundaries and, when unsure, leaves words together (a line then wraps a little +//! early, never mid-word). The model is also a sixth of the dictionary's size. Any break inside +//! a character cluster is still dropped ([`cluster_boundary`]), so a line never starts with a +//! mark or a following vowel and never ends with a leading vowel. +//! +//! The model takes a few microseconds a character and text is laid out again on every display +//! rebuild, so results are kept in a small cache. + +use std::collections::HashMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::{Arc, OnceLock, PoisonError, RwLock}; + +use icu_segmenter::LineSegmenter; +use icu_segmenter::options::LineBreakOptions; + +use crate::complex::is_thai; + +/// Longest text (characters) looked up; longer text (hostile contents) gets no Thai breaks and +/// wraps at spaces only. +const MAX_CHARS: usize = 4_000; + +/// Most texts and bytes of text kept in the cache; it is emptied when either would be passed. +const MAX_CACHED: usize = 8_192; +const MAX_CACHED_BYTES: usize = 4 << 20; + +/// Whether a line may break between Thai characters `prev` and `next` without splitting a +/// character cluster: `next` is no combining mark (vowels above and below, tone marks) and no +/// following vowel or sign (ะ า ำ ๅ, ๆ, ฯ), and `prev` is no leading vowel (เ แ โ ใ ไ), which is +/// written before the consonant it belongs to. +fn cluster_boundary(prev: char, next: char) -> bool { + !crate::bidi::is_mark(next) && !matches!(next, 'ะ' | 'า' | 'ำ' | 'ๅ' | 'ๆ' | 'ฯ') && !matches!(prev, 'เ' | 'แ' | 'โ' | 'ใ' | 'ไ') +} + +/// The line segmenter with the Southeast Asian LSTM models, built once. +fn segmenter() -> &'static icu_segmenter::LineSegmenterBorrowed<'static> { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| LineSegmenter::new_lstm(LineBreakOptions::default())) +} + +#[derive(Default)] +struct Cache { + entries: HashMap>, + bytes: usize, +} + +/// A read-write lock: text is laid out on all cores and nearly every lookup is a hit. +fn cache() -> &'static RwLock { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| RwLock::new(Cache::default())) +} + +/// Character offsets in `text`, ascending, where a line may break between two Thai characters +/// (between two Thai words, at a cluster boundary). Empty when `text` has no Thai, is longer than +/// [`MAX_CHARS`], or the segmenter fails. +pub(crate) fn thai_breaks(text: &str) -> Arc<[usize]> { + if !text.chars().any(is_thai) || text.chars().nth(MAX_CHARS).is_some() { + return Arc::default(); + } + if let Some(found) = cache().read().unwrap_or_else(PoisonError::into_inner).entries.get(text) { + return found.clone(); + } + let breaks: Arc<[usize]> = segment(text).into(); + let mut c = cache().write().unwrap_or_else(PoisonError::into_inner); + let bytes = text.len().saturating_add(breaks.len().saturating_mul(std::mem::size_of::())); + if c.entries.len() >= MAX_CACHED || c.bytes.saturating_add(bytes) > MAX_CACHED_BYTES { + c.entries.clear(); + c.bytes = 0; + } + if c.entries.insert(text.to_string(), breaks.clone()).is_none() { + c.bytes = c.bytes.saturating_add(bytes); + } + breaks +} + +/// [`thai_breaks`] without the cache. +fn segment(text: &str) -> Vec { + let chars: Vec<(usize, char)> = text.char_indices().collect(); + // A last-resort guard around the segmenter: a panic in it must not take the drawing down + // (the text then wraps at spaces only). + let bytes: Vec = catch_unwind(AssertUnwindSafe(|| segmenter().segment_str(text).collect())).unwrap_or_default(); + bytes + .into_iter() + .filter_map(|b| chars.binary_search_by_key(&b, |(at, _)| *at).ok()) + .filter(|&i| { + let prev = i.checked_sub(1).and_then(|p| chars.get(p)).map(|(_, c)| *c); + let next = chars.get(i).map(|(_, c)| *c); + matches!((prev, next), (Some(p), Some(n)) if is_thai(p) && is_thai(n) && cluster_boundary(p, n)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The pieces of `text` between its Thai breaks. + fn words(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut from = 0; + for at in thai_breaks(text).iter().copied().chain(std::iter::once(chars.len())) { + out.push(chars.get(from..at).unwrap_or_default().iter().collect()); + from = at; + } + out + } + + #[test] + fn thai_breaks_between_words() { + assert_eq!(words("ภาษาไทย"), ["ภาษา", "ไทย"]); + assert_eq!(words("เหล็กกล่อง"), ["เหล็ก", "กล่อง"]); + assert_eq!(words("น้ำทำให้ไม่ใช่"), ["น้ำ", "ทำ", "ให้", "ไม่", "ใช่"]); + // The sample drawing's specification text, with a loanword (metal sheet) inside: the + // words after it are still whole. + let spec = words("เหล็กกล่องขนาดมาตรฐานสำหรับโครงหลังคาเมทัลชีทแผ่นเรียบสีขาว"); + for w in ["เหล็ก", "กล่อง", "ขนาด", "มาตรฐาน", "สำหรับ", "เมทัล", "ชีท", "สี", "ขาว"] + { + assert!(spec.iter().any(|p| p == w), "{w}: {spec:?}"); + } + let wrong = ["ทแผ", "นเร", "ยบ"]; + assert!(spec.iter().all(|p| !wrong.iter().any(|w| p.starts_with(w))), "{spec:?}"); + } + + /// Words no dictionary has (names, loanwords from the sample drawing): no break splits a + /// character cluster there. + #[test] + fn breaks_never_split_a_character_cluster() { + for text in ["จีรศักดิ์สุพรมวัน", "เมทัลชีทแผ่นเรียบสีขาว", "ศุภประดิษฐ์มาสงค์", "น้ำทำให้ไม่ใช่ๆฯ"] + { + let pieces = words(text); + assert_eq!(pieces.concat(), text); + for w in &pieces { + let first = w.chars().next().unwrap_or(' '); + assert!(!crate::bidi::is_mark(first) && !matches!(first, 'ะ' | 'า' | 'ำ' | 'ๅ' | 'ๆ' | 'ฯ'), "{text}: {pieces:?}"); + assert!(!w.ends_with(['เ', 'แ', 'โ', 'ใ', 'ไ']), "{text}: {pieces:?}"); + } + } + } + + #[test] + fn breaks_inside_a_cluster_are_dropped() { + assert!(cluster_boundary('ก', 'ข') && cluster_boundary('า', 'เ') && cluster_boundary('\u{0E48}', 'น')); + for (prev, next) in [ + ('ก', '\u{0E48}'), + ('ด', '\u{0E34}'), + ('\u{0E34}', '\u{0E4C}'), + ('ช', '\u{0E35}'), + ('ก', 'า'), + ('น', 'ำ'), + ('ก', 'ๆ'), + ('เ', 'ม'), + ('ไ', 'ท'), + ] { + assert!(!cluster_boundary(prev, next), "{prev}|{next}"); + } + } + + #[test] + fn other_scripts_and_mixed_text_keep_their_breaks() { + assert!(thai_breaks("hello world").is_empty()); + assert!(thai_breaks("").is_empty()); + // Between Thai and Latin or digits there is no break added (spaces still break). + let text = "ขนาด50x50mm"; + assert!(thai_breaks(text).is_empty(), "{:?}", thai_breaks(text)); + } + + #[test] + fn hostile_text_never_panics_and_stays_bounded() { + for s in ["\u{0E33}\u{0E33}\u{0E33}", "\u{0E4C}\u{0E49}\u{0E34}", "ก\u{0E49}\u{0E49}\u{0E49}", "ก\u{200B}ข\u{FFFF}\u{10FFFF}ค"] { + let n = s.chars().count(); + assert!(thai_breaks(s).iter().all(|&i| i > 0 && i < n)); + } + assert!(thai_breaks(&"ภาษาไทย".repeat(MAX_CHARS)).is_empty(), "too long: no lookup"); + // The limit is on characters, not bytes (a Thai character is three bytes). + assert!(!thai_breaks(&"ภาษาไทย".repeat(MAX_CHARS / 7)).is_empty()); + let long = "ภาษาไทย".repeat(MAX_CHARS / 8); + let n = long.chars().count(); + let b = thai_breaks(&long); + assert!(!b.is_empty() && b.windows(2).all(|w| w[0] < w[1]) && b.iter().all(|&i| i < n)); + } + + #[test] + fn cached_breaks_match_and_the_cache_stays_bounded() { + let text = "เหล็กกล่องขนาดมาตรฐาน"; + assert_eq!(thai_breaks(text), segment(text).into()); + assert_eq!(thai_breaks(text), thai_breaks(text)); + // Many different texts: the cache empties itself rather than growing. + for i in 0..MAX_CACHED + 10 { + let _ = thai_breaks(&format!("ภาษา{i}ไทย")); + } + let c = cache().read().unwrap_or_else(PoisonError::into_inner); + assert!(c.entries.len() <= MAX_CACHED && c.bytes <= MAX_CACHED_BYTES); + } +} diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index 23736d017..a8d49fcbd 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -407,6 +407,67 @@ fn parse(contents: &str, base: Fmt) -> (Vec, Vec) { (atoms, fmts) } +/// `word` split where a line may wrap inside it: between two Thai words, as Thai writes no +/// spaces between words ([`crate::linebreak::thai_breaks`]). The pieces keep the word's segments +/// and formats in order and are laid out side by side, so the text looks the same and only gains +/// places to wrap. A word without Thai comes back whole. +/// +/// Lines still wrap at spaces first, as before: only a word too wide for a line of its own (which +/// would run out of the column) is split, so text that fits keeps its line breaks, as its author +/// saw them. +fn split_thai(word: Vec<(Seg, usize)>) -> Vec> { + if !word.iter().any(|(seg, _)| matches!(seg, Seg::Text(t) if t.chars().any(crate::complex::is_thai))) { + return vec![word]; + } + // The word's characters, a stacked fraction standing as one object replacement character + // (never next to a Thai break). + let text: String = word + .iter() + .map(|(seg, _)| match seg { + Seg::Text(t) => t.as_str(), + Seg::Stack(..) => "\u{FFFC}", + }) + .collect(); + let breaks = crate::linebreak::thai_breaks(&text); + if breaks.is_empty() { + return vec![word]; + } + let mut pieces = Vec::with_capacity(breaks.len().saturating_add(1)); + let mut breaks = breaks.iter().copied().peekable(); + let mut piece: Vec<(Seg, usize)> = Vec::new(); + // Character offset of the segment being split. + let mut at = 0usize; + for (seg, format) in word { + let Seg::Text(t) = seg else { + piece.push((seg, format)); + at += 1; + continue; + }; + let n = t.chars().count(); + let (mut rest, mut rest_at) = (t.as_str(), at); + while let Some(b) = breaks.next_if(|&b| b < at + n) { + let k = b.saturating_sub(rest_at); + let byte = rest.char_indices().nth(k).map_or(rest.len(), |(i, _)| i); + let (head, tail) = rest.split_at_checked(byte).unwrap_or((rest, "")); + if !head.is_empty() { + piece.push((Seg::Text(head.to_string()), format)); + } + if !piece.is_empty() { + pieces.push(std::mem::take(&mut piece)); + } + (rest, rest_at) = (tail, b.max(rest_at)); + } + if !rest.is_empty() { + piece.push((Seg::Text(rest.to_string()), format)); + } + at += n; + } + if !piece.is_empty() { + pieces.push(piece); + } + pieces +} + fn seg_width(seg: &Seg, f: &Fmt) -> f64 { match seg { Seg::Text(t) => text_width(&f.font, t, f.height, f.wf), @@ -537,6 +598,8 @@ pub fn layout_mtext(contents: &str, insert: Vec2, height: f64, width: f64, attac layout_mtext_with(contents, &MTextParams { insert, height, width, attach, rotation, line_spacing, ..MTextParams::new(insert, height) }) } +/// The lines of MTEXT without paragraph codes: words wrap at spaces to `width` (0: no wrapping), +/// and a word too wide for a line of its own wraps between its Thai words. fn plain_lines(atoms: &[Atom], fmts: &[Fmt], base: &Fmt, width: f64) -> Vec { let fmt = |index: usize| fmts.get(index).unwrap_or(base); let mut lines = Vec::new(); @@ -566,32 +629,44 @@ fn plain_lines(atoms: &[Atom], fmts: &[Fmt], base: &Fmt, width: f64) -> Vec { - let word_width: f64 = segments.iter().map(|(segment, format)| seg_width(segment, fmt(*format))).sum(); - let space_width = - pending_space.map(|format| crate::space_width(&fmt(format).font, fmt(format).height, fmt(format).wf)).unwrap_or(0.0); - if width > 0.0 && !line.items.is_empty() && line.width + space_width + word_width > width + 1e-9 { - finish(&mut lines, &mut line, fmt(last_fmt), paragraph); - } else if !line.items.is_empty() || pending_space.is_some() { - if let Some(format) = pending_space { + Atom::Word(word) => { + let word_width = + |segments: &[(Seg, usize)]| -> f64 { segments.iter().map(|(segment, format)| seg_width(segment, fmt(*format))).sum() }; + // Wider than the column: Thai breaks between its words (see `split_thai`). + let split; + let pieces = if width > 0.0 && word_width(word) > width + 1e-9 { + split = split_thai(word.clone()); + split.as_slice() + } else { + std::slice::from_ref(word) + }; + for segments in pieces { + let word_width = word_width(segments); + let space_width = + pending_space.map(|format| crate::space_width(&fmt(format).font, fmt(format).height, fmt(format).wf)).unwrap_or(0.0); + if width > 0.0 && !line.items.is_empty() && line.width + space_width + word_width > width + 1e-9 { + finish(&mut lines, &mut line, fmt(last_fmt), paragraph); + } else if !line.items.is_empty() || pending_space.is_some() { + if let Some(format) = pending_space { + let at = line.chars; + line.items.push(Item { seg: None, fmt: format, x: line.width, w: space_width, at }); + line.text.push(' '); + line.chars += 1; + } + line.width += space_width; + } + pending_space = None; + for (segment, format) in segments { + let segment_width = seg_width(segment, fmt(*format)); + line.max_h = line.max_h.max(fmt(*format).height); let at = line.chars; - line.items.push(Item { seg: None, fmt: format, x: line.width, w: space_width, at }); - line.text.push(' '); - line.chars += 1; + let plain = seg_plain(segment); + line.chars += plain.chars().count(); + line.text.push_str(&plain); + line.items.push(Item { seg: Some(segment.clone()), fmt: *format, x: line.width, w: segment_width, at }); + line.width += segment_width; + last_fmt = *format; } - line.width += space_width; - } - pending_space = None; - for (segment, format) in segments { - let segment_width = seg_width(segment, fmt(*format)); - line.max_h = line.max_h.max(fmt(*format).height); - let at = line.chars; - let plain = seg_plain(segment); - line.chars += plain.chars().count(); - line.text.push_str(&plain); - line.items.push(Item { seg: Some(segment.clone()), fmt: *format, x: line.width, w: segment_width, at }); - line.width += segment_width; - last_fmt = *format; } } } @@ -608,10 +683,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { let width = if p.width.is_finite() && p.width > 0.0 { p.width } else { 0.0 }; let rotation = if p.rotation.is_finite() { p.rotation } else { 0.0 }; let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique: p.oblique, valign: 1 }; - let (atoms, fmts) = parse(contents, base.clone()); if para::needed(contents, p) { return para::layout(contents, p, &base); } + let (atoms, fmts) = parse(contents, base.clone()); let fmt = |i: usize| fmts.get(i).unwrap_or(&base); let mut lines = plain_lines(&atoms, &fmts, &base, width); // Right-to-left text: each line in display order, its paragraph's direction from the @@ -875,5 +950,89 @@ mod tests { } let long = "word ".repeat(5000); assert!(lay(&long, 10.0).lines.len() > 100); + // Thai with stacks, format runs, stray marks and unpaired braces in one word. + for s in ["ก\\S1/2;ข{\\C1;ภาษา}ไทย\u{0E4C}\u{0E4C}", "{\\H2x;ภาษา}{\\H3x;ไทย}", "\u{0E33}\u{0E49}\\S/;ไทย}}", "ภาษาไทย\tภาษาไทย"] + { + let _ = lay(s, 0.5); + let _ = lay(&format!("\\pi1,l2,t3;{s}"), 0.5); + } + // Too long to look up: one unbreakable word, like a long Latin word. + assert_eq!(lay(&"ภาษาไทย".repeat(20_000), 3.0).lines.len(), 1); + } + + /// Thai writes no spaces between words: a long line wraps between words, in the plain and the + /// paragraph layout, without fonts installed (the line breaks don't depend on glyph widths). + #[test] + fn thai_wraps_between_words() { + let text = "เหล็กกล่องขนาดมาตรฐานสำหรับโครงหลังคาเมทัลชีทแผ่นเรียบสีขาว"; + let one = lay(text, 0.0); + assert_eq!(one.lines, vec![text]); + let wide = one.bounds.width(); + for contents in [text.to_string(), format!("\\pi0,l0;{text}"), format!("{{\\C1;เหล็กกล่อง}}{}", text.trim_start_matches("เหล็กกล่อง"))] + { + let l = lay(&contents, wide / 3.0); + assert!(l.lines.len() >= 3, "{contents}: {:?}", l.lines); + assert_eq!(l.lines.concat(), text, "nothing lost or added: {:?}", l.lines); + for line in &l.lines { + let first = line.chars().next().unwrap_or(' '); + assert!(!crate::bidi::is_mark(first) && !matches!(first, 'ะ' | 'า' | 'ำ'), "{line}"); + assert!(!line.ends_with(['เ', 'แ', 'โ', 'ใ', 'ไ']), "{line}"); + } + } + // The pieces sit side by side with nothing between them: drawn with a Thai font they are + // as wide as the word laid out in one piece (up to kerning). + let pieces = split_thai(vec![(Seg::Text(text.to_string()), 0)]); + if one.pieces.iter().any(|p| !p.shaped.glyphs.is_empty()) { + let base = Fmt { font: TextFont::Stroke, height: 1.0, color: None, under: false, over: false, wf: 1.0, oblique: 0.0, valign: 1 }; + let sum: f64 = pieces.iter().flatten().map(|(seg, _)| seg_width(seg, &base)).sum(); + assert!((sum - wide).abs() < 0.01 * wide, "{sum} {wide}"); + } + assert!(pieces.len() > 3); + // Latin and other words stay whole; stacks keep their place. + assert_eq!(split_thai(vec![(Seg::Text("hello".into()), 0)]).len(), 1); + let mixed = split_thai(vec![(Seg::Text("ภาษา".into()), 0), (Seg::Stack("1".into(), "2".into(), '/'), 0), (Seg::Text("ภาษาไทย".into()), 1)]); + let flat: Vec = mixed.iter().map(|p| p.iter().map(|(s, _)| seg_plain(s)).collect()).collect(); + assert_eq!(flat, ["ภาษา1/2ภาษา", "ไทย"]); + } + + /// A Thai run that would fit a line at the left indent but not after a first-line indent or a + /// leading tab still breaks between its words instead of running out of the column. The indent + /// is well over the slack: with no Thai font the stroke font's pieces are narrower than the run + /// (each loses its trailing letter gap). + #[test] + fn thai_wraps_after_first_line_indent_and_tab() { + let text = "เหล็กกล่องขนาดมาตรฐานสำหรับโครงหลังคา"; + let wide = lay(text, 0.0).bounds.width(); + for contents in [format!("\\pi4,l0;{text}"), format!("\\pxt4;\t{text}")] { + let l = lay(&contents, wide + 1.0); + assert!(l.lines.len() >= 2, "{contents}: {:?}", l.lines); + assert_eq!(l.lines.concat().trim_start(), text, "{contents}: {:?}", l.lines); + } + } + + /// Text that fits at its spaces keeps those line breaks: a note from the sample drawing + /// (frame, box section 50x50 mm, painted white) at every column width from one word up never + /// breaks inside ทาสีขาว or any other spaced word, in the plain and the paragraph layout. + #[test] + fn spaced_thai_keeps_its_space_breaks() { + let text = "โครงแนวนอน เหล็กกล่อง ขนาด 50x50 mm. ทาสีขาว"; + let words: Vec<&str> = text.split(' ').collect(); + let widest = words.iter().map(|w| lay(w, 0.0).bounds.width()).fold(0.0, f64::max); + let full = lay(text, 0.0).bounds.width(); + let mut width = widest * 1.001; + while width < full * 1.05 { + for contents in [text.to_string(), format!("\\pxql;\\A1;{text}")] { + let l = lay(&contents, width); + assert_eq!(l.lines.join(" "), text, "{width}: {:?}", l.lines); + assert!(l.lines.iter().flat_map(|line| line.split(' ')).all(|w| words.contains(&w)), "{width}: {:?}", l.lines); + } + width *= 1.05; + } + // After a Latin word, a Thai run too long for any line breaks between its words, filling + // the line it starts on. + let long = "เหล็กกล่องขนาดมาตรฐานสำหรับโครงหลังคา"; + let l = lay(&format!("abc {long}"), lay(long, 0.0).bounds.width() / 2.0); + assert!(l.lines.len() >= 3 && l.lines.first().is_some_and(|f| f.starts_with("abc ") && f.len() > 4), "{:?}", l.lines); + assert_eq!(l.lines.concat(), format!("abc {long}")); } } diff --git a/crates/fonts/src/mtext/para.rs b/crates/fonts/src/mtext/para.rs index 064e62d02..c3b6da3e6 100644 --- a/crates/fonts/src/mtext/para.rs +++ b/crates/fonts/src/mtext/para.rs @@ -7,7 +7,7 @@ use cadcraft_geom::{Bounds2, Vec2}; -use super::{Atom, Fmt, MTextLayout, MTextParams, MTextPiece, Seg, arg, num, parse, seg_plain, seg_shape, seg_width}; +use super::{Atom, Fmt, MTextLayout, MTextParams, MTextPiece, Seg, arg, num, parse, seg_plain, seg_shape, seg_width, split_thai}; /// Columns an MTEXT's text flows through, each as wide as the MTEXT's width. #[derive(Clone, Debug, Default, PartialEq)] @@ -512,8 +512,34 @@ impl<'a> LineBuilder<'a> { } } + /// Place a word (`index` > 0: the part of a word after a tab, with no space before it). A + /// word too wide for a line of its own breaks between its Thai words (see `split_thai`). fn push_text(&mut self, index: usize, segments: &[(Seg, usize)]) { - let word_width: f64 = segments.iter().map(|(segment, format)| seg_width(segment, self.format(*format))).sum(); + // An empty line can't wrap, so the word gets the room from where the line starts (a + // first-line indent or a tab stop); otherwise it can wrap to a line at the left indent. + let room = { + let props = self.paragraphs.props(self.paragraph); + let start = if self.line.items.is_empty() { self.line.width } else { props.left.max(0.0) }; + self.available_width(props) - start + }; + if self.width > 0.0 && self.word_width(segments) > room + 1e-9 { + for (k, piece) in split_thai(segments.to_vec()).iter().enumerate() { + self.place(if k == 0 { index } else { index.max(1) }, piece); + } + } else { + self.place(index, segments); + } + } + + /// Width of a word's segments, each in its format. + fn word_width(&self, segments: &[(Seg, usize)]) -> f64 { + segments.iter().map(|(segment, format)| seg_width(segment, self.format(*format))).sum() + } + + /// Put a word, or a piece of one, on the current line after any pending space, or start a + /// new line when it doesn't fit (`index` as in [`Self::push_text`]). + fn place(&mut self, index: usize, segments: &[(Seg, usize)]) { + let word_width = self.word_width(segments); let space_width = if index == 0 { self.pending_space .map(|format| crate::space_width(&self.format(format).font, self.format(format).height, self.format(format).wf)) diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 9c1888aa8..c426db243 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -74,6 +74,12 @@ impl Face { pub fn has_glyph(&self, c: char) -> bool { self.font().is_some_and(|f| f.charmap().map(c).is_some()) } + /// The glyph ids the face's character map gives `chars` (`None` for an unmapped one). + pub(crate) fn glyph_ids(&self, chars: &[char]) -> Vec> { + let Some(f) = self.font() else { return vec![None; chars.len()] }; + let map = f.charmap(); + chars.iter().map(|c| map.map(*c).map(|g| g.to_u32())).collect() + } fn names(&self) -> Vec { let Some(f) = self.font() else { return Vec::new() }; [skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME] @@ -155,14 +161,58 @@ const ARABIC_FALLBACK: &[&str] = &[ "dejavusans", ]; const HEBREW_FALLBACK: &[&str] = &["notosanshebrew", "notoserifhebrew", "arial", "tahoma", "segoeui", "dejavusans"]; +/// Fonts known to cover Thai, with the OpenType mark positioning its stacked vowels and tone +/// marks need. First the Windows faces Thai drawings are laid out with (Cordia New, Angsana New, +/// Browallia New: Thai SHX fonts such as `cordia2.shx` copy their design and widths, so text +/// keeps its fit in title blocks and tables), then IBM Plex Sans Thai (installed, or embedded +/// from craft-fonts, which makes it the web build's Thai font), then Noto and the fonts each OS +/// ships (macOS Thonburi and Sukhumvit, Windows Leelawadee and Tahoma, the Linux TLWG fonts). +/// Matched as parts of file names: `angsa` and `browa` find both today's Windows collections +/// (`angsana.ttc`, `browalia.ttc`) and the older single faces (`angsa.ttf`, `browab.ttf`). +const THAI_FALLBACK: &[&str] = &[ + "cordia", + "angsa", + "browa", + "ibmplexsansthai", + "notosansthai", + "notoserifthai", + "thonburi", + "sukhumvit", + "leelawui", + "leelawadeeui", + "leelawad", + "tahoma", + "kinnari", + "norasi", + "garuda", + "loma", + "waree", + "sarabun", +]; /// Script-specific fallback files loaded per character, at most. const MAX_SCRIPT_FILES: usize = 4; +/// The font file stems matching a script's fallback `names`, best first: regular weights before +/// bold, light and the like, then in the order of `names`, then by name. +fn script_candidates(stems: &[String], names: &[&str]) -> Vec { + let mut v: Vec<(usize, usize, String)> = stems + .iter() + .filter_map(|s| { + let n = norm(s); + names.iter().position(|p| n.contains(p)).map(|pref| (weight_penalty(s, ""), pref, s.clone())) + }) + .collect(); + v.sort(); + v.dedup_by(|a, b| a.2 == b.2); + v.into_iter().map(|(_, _, s)| s).collect() +} + /// Script-specific fallback font names for `c`, if any. fn script_fallback(c: char) -> &'static [&'static str] { match c { '\u{0600}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => ARABIC_FALLBACK, '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => HEBREW_FALLBACK, + '\u{0E00}'..='\u{0E7F}' => THAI_FALLBACK, _ => &[], } } @@ -362,18 +412,8 @@ impl Db { if names.is_empty() { return None; } - let mut stems: Vec<(usize, usize, String)> = self - .paths - .keys() - .chain(self.files.keys()) - .filter_map(|s| { - let n = norm(s); - names.iter().position(|p| n.contains(p)).map(|pref| (weight_penalty(s, ""), pref, s.clone())) - }) - .collect(); - stems.sort(); - stems.dedup_by(|a, b| a.2 == b.2); - for (_, _, stem) in stems.into_iter().take(MAX_SCRIPT_FILES) { + let stems: Vec = self.paths.keys().chain(self.files.keys()).cloned().collect(); + for stem in script_candidates(&stems, names).into_iter().take(MAX_SCRIPT_FILES) { let Some(bytes) = self.file(&stem) else { continue }; let Some(face) = faces(&bytes).into_iter().find(|f| f.has_glyph(c)) else { continue }; if let Some(i) = self.fallback.faces.iter().position(|f| f.same(&face)) { @@ -855,7 +895,7 @@ pub(crate) fn place_glyph_at(g: &GlyphOutline, origin: Vec2, scale: f64, wf: f64 /// Characters the face lacks come from a fallback font, scaled to its own cap height. pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { face.font()?; - if crate::bidi::has_rtl(s) + if crate::complex::needed(s) && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, oblique) { return Some(sh); @@ -893,7 +933,7 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) /// Width of one line set in a TrueType face. pub fn width(face: &Face, s: &str, height: f64, width_factor: f64) -> Option { face.font()?; - if crate::bidi::has_rtl(s) + if crate::complex::needed(s) && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, 0.0) { return Some(sh.width); @@ -923,6 +963,40 @@ pub fn layout_line(face: &Face, s: &str, height: f64, width_factor: f64, oblique mod family_tests { use super::*; + #[test] + fn thai_looks_in_thai_fonts_before_the_general_search() { + // Letters, vowels, tone marks and digits: the general search loads a bounded number of + // files with the CJK fonts first, none of which has Thai. + for c in ['\u{0E01}', '\u{0E33}', '\u{0E34}', '\u{0E4C}', '\u{0E50}'] { + assert!(script_fallback(c).contains(&"notosansthai"), "{c}"); + } + assert!(script_fallback('a').is_empty() && script_fallback('\u{0E80}').is_empty()); + // Windows, macOS and Linux each ship one of them under these file names. + let stems: Vec = ["LeelawUI", "Thonburi", "Loma", "msyh"].map(norm).to_vec(); + assert!(stems.iter().filter(|s| THAI_FALLBACK.iter().any(|p| s.contains(p))).count() == 3); + // The faces Thai drawings are laid out with come first, then IBM Plex Sans Thai (an + // installed file or the craft-fonts face, both under this stem), then the others; regular + // weights before bold. + let stems: Vec = + ["notosansthai-regular", "ibmplexsansthai-bold", "ibmplexsansthai-regular", "msyh", "cordiab", "cordia", "ibmplexsansthailooped-regular"] + .map(String::from) + .to_vec(); + assert_eq!( + script_candidates(&stems, THAI_FALLBACK), + ["cordia", "cordiab", "ibmplexsansthai-regular", "ibmplexsansthailooped-regular", "notosansthai-regular", "ibmplexsansthai-bold"] + ); + // A text style naming the family looks in its regular file first, not the Latin Plex + // Sans or the looped or bold variants. + let stems: Vec = ["ibmplexsans-regular", "ibmplexsansthai-bold", "ibmplexsansthailooped-regular", "ibmplexsansthai-regular", "arial"] + .map(String::from) + .to_vec(); + let found = family_candidates(&stems, &norm("IBM Plex Sans Thai"), "ibm"); + assert_eq!(found.first().map(String::as_str), Some("ibmplexsansthai-regular"), "{found:?}"); + // Angsana New and Browallia New under today's Windows file names and the older ones. + let stems: Vec = ["browa", "notosansthai-regular", "angsana", "browalia", "angsa", "arial"].map(String::from).to_vec(); + assert_eq!(script_candidates(&stems, THAI_FALLBACK), ["angsa", "angsana", "browa", "browalia", "notosansthai-regular"]); + } + #[test] fn a_family_is_looked_for_in_files_starting_with_its_first_word() { let stems: Vec = ["gost", "gost2304a", "gost common", "arial", "isocpeur"].map(String::from).to_vec(); From c72c0eebfb445abddc35e159efbbd153d851c608 Mon Sep 17 00:00:00 2001 From: Andrea Ficarra Date: Sun, 1 Nov 2026 20:38:04 +0100 Subject: [PATCH 082/151] SAVEAS portableFonts: Windows fonts for Linux text style fonts (#2576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * SAVEAS portableFonts: Windows fonts for Linux text style fonts A text style using a Linux TrueType font (Liberation, DejaVu, Open Sans) shows in AutoCAD on Windows with a substitute it picks. A save now names those styles on the command line (and as `nonPortableFonts` in the result), and SAVEAS {"portableFonts": true} writes the Windows equivalent in the file (Liberation -> Arial, Times New Roman, Courier New; DejaVu Sans -> Verdana; Open Sans -> Arial) while the drawing keeps its own fonts. Narrow and condensed families have no Windows stand-in (Arial Narrow comes with Office) and are kept and named. QSAVE takes no SAVEAS options. Tests: stand_in_tests in cmd/file.rs, tests/save_portable_fonts.rs. Co-Authored-By: Claude Opus 5.5 * Portable fonts kept in the file, applied by the writer Reworked after review. SAVEAS `portableFonts` writes a copy of the drawing with `Drawing::portable_fonts` set, and the DXF/DWG writer turns each style's Linux font into its Windows font, keeping the style's own font in the CADCraft FONT xdata already used for the built-in font. Reading the file gives the Linux fonts back (unless the font was changed since, in AutoCAD); the drawing itself is never changed. It applies to that save: a later QSAVE writes the drawing's fonts and says so, naming the styles and SAVEAS portableFonts. The family table moves to `cadcraft_fonts::windows`, used by the writer and the note. Styles from an xref aren't saved and aren't named. The parity docs are left to the maintainers (CLAUDE.md). Tests: crates/io/tests/it/portable_fonts.rs (DXF, DWG, a font changed in AutoCAD), crates/engine/tests/save_portable_fonts.rs, windows.rs tests. Co-Authored-By: Claude Opus 5.5 * Portable fonts: match the Windows font by file name A STYLE whose Windows font carries a path (C:\Windows\Fonts\…) still gets its own font back. Co-Authored-By: Claude Opus 5.5 * Portable fonts: say the font list again only when it changes A save still returns nonPortableFonts, but the command line note isn't repeated on every QSAVE of the same drawing. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/doc/src/lib.rs | 5 ++ crates/engine/src/cmd/file.rs | 59 ++++++++++++++-- crates/engine/src/doc_state.rs | 4 ++ crates/engine/tests/save_portable_fonts.rs | 54 ++++++++++++++ crates/fonts/src/lib.rs | 1 + crates/fonts/src/windows.rs | 82 ++++++++++++++++++++++ crates/io/src/dxf_read/tables.rs | 10 +-- crates/io/src/dxf_write/sections.rs | 7 +- crates/io/tests/it/main.rs | 1 + crates/io/tests/it/portable_fonts.rs | 59 ++++++++++++++++ 10 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 crates/engine/tests/save_portable_fonts.rs create mode 100644 crates/fonts/src/windows.rs create mode 100644 crates/io/tests/it/portable_fonts.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e07a08609..8f2a1e2e4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -132,6 +132,10 @@ pub struct Drawing { /// Records of the file this drawing was read from that the reader could not place (a damaged /// file), counted by the section they came from. Never written to a file. pub skipped_records: BTreeMap, + /// A save option, set on the copy SAVEAS `portableFonts` writes: the file gets Windows fonts + /// for the Linux fonts of text styles, and each style keeps its own in CADCraft xdata, so the + /// drawing reopens with it. Never set on an open drawing. + pub portable_fonts: bool, } impl Default for Drawing { @@ -191,6 +195,7 @@ impl Drawing { read_notes: Vec::new(), refedit: None, skipped_records: BTreeMap::new(), + portable_fonts: false, } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1c3fbd5c8..234769ad4 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -85,12 +85,13 @@ pub fn deliver() -> Option { DELIVER.get().copied() } -/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`) and -/// binary instead of ASCII DXF. +/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`), binary +/// instead of ASCII DXF, and Windows fonts for Linux ones ([`cadcraft_doc::Drawing::portable_fonts`]). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct SaveAs { pub version: Option, pub binary: bool, + pub portable_fonts: bool, } /// Write a drawing with [`SaveAs`] options (installed by hosts whose writer supports them). @@ -131,7 +132,12 @@ fn save_options(cmd: &str, p: &Value) -> Result { Some(Value::Bool(b)) => *b, Some(_) => return Err(bad(cmd, "`binary` must be true or false")), }; - Ok(SaveAs { version, binary }) + let portable_fonts = match p.get("portableFonts") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(cmd, "`portableFonts` must be true or false")), + }; + Ok(SaveAs { version, binary, portable_fonts }) } /// Whether `path` names a DXF file (or has no extension, which saves as DXF). @@ -148,7 +154,9 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result Vec { CommandSpec::new("saveas", "Save As...", run_saveas_reporting) .menu(&["File", "Save As..."]) .key("Cmd+Shift+S") - .params("{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool}") + .params( + "{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool, portableFonts?: bool (Windows fonts for Linux ones, in this file)}", + ) .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, version?: 2000…2018, binary?: bool} → {data: base64}") + .params("{format?: dxf, version?: 2000…2018, binary?: bool, portableFonts?: bool} → {data: base64}") .noundo(), CommandSpec::new("script", "Run Script...", run_script) .menu(&["Tools", "Run Script..."]) @@ -395,7 +405,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result { } pub(crate) fn save_to(s: &mut Session, path: &str) -> Result { - let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(path) }; + let opts = SaveAs { binary: s.state()?.binary_dxf && is_dxf(path), ..SaveAs::default() }; save_as(s, "save", path, &opts) } @@ -677,6 +687,7 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result { if is_drawing_file(&path) { r["version"] = json!(opts.version.unwrap_or_else(|| drawing_version(s, &path)).year()); r["binary"] = json!(opts.binary); + r["portableFonts"] = json!(opts.portable_fonts); } Ok(r) } @@ -799,9 +810,43 @@ fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> R if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) { report_not_saved(s, &mut r, &lost); } + if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) { + report_fonts(s, &mut r); + } Ok(r) } +/// Name the text styles whose font in the saved file AutoCAD on Windows doesn't have: in the +/// result, and at the command line when the list changed since the last save. Styles from an xref +/// (`XREF|name`) aren't saved: AutoCAD takes them from the xref. +fn report_fonts(s: &mut Session, r: &mut Value) { + use cadcraft_fonts::windows::{missing_on_windows, windows_font, written_font}; + let portable = r.get("portableFonts").and_then(Value::as_bool).unwrap_or(false); + let Ok(d) = s.state().map(|st| st.drawing()) else { return }; + let fonts: Vec<(String, String)> = d + .text_styles + .iter() + .filter(|t| !t.name.contains('|') && missing_on_windows(written_font(&t.font, portable))) + .map(|t| (t.name.clone(), t.font.clone())) + .collect(); + let list = fonts.iter().map(|(style, font)| format!("{style} ({font})")).collect::>().join(", "); + let changed = s.state_mut().map(|st| std::mem::replace(&mut st.fonts_noted, list.clone()) != list).unwrap_or(true); + if fonts.is_empty() { + return; + } + if changed { + let hint = if fonts.iter().any(|(_, font)| windows_font(font).is_some()) { + " SAVEAS with portableFonts writes Windows fonts for those that have one." + } else { + "" + }; + s.echo(format!("Fonts AutoCAD on Windows doesn't have: {list}.{hint}")); + } + if let Some(o) = r.as_object_mut() { + o.insert("nonPortableFonts".into(), json!(fonts.iter().map(|(style, font)| json!({ "style": style, "font": font })).collect::>())); + } +} + fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result { save_reporting(s, p, run_save) } diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index c70989a58..d11c6f0eb 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -35,6 +35,9 @@ pub struct DocState { /// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the /// drawing itself waits in it ([`DocState::drawing`]). pub block_edit: Option, + /// The fonts missing on Windows that a save last named: the next save names them again only + /// when they change. + pub(crate) fonts_noted: String, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -61,6 +64,7 @@ impl DocState { uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), binary_dxf: false, block_edit: None, + fonts_noted: String::new(), } } /// The drawing has changes not saved to its file, or the Block Editor has changes not yet diff --git a/crates/engine/tests/save_portable_fonts.rs b/crates/engine/tests/save_portable_fonts.rs new file mode 100644 index 000000000..a63016d60 --- /dev/null +++ b/crates/engine/tests/save_portable_fonts.rs @@ -0,0 +1,54 @@ +//! Text styles with Linux fonts AutoCAD on Windows doesn't have: a save names them, and SAVEAS +//! with portableFonts writes Windows fonts in that file while the drawing keeps its own. + +use cadcraft_doc::{Drawing, TextStyle}; +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use serde_json::json; + +fn dxf(d: &Drawing, _: &str) -> Result, String> { + Ok(cadcraft_io::write_dxf(d).into_bytes()) +} + +fn session() -> Session { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: dxf, plot: None }); + let mut d = Drawing::new_metric(); + for (name, font) in [("Notes", "DejaVuSans.ttf"), ("Plain", "arial.ttf"), ("Tight", "Liberation Sans Narrow")] { + d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() }); + } + let mut s = Session::empty(); + s.open_drawing(d, "fonts.dxf", None); + s +} + +#[test] +fn portable_fonts_writes_windows_fonts_in_that_file() { + let mut s = session(); + let dir = std::env::temp_dir().join(format!("cadcraft-portable-fonts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + let file = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap(); + let both = json!([{ "style": "Notes", "font": "DejaVuSans.ttf" }, { "style": "Tight", "font": "Liberation Sans Narrow" }]); + + // portableFonts writes Verdana for DejaVu Sans; the narrow style has no Windows font. + let r = s.execute("saveas", &json!({ "path": path("portable.dxf"), "portableFonts": true })).unwrap(); + assert_eq!(r["nonPortableFonts"], json!([{ "style": "Tight", "font": "Liberation Sans Narrow" }])); + assert_eq!(s.log.last().map(String::as_str), Some("Fonts AutoCAD on Windows doesn't have: Tight (Liberation Sans Narrow).")); + assert!(file("portable.dxf").contains("verdana.ttf")); + assert!(s.state().unwrap().drawing().text_styles.iter().any(|t| t.font == "DejaVuSans.ttf"), "the drawing keeps its fonts"); + + // A later save without it writes the drawing's fonts again, and says so. + let r = s.execute("qsave", &json!({})).unwrap(); + assert_eq!(r["nonPortableFonts"], both); + assert!(s.log.last().is_some_and(|l| l.ends_with("SAVEAS with portableFonts writes Windows fonts for those that have one.")), "{:?}", s.log); + assert!(!file("portable.dxf").contains("verdana.ttf")); + + // The same list isn't said again on every save. + let lines = s.log.len(); + let r = s.execute("qsave", &json!({})).unwrap(); + assert_eq!(r["nonPortableFonts"], both); + assert!(s.log.iter().skip(lines).all(|l| !l.starts_with("Fonts AutoCAD")), "{:?}", s.log); + + assert!(s.execute("saveas", &json!({ "path": path("bad.dxf"), "portableFonts": "yes" })).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index dd5c37597..beb8996ff 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -21,6 +21,7 @@ mod mtext; mod stroke; pub mod subset; pub mod ttf; +pub mod windows; use cadcraft_geom::{Bounds2, Vec2}; diff --git a/crates/fonts/src/windows.rs b/crates/fonts/src/windows.rs new file mode 100644 index 000000000..729a2eea0 --- /dev/null +++ b/crates/fonts/src/windows.rs @@ -0,0 +1,82 @@ +//! Linux font families that AutoCAD on Windows doesn't have, and the Windows fonts a drawing +//! saved with portable fonts gets instead. + +/// Family key (lower case, without spaces, hyphens or underscores; a longer key before a shorter +/// one it starts with) and the Windows fonts for its regular, bold, italic and bold italic styles. +const FAMILIES: [(&str, [&str; 4]); 7] = [ + ("liberationsans", ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"]), + ("liberationserif", ["times.ttf", "timesbd.ttf", "timesi.ttf", "timesbi.ttf"]), + ("liberationmono", ["cour.ttf", "courbd.ttf", "couri.ttf", "courbi.ttf"]), + ("opensans", ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"]), + ("dejavusansmono", ["cour.ttf", "courbd.ttf", "couri.ttf", "courbi.ttf"]), + ("dejavusans", ["verdana.ttf", "verdanab.ttf", "verdanai.ttf", "verdanaz.ttf"]), + ("dejavuserif", ["times.ttf", "timesbd.ttf", "timesi.ttf", "timesbi.ttf"]), +]; + +/// The family of a TrueType `font` (file or family name) from [`FAMILIES`], with the rest of its +/// key (the style: `bold`, `italic`, …). +fn family(font: &str) -> Option<(&'static [&'static str; 4], String)> { + let name = font.trim().rsplit(['/', '\\']).next().unwrap_or_default().to_lowercase(); + let stem = name.strip_suffix(".ttf").or_else(|| name.strip_suffix(".otf")).unwrap_or(&name); + if stem.contains('.') { + return None; // another kind of file, such as an SHX font + } + let key: String = stem.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).collect(); + let (family, fonts) = FAMILIES.iter().find(|(f, _)| key.starts_with(f))?; + Some((fonts, key.get(family.len()..).unwrap_or_default().to_string())) +} + +/// Whether `font` is a Linux font AutoCAD on Windows doesn't have. +pub fn missing_on_windows(font: &str) -> bool { + family(font).is_some() +} + +/// The Windows font for a Linux `font`. Narrow and condensed styles have none (Arial Narrow comes +/// with Office, not with Windows). +pub fn windows_font(font: &str) -> Option<&'static str> { + let (fonts, style) = family(font)?; + if style.contains("narrow") || style.contains("condensed") { + return None; + } + let bold = style.contains("bold"); + let italic = style.contains("italic") || style.contains("oblique"); + fonts.get(usize::from(bold) + 2 * usize::from(italic)).copied() +} + +/// The font a drawing file gets for a text style's `font`: with `portable`, the Windows font for +/// a Linux one. +pub fn written_font(font: &str, portable: bool) -> &str { + if portable { windows_font(font).unwrap_or(font) } else { font } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_fonts_by_file_or_family_name() { + assert_eq!(windows_font("DejaVuSans.ttf"), Some("verdana.ttf")); + assert_eq!(windows_font("/usr/share/fonts/truetype/LiberationSerif-Bold.ttf"), Some("timesbd.ttf")); + assert_eq!(windows_font("Liberation Mono"), Some("cour.ttf")); + assert_eq!(windows_font("LiberationMono-BoldItalic.ttf"), Some("courbi.ttf")); + assert_eq!(windows_font("OpenSans-SemiBold.ttf"), Some("arialbd.ttf")); + assert_eq!(windows_font("Open Sans"), Some("arial.ttf")); + assert_eq!(windows_font("DejaVuSansMono-BoldOblique.ttf"), Some("courbi.ttf")); + assert_eq!(windows_font("DejaVuSerif-Italic.ttf"), Some("timesi.ttf")); + // Narrow and condensed styles are Linux fonts with no Windows one. + for font in ["Liberation Sans Narrow", "LiberationSansNarrow-Bold.ttf", "OpenSans-CondensedBold.ttf", "DejaVuSansCondensed.ttf"] { + assert!(missing_on_windows(font) && windows_font(font).is_none(), "{font}"); + } + // Fonts AutoCAD has, other kinds of file, or names that aren't fonts. + for font in ["arial.ttf", "txt", "dejavusans.shx", "", "/", "Liberation"] { + assert!(!missing_on_windows(font) && windows_font(font).is_none(), "{font}"); + } + } + + #[test] + fn written_font_only_changes_with_portable_fonts() { + assert_eq!(written_font("DejaVuSans.ttf", true), "verdana.ttf"); + assert_eq!(written_font("DejaVuSans.ttf", false), "DejaVuSans.ttf"); + assert_eq!(written_font("Liberation Sans Narrow", true), "Liberation Sans Narrow"); + } +} diff --git a/crates/io/src/dxf_read/tables.rs b/crates/io/src/dxf_read/tables.rs index 8a57f7f9e..ee42b8a58 100644 --- a/crates/io/src/dxf_read/tables.rs +++ b/crates/io/src/dxf_read/tables.rs @@ -4,6 +4,7 @@ use super::{ Color, DashElement, DimStyle, Drawing, HVal, HashMap, Layer, Linetype, Lineweight, NamedView, Rgb, Rx, Tag, TextStyle, Ucs, Vec2, Vec3, VportConfig, VportTile, records, }; +use cadcraft_fonts::windows::windows_font; pub(super) fn header(tags: &[Tag], d: &mut Drawing) { let mut i = 0; @@ -66,9 +67,9 @@ fn annotative(tags: &[Tag]) -> bool { } /// A text style's font: CADCraft's own font name from `CADCRAFT` xdata (`1000 FONT`, name) -/// while the font file is still the `txt` written for other readers; the TrueType family of the -/// style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD stores TrueType -/// styles); otherwise the file. +/// while the font file is still the `txt` or Windows font written for other readers; the TrueType +/// family of the style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD +/// stores TrueType styles); otherwise the file. fn style_font(tags: &[Tag], file: String) -> String { if file.trim().is_empty() && let Some(face) = @@ -82,8 +83,9 @@ fn style_font(tags: &[Tag], file: String) -> String { _ => None, }); let stem = file.trim().to_ascii_lowercase(); + let base = stem.rsplit(['/', '\\']).next().unwrap_or_default(); match own { - Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx") => name, + Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx" || windows_font(&name) == Some(base)) => name, _ => file, } } diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index 9a21ef7a9..2d45a532f 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -90,9 +90,10 @@ fn write_tables(w: &mut W, plan: TableWritePlan<'_>) -> HashMap w.f(50, s.oblique.to_degrees()); w.i(71, if s.backwards { 2 } else { 0 } | if s.upside_down { 4 } else { 0 }); w.f(42, 0.2); - // The built-in font goes out as `txt`, which every reader has; CADCraft's own name - // travels in `CADCRAFT` xdata (`1000 FONT`, name) so the style reopens with it. - let file = if s.font == cadcraft_fonts_name() { "txt" } else { s.font.as_str() }; + // The built-in font goes out as `txt`, which every reader has, and a Linux font as its + // Windows one with portable fonts; CADCraft's own name travels in `CADCRAFT` xdata + // (`1000 FONT`, name) so the style reopens with it. + let file = if s.font == cadcraft_fonts_name() { "txt" } else { cadcraft_fonts::windows::written_font(&s.font, d.portable_fonts) }; w.s(3, file); w.s(4, &s.big_font); if s.annotative { diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 52f37068a..e0ff04f97 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -54,6 +54,7 @@ mod pdf_read; mod plot_layout_scale; mod plot_model_png_scale; mod plot_styles; +mod portable_fonts; mod region_roundtrip; mod roundtrip; mod roundtrip_audit; diff --git a/crates/io/tests/it/portable_fonts.rs b/crates/io/tests/it/portable_fonts.rs new file mode 100644 index 000000000..070c376e8 --- /dev/null +++ b/crates/io/tests/it/portable_fonts.rs @@ -0,0 +1,59 @@ +//! Portable fonts: a drawing saved with them gets the Windows font for each Linux font in its +//! STYLE table, keeps its own in CADCraft xdata, and reopens with it, from DXF and DWG. + +use cadcraft_doc::{Drawing, TextStyle}; +use cadcraft_io::{read, read_dxf, write, write_dxf}; + +fn drawing(portable_fonts: bool) -> Drawing { + let mut d = Drawing { portable_fonts, ..Drawing::new_metric() }; + // A Linux font, and one with no Windows font. + for (name, font) in [("Notes", "DejaVuSans-Bold.ttf"), ("Tight", "Liberation Sans Narrow")] { + d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() }); + } + d +} + +/// (style, font) of the drawing's text styles. +fn fonts(d: &Drawing) -> Vec<(String, String)> { + d.text_styles.iter().map(|t| (t.name.clone(), t.font.clone())).collect() +} + +/// (style, font file) of the STYLE records of DXF text. +fn style_files(dxf: &str) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + let mut style = false; + for t in cadcraft_dxf::parse(dxf.as_bytes()).unwrap() { + match t.code { + 0 => style = t.str() == "STYLE", + 2 if style => out.push((t.str(), String::new())), + 3 if style => out.last_mut().unwrap().1 = t.str(), + _ => {} + } + } + out +} + +#[test] +fn styles_get_windows_fonts_and_reopen_with_their_own() { + let d = drawing(true); + let dxf = write_dxf(&d); + let files = style_files(&dxf); + for (style, file) in [("Notes", "verdanab.ttf"), ("Tight", "Liberation Sans Narrow")] { + assert!(files.contains(&(style.into(), file.into())), "{style}: {files:?}"); + } + for back in [read_dxf(dxf.as_bytes()).unwrap(), read(&write(&d, "t.dwg").unwrap(), "t.dwg").unwrap()] { + assert_eq!(fonts(&back), fonts(&drawing(false))); + } + // The Windows font with a path is still the one written for the style's own. + let path = read_dxf(dxf.replace("verdanab.ttf", "C:\\Windows\\Fonts\\verdanab.ttf").as_bytes()).unwrap(); + assert_eq!(fonts(&path), fonts(&drawing(false))); + // A font changed in AutoCAD since (the xdata left as it was) is the font read. + let edited = read_dxf(dxf.replace("verdanab.ttf", "times.ttf").as_bytes()).unwrap(); + assert!(fonts(&edited).contains(&("Notes".into(), "times.ttf".into()))); +} + +#[test] +fn without_portable_fonts_the_file_has_the_drawings_fonts() { + let files = style_files(&write_dxf(&drawing(false))); + assert!(files.contains(&("Notes".into(), "DejaVuSans-Bold.ttf".into())), "{files:?}"); +} From e187c5fecb26937bd2781c7f11f4b8cdd7491058 Mon Sep 17 00:00:00 2001 From: SoldierPePe Date: Sun, 1 Nov 2026 22:38:07 +0300 Subject: [PATCH 083/151] i18n(ar): translate 129 new strings, use the catalog's terms in 3 (#2564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arabic catalog showed 129 interface strings in English: the Block Editor and REFEDIT, dynamic blocks, multileader editing, hatch islands, origin and boundaries, selection and grip settings, visual effects, command aliases, backup and units options, and the MTEXT ruler. `cargo xtask i18n` now reports `ar: nothing missing`. Hatch, Leader and Multileader now use the terms the rest of the catalog uses (تهشير, خط الإشارة) instead of تظليل and قائد. --- crates/ui-egui/src/i18n/ar.tsv | 135 ++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 40e14cdb5..026190729 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -412,7 +412,7 @@ Group Edit تحرير المجموعة Group Options خيارات المجموعة Group Selection تحديد المجموعة - Hatch تظليل + Hatch تهشير Hatch Edit تحرير التهشير Hatch Pattern نمط التهشير Hatch Patterns أنماط التهشير @@ -504,7 +504,7 @@ Layout list قائمة التخطيطات Layout paper ورقة التخطيط Layout {name}: {table} التخطيط {name}: {table} - Leader قائد + Leader خط الإشارة Leaders Only خطوط الإشارة فقط Left يسار Left device button: zoom extents. Right device button: calibrate. زر الجهاز الأيسر: تكبير الحدود. زر الجهاز الأيمن: معايرة. @@ -573,7 +573,7 @@ More toolbar commands المزيد من أوامر شريط الأدوات Move نقل Move sideways to pan; push/pull or twist to zoom. حرّك جانبياً للإزاحة؛ ادفع أو اسحب أو لُف للتكبير. - Multileader قائد متعدد + Multileader خط الإشارة المتعدد Multileader Style... نمط خط الإشارة المتعدد... Multileader style نمط خط الإشارة المتعدد Multiline متعدد الأسطر @@ -1242,3 +1242,132 @@ menu View عرض Check for Updates… التحقق من وجود تحديثات… CADCraft is up to date. CADCraft محدّث. Could not check for updates. تعذّر التحقق من وجود تحديثات. + Add to Working Set إضافة إلى مجموعة العمل + Add: Pick points إضافة: انتقاء نقاط + Add: Select objects إضافة: تحديد كائنات + Add… إضافة… + Alias اختصار + Alias file: {file} ملف الاختصارات: {file} + Align Multileaders محاذاة خطوط الإشارة المتعددة + Analyze Shapes تحليل الأشكال + Analyze Usage تحليل الاستخدام + Animation Effects تأثيرات الحركة + Animation effects تأثيرات الحركة + Associative ترابطي + Backup copy نسخة احتياطية + Block to create or edit الكتلة المراد إنشاؤها أو تحريرها + Bottom left أسفل اليسار + Bottom right أسفل اليمين + Boundaries الحدود + Boundaries picked: {n} الحدود المنتقاة: {n} + Break-line Symbol رمز خط الكسر + Centered متمركز + Change Properties تغيير الخصائص + Choose the command the alias starts. اختر الأمر الذي يشغّله الاختصار. + Click the ruler to add a tab stop, right-click to remove one انقر على المسطرة لإضافة علامة جدولة، وانقر بالزر الأيمن لإزالتها + Click to set new origin انقر لتعيين نقطة أصل جديدة + Close Block Editor إغلاق محرر الكتل + Collect جمع + Collect Multileaders جمع خطوط الإشارة المتعددة + Color 1 اللون 1 + Color 2 اللون 2 + Command الأمر + Convert Text to Mtext تحويل النص إلى نص متعدد الأسطر + Create backup copy with each save إنشاء نسخة احتياطية عند كل حفظ + Create separate hatches إنشاء تهشيرات منفصلة + Crossing selection color: لون تحديد التقاطع: + Curved منحنٍ + Customize تخصيص + Default to boundary extents الافتراضي: امتدادات الحدود + Define each corner of the selection window with a separate click. حدد كل زاوية من نافذة التحديد بنقرة منفصلة. + Discard تجاهل + Drag Dynamic Grip سحب المقبض الديناميكي + Dynamic Block Data بيانات الكتلة الديناميكية + Dynamic Block Grips مقابض الكتلة الديناميكية + Dynamic Block Reference مرجع كتلة ديناميكية + Edit Block Definition تحرير تعريف الكتلة + Edit Command Aliases (PGP)... تحرير اختصارات الأوامر (PGP)... + Edit Multileader تحرير خط الإشارة المتعدد + Edit Reference In-Place تحرير المرجع في مكانه + Edit… تحرير… + Evaluate LISP تقييم LISP + Exclude: استثناء: + Explode Attributes to Text تفجير السمات إلى نص + Express Tools أدوات Express + Font الخط + Grips المقابض + Groups المجموعات + Hatch Pick انتقاء التهشير + Hatch and Gradient التهشير والتدرج اللوني + Hatch origin نقطة أصل التهشير + Hatches تهشيرات + Hemispherical نصف كروي + Ignore تجاهل + Imperial (inches) إنجليزي (بوصة) + Implied Loops حلقات ضمنية + Indicate selection area إظهار منطقة التحديد + Interface scale مقياس الواجهة + Inverted معكوس + Island جزيرة + Island detection اكتشاف الجزر + Italic مائل + Justify ضبط المحاذاة + Load Application... تحميل تطبيق... + Metric (millimetres) متري (مليمتر) + More than {n} aliases. أكثر من {n} اختصار. + Move/Copy/Rotate نقل/نسخ/تدوير + Multiline text نص متعدد الأسطر + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). تستخدم الرسومات الجديدة وأول رسم عند بدء التشغيل هذه الوحدات ما لم يُعيَّن قالب افتراضي (QNEWTEMPLATE). + New block كتلة جديدة + No alias file loaded. REINIT replaces these aliases with a file's. لم يُحمَّل ملف اختصارات. يستبدل REINIT هذه الاختصارات باختصارات ملف. + Not a number or distance: {text} ليس رقماً أو مسافة: {text} + Object grouping تجميع الكائنات + Object selection limit for display of grips حد تحديد الكائنات لعرض المقابض + Objects on locked layers الكائنات على الطبقات المقفلة + Open in Block Editor فتح في محرر الكتل + Origin: {x}, {y} نقطة الأصل: {x}, {y} + Outer خارجي + Overline خط علوي + Preview معاينة + Query Texts الاستعلام عن النصوص + Save Block حفظ الكتلة + Save Block As حفظ الكتلة باسم + Save Layout as Template حفظ التخطيط كقالب + Save Reference Edits حفظ تعديلات المرجع + Save alias حفظ الاختصار + Save changes to block "{name}"? حفظ التغييرات على الكتلة "{name}"؟ + Select boundaries to remove حدد الحدود المراد إزالتها + Select using two clicks only التحديد بنقرتين فقط + Selection Area Effect تأثير منطقة التحديد + Selection Preview Filter مرشح معاينة التحديد + Selection area opacity: عتامة منطقة التحديد: + Selection effect color لون تأثير التحديد + Selection modes أوضاع التحديد + Selection preview معاينة التحديد + Set Dynamic Property تعيين خاصية ديناميكية + Shape الشكل + Sheet Books to PDF... دفاتر اللوحات إلى PDF... + Show grips إظهار المقابض + Specified origin نقطة أصل محددة + Spherical كروي + Tables الجداول + Text color لون النص + Top left أعلى اليسار + Top right أعلى اليمين + Type an alias name. اكتب اسم الاختصار. + Underline تسطير + Units of new drawings وحدات الرسومات الجديدة + Unsaved changes تغييرات غير محفوظة + Use current origin استخدام نقطة الأصل الحالية + Visual Effect Settings إعدادات التأثيرات المرئية + Visual Effect Settings... إعدادات التأثيرات المرئية... + When a command is active عندما يكون أمر ما نشطاً + When no command is active عندما لا يكون أي أمر نشطاً + Window selection color: لون تحديد النافذة: + Working Set مجموعة العمل + Xref and Block In-place Editing تحرير المراجع الخارجية والكتل في مكانها + Xrefs المراجع الخارجية + built-in مدمج + overrides built-in يتجاوز المدمج + {alias} is already defined; select it and use Edit. {alias} معرَّف مسبقاً؛ حدده واستخدم «تحرير». + {command} is an unknown command. {command} أمر غير معروف. From 5e9c478bfbd2d9520bd3535f0d9a41674f948b4c Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:38:10 +0700 Subject: [PATCH 084/151] U inside a LISP routine takes back the routine's last command (#2610) Inside a routine, commands recorded no undo step (the routine becomes one when it ends), so U inside a routine took back the step from before the routine: after drawing a line D, ERASE B then U in one routine brought B back and removed D. As AutoCAD 2024 does (accoreconsole, the drawing read back after one routine), U inside a routine now takes back the routine's last command, with what the routine did after it (entdel, entmake); U again, UNDO n and REDO go on through its commands. With none left, U takes back the drawing's step before the routine, as before, and the routine's step then starts from there. Once the routine ends it is still one undo step. The routine's steps live in the session's LISP state (RoutineSteps), not in the drawing's undo stack, so a long routine doesn't push the drawing's earlier steps out. A command inside the routine ends what REDO could bring back; UNDO Control One and None apply. DocState::swap_in is the one restore step undo, redo and the routine share. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/doc_state.rs | 8 + crates/engine/src/lisp.rs | 85 +++++++++- crates/engine/src/session/execute.rs | 43 +++-- crates/engine/src/session/interactive.rs | 6 +- .../engine/tests/it/lisp_undo_in_routine.rs | 154 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 6 files changed, 278 insertions(+), 19 deletions(-) create mode 100644 crates/engine/tests/it/lisp_undo_in_routine.rs diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d11c6f0eb..97a1ede49 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -72,6 +72,14 @@ impl DocState { pub fn is_dirty(&self) -> bool { !Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved() } + /// Show the drawing an undo or redo step holds, with nothing selected; returns the drawing as + /// it was, as the step back. + pub(crate) fn swap_in(&mut self, snap: Snapshot) -> Snapshot { + let cur = Snapshot { label: snap.label, doc: std::mem::replace(&mut self.doc, snap.doc), selection: std::mem::take(&mut self.selection) }; + self.follow_layout(&cur.doc); + self.revision += 1; + cur + } /// After undo/redo replaced the drawing (`before` is the one shown until then): if the current /// layout no longer exists, follow it to its other name when it was renamed (same tab order, /// a name `before` did not have), else go to the Model tab. diff --git a/crates/engine/src/lisp.rs b/crates/engine/src/lisp.rs index b3175c512..977d4f551 100644 --- a/crates/engine/src/lisp.rs +++ b/crates/engine/src/lisp.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use cadcraft_doc::{Drawing, Handle}; -use crate::{Input, Prompt, Session}; +use crate::{Input, Prompt, Session, undoctl}; use eval::{Ctx, Machine, Need, Outcome}; use value::{Sym, Value}; @@ -111,6 +111,8 @@ pub struct LispState { run: Option>, /// A routine runs, from its start to its end (also while its machine is out of `run`). active: bool, + /// The running routine's own undo steps (also while its machine is out of `run`). + steps: RoutineSteps, /// Files loaded by APPLOAD, by name, for `(load name)` where there is no file system. files: HashMap, /// Where startup files are read from (see `startup.rs`). @@ -123,6 +125,19 @@ pub struct LispState { pub(crate) limits: Limits, } +/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing +/// was before it, last last. The routine is still one undo step once it ends. +#[derive(Default)] +struct RoutineSteps { + /// The drawing the routine runs in. + uid: u64, + undo: std::collections::VecDeque, + redo: std::collections::VecDeque, + /// Where the routine's undo step starts, when U or REDO inside it went on to the drawing's + /// own steps; else where the routine started. + base: Option<(Arc, Vec)>, +} + /// What a run may use (see [`value`]'s memory notes). #[derive(Clone, Copy, Debug)] pub(crate) struct Limits { @@ -247,6 +262,7 @@ impl Session { let st = self.state()?; let before = Some((st.doc.clone(), st.selection.clone())); let uid = st.uid; + self.lisp.steps = RoutineSteps { uid, ..RoutineSteps::default() }; self.lisp.run = Some(Box::new(Run { machine, uid, @@ -324,7 +340,7 @@ impl Session { Ok(o) => o, Err(p) => { // A panic inside a built-in: put the drawing back as the routine found it. - if let Some((doc, sel)) = &run.before + if let Some((doc, sel)) = self.lisp.steps.base.as_ref().or(run.before.as_ref()) && let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid) { st.doc = doc.clone(); @@ -422,6 +438,7 @@ impl Session { fn lisp_end(&mut self, result: std::result::Result) { let Some(run) = self.lisp.run.take() else { return }; self.lisp.active = false; + let steps = std::mem::take(&mut self.lisp.steps); let mut env = self.lisp.envs.remove(&run.uid).unwrap_or_default(); self.lisp_flush(&mut env, true); self.lisp.envs.insert(run.uid, env); @@ -431,7 +448,7 @@ impl Session { self.echo(text); } } - if let Some((doc, sel)) = run.before + if let Some((doc, sel)) = steps.base.or(run.before) && let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid) && !Arc::ptr_eq(&doc, &st.doc) { @@ -489,6 +506,7 @@ impl Session { self.lisp_error(LispError::quit(msg)); } else if self.lisp.active { self.lisp.active = false; + self.lisp.steps = RoutineSteps::default(); self.echo(format!("; error: {msg}")); self.lisp_last = Some(Err(msg)); } else { @@ -577,6 +595,67 @@ impl Session { self.lisp.active } + /// A command the running routine ran changed the drawing (`snap` holds it as it was): U inside + /// the routine takes it back, as AutoCAD's does, while the routine stays one undo step. UNDO + /// Control One keeps only the last, None none. + pub(crate) fn lisp_record_step(&mut self, snap: crate::Snapshot) { + let Some(uid) = self.routine_steps() else { return }; + let mut control = undoctl::Control::All; + // A command ends what REDO could bring back, the drawing's own steps too. + if let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) { + st.redo.clear(); + control = st.undo_ctl.control; + } + let steps = &mut self.lisp.steps; + steps.redo.clear(); + match control { + undoctl::Control::None => steps.undo.clear(), + undoctl::Control::One => { + steps.undo.clear(); + steps.undo.push_back(snap); + } + // As many as the drawing keeps. + undoctl::Control::All => { + steps.undo.push_back(snap); + if steps.undo.len() > undoctl::MAX_UNDO { + steps.undo.pop_front(); + } + } + } + } + + /// U (or with `redo`, REDO) inside the running routine: take its last command back (or bring + /// back what U took). None when it has none left, and the drawing's own steps go on from there. + pub(crate) fn lisp_take_back(&mut self, redo: bool) -> Option { + let uid = self.routine_steps()?; + let st = self.docs.iter_mut().find(|d| d.uid == uid)?; + let steps = &mut self.lisp.steps; + let (from, to) = if redo { (&mut steps.redo, &mut steps.undo) } else { (&mut steps.undo, &mut steps.redo) }; + let cur = st.swap_in(from.pop_back()?); + let label = cur.label.clone(); + to.push_back(cur); + Some(label) + } + + /// U or REDO inside the running routine went on to the drawing's steps before it: the + /// routine's own undo step starts from the drawing as that left it, and REDO brings back that + /// step before what U took back of the routine's. + pub(crate) fn lisp_rebase(&mut self) { + if self.routine_steps().is_none() { + return; + } + if let Ok(st) = self.state() { + self.lisp.steps.base = Some((st.doc.clone(), st.selection.clone())); + self.lisp.steps.redo.clear(); + } + } + + /// The active drawing's uid when a routine runs in it. + fn routine_steps(&self) -> Option { + let uid = self.state().ok()?.uid; + (self.lisp.active && self.lisp.steps.uid == uid).then_some(uid) + } + /// The commands the active drawing's LISP defines (`c:` functions), uppercase and sorted. pub fn lisp_commands(&self) -> Vec { let Ok(uid) = self.state().map(|s| s.uid) else { return Vec::new() }; diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index a4c57eec5..230422e09 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -66,17 +66,25 @@ impl Session { (r, _) => r, }; let grouped = self.lisp_groups_undo(); + let mut in_routine = None; if spec.undoable && let Some((doc, sel, uid)) = before && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) && !Arc::ptr_eq(&doc, &st.doc) { - // Inside a LISP routine the routine's single undo step records it. - if !grouped { - st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel }); + let snap = Snapshot { label: spec.label.to_string(), doc, selection: sel }; + // Inside a LISP routine the routine's single undo step records it, and the routine + // keeps it for U inside it. + if grouped { + in_routine = Some(snap); + } else { + st.push_undo(snap); } st.revision += 1; } + if let Some(snap) = in_routine { + self.lisp_record_step(snap); + } result } @@ -89,27 +97,32 @@ impl Session { // ---------------- undo ---------------- + /// Take the last step back. Inside a LISP routine, the routine's last command first; past + /// those, the drawing's step before the routine, which then starts from there. pub fn undo(&mut self) -> Result> { + if let Some(label) = self.lisp_take_back(false) { + return Ok(Some(label)); + } let st = self.state_mut()?; let Some(snap) = st.undo.pop() else { return Ok(None) }; - let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; - st.doc = snap.doc; - st.follow_layout(&cur.doc); - st.selection = Vec::new(); + let cur = st.swap_in(snap); + let label = cur.label.clone(); st.redo.push(cur); - st.revision += 1; - Ok(Some(snap.label)) + self.lisp_rebase(); + Ok(Some(label)) } + /// Bring back what the last undo took back; inside a LISP routine, what U inside it took first. pub fn redo(&mut self) -> Result> { + if let Some(label) = self.lisp_take_back(true) { + return Ok(Some(label)); + } let st = self.state_mut()?; let Some(snap) = st.redo.pop() else { return Ok(None) }; - let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; - st.doc = snap.doc; - st.follow_layout(&cur.doc); - st.selection = Vec::new(); + let cur = st.swap_in(snap); + let label = cur.label.clone(); st.undo.push(cur); - st.revision += 1; - Ok(Some(snap.label)) + self.lisp_rebase(); + Ok(Some(label)) } } diff --git a/crates/engine/src/session/interactive.rs b/crates/engine/src/session/interactive.rs index d90c91683..aa66bd4c5 100644 --- a/crates/engine/src/session/interactive.rs +++ b/crates/engine/src/session/interactive.rs @@ -379,12 +379,16 @@ impl Session { } self.after_command(Some(&run.before), None, true); // A transparent command's changes belong to the command it ran inside, and a command a - // LISP routine ran belongs to the routine's single undo step. + // LISP routine ran belongs to the routine's single undo step (the routine keeps it for U + // inside it). if self.suspended.is_some() || self.lisp_groups_undo() { if let Ok(st) = self.state_mut() && !Arc::ptr_eq(&run.before, &st.doc) { st.revision += 1; + if self.suspended.is_none() { + self.lisp_record_step(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before }); + } } return; } diff --git a/crates/engine/tests/it/lisp_undo_in_routine.rs b/crates/engine/tests/it/lisp_undo_in_routine.rs new file mode 100644 index 000000000..9afa3c9f5 --- /dev/null +++ b/crates/engine/tests/it/lisp_undo_in_routine.rs @@ -0,0 +1,154 @@ +//! U, UNDO and REDO inside a LISP routine, as AutoCAD 2024 runs them (accoreconsole: a line A, a +//! line B, a circle C and a line D, then one routine, then the drawing's `entnext` order read back). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::Handle; +use serde_json::json; + +/// A session with A, B, C and D in draw order, each its own undo step, and their handles. +fn drawing() -> (Session, Vec) { + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + let mut s = Session::new(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap(); + s.execute("circle", &json!({ "center": [5, 25], "radius": 3 })).unwrap(); + s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap(); + let hs = handles(&s); + (s, hs) +} + +fn handles(s: &Session) -> Vec { + s.doc().unwrap().model.iter().map(|e| e.handle).collect() +} + +/// The drawing's objects in draw order: A to D by their handles, E for any other. +fn order(s: &Session, hs: &[Handle]) -> String { + let name = |h: &Handle| hs.iter().position(|x| x == h).map_or('E', |i| (b'A' + i as u8) as char); + handles(s).iter().map(|h| name(h).to_string()).collect::>().join(" ") +} + +/// Run `parts` as ONE routine, `@A`…`@D` standing for the objects. +fn routine(s: &mut Session, hs: &[Handle], parts: &[&str]) { + let mut code = format!("(progn {})", parts.join(" ")); + for (i, h) in hs.iter().enumerate() { + code = code.replace(&format!("@{}", (b'A' + i as u8) as char), &format!("(handent \"{}\")", h.hex())); + } + s.cmdline(&code).unwrap(); + assert!(s.running.is_none(), "{code}: {}", s.prompt_text()); +} + +const EB: &str = "(command \"_.ERASE\" @B \"\")"; +const EC: &str = "(command \"_.ERASE\" @C \"\")"; +const U: &str = "(command \"_.U\")"; +const LINE: &str = "(command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")"; +const MAKE: &str = "(entmake '((0 . \"LINE\") (8 . \"0\") (10 0.0 60.0 0.0) (11 10.0 60.0 0.0)))"; + +#[test] +fn u_in_a_routine_takes_back_the_routines_last_command() { + // (case, the routine, the drawing's order afterwards) + let cases: [(&str, &[&str], &str); 9] = [ + ("U-001: ERASE, U", &[EB, U], "A B C D"), + ("U-002: two erases, U", &[EB, EC, U], "A C D"), + ("U-003: two erases, U, U", &[EB, EC, U, U], "A B C D"), + ("U-005: entdel, ERASE, U", &["(entdel @B)", EC, U], "A C D"), + ("U-006: ERASE, entdel, U", &[EB, "(entdel @C)", U], "A B C D"), + ("U-007: LINE, U, REDO", &[LINE, U, "(command \"_.REDO\")"], "A B C D E"), + ("U-008: two erases, UNDO 2", &[EB, EC, "(command \"_.UNDO\" \"2\")"], "A B C D"), + ("U-012: ERASE, entmake, U", &[EB, MAKE, U], "A B C D"), + // OOPS-011 had an OOPS after the U, which brought B back: B was still erased. + ("OOPS-011: ERASE, LINE, U", &[EB, LINE, U], "A C D"), + ]; + for (case, parts, want) in cases { + let (mut s, hs) = drawing(); + routine(&mut s, &hs, parts); + assert_eq!(order(&s, &hs), want, "{case}"); + } +} + +#[test] +fn the_routine_is_still_one_undo_step_after_it() { + let (mut s, hs) = drawing(); + let depth = s.state().unwrap().undo.len(); + routine(&mut s, &hs, &[EB, EC, LINE, U, "(entdel @A)"]); + assert_eq!(order(&s, &hs), "D"); + assert_eq!(s.state().unwrap().undo.len(), depth + 1); + s.cmdline("U").unwrap(); + assert_eq!(order(&s, &hs), "A B C D"); + s.cmdline("REDO").unwrap(); + assert_eq!(order(&s, &hs), "D"); +} + +#[test] +fn u_with_nothing_of_the_routine_left_takes_back_the_step_before_it() { + let (mut s, hs) = drawing(); + let depth = s.state().unwrap().undo.len(); + routine(&mut s, &hs, &[EB, U, U]); + // The second U takes back D's line, drawn before the routine. + assert_eq!(order(&s, &hs), "A B C"); + assert_eq!(s.state().unwrap().undo.len(), depth - 1, "the routine changed nothing more of its own"); + // A command after that makes the routine's step, from where the U took the drawing. + routine(&mut s, &hs, &[EB, U, U, "(command \"_.ERASE\" @A \"\")"]); + assert_eq!(order(&s, &hs), "B"); + s.cmdline("U").unwrap(); + assert_eq!(order(&s, &hs), "A B"); +} + +#[test] +fn undo_off_still_refuses_inside_a_routine() { + let (mut s, hs) = drawing(); + routine(&mut s, &hs, &["(command \"_.UNDO\" \"_C\" \"_N\")", EB, U]); + assert_eq!(order(&s, &hs), "A C D"); +} + +#[test] +fn redo_inside_a_routine_brings_back_the_last_u_first() { + // U past the routine's own command, then REDO: the drawing's step comes back, not the erase. + let (mut s, hs) = drawing(); + let depth = s.state().unwrap().undo.len(); + routine(&mut s, &hs, &[EB, U, U, "(command \"_.REDO\")"]); + assert_eq!(order(&s, &hs), "A B C D"); + assert_eq!(s.state().unwrap().undo.len(), depth, "the routine left nothing of its own to undo"); + // A command inside the routine ends what REDO could bring back from before it. + let (mut s, hs) = drawing(); + s.cmdline("U").unwrap(); + assert_eq!(order(&s, &hs), "A B C"); + routine(&mut s, &hs, &[EB, "(command \"_.REDO\")"]); + assert_eq!(order(&s, &hs), "A C"); +} + +#[test] +fn a_command_without_prompts_is_a_step_of_the_routine_too() { + // HATCHTOBACK runs as its JSON form from a routine; U takes it back, not the hatch. + let mut s = Session::new(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + let rect = handles(&s)[0]; + s.execute("hatch", &json!({ "handles": [rect.hex()], "pattern": "SOLID" })).unwrap(); + let both = handles(&s); + assert_eq!(both.len(), 2); + s.cmdline("(progn (command \"_.HATCHTOBACK\") (command \"_.U\"))").unwrap(); + assert_eq!(handles(&s), both); +} + +#[test] +fn undo_control_one_keeps_only_the_routines_last_command() { + // As UNDO Control One keeps only the drawing's last step: the second U goes on to the step + // before the routine (D's line), the drawing as it was then. + let (mut s, hs) = drawing(); + routine(&mut s, &hs, &["(command \"_.UNDO\" \"_C\" \"_O\")", EB, EC, U, U]); + assert_eq!(order(&s, &hs), "A B C"); +} + +#[test] +fn a_routine_keeps_as_many_steps_as_the_drawing() { + use cadcraft_engine::undoctl::MAX_UNDO; + let (mut s, hs) = drawing(); + // Two lines more than the routine keeps steps for, then a U for each kept step and one more, + // which goes on to the drawing's step before the routine (D's line): the drawing as it was + // then, without the two lines whose steps went. + let code = + format!("(progn (repeat {} (command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")) (repeat {} (command \"_.U\")))", MAX_UNDO + 2, MAX_UNDO + 1); + s.cmdline(&code).unwrap(); + assert!(s.running.is_none()); + assert_eq!(order(&s, &hs), "A B C"); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 2aae7c117..65b8e244f 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -73,6 +73,7 @@ mod layout_prompts; mod leader_command; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod lisp_corpus; +mod lisp_undo_in_routine; mod malformed_handles; mod matchprop; mod measure_distance_snapping; From fb05c7859030ca7063228bd4e184044ccc278814 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:38:14 +0700 Subject: [PATCH 085/151] Read bearings and compass points as AutoCAD does (#2590) Measured in AutoCAD 2024 (accoreconsole): - N, E, S and W are taken on their own in any angle format (LINE to @10 --- crates/engine/src/cmd/props/dash_units.rs | 13 +--- crates/engine/src/units.rs | 48 +++++++++------ crates/engine/tests/it/angle_settings.rs | 74 ++++++++++++++++++++++- crates/engine/tests/it/dash_units.rs | 65 +++----------------- 4 files changed, 112 insertions(+), 88 deletions(-) diff --git a/crates/engine/src/cmd/props/dash_units.rs b/crates/engine/src/cmd/props/dash_units.rs index a10153f56..95b6bc021 100644 --- a/crates/engine/src/cmd/props/dash_units.rs +++ b/crates/engine/src/cmd/props/dash_units.rs @@ -66,17 +66,10 @@ impl Units { } /// A typed direction of angle 0, in radians counterclockwise from +X: an amount in the angle - /// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, and in surveyor's - /// units N, E, S or W), measured from +X as the drawing measures angles. + /// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, `N`), measured + /// from +X as the drawing measures angles. fn direction(&self, t: &str) -> Option { - let cardinal = match t.trim().to_ascii_lowercase().as_str() { - "e" if self.aunits == 4 => Some(0.0), - "n" if self.aunits == 4 => Some(90f64.to_radians()), - "w" if self.aunits == 4 => Some(180f64.to_radians()), - "s" if self.aunits == 4 => Some(270f64.to_radians()), - _ => None, - }; - let a = cardinal.or_else(|| AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t))?; + let a = AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t)?; let a = if self.clockwise { -a } else { a }; a.is_finite().then(|| a.rem_euclid(TAU)) } diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index 560551f1c..edfa24377 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -277,7 +277,8 @@ impl AngleSettings { } /// A typed angle size in radians (included angles, sweeps, angles from an axis): a bare - /// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`. + /// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`; a + /// surveyor's bearing (`N45dE`, or `N`, `E`, `S`, `W`) is its angle counterclockwise from east. pub fn amount(&self, s: &str) -> Option { parse_units(s, self.aunits).map(|(a, _)| a) } @@ -288,20 +289,19 @@ impl AngleSettings { } /// A typed direction in radians counterclockwise from +X: the amount measured from ANGBASE in - /// the ANGDIR sense. Surveyor's bearings (`N45dE`) are absolute. The overrides measure from - /// +X counterclockwise whatever ANGBASE and ANGDIR say: `< Option { let t = s.trim(); if let Some(r) = t.strip_prefix("<<<") { return parse_units(r, self.aunits).map(|(a, _)| a); } if let Some(r) = t.strip_prefix("<<") { - return parse_units(r, 0).map(|(a, _)| a); - } - let (a, absolute) = parse_units(t, self.aunits)?; - if absolute { - return Some(a); + return parse_units(r, 0).filter(|(_, bearing)| !bearing).map(|(a, _)| a); } + let (a, _) = parse_units(t, self.aunits)?; let v = self.angbase + if self.clockwise { -a } else { a }; v.is_finite().then_some(v) } @@ -328,7 +328,8 @@ pub fn parse_angle(s: &str) -> Option { AngleSettings::default().direction(s) } -/// A typed angle in radians and whether it is absolute (a surveyor's bearing). +/// A typed angle in radians and whether it is a surveyor's bearing (`N45dE`, or `N`, `E`, `S`, +/// `W`), whose angle is its direction counterclockwise from east. fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> { let t = s.trim().to_ascii_lowercase(); if t.is_empty() { @@ -339,10 +340,7 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> { (num(r)?, false) } else if let Some(g) = t.strip_suffix('g') { ((num(g)? * 0.9).to_radians(), false) - } else if let Some(b) = bearing(&t) { - (b, true) - } else if let Some(b) = (aunits == 4).then(|| cardinal(&t)).flatten() { - // A due bearing as surveyor's units show it: one letter. + } else if let Some(b) = cardinal(&t).or_else(|| bearing(&t)) { (b, true) } else if let Some(v) = num(&t) { let a = match aunits { @@ -357,7 +355,8 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> { v.0.is_finite().then_some(v) } -/// A due bearing (`n`, `e`, `s`, `w`, lower case) as radians counterclockwise from +X. +/// A compass point (`n`, `e`, `s`, `w`, lower case), a due bearing as surveyor's units show it, +/// as radians counterclockwise from +X. AutoCAD 2024 takes one in any angle format. fn cardinal(t: &str) -> Option { match t { "e" => Some(0.0), @@ -567,14 +566,19 @@ mod tests { #[test] fn surveyors_units_read_a_due_bearing_typed_as_one_letter() { - // What surveyor's units show for a due bearing can be typed back, e.g. `@10 assert_near(l.b.xy(), 0.0, -10.0, "ROTATE by N with ANGDIR 1"), + k => panic!("expected a line, got {k:?}"), + } + // ANGTOF reads them the same way: AutoCAD 2024 returns 0 for "N" with ANGBASE 90, ANGDIR 1 + // and AUNITS 3, and 4.18879020478639 for "S 30d W". + let mut s = Session::new(); + setvar(&mut s, "AUNITS", json!(3)); + setvar(&mut s, "ANGBASE", json!(90)); + setvar(&mut s, "ANGDIR", json!(1)); + let n = s.lisp_eval("(angtof \"N\")", false, true, Vec::new()).unwrap(); + assert!(matches!(n, Some(Ok(Value::Real(a))) if a.abs() < 1e-12), "{n:?}"); + let mut s = Session::new(); + setvar(&mut s, "AUNITS", json!(4)); + let sw = s.lisp_eval("(angtof \"S 30d W\")", false, true, Vec::new()).unwrap(); + assert!(matches!(sw, Some(Ok(Value::Real(a))) if (a - 4.18879020478639).abs() < 1e-12), "{sw:?}"); } #[test] diff --git a/crates/engine/tests/it/dash_units.rs b/crates/engine/tests/it/dash_units.rs index 5c8c3e761..f554378d8 100644 --- a/crates/engine/tests/it/dash_units.rs +++ b/crates/engine/tests/it/dash_units.rs @@ -39,7 +39,7 @@ fn run(s: &mut Session, answers: &str) { #[test] fn the_measured_scripts_set_the_units() { - let cases: [(Units, &str, Units); 21] = [ + let cases: [(Units, &str, Units); 22] = [ (DEFAULT, "4 16 1 0 0 \"_N\"", (4, 4, 0, 0, 0.0, 0)), (DEFAULT, "3 2 2 4 90 \"_Y\"", (3, 2, 1, 4, FRAC_PI_2, 1)), (DEFAULT, "2 3 3 2 100 \"_N\"", (2, 3, 2, 2, FRAC_PI_2, 0)), @@ -61,6 +61,7 @@ fn the_measured_scripts_set_the_units() { (DEFAULT, "\"\" \"\" \"\" \"\" \"\" \"Yes\"", (2, 4, 0, 0, 0.0, 1)), ((2, 4, 2, 6, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 2, 6, 0.0, 0)), ((2, 4, 4, 2, 0.0, 1), "\"\" \"\" \"\" \"\" \"S45dE\" \"\"", (2, 4, 4, 2, 0.7853981633974482, 1)), + (DEFAULT, "\"\" \"\" \"\" \"\" \"N\" \"\"", (2, 4, 0, 0, FRAC_PI_2, 0)), ]; for (from, answers, to) in cases { let mut s = session(from); @@ -112,15 +113,7 @@ fn prompts_and_listings_show_the_drawings_units() { // Surveyor's units, clockwise: north is listed as S, as AutoCAD lists it. let mut s = session((2, 4, 0, 0, 0.0, 1)); - s.script( - "-units - - -5 - -", - ) - .unwrap(); + s.script("-units\n\n\n5\n\n").unwrap(); let directions = [ "Direction for angle E:", " East 3 o'clock = E", @@ -133,15 +126,7 @@ fn prompts_and_listings_show_the_drawings_units() { // The listing shows directions to the precision just given. let mut s = session(DEFAULT); - s.script( - "-units - - -2 -2 -", - ) - .unwrap(); + s.script("-units\n\n\n2\n2\n").unwrap(); let directions = [ "Direction for angle 0d0':", " East 3 o'clock = 0d0'", @@ -166,27 +151,11 @@ fn a_rejected_answer_is_asked_again() { s.cmdline("3").unwrap(); assert!(ends_with(&s, &["Invalid choice.", "Enter denominator of smallest fraction to display"]), "{:?}", s.log); assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <16>:"); - s.script( - " - -9 -", - ) - .unwrap(); + s.script("\n\n9\n").unwrap(); assert!(ends_with(&s, &["Requires an integer between 0 and 8."]), "{:?}", s.log); - s.script( - " -abc -", - ) - .unwrap(); + s.script("\nabc\n").unwrap(); assert!(ends_with(&s, &["Requires valid numeric angle or two points."]), "{:?}", s.log); - s.script( - " - -", - ) - .unwrap(); + s.script("\n\n").unwrap(); assert!(s.running.is_none()); assert!(same(units(&s), (4, 4, 0, 0, 0.0, 0)), "{:?}", units(&s)); } @@ -196,28 +165,12 @@ fn each_answer_is_kept_as_it_is_given() { // Esc after the angle format: the answers before it are kept, and the new angle format's // precision is reset to 4. let mut s = session(DEFAULT); - s.script( - "-units -4 -16 -2 -", - ) - .unwrap(); + s.script("-units\n4\n16\n2\n").unwrap(); s.cancel(); assert!(s.running.is_none()); assert!(same(units(&s), (4, 4, 1, 4, 0.0, 0)), "{:?}", units(&s)); let mut s = session(DEFAULT); - s.script( - "-units -3 -2 -3 -5 -90 -", - ) - .unwrap(); + s.script("-units\n3\n2\n3\n5\n90\n").unwrap(); s.cancel(); assert!(same(units(&s), (3, 2, 2, 5, 1.413716694115407, 0)), "{:?}", units(&s)); } From 5cc342b033728fe8261273abd0a578c421af61d5 Mon Sep 17 00:00:00 2001 From: Andrea Ficarra Date: Sun, 1 Nov 2026 20:38:17 +0100 Subject: [PATCH 086/151] DXF/DWG: viewport frozen layers as group 331 (341 in an R2000 DXF) (#2575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * DXF/DWG: viewport frozen layers as group 331 (341 in an R2000 DXF) AutoCAD 2004 and later, and the DWG converter, read a VIEWPORT's frozen layers from group 331; CADCraft wrote only group 341 (the DXF 2000 form), so a DWG or 2004+ DXF lost the freeze outside CADCraft, and a file frozen only in group 331 (as AutoCAD 2026 and ezdxf write it) lost it on the first save. Now: group 341 in an R2000 DXF, 331 otherwise and for the DWG bridge; both are read, each layer once. CADCraft's own xdata still wins. Tests in crates/io/src/tests.rs, including a_331_only_viewport_freeze_survives_open_and_save. Co-Authored-By: Claude Opus 5.5 * Leave the parity docs to the maintainers CLAUDE.md: PRs don't edit the parity docs; the affected rows are named in the PR description instead. Co-Authored-By: Claude Opus 5.5 * Viewport frozen layers: group 331 wins over stale xdata AutoCAD now sees the freeze in group 331, edits it, and keeps CADCraft's VPFROZEN xdata as it was. Reading preferred the xdata, so a layer thawed in AutoCAD came back frozen. The handles now win; the xdata still gives names that have no LAYER record, and the whole list for records without handles (entget/entmod, older files). The handles are written before group 90, as in the DXF Reference. Test: viewport_frozen_331_wins_over_the_xdata. Co-Authored-By: Claude Opus 5.5 * Viewport frozen layers: handles for real layers, xdata only for the rest Simpler and safe against edits in AutoCAD. A layer with a LAYER record is written only as a handle (331, or 341 in an R2000 DXF); CADCraft's VPFROZEN xdata now holds only the names with no record (and the whole list for entget, which writes no handles). Reading takes the handles, then the xdata names, each layer once: no precedence, and a viewport thawed in AutoCAD (even its last frozen layer) stays thawed, since no stale list is left behind. The reader no longer builds a layer-name set per viewport. Tests: viewport_frozen_layers_read_from_331_or_341_once_each, viewport_freeze_edited_in_autocad_is_read_as_edited. Co-Authored-By: Claude Opus 5.5 * Viewport frozen layers: skip 102 application groups A group 331 inside another application's `102 {…}` group isn't a frozen layer. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_read.rs | 2 +- crates/io/src/dxf_read/entities.rs | 22 ++++--- crates/io/src/dxf_write.rs | 6 +- crates/io/src/dxf_write/ctx.rs | 3 + crates/io/src/dxf_write/entity.rs | 22 +++---- crates/io/src/dxf_write/sections.rs | 5 +- crates/io/src/tests.rs | 89 ++++++++++++++++++----------- 7 files changed, 93 insertions(+), 56 deletions(-) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 1489f4def..5b8865953 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -59,7 +59,7 @@ struct Rx { pstyles: HashMap, /// Upper-case block name → associative array parameters from its BLOCK_RECORD. block_arrays: HashMap, - /// LAYER record handle (upper case) → layer name (VIEWPORT group 341 frozen layers). + /// LAYER record handle (upper case) → layer name (VIEWPORT group 331/341 frozen layers). layers: HashMap, } diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index 12abae7da..a074a6517 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -466,14 +466,20 @@ pub(super) fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityK } rx.mleader_fix.push((h, pending)); } - // CADCraft's VPFROZEN xdata wins; otherwise AutoCAD's group 341 layer handles. - ("VIEWPORT", EntityKind::Viewport(v)) if v.frozen_layers.is_empty() => { - v.frozen_layers = tags - .iter() - .take_while(|t| t.code < 1000) - .filter(|t| t.code == 341) - .filter_map(|t| rx.layers.get(&t.str().trim().to_ascii_uppercase()).cloned()) - .collect(); + // The frozen layers: the LAYER handles of group 331 (341 in DXF 2000), outside `102 {…}` + // application groups, then the names in CADCraft's VPFROZEN xdata, which holds those + // without one; each layer once. + ("VIEWPORT", EntityKind::Viewport(v)) => { + let mut app_group = false; + let handles = tags.iter().take_while(|t| t.code < 1000).filter(|t| { + if t.code == 102 { + app_group = t.str().starts_with('{'); + } + !app_group && (t.code == 331 || t.code == 341) + }); + let named = handles.filter_map(|t| rx.layers.get(&t.str().trim().to_ascii_uppercase())); + let mut seen = std::collections::HashSet::new(); + v.frozen_layers = named.chain(&v.frozen_layers).filter(|n| seen.insert(n.to_ascii_uppercase())).cloned().collect(); } _ => {} } diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 8eb3ab91f..341f70e62 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -41,7 +41,7 @@ struct W { next: u64, /// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects). pstyles: HashMap, - /// Upper-case layer name → its LAYER table record handle (group 341 of viewports). + /// Upper-case layer name → its LAYER table record handle (group 331/341 of viewports). layers: HashMap, /// Persistent reactors of boundary objects: each source object of an associative hatch → /// the hatches (groups `{ACAD_REACTORS 330 …}`), from [`hatch_reactors`]. @@ -112,13 +112,13 @@ pub fn write(d: &Drawing) -> String { /// The drawing's DXF tags, declaring `version` in `$ACADVER`. pub fn tags(d: &Drawing, version: Version) -> Vec { - tags_for(d, version, version) + tags_for(d, version, version, version < Version::R2004) } /// The R2000 DXF the DWG bridge converts to a `dwg` version DWG: its entity types are the DWG /// version's (MULTILEADER from 2007). pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String { - let mut tags = tags_for(d, Version::R2000, dwg); + let mut tags = tags_for(d, Version::R2000, dwg, false); bridge_markers(&mut tags); cadcraft_dxf::write_ascii(&tags) } diff --git a/crates/io/src/dxf_write/ctx.rs b/crates/io/src/dxf_write/ctx.rs index 157dcd441..25194bb46 100644 --- a/crates/io/src/dxf_write/ctx.rs +++ b/crates/io/src/dxf_write/ctx.rs @@ -53,6 +53,9 @@ pub(super) struct Ctx { /// The version the file ends up in (the DWG version for a DWG save): it decides which /// entity types exist (MULTILEADER from 2007). pub(super) target: Version, + /// A VIEWPORT's frozen layers in group 341, as an R2000 DXF has them; otherwise 331 (DXF 2004 + /// and later, and the DWG bridge, which reads only 331). + pub(super) frozen_341: bool, /// MTEXT columns as the legacy groups 75..79 even from R2018 (the groups AutoLISP lists). pub(super) legacy_columns: bool, } diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index f5771f106..a33437017 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -620,22 +620,24 @@ fn write_viewport_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: // 340 entity, 131072 = off. let clip = v.clip.filter(|h| *h != e.handle && d.entity(*h).is_some()); let flags = 32768 | if v.locked { 16384 } else { 0 } | if clip.is_some() { 65536 } else { 0 } | if v.on { 0 } else { 131072 }; + // AutoCAD's frozen-layer list, before group 90 as in the DXF Reference: one pointer per + // layer, to its LAYER record. A name with no record goes in CADCraft's xdata below. + let mut unrecorded: Vec = Vec::new(); + for name in &v.frozen_layers { + match w.layers.get(&name.to_ascii_uppercase()).cloned() { + Some(h) => w.s(if cx.frozen_341 { 341 } else { 331 }, h), + None => unrecorded.push(name.clone()), + } + } w.i(90, flags); if let Some(h) = clip { w.s(340, h.hex()); } - // AutoCAD's frozen-layer list: one soft pointer per layer, to its LAYER record. Other - // programs read only this; CADCraft prefers its own xdata below when present. - for name in &v.frozen_layers { - if let Some(h) = w.layers.get(&name.to_ascii_uppercase()).cloned() { - w.s(341, h); - } - } // One CADCraft xdata group holds both lists: readers stop at the first group of an app. - if !v.frozen_layers.is_empty() || !v.layer_colors.is_empty() { + if !unrecorded.is_empty() || !v.layer_colors.is_empty() { w.s(1001, dxf_ext::APP); - if !v.frozen_layers.is_empty() { - w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers)); + if !unrecorded.is_empty() { + w.xdata(dxf_ext::frozen_xdata(&unrecorded)); } if !v.layer_colors.is_empty() { w.xdata(dxf_ext::layer_colors_xdata(&v.layer_colors)); diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index 2d45a532f..e9f8a9e35 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -883,7 +883,8 @@ fn plan_dimension_associations(d: &Drawing, all_spaces: &[&EntityStore], w: &mut /// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the /// file ends up in: `version`, or the DWG version the bridge writes) has. -pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { +/// `frozen_341`: see [`Ctx::frozen_341`]. +pub(super) fn tags_for(d: &Drawing, version: Version, target: Version, frozen_341: bool) -> Vec { // An in-place block edit in progress is not written: the drawing as before it. let committed = d.committed(); let d: &Drawing = &committed; @@ -916,7 +917,7 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec = user_blocks.into_iter().map(|(n, _, b)| (n, w.h(), b)).collect(); - let mut cx = Ctx { paper_views: paper_views(d), target, ..Ctx::default() }; + let mut cx = Ctx { paper_views: paper_views(d), target, frozen_341, ..Ctx::default() }; cx.block_records = user_blocks.iter().map(|(n, h, _)| (n.to_ascii_uppercase(), h.clone())).collect(); let all_spaces: Vec<&EntityStore> = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).collect(); let mut dim_defs = dimension_block_definitions(d, &all_spaces, &dim_plan, &mut w, &mut cx); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 977a7c184..12d9f0a12 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -604,6 +604,12 @@ fn viewport_frozen_layers_roundtrip() { assert!(frozen.contains(&(3, Vec::new())), "{frozen:?}"); } +/// `d` as an R2018 DXF (frozen viewport layers in group 331). +fn dxf_2018(d: &Drawing) -> String { + let opts = SaveOptions { version: Some(Version::R2018), binary: false }; + String::from_utf8(write_with(d, "t.dxf", &opts).unwrap()).unwrap() +} + /// A layout with viewport 2 frozen on `Walls` and `A B` (both real layers), and its written DXF as /// (code, value) pairs. fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) { @@ -624,10 +630,14 @@ fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) { on: true, }); d.add(&paper, Common::default(), vp).unwrap(); - let text = write_dxf(&d); + let text = dxf_2018(&d); + (d, pairs(&text)) +} + +/// The (code, value) pairs of DXF text. +fn pairs(text: &str) -> Vec<(String, String)> { let lines: Vec<&str> = text.lines().collect(); - let pairs = lines.chunks(2).map(|c| (c[0].trim().to_string(), c.get(1).copied().unwrap_or("").to_string())).collect(); - (d, pairs) + lines.chunks(2).map(|c| (c[0].trim().to_string(), c.get(1).copied().unwrap_or("").to_string())).collect() } /// The pair range of the VIEWPORT record whose group 69 is `id`. @@ -676,47 +686,60 @@ fn join(p: &[(String, String)]) -> String { .collect() } -#[test] -fn viewport_frozen_layers_written_as_341_handles() { - let (_, p) = frozen_vp_dxf(); - let r = vp_record(&p, "2"); - let rec = &p[r]; - let h341: Vec<&str> = rec.iter().filter(|(c, _)| c == "341").map(|(_, v)| v.as_str()).collect(); - assert_eq!(h341, vec![layer_handle(&p, "Walls").as_str(), layer_handle(&p, "A B").as_str()]); - let last341 = rec.iter().rposition(|(c, _)| c == "341").unwrap(); - let x = rec.iter().position(|(c, _)| c == "1001").expect("xdata"); - assert!(last341 < x, "341 must precede the xdata"); +/// The group 331 values of viewport 2's record. +fn frozen_331(p: &[(String, String)]) -> Vec { + p[vp_record(p, "2")].iter().filter(|(c, _)| c == "331").map(|(_, v)| v.trim().to_string()).collect() } #[test] -fn viewport_frozen_layers_read_from_341_without_xdata() { +fn viewport_frozen_layers_written_as_331_handles() { + // Real layers go out as handles only, before group 90. let (_, p) = frozen_vp_dxf(); - let r = vp_record(&p, "2"); - // Strip the CADCraft xdata, and add a 341 to a handle that names no layer. - let x = p[r.clone()].iter().position(|(c, _)| c == "1001").unwrap() + r.start; - let mut q: Vec<(String, String)> = p[..r.start].to_vec(); - let mut head = p[r.start..x].to_vec(); - head.push(("341".into(), "FFFFF".into())); - q.extend(head); - q.extend(p[r.end..].iter().cloned()); - let back = read_dxf(join(&q).as_bytes()).unwrap(); - assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]); + assert_eq!(frozen_331(&p), [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]); + let rec = &p[vp_record(&p, "2")]; + assert!(!rec.iter().any(|(c, v)| c == "1000" && v == "VPFROZEN"), "no xdata for layers with a handle"); + assert!(rec.iter().position(|(c, _)| c == "331") < rec.iter().position(|(c, _)| c == "90")); + // An R2000 DXF holds them in group 341, as the DXF 2000 Reference has them. + let (d, _) = frozen_vp_dxf(); + let p = pairs(&write_dxf(&d)); + let h341: Vec = p[vp_record(&p, "2")].iter().filter(|(c, _)| c == "341").map(|(_, v)| v.clone()).collect(); + assert_eq!(h341, [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]); + assert!(frozen_331(&p).is_empty()); } #[test] -fn viewport_frozen_xdata_wins_over_341() { +fn viewport_frozen_layers_read_from_331_or_341_once_each() { let (_, p) = frozen_vp_dxf(); let r = vp_record(&p, "2"); - // Point every 341 at layer 0: the xdata list must still be what is read. - let zero = layer_handle(&p, "0"); + // 341 instead of 331, then Walls again in 331 and a handle that names no layer. let mut q = p.clone(); - for t in &mut q[r] { - if t.0 == "341" { - t.1 = zero.clone(); + for t in &mut q[r.clone()] { + if t.0 == "331" { + t.0 = "341".into(); } } + q.splice(r.end..r.end, [("331".into(), "FFFFF".into()), ("331".into(), layer_handle(&p, "Walls"))]); + // A 331 inside another application's `102 {…}` group isn't a frozen layer. + let app = [("102".into(), "{ACAD_OTHER".into()), ("331".into(), layer_handle(&p, "0")), ("102".into(), "}".into())]; + q.splice(r.start + 2..r.start + 2, app); let back = read_dxf(join(&q).as_bytes()).unwrap(); - assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]); + assert_eq!(viewport_frozen(&back, 2), ["Walls", "A B"]); +} + +#[test] +fn viewport_freeze_edited_in_autocad_is_read_as_edited() { + // Every layer thawed in AutoCAD (no 331 left) stays thawed; a layer it froze instead is read. + let (_, p) = frozen_vp_dxf(); + let r = vp_record(&p, "2"); + let thawed: Vec<(String, String)> = p.iter().enumerate().filter(|(i, (c, _))| !(r.contains(i) && c == "331")).map(|(_, t)| t.clone()).collect(); + assert!(viewport_frozen(&read_dxf(join(&thawed).as_bytes()).unwrap(), 2).is_empty()); + let mut zero = p.clone(); + for t in &mut zero[r] { + if t.0 == "331" { + t.1 = layer_handle(&p, "0"); + } + } + assert_eq!(viewport_frozen(&read_dxf(join(&zero).as_bytes()).unwrap(), 2), ["0"]); } #[test] @@ -725,7 +748,9 @@ fn viewport_frozen_layers_survive_dwg() { let dwg = write(&d, "t.dwg").unwrap(); let back = read(&dwg, "t.dwg").unwrap(); assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]); - // Survives via the CADCraft xdata: the DWG converter does not yet carry group 341 itself. + // The DWG holds them in group 331, what AutoCAD reads. + let p = pairs(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap()); + assert_eq!(frozen_331(&p), [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]); } #[test] From 2d02a5faa599eab78497aad52af056765f29633a Mon Sep 17 00:00:00 2001 From: Andrea Ficarra Date: Sun, 1 Nov 2026 20:38:21 +0100 Subject: [PATCH 087/151] DXF: escape carets in TEXT, ATTDEF, ATTRIB and DIMENSION strings (#2574) * DXF: escape carets in TEXT, ATTDEF, ATTRIB and DIMENSION strings AutoCAD reads `^x` in a DXF string as a control character, so a TEXT of `10^2` opened in AutoCAD showed a control character instead of the caret. The DXF Reference writes a caret as `^` and a space: the writer now does for the text (group 1) of TEXT, ATTRIB, ATTDEF and DIMENSION and an ATTDEF's prompt (group 3), and the reader turns it back. A bare `^x` from older files stays as it is; single-line strings keep `^J` literal (they hold no line feeds). AutoLISP's entget shows the plain caret, and a DIMENSION's style name (also group 3) is left as written. Tests: crates/io/tests/it/text_carets_roundtrip.rs. Co-Authored-By: Claude Opus 5.5 * entget gives TEXT strings as the file has them, like MTEXT Decoding the carets in entity_groups broke entmod (it reads the groups as a file, so `A^ B` came back as `A^B`) and reached into an ATTDEF's embedded MTEXT. entity_groups is left as on main: it gives the file's groups, as it already does for MTEXT, and entmod reads them back unchanged (test entmod_of_entget_keeps_the_strings). Co-Authored-By: Claude Opus 5.5 * entget unescapes carets, entmake and entmod escape them e61b4dd left entget with the file's escapes, but entmake still read a plain `p^ q` as `p^q`. Now one rule, map_carets, picks the groups that hold an escaped string (the text, 1, and an ATTDEF's prompt, 3, up to an embedded MTEXT at group 101): entity_groups unescapes them and entity_from_groups escapes them before reading, so entget is plain, entmake takes plain strings and entmod of entget changes nothing. Tests: entget_shows_plain_carets_and_entmake_takes_them, entmod_leaves_the_embedded_text_alone. Co-Authored-By: Claude Opus 5.5 * Leave the parity docs to the maintainers CLAUDE.md: PRs don't edit the parity docs; the affected rows are named in the PR description instead. Co-Authored-By: Claude Opus 5.5 * Single-line strings read like MTEXT: a tab from AutoCAD survives AutoCAD writes a tab in a TEXT as `^I`. The reader left it as two characters and the writer then saved `^ I`, so the tab was lost. Single-line strings are now read with decode_contents, as MTEXT is (`^ `, `^I`, `^J`; other bare carets stay), and encode_line writes a caret as `^ ` and a tab as `^I`. unescape_carets is gone. Co-Authored-By: Claude Opus 5.5 * Rename map_carets to map_text_groups (it encodes tabs too) Co-Authored-By: Claude Opus 5.5 * Single-line strings keep a bare ^J; entity_from_groups doc A TEXT holding `A^JB` (from a file saved before this change, which wrote carets bare) was read as a line feed and saved as `A B`. Single-line strings now decode only `^ ` and `^I` (decode_line, decode_contents without line feeds), so `^J` stays two characters. entity_from_groups documents that it takes plain strings, as entity_groups gives them. The caret test also goes through a DWG. Co-Authored-By: Claude Opus 5.5 * Test a caret before a space and at the end of a TEXT Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_read.rs | 8 +- crates/io/src/dxf_read/entities.rs | 3 +- crates/io/src/dxf_read/mtext_dxf.rs | 37 +++++- crates/io/src/dxf_read/text.rs | 4 +- crates/io/src/dxf_write.rs | 5 +- crates/io/src/dxf_write/common.rs | 4 +- crates/io/src/dxf_write/entity.rs | 2 +- crates/io/tests/it/main.rs | 1 + crates/io/tests/it/multiline_attributes.rs | 15 +++ crates/io/tests/it/text_carets_roundtrip.rs | 125 ++++++++++++++++++++ 10 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 crates/io/tests/it/text_carets_roundtrip.rs diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 5b8865953..324caa361 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -143,9 +143,13 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { } /// An entity from the groups of a DXF record of type `kind` (without its `0` group), as the -/// reader makes it. AutoLISP's `entmake` and `entmod` are built on it. +/// reader makes it, with plain strings as [`crate::entity_groups`] gives them (not the file's +/// `^ ` escapes). AutoLISP's `entmake` and `entmod` are built on it. pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { - entity(&kind.to_ascii_uppercase(), tags) + // AutoLISP's strings are plain: escape them as the file has them (see `entity_groups`). + let mut tags = tags.to_vec(); + mtext_dxf::map_text_groups(kind, &mut tags, mtext_dxf::encode_line); + entity(&kind.to_ascii_uppercase(), &tags) } #[cfg(test)] diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index a074a6517..394feb785 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -1,5 +1,6 @@ use super::common::{T, common, ocs}; use super::hatch::hatch; +use super::mtext_dxf; use super::text::{attdef, attrib_modes, attrib_props, mtext_from, multiline_attrib, split_embedded, text_from}; use super::{ Arc, Attrib, Circle, Common, DimKind, Dimension, Ellipse, EntityKind, Face3d, Handle, HashMap, Helix, HelixConstrain, Insert, Leader, Line, @@ -189,7 +190,7 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef p14: t.p(14), p15: t.p(15), p16: t.p(16), - text: t.s(1).unwrap_or_default(), + text: mtext_dxf::decode_line(&t.s(1).unwrap_or_default()), style: t.s(3).unwrap_or_else(|| "Standard".into()), measurement: t.fd(42, 0.0), text_rotation: t.fd(53, 0.0).to_radians(), diff --git a/crates/io/src/dxf_read/mtext_dxf.rs b/crates/io/src/dxf_read/mtext_dxf.rs index 9d9213947..822384f73 100644 --- a/crates/io/src/dxf_read/mtext_dxf.rs +++ b/crates/io/src/dxf_read/mtext_dxf.rs @@ -1,7 +1,9 @@ //! MTEXT specifics of DXF (DXF Reference, MTEXT): control characters in the contents written //! as a caret and a letter (`^I` a tab, `^J` a line feed, `^ ` a caret), and the column groups //! 75 (type), 76 (count), 78 (flow reversed), 79 (auto height), 48 (width), 49 (gutter) and -//! 50 (column heights, after the column type; before it, 50 is the rotation). +//! 50 (column heights, after the column type; before it, 50 is the rotation). TEXT, ATTDEF, +//! ATTRIB and DIMENSION strings use the same encoding ([`encode_line`]): AutoCAD reads a bare +//! `^2` in `10^2` as a control character. use cadcraft_doc::{MText, MTextColumnType, MTextColumns}; use cadcraft_dxf::Tag; @@ -12,6 +14,15 @@ const MAX_COLUMNS: usize = 1_000; /// Decode the contents' `^I` (tab), `^J` (line feed) and `^ ` (caret). Other carets stay as /// they are (older files write stacked tolerances `\S+0.1^-0.1;` with a bare caret). pub(crate) fn decode_contents(s: &str) -> String { + decode(s, true) +} + +/// A single-line string as read: [`decode_contents`] without line feeds (`^J` stays as it is). +pub(crate) fn decode_line(s: &str) -> String { + decode(s, false) +} + +fn decode(s: &str, line_feeds: bool) -> String { if !s.contains('^') { return s.to_string(); } @@ -25,7 +36,7 @@ pub(crate) fn decode_contents(s: &str) -> String { match it.peek().copied() { Some(' ') => out.push('^'), Some('I') => out.push('\t'), - Some('J') => out.push('\n'), + Some('J') if line_feeds => out.push('\n'), _ => { out.push('^'); continue; @@ -36,6 +47,28 @@ pub(crate) fn decode_contents(s: &str) -> String { out } +/// A single-line string (TEXT, ATTDEF/ATTRIB, DIMENSION) for a DXF file: a caret as `^ ` and a +/// tab as `^I`, read back by [`decode_line`]. It holds no line break (the writer makes it a +/// space). +pub(crate) fn encode_line(s: &str) -> String { + s.replace('^', "^ ").replace('\t', "^I") +} + +/// Apply `f` ([`encode_line`] or [`decode_line`]) to the groups of a `kind` record that hold +/// an encoded string: the text (1), and an ATTDEF's prompt (3). An embedded MTEXT (from group 101) +/// has its own and is left alone. AutoLISP's entget and entmake/entmod use it. +pub(crate) fn map_text_groups(kind: &str, tags: &mut [Tag], f: fn(&str) -> String) { + let kind = kind.to_ascii_uppercase(); + if !matches!(kind.as_str(), "TEXT" | "ATTRIB" | "ATTDEF" | "DIMENSION") { + return; + } + for t in tags.iter_mut().take_while(|t| t.code != 101) { + if t.code == 1 || (t.code == 3 && kind == "ATTDEF") { + *t = Tag::s(t.code, f(&t.str())); + } + } +} + /// Encode the contents for a DXF string: a tab as `^I`, a line feed as `^J`, a caret as `^ `; /// other control characters (which draw nothing) are dropped. pub(crate) fn encode_contents(s: &str) -> String { diff --git a/crates/io/src/dxf_read/text.rs b/crates/io/src/dxf_read/text.rs index 492825df7..df73b878c 100644 --- a/crates/io/src/dxf_read/text.rs +++ b/crates/io/src/dxf_read/text.rs @@ -112,7 +112,7 @@ pub(super) fn text_from(t: &T) -> Text { insert: t.p(10), align_pt: if h == HAlign::Left && v == VAlign::Baseline { None } else { align }, height: t.fd(40, 0.2).max(1e-9), - value: t.s(1).unwrap_or_default(), + value: mtext_dxf::decode_line(&t.s(1).unwrap_or_default()), rotation: t.fd(50, 0.0).to_radians(), width_factor: t.fd(41, 1.0), oblique: t.fd(51, 0.0).to_radians(), @@ -172,7 +172,7 @@ pub(super) fn attdef(tags: &[Tag], c: &Common) -> Attrib { text, invisible: own.i(70).unwrap_or(0) & 1 != 0, constant: own.i(70).unwrap_or(0) & 2 != 0, - prompt: own.s(3).unwrap_or_default(), + prompt: mtext_dxf::decode_line(&own.s(3).unwrap_or_default()), props: Default::default(), multiline, modes: attrib_modes(own.0), diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 341f70e62..d6aa337aa 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -152,5 +152,8 @@ pub fn entity_groups(d: &Drawing, e: &Entity) -> (String, Vec) { entity(&mut w, d, e, "0", paper, &cx); let mut tags = w.t.into_iter(); let kind = tags.next().filter(|t| t.code == 0).map(|t| t.value.as_str()).unwrap_or_default(); - (kind, tags.take_while(|t| t.code != 0).filter(|t| t.code != 330).collect()) + let mut tags: Vec = tags.take_while(|t| t.code != 0).filter(|t| t.code != 330).collect(); + // Strings as AutoLISP sees them: `10^2`, not the file's `10^ 2`. + crate::dxf_read::mtext_dxf::map_text_groups(&kind, &mut tags, crate::dxf_read::mtext_dxf::decode_line); + (kind, tags) } diff --git a/crates/io/src/dxf_write/common.rs b/crates/io/src/dxf_write/common.rs index b09d90410..25d2bb074 100644 --- a/crates/io/src/dxf_write/common.rs +++ b/crates/io/src/dxf_write/common.rs @@ -255,7 +255,7 @@ pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attri }; w.p(10, insert); w.f(40, t.height); - w.s(1, &t.value); + w.s(1, crate::dxf_read::mtext_dxf::encode_line(&t.value)); if t.rotation != 0.0 { w.f(50, t.rotation.to_degrees()); } @@ -293,7 +293,7 @@ pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attri w.i(280, 0); } if let Some(prompt) = attdef_prompt { - w.s(3, prompt); + w.s(3, crate::dxf_read::mtext_dxf::encode_line(prompt)); } w.s(2, &a.tag); w.i(70, attrib_flags(a)); diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index a33437017..f1e0a1d73 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -355,7 +355,7 @@ fn write_dimension_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper // The actual measurement (read-only in DXF): AutoLISP's entget shows it. w.f(42, g.measurement); if !dm.text.is_empty() { - w.s(1, &dm.text); + w.s(1, crate::dxf_read::mtext_dxf::encode_line(&dm.text)); } w.s(3, &dm.style); if dm.text_rotation != 0.0 { diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index e0ff04f97..8442a7629 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -67,6 +67,7 @@ mod svg_arc_budget; mod svg_tiny_extents; mod table_cellstyle_roundtrip; mod tables_objects_roundtrip; +mod text_carets_roundtrip; mod text_left_justify; mod tolerance_roundtrip; mod user_lin_pat_roundtrip; diff --git a/crates/io/tests/it/multiline_attributes.rs b/crates/io/tests/it/multiline_attributes.rs index 625c38a30..428dc7db5 100644 --- a/crates/io/tests/it/multiline_attributes.rs +++ b/crates/io/tests/it/multiline_attributes.rs @@ -335,3 +335,18 @@ fn damaged_embedded_text_does_not_panic() { let _ = read_dxf(save(&d).as_bytes()).unwrap(); } } + +#[test] +fn entmod_leaves_the_embedded_text_alone() { + // entget and entmod convert the carets of the attribute's own groups, not the embedded MTEXT's, + // which has its own encoding: `^ J` there is a caret and a J, not a line feed. + let d = read_dxf(dxf(FIXTURE).replace("SITE\\PNAME", "SITE^ JNAME").as_bytes()).unwrap(); + let Some(e) = d.block("TAG").and_then(|b| b.entities.iter().find(|e| matches!(e.kind, EntityKind::AttDef(_)))) else { + panic!("no definition"); + }; + let (kind, groups) = cadcraft_io::entity_groups(&d, e); + let embedded: Vec = groups.iter().skip_while(|t| t.code != 101).filter(|t| t.code == 1).map(|t| t.str()).collect(); + assert_eq!(embedded, ["SITE^ JNAME"]); + let Some((_, EntityKind::AttDef(a))) = cadcraft_io::entity_from_groups(&kind, &groups) else { panic!("no ATTDEF") }; + assert_eq!(a.multiline.map(|m| m.mtext.contents), Some("SITE^JNAME".to_string())); +} diff --git a/crates/io/tests/it/text_carets_roundtrip.rs b/crates/io/tests/it/text_carets_roundtrip.rs new file mode 100644 index 000000000..1c30dd1a3 --- /dev/null +++ b/crates/io/tests/it/text_carets_roundtrip.rs @@ -0,0 +1,125 @@ +//! Carets in TEXT, ATTDEF, ATTRIB and DIMENSION strings are written as `^ ` and read back: AutoCAD +//! reads a bare `^2` in `10^2` as a control character. A tab is `^I`, as AutoCAD writes it. + +use cadcraft_doc::{Attrib, Common, DimKind, Dimension, Drawing, EntityKind, HAlign, Space, Text, VAlign}; +use cadcraft_geom::Vec3; +use cadcraft_io::{read_dxf, write_dxf}; + +fn text(value: &str) -> Text { + Text { + insert: Vec3::new(1.0, 1.0, 0.0), + align_pt: None, + height: 2.5, + value: value.into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + } +} + +fn dimension(label: &str) -> Dimension { + Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(0.0, 5.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: label.into(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + } +} + +fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Text(text("10^2 m, A^JB"))).unwrap(); + let attdef = Attrib { + tag: "AREA".into(), + text: text("1^3"), + invisible: false, + constant: false, + prompt: "Area in m^2".into(), + props: Default::default(), + multiline: None, + modes: Default::default(), + }; + d.add(&Space::Model, Common::default(), EntityKind::AttDef(attdef)).unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dimension("<> x10^3"))).unwrap(); + d +} + +fn strings(k: &EntityKind) -> Vec { + match k { + EntityKind::Text(t) => vec![t.value.clone()], + EntityKind::AttDef(a) => vec![a.text.value.clone(), a.prompt.clone()], + EntityKind::Dimension(dm) => vec![dm.text.clone()], + _ => Vec::new(), + } +} + +fn all_strings(d: &Drawing) -> Vec { + d.model.iter().flat_map(|e| strings(&e.kind)).collect() +} + +#[test] +fn carets_are_written_escaped_and_read_back() { + let dxf = write_dxf(&drawing()); + // `^J` stays two characters: a single-line string has no line feeds. + for escaped in ["10^ 2 m, A^ JB", "1^ 3", "Area in m^ 2", "<> x10^ 3"] { + assert!(dxf.contains(escaped), "{escaped}"); + } + let back = read_dxf(dxf.as_bytes()).unwrap(); + assert_eq!(all_strings(&back), ["10^2 m, A^JB", "1^3", "Area in m^2", "<> x10^3"]); + // Older files have bare carets, `^J` too: kept as they are (a single-line string has no line feed). + let old = read_dxf(dxf.replace("10^ 2 m, A^ JB", "10^2 m, A^JB").as_bytes()).unwrap(); + assert_eq!(all_strings(&old)[0], "10^2 m, A^JB"); + assert!(write_dxf(&old).contains("10^ 2 m, A^ JB")); + // AutoCAD writes a tab as `^I`: it is read as a tab and written back as `^I`. + let tab = read_dxf(dxf.replace("10^ 2 m, A^ JB", "A^IB").as_bytes()).unwrap(); + assert_eq!(all_strings(&tab)[0], "A\tB"); + assert!(write_dxf(&tab).contains("A^IB")); + // Through a DWG. + let dwg = cadcraft_io::write(&drawing(), "t.dwg").unwrap(); + assert_eq!(all_strings(&cadcraft_io::read(&dwg, "t.dwg").unwrap()), all_strings(&drawing())); +} + +#[test] +fn a_caret_before_a_space_or_at_the_end_round_trips() { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Text(text("x^ y^"))).unwrap(); + let dxf = write_dxf(&d); + assert!(dxf.contains("x^ y^ "), "{dxf}"); + assert_eq!(all_strings(&read_dxf(dxf.as_bytes()).unwrap()), ["x^ y^"]); +} + +#[test] +fn entget_shows_plain_carets_and_entmake_takes_them() { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Text(text("x^ y 10^2"))).unwrap(); + let e = d.model.iter().next().unwrap(); + let (kind, groups) = cadcraft_io::entity_groups(&d, e); + assert!(groups.iter().any(|t| t.code == 1 && t.str() == "x^ y 10^2"), "{groups:?}"); + // entmod of entget changes nothing; entmake takes a plain string. + let (_, back) = cadcraft_io::entity_from_groups(&kind, &groups).unwrap(); + assert_eq!(strings(&back), ["x^ y 10^2"]); + let made = [cadcraft_dxf::Tag::s(1, "p^ q")]; + assert_eq!(strings(&cadcraft_io::entity_from_groups("TEXT", &made).unwrap().1), ["p^ q"]); + // Every kind, and a DIMENSION's style name (also group 3) as it is. + let d = drawing(); + for e in d.model.iter() { + let (kind, groups) = cadcraft_io::entity_groups(&d, e); + let (_, back) = cadcraft_io::entity_from_groups(&kind, &groups).unwrap(); + assert_eq!(strings(&back), strings(&e.kind), "{kind}"); + } +} From 06c5760ea6e477fa72857753b737d8a23123270b Mon Sep 17 00:00:00 2001 From: campbellcody <142101597+campbellcody@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:38:24 -0500 Subject: [PATCH 088/151] Persist QuickCalc history, variables and last answer (#2572) * Persist QuickCalc history, variables and last answer Store bounded calculator data in app preferences for desktop and web. Restore finite numeric and point variables, normalize names and ignore malformed entries without resuming transient drawing input. Covers gaps F22. Validation: 3079 workspace tests passed; wasm, fmt, assets, layers and i18n passed; UI screenshot inspected. Strict CI remains blocked by pre-existing nonminimal_bool lints in DXF and UI code; scoped UI Clippy passes with only that lint disabled. * quickcalc: apply the variable limit after validation Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/palettes/quickcalc.rs | 166 +++++++++++++++++++++-- crates/ui-egui/tests/quickcalc_toggle.rs | 47 ++++++- 3 files changed, 212 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 97ab2faf4..142a589a3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -239,6 +239,8 @@ pub struct CadApp { pub canvas: canvas::CanvasState, /// Desktop HID status and navigation preferences; the web host supplies no HID backend. pub spacemouse: spacemouse::State, + /// Calculator data shared by its floating and docked views; durable fields live in prefs. + pub(crate) quickcalc: palettes::quickcalc::State, pub cmd: cmdline::CmdLine, pub status: Option<(String, f64)>, /// Drawings being read on worker threads ([`opening`]). @@ -327,6 +329,7 @@ impl CadApp { services, canvas: canvas::CanvasState::default(), spacemouse: spacemouse::State::default(), + quickcalc: palettes::quickcalc::State::default(), cmd: cmdline::CmdLine::default(), status: None, opening: opening::Opening::default(), @@ -465,6 +468,7 @@ impl CadApp { "interfaceLanguage": self.ui.interface_language.code(), "saveFormat": self.ui.save_format.as_str(), "saveVersion": self.ui.save_version, + "quickcalc": self.quickcalc.prefs(), }); if self.spacemouse.settings != spacemouse::Settings::default() { prefs["spacemouse"] = json!(self.spacemouse.settings); @@ -481,6 +485,9 @@ impl CadApp { /// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored. pub fn load_prefs(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; + if let Some(calculator) = v.get("quickcalc") { + self.quickcalc.load_prefs(calculator); + } if let Some(prefs) = v.get("spacemouse") { let _ = self.spacemouse.load_settings(prefs); } @@ -1129,7 +1136,8 @@ 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#"{"animations":"system","interfaceLanguage":"auto","saveFormat":"dxf","saveVersion":"2018","theme":"system"}"#); + let prefs: Value = serde_json::from_str(&a.prefs_json()).unwrap(); + assert_eq!(prefs["theme"], "system"); } #[test] diff --git a/crates/ui-egui/src/palettes/quickcalc.rs b/crates/ui-egui/src/palettes/quickcalc.rs index 5f601b2fa..d6948e363 100644 --- a/crates/ui-egui/src/palettes/quickcalc.rs +++ b/crates/ui-egui/src/palettes/quickcalc.rs @@ -14,10 +14,14 @@ const PICKED: &str = "QuickCalc: "; const MAX_HISTORY: usize = 100; /// Variables kept. const MAX_VARS: usize = 50; +/// Bound each saved expression/result and variable name, including hand-edited preferences. +// CAL accepts expressions up to 10,000 bytes, including a variable's name on its own. +const MAX_TEXT_BYTES: usize = 10_000; +const MAX_NAME_BYTES: usize = MAX_TEXT_BYTES; const KINDS: [(&str, &str); 4] = [("length", "Length"), ("area", "Area"), ("volume", "Volume"), ("angle", "Angle")]; #[derive(Clone, Default)] -struct State { +pub(crate) struct State { input: String, /// (expression, result) newest last. history: Vec<(String, String)>, @@ -39,8 +43,17 @@ struct State { pending: Option, } -fn id() -> egui::Id { - egui::Id::unique("cadcraft-quickcalc") +fn valid_name(name: &str) -> bool { + name.len() <= MAX_NAME_BYTES + && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && !["pi", "e", "ans"].contains(&name) +} + +/// CAL variables are finite numbers or 2D points; no nested arrays or objects. +fn valid_value(value: &Value) -> bool { + let number = |v: &Value| v.as_f64().is_some_and(f64::is_finite); + number(value) || value.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(number)) } /// A variable's value as typed into an expression: a number, or a point as `[x,y]`. @@ -57,6 +70,61 @@ fn result_value(r: &Value) -> Option { } impl State { + /// Only durable calculator data: never resume an in-flight drawing pick after a restart. + pub(crate) fn prefs(&self) -> Value { + let history: Vec<_> = self + .history + .iter() + .rev() + .take(MAX_HISTORY) + .filter(|(expr, result)| expr.len() <= MAX_TEXT_BYTES && result.len() <= MAX_TEXT_BYTES) + .collect(); + let vars: Vec<_> = self.vars.iter().filter(|(name, value)| valid_name(name) && valid_value(value)).take(MAX_VARS).collect(); + json!({"history": history.into_iter().rev().collect::>(), "vars": vars, + "ans": self.ans.as_ref().filter(|v| valid_value(v))}) + } + + /// Restore bounded, independently validated entries; older preferences have no calculator. + pub(crate) fn load_prefs(&mut self, value: &Value) { + if !value.is_object() { + return; + } + if let Some(history) = value.get("history").and_then(Value::as_array) { + self.history = history + .iter() + .rev() + .take(MAX_HISTORY) + .filter_map(|entry| { + let pair = entry.as_array().filter(|a| a.len() == 2)?; + let expr = pair.first()?.as_str().filter(|s| s.len() <= MAX_TEXT_BYTES)?; + let result = pair.get(1)?.as_str().filter(|s| s.len() <= MAX_TEXT_BYTES)?; + Some((expr.to_string(), result.to_string())) + }) + .collect(); + self.history.reverse(); + } + if let Some(vars) = value.get("vars").and_then(Value::as_array) { + self.vars.clear(); + for entry in vars { + // Only valid entries count toward the limit. + if self.vars.len() >= MAX_VARS { + break; + } + let Some(pair) = entry.as_array().filter(|a| a.len() == 2) else { continue }; + let Some(name) = pair.first().and_then(Value::as_str).filter(|s| s.len() <= MAX_NAME_BYTES) else { continue }; + let name = name.to_ascii_lowercase(); + let Some(value) = pair.get(1).filter(|v| valid_value(v)) else { continue }; + if valid_name(&name) { + self.vars.retain(|(key, _)| key != &name); + self.vars.push((name, value.clone())); + } + } + } + if let Some(ans) = value.get("ans") { + self.ans = valid_value(ans).then(|| ans.clone()); + } + } + fn vars_json(&self) -> Value { let mut m = serde_json::Map::new(); for (k, v) in &self.vars { @@ -88,9 +156,7 @@ impl State { } fn add_var(&mut self, app: &mut CadApp) { let name = self.var_name.trim().to_ascii_lowercase(); - let valid = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) - && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') - && !["pi", "e", "ans"].contains(&name.as_str()); + let valid = valid_name(&name); if !valid { self.error = Some(crate::tl!("A variable name starts with a letter and has only letters, digits and _").into()); return; @@ -219,7 +285,7 @@ fn value_row(ui: &mut egui::Ui, value: &mut String) { /// Quick Calculator dock body: expression entry with evaluation, variables and unit conversion. pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); - let mut st: State = ui.ctx().data_mut(|d| d.get_temp(id())).unwrap_or_default(); + let mut st = std::mem::take(&mut app.quickcalc); st.collect(app); egui::ScrollArea::vertical().id_salt("dock-quickcalc").auto_shrink([false, false]).show(ui, |ui| { ui.add_space(6.0); @@ -371,16 +437,98 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }); }); }); - ui.ctx().data_mut(|d| d.insert_temp(id(), st)); + app.quickcalc = st; } #[cfg(test)] mod tests { + use cadcraft_engine::Session; use egui::accesskit::Role; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; - use super::value_row; + use super::{MAX_HISTORY, MAX_TEXT_BYTES, MAX_VARS, State, value_row}; + use crate::{CadApp, Services}; + use serde_json::{Value, json}; + + #[test] + fn preferences_restore_numbers_points_history_and_ans_without_pending_input() { + let mut app = CadApp::new(Session::default(), Services::default()); + let mut state = State { var_name: "Width".into(), var_expr: "2+3".into(), ..State::default() }; + state.add_var(&mut app); + state.var_name = "origin".into(); + state.var_expr = "[3,4]".into(); + state.add_var(&mut app); + state.input = "origin*width".into(); + state.evaluate(&mut app); + state.pending = Some(123); + state.error = Some("transient error".into()); + app.quickcalc = state; + + let mut restarted = CadApp::new(Session::default(), Services::default()); + let drawing = restarted.session.doc().unwrap().clone(); + restarted.load_prefs(&app.prefs_json()); + let mut state = std::mem::take(&mut restarted.quickcalc); + assert_eq!(state.history, [("origin*width".into(), "15,20".into())]); + assert_eq!(state.vars, [("width".into(), json!(5.0)), ("origin".into(), json!([3.0, 4.0]))]); + assert_eq!(state.ans, Some(json!([15.0, 20.0]))); + assert!(state.pending.is_none()); + assert!(state.error.is_none()); + assert!(state.input.is_empty()); + state.input = "ans+origin".into(); + state.evaluate(&mut restarted); + assert_eq!(state.ans, Some(json!([18.0, 24.0]))); + assert_eq!(restarted.session.doc().unwrap(), &drawing); + assert!(restarted.session.running.is_none()); + } + + #[test] + fn malformed_entries_do_not_hide_valid_calculator_data_or_other_preferences() { + let mut app = CadApp::new(Session::default(), Services::default()); + app.load_prefs( + &json!({"theme": "light", "quickcalc": { + "history": [null, ["bad"], [false, 3], ["2+3", "5"]], + "vars": [["PI", 3], ["1bad", 5], ["nested", [[1,2],3]], ["bad", [1,2,3]], + ["object", {"x":1}], ["WIDTH", 4], ["width", 5], ["origin", [3,4]]], + "ans": "NaN" + }}) + .to_string(), + ); + assert_eq!(app.ui.theme.as_str(), "light"); + assert_eq!(app.quickcalc.history, [("2+3".into(), "5".into())]); + assert_eq!(app.quickcalc.vars, [("width".into(), json!(5)), ("origin".into(), json!([3, 4]))]); + assert!(app.quickcalc.ans.is_none()); + let saved = app.prefs_json(); + for malformed in ["not JSON", "{}", r#"{"quickcalc":null}"#, r#"{"quickcalc":{"vars":false,"history":0}}"#] { + app.load_prefs(malformed); + assert_eq!(app.prefs_json(), saved); + } + } + + #[test] + fn stored_calculator_data_is_bounded_and_cleared_data_stays_cleared() { + let mut state = State::default(); + let history: Vec<_> = (0..MAX_HISTORY + 10).map(|i| json!([i.to_string(), i.to_string()])).collect(); + let vars: Vec<_> = (0..MAX_VARS + 10).map(|i| json!([format!("v{i}"), i])).collect(); + state.load_prefs(&json!({"history": history, "vars": vars, "ans": 5})); + assert_eq!(state.history.len(), MAX_HISTORY); + assert_eq!(state.history.first().unwrap().0, "10"); + assert_eq!(state.history.last().unwrap().0, (MAX_HISTORY + 9).to_string()); + assert_eq!(state.vars.len(), MAX_VARS); + let oversized = "x".repeat(MAX_TEXT_BYTES + 1); + state.load_prefs(&json!({"history": [[oversized, "1"]], "vars": [[oversized, 1]]})); + assert!(state.history.is_empty()); + assert!(state.vars.is_empty()); + let mut restored = State { history: vec![("old".into(), "1".into())], vars: vec![("old".into(), json!(1))], ..State::default() }; + restored.load_prefs(&state.prefs()); + assert!(restored.history.is_empty()); + assert!(restored.vars.is_empty()); + assert_eq!(restored.ans, Some(json!(5))); + // An invalid answer is discarded rather than reused as a calculator value. + restored.load_prefs(&json!({"ans": [1, null]})); + assert_eq!(restored.ans, None); + assert_eq!(restored.prefs()["ans"], Value::Null); + } /// The conversion value field is named by its visible label under bundled fonts only, so a /// system face cannot hide a missing glyph and a bare "1" placeholder cannot pass as a name. diff --git a/crates/ui-egui/tests/quickcalc_toggle.rs b/crates/ui-egui/tests/quickcalc_toggle.rs index 9b6bec6d1..caf52f0df 100644 --- a/crates/ui-egui/tests/quickcalc_toggle.rs +++ b/crates/ui-egui/tests/quickcalc_toggle.rs @@ -9,10 +9,17 @@ use egui_kittest::kittest::{NodeT, Queryable, by}; use serde_json::{Value, json}; fn harness() -> Harness<'static, CadApp> { + harness_from_prefs("{}", false) +} + +fn harness_from_prefs(prefs: &str, render: bool) -> Harness<'static, CadApp> { let mut app = CadApp::new(Session::default(), Services::default()); + app.load_prefs(prefs); app.ui.start_tab = false; app.ui.in_window_menu = false; - let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + let builder = Harness::builder().with_size(vec2(1280.0, 800.0)); + let builder = if render { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( |ui, app: &mut CadApp| { app.logic(&ui.ctx().clone()); app.ui(ui); @@ -23,6 +30,44 @@ fn harness() -> Harness<'static, CadApp> { h } +#[test] +fn restarting_restores_calculator_data_and_ui_edits_are_saved_when_hidden() { + let prefs = json!({"quickcalc": { + "history": [["2+3", "5"]], "vars": [["width", 5]], "ans": 5 + }}) + .to_string(); + let screenshot = std::env::var_os("CADCRAFT_QUICKCALC_SHOT"); + let mut h = harness_from_prefs(&prefs, screenshot.is_some()); + h.state_mut().run("ui.panel.quickcalc", Value::Null).unwrap(); + h.run_steps(5); + assert!(h.query_all(by().value("2+3")).next().is_some()); + expression(&h).click(); + h.run_steps(3); + expression(&h).type_text("ans+width"); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "=").click(); + h.run_steps(3); + let prefs: Value = serde_json::from_str(&h.state().prefs_json()).unwrap(); + assert_eq!(prefs["quickcalc"]["ans"], 10.0); + assert_eq!(prefs["quickcalc"]["history"][1], json!(["ans+width", "10"])); + if let Some(path) = screenshot { + h.render().unwrap().save(std::path::PathBuf::from(path)).unwrap(); + } + + h.get_by_role_and_label(Role::Button, "Clear History").click(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "×").click(); + h.run_steps(3); + h.state_mut().run("ui.toggle.quickcalc", Value::Null).unwrap(); + h.run_steps(5); + let mut restarted = CadApp::new(Session::default(), Services::default()); + restarted.load_prefs(&h.state().prefs_json()); + let prefs: Value = serde_json::from_str(&restarted.prefs_json()).unwrap(); + assert_eq!(prefs["quickcalc"]["history"], json!([])); + assert_eq!(prefs["quickcalc"]["vars"], json!([])); + assert_eq!(prefs["quickcalc"]["ans"], 10.0); +} + /// In these tests QuickCalc floats alone; scope to its body to avoid the Properties text fields. /// Its first text input is the expression, before conversion values and variable definitions. fn expression<'a>(h: &'a Harness<'_, CadApp>) -> egui_kittest::Node<'a> { From f0b46e34dc5a32153a2f062eee761f6660d69ab7 Mon Sep 17 00:00:00 2001 From: Steffen <576706+camperking@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:38:27 +0100 Subject: [PATCH 089/151] Hit tests don't lay out dashes: moving the mouse over a block stays smooth (#2603) * Hit tests don't lay out dashes: moving the mouse over a block stays smooth (#1285) Every mouse move hit-tests the block references under the cursor, and hit_polylines built them with a full-detail display list, one primitive per dash. A fine pattern on long lines in a block inserted at 0.001 made millions of dashes per move. Options::linetypes off draws every line whole and continuous, and hit_polylines uses it for blocks, dimensions and multileaders. A dashed line in a block is now hit across its gaps, as one outside a block already was. Co-Authored-By: Claude Opus 5.5 * render: with linetypes off, a lightweight polyline is one continuous stroke Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Steffen Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/select.rs | 52 ++++++++++++++++++++++++++- crates/io/src/pdf.rs | 1 + crates/render/src/draw.rs | 8 ++--- crates/render/src/mline.rs | 2 +- crates/render/src/pipeline.rs | 5 +++ crates/render/src/tests.rs | 25 +++++++++++++ crates/ui-egui/src/canvas/list.rs | 1 + crates/ui-egui/src/canvas/viewport.rs | 1 + 8 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 6c70bee37..8047412fd 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -29,7 +29,7 @@ pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec> { let list = cadcraft_render::build_entities( d, std::iter::once(e), - &cadcraft_render::Options { tolerance: tol, text: false, ..Default::default() }, + &cadcraft_render::Options { tolerance: tol, text: false, linetypes: false, ..Default::default() }, ); v = list.prims.iter().map(|p| list.points(p).to_vec()).collect(); v.push(vec![c[0], c[1], c[2], c[3], c[0]]); @@ -502,6 +502,56 @@ mod tests { // Only one within reach: that one, whatever the order. assert_eq!(pick(&d, &Space::Model, Vec2::new(41.3, 50.0), 1.0), Some(line)); } + + /// A block holding a diagonal dashed line, inserted at `scale`. + fn dashed_block(length: f64, scale: f64) -> (Drawing, Handle) { + let mut d = Drawing::new_imperial(); + d.linetypes.extend(cadcraft_doc::library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED")); + let mut line = + Entity::new(Handle(1), EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(length, length, 0.0) })); + line.common.linetype = "DASHED".into(); + let mut blk = cadcraft_doc::Block::new("B"); + blk.entities.push(line); + d.blocks.insert("B".into(), std::sync::Arc::new(blk)); + let insert = cadcraft_doc::Insert { + block: "B".into(), + insert: Vec3::new(0.0, 0.0, 0.0), + scale: Vec3::new(scale, scale, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap(); + (d, h) + } + + /// Every mouse move over a block hit-tests what it draws. Two blocks with a fine pattern on + /// long lines (inserted at 0.001) made 5.8 million dashes, and half a second, per move (#1285). + #[test] + fn hit_testing_a_block_does_not_lay_out_its_dashes() { + let (d, h) = dashed_block(20_000.0, 0.001); + let e = d.entity(h).unwrap(); + let drawn = cadcraft_render::build_entities(&d, std::iter::once(e.as_ref()), &cadcraft_render::Options { text: false, ..Default::default() }); + assert!(drawn.prims.len() > 10_000, "the pattern draws {} dashes", drawn.prims.len()); + // The line, and the block's bounding rectangle. + assert_eq!(hit_polylines(&d, e, 0.01).len(), 2); + } + + /// A dashed line in a block is hit across its gaps, as one outside a block is. + #[test] + fn a_dashed_line_in_a_block_is_picked_in_a_gap() { + let (d, h) = dashed_block(100.0, 1.0); + let e = d.entity(h).unwrap(); + let drawn = cadcraft_render::build_entities(&d, std::iter::once(e.as_ref()), &cadcraft_render::Options { text: false, ..Default::default() }); + let (a, b) = (drawn.prims.first().unwrap(), drawn.prims.get(1).unwrap()); + let (end, start) = (*drawn.points(a).last().unwrap(), *drawn.points(b).first().unwrap()); + assert!(end.dist(start) > 0.01, "a gap between the first two dashes"); + let gap = end + (start - end) * 0.5; + assert_eq!(pick(&d, &Space::Model, gap, end.dist(start) / 8.0), Some(h)); + } } #[cfg(test)] diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 3f35233a8..b06580d53 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -520,6 +520,7 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String tolerance: 0.001, min_dash: 0.0, text: true, + linetypes: true, fill: true, lineweights, view_height: 0.0, diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7e00b4c8c..39b0ddfcc 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -77,7 +77,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { let tol = b.opts.tolerance / ctx.xf.scale_factor().max(1e-12); let lw = if b.opts.lineweights { lw } else { 0.0 }; // Stroke a polyline with the entity's linetype. - let stroke = |b: &mut Builder, pts: &[Vec2]| match < { + let stroke = |b: &mut Builder, pts: &[Vec2]| match lt.as_ref().filter(|_| b.opts.linetypes) { Some(lt) => { let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); for dash in linetype::apply(pts, lt, ltscale, min) { @@ -140,7 +140,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { // the list serves a whole band of zooms. Plots and exports draw the band alone, and // a transparent band gets none (its alpha would add up along the centre). let hairline = !b.plotting && b.opts.min_dash > 0.0 && b.transparency == 0; - match < { + match lt.as_ref().filter(|_| b.opts.linetypes) { // Dashed: each dash a filled piece of the band; dots a line across it. Some(lt) => { let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); @@ -173,7 +173,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { } EntityKind::LwPolyline(p) => { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - if p.plinegen || lt.is_none() { + if !b.opts.linetypes || p.plinegen || lt.is_none() { stroke(b, &pl.tessellate(tol)); } else { for s in pl.segments() { @@ -355,7 +355,7 @@ fn draw_dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dim /// Draw a polyline with a linetype (None: continuous). pub(crate) fn stroke_with(b: &mut Builder, ctx: &Ctx, rgb: Ink, lw: f32, lt: Option<&cadcraft_doc::Linetype>, ltscale: f64, pts: &[Vec2]) { - match lt { + match lt.filter(|_| b.opts.linetypes) { Some(lt) => { let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); for dash in linetype::apply(pts, lt, ltscale, min) { diff --git a/crates/render/src/mline.rs b/crates/render/src/mline.rs index bbaacb5cc..882f3a626 100644 --- a/crates/render/src/mline.rs +++ b/crates/render/src/mline.rs @@ -59,7 +59,7 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, m: &MLine, rgb: Ink, #[allow(clippy::too_many_arguments)] fn stroke(b: &mut Builder, ctx: &Ctx, rgb: Ink, lw: f32, lt: Option<&cadcraft_doc::Linetype>, scale: f64, min: f64, pts: &[Vec2]) { - match lt { + match lt.filter(|_| b.opts.linetypes) { Some(lt) => { for dash in linetype::apply(pts, lt, scale, min) { match dash.as_slice() { diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 8cbcb95b9..3fc72f518 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -15,6 +15,10 @@ pub struct Options { pub min_dash: f64, /// Draw text (off for very coarse previews). pub text: bool, + /// Draw lines with their linetypes. Off, every line is drawn whole and continuous, as picking + /// sees it: a dashed line is hit across its gaps, and a small pattern on a long line makes + /// millions of dashes. + pub linetypes: bool, /// Highlighted (selected) handles are not special here; the canvas overlays them. pub fill: bool, pub lineweights: bool, @@ -52,6 +56,7 @@ impl Default for Options { tolerance: 0.001, min_dash: 0.0, text: true, + linetypes: true, fill: true, lineweights: false, view_height: 0.0, diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ae35f27f9..fe2dc6470 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1122,3 +1122,28 @@ fn raster_hatch_pattern_matches_one_by_one() { assert!(render(&l, &view, &o).unwrap().data() == render_one_by_one(&l, &view, &o).data()); } } + +/// `Options::linetypes` off draws a dashed line, a dashed wide polyline and a linetype on a layer +/// as the same whole line a Continuous one draws: what hit tests look at. +#[test] +fn without_linetypes_every_line_is_drawn_whole() { + let drawing = |lt: &str| { + let mut d = Drawing::new_imperial(); + d.linetypes.extend(cadcraft_doc::library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED")); + d.layer_mut("0").unwrap().linetype = lt.into(); + let line = EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(30.0, 0.0, 0.0) }); + d.add(&Space::Model, Common::default(), line).unwrap(); + let vertices = [(0.0, 5.0), (30.0, 5.0)].map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).to_vec(); + let wide = cadcraft_doc::LwPolyline { vertices, closed: false, const_width: 0.2, elevation: 0.0, plinegen: false }; + d.add(&Space::Model, Common::default(), EntityKind::LwPolyline(wide)).unwrap(); + d + }; + let whole = Options { linetypes: false, ..Options::default() }; + let continuous = build(&drawing("Continuous"), &Space::Model, &Options::default()); + let dashed = build(&drawing("DASHED"), &Space::Model, &whole); + assert_eq!(dashed.prims.len(), continuous.prims.len()); + assert_eq!(dashed.verts, continuous.verts); + assert_eq!(dashed.tris, continuous.tris); + let drawn = build(&drawing("DASHED"), &Space::Model, &Options::default()); + assert!(drawn.prims.len() > 2 * continuous.prims.len(), "drawn with its linetype: {} primitives", drawn.prims.len()); +} diff --git a/crates/ui-egui/src/canvas/list.rs b/crates/ui-egui/src/canvas/list.rs index 2d548264e..f965b0ae1 100644 --- a/crates/ui-egui/src/canvas/list.rs +++ b/crates/ui-egui/src/canvas/list.rs @@ -128,6 +128,7 @@ pub(super) fn ensure_list(app: &mut CadApp, xf: &Xf, views: &[(Bounds2, f64)], c // level of detail must hold at the finest one. min_dash: 2f64.powi(lod_band) * 2.0, text: true, + linetypes: true, fill: true, lineweights: lwdisplay, view_height: st.paper_view().height, diff --git a/crates/ui-egui/src/canvas/viewport.rs b/crates/ui-egui/src/canvas/viewport.rs index c79354b6c..0857e23ee 100644 --- a/crates/ui-egui/src/canvas/viewport.rs +++ b/crates/ui-egui/src/canvas/viewport.rs @@ -128,6 +128,7 @@ pub(super) fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Con tolerance: 2f64.powi(band) * 0.5, min_dash: 2f64.powi(band) * 2.0, text: true, + linetypes: true, fill: true, lineweights: lwdisplay, view_height: st.paper_view().height, From ccbc4fa8541ec71655448eddb2f2e283006836fc Mon Sep 17 00:00:00 2001 From: Books Date: Mon, 2 Nov 2026 06:38:31 +1100 Subject: [PATCH 090/151] DXF: link dimension sources to their associations (fixes #2221) (#2589) * DXF: link dimension sources to their associations (#2221) Source objects lacked the persistent reactor back to their DIMASSOC. Register validated source and intersection references once through the existing reactor list, keeping a self-link from duplicating the dimension's own reactor. Synthetic regressions cover missing links, repeated references and self-links. * Parity checklist: regenerated for dimension reactor gate * Parity checklist: regenerated after merging main * DXF: a dimension source keeps its other reactors next to the DIMASSOC (review on #2589) The DIMASSOC reactor made the writer emit an ACAD_REACTORS group, so the raw passthrough skipped the source's retained foreign reactors. They are now merged, deduplicated, into that one group. Test: a line reactor-linked to a kept object keeps both links after an associative dimension is added. * DXF: an unmodelled DIMASSOC source gets the DIMASSOC reactor (review on #2589) Unknown entities are written by the raw passthrough, which never saw the planned reactors. The map is now passed to dxf_raw::finish and merged into the entity's reactor group. Test: an unknown entity with a dimension snapped to it names the DIMASSOC. * Docs: leave the file-format parity document to maintainers (review on #2589) * DXF: pass the unknown-entity reactors as one argument (review on #2589) * DXF: collapse the extension dictionary condition (review on #2589) * Test: pin the reactor merge for dimension sources (review on #2589) * DXF: a kept extension dictionary follows the reactors group (review on #2589) When a written record already had its reactors group, the kept extension dictionary was inserted right after the handle, ahead of it; the DXF reference puts the reactors first. The reactor tests now find each group by name, so either order can no longer pass. * docs: leave the generated parity checklist to maintainers Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_raw.rs | 60 +++++-- crates/io/src/dxf_write.rs | 4 +- crates/io/src/dxf_write/sections.rs | 17 +- crates/io/tests/dimassoc_angular_radial.rs | 47 ++++++ crates/io/tests/it/roundtrip_audit.rs | 179 +++++++++++++++++++++ 5 files changed, 288 insertions(+), 19 deletions(-) diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index 9b755f80f..16ea48b6c 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -846,7 +846,7 @@ fn unknown_handles(d: &Drawing) -> HashSet { } /// Put the drawing's foreign data back into a written file's tags (see the module comment). -pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { +pub(crate) fn finish(tags: &mut Vec, d: &Drawing, reactors: &HashMap>) { let f = d.foreign.as_ref(); let unknown = unknown_handles(d); let xd: XdataOf = all_entities(d).filter(|e| !e.common.xdata.is_empty()).map(|e| (e.handle, e.common.xdata.as_slice())).collect(); @@ -993,8 +993,10 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { let (Some(zero), Some(ub)) = (t.first(), t.get(1..)) else { continue }; let ur = Rec { sec: sec.clone(), kind: zero.str(), start: 0, end: 0 }; let x = head(ub).handle.and_then(|h| handle(&h)).and_then(|h| entity_extra(f, &xd, h)); + // Reactors the writer planned for this source (a DIMASSOC, an associative hatch). + let extra = head(ub).handle.and_then(|h| handle(&h)).and_then(|h| reactors.get(&h)).map_or(&[][..], Vec::as_slice); out.push(zero.clone()); - record(&map, &ur, ub, x.as_deref(), true, false, &mut out); + record(&map, &ur, ub, x.as_deref(), Some(extra), false, &mut out); } out.extend(tags.get(r.start).cloned()); let x = extra_for(f, &xd, r, b, &block); @@ -1002,7 +1004,7 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { out.extend(b.iter().cloned()); continue; } - record(&map, r, b, x.as_deref(), false, Some(r.start) == root, &mut out); + record(&map, r, b, x.as_deref(), None, Some(r.start) == root, &mut out); } add_appids(&mut out); let top = max_handle(&out).saturating_add(1); @@ -1076,10 +1078,23 @@ fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap, unknown: Option<&[String]>) -> Vec { + let extra = unknown.unwrap_or_default(); + let mut have: HashSet = h.reactors.iter().cloned().collect(); + let kept = x.filter(|_| !(unknown.is_some() && h.has_reactors)).into_iter().flat_map(|x| x.reactors.iter()).filter_map(|r| map.get(&r.hex())); + kept.chain(extra.iter().cloned()).filter(|r| r != "0" && have.insert(norm(r))).collect() +} + /// A written record with its kept extras, remapped references (unknown entities) and kept /// dictionary entries (the named object dictionary). -fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool, root: bool, out: &mut Vec) { +fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: Option<&[String]>, root: bool, out: &mut Vec) { let h = head(b); + let adds = reactors_to_add(map, &h, x, unknown); + let mut in_reactors = false; let entity = matches!(r.sec.as_str(), "ENTITIES" | "BLOCKS") && r.kind != "BLOCK"; let xdata_at = b.iter().position(|t| t.code == 1001).unwrap_or(b.len()); let own_apps: HashSet = b.iter().filter(|t| t.code == 1001).map(|t| up(&t.str())).collect(); @@ -1096,6 +1111,12 @@ fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool let rgb = b.iter().take(xdata_at).find(|t| t.code == 420).map(Tag::str); let has_book = b.iter().take(xdata_at).any(|t| t.code == 430); let handle_at = b.iter().position(|t| matches!(t.code, 5 | 105)); + // A kept extension dictionary follows the reactors group, as the DXF reference orders them. + let reactors_end = b.iter().position(|t| t.code == 102 && t.str().trim() == "{ACAD_REACTORS").and_then(|s| { + let rest = b.get(s + 1..)?; + rest.iter().position(|t| t.code == 102 && t.str().trim() == "}").map(|e| s + 1 + e) + }); + let xdict_at = if h.has_reactors { reactors_end.or(handle_at) } else { handle_at }; let mut seen_owner = false; for (i, t) in b.iter().enumerate() { if i == props_at @@ -1114,8 +1135,18 @@ fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool { xdata(map, x, &own_apps, out); } + if t.code == 102 { + match t.str().trim() { + "{ACAD_REACTORS" => in_reactors = true, + "}" if in_reactors => { + in_reactors = false; + out.extend(adds.iter().map(|a| Tag::s(330, a.clone()))); + } + _ => {} + } + } let mut t = t.clone(); - if unknown && i < xdata_at && is_pointer(t.code) && t.code != 102 { + if unknown.is_some() && i < xdata_at && is_pointer(t.code) && t.code != 102 { if t.code == 330 && !seen_owner { seen_owner = true; } else { @@ -1123,18 +1154,17 @@ fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool } } out.push(t); - if Some(i) == handle_at + if Some(i) == handle_at && !h.has_reactors && !adds.is_empty() { + out.push(Tag::s(102, "{ACAD_REACTORS")); + out.extend(adds.iter().map(|a| Tag::s(330, a.clone()))); + out.push(Tag::s(102, "}")); + } + if Some(i) == xdict_at && let Some(x) = x + && h.xdict.is_none() + && let Some(xd) = x.xdict { - if !h.has_reactors { - let rs: Vec = x.reactors.iter().map(|r| r.hex()).collect(); - map.handle_group(out, "ACAD_REACTORS", 330, &rs); - } - if h.xdict.is_none() - && let Some(xd) = x.xdict - { - map.handle_group(out, "ACAD_XDICTIONARY", 360, &[xd.hex()]); - } + map.handle_group(out, "ACAD_XDICTIONARY", 360, &[xd.hex()]); } } if let Some(x) = x { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index d6aa337aa..b93471b01 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -43,8 +43,8 @@ struct W { pstyles: HashMap, /// Upper-case layer name → its LAYER table record handle (group 331/341 of viewports). layers: HashMap, - /// Persistent reactors of boundary objects: each source object of an associative hatch → - /// the hatches (groups `{ACAD_REACTORS 330 …}`), from [`hatch_reactors`]. + /// Persistent reactors of source geometry: the associative hatches and DIMASSOC objects + /// that follow it (groups `{ACAD_REACTORS 330 …}`). reactors: HashMap>, } diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index e9f8a9e35..b2eff022d 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -874,7 +874,20 @@ fn plan_dimension_associations(d: &Drawing, all_spaces: &[&EntityStore], w: &mut if let EntityKind::Dimension(dm) = &e.kind { let (flags, refs) = std_assoc_refs(d, dm); if !refs.is_empty() { - cx.assoc.insert(e.handle, AssocObj { xdict: w.h(), handle: w.h(), flags, refs }); + let assoc = AssocObj { xdict: w.h(), handle: w.h(), flags, refs }; + // Source geometry must name the DIMASSOC as well, or other readers do not + // follow later edits. Both objects of an intersection need the reactor. + for source in assoc.refs.iter().flat_map(|r| std::iter::once(r.main).chain(r.other)) { + // common_x already gives the dimension its own DIMASSOC reactor. + if source == e.handle { + continue; + } + let reactors = w.reactors.entry(source).or_default(); + if !reactors.contains(&assoc.handle) { + reactors.push(assoc.handle.clone()); + } + } + cx.assoc.insert(e.handle, assoc); } } } @@ -1117,7 +1130,7 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version, frozen_34 if let Some(t) = w.t.get_mut(seed_pos + 1) { *t = Tag::s(5, seed); } - crate::dxf_raw::finish(&mut w.t, d); + crate::dxf_raw::finish(&mut w.t, d, &w.reactors); crate::dxf_raw::data_store(&mut w.t, d, target >= Version::R2013); add_class_instance_counts(&mut w.t, target); mark_xrefs(&mut w.t, d); diff --git a/crates/io/tests/dimassoc_angular_radial.rs b/crates/io/tests/dimassoc_angular_radial.rs index ec1a11f69..f955ad7b5 100644 --- a/crates/io/tests/dimassoc_angular_radial.rs +++ b/crates/io/tests/dimassoc_angular_radial.rs @@ -288,3 +288,50 @@ fn standard_dimassoc_associates_in_dxf_and_dwg() { let back = read(&dwg, "case.dwg").unwrap(); assert_standard_assoc(&c, &back, "DWG"); } + +/// Every source object names each DIMASSOC that follows it once, before its owner group. +#[test] +fn dimension_sources_carry_their_persistent_reactors() { + let c = case(); + let text = write_dxf(&c.d); + let all = records(&text); + for dimension in [c.hd, c.hr, c.ha, c.hl] { + let assoc = dimassoc(&text, dimension).expect("DIMASSOC"); + let reactor = string(&assoc, 5).expect("DIMASSOC handle"); + for reference in point_refs(&assoc) { + // Both objects of an intersection need the link back. Repeated snaps to one + // object must not write the same DIMASSOC reactor twice. + for source in [string(&reference, 331), string(&reference, 332)].into_iter().flatten() { + let geometry = all.iter().find(|r| string(r, 5) == Some(source)).expect("referenced geometry"); + let start = geometry.iter().position(|(code, value)| *code == 102 && value == "{ACAD_REACTORS").expect("source reactor group"); + let end = + start + 1 + geometry.iter().skip(start + 1).position(|(code, value)| *code == 102 && value == "}").expect("reactor group end"); + assert_eq!(geometry[start + 1..end].iter().filter(|(code, value)| *code == 330 && value == reactor).count(), 1); + assert_eq!(start, 2, "reactors immediately follow the entity handle"); + assert_eq!(geometry.get(end + 1).map(|(code, _)| *code), Some(330), "entity owner follows reactors"); + assert_eq!(geometry.iter().filter(|(code, value)| *code == 102 && value == "{ACAD_REACTORS").count(), 1); + } + } + } +} + +/// Malformed self-links must not duplicate the reactor the dimension already carries. +#[test] +fn a_self_referential_dimension_writes_its_reactor_once() { + let mut c = case(); + assert!(c.d.model.modify(c.hl, |e| { + let EntityKind::Dimension(dm) = &mut e.kind else { panic!("dimension") }; + for reference in &mut dm.assoc { + reference.handle = c.hl; + } + })); + let text = write_dxf(&c.d); + let assoc = dimassoc(&text, c.hl).expect("DIMASSOC"); + let reactor = string(&assoc, 5).expect("DIMASSOC handle"); + let all = records(&text); + let handle = c.hl.hex(); + let dimension = all.iter().find(|r| string(r, 5) == Some(&handle)).expect("dimension record"); + let start = dimension.iter().position(|(code, value)| *code == 102 && value == "{ACAD_REACTORS").expect("reactor group"); + let end = start + 1 + dimension.iter().skip(start + 1).position(|(code, value)| *code == 102 && value == "}").expect("reactor group end"); + assert_eq!(dimension[start + 1..end].iter().filter(|(code, value)| *code == 330 && value == reactor).count(), 1); +} diff --git a/crates/io/tests/it/roundtrip_audit.rs b/crates/io/tests/it/roundtrip_audit.rs index 0b15f3181..c2a326935 100644 --- a/crates/io/tests/it/roundtrip_audit.rs +++ b/crates/io/tests/it/roundtrip_audit.rs @@ -1010,3 +1010,182 @@ fn dwg_keeps_extension_dictionaries_xrecords_and_entity_xdata() { assert_eq!(d.block("DOOR").map(|b| b.description.as_str()), Some("A door leaf")); assert!(d.ucss.iter().any(|u| u.name == "TILTED")); } + +/// A linear dimension whose two extension origins are snapped to `a` and `b`, so the saved file +/// carries a DIMASSOC naming both objects. +fn add_associated_dimension(d: &mut Drawing, a: cadcraft_doc::Handle, b: cadcraft_doc::Handle) { + use cadcraft_doc::{AssocSnap, Common, DimAssoc, DimKind, Dimension, Space}; + use cadcraft_geom::Vec3; + let at = |point: &str, handle| DimAssoc { point: point.into(), handle, snap: AssocSnap::Mid }; + let dm = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(5.0, -3.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: vec![at("p13", a), at("p14", b)], + marks: Default::default(), + }; + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).expect("add dimension"); +} + +/// The handle of the one DIMASSOC object of a written file. +fn dimassoc_handle(o: &Out) -> String { + let assocs = o.of("DIMASSOC"); + assert_eq!(assocs.len(), 1, "one DIMASSOC"); + h(assocs.first().copied().unwrap_or_default()) +} + +/// A source object that already has a reactor to a kept foreign object (the fixture's line 2A +/// names ACME_OBJECT 42) keeps it when a DIMASSOC reactor is added: one group with both (review on #2589). +#[test] +fn a_dimension_source_keeps_its_foreign_reactors_next_to_the_dimassoc() { + let mut d = read_dxf(fixture().as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x2A), cadcraft_doc::Handle(0x2A)); + let o = Out::new(&write_dxf(&d)); + let line = o.of("LINE").into_iter().find(|t| h(t).eq_ignore_ascii_case("2A")).expect("line 2A"); + assert_eq!(line.iter().filter(|t| t.code == 102 && t.str() == "{ACAD_REACTORS").count(), 1, "one reactor group"); + let reactors = group(line, "ACAD_REACTORS"); + assert!(reactors.contains(&dimassoc_handle(&o)), "DIMASSOC reactor: {reactors:?}"); + assert!(reactors.iter().any(|r| o.handle(r).is_some_and(|(k, _)| k == "ACME_OBJECT")), "foreign reactor kept: {reactors:?}"); + assert!(o.dangling().is_empty(), "{:?}", o.dangling()); +} +/// An unmodelled entity a DIMASSOC refers to gets the reactor too, next to what it had (review on #2589). +#[test] +fn an_unknown_dimension_source_carries_the_dimassoc_reactor() { + let mut d = read_dxf(fixture().as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x30), cadcraft_doc::Handle(0x2A)); + let o = Out::new(&write_dxf(&d)); + let widget = entity_by_text(&o, "ACME_WIDGET", "widget-in-model"); + let reactors = group(widget, "ACAD_REACTORS"); + assert!(reactors.contains(&dimassoc_handle(&o)), "DIMASSOC reactor: {reactors:?}"); + assert_eq!(widget.iter().filter(|t| t.code == 102 && t.str() == "{ACAD_REACTORS").count(), 1); + assert_eq!(owner(widget), h(o.named("BLOCK_RECORD", "*Model_Space")), "owner unchanged"); + assert!(o.dangling().is_empty(), "{:?}", o.dangling()); +} + +/// The handles of the `{ACAD_REACTORS` group of the record of handle `at`, after checking the +/// written text holds exactly one such group, placed as the DXF reference orders a record: +/// handle (5), then the reactors, then the owner (330), then the first subclass marker (100) and +/// any xdata (1001). Each handle appears once and names a DIMASSOC when `assoc_only` is set. +fn one_reactor_group(o: &Out, at: &str, assoc_only: bool) -> Vec { + let (_, rec) = o.handle(at).expect("source record"); + let codes: Vec<(i32, String)> = rec.iter().map(|t| (t.code, t.str())).collect(); + let starts: Vec = (0..codes.len()).filter(|&i| codes[i].0 == 102 && codes[i].1 == "{ACAD_REACTORS").collect(); + assert_eq!(starts.len(), 1, "exactly one reactor group in {codes:?}"); + let start = starts[0]; + let end = start + codes[start..].iter().position(|c| c.0 == 102 && c.1 == "}").expect("group end"); + let handles: Vec = codes[start + 1..end].iter().map(|c| c.1.clone()).collect(); + assert!(codes[start + 1..end].iter().all(|c| c.0 == 330), "only 330 groups inside: {codes:?}"); + let unique: std::collections::HashSet = handles.iter().map(|r| r.to_ascii_uppercase()).collect(); + assert_eq!(unique.len(), handles.len(), "each reactor once: {handles:?}"); + let pos = |code: i32| codes.iter().position(|c| c.0 == code); + assert_eq!(pos(5), Some(0), "handle first"); + let owner_at = end + 1 + codes[end + 1..].iter().position(|c| c.0 == 330).expect("owner"); + assert!(owner_at > end, "owner after the reactors: {codes:?}"); + assert!(end < pos(100).expect("subclass marker"), "reactors before the first subclass marker"); + if let Some(x) = pos(1001) { + assert!(end < x, "reactors before the xdata"); + } + if assoc_only { + for r in &handles { + assert_eq!(o.handle(r).map(|(k, _)| k), Some("DIMASSOC"), "{r} resolves to a DIMASSOC"); + } + } + handles +} + +/// A record that already has a reactors group (line 2A names ACME_OBJECT 42) comes out with one +/// group holding the foreign handle and the DIMASSOC, each once, in the same order after a +/// second save and with the extension dictionary group still between it and the owner. +/// Pinning test for the merge (review on #2589). +#[test] +fn a_record_with_a_reactor_group_gets_one_group_with_every_handle_once() { + let mut d = read_dxf(fixture().as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x2A), cadcraft_doc::Handle(0x2A)); + let text = write_dxf(&d); + let o = Out::new(&text); + let reactors = one_reactor_group(&o, "2A", false); + assert_eq!(reactors.len(), 2, "foreign + DIMASSOC: {reactors:?}"); + let assoc = dimassoc_handle(&o); + assert_eq!(reactors.iter().filter(|r| r.eq_ignore_ascii_case(&assoc)).count(), 1); + assert!(reactors.iter().any(|r| o.handle(r).is_some_and(|(k, _)| k == "ACME_OBJECT"))); + let (_, line) = o.handle("2A").expect("line"); + let order: Vec = line.iter().map(|t| t.code).collect(); + let start = |name: &str| line.iter().position(|t| t.code == 102 && t.str() == format!("{{{name}")); + let (rx, xd) = (start("ACAD_REACTORS"), start("ACAD_XDICTIONARY")); + assert!(rx.is_some() && xd.is_some() && rx < xd, "reactors group before the extension dictionary group: {order:?}"); + assert_eq!(group(line, "ACAD_XDICTIONARY").len(), 1); + // Our reader accepts it, the association survives, and a second save keeps the same group. + let back = read_dxf(text.as_bytes()).expect("reopen"); + let again = Out::new(&write_dxf(&back)); + assert_eq!(again.of("DIMASSOC").len(), 1); + let second = one_reactor_group(&again, "2A", false); + assert_eq!(second.len(), 2, "{second:?}"); + assert!(second.iter().any(|r| again.handle(r).is_some_and(|(k, _)| k == "DIMASSOC"))); + assert!(again.dangling().is_empty(), "{:?}", again.dangling()); +} + +/// A record with no reactors group (line 2A with its group removed, keeping the extension +/// dictionary and the xdata) gets a new one in the preamble, between the handle and the owner, +/// so before the first subclass marker and the xdata. Pinning test (review on #2589). +#[test] +fn a_record_without_a_reactor_group_gets_a_new_one_in_the_preamble() { + let bare = fixture().replacen("5\n2A\n102\n{ACAD_REACTORS\n330\n42\n102\n}\n", "5\n2A\n", 1); + assert_ne!(bare, fixture(), "the fixture line lost its reactors"); + let mut d = read_dxf(bare.as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x2A), cadcraft_doc::Handle(0x2A)); + let text = write_dxf(&d); + let o = Out::new(&text); + let (_, line) = o.handle("2A").expect("line"); + assert!(line.iter().any(|t| t.code == 1001), "the record keeps its xdata"); + let xdict_at = line.iter().position(|t| t.code == 102 && t.str() == "{ACAD_XDICTIONARY").expect("xdict group"); + let reactors = one_reactor_group(&o, "2A", true); + assert_eq!(reactors, vec![dimassoc_handle(&o)]); + // The preamble is the handle, then the reactors group and the extension dictionary group in + // the DXF reference's order, then the owner: nothing else in between. + let reactors_at = line.iter().position(|t| t.code == 102 && t.str() == "{ACAD_REACTORS").expect("reactor group"); + let owner_at = line.iter().position(|t| t.code == 330 && t.str() != reactors[0]).expect("owner"); + assert!(reactors_at < xdict_at && xdict_at < owner_at, "reactors {reactors_at}, xdict {xdict_at}, owner {owner_at}"); + assert!(line.iter().take(owner_at).all(|t| matches!(t.code, 5 | 102 | 330 | 360)), "only handle and groups before the owner"); + // Reading it back keeps the association and the DIMASSOC still resolves. + let back = read_dxf(text.as_bytes()).expect("reopen"); + let again = Out::new(&write_dxf(&back)); + one_reactor_group(&again, "2A", true); + assert!(again.dangling().is_empty(), "{:?}", again.dangling()); +} + +/// A new line used by two dimensions (so by two DIMASSOC objects) lists both, once each, in one +/// group that sits between its handle and its owner. Pinning test (review on #2589). +#[test] +fn a_source_used_by_two_associations_lists_both_dimassocs_once() { + use cadcraft_doc::{Common, Line, Space}; + use cadcraft_geom::Vec3; + let mut d = Drawing::new_metric(); + let line = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 0.0, 0.0) })).expect("add line"); + add_associated_dimension(&mut d, line, line); + add_associated_dimension(&mut d, line, line); + let text = write_dxf(&d); + let o = Out::new(&text); + let assocs: Vec = o.of("DIMASSOC").into_iter().map(h).collect(); + assert_eq!(assocs.len(), 2, "one DIMASSOC per dimension"); + let reactors = one_reactor_group(&o, &line.hex(), true); + let mut got: Vec = reactors.iter().map(|r| r.to_ascii_uppercase()).collect(); + let mut want: Vec = assocs.iter().map(|r| r.to_ascii_uppercase()).collect(); + got.sort(); + want.sort(); + assert_eq!(got, want, "both associations, once each"); + let back = read_dxf(text.as_bytes()).expect("reopen"); + let again = Out::new(&write_dxf(&back)); + assert_eq!(one_reactor_group(&again, &line.hex(), true).len(), 2); + assert!(again.dangling().is_empty(), "{:?}", again.dangling()); +} From 152617f4df0b81d733cb0ca8a0596079c97b63d1 Mon Sep 17 00:00:00 2001 From: Books Date: Mon, 2 Nov 2026 06:38:34 +1100 Subject: [PATCH 091/151] Plot: a layout's saved page setup is used by default (part of #2493) (#2586) * Plot: a layout's saved page setup is used by default (#2493) A layout whose page setup says plot window, scale to fit or a plot scale was plotted 1:1 from the sheet origin unless the caller passed fit. plot, exportpdf, the PNG device and preview now read the layout's saved window, scale to fit, scale and centring when the options don't say (render::paper::saved_plot); explicit options win, and fit: false without a scale is still the plain 1:1 sheet. Tests: saved_setup_tests in pdf.rs (four), and preview_uses_the_layouts_saved_page_setup_by_default. * Parity checklist: regenerated after merging main * Parity checklist: regenerated after merging main * Plot: saved page setup in preview and PNG matches the PDF (review on #2586) Preview honours the saved centre when fitting a window and an explicit scale beats a saved scale to fit (PDF too); PNG places a saved window without fit and clips to the printable area. * Preview: a saved window clips to the printable area (review on #2586) The preview drew the placed plot over the margins while the PDF and PNG blank them. The blanking helper now lives in the render crate and both the PNG plot and the preview use it. Test: preview_clips_a_saved_window_to_the_printable_area. * Preview: pin hostile saved page setups and the default setup (review on #2586) Degenerate or far-away saved windows and saved scales of 0 or not finite already fall back to the plain plot in the PDF, PNG and preview; tests now pin that, and that a layout with no saved setup plots as before. * Preview: the printable clip follows the view's fit (review on #2586) The clip assumed the sheet filled the image; when the height cap applies View::fit scales and centres it. The printable corners now go through View::to_screen. Test: preview_clip_follows_the_view_when_the_height_cap_applies. * docs: leave the generated parity checklist to maintainers Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/exchange.rs | 169 ++++++++++++++++++++++++++++-- crates/io/src/pdf.rs | 129 +++++++++++++++++++++-- crates/io/src/plotter.rs | 158 +++++++++++++++++++++++++--- crates/render/src/paper.rs | 27 +++++ crates/render/src/raster.rs | 31 ++++++ 5 files changed, 487 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index e28de89fc..f6ce1e9b6 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -330,17 +330,23 @@ fn run_preview(s: &mut Session, p: &Value) -> Result { if let Some(l) = p.get("landscape").and_then(Value::as_bool) { page.landscape = l; } + // A layout's saved page setup (plot window, scale to fit, scale, centring) applies unless the call says otherwise (#2493). + // An explicit `fit: false` without a `scale` asks for the plain 1:1 plot. + let plain = p.get("fit").and_then(Value::as_bool) == Some(false) && p.get("scale").is_none(); + let saved = if space.is_paper() && !plain { cadcraft_render::paper::saved_plot(&page) } else { Default::default() }; let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page)); let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true }); let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency); - let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model)); - let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let given_scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + // A saved scale to fit applies only when the call gives neither `fit` nor `scale`, as in the PDF. + let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model) || (saved.fit && given_scale.is_none())); + let scale = given_scale.or(saved.scale); let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX); let height = ((width as f64) * sheet.size.y / sheet.size.x).round(); let height = if height.is_finite() { (height as u64).clamp(16, MAX_PREVIEW_PX) } else { width }; let px_per_paper_unit = width as f64 / sheet.size.x; // Curves are flattened to about half a pixel. - let est = plot_scale(&d.extents(&space), &sheet, fit, scale); + let est = plot_scale(&saved.window.unwrap_or_else(|| d.extents(&space)), &sheet, fit, scale); let tol = 0.5 / (px_per_paper_unit * est); let ropts = cadcraft_render::Options { tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 }, @@ -352,9 +358,13 @@ fn run_preview(s: &mut Session, p: &Value) -> Result { ..Default::default() }; let list = cadcraft_render::build_plot(d, &space, &ropts); - let b = list.bounds; + let b = saved.window.unwrap_or(list.bounds); // Paper point q shows drawing point (q - to) / k + from. - let (from, k, to) = if fit || space == Space::Model { + let (from, k, to) = if saved.window.is_some() { + // A saved window, fitted or at a saved scale: its centre (or corner) on the printable area's. + let c = saved.center; + (if c { b.center() } else { b.min }, plot_scale(&b, &sheet, fit, scale), if c { sheet.printable.center() } else { sheet.printable.min }) + } else if fit || space == Space::Model { (if b.is_empty() { Vec2::ZERO } else { b.center() }, plot_scale(&b, &sheet, fit, scale), sheet.printable.center()) } else { (Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO) @@ -372,7 +382,20 @@ fn run_preview(s: &mut Session, p: &Value) -> Result { px_per_mm: if lineweights && px_per_mm.is_finite() { px_per_mm } else { 0.0 }, antialias: true, }; - let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?; + let mut pm = cadcraft_render::raster::render(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?; + // A placed plot (fitted, or a saved window) shows only inside the printable area, as the PDF and PNG plots clip it. + if fit || saved.window.is_some() || space == Space::Model { + // The view may scale and centre the sheet (the height cap), so map the printable corners + // (paper point q shows drawing point (q - to) / k + from) through it. + let screen = |q: Vec2| { + let (x, y) = view.to_screen((q - to) / k + from); + (f64::from(x), f64::from(y)) + }; + let (min, max) = (sheet.printable.min, sheet.printable.max); + let (a, b) = (screen(Vec2::new(min.x, max.y)), screen(Vec2::new(max.x, min.y))); + cadcraft_render::raster::blank_outside_pixels(&mut pm, a, b); + } + let png = pm.encode_png().map_err(|_| bad("preview", "rendering the preview failed"))?; let layout = space.tab_name(); Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape })) } @@ -738,6 +761,140 @@ mod tests { assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1"); } + /// A layout's saved page setup (plot window, scale to fit) shapes its preview by default, as + /// it shapes the PDF; `fit: false` still gives the plain 1:1 sheet (#2493). + #[test] + fn preview_uses_the_layouts_saved_page_setup_by_default() { + let mut s = Session::new(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap(); + s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap(); + let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string(); + let unsaved = png(&mut s, json!({ "layout": "Layout1", "width": 200 })); + let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page; + page.plot_area = "window".into(); + page.plot_window = [20.0, 5.0, 836.0, 589.0]; + page.scale_to_fit = true; + let saved = png(&mut s, json!({ "layout": "Layout1", "width": 200 })); + assert_ne!(saved, unsaved, "the saved window and scale to fit change the preview"); + assert_eq!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false })), unsaved, "fit: false is the plain 1:1 sheet"); + } + + /// With a saved plot window the preview follows the page setup's centring (fitted or not) and an + /// explicit `scale` beats a saved scale to fit, as in the PDF. + /// A placed saved window shows only inside the printable area, as the PDF and PNG plots clip it: + /// geometry in the margin leaves the preview unchanged. + #[test] + fn preview_clips_a_saved_window_to_the_printable_area() { + let build = |margin_line: bool| { + let mut s = Session::new(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + s.execute("line", &json!({ "points": [[0.0, 30.0], [100.0, 30.0]] })).unwrap(); + if margin_line { + s.execute("line", &json!({ "points": [[-3.0, 0.0], [-3.0, 60.0]] })).unwrap(); + } + s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [10, 10, 10, 10] })).unwrap(); + let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page; + page.plot_area = "window".into(); + page.plot_window = [0.0, 0.0, 100.0, 60.0]; + page.scale_to_fit = true; + page.center = true; + s.execute("preview", &json!({ "layout": "Layout1", "width": 600 })).unwrap()["data"].as_str().unwrap_or_default().to_string() + }; + assert_eq!(build(true), build(false), "a line in the margin is clipped away"); + } + + /// On a tall sheet the height cap makes the view scale and centre the sheet in the image; the + /// printable clip must follow it, so ink in the margin is still blanked. + #[test] + fn preview_clip_follows_the_view_when_the_height_cap_applies() { + let build = |margin_line: bool| { + let mut s = Session::new(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + s.execute("line", &json!({ "points": [[0.0, 50.0], [100.0, 50.0]] })).unwrap(); + if margin_line { + s.execute("line", &json!({ "points": [[-2.6, 0.0], [-2.6, 100.0]] })).unwrap(); + } + s.execute("pagesetup", &json!({ "layout": "Layout1", "margins": [10, 10, 10, 10] })).unwrap(); + let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page; + page.width_mm = 210.0; + page.height_mm = 1200.0; + page.landscape = false; + page.plot_area = "window".into(); + page.plot_window = [0.0, 0.0, 100.0, 100.0]; + page.scale_to_fit = true; + page.center = true; + s.execute("preview", &json!({ "layout": "Layout1", "width": 1000 })).unwrap()["data"].as_str().unwrap_or_default().to_string() + }; + assert_eq!(build(true), build(false), "a line in the margin is clipped away on a tall sheet"); + } + + /// Hostile saved setups (a degenerate or far-away window, a scale of 0 or not finite) fall back + /// to the plain preview and never panic, and a layout with no saved setup previews exactly as + /// the plain 1:1 sheet does. + #[test] + fn preview_survives_hostile_saved_setups_and_default_is_unchanged() { + let mut s = Session::new(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + s.execute("line", &json!({ "points": [[20.0, 5.0], [200.0, 100.0]] })).unwrap(); + s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true })).unwrap(); + let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string(); + let call = json!({ "layout": "Layout1", "width": 200 }); + let plain = png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false })); + assert_eq!(png(&mut s, call.clone()), plain, "no saved setup: the default preview is the plain sheet"); + let set = |s: &mut Session, window: [f64; 4], scale: f64, fit: bool| { + let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page; + page.plot_area = "window".into(); + page.plot_window = window; + page.scale = scale; + page.scale_to_fit = fit; + }; + let good = [0.0, 0.0, 100.0, 60.0]; + let (nan, inf) = (f64::NAN, f64::INFINITY); + for window in [[5.0, 5.0, 5.0, 40.0], [5.0, 5.0, 40.0, 5.0], [nan, 0.0, 10.0, 10.0], [0.0, 0.0, inf, 10.0]] { + set(&mut s, window, 1.0, true); + assert_eq!(png(&mut s, call.clone()), plain, "{window:?}: a degenerate window changes nothing"); + } + for scale in [0.0, -2.0, nan, inf] { + set(&mut s, good, scale, false); + let _ = png(&mut s, call.clone()); + set(&mut s, [5.0, 5.0, 5.0, 40.0], scale, false); + assert_eq!(png(&mut s, call.clone()), plain, "{scale}: an unusable saved scale is ignored"); + } + set(&mut s, [1e9, 1e9, 1e9 + 10.0, 1e9 + 10.0], 1.0, true); + let _ = png(&mut s, call.clone()); + set(&mut s, [-1e300, -1e300, 1e300, 1e300], 1.0, true); + let _ = png(&mut s, call.clone()); + } + + #[test] + fn preview_honours_saved_centering_and_an_explicit_scale() { + let mut s = Session::new(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap(); + s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap(); + let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page; + page.plot_area = "window".into(); + page.plot_window = [20.0, 5.0, 400.0, 200.0]; + page.scale_to_fit = true; + let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string(); + let set_center = |s: &mut Session, c: bool| s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page.center = c; + set_center(&mut s, true); + let centred = png(&mut s, json!({ "layout": "Layout1", "width": 200 })); + set_center(&mut s, false); + // The window is fitted by width, so only the other axis shows where it sits. + let corner = png(&mut s, json!({ "layout": "Layout1", "width": 200 })); + assert_ne!(corner, centred, "a saved centre: false puts the fitted window at the printable corner"); + let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page; + page.center = true; + let by_scale = png(&mut s, json!({ "layout": "Layout1", "width": 200, "scale": 0.5 })); + assert_ne!(by_scale, centred, "an explicit scale is not the fitted plot"); + assert_eq!( + by_scale, + png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), + "scale decides, not the saved fit" + ); + } #[test] fn pdfbook_collects_frames_into_one_pdf_per_row() { our_hooks(); diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index b06580d53..ba9d65867 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -508,8 +508,37 @@ struct SheetPage { label: String, } +/// `o` with a layout's saved page setup filled in where the options don't say: its plot window, +/// scale to fit or saved scale, and centring (#2493). Explicit options win; model space has no +/// saved setup. +pub(crate) fn with_saved_setup(d: &Drawing, space: &Space, o: &PdfOptions) -> PdfOptions { + let mut o = o.clone(); + let Some(page) = space.layout().and_then(|n| d.layout(n)).map(|l| &l.page) else { return o }; + // An explicit `fit: false` without a `scale` asks for the plain 1:1 plot of the sheet. + if o.fit == Some(false) && o.scale.is_none() { + o.window = None; + return o; + } + let saved = paper::saved_plot(page); + // A layout is plotted over its own sheet: only its saved window applies, not one in the options. + o.window = saved.window; + // An explicit scale beats a saved scale to fit (the scale is ignored while fitting). + if o.fit.is_none() && o.scale.is_none() && saved.fit { + o.fit = Some(true); + } + if o.scale.is_none() { + o.scale = saved.scale; + } + if o.center.is_none() && saved.window.is_some() { + o.center = Some(saved.center); + } + o +} + /// Plot one space. fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String)]) -> Result { + let saved = with_saved_setup(d, space, o); + let o = &saved; let page = page_for(d, space, o)?; let unit_mm = paper::page_unit_mm(d, &page); let sheet = Sheet::from_page(&page, unit_mm); @@ -530,8 +559,8 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String cancel: None, }; let k = unit_mm * PT_PER_MM; - let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); - let fit = o.fit.unwrap_or(window.is_some() || matches!(space, Space::Model)); + let window = o.window.filter(|w| !w.is_empty()); + let fit = o.fit.unwrap_or(matches!(space, Space::Model)); // Chord tolerance: about 0.05 mm on paper. let est = plot_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, fit, o.scale); let tol = 0.05 / unit_mm / est.max(1e-300); @@ -541,8 +570,11 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String let list = cadcraft_render::build_plot(d, space, &cadcraft_render::Options { tolerance, view_height, ..ropts }); let b = window.unwrap_or(list.bounds); let s = plot_scale(&b, &sheet, fit, o.scale); - let map = if fit || matches!(space, Space::Model) { - let (from, to) = placement(space, &b, fit, &sheet, o); + // A layout plotted over a window is placed like model space: the window on the printable area. + let placed = fit || window.is_some() || matches!(space, Space::Model); + let map = if placed { + let by = if window.is_some() { &Space::Model } else { space }; + let (from, to) = placement(by, &b, fit, &sheet, o); Map { from, s, to, k } } else { Map { from: Vec2::ZERO, s, to: Vec2::ZERO, k } @@ -551,11 +583,7 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String let mut list = list; cadcraft_render::pen::apply(&mut list, map.s * unit_mm); let media = sheet.size * k; - let clip_pt = if fit || matches!(space, Space::Model) { - Bounds2::new(sheet.printable.min * k, sheet.printable.max * k) - } else { - Bounds2::new(Vec2::ZERO, media) - }; + let clip_pt = if placed { Bounds2::new(sheet.printable.min * k, sheet.printable.max * k) } else { Bounds2::new(Vec2::ZERO, media) }; // The layers drawn on, in order of first use. let mut layers: Vec = Vec::new(); if o.layers { @@ -989,3 +1017,86 @@ fn uri(url: &str) -> Option { } Some(out) } + +#[cfg(test)] +mod saved_setup_tests { + use super::*; + use cadcraft_doc::{Common, EntityKind, Layout, Line}; + use cadcraft_geom::Vec3; + use serde_json::json; + + /// A layout drawn in mm at A1 size (frame 20,5 - 836,589) whose page setup is A4 landscape + /// plotting that window, as in #2493. + fn drawing(scale_to_fit: bool, scale: f64) -> Drawing { + let mut d = Drawing::new_metric(); + let mut l = Layout::new("L1", 3); + l.page = PageSetup { + width_mm: 297.0, + height_mm: 210.0, + landscape: true, + margins_mm: [0.0; 4], + plot_area: "window".into(), + plot_window: [20.0, 5.0, 836.0, 589.0], + scale_to_fit, + scale, + center: true, + ..PageSetup::default() + }; + d.layouts.push(l); + let line = EntityKind::Line(Line { a: Vec3::new(20.0, 5.0, 0.0), b: Vec3::new(836.0, 589.0, 0.0) }); + d.add(&Space::Paper("L1".into()), Common::default(), line).unwrap(); + d + } + + fn map(d: &Drawing, opts: serde_json::Value) -> Map { + sheet(d, &Space::Paper("L1".into()), &PdfOptions::from_json(&opts), &[]).unwrap().map + } + + /// A layout plotted with no options uses its saved scale to fit and plot window, not 1:1. + #[test] + fn a_layout_plots_its_saved_window_scaled_to_fit_by_default() { + let d = drawing(true, 1.0); + let m = map(&d, json!({})); + let want = (297.0_f64 / 816.0).min(210.0 / 584.0); + assert!((m.s - want).abs() < 1e-9, "scale {} not {want}", m.s); + assert!(m.from.dist(Vec2::new(428.0, 297.0)) < 1e-9, "window centre, got {:?}", m.from); + assert!(m.to.dist(Vec2::new(148.5, 105.0)) < 1e-9, "page centre, got {:?}", m.to); + } + + /// The options still win: `fit: false` is the plain 1:1 plot, and a `scale` is used as given. + #[test] + fn explicit_options_override_the_saved_setup() { + let d = drawing(true, 1.0); + let m = map(&d, json!({"fit": false})); + assert_eq!((m.s, m.from, m.to), (1.0, Vec2::ZERO, Vec2::ZERO)); + let m = map(&d, json!({"fit": false, "scale": 0.25})); + assert!((m.s - 0.25).abs() < 1e-12, "{}", m.s); + } + + /// A saved scale (not scale to fit) is used by default, with the window centred on the page. + #[test] + fn a_layout_plots_at_its_saved_scale_by_default() { + let d = drawing(false, 0.3394); + let m = map(&d, json!({})); + assert!((m.s - 0.3394).abs() < 1e-12, "{}", m.s); + assert!(m.from.dist(Vec2::new(428.0, 297.0)) < 1e-9 && m.to.dist(Vec2::new(148.5, 105.0)) < 1e-9, "{:?} {:?}", m.from, m.to); + } + + /// A page setup with nothing saved to fit plots as it always did. + #[test] + fn a_layout_without_a_saved_window_or_scale_is_unchanged() { + let mut d = drawing(false, 1.0); + d.layouts.iter_mut().find(|l| l.name == "L1").unwrap().page.plot_area = "layout".into(); + let m = map(&d, json!({})); + assert_eq!((m.s, m.from, m.to), (1.0, Vec2::ZERO, Vec2::ZERO)); + } + + /// An explicit `scale` beats a saved scale to fit, in the PDF as in the preview. + #[test] + fn an_explicit_scale_overrides_the_saved_scale_to_fit() { + let d = drawing(true, 1.0); + let m = map(&d, json!({"scale": 0.25})); + assert!((m.s - 0.25).abs() < 1e-12, "{}", m.s); + assert!(m.from.dist(Vec2::new(428.0, 297.0)) < 1e-9 && m.to.dist(Vec2::new(148.5, 105.0)) < 1e-9, "still placed: {:?} {:?}", m.from, m.to); + } +} diff --git a/crates/io/src/plotter.rs b/crates/io/src/plotter.rs index 12da96c3b..f0a174ea1 100644 --- a/crates/io/src/plotter.rs +++ b/crates/io/src/plotter.rs @@ -63,7 +63,7 @@ fn fit_scale(b: &Bounds2, sheet: &Sheet, o: &PdfOptions) -> f64 { } fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { - let o = PdfOptions::from_json(opts); + let o = pdf::with_saved_setup(d, space, &PdfOptions::from_json(opts)); let (sheet, page) = sheet(d, space, &o)?; let dpi = opts.get("resolution").and_then(Value::as_f64).filter(|r| r.is_finite()).unwrap_or(300.0).clamp(50.0, 2400.0); // Pixels per paper unit, lowered so the image stays within the caps. @@ -78,10 +78,17 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { let (width, height) = (px(sheet.size.x), px(sheet.size.y)); let lineweights = o.lineweights.unwrap_or(if space.is_paper() { page.lineweights } else { true }); let table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(Arc::new); - // Paper units per drawing unit: 1 for a layout at 1:1, the fitted scale for model space. + // Paper units per drawing unit: the fitted scale for model space or a fitted layout, else the + // `scale` option or 1:1, as PDF plots it. let fit = matches!(space, Space::Model) || o.fit == Some(true); - let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); - let est = if fit { fit_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, &o) } else { 1.0 }; + let window = o.window.filter(|w| !w.is_empty()); + // A layout over a saved window is placed like model space, the window on the printable area. + let placed = fit || window.is_some(); + let est = if fit { + fit_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, &o) + } else { + pdf::plot_scale(&Bounds2::EMPTY, &sheet, false, o.scale) + }; // Chord tolerance: a fifth of a pixel. let tolerance = (0.2 / (k * est)).max(1e-9); let transparency = o.plot_transparency.unwrap_or(page.plot_transparency); @@ -89,21 +96,27 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { let mut list = cadcraft_render::build_plot(d, space, &ropts); let half = Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k); let b = window.unwrap_or(list.bounds); - let s = if fit { fit_scale(&b, &sheet, &o) } else { 1.0 }; + let s = if fit { fit_scale(&b, &sheet, &o) } else { est }; // Plot style ends, joins and fill patterns at the plot scale, as PDF draws them (a layout's // are already drawn). cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm); - let view = if fit { - let (from, to) = pdf::placement(space, &b, true, &sheet, &o); - // The world point at the image centre, mapped through the same paper origin as PDF. - let center = from + (half - to) / s; - raster::View { center, scale: k * s, width, height } + let (from, to) = if placed { + // A window is placed like model space, whatever the space (as in PDF). + pdf::placement(if window.is_some() { &Space::Model } else { space }, &b, fit, &sheet, &o) } else { - raster::View { center: half, scale: k, width, height } + (Vec2::ZERO, Vec2::ZERO) }; + // The world point at the image centre, mapped through the same paper origin as PDF. + let center = from + (half - to) / s; + let view = raster::View { center, scale: k * s, width, height }; let px_per_mm = if lineweights { (k / sheet.unit_mm) as f32 } else { 0.0 }; let ro = raster::RasterOptions { background: Rgb(255, 255, 255), hairline: 1.0, px_per_mm, antialias: true }; - raster::render_png(&list, &view, &ro).ok_or_else(|| IoError::Format("render failed".into())) + let mut pm = raster::render(&list, &view, &ro).ok_or_else(|| IoError::Format("render failed".into()))?; + if placed { + // PDF clips a placed plot to the printable area: blank the rest of the sheet. + raster::blank_outside_printable(&mut pm, &sheet.printable, k); + } + pm.encode_png().map_err(|_| IoError::Format("render failed".into())) } fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result> { @@ -237,4 +250,125 @@ mod tests { assert!(err.to_string().contains("not supported for SVG")); assert!(plot(&d, &model, &json!({"output": "svg", "center": true})).unwrap().starts_with(b" Drawing { + let mut d = Drawing::new_metric(); + let mut l = cadcraft_doc::Layout::new("L1", 3); + l.page = cadcraft_doc::PageSetup { + width_mm: 297.0, + height_mm: 210.0, + landscape: true, + margins_mm: [10.0; 4], + plot_area: "window".into(), + plot_window: [0.0, 0.0, 100.0, 60.0], + scale_to_fit, + scale: 1.0, + center: true, + ..cadcraft_doc::PageSetup::default() + }; + d.layouts.push(l); + let sp = Space::Paper("L1".into()); + let mut line = |a: (f64, f64), b: (f64, f64)| { + d.add(&sp, Common::default(), EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })).unwrap(); + }; + line((0.0, 30.0), (100.0, 30.0)); + line((-3.0, 0.0), (-3.0, 60.0)); + d + } + + /// Whether any pixel of the PNG in the column range `x0..=x1` (mm from the sheet's left edge) + /// at the row of `y_mm` (from the sheet's bottom edge, give or take 2 px) is not white. + fn inked(png: &[u8], x0_mm: f64, x1_mm: f64, y_mm: f64) -> bool { + let pm = tiny_skia::Pixmap::decode_png(png).unwrap(); + let px_per_mm = f64::from(pm.width()) / 297.0; + let row = (f64::from(pm.height()) - y_mm * px_per_mm).round() as i64; + let (c0, c1) = ((x0_mm * px_per_mm).round() as i64, (x1_mm * px_per_mm).round() as i64); + (row - 2..=row + 2).any(|r| (c0..=c1).any(|c| pm.pixel(c as u32, r as u32).is_some_and(|p| p.red() < 200))) + } + + const LAYOUT_PNG: &str = r#"{"output": "png", "resolution": 100}"#; + + /// Without scale to fit, a saved window is still placed (centred on the printable area, 1:1) as PDF + /// places it, not left at the sheet origin. + #[test] + fn png_places_the_saved_window_when_fit_is_off() { + let d = layout_drawing(false); + let png = plot(&d, &Space::Paper("L1".into()), &serde_json::from_str(LAYOUT_PNG).unwrap()).unwrap(); + // The 100 mm line is centred on the sheet: 98.5 to 198.5 mm. At the origin it would end at 100 mm. + assert!(inked(&png, 140.0, 160.0, 105.0), "the window's line crosses the sheet centre"); + assert!(!inked(&png, 10.0, 90.0, 105.0), "nothing at the sheet origin"); + } + + /// An explicit scale overrides a saved scale to fit, and the PNG uses it. + #[test] + fn png_explicit_scale_beats_saved_fit() { + let d = layout_drawing(true); + let sp = Space::Paper("L1".into()); + let fitted = plot(&d, &sp, &serde_json::from_str(LAYOUT_PNG).unwrap()).unwrap(); + assert!(inked(&fitted, 200.0, 280.0, 105.0), "fitted, the 100 unit line is 277 mm wide"); + let half = plot(&d, &sp, &json!({"output": "png", "resolution": 100, "scale": 0.5})).unwrap(); + assert!(inked(&half, 130.0, 170.0, 105.0)); + assert!(!inked(&half, 200.0, 280.0, 105.0), "at 0.5 the line is 50 mm wide, centred"); + } + + /// A placed window shows only inside the printable area, as PDF clips it. + #[test] + fn png_clips_a_placed_window_to_the_printable_area() { + let d = layout_drawing(true); + let png = plot(&d, &Space::Paper("L1".into()), &serde_json::from_str(LAYOUT_PNG).unwrap()).unwrap(); + // The line 3 units left of the window lands about 1.7 mm from the sheet edge, in the margin. + assert!(!inked(&png, 0.0, 9.0, 105.0), "the margin stays blank"); + } + + /// A layout with `window`/`scale` saved (any values, hostile ones included). + fn saved_drawing(window: [f64; 4], scale: f64, fit: bool) -> Drawing { + let mut d = layout_drawing(fit); + let page = &mut d.layouts.iter_mut().find(|l| l.name == "L1").unwrap().page; + page.plot_window = window; + page.scale = scale; + d + } + + /// Degenerate windows, a window off the drawing, and a saved scale of 0 or not finite fall back + /// to the plain plot: the setup resolves to no window or scale, and PDF and PNG still plot. + #[test] + fn hostile_saved_setups_fall_back_and_never_panic() { + let sp = Space::Paper("L1".into()); + let nan = f64::NAN; + let inf = f64::INFINITY; + for window in [[5.0, 5.0, 5.0, 40.0], [5.0, 5.0, 40.0, 5.0], [nan, 0.0, 10.0, 10.0], [0.0, 0.0, inf, 10.0]] { + let d = saved_drawing(window, 1.0, true); + let o = pdf::with_saved_setup(&d, &sp, &PdfOptions::from_json(&json!({}))); + assert!(o.window.is_none(), "{window:?}: a degenerate window is dropped, got {:?}", o.window); + assert!(plot(&d, &sp, &json!({})).unwrap().starts_with(b"%PDF"), "{window:?}"); + assert!(plot(&d, &sp, &serde_json::from_str(LAYOUT_PNG).unwrap()).is_ok(), "{window:?}"); + } + for scale in [0.0, -2.0, nan, inf] { + let d = saved_drawing([0.0, 0.0, 100.0, 60.0], scale, false); + let o = pdf::with_saved_setup(&d, &sp, &PdfOptions::from_json(&json!({}))); + assert!(o.scale.is_none(), "{scale}: an unusable saved scale is ignored"); + assert!(plot(&d, &sp, &json!({})).unwrap().starts_with(b"%PDF"), "{scale}"); + assert!(plot(&d, &sp, &serde_json::from_str(LAYOUT_PNG).unwrap()).is_ok(), "{scale}"); + } + // A window far from everything drawn is plotted (an empty sheet), not an error. + let d = saved_drawing([1e9, 1e9, 1e9 + 10.0, 1e9 + 10.0], 1.0, true); + assert!(plot(&d, &sp, &json!({})).unwrap().starts_with(b"%PDF")); + assert!(plot(&d, &sp, &serde_json::from_str(LAYOUT_PNG).unwrap()).is_ok()); + } + + /// A layout with no saved setup (`PageSetup::default()`) plots as the options say: nothing is + /// filled in from it. + #[test] + fn a_layout_without_saved_setup_changes_nothing() { + let mut d = Drawing::new_metric(); + d.layouts.push(cadcraft_doc::Layout::new("L1", 3)); + let sp = Space::Paper("L1".into()); + let given = PdfOptions::from_json(&json!({"scale": 0.5})); + let o = pdf::with_saved_setup(&d, &sp, &given); + assert_eq!((o.window, o.fit, o.scale, o.center), (None, None, Some(0.5), None)); + let o = pdf::with_saved_setup(&d, &sp, &PdfOptions::from_json(&json!({}))); + assert_eq!((o.window, o.fit, o.scale, o.center), (None, None, None, None)); + } } diff --git a/crates/render/src/paper.rs b/crates/render/src/paper.rs index 457a2a48d..ff4a027ba 100644 --- a/crates/render/src/paper.rs +++ b/crates/render/src/paper.rs @@ -155,3 +155,30 @@ mod tests { assert!(s.size.x > 0.0 && s.printable == s.bounds()); } } + +/// What a layout's saved page setup says about how to plot it, for a plot that was not given the +/// matching options (#2493): the plot window, whether to scale to fit, the saved scale, and +/// centring. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SavedPlot { + /// The saved plot window, when the plot area is "window" and its corners are a real box. + pub window: Option, + /// Scale to fit the window (or the extents) on the printable area. + pub fit: bool, + /// The saved plot scale, paper units per drawing unit, when it isn't scale to fit or 1:1. + pub scale: Option, + /// Centre the plot on the printable area. + pub center: bool, +} + +/// The saved plot settings of `page`. A layout plotted over its whole sheet ("layout" plot area) +/// has nothing to fit, so scale to fit only counts for a window or the extents. +pub fn saved_plot(page: &PageSetup) -> SavedPlot { + let [x1, y1, x2, y2] = page.plot_window; + let window = (page.plot_area.eq_ignore_ascii_case("window") && [x1, y1, x2, y2].iter().all(|v| v.is_finite())) + .then(|| Bounds2::new(Vec2::new(x1, y1), Vec2::new(x2, y2))) + .filter(|w| w.width() > 0.0 && w.height() > 0.0); + let fit = page.scale_to_fit && (window.is_some() || page.plot_area.eq_ignore_ascii_case("extents")); + let scale = Some(page.scale).filter(|s| !page.scale_to_fit && s.is_finite() && *s > 0.0 && (*s - 1.0).abs() > 1e-9); + SavedPlot { window, fit, scale, center: page.center } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 72ac821d0..a32818a38 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -222,6 +222,37 @@ impl Hairlines { } } +/// Blank (white) everything outside `printable`, a rectangle in paper units with the origin at the +/// sheet's bottom-left, on a pixmap showing the whole sheet at `px_per_unit` pixels per paper unit. +/// A placed plot is clipped to the printable area this way, in the PNG plot and in the preview. +pub fn blank_outside_printable(pm: &mut Pixmap, printable: &Bounds2, px_per_unit: f64) { + let ph = f64::from(pm.height()); + blank_outside_pixels( + pm, + (printable.min.x * px_per_unit, ph - printable.max.y * px_per_unit), + (printable.max.x * px_per_unit, ph - printable.min.y * px_per_unit), + ); +} + +/// Blank (white) everything outside the pixel rectangle from `top_left` to `bottom_right` (image +/// coordinates, y down; rounded and clamped to the image). For a pixmap whose view does not map +/// the sheet onto the whole image, map the printable corners through `View::to_screen`. +pub fn blank_outside_pixels(pm: &mut Pixmap, top_left: (f64, f64), bottom_right: (f64, f64)) { + let (pw, ph) = (pm.width(), pm.height()); + let col = |x: f64| x.round().clamp(0.0, f64::from(pw)) as usize; + let row = |y: f64| y.round().clamp(0.0, f64::from(ph)) as usize; + let (x0, x1) = (col(top_left.0), col(bottom_right.0)); + let (y0, y1) = (row(top_left.1), row(bottom_right.1)); + let stride = pw as usize * 4; + for (r, line) in pm.data_mut().chunks_exact_mut(stride.max(4)).enumerate() { + for (c, px) in line.as_chunks_mut::<4>().0.iter_mut().enumerate() { + if r < y0 || r >= y1 || c < x0 || c >= x1 { + px.copy_from_slice(&[255, 255, 255, 255]); + } + } + } +} + /// Render and encode as PNG. pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option> { render(list, view, o)?.encode_png().ok() From e8bcc38e34285c40c24ffc0513f81f7424a2c1f9 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:40:15 +0700 Subject: [PATCH 092/151] OOPS: bring back the last erase, each object where it was (#2605) There was no OOPS. It now brings back what the last ERASE removed (the Delete key too), or BLOCK deleting the objects it makes a block of, as AutoCAD 2024 does (accoreconsole, the drawing's entnext order read back): - Each object keeps its handle and goes back directly above the object that was under it, or under the one that was above it when that is gone. An erase records both neighbours (EntityStore::neighbours). - Only the last erase counts; entdel doesn't. OOPS says nothing and is one undo step. - An object stays out when it is already in the drawing, when an undo took the drawing back to before it was made, when its layer, block or layout is gone, and, during REFEDIT, when it isn't in the working set. REFCLOSE forgets the working copies; the Block Editor has an OOPS of its own. - The command's label is in the 17 interface catalogs. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/store.rs | 35 ++++ crates/engine/src/blockedit.rs | 4 +- crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/blocks.rs | 13 +- crates/engine/src/cmd/blocks/refedit.rs | 2 + crates/engine/src/cmd/edit.rs | 8 +- crates/engine/src/cmd/modify.rs | 4 + crates/engine/src/cmd/modify/oops.rs | 171 ++++++++++++++++ crates/engine/src/cmd/modify/transform.rs | 13 +- crates/engine/src/doc_state.rs | 3 + crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/oops_command.rs | 232 ++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt_br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh.tsv | 1 + 29 files changed, 484 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/cmd/modify/oops.rs create mode 100644 crates/engine/tests/it/oops_command.rs diff --git a/crates/doc/src/store.rs b/crates/doc/src/store.rs index 3e9b440e1..00b04970e 100644 --- a/crates/doc/src/store.rs +++ b/crates/doc/src/store.rs @@ -189,6 +189,19 @@ impl EntityStore { None => false, } } + /// The objects directly under and above `h` in the draw order (None at the bottom or the + /// top), when `h` is here. + pub fn neighbours(&self, h: Handle) -> Option<(Option, Option)> { + let pos = self.chunk_pos(self.chunk_id(h)?)?; + let items = &self.chunks.get(pos)?.items; + let i = items.iter().position(|e| e.handle == h)?; + let below = match i.checked_sub(1) { + Some(j) => items.get(j), + None => pos.checked_sub(1).and_then(|p| self.chunks.get(p)).and_then(|c| c.items.last()), + }; + let above = items.get(i + 1).or_else(|| self.chunks.get(pos + 1).and_then(|c| c.items.first())); + Some((below.map(|e| e.handle), above.map(|e| e.handle))) + } /// Move `h` directly above (`above`) or under `anchor` in the draw order. False (and nothing /// moves) when either is absent or they are the same object. pub fn move_next_to(&mut self, h: Handle, anchor: Handle, above: bool) -> bool { @@ -364,4 +377,26 @@ mod tests { assert_eq!(s.get(Handle(h)).map(|e| e.handle.0), Some(h)); } } + + #[test] + fn neighbours_across_chunks() { + let mut s = EntityStore::new(); + for h in 1..=3000 { + s.push(ent(h)); + } + assert_eq!(s.neighbours(Handle(1)), Some((None, Some(Handle(2))))); + assert_eq!(s.neighbours(Handle(1500)), Some((Some(Handle(1499)), Some(Handle(1501))))); + // The first and last objects of a chunk look into the chunks next to it. + let k = CHUNK_MAX as u64; + assert_eq!(s.neighbours(Handle(k)), Some((Some(Handle(k - 1)), Some(Handle(k + 1))))); + assert_eq!(s.neighbours(Handle(k + 1)), Some((Some(Handle(k)), Some(Handle(k + 2))))); + assert_eq!(s.neighbours(Handle(3000)), Some((Some(Handle(2999)), None))); + assert_eq!(s.neighbours(Handle(9999)), None); + assert!(s.move_next_to(Handle(3000), Handle(1), false)); + assert_eq!(s.neighbours(Handle(1)), Some((Some(Handle(3000)), Some(Handle(2))))); + assert_eq!(s.neighbours(Handle(3000)), Some((None, Some(Handle(1))))); + let mut one = EntityStore::new(); + one.push(ent(7)); + assert_eq!(one.neighbours(Handle(7)), Some((None, None))); + } } diff --git a/crates/engine/src/blockedit.rs b/crates/engine/src/blockedit.rs index fae52757e..89da207f0 100644 --- a/crates/engine/src/blockedit.rs +++ b/crates/engine/src/blockedit.rs @@ -268,11 +268,12 @@ impl DocState { } /// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh - /// undo history, nothing selected, model space. + /// undo history, nothing selected, nothing for OOPS, model space. fn enter_block_editor(&mut self, be: BlockEdit) { self.undo.clear(); self.redo.clear(); self.undo_ctl = Default::default(); + self.erased = Default::default(); self.selection.clear(); self.previous_selection.clear(); self.space = cadcraft_doc::Space::Model; @@ -338,6 +339,7 @@ impl DocState { self.undo = host.undo; self.redo = host.redo; self.undo_ctl = host.undo_ctl; + self.erased = host.erased; // The drawing comes back with nothing selected; Previous is what was selected before. self.selection.clear(); self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection }; diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 172e416d1..bc93b9f55 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -25,6 +25,7 @@ mod lisp; mod mleader_content; mod mleader_edit; mod modify; +pub(crate) use modify::Erased; mod modify2; mod mtext_opts; mod overkill; diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 81732a3f8..258594ff0 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -187,9 +187,16 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], b.entities.push(e); } doc.blocks.insert(name.to_string(), Arc::new(b)); - if keep != Keep::Retain { - for h in hs { - doc.remove_entity(*h); + match keep { + Keep::Retain => {} + Keep::Convert => { + for h in hs { + doc.remove_entity(*h); + } + } + // As after ERASE, OOPS brings them back. + Keep::Delete => { + super::modify::erase_kept(s, hs)?; } } s.set_selection(Vec::new()); diff --git a/crates/engine/src/cmd/blocks/refedit.rs b/crates/engine/src/cmd/blocks/refedit.rs index 0a61122a5..18d0d436b 100644 --- a/crates/engine/src/cmd/blocks/refedit.rs +++ b/crates/engine/src/cmd/blocks/refedit.rs @@ -281,6 +281,8 @@ fn close(s: &mut Session, save: bool) -> Result { } nd.end_refedit_discard(&r); *s.doc_mut()? = nd; + // The objects the edit managed were its own: OOPS doesn't bring them back after it. + s.state_mut()?.erased.forget(|h| r.owns(h)); s.set_selection(Vec::new()); Ok(json!({ "block": r.block, "saved": save, "objects": objects })) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3a4c43e73..754248cd3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -549,13 +549,7 @@ fn run_pasteorig(s: &mut Session, _p: &Value) -> Result { fn run_clear(s: &mut Session, _p: &Value) -> Result { let mut hs = s.selection(); hs.retain(|h| !super::curves::is_locked(s, *h)); - let d = s.doc_mut()?; - let mut n = 0; - for h in hs { - if d.remove_entity(h).is_some() { - n += 1; - } - } + let n = super::modify::erase_kept(s, &hs)?; s.set_selection(Vec::new()); Ok(json!({ "erased": n })) } diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 22126f900..828ce1c01 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -20,6 +20,7 @@ mod break_join; mod explode; mod fillet; mod offset; +mod oops; mod select_then; mod stretch; mod transform; @@ -30,6 +31,8 @@ use explode::run_explode; pub(crate) use explode::{explode_kind, explode_with}; use fillet::{FilletM, run_chamfer, run_fillet}; use offset::{OffsetM, run_offset}; +use oops::run_oops; +pub(crate) use oops::{Erased, erase_kept}; use select_then::{DisplaceM, Op, SelectThen}; use stretch::{StretchM, run_stretch}; pub(crate) use transform::transform_entities; @@ -43,6 +46,7 @@ pub fn specs() -> Vec { .alias(&["e", "delete"]) .params("{handles?: [hex]} (default: selection)") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Erase)))), + CommandSpec::new("oops", "Oops", run_oops).params("{} (brings back what the last ERASE or BLOCK removed)"), CommandSpec::new("move", "Move", run_move) .menu(&["Modify", "Move"]) .alias(&["m"]) diff --git a/crates/engine/src/cmd/modify/oops.rs b/crates/engine/src/cmd/modify/oops.rs new file mode 100644 index 000000000..217f57ae6 --- /dev/null +++ b/crates/engine/src/cmd/modify/oops.rs @@ -0,0 +1,171 @@ +//! OOPS: bring back the objects the last ERASE (or the Delete key) removed, or BLOCK deleting the +//! objects it makes a block of, with their handles and each where it was in the draw order, as +//! AutoCAD 2024 does (accoreconsole, the drawing's `entnext` order read back afterwards). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use cadcraft_doc::{Drawing, Entity, EntityKind, EntityStore, Handle, Space}; +use serde_json::{Value, json}; + +use crate::{Result, Session}; + +/// One erased object, with the objects that were directly under and above it. +#[derive(Clone, Debug)] +struct Taken { + space: Space, + entity: Arc, + below: Option, + above: Option, +} + +/// What the drawing's last erase removed, for OOPS. +#[derive(Clone, Debug, Default)] +pub(crate) struct Erased(Vec); + +impl Erased { + /// Never bring back the objects `drop` picks. + pub(crate) fn forget(&mut self, drop: impl Fn(Handle) -> bool) { + self.0.retain(|t| !drop(t.entity.handle)); + } +} + +/// Remove the objects `hs` and keep them for OOPS in place of what an earlier erase kept. +/// Returns how many went; when none did, OOPS keeps what it had. +pub(crate) fn erase_kept(s: &mut Session, hs: &[Handle]) -> Result { + let d = s.doc_mut()?; + // Every neighbour is read before anything goes: objects erased together may be neighbours. + let at: Vec<(Handle, Space, Option, Option)> = hs + .iter() + .filter_map(|&h| { + let space = d.space_of(h)?; + let (below, above) = d.space(&space)?.neighbours(h)?; + Some((h, space, below, above)) + }) + .collect(); + let mut taken = Vec::with_capacity(at.len()); + for (h, space, below, above) in at { + if let Some(entity) = d.space_mut(&space).and_then(|st| st.remove(h)) { + taken.push(Taken { space, entity, below, above }); + } + } + let n = taken.len(); + if n > 0 { + s.state_mut()?.erased = Erased(taken); + } + Ok(n) +} + +pub(super) fn run_oops(s: &mut Session, _p: &Value) -> Result { + let taken = s.state()?.erased.0.clone(); + let back: Vec = { + let d = s.doc()?; + let in_blocks = in_blocks(d, &taken); + taken.iter().map(|t| !in_blocks.contains(&t.entity.handle) && can_return(d, t)).collect() + }; + // Nothing to bring back leaves the drawing as it is: no undo step. + if !back.contains(&true) { + return Ok(json!({ "restored": 0 })); + } + let n = restore(s.doc_mut()?, &taken, &back); + Ok(json!({ "restored": n })) +} + +/// The handles of `taken` that a block's object has now (an undo can take the drawing back to +/// before an erased object was made, and a block's new object take its handle). +fn in_blocks(d: &Drawing, taken: &[Taken]) -> HashSet { + let hs: HashSet = taken.iter().map(|t| t.entity.handle).collect(); + let mut out = HashSet::new(); + for b in d.blocks.values() { + // Through whichever is smaller. + if b.entities.len() < hs.len() { + out.extend(b.entities.iter().map(|e| e.handle).filter(|h| hs.contains(h))); + } else { + out.extend(hs.iter().copied().filter(|h| b.entities.contains(*h))); + } + } + out +} + +/// Whether `t` can come back: it is not in the drawing (OOPS again, or an undo brought it back), +/// its handle is one the drawing has given out, a reference being edited in place (REFEDIT) +/// doesn't keep it out, and its layout, layer and block are still there. +fn can_return(d: &Drawing, t: &Taken) -> bool { + let h = t.entity.handle; + h.0 < d.handseed + && d.entity(h).is_none() + && !crate::cmd::blocks::refedit::locked(d, h) + && d.space(&t.space).is_some() + && d.layer(&t.entity.common.layer).is_some() + && match &t.entity.kind { + EntityKind::Insert(i) => d.block(&i.block).is_some(), + _ => true, + } +} + +/// Put back the objects `back` marks. Each run of objects that were neighbours comes back from +/// its bottom one up, so each goes directly above the one that was under it. +fn restore(d: &mut Drawing, taken: &[Taken], back: &[bool]) -> usize { + let index: HashMap = taken.iter().enumerate().map(|(i, t)| (t.entity.handle, i)).collect(); + let next: HashMap = taken.iter().enumerate().filter_map(|(i, t)| Some((t.below.filter(|b| index.contains_key(b))?, i))).collect(); + let mut done = vec![false; taken.len()]; + let mut n = 0; + for first in 0..taken.len() { + if taken[first].below.is_some_and(|b| index.contains_key(&b)) { + continue; + } + let mut at = Some(first); + while let Some(i) = at { + if std::mem::replace(&mut done[i], true) { + break; + } + if back[i] && put_back(d, taken, &index, i) { + n += 1; + } + at = next.get(&taken[i].entity.handle).copied(); + } + } + n +} + +/// Put `taken[i]` back directly above the nearest object that was under it and is here; when +/// those are gone, directly under the nearest one that was above it; else on top. +fn put_back(d: &mut Drawing, taken: &[Taken], index: &HashMap, i: usize) -> bool { + let t = &taken[i]; + let Some(store) = d.space_mut(&t.space) else { return false }; + let h = t.entity.handle; + store.push(Entity::clone(&t.entity)); + match nearest(store, taken, index, t.below, |t| t.below) { + Some(Some(b)) => { + store.move_next_to(h, b, true); + } + Some(None) => { + store.send_to_back(h); + } + None => { + if let Some(Some(a)) = nearest(store, taken, index, t.above, |t| t.above) { + store.move_next_to(h, a, false); + } + } + } + true +} + +/// From `h`, past objects erased with it that are not here, to the first object that is +/// (`Some(Some)`), the end of the draw order (`Some(None)`), or an object that is gone (`None`). +fn nearest( + store: &EntityStore, + taken: &[Taken], + index: &HashMap, + mut h: Option, + step: fn(&Taken) -> Option, +) -> Option> { + for _ in 0..=taken.len() { + let Some(x) = h else { return Some(None) }; + if store.contains(x) { + return Some(Some(x)); + } + h = step(taken.get(*index.get(&x)?)?); + } + None +} diff --git a/crates/engine/src/cmd/modify/transform.rs b/crates/engine/src/cmd/modify/transform.rs index 9adc54ec1..11e52ea23 100644 --- a/crates/engine/src/cmd/modify/transform.rs +++ b/crates/engine/src/cmd/modify/transform.rs @@ -1,6 +1,6 @@ use cadcraft_geom::Vec3; -use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets}; +use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, erase_kept, f64_req, json, point_param, point_req, targets}; // ---------------- core operations ---------------- @@ -54,14 +54,9 @@ fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut } pub(super) fn erase(s: &mut Session, hs: &[Handle]) -> Result { - let d = s.doc_mut()?; - let mut n = 0; - for h in hs { - let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); - if !locked && d.remove_entity(*h).is_some() { - n += 1; - } - } + let d = s.doc()?; + let free: Vec = hs.iter().copied().filter(|h| !d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked)).collect(); + let n = erase_kept(s, &free)?; s.set_selection(Vec::new()); Ok(n) } diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 97a1ede49..31d23a94e 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -35,6 +35,8 @@ pub struct DocState { /// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the /// drawing itself waits in it ([`DocState::drawing`]). pub block_edit: Option, + /// What the last erase removed, for OOPS. + pub(crate) erased: crate::cmd::Erased, /// The fonts missing on Windows that a save last named: the next save names them again only /// when they change. pub(crate) fonts_noted: String, @@ -64,6 +66,7 @@ impl DocState { uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), binary_dxf: false, block_edit: None, + erased: Default::default(), fonts_noted: String::new(), } } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 65b8e244f..f49d7bb7e 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -93,6 +93,7 @@ mod non_finite_edits; mod offset_closed_spline; mod offset_ellipse_side; mod offset_move; +mod oops_command; mod options_profile; mod overkill; mod parallel_display_list; diff --git a/crates/engine/tests/it/oops_command.rs b/crates/engine/tests/it/oops_command.rs new file mode 100644 index 000000000..465484e0f --- /dev/null +++ b/crates/engine/tests/it/oops_command.rs @@ -0,0 +1,232 @@ +//! OOPS, as AutoCAD 2024 runs it (accoreconsole: a line A, a line B, a circle C and a line D, these +//! scripts, then the drawing's `entnext` order read back). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::{Common, EntityKind, Handle, Line, Space}; +use cadcraft_engine::geom::Vec3; +use serde_json::{Value, json}; + +/// A session that `entmake` works in. +fn session() -> Session { + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + Session::new() +} + +/// A session with A, B, C and D in draw order, and their handles. +fn drawing() -> (Session, Vec) { + let mut s = session(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap(); + s.execute("circle", &json!({ "center": [5, 25], "radius": 3 })).unwrap(); + s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap(); + let hs = handles(&s); + (s, hs) +} + +fn handles(s: &Session) -> Vec { + s.doc().unwrap().model.iter().map(|e| e.handle).collect() +} + +/// The drawing's objects in draw order: A to D by their handles, E for any other. +fn order(s: &Session, hs: &[Handle]) -> String { + let name = |h: &Handle| hs.iter().position(|x| x == h).map_or('E', |i| (b'A' + i as u8) as char); + handles(s).iter().map(|h| name(h).to_string()).collect::>().join(" ") +} + +/// Run `script` with `@A`…`@D` standing for the objects. +fn run(s: &mut Session, hs: &[Handle], script: &[&str]) { + for line in script { + let mut line = line.to_string(); + for (i, h) in hs.iter().enumerate() { + line = line.replace(&format!("@{}", (b'A' + i as u8) as char), &format!("(handent \"{}\")", h.hex())); + } + s.cmdline(&line).unwrap(); + assert!(s.running.is_none(), "{line}: {}", s.prompt_text()); + } +} + +fn oops(s: &mut Session) -> Value { + s.execute("oops", &json!({})).unwrap() +} + +#[test] +fn the_last_erase_comes_back_where_it_was() { + const LINE: &str = "(command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")"; + const OOPS: &str = "(command \"_.OOPS\")"; + // (case, script, the drawing's order afterwards: E is the line drawn after the erase) + let cases: [(&str, &[&str], &str); 14] = [ + ("OOPS-000: nothing erased yet", &[OOPS], "A B C D"), + ("OOPS-001: two in the middle", &["(command \"_.ERASE\" @B @C \"\")", OOPS], "A B C D"), + ("OOPS-002: picked in reverse", &["(command \"_.ERASE\" @C @B \"\")", OOPS], "A B C D"), + ("OOPS-003: a new object after the erase", &["(command \"_.ERASE\" @B \"\")", LINE, OOPS], "A B C D E"), + ("OOPS-004: the bottom object", &["(command \"_.ERASE\" @A \"\")", LINE, OOPS], "A B C D E"), + ("OOPS-005: twice", &["(command \"_.ERASE\" @B \"\")", OOPS, OOPS], "A B C D"), + ("OOPS-006: after U", &["(command \"_.ERASE\" @B \"\")", "U", OOPS], "A B C D"), + ("OOPS-007: the last erase only", &["(command \"_.ERASE\" @B \"\")", "(command \"_.ERASE\" @C \"\")", OOPS], "A C D"), + ("OOPS-008: U after OOPS", &["(command \"_.ERASE\" @B \"\")", OOPS, "U"], "A C D"), + ("OOPS-009: entdel", &["(entdel @B)", OOPS], "A C D"), + ("OOPS-010: -BLOCK", &["(command \"_.-BLOCK\" \"BKO\" '(0.0 0.0) @A @C \"\")", OOPS], "A B C D"), + ("OOPS-011: U of another command", &["(command \"_.ERASE\" @B \"\")", LINE, "U", OOPS], "A B C D"), + ("OOPS-012: the object under it erased since", &["(command \"_.ERASE\" @B \"\")", "(entdel @A)", OOPS], "B C D"), + ("OOPS-013: everything", &["(command \"_.ERASE\" \"_ALL\" \"\")", OOPS], "A B C D"), + ]; + for (case, script, want) in cases { + let (mut s, hs) = drawing(); + run(&mut s, &hs, script); + assert_eq!(order(&s, &hs), want, "{case}"); + } +} + +#[test] +fn oops_says_nothing_and_counts_what_came_back() { + let (mut s, hs) = drawing(); + s.cmdline("oops").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS")); + s.execute("erase", &json!({ "handles": [hs[1].hex(), hs[3].hex()] })).unwrap(); + s.cmdline("oops").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS")); + assert_eq!(order(&s, &hs), "A B C D"); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + s.execute("u", &json!({})).unwrap(); + assert_eq!(order(&s, &hs), "A C"); + assert_eq!(oops(&mut s), json!({ "restored": 2 })); + // They come back as they were. + let d = s.doc().unwrap(); + assert!(matches!(d.entity(hs[3]).map(|e| &e.kind), Some(EntityKind::Line(l)) if l.a.y == 40.0 && l.b.x == 10.0)); +} + +#[test] +fn the_delete_key_and_block_feed_oops_too() { + // Edit > Clear, the Delete key. + let (mut s, hs) = drawing(); + s.set_selection(vec![hs[2]]); + s.execute("erase.selection", &json!({})).unwrap(); + assert_eq!(order(&s, &hs), "A B D"); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert_eq!(order(&s, &hs), "A B C D"); + // BLOCK deleting its objects. + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[3].hex()], "keep": "delete" })).unwrap(); + assert_eq!(order(&s, &hs), "B C"); + assert_eq!(oops(&mut s), json!({ "restored": 2 })); + assert_eq!(order(&s, &hs), "A B C D"); +} + +#[test] +fn an_object_an_undo_took_away_stays_away() { + // U past the line's creation: its handle is free again. + let mut s = session(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + let x = handles(&s)[0]; + s.execute("erase", &json!({ "handles": [x.hex()] })).unwrap(); + s.execute("u", &json!({})).unwrap(); + s.execute("u", &json!({})).unwrap(); + assert!(handles(&s).is_empty()); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + assert!(handles(&s).is_empty()); + // A new object takes the handle. + s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap(); + assert_eq!(handles(&s), [x]); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + assert!(matches!(s.doc().unwrap().entity(x).map(|e| &e.kind), Some(EntityKind::Circle(_)))); + // A block's object takes the handle: one erased object of two, one of a block's two. + for (erased, in_block) in [(2, 1), (1, 2)] { + let mut s = session(); + for y in 0..erased { + s.execute("line", &json!({ "points": [[0, y], [10, y]] })).unwrap(); + } + let hs = handles(&s); + s.execute("erase", &json!({ "handles": hs.iter().map(|h| h.hex()).collect::>() })).unwrap(); + for _ in 0..=erased { + s.execute("u", &json!({})).unwrap(); + } + assert!(handles(&s).is_empty()); + s.cmdline("(entmake '((0 . \"BLOCK\") (2 . \"BX\") (10 0.0 0.0 0.0) (70 . 0)))").unwrap(); + for _ in 0..in_block { + s.cmdline("(entmake '((0 . \"LINE\") (8 . \"0\") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0)))").unwrap(); + } + s.cmdline("(entmake '((0 . \"ENDBLK\")))").unwrap(); + assert!(s.doc().unwrap().block("BX").is_some_and(|b| b.entities.contains(hs[0])), "{erased} {in_block}"); + assert_eq!(oops(&mut s), json!({ "restored": 0 }), "{erased} {in_block}"); + assert!(handles(&s).is_empty()); + } +} + +#[test] +fn an_object_whose_layer_or_block_is_gone_stays_away() { + // Its layer purged. + let (mut s, hs) = drawing(); + run(&mut s, &hs, &["(command \"_.-LAYER\" \"_M\" \"L1\" \"\")"]); + s.execute("line", &json!({ "points": [[0, 60], [10, 60]] })).unwrap(); + let on_l1 = *handles(&s).last().unwrap(); + run(&mut s, &hs, &["(setvar \"CLAYER\" \"0\")"]); + s.execute("erase", &json!({ "handles": [on_l1.hex()] })).unwrap(); + s.execute("purge", &json!({ "type": "layers", "names": "L1" })).unwrap(); + assert!(s.doc().unwrap().layer("L1").is_none()); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + // Its block purged. + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap(); + s.execute("insert", &json!({ "name": "BK", "at": [50, 0] })).unwrap(); + let ins = *handles(&s).last().unwrap(); + s.execute("erase", &json!({ "handles": [ins.hex()] })).unwrap(); + s.execute("purge", &json!({ "type": "blocks", "names": "BK" })).unwrap(); + assert!(s.doc().unwrap().block("BK").is_none()); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + assert_eq!(order(&s, &hs), "A B C D"); +} + +#[test] +fn the_block_editor_has_an_oops_of_its_own() { + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap(); + s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap(); + s.execute("bedit", &json!({ "name": "BK" })).unwrap(); + // The drawing's erased line doesn't come back into the block. + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + s.execute("bclose", &json!({ "save": false })).unwrap(); + // Back in the drawing, it does. + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert_eq!(order(&s, &hs), "A B C D"); +} + +#[test] +fn a_layouts_objects_come_back_to_it() { + let mut s = session(); + let paper = Space::Paper("Layout1".into()); + let line = |y: f64| EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(10.0, y, 0.0) }); + let hs: Vec = (0..3).map(|y| s.doc_mut().unwrap().add(&paper, Common::default(), line(f64::from(y))).unwrap()).collect(); + s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap(); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + let d = s.doc().unwrap(); + assert!(d.model.is_empty()); + assert_eq!(d.space(&paper).unwrap().handles(), hs); +} + +#[test] +fn a_reference_edited_in_place_keeps_its_objects_and_the_drawings_apart() { + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[1].hex()], "keep": "convert" })).unwrap(); + let ins = *handles(&s).last().unwrap(); + s.execute("erase", &json!({ "handles": [hs[3].hex()] })).unwrap(); + let r = s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap(); + // The drawing's line stays out while the reference is edited... + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + // ...and a working copy comes back. + let copy = r["working"][0].as_str().unwrap().to_string(); + s.execute("erase", &json!({ "handles": [copy] })).unwrap(); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + // Once the edit is over, its copies don't come back into the drawing. + s.execute("erase", &json!({ "handles": [copy] })).unwrap(); + s.execute("refclose", &json!({ "save": true })).unwrap(); + assert_eq!(s.doc().unwrap().block("BK").unwrap().entities.len(), 1); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + assert_eq!(order(&s, &hs), "C E"); + // The drawing's own erased line does, after an edit that ends. + s.execute("erase", &json!({ "handles": [hs[2].hex()] })).unwrap(); + s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap(); + s.execute("refclose", &json!({ "save": false })).unwrap(); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert_eq!(order(&s, &hs), "C E"); +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 026190729..ccc9b72d5 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -643,6 +643,7 @@ One of the Crafting Apps by the ArtCraft team and community. أحد تطبيقات Crafting من فريق ArtCraft والمجتمع. Only in this drawing: {n} في هذا الرسم فقط: {n} Only in {name}: {n} في {name} فقط: {n} + Oops استعادة الكائنات الممحوة Open فتح Open (⌘O) فتح (⌘O) Open Sample Drawing فتح رسم نموذجي diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 14565ce2f..460959a33 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -585,6 +585,7 @@ One of the Crafting Apps by the ArtCraft team and community. Jedna od Crafting aplikacija ArtCraft tima i zajednice. Only in this drawing: {n} Samo u ovom crtežu: {n} Only in {name}: {n} Samo u {name}: {n} + Oops Vrati obrisane objekte Open (⌘O) Otvori (⌘O) Open Sample Drawing Otvori primjer crteža Open sample drawing Otvori primjer crteža diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c979cd786..2419bf41f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -583,6 +583,7 @@ One of the Crafting Apps by the ArtCraft team and community. Jedna z aplikací Crafting Apps od týmu ArtCraft a komunity. Only in this drawing: {n} Pouze v tomto výkresu: {n} Only in {name}: {n} Pouze v {name}: {n} + Oops Obnovit vymazané objekty Open (⌘O) Otevřít (⌘O) Open Sample Drawing Otevřít ukázkový výkres Open sample drawing Otevřít ukázku výkresu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 84b944529..2186fc743 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -495,6 +495,7 @@ On Ein On/Off Ein/Aus One of the Crafting Apps by the ArtCraft team and community. Eine der Crafting Apps vom ArtCraft-Team und seiner Community. + Oops Gelöschte Objekte wiederherstellen Open (⌘O) Öffnen (⌘O) Open Sample Drawing Beispielzeichnung öffnen Open sample drawing Beispielzeichnung öffnen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2825e8fd2..16fcecfeb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -702,6 +702,7 @@ One of the Crafting Apps by the ArtCraft team and community. Μία από τις εφαρμογές Crafting Apps της ομάδας και της κοινότητας ArtCraft. Only in this drawing: {n} Μόνο σε αυτό το σχέδιο: {n} Only in {name}: {n} Μόνο στο {name}: {n} + Oops Επαναφορά διαγραμμένων αντικειμένων Open Άνοιγμα Open (⌘O) Άνοιγμα (⌘O) Open a drawing first. Ανοίξτε πρώτα ένα σχέδιο. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b24970979..8c45b7f97 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -620,6 +620,7 @@ One of the Crafting Apps by the ArtCraft team and community. Una de las Crafting Apps del equipo y la comunidad de ArtCraft. Only in this drawing: {n} Solo en este dibujo: {n} Only in {name}: {n} Solo en {name}: {n} + Oops Restaurar objetos borrados Open Abrir Open (⌘O) Abrir (⌘O) Open Sample Drawing Abrir dibujo de ejemplo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 42ad7ee48..548ed6a66 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -490,6 +490,7 @@ On Activé On/Off Activer/Désactiver One of the Crafting Apps by the ArtCraft team and community. L'une des applications Crafting Apps de l'équipe et de la communauté ArtCraft. + Oops Restaurer les objets effacés Open (⌘O) Ouvrir (⌘O) Open Sample Drawing Ouvrir un dessin d'exemple Open sample drawing Ouvrir un dessin d'exemple diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fc27bd7ce..926ad0e6c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -711,6 +711,7 @@ One of the Crafting Apps by the ArtCraft team and community. Az ArtCraft csapata és közössége által készített Craft-alkalmazások egyike. Only in this drawing: {n} Csak ebben a rajzban: {n} Only in {name}: {n} Csak ebben: {name} – {n} + Oops Törölt objektumok visszaállítása Open Megnyitás Open (⌘O) Megnyitás (⌘O) Open Recent Legutóbbi megnyitása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 634249093..3287ec077 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -594,6 +594,7 @@ menu Modify Edita One of the Crafting Apps by the ArtCraft team and community. Una delle Crafting App del team e della comunità ArtCraft. Only in this drawing: {n} Solo in questo disegno: {n} Only in {name}: {n} Solo in {name}: {n} + Oops Ripristina oggetti cancellati Open (⌘O) Apri (⌘O) Open Sample Drawing Apri disegno di esempio Open sample drawing Apri disegno di esempio diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1bd7e0fd5..f41128ba1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -585,6 +585,7 @@ One of the Crafting Apps by the ArtCraft team and community. ArtCraft 팀과 커뮤니티가 만드는 Crafting Apps 제품군의 하나입니다. Only in this drawing: {n} 이 도면에만 있음: {n}개 Only in {name}: {n} {name}에만 있음: {n}개 + Oops 지운 객체 복원 Open (⌘O) 열기(⌘O) Open Sample Drawing 예제 도면 열기 Open sample drawing 예제 도면 열기 diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 8f6533d51..3e16b65cf 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -616,6 +616,7 @@ One of the Crafting Apps by the ArtCraft team and community. Um dos Crafting Apps da equipe e da comunidade ArtCraft. Only in this drawing: {n} Somente neste desenho: {n} Only in {name}: {n} Somente em {name}: {n} + Oops Restaurar objetos apagados Open Abrir Open (⌘O) Abrir (⌘O) Open Sample Drawing Abrir desenho de exemplo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b8f97bf73..7e4ba21d5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -490,6 +490,7 @@ On Включено On/Off Включить/выключить One of the Crafting Apps by the ArtCraft team and community. Одна из программ Crafting Apps от команды и сообщества ArtCraft. + Oops Восстановить стертые объекты Open (⌘O) Открыть (⌘O) Open Sample Drawing Открыть образец чертежа Open sample drawing Открыть образец чертежа diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6bea4c3da..96114f857 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -619,6 +619,7 @@ One of the Crafting Apps by the ArtCraft team and community. En av Crafting Apps, gjord av ArtCraft-teamet och dess community. Only in this drawing: {n} Bara i den här ritningen: {n} Only in {name}: {n} Bara i {name}: {n} + Oops Återställ raderade objekt Open Öppna Open (⌘O) Öppna (⌘O) Open Sample Drawing Öppna exempelritning diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2bf979f85..3fed6bc3b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -712,6 +712,7 @@ One of the Crafting Apps by the ArtCraft team and community. ArtCraft ekibi ve topluluğunun Crafting Apps uygulamalarından biri. Only in this drawing: {n} Yalnızca bu çizimde: {n} Only in {name}: {n} Yalnızca {name} içinde: {n} + Oops Silinen Nesneleri Geri Yükle Open Aç Open (⌘O) Aç (⌘O) Open Recent Son Kullanılanları Aç diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 714ce6c4c..bbb70e489 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -598,6 +598,7 @@ One of the Crafting Apps by the ArtCraft team and community. Одна з програм Crafting Apps від команди та спільноти ArtCraft. Only in this drawing: {n} Лише в цьому кресленні: {n} Only in {name}: {n} Лише в {name}: {n} + Oops Відновити стерті об'єкти Open (⌘O) Відкрити (⌘O) Open Sample Drawing Відкрити зразок креслення Open sample drawing Відкрити зразок креслення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9b1493031..8887bb45b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -584,6 +584,7 @@ One of the Crafting Apps by the ArtCraft team and community. Một trong các ứng dụng Crafting của nhóm và cộng đồng ArtCraft. Only in this drawing: {n} Chỉ có trong bản vẽ này: {n} Only in {name}: {n} Chỉ có trong {name}: {n} + Oops Khôi phục đối tượng đã xóa Open (⌘O) Mở (⌘O) Open Sample Drawing Mở bản vẽ mẫu Open sample drawing Mở bản vẽ mẫu diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 2663105a2..21c86d1fe 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -489,6 +489,7 @@ On 开启 On/Off 开/关 One of the Crafting Apps by the ArtCraft team and community. 由 ArtCraft 团队和社区打造的 Crafting 系列应用之一。 + Oops 恢复已擦除对象 Open (⌘O) 打开 (⌘O) Open Sample Drawing 打开示例图形 Open sample drawing 打开示例图形 From 2034d6899eba72363028723ec75d4c36eacfceac Mon Sep 17 00:00:00 2001 From: Andrea Ficarra Date: Sun, 1 Nov 2026 20:40:30 +0100 Subject: [PATCH 093/151] Polyface meshes stay one object through DXF and DWG (#2600) * Polyface meshes stay one object through DXF and DWG A polyface mesh (POLYLINE flag 64) was read as loose 3D faces. It is now read, drawn, exploded into its 3D faces and written back as a POLYLINE. Hidden edges aren't drawn. A mesh too large for the 16-bit groups 71-74 is saved as 3D faces. Split from #1005. Co-Authored-By: Claude Opus 5.5 * A polyface face ends at its first zero index A face record with a gap ([1, 2, 0, 3]) has two corners and is dropped, not read as a triangle. Co-Authored-By: Claude Opus 5.5 * Polyface meshes: write only faces that name their vertices; ID gives their Z The writer drops the faces the reader would drop, so no record names a missing vertex, and the 16-bit check only needs the counts. ID and DIST give a mesh the Z of its nearest visible edge, as they do for 3D faces. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/entity.rs | 16 ++- crates/doc/src/entity/curves.rs | 46 +++++++ crates/doc/tests/it/face3d_hidden_edges.rs | 19 +++ crates/doc/tests/it/main.rs | 1 + crates/engine/src/cmd/inquiry.rs | 8 ++ crates/engine/src/cmd/modify/explode.rs | 6 + crates/engine/src/cmd/modify2/selop.rs | 1 + crates/engine/src/grips.rs | 1 + crates/engine/tests/it/inquiry_z.rs | 12 +- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/polyface_explode.rs | 32 +++++ crates/engine/tests/it/scale_offset_z.rs | 13 +- crates/io/src/dxf_read.rs | 2 +- crates/io/src/dxf_read/document.rs | 2 +- crates/io/src/dxf_read/entities.rs | 48 +++----- crates/io/src/dxf_write.rs | 6 +- crates/io/src/dxf_write/entity.rs | 61 +++++++++- crates/io/tests/it/entity_props_roundtrip.rs | 16 ++- crates/io/tests/it/low_handseed.rs | 24 ++-- crates/io/tests/it/main.rs | 1 + crates/io/tests/it/polyface_roundtrip.rs | 121 +++++++++++++++++++ 21 files changed, 376 insertions(+), 61 deletions(-) create mode 100644 crates/doc/tests/it/face3d_hidden_edges.rs create mode 100644 crates/engine/tests/it/polyface_explode.rs create mode 100644 crates/io/tests/it/polyface_roundtrip.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index b19d10961..a516137fa 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -48,7 +48,7 @@ mod wipeout; pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal}; pub use common::{Common, Handle, Lineweight, RawTag, Transparency}; -pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid}; +pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, PolyfaceMesh, Polyline3d, RayLine, Solid}; pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader}; pub use fill::{Gradient, Hatch, HatchLoop}; pub use layout::{Image, Table, TableCell, Viewport}; @@ -86,6 +86,7 @@ pub enum EntityKind { Solid(Solid), Trace(Solid), Face3d(Face3d), + PolyfaceMesh(PolyfaceMesh), Region(Region), Viewport(Viewport), Image(Image), @@ -140,6 +141,7 @@ impl EntityKind { EntityKind::Solid(_) => "2D Solid", EntityKind::Trace(_) => "Trace", EntityKind::Face3d(_) => "3D Face", + EntityKind::PolyfaceMesh(_) => "Polyface Mesh", EntityKind::Region(_) => "Region", EntityKind::Viewport(_) => "Viewport", EntityKind::Image(_) => "Raster Image", @@ -175,6 +177,7 @@ impl EntityKind { EntityKind::Solid(_) => "SOLID", EntityKind::Trace(_) => "TRACE", EntityKind::Face3d(_) => "3DFACE", + EntityKind::PolyfaceMesh(_) => "POLYLINE", EntityKind::Region(_) => "REGION", EntityKind::Viewport(_) => "VIEWPORT", EntityKind::Image(_) => "IMAGE", @@ -218,10 +221,10 @@ impl EntityKind { let c = &s.corners; vec![Prim::Fill(vec![c[0].xy(), c[1].xy(), c[3].xy(), c[2].xy()])] } - EntityKind::Face3d(f) => { - let c = &f.corners; - let pts = [c[0].xy(), c[1].xy(), c[2].xy(), c[3].xy(), c[0].xy()]; - pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect() + EntityKind::Face3d(f) => f.visible_edges().into_iter().map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect(), + // Each face's visible edges (an edge flagged hidden, by a negative index, isn't drawn). + EntityKind::PolyfaceMesh(m) => { + m.faces3d().iter().flat_map(Face3d::visible_edges).map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect() } EntityKind::Region(r) => r.segments().into_iter().map(Prim::Seg).collect(), EntityKind::Hatch(h) => { @@ -424,6 +427,7 @@ impl EntityKind { } EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(t3), EntityKind::Face3d(f) => f.corners.iter_mut().for_each(t3), + EntityKind::PolyfaceMesh(pm) => pm.vertices.iter_mut().for_each(t3), EntityKind::Region(r) => { for v in r.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) { v.p = m.apply(v.p); @@ -542,6 +546,7 @@ impl EntityKind { EntityKind::Hatch(h) => scale(&mut h.elevation), EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z), EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z), + EntityKind::PolyfaceMesh(m) => m.vertices.iter_mut().for_each(z), EntityKind::Viewport(v) => z(&mut v.center), EntityKind::Image(i) => z(&mut i.insert), EntityKind::Table(t) => z(&mut t.insert), @@ -611,6 +616,7 @@ impl EntityKind { } EntityKind::Solid(s) | EntityKind::Trace(s) => s.corners.iter().map(|c| c.xy()).collect(), EntityKind::Face3d(f) => f.corners.iter().map(|c| c.xy()).collect(), + EntityKind::PolyfaceMesh(m) => m.vertices.iter().map(|v| v.xy()).collect(), EntityKind::Region(r) => vec![r.bounds().center()], EntityKind::Viewport(v) => { let c = v.center.xy(); diff --git a/crates/doc/src/entity/curves.rs b/crates/doc/src/entity/curves.rs index 8eeb556c4..f32319b02 100644 --- a/crates/doc/src/entity/curves.rs +++ b/crates/doc/src/entity/curves.rs @@ -69,6 +69,52 @@ pub struct Face3d { pub hidden_edges: u8, } +impl Face3d { + /// The edges drawn: side i (corner i to the next) unless bit i of `hidden_edges` hides it, + /// without the zero-length side of a triangle. + pub fn visible_edges(&self) -> Vec<(Vec3, Vec3)> { + let c = self.corners; + (0..4).filter(|i| self.hidden_edges & (1 << i) == 0).map(|i| (c[i], c[(i + 1) % 4])).filter(|(a, b)| a != b).collect() + } +} + +/// POLYLINE polyface mesh (DXF flag 64): its vertices, and faces naming three or four of them +/// by 1-based index as DXF stores them (0: no fourth vertex; a negative index hides the edge +/// that starts at that vertex). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PolyfaceMesh { + pub vertices: Vec, + pub faces: Vec<[i32; 4]>, +} + +impl PolyfaceMesh { + /// The faces as 3D faces (what EXPLODE makes). Faces with fewer than three vertices or an + /// index outside the vertex list are skipped. + pub fn faces3d(&self) -> Vec { + self.faces.iter().filter_map(|f| self.face3d(f)).collect() + } + /// One face record as a 3D face: `None` when it names fewer than three vertices before a 0, + /// or one outside the vertex list. + pub fn face3d(&self, f: &[i32; 4]) -> Option { + let mut corners: Vec = Vec::with_capacity(4); + let mut hidden = 0u8; + for &ix in f.iter().take_while(|&&ix| ix != 0) { + let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1)); + let p = *at.and_then(|n| self.vertices.get(n))?; + if ix < 0 { + hidden |= 1 << corners.len(); + } + corners.push(p); + } + match corners.as_slice() { + // A triangle's closing edge is the 3DFACE's fourth (the third is degenerate). + [a, b, c] => Some(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }), + [a, b, c, d] => Some(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }), + _ => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RayLine { pub base: Vec3, diff --git a/crates/doc/tests/it/face3d_hidden_edges.rs b/crates/doc/tests/it/face3d_hidden_edges.rs new file mode 100644 index 000000000..ee49f462f --- /dev/null +++ b/crates/doc/tests/it/face3d_hidden_edges.rs @@ -0,0 +1,19 @@ +//! A 3D face draws only its visible edges: `hidden_edges` bit i hides the side from corner i to +//! the next, as DXF group 70 defines it, and a triangle's zero-length side isn't drawn. + +use cadcraft_doc::{EntityKind, Face3d, Prim}; +use cadcraft_geom::Vec3; + +fn sides(f: Face3d) -> usize { + EntityKind::Face3d(f).prims().iter().filter(|p| matches!(p, Prim::Seg(_))).count() +} + +#[test] +fn hidden_edges_are_not_drawn() { + let quad = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 0.0)]; + assert_eq!(sides(Face3d { corners: quad, hidden_edges: 0 }), 4); + assert_eq!(sides(Face3d { corners: quad, hidden_edges: 1 | 4 }), 2); + assert_eq!(sides(Face3d { corners: quad, hidden_edges: 15 }), 0); + let triangle = [quad[0], quad[1], quad[2], quad[2]]; + assert_eq!(sides(Face3d { corners: triangle, hidden_edges: 0 }), 3); +} diff --git a/crates/doc/tests/it/main.rs b/crates/doc/tests/it/main.rs index dcad121df..18d01e293 100644 --- a/crates/doc/tests/it/main.rs +++ b/crates/doc/tests/it/main.rs @@ -1,2 +1,3 @@ +mod face3d_hidden_edges; mod lin_pat_parse; mod sub_segment_alias; diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 0fa342e23..119d727e1 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -285,6 +285,14 @@ fn entity_z(e: &Entity, p: Vec2) -> Option { EntityKind::MText(t) => Some(t.insert.z), EntityKind::Insert(i) => Some(i.insert.z), EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)), + // Drawn as its faces' visible edges: the Z on the nearest one. + EntityKind::PolyfaceMesh(m) => m + .faces3d() + .iter() + .flat_map(cadcraft_doc::Face3d::visible_edges) + .map(|(a, b)| on_segment(a, b)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(z, _)| z), _ => None, } } diff --git a/crates/engine/src/cmd/modify/explode.rs b/crates/engine/src/cmd/modify/explode.rs index 46ada655d..d7db29d35 100644 --- a/crates/engine/src/cmd/modify/explode.rs +++ b/crates/engine/src/cmd/modify/explode.rs @@ -151,6 +151,12 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option { + let faces = m.faces3d(); + (!faces.is_empty()) + .then(|| faces.into_iter().map(|f| Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Face3d(f) }).collect()) + } EntityKind::MLine(m) => Some(crate::cmd::draw2::mline::explode(e, m)), EntityKind::MText(m) => Some(explode_mtext(e, m)), // A region explodes into its boundary's lines and arcs. diff --git a/crates/engine/src/cmd/modify2/selop.rs b/crates/engine/src/cmd/modify2/selop.rs index f1b63e3ce..53939d4b6 100644 --- a/crates/engine/src/cmd/modify2/selop.rs +++ b/crates/engine/src/cmd/modify2/selop.rs @@ -103,6 +103,7 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) { EntityKind::Hatch(h) => h.elevation = 0.0, EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z), EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z), + EntityKind::PolyfaceMesh(m) => m.vertices.iter_mut().for_each(z), EntityKind::Region(_) => {} EntityKind::Image(i) => { z(&mut i.insert); diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index 8c58e30b5..b0c1a1bca 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -226,6 +226,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option set_xy(s.corners.get_mut(index)?, to), EntityKind::Face3d(f) => set_xy(f.corners.get_mut(index)?, to), + EntityKind::PolyfaceMesh(m) => set_xy(m.vertices.get_mut(index)?, to), EntityKind::Region(_) => return Some(translated(kind, delta)), EntityKind::Viewport(v) => { let opposite = *grips.get((index + 2) % 4)?; diff --git a/crates/engine/tests/it/inquiry_z.rs b/crates/engine/tests/it/inquiry_z.rs index 17f44ac03..274eeacd0 100644 --- a/crates/engine/tests/it/inquiry_z.rs +++ b/crates/engine/tests/it/inquiry_z.rs @@ -3,7 +3,7 @@ //! all said Z = 0, so a topographic drawing's levels couldn't be read in CADCraft. use cadcraft_engine::Session; -use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point}; +use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point, PolyfaceMesh}; use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3}; use serde_json::json; @@ -56,6 +56,16 @@ fn id_reports_the_z_of_what_is_at_the_point() { assert!(s.log.iter().any(|l| l.contains("Z = 1883.5460")), "{:?}", s.log.iter().rev().take(3).collect::>()); } +#[test] +fn id_reports_the_z_on_a_polyface_mesh() { + let mut s = Session::new(); + // A sloped TIN as two triangles: z = 1000 + x / 10 over (0..100, 0..100). + let vertices = vec![Vec3::new(0.0, 0.0, 1000.0), Vec3::new(100.0, 0.0, 1010.0), Vec3::new(100.0, 100.0, 1010.0), Vec3::new(0.0, 100.0, 1000.0)]; + add(&mut s, EntityKind::PolyfaceMesh(PolyfaceMesh { vertices, faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]] })); + s.cmdline("zoom e").ok(); + assert!((z(&mut s, [50.0, 0.0]) - 1005.0).abs() < 1e-9, "on the mesh's edge"); +} + fn z_given(s: &mut Session) -> f64 { s.execute("id", &json!({ "at": [10, 10, 3] })).unwrap()["z"].as_f64().unwrap() } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f49d7bb7e..54ea40675 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -104,6 +104,7 @@ mod pickdrag; mod pline_arc_xline_options; mod plot_styles; mod point_modifiers; +mod polyface_explode; mod post_command_passes; mod prompt_keywords; mod props_bad_lineweight; diff --git a/crates/engine/tests/it/polyface_explode.rs b/crates/engine/tests/it/polyface_explode.rs new file mode 100644 index 000000000..d6d850e88 --- /dev/null +++ b/crates/engine/tests/it/polyface_explode.rs @@ -0,0 +1,32 @@ +//! EXPLODE turns a polyface mesh into its 3D faces, as AutoCAD does; hidden edges stay hidden. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Common, EntityKind, PolyfaceMesh, Space}; +use cadcraft_geom::Vec3; +use serde_json::json; + +#[test] +fn exploding_a_polyface_mesh_gives_its_3d_faces() { + let mut s = Session::new(); + let mesh = PolyfaceMesh { + vertices: vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 2.0), Vec3::new(0.0, 10.0, 2.0)], + faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]], + }; + let h = s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::PolyfaceMesh(mesh)).unwrap(); + s.execute("explode", &json!({"handles": [h.hex()]})).unwrap(); + let faces: Vec<_> = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Face3d(f) => Some(f.clone()), + _ => None, + }) + .collect(); + assert_eq!(faces.len(), 2); + assert!(s.doc().unwrap().model.iter().all(|e| !matches!(e.kind, EntityKind::PolyfaceMesh(_)))); + // The diagonal starts at the third corner of the first triangle: edge 3 (and the closing 4th) hidden. + assert_eq!(faces[0].corners[2], Vec3::new(10.0, 10.0, 2.0)); + assert_eq!(faces[0].hidden_edges, 4 | 8); +} diff --git a/crates/engine/tests/it/scale_offset_z.rs b/crates/engine/tests/it/scale_offset_z.rs index a32cea081..d82d53ce0 100644 --- a/crates/engine/tests/it/scale_offset_z.rs +++ b/crates/engine/tests/it/scale_offset_z.rs @@ -5,7 +5,7 @@ //! has no thickness while a polyline's keeps it. use cadcraft_engine::Session; -use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point}; +use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point, PolyfaceMesh}; use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3}; use serde_json::json; @@ -182,3 +182,14 @@ fn offset_keeps_a_polylines_elevation_and_thickness() { let first = k.vertices.first().unwrap().p; assert!(close(first.x, 0.0) && close(first.y, 3.0), "{first:?}"); } + +#[test] +fn scale_scales_a_polyface_meshs_vertex_heights() { + let mut s = Session::new(); + let vertices = vec![Vec3::new(0.0, 0.0, 2.0), Vec3::new(10.0, 0.0, 2.0), Vec3::new(0.0, 10.0, 6.0)]; + let m = add(&mut s, EntityKind::PolyfaceMesh(PolyfaceMesh { vertices, faces: vec![[1, 2, 3, 0]] }), 0.0); + scale(&mut s, m, "0.0 0.0"); + let EntityKind::PolyfaceMesh(k) = get(&s, m).0 else { panic!() }; + let want = [Vec3::new(0.0, 0.0, 3.4), Vec3::new(17.0, 0.0, 3.4), Vec3::new(0.0, 17.0, 10.2)]; + assert!(k.vertices.iter().zip(want).all(|(a, b)| near3(*a, b)), "{:?}", k.vertices); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 324caa361..341e72a77 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -93,7 +93,7 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { let mut objs = Objects::default(); // Every handle another program's file already uses is taken before anything gets a new one: // its $HANDSEED can be lower than its handles, and entities made while reading (the faces of - // a polyface mesh) would otherwise get handles that records further on still have. A file + // a polygon mesh) would otherwise get handles that records further on still have. A file // CADCraft wrote names its own next handle, above which its rebuilt records are numbered // (#495), so it is left to that. let ours = secs.iter().any(|s| s.name == "OBJECTS" && s.tags.iter().any(|t| t.code == 3 && t.str() == crate::dxf_ext::HANDSEED_KEY)); diff --git a/crates/io/src/dxf_read/document.rs b/crates/io/src/dxf_read/document.rs index d732f4450..db935974a 100644 --- a/crates/io/src/dxf_read/document.rs +++ b/crates/io/src/dxf_read/document.rs @@ -23,7 +23,7 @@ pub(super) fn parse_entities( { c.plot_style = n; } - // More entities from this record (the faces of a polyface or polygon mesh). + // More entities from this record (the faces of a polygon mesh). let mut extra: Vec = Vec::new(); i += 1; match kind.as_str() { diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index 394feb785..119d00bbd 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -7,6 +7,7 @@ use super::{ LwPolyline, MAX_FRAME_CHARS, Point, PolyVertex, Polyline3d, RawTag, RayLine, Rx, Solid, Spline, Table, TableCell, Tag, Tolerance, Unknown, Vec2, Vec3, Viewport, Wipeout, records, }; +use cadcraft_doc::PolyfaceMesh; /// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. pub(super) fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { @@ -514,11 +515,20 @@ pub(super) fn polyline_entity(recs: &[(String, Vec)], i: &mut usize, t: &T) } let c = common(t); let mut extra: Vec = Vec::new(); - let k = if flags & (16 | 64) != 0 { - // Polyface (64) and polygon (16) meshes become 3D faces; the first takes the - // polyline's place. + let k = if flags & 64 != 0 { + // Face records that name a missing vertex (or fewer than three) are dropped: they draw + // nothing and would be written back broken. A mesh left with no face is skipped. + let mut mesh = PolyfaceMesh { vertices: pts.iter().map(|p| p.0).collect(), faces: Vec::new() }; + let kept = faces.iter().map(|f| f.map(|ix| i32::try_from(ix).unwrap_or(i32::MAX))).filter(|f| mesh.face3d(f).is_some()).collect(); + mesh.faces = kept; + if mesh.faces.is_empty() { + return None; + } + EntityKind::PolyfaceMesh(mesh) + } else if flags & 16 != 0 { + // Polygon meshes become 3D faces; the first takes the polyline's place. let verts: Vec = pts.iter().map(|p| p.0).collect(); - let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, t, flags) }.into_iter().map(EntityKind::Face3d); + let mut fs = mesh_faces(&verts, t, flags).into_iter().map(EntityKind::Face3d); let first = fs.next()?; extra.extend(fs); first @@ -573,36 +583,6 @@ pub(super) fn insert_attribs(recs: &[(String, Vec)], i: &mut usize, ins_lay attribs } -/// Faces of a polyface mesh: each face record names three or four vertices by 1-based index; -/// a negative index hides the edge that starts at that vertex. Faces with fewer vertices or -/// an index outside the vertex list are skipped. -fn polyface_faces(verts: &[Vec3], faces: &[[i64; 4]]) -> Vec { - let mut out = Vec::new(); - for f in faces { - let mut corners: Vec = Vec::with_capacity(4); - let mut hidden = 0u8; - let mut ok = true; - for &ix in f.iter().filter(|&&ix| ix != 0) { - let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1)); - let Some(p) = at.and_then(|n| verts.get(n)) else { - ok = false; - break; - }; - if ix < 0 { - hidden |= 1 << corners.len(); - } - corners.push(*p); - } - match (ok, corners.as_slice()) { - // A triangle's closing edge is the 3DFACE's fourth (the third is degenerate). - (true, [a, b, c]) => out.push(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }), - (true, [a, b, c, d]) => out.push(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }), - _ => {} - } - } - out -} - /// Quads of an M×N polygon mesh (71/72, or the smoothed 73/74 when the vertex count matches /// those), closed in M by bit 1 and in N by bit 32. Vertices run row by row. fn mesh_faces(verts: &[Vec3], t: &T, flags: i64) -> Vec { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index b93471b01..411f1c4b2 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -68,7 +68,11 @@ impl W { self.f(c + 10, p.y); } fn h(&mut self) -> String { - let h = format!("{:X}", self.next); + format!("{:X}", self.next_handle()) + } + /// A new handle for a record the drawing doesn't hold (as [`W::h`]). + fn next_handle(&mut self) -> u64 { + let h = self.next; self.next = self.next.saturating_add(1); h } diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index f1e0a1d73..51b6b4388 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -32,6 +32,64 @@ pub(super) fn hatch_reactors(d: &Drawing) -> HashMap> { out } +/// A polyface mesh as one POLYLINE record: the vertices (flags 64 | 128), then one face record +/// (128) per face that names them (the others draw nothing and are dropped, as on read). Counts +/// and indices are 16-bit groups (71–74), and no index is above the vertex count: a mesh too large +/// for them is written as its 3D faces rather than with numbers cut short. +fn polyface(w: &mut W, d: &Drawing, e: &Entity, m: &cadcraft_doc::PolyfaceMesh, owner: &str, paper: bool, cx: &Ctx) { + let faces: Vec<&[i32; 4]> = m.faces.iter().filter(|f| m.face3d(f).is_some()).collect(); + let max = usize::try_from(i16::MAX).unwrap_or(usize::MAX); + if m.vertices.len() > max || faces.len() > max { + for (i, f) in m.faces3d().into_iter().enumerate() { + let handle = if i == 0 { e.handle } else { Handle(w.next_handle()) }; + let face = Entity { handle, common: e.common.clone(), kind: EntityKind::Face3d(f) }; + entity(w, d, &face, owner, paper, cx); + } + return; + } + w.s(0, "POLYLINE"); + common(w, e, owner, paper, "AcDbPolyFaceMesh"); + w.i(66, 1); + w.p(10, Vec3::ZERO); + w.i(70, 64); + w.i(71, m.vertices.len() as i64); + w.i(72, faces.len() as i64); + for q in &m.vertices { + let vh = w.h(); + w.s(0, "VERTEX"); + w.s(5, vh); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + w.s(8, &e.common.layer); + w.s(100, "AcDbVertex"); + w.s(100, "AcDbPolyFaceMeshVertex"); + w.p(10, *q); + w.i(70, 192); + } + for f in faces { + let vh = w.h(); + w.s(0, "VERTEX"); + w.s(5, vh); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + w.s(8, &e.common.layer); + w.s(100, "AcDbFaceRecord"); + w.p(10, Vec3::ZERO); + w.i(70, 128); + for (code, ix) in [71, 72, 73, 74].into_iter().zip(f) { + if *ix != 0 { + w.i(code, i64::from(*ix)); + } + } + } + let sh = w.h(); + w.s(0, "SEQEND"); + w.s(5, sh); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + w.s(8, &e.common.layer); +} + pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { write_basic_entity(w, d, e, owner, paper, cx); write_text_entity(w, d, e, owner, paper, cx); @@ -45,7 +103,7 @@ pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo write_other_entity(w, d, e, owner, paper, cx); } -fn write_basic_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { +fn write_basic_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { match &e.kind { EntityKind::Line(l) => { w.s(0, "LINE"); @@ -144,6 +202,7 @@ fn write_basic_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: b w.s(100, "AcDbEntity"); w.s(8, &e.common.layer); } + EntityKind::PolyfaceMesh(m) => polyface(w, d, e, m, owner, paper, cx), EntityKind::Spline(s) => { w.s(0, "SPLINE"); common(w, e, owner, paper, "AcDbSpline"); diff --git a/crates/io/tests/it/entity_props_roundtrip.rs b/crates/io/tests/it/entity_props_roundtrip.rs index 38d325604..a5467d7cc 100644 --- a/crates/io/tests/it/entity_props_roundtrip.rs +++ b/crates/io/tests/it/entity_props_roundtrip.rs @@ -257,8 +257,16 @@ fn faces(d: &Drawing) -> Vec { .collect() } +/// The faces of the one polyface mesh in `d`, as 3D faces. +fn polyface_faces(d: &Drawing) -> Vec { + match d.model.iter().map(|e| &e.kind).collect::>().as_slice() { + [EntityKind::PolyfaceMesh(m)] => m.faces3d(), + ks => panic!("expected one polyface mesh, got {ks:?}"), + } +} + #[test] -fn polyface_and_polygon_mesh_polylines_become_faces_not_bogus_vertices() { +fn polyface_and_polygon_mesh_polylines_read_as_meshes_and_faces_not_bogus_vertices() { let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z); // Polyface: four vertices, a quad, a triangle whose third edge is hidden, and faces with an // out-of-range index or only two vertices (skipped). @@ -274,14 +282,14 @@ fn polyface_and_polygon_mesh_polylines_become_faces_not_bogus_vertices() { recs.push(vec![(0, "SEQEND"), (8, "0")]); let refs: Vec<&[(i32, &str)]> = recs.iter().map(Vec::as_slice).collect(); let d = read_dxf(dxf(&refs).as_bytes()).expect("read polyface"); - let fs = faces(&d); + let fs = polyface_faces(&d); assert_eq!(fs.len(), 2, "{fs:?}"); assert_eq!(fs[0].corners, [p(0.0, 0.0, 2.0), p(1.0, 0.0, 2.0), p(1.0, 1.0, 2.0), p(0.0, 1.0, 2.0)]); assert_eq!(fs[0].hidden_edges, 0); assert_eq!(fs[1].corners, [p(0.0, 0.0, 2.0), p(1.0, 1.0, 2.0), p(0.0, 1.0, 2.0), p(0.0, 1.0, 2.0)]); assert_eq!(fs[1].hidden_edges, 4 | 8, "the triangle's closing edge is hidden"); - // The faces survive a save and reopen as 3DFACEs. - assert_eq!(faces(&roundtrip(&d)), fs); + // The mesh survives a save and reopen. + assert_eq!(polyface_faces(&roundtrip(&d)), fs); // 3×2 polygon mesh: two quads. let mut recs: Vec> = diff --git a/crates/io/tests/it/low_handseed.rs b/crates/io/tests/it/low_handseed.rs index fbc1a77b6..26273f778 100644 --- a/crates/io/tests/it/low_handseed.rs +++ b/crates/io/tests/it/low_handseed.rs @@ -1,32 +1,32 @@ //! Another program's file can name a $HANDSEED below handles it uses. Entities CADCraft makes -//! while reading (the faces a polyface mesh becomes) must not take handles that records further +//! while reading (the faces a polygon mesh becomes) must not take handles that records further //! on still have: a DWG keeps one object per handle, so the duplicates were lost on save. use std::collections::HashSet; use cadcraft_doc::{Drawing, EntityKind, Handle}; -/// A block holding a polyface mesh of three triangles (handle 2000) and, in model space after it, +/// A block holding a 2×4 polygon mesh, three quads (handle 2000) and, in model space after it, /// lines with handles 2001–2003 (the next ones after the mesh's), under a $HANDSEED of 10. The /// collision is across separately read lists (a block and model space), as in the files it came /// from: within one list duplicates were already renumbered. fn dxf() -> String { - let vertex = |h: &str, x: f64, y: f64| format!("0\nVERTEX\n5\n{h}\n8\n0\n10\n{x}\n20\n{y}\n30\n0.0\n70\n192\n"); - let face = |h: &str, a: i32, b: i32, c: i32| format!("0\nVERTEX\n5\n{h}\n8\n0\n10\n0.0\n20\n0.0\n30\n0.0\n70\n128\n71\n{a}\n72\n{b}\n73\n{c}\n"); + let vertex = |h: &str, x: f64, y: f64| format!("0\nVERTEX\n5\n{h}\n8\n0\n10\n{x}\n20\n{y}\n30\n0.0\n70\n64\n"); let line = |h: &str| format!("0\nLINE\n5\n{h}\n8\n0\n10\n0.0\n20\n0.0\n30\n0.0\n11\n1.0\n21\n1.0\n31\n0.0\n"); format!( "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n9\n$HANDSEED\n5\n10\n0\nENDSEC\n\ 0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n5\n30\n8\n0\n2\nMESH\n70\n0\n10\n0.0\n20\n0.0\n30\n0.0\n\ - 0\nPOLYLINE\n5\n2000\n8\n0\n66\n1\n10\n0.0\n20\n0.0\n30\n0.0\n70\n64\n71\n4\n72\n3\n\ - {}{}{}{}{}{}{}0\nSEQEND\n5\n2010\n8\n0\n0\nENDBLK\n5\n31\n8\n0\n0\nENDSEC\n\ + 0\nPOLYLINE\n5\n2000\n8\n0\n66\n1\n10\n0.0\n20\n0.0\n30\n0.0\n70\n16\n71\n2\n72\n4\n\ + {}{}{}{}{}{}{}{}0\nSEQEND\n5\n2010\n8\n0\n0\nENDBLK\n5\n31\n8\n0\n0\nENDSEC\n\ 0\nSECTION\n2\nENTITIES\n{}{}{}0\nINSERT\n5\n60\n8\n0\n2\nMESH\n10\n0.0\n20\n0.0\n30\n0.0\n0\nENDSEC\n0\nEOF\n", vertex("2004", 0.0, 0.0), - vertex("2005", 1.0, 0.0), - vertex("2006", 1.0, 1.0), - vertex("2007", 0.0, 1.0), - face("2008", 1, 2, 3), - face("2009", 1, 3, 4), - face("200A", 2, 3, 4), + vertex("2005", 0.0, 1.0), + vertex("2006", 0.0, 2.0), + vertex("2007", 0.0, 3.0), + vertex("2008", 1.0, 0.0), + vertex("2009", 1.0, 1.0), + vertex("200A", 1.0, 2.0), + vertex("200B", 1.0, 3.0), line("2001"), line("2002"), line("2003"), diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 8442a7629..79c4e4b3e 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -54,6 +54,7 @@ mod pdf_read; mod plot_layout_scale; mod plot_model_png_scale; mod plot_styles; +mod polyface_roundtrip; mod portable_fonts; mod region_roundtrip; mod roundtrip; diff --git a/crates/io/tests/it/polyface_roundtrip.rs b/crates/io/tests/it/polyface_roundtrip.rs new file mode 100644 index 000000000..0ad05518d --- /dev/null +++ b/crates/io/tests/it/polyface_roundtrip.rs @@ -0,0 +1,121 @@ +//! A polyface mesh stays one object through DXF and DWG, instead of becoming loose 3D faces; +//! its hidden edges stay hidden. + +use cadcraft_doc::{Drawing, EntityKind, PolyfaceMesh, Prim}; +use cadcraft_geom::Vec3; +use cadcraft_io::{read_dxf, write_dxf}; + +/// A 10 × 10 square as two triangles; the diagonal (from vertex 3) is hidden in both. +const POLYFACE_DXF: &str = "0\nSECTION\n2\nENTITIES\n\ +0\nPOLYLINE\n5\n30\n100\nAcDbEntity\n8\n0\n100\nAcDbPolyFaceMesh\n66\n1\n10\n0\n20\n0\n30\n0\n70\n64\n71\n4\n72\n2\n\ +0\nVERTEX\n5\n31\n8\n0\n10\n0\n20\n0\n30\n0\n70\n192\n\ +0\nVERTEX\n5\n32\n8\n0\n10\n10\n20\n0\n30\n0\n70\n192\n\ +0\nVERTEX\n5\n33\n8\n0\n10\n10\n20\n10\n30\n2\n70\n192\n\ +0\nVERTEX\n5\n34\n8\n0\n10\n0\n20\n10\n30\n2\n70\n192\n\ +0\nVERTEX\n5\n35\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n1\n72\n2\n73\n-3\n\ +0\nVERTEX\n5\n36\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n3\n72\n4\n73\n-1\n\ +0\nSEQEND\n5\n37\n8\n0\n0\nENDSEC\n0\nEOF\n"; + +fn mesh(d: &Drawing) -> PolyfaceMesh { + let meshes: Vec<&PolyfaceMesh> = d + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::PolyfaceMesh(m) => Some(m), + _ => None, + }) + .collect(); + assert_eq!(meshes.len(), 1, "one polyface mesh"); + assert!(d.model.iter().all(|e| !matches!(e.kind, EntityKind::Face3d(_))), "no loose 3D faces"); + meshes[0].clone() +} + +fn expected() -> PolyfaceMesh { + PolyfaceMesh { + vertices: vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 2.0), Vec3::new(0.0, 10.0, 2.0)], + faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]], + } +} + +#[test] +fn polyface_mesh_is_read_as_one_object_with_its_hidden_edges() { + let m = mesh(&read_dxf(POLYFACE_DXF.as_bytes()).unwrap()); + assert_eq!(m, expected()); + assert_eq!(m.faces3d().len(), 2); + // The square's four sides; the hidden diagonal isn't drawn. + let edges = EntityKind::PolyfaceMesh(m).prims().into_iter().filter(|p| matches!(p, Prim::Seg(_))).count(); + assert_eq!(edges, 4); +} + +#[test] +fn polyface_mesh_round_trips_through_dxf_and_dwg() { + let d = read_dxf(POLYFACE_DXF.as_bytes()).unwrap(); + let text = write_dxf(&d); + assert!(text.contains("AcDbPolyFaceMesh") && text.contains("AcDbFaceRecord")); + assert_eq!(mesh(&read_dxf(text.as_bytes()).unwrap()), expected()); + #[cfg(not(target_arch = "wasm32"))] + { + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + assert_eq!(mesh(&cadcraft_io::read(&dwg, "x.dwg").unwrap()), expected()); + } +} + +#[test] +fn a_polyface_mesh_too_large_for_its_record_is_saved_as_3d_faces() { + use cadcraft_doc::{Common, Space}; + use cadcraft_io::{SaveOptions, read, write_with}; + // 40,000 vertices: counts and indices past 32,767 don't fit the 16-bit groups 71–74. + let vertices: Vec = (0..40_000).map(|i| Vec3::new(f64::from(i), 0.0, f64::from(i % 7))).collect(); + let mesh = PolyfaceMesh { vertices, faces: vec![[1, 2, 39_999, 0], [39_998, -39_999, 40_000, 3]] }; + let mut d = Drawing::new_metric(); + let common = Common { layer: "0".into(), ..Common::default() }; + d.add(&Space::Model, common, EntityKind::PolyfaceMesh(mesh.clone())).unwrap(); + let want = mesh.faces3d(); + for binary in [false, true] { + let bytes = write_with(&d, "big.dxf", &SaveOptions { version: None, binary }).unwrap(); + let back = read(&bytes, "big.dxf").unwrap(); + let faces: Vec<_> = back + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Face3d(f) => Some(f.clone()), + _ => None, + }) + .collect(); + // Every face keeps its corners and hidden edges: no index was cut to 32,767. + assert_eq!(faces, want, "binary: {binary}"); + assert!(back.model.iter().all(|e| !matches!(e.kind, EntityKind::PolyfaceMesh(_))), "binary: {binary}"); + let mut handles: Vec<_> = back.model.iter().map(|e| e.handle).collect(); + handles.sort(); + handles.dedup(); + assert_eq!(handles.len(), back.model.iter().count(), "each face has its own handle"); + } +} + +#[test] +fn faces_naming_missing_vertices_are_dropped_on_read() { + // Added faces: one names vertex 9 of a 4-vertex mesh, one has only two corners, one has two + // before its first 0 (which ends the face). + let text = POLYFACE_DXF.replace( + "0\nSEQEND\n", + "0\nVERTEX\n5\n38\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n1\n72\n2\n73\n9\n\ +0\nVERTEX\n5\n39\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n1\n72\n2\n\ +0\nVERTEX\n5\n3A\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n1\n72\n2\n74\n3\n0\nSEQEND\n", + ); + let d = read_dxf(text.as_bytes()).unwrap(); + assert_eq!(mesh(&d), expected(), "only the faces that name its vertices"); + let back = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(mesh(&back), expected()); +} + +#[test] +fn faces_naming_missing_vertices_are_not_written() { + use cadcraft_doc::{Common, Space}; + let mut m = expected(); + m.faces.push([1, 2, 9, 0]); + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common { layer: "0".into(), ..Common::default() }, EntityKind::PolyfaceMesh(m)).unwrap(); + let text = write_dxf(&d); + assert_eq!(text.matches("AcDbFaceRecord").count(), 2, "only the faces that name its vertices"); + assert_eq!(mesh(&read_dxf(text.as_bytes()).unwrap()), expected()); +} From b006110dc591c268ac558dc25c32705262d01503 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:31:14 +0700 Subject: [PATCH 094/151] OOPS: bring back what -WBLOCK erased; keep the last erase through REFEDIT; the Block Editor (#2625) * OOPS brings back what -WBLOCK erased -WBLOCK erases the objects it writes, and OOPS brings back what the last ERASE or BLOCK removed; the two landed separately, so OOPS never saw what -WBLOCK erased. -WBLOCK now erases through erase_kept, as ERASE and BLOCK do. As AutoCAD 2024 does (accoreconsole, the drawing's entnext order read back): OOPS after -WBLOCK brings its objects back, each where it was and with its handle; only the last erase counts, -WBLOCK's or ERASE's; and -WBLOCK * erases nothing, so OOPS still brings back the erase before it. Co-Authored-By: Claude Opus 5.5 * OOPS: keep the last erase through a refused REFEDIT command; Block Editor During REFEDIT, a command that removes an object outside the working set is undone (refedit::after_command), but erase_kept had already replaced what OOPS kept: the objects came back, and the erase before them was lost. erase_kept now leaves OOPS as it was when an object it removes is outside the working set. That covers BLOCK, -WBLOCK, ERASE and the Delete key. In the Block Editor, its objects keep the handles they have in the block definition, so in_blocks found them there and OOPS brought back nothing erased in the editor. in_blocks now skips the block open there. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks/wblock.rs | 8 ++-- crates/engine/src/cmd/modify.rs | 2 +- crates/engine/src/cmd/modify/oops.rs | 22 +++++++--- crates/engine/tests/dash_wblock.rs | 59 ++++++++++++++++++++++++++ crates/engine/tests/it/oops_command.rs | 28 ++++++++++++ 5 files changed, 107 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/blocks/wblock.rs b/crates/engine/src/cmd/blocks/wblock.rs index 03356269e..98ab0c344 100644 --- a/crates/engine/src/cmd/blocks/wblock.rs +++ b/crates/engine/src/cmd/blocks/wblock.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use super::super::curves::KW; use super::super::machines::{SelOutcome, SelectPhase}; +use super::super::modify::erase_kept; use super::super::{CommandSpec, bad, point_param, str_param, targets}; use super::insert_file::stem; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; @@ -16,7 +17,7 @@ pub(super) fn wblock_spec() -> CommandSpec { } /// -WBLOCK erases the objects it writes, whatever its mOde option says: AutoCAD 2024 does, and -/// keeps "Object conversion=Delete" as the setting. +/// keeps "Object conversion=Delete" as the setting. OOPS brings them back, as after ERASE. pub(super) fn dash_wblock_spec() -> CommandSpec { CommandSpec::new("-wblock", "Write Block", run_dash_wblock) .params("{path, name? | handles?, base?} (the objects written are erased)") @@ -124,10 +125,7 @@ fn moved(e: &Entity, m: &Mat3) -> Entity { } fn erase(s: &mut Session, hs: &[Handle]) -> Result<()> { - let doc = s.doc_mut()?; - for h in hs { - doc.remove_entity(*h); - } + erase_kept(s, hs)?; s.set_selection(Vec::new()); Ok(()) } diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 828ce1c01..f68d08ebb 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { .alias(&["e", "delete"]) .params("{handles?: [hex]} (default: selection)") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Erase)))), - CommandSpec::new("oops", "Oops", run_oops).params("{} (brings back what the last ERASE or BLOCK removed)"), + CommandSpec::new("oops", "Oops", run_oops).params("{} (brings back what the last ERASE, BLOCK or -WBLOCK removed)"), CommandSpec::new("move", "Move", run_move) .menu(&["Modify", "Move"]) .alias(&["m"]) diff --git a/crates/engine/src/cmd/modify/oops.rs b/crates/engine/src/cmd/modify/oops.rs index 217f57ae6..6308cc615 100644 --- a/crates/engine/src/cmd/modify/oops.rs +++ b/crates/engine/src/cmd/modify/oops.rs @@ -1,6 +1,7 @@ //! OOPS: bring back the objects the last ERASE (or the Delete key) removed, or BLOCK deleting the -//! objects it makes a block of, with their handles and each where it was in the draw order, as -//! AutoCAD 2024 does (accoreconsole, the drawing's `entnext` order read back afterwards). +//! objects it makes a block of, or -WBLOCK the objects it writes, with their handles and each where +//! it was in the draw order, as AutoCAD 2024 does (accoreconsole, the drawing's `entnext` order read +//! back afterwards). use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -50,7 +51,10 @@ pub(crate) fn erase_kept(s: &mut Session, hs: &[Handle]) -> Result { } } let n = taken.len(); - if n > 0 { + // During REFEDIT, removing an object outside the working set gets the command undone + // (`refedit::after_command`), so what OOPS had stays. + let refused = taken.iter().any(|t| crate::cmd::blocks::refedit::locked(d, t.entity.handle)); + if n > 0 && !refused { s.state_mut()?.erased = Erased(taken); } Ok(n) @@ -58,9 +62,10 @@ pub(crate) fn erase_kept(s: &mut Session, hs: &[Handle]) -> Result { pub(super) fn run_oops(s: &mut Session, _p: &Value) -> Result { let taken = s.state()?.erased.0.clone(); + let editing = s.state()?.block_edit.as_ref().map(|b| b.name.clone()); let back: Vec = { let d = s.doc()?; - let in_blocks = in_blocks(d, &taken); + let in_blocks = in_blocks(d, &taken, editing.as_deref()); taken.iter().map(|t| !in_blocks.contains(&t.entity.handle) && can_return(d, t)).collect() }; // Nothing to bring back leaves the drawing as it is: no undo step. @@ -72,11 +77,16 @@ pub(super) fn run_oops(s: &mut Session, _p: &Value) -> Result { } /// The handles of `taken` that a block's object has now (an undo can take the drawing back to -/// before an erased object was made, and a block's new object take its handle). -fn in_blocks(d: &Drawing, taken: &[Taken]) -> HashSet { +/// before an erased object was made, and a block's new object take its handle). The block open +/// in the Block Editor (`editing`) doesn't count: its objects there keep the handles they have in +/// it. +fn in_blocks(d: &Drawing, taken: &[Taken], editing: Option<&str>) -> HashSet { let hs: HashSet = taken.iter().map(|t| t.entity.handle).collect(); let mut out = HashSet::new(); for b in d.blocks.values() { + if editing.is_some_and(|n| b.name.eq_ignore_ascii_case(n)) { + continue; + } // Through whichever is smaller. if b.entities.len() < hs.len() { out.extend(b.entities.iter().map(|e| e.handle).filter(|h| hs.contains(h))); diff --git a/crates/engine/tests/dash_wblock.rs b/crates/engine/tests/dash_wblock.rs index 348cde45a..52d456f42 100644 --- a/crates/engine/tests/dash_wblock.rs +++ b/crates/engine/tests/dash_wblock.rs @@ -242,3 +242,62 @@ fn the_json_form_of_dash_wblock_erases_what_it_writes() { assert!(s.execute("-wblock", &json!({ "path": none })).is_err()); assert!(!std::path::Path::new(&none).exists()); } + +#[test] +fn oops_brings_back_what_it_erased_where_it_was() { + // OOPSW-001 to OOPSW-005 (the drawing's `entnext` order read back): OOPS brings back what + // -WBLOCK erased, each where it was, as after ERASE. Only the last erase counts, and "*" (the + // whole drawing) erases nothing. + const OOPS: &str = "(command \"_.OOPS\")"; + // (script with @A to @D for the objects and @F for a new file, the order afterwards) + let cases: [(&[&str], &str); 5] = [ + (&["(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B @C \"\")", OOPS], "A B C D"), + (&["(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B \"\")", "(command \"_.ERASE\" @D \"\")", OOPS], "A C D"), + (&["(command \"_.ERASE\" @D \"\")", "(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B \"\")", OOPS], "A B C"), + (&["(command \"_.ERASE\" @D \"\")", "(command \"_.-WBLOCK\" @F \"*\")", OOPS], "A B C D"), + (&["(command \"_.-WBLOCK\" @F \"\" \"_O\" \"_R\" '(0.0 0.0) @A @C \"\")", OOPS], "A B C D"), + ]; + let dir = TempDir::new("dash-wblock"); + for (i, (script, want)) in cases.into_iter().enumerate() { + let (mut s, a, b, c) = drawing(); + let v = s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap(); + let d = Handle::parse_hex(v["handles"][0].as_str().or(v["handle"].as_str()).unwrap()).unwrap(); + let hs = [a, b, c, d]; + for line in script { + let mut line = line.to_string(); + for (n, h) in ["@A", "@B", "@C", "@D"].into_iter().zip(hs) { + line = line.replace(n, &ent(h)); + } + let line = line.replace("@F", &format!("\"{}\"", file(&dir, &format!("OOPSW{i}.dwg")))); + lisp(&mut s, &line); + assert!(s.running.is_none(), "{line}: {}", s.prompt_text()); + } + let order: Vec<&str> = + s.doc().unwrap().model.iter().map(|e| hs.iter().position(|h| *h == e.handle).map_or("E", |k| ["A", "B", "C", "D"][k])).collect(); + assert_eq!(order.join(" "), want, "{script:?}"); + } + // The JSON form too. + let (mut s, a, b, c) = drawing(); + s.execute("-wblock", &json!({ "path": file(&dir, "json.dwg"), "handles": [b.hex()] })).unwrap(); + assert!(!alive(&s, b)); + s.execute("oops", &json!({})).unwrap(); + let order: Vec = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + assert_eq!(order, [a, b, c]); +} + +#[test] +fn a_dash_wblock_refused_during_refedit_leaves_oops_as_it_was() { + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [a.hex()], "keep": "convert" })).unwrap(); + let ins = Handle::parse_hex(s.execute("entities", &json!({ "type": "INSERT" })).unwrap()["entities"][0]["handle"].as_str().unwrap()).unwrap(); + s.execute("erase", &json!({ "handles": [c.hex()] })).unwrap(); + s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap(); + // B isn't in the working set: -WBLOCK is refused and the drawing put back. + assert!(s.execute("-wblock", &json!({ "path": file(&dir, "out.dwg"), "handles": [b.hex()] })).is_err()); + assert!(alive(&s, b)); + s.execute("refclose", &json!({ "save": false })).unwrap(); + // OOPS still has C, the last erase that stood. + s.execute("oops", &json!({})).unwrap(); + assert!(alive(&s, c)); +} diff --git a/crates/engine/tests/it/oops_command.rs b/crates/engine/tests/it/oops_command.rs index 465484e0f..f42564c6f 100644 --- a/crates/engine/tests/it/oops_command.rs +++ b/crates/engine/tests/it/oops_command.rs @@ -230,3 +230,31 @@ fn a_reference_edited_in_place_keeps_its_objects_and_the_drawings_apart() { assert_eq!(oops(&mut s), json!({ "restored": 1 })); assert_eq!(order(&s, &hs), "C E"); } + +#[test] +fn a_block_refused_during_refedit_leaves_oops_as_it_was() { + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "convert" })).unwrap(); + let ins = *handles(&s).last().unwrap(); + s.execute("erase", &json!({ "handles": [hs[3].hex()] })).unwrap(); + s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap(); + // B isn't in the working set: the block is refused and the drawing put back. + let r = s.execute("block", &json!({ "name": "BK2", "base": [0, 0], "handles": [hs[1].hex()], "keep": "delete" })); + assert!(r.is_err(), "{r:?}"); + s.execute("refclose", &json!({ "save": false })).unwrap(); + // OOPS still has D, the last erase that stood. + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert!(s.doc().unwrap().entity(hs[3]).is_some()); +} + +#[test] +fn an_erase_in_the_block_editor_comes_back_there() { + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[1].hex()], "keep": "retain" })).unwrap(); + s.execute("bedit", &json!({ "name": "BK" })).unwrap(); + let inside = handles(&s); + assert_eq!(inside.len(), 2); + s.execute("erase", &json!({ "handles": [inside[0].hex()] })).unwrap(); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert_eq!(handles(&s), inside); +} From fa03bf2a29b714e0832a120caa25b871e5f147d2 Mon Sep 17 00:00:00 2001 From: andrewbrake Date: Mon, 2 Nov 2026 23:31:18 +1300 Subject: [PATCH 095/151] feat(engine): INSERT of a drawing file keeps an attribute definition's field (#2619) * feat(engine): INSERT of a drawing file keeps an attribute definition's field The attribute definitions of a drawing file read by INSERT name=file lost their extension dictionary, so the ACAD_FIELD dictionary and the FIELD objects of an attribute whose value is a field (the file name in a title block) were gone from the block. adopt() now copies that object tree to the drawing under new handles, with the FIELD class and a field list if the drawing has none. Co-Authored-By: Claude Sonnet 5.5 * foreign: index the kept objects once when walking and copying a dictionary tree A file can keep many objects; a linear lookup per pointer grew with their product. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: andrewbrake <6925387+andrewbrake@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/foreign.rs | 88 +++++++++++++++- crates/engine/src/cmd/blocks/insert_file.rs | 17 +++- crates/engine/tests/insert_file_field.rs | 107 ++++++++++++++++++++ 3 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/insert_file_field.rs diff --git a/crates/doc/src/foreign.rs b/crates/doc/src/foreign.rs index e0ebb81a4..d732df261 100644 --- a/crates/doc/src/foreign.rs +++ b/crates/doc/src/foreign.rs @@ -6,7 +6,7 @@ //! named object dictionary, a table record, a block, an entity); references to objects that are //! gone are dropped. Commands don't edit it. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use crate::{Handle, RawTag}; @@ -94,3 +94,89 @@ impl Foreign { self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect() } } + +impl Foreign { + /// The kept objects by handle (the first of any that share one). + fn object_index(&self) -> BTreeMap { + let mut m = BTreeMap::new(); + for o in &self.objects { + m.entry(o.handle).or_insert(o); + } + m + } + + /// The kept object `root` and the kept objects it owns, directly or not (dictionary entries + /// and child pointers, groups 350 and 360), `root` first. + pub fn tree(&self, root: Handle) -> Vec { + // Indexed once: a file can keep many objects, and a lookup per pointer would grow with + // their product. + let by_handle = self.object_index(); + let mut out = vec![root]; + let mut seen = BTreeSet::from([root]); + let mut i = 0; + while let Some(&h) = out.get(i) { + i += 1; + let Some(o) = by_handle.get(&h) else { continue }; + for c in o.tags.iter().filter(|t| matches!(t.code, 350 | 360)).filter_map(|t| Handle::parse_hex(t.value.trim())) { + if out.len() < 4096 && by_handle.contains_key(&c) && seen.insert(c) { + out.push(c); + } + } + } + out + } + + /// Copy the objects of `tree` (see [`Foreign::tree`]) of `src` into this one under the handles + /// `fresh` (the same order; two more follow for a field list), `owner` taking the place of the + /// root's owner. References to objects outside the tree become null. Fields keep a field + /// list: this one gets a copy of the source's if it has none. + pub fn adopt_tree(&mut self, src: &Foreign, tree: &[Handle], fresh: &[Handle], owner: Handle) { + if fresh.len() < tree.len() + 2 { + return; + } + let map: BTreeMap = tree.iter().copied().zip(fresh.iter().copied()).collect(); + let by_handle = src.object_index(); + let mut kinds = Vec::new(); + for (n, h) in tree.iter().enumerate() { + let Some(o) = by_handle.get(h) else { continue }; + let mut c = (*o).clone(); + let Some(&new) = fresh.get(n) else { continue }; + c.handle = new; + for t in &mut c.tags { + if t.code == 5 { + t.value = new.hex(); + } else if matches!(t.code, 320..=369 | 390..=399) { + let to = Handle::parse_hex(t.value.trim()).and_then(|h| map.get(&h).copied()).or((n == 0 && t.code == 330).then_some(owner)); + t.value = to.map_or_else(|| "0".to_string(), Handle::hex); + } + } + kinds.push(c.kind.clone()); + self.objects.push(c); + } + if let Some(list) = src.objects.iter().find(|o| o.kind == "FIELDLIST").filter(|_| !self.objects.iter().any(|o| o.kind == "FIELDLIST")) { + let (Some(&h), Some(&nod)) = (fresh.get(tree.len()), fresh.get(tree.len() + 1)) else { return }; + let mut c = list.clone(); + c.handle = h; + for t in &mut c.tags { + match t.code { + 5 => t.value = h.hex(), + 330 => t.value = "0".to_string(), + _ => {} + } + } + if let Some(t) = c.tags.iter_mut().find(|t| t.code == 330) { + t.value = nod.hex(); + } + self.targets.insert(nod, ("NOD".to_string(), String::new())); + self.dictionary.push(("ACAD_FIELDLIST".to_string(), h)); + kinds.push(c.kind.clone()); + self.objects.push(c); + } + for k in kinds { + let named = |c: &Vec| c.iter().any(|t| t.code == 1 && t.value.eq_ignore_ascii_case(&k)); + if !self.classes.iter().any(named) { + self.classes.extend(src.classes.iter().filter(|c| named(c)).cloned()); + } + } + } +} diff --git a/crates/engine/src/cmd/blocks/insert_file.rs b/crates/engine/src/cmd/blocks/insert_file.rs index 1a90502b5..a00dbc64c 100644 --- a/crates/engine/src/cmd/blocks/insert_file.rs +++ b/crates/engine/src/cmd/blocks/insert_file.rs @@ -109,7 +109,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result { nb.name = to.clone(); nb.entities = Default::default(); for e in b.entities.iter() { - let e = adopt(&mut d, e, &renames); + let e = adopt(&mut d, src, e, &renames); nb.entities.push(e); } d.blocks.insert(to.clone(), Arc::new(nb)); @@ -118,7 +118,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result { b.base = src.header.point("INSBASE").filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()).unwrap_or_default(); let mut ents = Vec::new(); for e in src.model.iter() { - let e = adopt(&mut d, e, &renames); + let e = adopt(&mut d, src, e, &renames); d.ensure_layer(&e.common.layer); ents.push(e); } @@ -183,9 +183,20 @@ fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap) -> Entity { +fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap) -> Entity { let mut e = e.clone(); + let old = e.handle; e.handle = d.new_handle(); + // An attribute definition keeps its field: its extension dictionary and the objects in it. + if matches!(e.kind, EntityKind::AttDef(_)) + && let Some(root) = src.foreign.entities.get(&old).and_then(|x| x.xdict) + { + let tree = src.foreign.tree(root); + let fresh: Vec = (0..tree.len() + 2).map(|_| d.new_handle()).collect(); + let foreign = Arc::make_mut(&mut d.foreign); + foreign.adopt_tree(&src.foreign, &tree, &fresh, e.handle); + foreign.entities.entry(e.handle).or_default().xdict = fresh.first().copied(); + } match &mut e.kind { EntityKind::Insert(i) => { if let Some(n) = renames.get(&i.block) { diff --git a/crates/engine/tests/insert_file_field.rs b/crates/engine/tests/insert_file_field.rs new file mode 100644 index 000000000..1365f88b5 --- /dev/null +++ b/crates/engine/tests/insert_file_field.rs @@ -0,0 +1,107 @@ +//! `INSERT name=file` keeps the field of an attribute definition: the definition's extension +//! dictionary and the objects in it come along under new handles, so the attributes the block's +//! references get can carry the field when the drawing is saved. + +use std::sync::Arc; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::foreign::{ForeignExtra, ForeignObject}; +use cadcraft_engine::doc::{Attrib, Drawing, Entity, EntityKind, Handle, RawTag, Text}; +use cadcraft_engine::geom::Vec3; +use serde_json::json; + +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +fn tag(code: i32, value: &str) -> RawTag { + RawTag { code, value: value.into() } +} + +fn object(handle: u64, kind: &str, tags: Vec) -> ForeignObject { + ForeignObject { handle: Handle(handle), kind: kind.into(), tags } +} + +/// A drawing file in a test format: model space holds a definition NUMBER (handle 0x50) whose +/// extension dictionary 0x60 owns the ACAD_FIELD dictionary 0x61, which owns the field 0x62 with +/// a child field 0x63. Handles 0x61..0x63 stand for the file's own; a second, unrelated object +/// 0x70 is not part of that tree. The bytes `plain` give the same definition without any of it. +fn read(bytes: &[u8], _name: &str) -> Result { + let mut d = Drawing::new_imperial(); + let text = Text { + insert: Vec3::ZERO, + align_pt: None, + height: 2.0, + value: "TEMPLATE".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: Default::default(), + valign: Default::default(), + }; + let def = Attrib { + tag: "NUMBER".into(), + text, + invisible: false, + constant: false, + prompt: String::new(), + props: Default::default(), + multiline: None, + modes: Default::default(), + }; + d.model.push(Entity::new(Handle(0x50), EntityKind::AttDef(def))); + if bytes == b"plain" { + return Ok(d); + } + let f = Arc::make_mut(&mut d.foreign); + f.entities.insert(Handle(0x50), ForeignExtra { xdict: Some(Handle(0x60)), ..Default::default() }); + f.objects.push(object(0x60, "DICTIONARY", vec![tag(5, "60"), tag(330, "50"), tag(3, "ACAD_FIELD"), tag(360, "61")])); + f.objects.push(object(0x61, "DICTIONARY", vec![tag(5, "61"), tag(330, "60"), tag(3, "TEXT"), tag(360, "62")])); + f.objects.push(object(0x62, "FIELD", vec![tag(5, "62"), tag(330, "61"), tag(1, "_text"), tag(360, "63")])); + f.objects.push(object(0x63, "FIELD", vec![tag(5, "63"), tag(330, "62"), tag(1, "AcVar")])); + f.objects.push(object(0x70, "XRECORD", vec![tag(5, "70"), tag(330, "0")])); + f.classes.push(vec![tag(1, "FIELD"), tag(2, "AcDbField")]); + Ok(d) +} + +fn hooks() { + set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); +} + +#[test] +fn the_definition_of_an_inserted_file_keeps_its_field() { + hooks(); + let dir = TempDir::new("insert-file-field"); + let path = dir.file("border.dxf", ""); + let mut s = Session::new(); + s.execute("insert", &json!({ "name": "BORDER", "file": path, "at": [0, 0], "attribs": { "NUMBER": "A-1" } })).unwrap(); + let d = s.doc().unwrap(); + let def = d.block("BORDER").and_then(|b| b.entities.iter().find(|e| matches!(e.kind, EntityKind::AttDef(_)))).expect("the definition"); + assert_ne!(def.handle, Handle(0x50), "the definition has a handle of this drawing"); + let root = d.foreign.entities.get(&def.handle).and_then(|x| x.xdict).expect("the definition has an extension dictionary"); + let tree = d.foreign.tree(root); + assert_eq!(tree.len(), 4, "the dictionaries and both fields, not the unrelated object"); + assert!(tree.iter().all(|h| ![0x60, 0x61, 0x62, 0x63, 0x70].contains(&h.0)), "the copies have new handles"); + // The root's owner is the definition, the others point at each other. + let owner = + |h: Handle| d.foreign.objects.iter().find(|o| o.handle == h).and_then(|o| o.tags.iter().find(|t| t.code == 330)).map(|t| t.value.clone()); + assert_eq!(owner(tree[0]), Some(def.handle.hex())); + assert_eq!(owner(tree[1]), Some(tree[0].hex())); + assert_eq!(owner(tree[3]), Some(tree[2].hex())); + assert!(d.foreign.classes.iter().any(|c| c.iter().any(|t| t.code == 1 && t.value == "FIELD")), "the field class comes along"); +} + +#[test] +fn a_definition_without_a_field_gets_none() { + hooks(); + let dir = TempDir::new("insert-file-nofield"); + let path = dir.file("plain.dxf", "plain"); + let mut s = Session::new(); + s.execute("insert", &json!({ "name": "PLAIN", "file": path, "at": [0, 0] })).unwrap(); + let d = s.doc().unwrap(); + assert!(d.block("PLAIN").is_some()); + assert!(d.foreign.is_empty(), "nothing is kept for a drawing without fields"); +} From ebc67fa81dccb0d89c157babb27e7d5c3a5c1177 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:31:22 +0700 Subject: [PATCH 096/151] Dynamic blocks: stretch the points inside the frame, as AutoCAD does (#2627) * Dynamic blocks: stretch the points inside the frame, as AutoCAD does A dynamic block made by a third-party tool came out distorted when a distance changed: its stretch action has distance multiplier 0 with angle offset 1, and stored vertex lists that don't match its objects. apply turned the 0 into 1 but kept the angle (a 57 degree slant), and moved exactly the listed points. AutoCAD 2024, on a copy of that drawing, moved the block straight along the parameter, and every point of its lines and polylines exactly when it was inside the frame, whatever the lists said. So: - a zero multiplier moves as 1, without its angle offset; - a line's or lightweight polyline's moving points are those inside the frame where the dynamic block has them; a selected one wholly outside the frame moves whole, as in AutoCAD's own stretched copies; - a frame of fewer than 2 or more than 1,000 points keeps the stored lists, so a huge frame can't make every point test every edge. Against AutoCAD's own anonymous copies in two library drawings (556 references set back to their block's distances), 249 come back exactly on main and 258 with this change; none stops matching. Co-Authored-By: Claude Opus 5.5 * Dynamic blocks: a point on a polygon frame's edge is inside it in_frame counted a point on a two-corner frame's edge as inside, but the even-odd test of a polygon frame put one on its right edge outside. A line whose end lay there, with its other end outside, then had no point inside the frame and moved whole instead of its end alone. A polygon frame now counts a point on an edge (within 1e-9, relative to the edge's length) as inside too, as the two-corner frame does. Co-Authored-By: Claude Opus 5.5 * Dynamic blocks: frame edge tolerance by the edge's length; tilted polylines keep their lists A point counted as on a polygon frame's edge within 1e-9 * (1 + the edge's length), so never less than 1e-9 drawing units: in a tiny frame, a point visibly outside was taken as inside. The tolerance is now a billionth of the edge's length. A lightweight polyline's vertices are in its own plane. The reader flips a mirrored one (extrusion along -z) into the block's plane, but leaves a tilted one's in its OCS, which the frame (block coordinates) can't be tested against. A tilted polyline keeps the stored vertex list, as before the frame test. Co-Authored-By: Claude Opus 5.5 * dynblock: only an exactly untilted polyline is tested against the frame; a tilted one keeps its stored list Co-Authored-By: Claude Opus 5.5 * fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/dynblock.rs | 4 +- crates/doc/src/dynblock/apply.rs | 38 ++++++- crates/io/tests/it/dynblock_read.rs | 154 ++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index ef0a88be2..392861388 100644 --- a/crates/doc/src/dynblock.rs +++ b/crates/doc/src/dynblock.rs @@ -194,7 +194,9 @@ pub struct Action { #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] pub enum ActionKind { - /// Moves the vertices inside `frame` of the objects listed (all of an object not listed). + /// Moves the points of its objects inside `frame`: a line's or polyline's points inside it + /// (the whole of one outside it), any other object whole. `vertices` are the point indices the + /// file lists for each object, used only when the frame can't be tested (see `apply`). Stretch { frame: Vec, #[serde(serialize_with = "hex_vertices")] diff --git a/crates/doc/src/dynblock/apply.rs b/crates/doc/src/dynblock/apply.rs index ed5281bf6..d5651faac 100644 --- a/crates/doc/src/dynblock/apply.rs +++ b/crates/doc/src/dynblock/apply.rs @@ -403,7 +403,20 @@ fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) { } } -/// Whether point `p` lies inside a stretch frame (two corners, or a polygon). +/// Most points of a stretch frame whose objects' points are tested against it one by one. A frame +/// is two corners or a short polygon; testing every point of every object against every edge of a +/// longer one would grow with their product, so the action's own vertex lists are used instead. +const MAX_FRAME_POINTS: usize = 1000; + +/// The indices of `points` inside stretch frame `frame`, or `None` when none is: an object the +/// action selects that lies outside its frame moves whole. +fn inside(frame: &[Vec2], points: impl Iterator) -> Option> { + let idx: Vec = points.enumerate().filter(|(_, p)| in_frame(frame, *p)).filter_map(|(i, _)| i32::try_from(i).ok()).collect(); + (!idx.is_empty()).then_some(idx) +} + +/// Whether point `p` lies inside a stretch frame (two corners, or a polygon). A point on its edge is +/// inside, for a polygon as for two corners: within a billionth of the edge's length of it. fn in_frame(frame: &[Vec2], p: Vec2) -> bool { match frame { [a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y), @@ -412,6 +425,10 @@ fn in_frame(frame: &[Vec2], p: Vec2) -> bool { let mut j = f.len() - 1; for i in 0..f.len() { let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break }; + let edge = cadcraft_geom::Line { a: *pj, b: *pi }; + if edge.dist(p) <= 1e-9 * pi.dist(*pj) { + return true; + } if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x { inside = !inside; } @@ -464,7 +481,10 @@ fn apply( _ => 0.0, }) }; - let mult = if mult == 0.0 { 1.0 } else { mult }; + // A zero multiplier would move nothing. AutoCAD 2024 moves such an action by the + // parameter's change and leaves its angle offset out (measured on a stretch action written + // with multiplier 0, angle offset 1 and group 280 = 1). + let (mult, angle) = if mult == 0.0 { (1.0, 0.0) } else { (mult, angle) }; let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult; if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) { continue; @@ -473,6 +493,8 @@ fn apply( // this action's selection, so a selection that repeats a handle does not re-scan the // vertex list and re-clone its index vector per occurrence (CWE-400). let vtx_by_handle: HashMap> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect(); + // Whether the frame (two corners or a polygon) is tested point by point. + let framed = (2..=MAX_FRAME_POINTS).contains(&frame.len()); let mut handled: HashSet = HashSet::new(); for h in &a.selection { if !handled.insert(*h) { @@ -502,7 +524,17 @@ fn apply( continue; } let Some(c) = copy_of(h) else { continue }; - let idx = vtx_by_handle.get(h).map(|i| (*i).clone()); + // The points of a line or polyline that move are those inside the frame where the + // dynamic block has them, all of them when none is, as AutoCAD 2024 moves them. It + // doesn't go by the vertex lists the action stores, which a block from another program + // can get wrong. A polyline's vertices are in its own plane: in the block's when its + // extrusion is along z (a mirrored one is read flipped into it), so a tilted one keeps + // the stored list. + let idx = match def.entities.get(*h).map(|e| (&e.kind, e.common.extrusion)) { + Some((EntityKind::Line(l), _)) if framed => inside(frame, [l.a.xy(), l.b.xy()].into_iter()), + Some((EntityKind::LwPolyline(pl), n)) if framed && n.x == 0.0 && n.y == 0.0 => inside(frame, pl.vertices.iter().map(|v| v.p)), + _ => vtx_by_handle.get(h).map(|i| (*i).clone()), + }; moves.entry(c).or_default().push((idx, v)); } } diff --git a/crates/io/tests/it/dynblock_read.rs b/crates/io/tests/it/dynblock_read.rs index d265f9e70..bd9f2d1e1 100644 --- a/crates/io/tests/it/dynblock_read.rs +++ b/crates/io/tests/it/dynblock_read.rs @@ -406,6 +406,160 @@ fn parameters_in_a_stretch_selection_are_carried_along() { assert_eq!(tags[grip + 1..grip + 3], [(300, "GRIPLOC".to_string()), (11, "-15".to_string())]); } +/// A reference to Door at the origin with Door's own values, in a drawing read from `src`. +fn door_reference(src: &str) -> (cadcraft_doc::Drawing, Handle) { + let mut d = read_dxf(dxf(src).as_bytes()).unwrap(); + let ins = cadcraft_doc::Insert { + block: "Door".into(), + insert: cadcraft_doc::geom::Vec3::new(0.0, 0.0, 0.0), + scale: cadcraft_doc::geom::Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let r = d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap(); + (d, r) +} + +/// The points of the lines and polylines of reference `r`'s block, in its order. +fn shapes(d: &cadcraft_doc::Drawing, r: Handle) -> Vec> { + let b = d.block(&block_of(d, &r.hex())).unwrap(); + b.entities + .iter() + .filter_map(|e| match &e.kind { + cadcraft_doc::EntityKind::Line(l) => Some(vec![(l.a.x, l.a.y), (l.b.x, l.b.y)]), + cadcraft_doc::EntityKind::LwPolyline(p) => Some(p.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()), + _ => None, + }) + .collect() +} + +#[test] +fn a_stretch_moves_the_points_inside_its_frame_not_the_listed_ones() { + // The stretch (frame (34,-2)-(38,2)) also acts on line 32, which is all outside the frame, and + // on a polyline whose last two vertices are inside it; and the vertex lists it stores are + // wrong for all three. AutoCAD 2024 moves the points inside the frame, measured on a dynamic + // block from another program whose lists didn't match its objects: every point of its lines and + // polylines moved exactly when it was inside the frame, whatever the lists said. A selected + // line or polyline all outside the frame moves whole, as in AutoCAD's own stretched copies. + let polyline = "0 LWPOLYLINE\n5 33\n330 1D\n100 AcDbEntity\n8 0\n100 AcDbPolyline\n90 3\n70 0\n10 20\n20 1\n10 36\n20 1\n10 36\n20 -1\n"; + let src = fixture() + .replace("71 1\n330 30\n1010 40", "71 3\n330 30\n330 32\n330 33\n1010 40") + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 3\n331 30\n74 1\n94 0\n331 32\n74 1\n94 1\n331 33\n74 1\n94 0\n75 0") + .replace("21 25\n31 0\n0 ENDBLK\n", &format!("21 25\n31 0\n{polyline}0 ENDBLK\n")); + let (mut d, r) = door_reference(&src); + let ActionKind::Stretch { vertices, .. } = &dynamic_block(&d, "Door").unwrap().actions[0].kind else { panic!("a stretch action") }; + assert_eq!(vertices, &[(h("30"), vec![0]), (h("32"), vec![1]), (h("33"), vec![0])]); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + let want: Vec> = vec![ + vec![(0.0, 0.0), (30.0, 0.0)], + vec![(0.0, 0.0), (0.0, 36.0)], + vec![(-6.0, 0.0), (19.0, 25.0)], + vec![(20.0, 1.0), (30.0, 1.0), (30.0, -1.0)], + ]; + assert_eq!(shapes(&d, r), want); +} + +#[test] +fn a_zero_distance_multiplier_moves_as_one_without_its_angle_offset() { + // A stretch written with multiplier 0 and angle offset 1 (radian), and group 280 = 1. AutoCAD + // 2024 moved such an action by the parameter's change, straight along it. + let src = fixture().replace("140 1\n141 0\n280 0", "140 0\n141 1\n280 1"); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + let ActionKind::Stretch { multiplier, angle_offset, .. } = &dynamic_block(&d, "Door").unwrap().actions[0].kind else { + panic!("a stretch action") + }; + assert_eq!((*multiplier, *angle_offset), (0.0, 1.0)); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(0.0, 0.0), (30.0, 0.0)]); +} + +#[test] +fn a_point_on_the_edge_of_a_polygon_frame_is_inside_it() { + // Line 30 ends at (36, 0), and its start is outside every frame here. On the edge of the frame + // (34,-2)-(36,2) given as two corners, the end is inside and moves alone. The same frame as a + // polygon, and triangles whose slanted edge passes through (36, 0), exactly or up to rounding, + // take it the same way. + let frames = [ + "72 2\n1011 34\n1021 -2\n1011 36\n1021 2\n", + "72 4\n1011 34\n1021 -2\n1011 36\n1021 -2\n1011 36\n1021 2\n1011 34\n1021 2\n", + "72 3\n1011 34\n1021 -2\n1011 38\n1021 2\n1011 34\n1021 2\n", + "72 3\n1011 35.7\n1021 -0.3\n1011 36.7\n1021 0.7\n1011 30\n1021 10\n", + ]; + for frame in frames { + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(0.0, 0.0), (30.0, 0.0)], "{frame}"); + } +} + +#[test] +fn a_point_just_outside_a_tiny_frame_is_outside_it() { + // A frame 1e-8 across whose right edge is 5e-10 short of line 30's end (36, 0): the end is + // outside, so the line, with no point inside, moves whole. An edge's tolerance is a billionth of + // its length, not of a drawing unit. + let frame = "72 4\n1011 35.99999999\n1021 -1e-8\n1011 35.9999999995\n1021 -1e-8\n1011 35.9999999995\n1021 1e-8\n1011 35.99999999\n1021 1e-8\n"; + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(-6.0, 0.0), (30.0, 0.0)]); +} + +#[test] +fn a_tilted_polyline_keeps_the_stored_vertex_list() { + // Polyline 34 lies in a plane tilted about the y axis (extrusion (1,0,0)): its vertices are in + // that plane's coordinates, not the block's, so the frame isn't tested against them and the + // stored list (vertex 0) is used, as before. Polyline 35 is mirrored (extrusion (0,0,-1)): it + // is read flipped into the block's plane, (-20, 1) as (20, 1), so the frame is tested. + let polyline = |h: &str, [x0, x1]: [i32; 2], [ex, ey, ez]: [i32; 3]| { + format!( + "0 LWPOLYLINE\n5 {h}\n330 1D\n100 AcDbEntity\n8 0\n100 AcDbPolyline\n90 2\n70 0\n10 {x0}\n20 1\n10 {x1}\n20 1\n210 {ex}\n220 {ey}\n230 {ez}\n" + ) + }; + let src = fixture() + .replace("71 1\n330 30\n1010 40", "71 3\n330 30\n330 34\n330 35\n1010 40") + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 3\n331 30\n74 1\n94 1\n331 34\n74 1\n94 0\n331 35\n74 1\n94 0\n75 0") + .replace( + "21 25\n31 0\n0 ENDBLK\n", + &format!("21 25\n31 0\n{}{}0 ENDBLK\n", polyline("34", [20, 36], [1, 0, 0]), polyline("35", [-20, -36], [0, 0, -1])), + ); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + let s = shapes(&d, r); + assert_eq!(s[3], [(14.0, 1.0), (36.0, 1.0)]); + assert_eq!(s[4], [(20.0, 1.0), (30.0, 1.0)]); +} + +#[test] +fn a_frame_with_too_many_points_to_test_one_by_one_keeps_the_stored_lists() { + // The same frame as a polygon of `n` points (its four corners, the last repeated). Up to 1000 + // points each point of the objects is tested against it; past that the stored list (here the + // line's start, outside the frame) is used, so a huge frame doesn't make every point test every + // edge. + let frame = |n: usize| { + let mut f = format!("72 {n}\n1011 34\n1021 -2\n1011 38\n1021 -2\n1011 38\n1021 2\n"); + f.push_str(&"1011 34\n1021 2\n".repeat(n - 3)); + f + }; + for (n, want) in [(1000, [(0.0, 0.0), (30.0, 0.0)]), (1001, [(-6.0, 0.0), (36.0, 0.0)])] { + let src = fixture() + .replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", &frame(n)) + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 1\n331 30\n74 1\n94 0\n75 0"); + let (mut d, r) = door_reference(&src); + let ActionKind::Stretch { frame, .. } = &dynamic_block(&d, "Door").unwrap().actions[0].kind else { panic!("a stretch action") }; + assert_eq!(frame.len(), n); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], want, "{n} frame points"); + } +} + /// The object entry `key` of dictionary `dict` refers to. fn dict_entry(d: &cadcraft_doc::Drawing, dict: Handle, key: &str) -> Option { let o = d.foreign.objects.iter().find(|o| o.handle == dict)?; From 3146b4765665eafe0e4c7d1c8d47a367140468c7 Mon Sep 17 00:00:00 2001 From: austin62jake Date: Mon, 2 Nov 2026 15:25:41 -0600 Subject: [PATCH 097/151] fix(engine): WBLOCK writes only what the block or selection uses (#2423) (#2621) * fix(engine): WBLOCK writes only what the block or selection uses (#2423) WBLOCK of a block, or of selected objects, copied the source drawing's whole layer, linetype, style and block tables into the new file, so a 5-object symbol carried 40 unused layers and 20 blocks. The written drawing is now purged of what nothing in it uses, as AutoCAD's WBLOCK writes it: purge::purge_unused runs PURGE All's scan on a plain Drawing until a pass removes nothing (a block can free its layer), keeping the defaults and the current ones. Writing the entire drawing is unchanged. Co-Authored-By: Claude Opus 5.5 * fix(engine): PURGE's scan counts leaders' dimension styles and styles used by other styles From the review of #2621: PURGE (and now WBLOCK, which runs its scan) took as unused a dimension style only a LEADER used, a linetype only a dimension style or a dimension's override named (DIMLTYPE, DIMLTEX1, DIMLTEX2), and a text style only a complex linetype's text used, and removed them while the objects still named them. The scan counts all three now; text_styles_in_use, which STYLE's delete also asks, includes complex linetypes' text styles. Co-Authored-By: Claude Opus 5.5 * fix(engine): PURGE's scan counts multiline styles' linetypes and table styles' text styles From the review of #2621: a linetype only a multiline style's element used, and a text style only a table style's cell style used, were taken as unused and purged while the styles still named them. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks/purge.rs | 52 +++++++- crates/engine/src/cmd/blocks/wblock.rs | 5 +- crates/engine/src/cmd/props.rs | 5 +- crates/engine/tests/wblock_purge.rs | 165 +++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/wblock_purge.rs diff --git a/crates/engine/src/cmd/blocks/purge.rs b/crates/engine/src/cmd/blocks/purge.rs index 68aea3c36..b91507b15 100644 --- a/crates/engine/src/cmd/blocks/purge.rs +++ b/crates/engine/src/cmd/blocks/purge.rs @@ -197,7 +197,21 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec { .collect() } Kind::Linetypes => { - let used = lower(&mut entities(d).map(|e| e.common.linetype.clone()).chain(d.layers.iter().map(|l| l.linetype.clone()))); + // Objects and layers use linetypes, and so do dimension styles and a dimension's own + // overrides (DIMLTYPE, DIMLTEX1, DIMLTEX2), and multiline styles' elements. + let styles = d.dim_styles.iter().flat_map(|s| [s.dim_linetype.clone(), s.ext_linetype1.clone(), s.ext_linetype2.clone()]); + let overrides = entities(d).filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm) } else { None }).flat_map(|dm| { + ["dimLinetype", "extLinetype1", "extLinetype2"].into_iter().filter_map(|k| dm.overrides.get(k)?.as_str().map(str::to_string)) + }); + let mlines = d.mline_styles.iter().flat_map(|s| s.elements.iter().map(|el| el.linetype.clone())); + let used = lower( + &mut entities(d) + .map(|e| e.common.linetype.clone()) + .chain(d.layers.iter().map(|l| l.linetype.clone())) + .chain(styles) + .chain(overrides) + .chain(mlines), + ); let cur = d.header.str("CELTYPE", "ByLayer"); d.linetypes .iter() @@ -218,6 +232,7 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec { let used = lower(&mut entities(d).filter_map(|e| match &e.kind { EntityKind::Dimension(dm) => Some(dm.style.clone()), EntityKind::Tolerance(t) => Some(t.style.clone()), + EntityKind::Leader(l) => Some(l.style.clone()), _ => None, })); let cur = d.header.str("DIMSTYLE", "Standard"); @@ -299,6 +314,41 @@ fn retain(v: &mut Vec, name: impl Fn(&T) -> &String, n: &str) -> bool { v.len() != before } +/// Most passes [`purge_unused`] makes: each can free what the last one's objects used (a nested +/// block, its layer, that layer's linetype). +const MAX_PASSES: usize = 64; + +/// Remove every named object drawing `d` doesn't use, as PURGE All would, nested ones included; +/// the defaults and the current ones stay. For a drawing being written (WBLOCK's), not the one +/// being edited: nothing goes through the session or its undo. +pub(in crate::cmd) fn purge_unused(d: &mut Drawing) { + for _ in 0..MAX_PASSES { + let mut removed = false; + for kind in ALL { + for name in candidates(d, *kind) { + removed |= match kind { + Kind::Blocks => { + let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&name)).cloned(); + key.is_some_and(|k| d.blocks.remove(&k).is_some()) + } + Kind::Layers => retain(&mut d.layers, |l| &l.name, &name), + Kind::TextStyles => retain(&mut d.text_styles, |t| &t.name, &name), + Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, &name), + Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, &name), + Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, &name), + Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, &name), + Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, &name), + Kind::Groups => retain(&mut d.groups, |g| &g.name, &name), + Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false, + }; + } + } + if !removed { + break; + } + } +} + /// Model-space and layout objects with no length, or text with no characters. fn degenerate(d: &Drawing, kind: Kind) -> Vec { let zero = |e: &EntityKind| match e { diff --git a/crates/engine/src/cmd/blocks/wblock.rs b/crates/engine/src/cmd/blocks/wblock.rs index 98ab0c344..bc2ff711a 100644 --- a/crates/engine/src/cmd/blocks/wblock.rs +++ b/crates/engine/src/cmd/blocks/wblock.rs @@ -97,7 +97,9 @@ fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usi Ok((d.model.len(), bytes.len())) } -/// A new drawing of `ents`, with `src`'s settings, styles and blocks; its insertion base 0,0. +/// A new drawing of `ents`, with `src`'s settings and the layers, linetypes, styles and blocks +/// they use, as AutoCAD's WBLOCK writes them (#2423): what nothing in it uses is purged, and +/// the defaults and the current ones stay. Its insertion base is 0,0. fn with_objects(src: &Drawing, ents: impl Iterator) -> Drawing { let mut d = Drawing::new_imperial(); d.header = src.header.clone(); @@ -115,6 +117,7 @@ fn with_objects(src: &Drawing, ents: impl Iterator) -> Drawing { e.handle = d.new_handle(); d.model.push(e); } + super::purge::purge_unused(&mut d); d } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 8afaddb3d..018e2dbf6 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -765,10 +765,13 @@ fn run_style_current(s: &mut Session, p: &Value) -> Result { ok() } -/// Text style names used by entities (in model, paper spaces and blocks) and dimension styles. +/// Text style names used by entities (in model, paper spaces and blocks), dimension, +/// multileader and table styles (their cell styles), and the text of complex linetypes. pub(super) fn text_styles_in_use(d: &cadcraft_doc::Drawing) -> Vec { let mut out: Vec = d.dim_styles.iter().map(|s| s.text_style.clone()).collect(); out.extend(d.mleader_styles.iter().map(|s| s.text_style.clone())); + out.extend(d.table_styles.iter().flat_map(|s| s.cell_styles.iter().map(|c| c.text_style.clone()))); + out.extend(d.linetypes.iter().flat_map(|lt| lt.pattern.iter().filter_map(|el| el.style.clone()))); let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); for st in stores { for e in st.iter() { diff --git a/crates/engine/tests/wblock_purge.rs b/crates/engine/tests/wblock_purge.rs new file mode 100644 index 000000000..3fc3bad5e --- /dev/null +++ b/crates/engine/tests/wblock_purge.rs @@ -0,0 +1,165 @@ +//! WBLOCK writes what the block or the selection uses, not the source drawing's whole tables +//! (#2423): AutoCAD's WBLOCK of a 5-object symbol writes its layer, its style and the defaults, +//! not the other 40 layers and 20 blocks of the drawing it came from. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use serde_json::{Value, json}; + +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +/// Writes the written drawing's table names, one table a line: layers, text styles, blocks, +/// dimension styles, linetypes. One hook for every test here: `set_io` is process-wide and the +/// tests run in parallel. +fn write(d: &Drawing, _path: &str) -> Result, String> { + let mut blocks: Vec<&str> = d.blocks.values().map(|b| b.name.as_str()).collect(); + blocks.sort_unstable(); + let layers: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect(); + let styles: Vec<&str> = d.text_styles.iter().map(|t| t.name.as_str()).collect(); + let dims: Vec<&str> = d.dim_styles.iter().map(|t| t.name.as_str()).collect(); + let linetypes: Vec<&str> = d.linetypes.iter().map(|t| t.name.as_str()).collect(); + Ok(format!("{}\n{}\n{}\n{}\n{}", layers.join(","), styles.join(","), blocks.join(","), dims.join(","), linetypes.join(",")).into_bytes()) +} + +/// The layers, text styles and blocks of the file WBLOCK writes with `p`. +fn written(s: &mut Session, mut p: Value) -> (String, String, String) { + let dir = TempDir::new("wblock-purge"); + let path = dir.path_str("out.dxf"); + p["path"] = json!(path); + s.execute("wblock", &p).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let mut lines = text.lines().map(str::to_string); + (lines.next().unwrap_or_default(), lines.next().unwrap_or_default(), lines.next().unwrap_or_default()) +} + +fn line_on(s: &mut Session, layer: &str, y: f64) -> String { + let h = s.execute("line", &json!({ "points": [[0, y], [5, y]] })).unwrap()["handles"][0].as_str().unwrap().to_string(); + s.execute("properties.set", &json!({ "handles": [h], "layer": layer })).unwrap(); + h +} + +/// Block SYM: a line on SYM-LAYER and a reference to INNER, whose line is on INNER-LAYER. The +/// drawing also has a layer, a text style and a block that SYM doesn't use. +fn drawing() -> Session { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + for name in ["SYM-LAYER", "INNER-LAYER", "UNUSED-LAYER", "OTHER-LAYER"] { + s.execute("layer.new", &json!({ "name": name })).unwrap(); + } + s.execute("style", &json!({ "name": "UNUSED-STYLE" })).unwrap(); + s.execute("style.current", &json!({ "name": "Standard" })).unwrap(); + let inner = line_on(&mut s, "INNER-LAYER", 0.0); + s.execute("block", &json!({ "name": "INNER", "base": [0, 0], "handles": [inner], "keep": "delete" })).unwrap(); + let r = s.execute("insert", &json!({ "name": "INNER", "at": [0, 5] })).unwrap(); + let inner_ref = r["handle"].as_str().unwrap().to_string(); + let own = line_on(&mut s, "SYM-LAYER", 10.0); + s.execute("block", &json!({ "name": "SYM", "base": [0, 0], "handles": [own, inner_ref], "keep": "delete" })).unwrap(); + let other = line_on(&mut s, "OTHER-LAYER", 20.0); + s.execute("block", &json!({ "name": "OTHER", "base": [0, 0], "handles": [other], "keep": "delete" })).unwrap(); + s +} + +#[test] +fn a_block_is_written_with_what_it_uses() { + let mut s = drawing(); + let (layers, styles, blocks) = written(&mut s, json!({ "name": "SYM" })); + let layers: Vec<&str> = layers.split(',').collect(); + for used in ["0", "SYM-LAYER", "INNER-LAYER"] { + assert!(layers.contains(&used), "{used} in {layers:?}"); + } + for unused in ["UNUSED-LAYER", "OTHER-LAYER"] { + assert!(!layers.contains(&unused), "{unused} in {layers:?}"); + } + assert!(!styles.split(',').any(|n| n == "UNUSED-STYLE"), "{styles}"); + assert!(styles.split(',').any(|n| n == "Standard"), "{styles}"); + // The nested block comes along; the block being written is the file itself, and OTHER stays out. + assert_eq!(blocks, "INNER"); + // The source drawing is untouched. + let d = s.doc().unwrap(); + assert!(d.layer("UNUSED-LAYER").is_some() && d.block("OTHER").is_some()); +} + +#[test] +fn selected_objects_are_written_with_what_they_use() { + let mut s = drawing(); + let r = s.execute("insert", &json!({ "name": "INNER", "at": [50, 50] })).unwrap(); + let h = r["handle"].as_str().unwrap().to_string(); + let (layers, _, blocks) = written(&mut s, json!({ "handles": [h] })); + assert_eq!(blocks, "INNER", "only the block the selection references"); + let layers: Vec<&str> = layers.split(',').collect(); + assert!(layers.contains(&"INNER-LAYER") && !layers.contains(&"SYM-LAYER"), "{layers:?}"); +} + +/// What the block uses through its styles stays too: a leader's dimension style, the linetype a +/// dimension style draws its dimension line with, and the text style of a complex linetype's text. +#[test] +fn styles_used_through_other_styles_stay() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + s.execute("style", &json!({ "name": "LTEXT" })).unwrap(); + s.execute("style.current", &json!({ "name": "Standard" })).unwrap(); + let lin = "*TXT,Text --T--\nA,.5,-.2,[\"T\",LTEXT,S=.1,X=-.1,Y=-.05],-.2\n"; + s.execute("linetype", &json!({ "load": "TXT", "text": lin })).unwrap(); + s.execute("linetype", &json!({ "load": "DASHED" })).unwrap(); + s.execute("dimstyle", &json!({ "name": "LDR", "current": true })).unwrap(); + let leader = s.execute("leader", &json!({ "points": [[0, 0], [5, 5]], "text": "NOTE" })).unwrap()["handles"].clone(); + s.execute("dimstyle", &json!({ "name": "DS", "dimLinetype": "DASHED", "current": true })).unwrap(); + let dim = s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, -3] })).unwrap()["handle"].clone(); + s.execute("dimstyle", &json!({ "name": "Standard", "current": true })).unwrap(); + let line = s.execute("line", &json!({ "points": [[0, 8], [9, 8]] })).unwrap()["handles"][0].clone(); + s.execute("properties.set", &json!({ "handles": [line], "linetype": "TXT" })).unwrap(); + let mut handles: Vec = leader.as_array().cloned().unwrap_or_default(); + handles.extend([dim, line]); + s.execute("block", &json!({ "name": "SYM", "base": [0, 0], "handles": handles, "keep": "delete" })).unwrap(); + let dir = TempDir::new("wblock-purge-styles"); + let path = dir.path_str("out.dxf"); + s.execute("wblock", &json!({ "name": "SYM", "path": path })).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let table = |n: usize| text.lines().nth(n).unwrap_or("").split(',').map(str::to_string).collect::>(); + let (styles, dims, linetypes) = (table(1), table(3), table(4)); + for kept in ["LDR", "DS"] { + assert!(dims.iter().any(|n| n == kept), "dimension style {kept} kept: {dims:?}"); + } + for kept in ["DASHED", "TXT"] { + assert!(linetypes.iter().any(|n| n == kept), "linetype {kept} kept: {linetypes:?}"); + } + assert!(styles.iter().any(|n| n == "LTEXT"), "text style LTEXT kept: {styles:?}"); +} + +/// PURGE keeps a dimension style a leader uses (it only counted dimensions and tolerances). +#[test] +fn purge_keeps_a_leaders_dimension_style() { + let mut s = Session::new(); + s.execute("dimstyle", &json!({ "name": "LDR", "current": true })).unwrap(); + s.execute("leader", &json!({ "points": [[0, 0], [5, 5]], "text": "NOTE" })).unwrap(); + s.execute("dimstyle", &json!({ "name": "Standard", "current": true })).unwrap(); + s.execute("purge", &json!({ "type": "all" })).unwrap(); + assert!(s.doc().unwrap().dim_style("LDR").is_some(), "the leader's style stays"); +} + +/// PURGE keeps a linetype only a multiline style's element uses, and a text style only a table +/// style's cell style uses (from the review of #2621). +#[test] +fn purge_keeps_styles_used_by_multiline_and_table_styles() { + use cadcraft_engine::doc::{CellStyle, MLineElement, MLineStyle, TableStyle}; + let mut s = Session::new(); + s.execute("linetype", &json!({ "load": "DASHED" })).unwrap(); + s.execute("style", &json!({ "name": "CELLTEXT" })).unwrap(); + s.execute("style.current", &json!({ "name": "Standard" })).unwrap(); + let d = s.doc_mut().unwrap(); + let element = MLineElement { linetype: "DASHED".into(), ..MLineElement::default() }; + d.mline_styles.push(MLineStyle { name: "ML".into(), elements: vec![element], ..MLineStyle::default() }); + let cell = CellStyle { text_style: "CELLTEXT".into(), ..CellStyle::default() }; + d.table_styles.push(TableStyle { name: "TS".into(), cell_styles: vec![cell], ..TableStyle::default() }); + // Both styles are made current, so PURGE keeps them; what they use must stay with them. + d.header.set_str("CMLSTYLE", "ML"); + d.header.set_str("CTABLESTYLE", "TS"); + s.execute("purge", &json!({ "type": "all" })).unwrap(); + let d = s.doc().unwrap(); + assert!(d.linetype("DASHED").is_some(), "the multiline style's linetype stays"); + assert!(d.text_style("CELLTEXT").is_some(), "the table style's text style stays"); +} From a277346c2f31385e7a76ace2952641df33d03df0 Mon Sep 17 00:00:00 2001 From: austin62jake Date: Mon, 2 Nov 2026 15:25:46 -0600 Subject: [PATCH 098/151] fix(io): a viewport looks at its view target plus its centre (#2492) (#2508) * fix(io): a viewport looks at its view target plus its centre (#2492) A DXF stores a view's centre in display coordinates, whose origin is the view target (DXF Reference: VIEWPORT 12/22 and 17/27/37, VPORT 12/22 and 17/27/37, VIEW 10/20 and 12/22/32). The readers took the centre alone, so a file that keeps the location in the target with a centre of 0,0 (as ArchiCAD's AutoCAD 2018 export does) showed model point 0,0 in every layout viewport, on screen and when plotted. The VIEWPORT entity, VPORT tiles and named views now read the target plus the centre. A sum that overflows keeps the stored centre. The writers already store the whole location as the centre with a target of 0, so a save and reopen keeps the view, through DXF and DWG. Co-Authored-By: Claude Opus 5.5 * test(io): a viewport looking along another direction centres on its target Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_read/common.rs | 9 ++ crates/io/src/dxf_read/entities.rs | 3 +- crates/io/src/dxf_read/tables.rs | 6 +- crates/io/tests/it/main.rs | 1 + crates/io/tests/it/view_target.rs | 181 +++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 crates/io/tests/it/view_target.rs diff --git a/crates/io/src/dxf_read/common.rs b/crates/io/src/dxf_read/common.rs index e46f3c09d..1dbabf6e9 100644 --- a/crates/io/src/dxf_read/common.rs +++ b/crates/io/src/dxf_read/common.rs @@ -19,6 +19,15 @@ impl T<'_> { pub(super) fn p(&self, code: i32) -> Vec3 { Vec3::new(self.fd(code, 0.0), self.fd(code + 10, 0.0), self.fd(code + 20, 0.0)) } + /// A view's centre in the drawing. The DXF stores it in display coordinates (`center`), whose + /// origin is the view target (`target`), so the point in the middle of a plan view is their + /// sum: a file whose view centre is 0,0 keeps the location in the target (#2492). A sum that + /// overflows leaves the stored centre. + pub(super) fn view_center(&self, center: i32, target: i32) -> Vec2 { + let c = self.p(center).xy(); + let sum = c + self.p(target).xy(); + if sum.is_finite() { sum } else { c } + } pub(super) fn all_f(&self, code: i32) -> Vec { self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect() } diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index 119d00bbd..980b1718c 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -246,7 +246,8 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef center: t.p(10), width: t.fd(40, 1.0), height: t.fd(41, 1.0), - view_center: Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)), + // 12/22 is in display coordinates, whose origin is the view target 17/27/37. + view_center: t.view_center(12, 17), view_height: t.fd(45, 1.0), id: t.i(69).unwrap_or(0) as u32, locked: t.i(90).unwrap_or(0) & 16384 != 0, diff --git a/crates/io/src/dxf_read/tables.rs b/crates/io/src/dxf_read/tables.rs index ee42b8a58..c914ea9f5 100644 --- a/crates/io/src/dxf_read/tables.rs +++ b/crates/io/src/dxf_read/tables.rs @@ -217,7 +217,8 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { continue; } - let center = t.p(10).xy(); + // The view centre 10/20 is in display coordinates, whose origin is the target 12/22/32. + let center = t.view_center(10, 12); if !(center.x.is_finite() && center.y.is_finite()) { continue; } @@ -316,7 +317,8 @@ const MAX_VPORT_TILES: usize = 64; /// are fractions of the drawing area; unusable tiles are skipped. fn vport_record(d: &mut Drawing, t: &T) { let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default(); - let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy()); + // The view centre 12/22 is in display coordinates, whose origin is the view target 17/27/37. + let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17)); let height = t.fd(40, 0.0); let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 79c4e4b3e..aa77795fa 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -73,5 +73,6 @@ mod text_left_justify; mod tolerance_roundtrip; mod user_lin_pat_roundtrip; mod view_tables_roundtrip; +mod view_target; mod viewport_clip_roundtrip; mod xref_roundtrip; diff --git a/crates/io/tests/it/view_target.rs b/crates/io/tests/it/view_target.rs new file mode 100644 index 000000000..c6a2eea27 --- /dev/null +++ b/crates/io/tests/it/view_target.rs @@ -0,0 +1,181 @@ +//! A view's centre is stored in display coordinates, whose origin is the view target (DXF +//! Reference: VIEWPORT 12/22 and 17/27/37, VPORT 12/22 and 17/27/37, VIEW 10/20 and 12/22/32). +//! ArchiCAD's AutoCAD 2018 export puts each layout viewport's location in the target with a +//! centre of 0,0, so reading the centre alone showed model point 0,0 (#2492). + +use cadcraft_doc::geom::Vec2; +use cadcraft_doc::{Drawing, EntityKind, Viewport}; +use cadcraft_io::{read_dxf, write_dxf}; + +/// An ASCII DXF of `tags`. +fn dxf(tags: &[(i32, &str)]) -> String { + tags.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect() +} + +/// A layout viewport with view centre `center` (12/22) and view target `target` (17/27/37). +fn with_viewport(center: [&str; 2], target: [&str; 3]) -> Drawing { + looking_along(center, target, ["0", "0", "1"]) +} + +/// The same, looking along view direction `dir` (16/26/36). +fn looking_along(center: [&str; 2], target: [&str; 3], dir: [&str; 3]) -> Drawing { + let tags: Vec<(i32, &str)> = vec![ + (0, "SECTION"), + (2, "ENTITIES"), + (0, "VIEWPORT"), + (5, "40"), + (67, "1"), + (8, "0"), + (10, "148.5"), + (20, "105"), + (30, "0"), + (40, "250"), + (41, "180"), + (68, "2"), + (69, "2"), + (12, center[0]), + (22, center[1]), + (16, dir[0]), + (26, dir[1]), + (36, dir[2]), + (17, target[0]), + (27, target[1]), + (37, target[2]), + (45, "48680.2"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + read_dxf(dxf(&tags).as_bytes()).expect("read") +} + +fn viewports(d: &Drawing) -> Vec { + d.layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) if v.id > 1 => Some(v.clone()), + _ => None, + }) + .collect() +} + +fn view_center(d: &Drawing) -> Vec2 { + let vs = viewports(d); + assert_eq!(vs.len(), 1, "{vs:?}"); + vs[0].view_center +} + +/// `a` and `b` agree to within the rounding of adding two decimals. +fn close(a: Vec2, b: Vec2) -> bool { + (a - b).len() < 1e-6 +} + +#[test] +fn a_viewport_looks_at_its_target_plus_its_centre() { + // The issue's file: centre 0,0, the facade in the target. + let c = view_center(&with_viewport(["0", "0"], ["230748.7", "-867.4", "0"])); + assert_eq!(c, Vec2::new(230748.7, -867.4)); + // Both set: their sum. A target alone of 0 leaves the centre as it is. + assert_eq!(view_center(&with_viewport(["10", "5"], ["100", "50", "7"])), Vec2::new(110.0, 55.0)); + assert_eq!(view_center(&with_viewport(["10", "5"], ["0", "0", "0"])), Vec2::new(10.0, 5.0)); +} + +/// CADCraft draws every viewport as a plan view (the view direction, 16/26/36, isn't applied). A +/// viewport looking along another direction is centred on what it looks at, its target, as +/// AutoCAD's view of it is; its centre is in that view's own axes, so only the target is exact. +#[test] +fn a_viewport_looking_along_another_direction_centres_on_its_target() { + let c = view_center(&looking_along(["0", "0"], ["500", "-200", "30"], ["1", "1", "1"])); + assert_eq!(c, Vec2::new(500.0, -200.0)); +} + +/// Saved and reopened, the viewport still looks at the same place: CADCraft writes the whole +/// location as the centre with a target of 0. +#[test] +fn the_view_survives_a_save_and_reopen() { + let d = with_viewport(["3", "4"], ["84672.2", "25851.5", "0"]); + let back = read_dxf(write_dxf(&d).as_bytes()).expect("reopen"); + let c = view_center(&back); + assert!(close(c, Vec2::new(84675.2, 25855.5)), "{c:?}"); +} + +/// A target coordinate that is not a finite number reads as 0, as any DXF real does, and a sum +/// that overflows leaves the centre. +#[test] +fn a_hostile_target_leaves_the_centre() { + for target in [["1e999", "0", "0"], ["nan", "0", "0"], ["1e308", "0", "0"]] { + assert_eq!(view_center(&with_viewport(["1e308", "2"], target)), Vec2::new(1e308, 2.0), "{target:?}"); + } + assert_eq!(view_center(&with_viewport(["1", "2"], ["inf", "5", "0"])), Vec2::new(1.0, 7.0)); +} + +/// The model viewport tiles of a saved configuration (VPORT 12/22 and 17/27/37) and named views +/// (VIEW 10/20 and 12/22/32) are read the same way. +#[test] +fn vport_tiles_and_named_views_add_their_targets() { + let tags: &[(i32, &str)] = &[ + (0, "SECTION"), + (2, "TABLES"), + (0, "TABLE"), + (2, "VPORT"), + (0, "VPORT"), + (2, "TWO"), + (70, "0"), + (10, "0"), + (20, "0"), + (11, "0.5"), + (21, "1"), + (12, "1"), + (22, "2"), + (17, "100"), + (27, "200"), + (37, "0"), + (40, "50"), + (41, "1"), + (0, "VPORT"), + (2, "TWO"), + (70, "0"), + (10, "0.5"), + (20, "0"), + (11, "1"), + (21, "1"), + (12, "0"), + (22, "0"), + (17, "-40"), + (27, "30"), + (37, "0"), + (40, "50"), + (41, "1"), + (0, "ENDTAB"), + (0, "TABLE"), + (2, "VIEW"), + (0, "VIEW"), + (2, "Facade"), + (70, "0"), + (40, "20"), + (41, "30"), + (10, "1"), + (20, "2"), + (11, "0"), + (21, "0"), + (31, "1"), + (12, "367097.3"), + (22, "-441.8"), + (32, "0"), + (0, "ENDTAB"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + let d = read_dxf(dxf(tags).as_bytes()).expect("read"); + let two = d.vports.iter().find(|c| c.name.eq_ignore_ascii_case("TWO")).expect("configuration TWO"); + let centers: Vec = two.tiles.iter().map(|t| t.center).collect(); + assert_eq!(centers, [Vec2::new(101.0, 202.0), Vec2::new(-40.0, 30.0)]); + let facade = d.views.iter().find(|v| v.name == "Facade").expect("view Facade"); + assert!(close(facade.center, Vec2::new(367098.3, -439.8)), "{:?}", facade.center); + // And again after a save and reopen. + let back = read_dxf(write_dxf(&d).as_bytes()).expect("reopen"); + let two = back.vports.iter().find(|c| c.name.eq_ignore_ascii_case("TWO")).expect("configuration TWO"); + assert_eq!(two.tiles.iter().map(|t| t.center).collect::>(), centers); + let again = back.views.iter().find(|v| v.name == "Facade").map(|v| v.center).expect("view Facade"); + assert!(close(again, Vec2::new(367098.3, -439.8)), "{again:?}"); +} From c4ac8761e80df60d4bf9e9ab4ab91cc9c02c70f1 Mon Sep 17 00:00:00 2001 From: assamaral Date: Mon, 2 Nov 2026 18:25:51 -0300 Subject: [PATCH 099/151] fix(io): a ByBlock plot style (group 380 = 1) is written and read (#2242) (#2566) * test(io): a ByBlock plot style (group 380 = 1) survives a DXF round trip (#2242) Red: the writer leaves group 380 out and the reader maps it to ByLayer. Co-Authored-By: Claude Fable 5.1 * fix(io): a ByBlock plot style is group 380 = 1, written and read (#2242) The writer only knew named styles (group 390) and left ByBlock out like ByLayer; the reader mapped a record without 390 to ByLayer. Both now use the plot style name type. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- crates/io/src/dxf_read/entities.rs | 16 +++++++++++----- crates/io/src/dxf_write/common.rs | 4 ++++ crates/io/tests/it/plot_styles.rs | 13 +++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index 980b1718c..2011c3d63 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -411,20 +411,26 @@ pub(super) fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap Option { let mut in_entity = false; + let mut by_block = false; for t in tags { if t.code == 100 { if in_entity { - return None; + break; } in_entity = t.str() == "AcDbEntity"; - } else if t.code == 390 && in_entity { - return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned(); + } else if in_entity { + match t.code { + 390 => return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned(), + 380 => by_block = t.i64() == 1, + _ => {} + } } } - None + by_block.then(|| "ByBlock".to_string()) } /// Dimension overrides and associativity, table references: data of an entity record that diff --git a/crates/io/src/dxf_write/common.rs b/crates/io/src/dxf_write/common.rs index 25d2bb074..ef2632665 100644 --- a/crates/io/src/dxf_write/common.rs +++ b/crates/io/src/dxf_write/common.rs @@ -101,6 +101,10 @@ pub(super) fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass if let Some(v) = transparency_440(e.common.transparency) { w.i(440, v); } + // ByBlock is the plot style name type 1 (ByLayer, 0, is the default and left out). + if e.common.plot_style.trim().eq_ignore_ascii_case("byblock") { + w.i(380, 1); + } // A named plot style (not ByLayer/ByBlock): its placeholder object. if let Some(h) = w.pstyles.get(&e.common.plot_style.trim().to_ascii_uppercase()).cloned() { w.s(390, h); diff --git a/crates/io/tests/it/plot_styles.rs b/crates/io/tests/it/plot_styles.rs index 679c167b9..73a5c8e5b 100644 --- a/crates/io/tests/it/plot_styles.rs +++ b/crates/io/tests/it/plot_styles.rs @@ -86,6 +86,19 @@ fn named_plot_styles_round_trip_through_the_plot_style_dictionary() { assert!(plain.contains("ACAD_PLOTSTYLENAME") && !plain.contains("CADCRAFT_PLOTSTYLES")); } +/// #2242: a ByBlock plot style is group 380 = 1 (AcDb::PlotStyleNameType); it was written like +/// ByLayer (no group at all) and read back as ByLayer. +#[test] +fn by_block_plot_style_round_trips() { + let mut d = Drawing::new_metric(); + let line = EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(1.0, 0.0, 0.0) }); + let h = d.add(&Space::Model, Common { plot_style: "ByBlock".into(), ..Default::default() }, line).unwrap(); + let text = write_dxf(&d); + let lines: Vec<&str> = text.lines().collect(); + assert!(lines.windows(2).any(|w| w[0].trim() == "380" && w[1].trim() == "1"), "group 380 = 1 written"); + assert_eq!(read_dxf(text.as_bytes()).unwrap().entity(h).unwrap().common.plot_style, "ByBlock"); +} + #[test] fn pdf_applies_a_named_table_from_the_drawing() { use cadcraft_doc::{PlotStyle, PlotStyleTable}; From ab15e42c6c12049f2c68e1c2715ca7183075703b Mon Sep 17 00:00:00 2001 From: austin62jake Date: Mon, 2 Nov 2026 15:25:55 -0600 Subject: [PATCH 100/151] fix(io): a layout keeps its limits and paper image origin through a save (#2109) (#2623) Every LAYOUT was written with limits 0,0 to 12,9 (AcDbLayout groups 10/11) and paper image origin 0,0 (groups 148/149), whatever the file had, so another program drew the sheet elsewhere and a Layout plot came out shifted. Layout::limits and PageSetup::image_origin keep what the file had (finite, within 1e12) and a save writes it back; a layout without them writes the old defaults, which read back as none. Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/doc/src/tables.rs | 11 +++- crates/engine/src/cmd/layout.rs | 2 +- crates/io/src/dxf_read.rs | 3 +- crates/io/src/dxf_read/document.rs | 16 +++-- crates/io/src/dxf_read/objects.rs | 4 ++ crates/io/src/dxf_write/sections.rs | 17 +++-- crates/io/tests/it/layout_limits_roundtrip.rs | 64 +++++++++++++++++++ crates/io/tests/it/main.rs | 1 + 8 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 crates/io/tests/it/layout_limits_roundtrip.rs diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index a2c63ca6a..a5f02454f 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -799,6 +799,10 @@ pub struct PageSetup { /// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the /// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058). pub paper_mm: bool, + /// The paper image origin (DXF groups 148/149, in plot paper units) as the file had it, so a + /// save keeps where the sheet sits; `None` for a page CADCraft made, written as 0,0 (#2109). + #[serde(skip_serializing_if = "Option::is_none")] + pub image_origin: Option, } impl Default for PageSetup { @@ -820,6 +824,7 @@ impl Default for PageSetup { show_plot_styles: false, plot_transparency: false, paper_mm: false, + image_origin: None, } } } @@ -835,11 +840,15 @@ pub struct Layout { /// Saved paper-space view (centre, height). #[serde(default)] pub view: Option<(Vec2, f64)>, + /// The layout's limits (LAYOUT groups 10/11, lower left and upper right) as the file had + /// them; `None` for a layout CADCraft made, written as 0,0 to 12,9 (#2109). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limits: Option<(Vec2, Vec2)>, } impl Layout { pub fn new(name: &str, tab_order: u32) -> Self { - Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None } + Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None, limits: None } } } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index a538d73b5..efe9bf13f 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -313,7 +313,7 @@ fn run_copy(s: &mut Session, p: &Value) -> Result { let h = d.new_handle(); store.push(Entity { handle: h, ..Entity::clone(e) }); } - d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view }); + d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view, limits: src.limits }); Ok(json!({ "name": to })) } diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 341e72a77..2dcb14fc0 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -73,7 +73,8 @@ pub fn read(bytes: &[u8]) -> Result { type BlockDef = (String, Vec3, String, Vec<(Option, bool, Entity)>, i64); /// A layout read from OBJECTS: name, tab order, paper block handle and page setup. -type LayoutDef = (String, u32, String, PageSetup); +/// A LAYOUT object: name, tab order, block record, page setup and its limits (groups 10/11). +type LayoutDef = (String, u32, String, PageSetup, Option<(Vec2, Vec2)>); /// Import DXF sections while sharing one materialized-table-cell budget across blocks and entity sections. fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { diff --git a/crates/io/src/dxf_read/document.rs b/crates/io/src/dxf_read/document.rs index db935974a..6f61e3415 100644 --- a/crates/io/src/dxf_read/document.rs +++ b/crates/io/src/dxf_read/document.rs @@ -1,7 +1,7 @@ use super::common::T; use super::entities::{entity_extras, entity_plot_style, entity_with_table_budget, insert_attribs, polyline_entity}; use super::objects::{MAX_OBJECTS, Objects, page_setup}; -use super::{Block, BlockDef, Drawing, Entity, EntityKind, Handle, HashMap, Layout, LayoutDef, Rx, Tag, records}; +use super::{Block, BlockDef, Drawing, Entity, EntityKind, Handle, HashMap, Layout, LayoutDef, Rx, Tag, Vec2, records}; pub(super) fn parse_entities( recs: &[(String, Vec)], @@ -138,21 +138,29 @@ pub(super) fn read_objects(tags: &[Tag], objs: &mut Objects, layout_objs: &mut V // LAYOUT has two 330s: owner dict and the block record; take the last. let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default(); let plot = T(tg.get(..start).unwrap_or(&[])); - layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot))); + layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot), limits(&t))); } } } +/// A LAYOUT's limits (groups 10/20 and 11/21 after the AcDbLayout marker), when both corners +/// are there and usable, so a save writes back the file's own (#2109). The 0,0 to 12,9 a +/// layout without its own limits writes reads back as none. +fn limits(t: &T) -> Option<(Vec2, Vec2)> { + let corner = |x: i32, y: i32| Some(Vec2::new(t.f(x)?, t.f(y)?)).filter(|p| p.is_finite() && p.x.abs() < 1e12 && p.y.abs() < 1e12); + corner(10, 20).zip(corner(11, 21)).filter(|l| *l != (Vec2::ZERO, Vec2::new(12.0, 9.0))) +} + /// Add the drawing's layouts, and return the layout each paper-space block belongs to, and the /// layout that takes paper-space entities without an owner. pub(super) fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx: &Rx) -> (HashMap, String) { let mut paper_block_for: HashMap = HashMap::new(); layout_objs.sort_by_key(|l| l.1); - for (name, order, br, page) in layout_objs.iter() { + for (name, order, br, page, limits) in layout_objs.iter() { if name.eq_ignore_ascii_case("Model") { continue; } - d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) }); + d.layouts.push(Layout { page: page.clone(), limits: *limits, ..Layout::new(name, *order) }); if let Some(bname) = rx.brs.get(br) { paper_block_for.insert(bname.to_ascii_uppercase(), name.clone()); } diff --git a/crates/io/src/dxf_read/objects.rs b/crates/io/src/dxf_read/objects.rs index 4d692ccee..359dc887a 100644 --- a/crates/io/src/dxf_read/objects.rs +++ b/crates/io/src/dxf_read/objects.rs @@ -274,6 +274,10 @@ pub(super) fn page_setup(t: &T) -> PageSetup { .into(); p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 }); p.plot_style_table = t.s(7).unwrap_or_default(); + // Paper image origin: kept as the file has it, so a save puts the sheet back where it was (#2109). + // 0,0 is what a page without one writes, so it reads back as none. + p.image_origin = + t.f(148).zip(t.f(149)).map(|(x, y)| Vec2::new(x, y)).filter(|o| o.is_finite() && o.x.abs() < 1e12 && o.y.abs() < 1e12 && *o != Vec2::ZERO); p } diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index b2eff022d..0f93013a5 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -384,6 +384,8 @@ struct LayoutWriteData<'a> { flags: i64, page: &'a PageSetup, view: Option<(Vec2, f64)>, + /// The layout's limits as read (`None`: the default 0,0 to 12,9). + limits: Option<(Vec2, Vec2)>, } fn layout_obj(w: &mut W, context: LayoutWriteContext<'_>, data: LayoutWriteData<'_>) { @@ -448,14 +450,18 @@ fn layout_obj(w: &mut W, context: LayoutWriteContext<'_>, data: LayoutWriteData< w.s(7, &page.plot_style_table); w.i(75, if page.scale_to_fit { 0 } else { 16 }); w.f(147, scale); - w.f(148, 0.0); - w.f(149, 0.0); + // The paper image origin the file had (#2109), else 0,0. + let origin = page.image_origin.filter(|o| o.is_finite()).unwrap_or(Vec2::ZERO); + w.f(148, origin.x); + w.f(149, origin.y); w.s(100, "AcDbLayout"); w.s(1, name); w.i(70, flags); w.i(71, tab); - w.p2(10, Vec2::ZERO); - w.p2(11, Vec2::new(12.0, 9.0)); + // The layout's limits as the file had them (#2109), else 0,0 to 12,9. + let (lo, hi) = data.limits.filter(|(a, b)| a.is_finite() && b.is_finite()).unwrap_or((Vec2::ZERO, Vec2::new(12.0, 9.0))); + w.p2(10, lo); + w.p2(11, hi); w.p(12, Vec3::ZERO); w.p(14, Vec3::ZERO); w.p(15, Vec3::ZERO); @@ -524,7 +530,7 @@ fn write_layout_objects(w: &mut W, plan: &ObjectWritePlan<'_>) { layout_obj( w, LayoutWriteContext { drawing: d, dictionary: layout_dict }, - LayoutWriteData { handle: model_layout, name: "Model", tab: 0, block_record: ms_br, flags: 1, page: &model_page, view: None }, + LayoutWriteData { handle: model_layout, name: "Model", tab: 0, block_record: ms_br, flags: 1, page: &model_page, view: None, limits: None }, ); for (i, (_, brh, l)) in ps_brs.iter().enumerate() { let h = layout_handles.get(i).cloned().unwrap_or_default(); @@ -539,6 +545,7 @@ fn write_layout_objects(w: &mut W, plan: &ObjectWritePlan<'_>) { flags: 1, page: &l.page, view: l.view, + limits: l.limits, }, ); } diff --git a/crates/io/tests/it/layout_limits_roundtrip.rs b/crates/io/tests/it/layout_limits_roundtrip.rs new file mode 100644 index 000000000..31baea67d --- /dev/null +++ b/crates/io/tests/it/layout_limits_roundtrip.rs @@ -0,0 +1,64 @@ +//! A layout keeps its limits (LAYOUT groups 10/11) and its page's paper image origin +//! (groups 148/149) through a DXF and a DWG save, as the file had them, so another program puts +//! the sheet back where it was (#2109). A layout CADCraft made writes the defaults as before. + +use cadcraft_doc::geom::Vec2; +use cadcraft_doc::{Drawing, Layout}; +use cadcraft_io::{SaveOptions, Version, read, read_dxf, write_dxf, write_with}; + +/// A drawing whose layout has the limits and image origin of the ZWCAD file in #2109. +fn drawing() -> Drawing { + let mut d = Drawing::new_imperial(); + let mut layout = Layout::new("Sheet", 1); + layout.limits = Some((Vec2::new(1.038, 0.694), Vec2::new(9.506, 6.681))); + layout.page.image_origin = Some(Vec2::new(-1.054, -0.71)); + d.layouts = vec![layout]; + d +} + +fn sheet(d: &Drawing) -> &Layout { + d.layouts.iter().find(|l| l.name == "Sheet").expect("the layout") +} + +fn near(a: Vec2, b: Vec2) -> bool { + (a - b).len() < 1e-9 +} + +fn assert_kept(d: &Drawing, how: &str) { + let l = sheet(d); + let (lo, hi) = l.limits.unwrap_or_else(|| panic!("{how}: the limits are kept")); + assert!(near(lo, Vec2::new(1.038, 0.694)) && near(hi, Vec2::new(9.506, 6.681)), "{how}: {lo:?} {hi:?}"); + let origin = l.page.image_origin.unwrap_or_else(|| panic!("{how}: the image origin is kept")); + assert!(near(origin, Vec2::new(-1.054, -0.71)), "{how}: {origin:?}"); +} + +#[test] +fn a_layout_keeps_its_limits_and_image_origin_through_dxf() { + let back = read_dxf(write_dxf(&drawing()).as_bytes()).expect("reopen"); + assert_kept(&back, "DXF"); + // And again: a second save writes what the first read. + let again = read_dxf(write_dxf(&back).as_bytes()).expect("reopen"); + assert_kept(&again, "DXF twice"); +} + +#[test] +fn a_layout_keeps_its_limits_and_image_origin_through_dwg() { + let dwg = write_with(&drawing(), "t.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).expect("DWG"); + let back = read(&dwg, "t.dwg").expect("reopen"); + assert_kept(&back, "DWG"); +} + +#[test] +fn a_new_layout_writes_the_defaults() { + let text = write_dxf(&Drawing::new_imperial()); + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + // The AcDbLayout limits after the layout's name, and the image origin before it. + let after = |code: &str| lines.windows(2).filter(|w| w[0] == code).map(|w| w[1]).collect::>(); + assert!(after("148").iter().all(|v| v.parse::() == Ok(0.0)), "{:?}", after("148")); + let back = read_dxf(text.as_bytes()).expect("reopen"); + let l = back.layouts.first().expect("a layout"); + // The defaults read back as none, so a drawing CADCraft made round-trips unchanged. + assert_eq!((l.limits, l.page.image_origin), (None, None)); + let limits = (after("10"), after("11")); + assert!(limits.0.iter().any(|v| v.parse::() == Ok(0.0)) && limits.1.iter().any(|v| v.parse::() == Ok(12.0)), "{limits:?}"); +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index aa77795fa..1601fc0ea 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -31,6 +31,7 @@ mod groups_roundtrip; mod hatch_assoc_roundtrip; mod hatch_pattern_def; mod helix_roundtrip; +mod layout_limits_roundtrip; mod layout_paper_units; mod layout_plot_window; mod leader_vertex_elevation; From ed08716f306f815a92d9c4a8ec6469403a249e2e Mon Sep 17 00:00:00 2001 From: austin62jake Date: Mon, 2 Nov 2026 15:26:00 -0600 Subject: [PATCH 101/151] fix(io): the paper viewport a save adds is the sheet at the layout's plot scale (#2108) (#2624) A layout without a paper-space viewport (id 1) gets one on save, sized by the page alone; a custom plot scale (142/143) wasn't applied, so an A3 layout at 49.6 mm per unit got a 420 x 297 viewport around its 8.4 x 6 unit sheet, and its extents went wrong. paper_view divides the sheet by the layout's plot scale; a layout scaled to fit, at 1:1 or with an unusable scale writes what it did. Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_write/layout.rs | 11 ++++++++++- crates/io/tests/it/dwg_roundtrip_losses.rs | 23 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/io/src/dxf_write/layout.rs b/crates/io/src/dxf_write/layout.rs index 4ad19ffbb..364648daf 100644 --- a/crates/io/src/dxf_write/layout.rs +++ b/crates/io/src/dxf_write/layout.rs @@ -33,12 +33,21 @@ pub(super) fn sheet_first(l: &Layout) -> Vec<&Entity> { all().filter(sheet).chain(all().filter(|e| !sheet(e))).collect() } +/// Paper units per paper-space unit of layout `l`: its custom plot scale (groups 142/143), so +/// the sheet is as large in paper space as it plots (a 420 mm sheet at 49.6 mm per unit is 8.47 +/// units wide, #2108). 1 when it is scaled to fit or the scale is unusable. +fn sheet_scale(l: &Layout) -> f64 { + let s = l.page.scale; + if l.page.scale_to_fit || !(s.is_finite() && (1e-6..=1e6).contains(&s)) { 1.0 } else { s } +} + /// The paper-space viewport (id 1) of the layout whose first viewport is `e`, if it needs one /// ([`paper_views`]): the whole sheet, viewed 1:1. Marked with CADCraft xdata so that our reader /// leaves it implicit again. pub(super) fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &Ctx) { let Some(l) = cx.paper_views.get(&e.handle).and_then(|i| d.layouts.get(*i)) else { return }; - let size = cadcraft_render::paper::sheet(d, &l.name).map(|s| s.size).unwrap_or(Vec2::new(12.0, 9.0)); + let paper = cadcraft_render::paper::sheet(d, &l.name).map(|s| s.size).unwrap_or(Vec2::new(12.0, 9.0)); + let size = paper / sheet_scale(l); let center = size * 0.5; let h = w.h(); w.s(0, "VIEWPORT"); diff --git a/crates/io/tests/it/dwg_roundtrip_losses.rs b/crates/io/tests/it/dwg_roundtrip_losses.rs index ec9c2b93d..aa00458a6 100644 --- a/crates/io/tests/it/dwg_roundtrip_losses.rs +++ b/crates/io/tests/it/dwg_roundtrip_losses.rs @@ -159,3 +159,26 @@ fn a_paper_viewport_behind_a_model_viewport_stays_id_1_through_dwg() { assert_eq!((id(model), id(sheet)), (Some(2), Some(1)), "layout {name}"); } } + +/// The paper-space viewport a save adds is the sheet as large as it plots: a layout with a custom +/// plot scale (here 49.6 mm of paper per unit, as in #2108's file) has an A3 sheet of 8.47 × 5.99 +/// units in paper space, not 420 × 297. A layout at 1:1 or scaled to fit keeps the page size. +#[test] +fn the_added_paper_viewport_follows_the_plot_scale() { + let sheet_width = |scale: f64, fit: bool| { + let mut d = Drawing::new_metric(); + let page = &mut d.layouts[0].page; + (page.width_mm, page.height_mm, page.landscape, page.paper_mm) = (297.0, 420.0, true, true); + (page.scale, page.scale_to_fit) = (scale, fit); + let first = Space::Paper(d.layouts[0].name.clone()); + d.add(&first, Common::default(), viewport(2)).unwrap(); + let text = cadcraft_io::write_dxf(&d); + let vps = records(&text, "VIEWPORT"); + assert_eq!(group(&vps[0], "1000"), Some("PAPERVIEW"), "{text}"); + group(&vps[0], "40").unwrap().parse::().unwrap() + }; + assert!((sheet_width(49.6, false) - 420.0 / 49.6).abs() < 1e-9, "{}", sheet_width(49.6, false)); + assert!((sheet_width(1.0, false) - 420.0).abs() < 1e-9); + assert!((sheet_width(49.6, true) - 420.0).abs() < 1e-9, "scaled to fit: the scale means nothing"); + assert!((sheet_width(f64::NAN, false) - 420.0).abs() < 1e-9, "an unusable scale is 1"); +} From f4c68e18fcd3e38d4122486c374e9d805cec8139 Mon Sep 17 00:00:00 2001 From: Elarionus <29511515+Elarionus@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:26:04 -0800 Subject: [PATCH 102/151] macOS: Quit (Cmd+Q), the Dock's Quit and logout ask to save unsaved drawings (#2157) (#2620) Quit CADCraft in the app menu, the Dock's Quit and logout end the app through AppKit's terminate, which skipped the "Save changes to ...?" prompt, so unsaved work was lost. The event loop now turns termination into a close request for the window (the storytold winit fork's opt-in, as DesignCraft does), so it asks first, as the close button does. Cancel keeps the app open; Force Quit can't be intercepted. Co-authored-by: Ethan Dale Co-authored-by: Claude Opus 5.5 --- apps/cadcraft/Cargo.toml | 3 +++ apps/cadcraft/src/main.rs | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index d8b4ef4c5..993668694 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -50,6 +50,9 @@ muda = { version = "0.21", default-features = false } # View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion # (system_animations.rs). Already in Cargo.lock through muda. objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] } +# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns +# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about. +winit = { version = "0.30", default-features = false } # Rust HID backends: no proprietary SDK or C HID library. [target.'cfg(target_os = "windows")'.dependencies] diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index c591c10b3..98dd3c679 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -310,6 +310,16 @@ fn main() -> eframe::Result { b.with_x11(); })); } + // macOS: Quit CADCraft (Cmd+Q), the Dock's Quit and logout end the app through AppKit's + // terminate, which would skip the unsaved-changes prompt (#2157). winit turns each into a + // close request for the window instead, so it asks first, as the close button does. + #[cfg(target_os = "macos")] + { + options.event_loop_builder = Some(Box::new(|b| { + use winit::platform::macos::EventLoopBuilderExtMacOS; + b.with_close_requested_on_terminate(true); + })); + } eframe::run_native( "CADCraft", options, From 067439110048e717b5dc6598528994afeaea824d Mon Sep 17 00:00:00 2001 From: whaslbeck Date: Mon, 2 Nov 2026 22:26:09 +0100 Subject: [PATCH 103/151] fix(io): read drawings with many groups in linear time (#862) (#2645) * fix(io): read drawings with many groups in linear time (#862) Drawings can carry hundreds of thousands of empty anonymous groups (*A1, *A2, ...). Two places in the DXF reader were quadratic in their number: - apply_groups looked up each group by name with a linear search; it now uses an index keyed by the lower-case name, so the case-insensitive merge (the later definition wins) is unchanged. - retained_objects called is_xdict(parent) for every object before consulting its cache; for a group the parent is the ACAD_GROUP dictionary, which head() walks completely. is_xdict is now memoized per object index. Two drawings with 179,124 and 124,362 empty groups go from 388 s and 165 s (DWG to PDF) to 7.3 s and 8.0 s, with byte-identical output. * test(io): check that reading groups scales linearly (#862) The content test passes on the quadratic code as well, so add a scaling check: 16 times the groups (2,500 vs 40,000, best of three reads each) must take less than 64 times as long. With both fixes the ratio is about 21; without the name index it is about 218, without the is_xdict cache about 180. No wall-clock limit is involved. Also document apply_groups. --------- Co-authored-by: Walter Haslbeck <8794773+whaslbeck@users.noreply.github.com> --- crates/io/src/dxf_raw.rs | 4 ++ crates/io/src/dxf_read/objects.rs | 17 +++++-- crates/io/tests/it/groups_roundtrip.rs | 62 ++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index 16ea48b6c..ab18284ba 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -502,6 +502,10 @@ fn retained_objects( let modelled_entry = |tags: &[Tag], handle: &str| { entries(tags).iter().any(|(key, entry_handle)| entry_handle == handle && MODELLED_XDICT_KEYS.contains(&up(key).as_str())) }; + // is_xdict reads the whole record; a dictionary that owns many objects (ACAD_GROUP with + // hundreds of thousands of groups) would be read once per child (#862). + let mut xdict_memo: HashMap = HashMap::new(); + let mut is_xdict = |index: usize| *xdict_memo.entry(index).or_insert_with(|| is_xdict(index)); let mut retained = HashMap::new(); for index in 0..objects.len() { let mut path = Vec::new(); diff --git a/crates/io/src/dxf_read/objects.rs b/crates/io/src/dxf_read/objects.rs index 359dc887a..374313d48 100644 --- a/crates/io/src/dxf_read/objects.rs +++ b/crates/io/src/dxf_read/objects.rs @@ -153,8 +153,16 @@ impl Objects { } } + /// Add the groups named by the ACAD_GROUP dictionary entries, with the members that exist. + /// A name already present (compared case-insensitively) is replaced by the later definition. fn apply_groups(&self, d: &mut Drawing) { - // Groups (named by their ACAD_GROUP dictionary entries); members that exist. + // Names are matched through an index: a drawing can carry hundreds of thousands of + // (often empty) anonymous groups, and a linear search per group made loading quadratic + // (#862). + let mut by_name: HashMap = HashMap::new(); + for (i, g) in d.groups.iter().enumerate() { + by_name.entry(g.name.to_ascii_lowercase()).or_insert(i); + } for (h, tags) in &self.groups { let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; let t = T(tags); @@ -166,9 +174,12 @@ impl Objects { .take(MAX_OBJECTS) .collect(); let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members }; - match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { + match by_name.get(&name.to_ascii_lowercase()).and_then(|&i| d.groups.get_mut(i)) { Some(x) => *x = g, - None => d.groups.push(g), + None => { + by_name.insert(name.to_ascii_lowercase(), d.groups.len()); + d.groups.push(g); + } } } } diff --git a/crates/io/tests/it/groups_roundtrip.rs b/crates/io/tests/it/groups_roundtrip.rs index 394b3d482..8614dc757 100644 --- a/crates/io/tests/it/groups_roundtrip.rs +++ b/crates/io/tests/it/groups_roundtrip.rs @@ -29,3 +29,65 @@ fn groups_survive_dwg_round_trip() { .collect(); assert_eq!(got, vec![("Doors", "front", false, vec![5.0, 0.0]), ("*A1", "", true, vec![0.0])]); } + +/// A DXF with one LINE (handle 2A) and the given groups in ACAD_GROUP: `n` empty anonymous +/// groups `*A1`..`*An`, then the `named` ones `(name, handle, description)`, each holding the line. +fn groups_dxf(n: usize, named: &[(&str, &str, &str)]) -> String { + let mut dict = String::from("0\nDICTIONARY\n5\nC\n3\nACAD_GROUP\n350\nD\n0\nDICTIONARY\n5\nD\n330\nC\n"); + let mut groups = String::new(); + for i in 1..=n { + let h = format!("{:X}", 0x1000 + i); + dict.push_str(&format!("3\n*A{i}\n350\n{h}\n")); + groups.push_str(&format!("0\nGROUP\n5\n{h}\n330\nD\n300\n\n70\n1\n71\n1\n")); + } + for (name, h, text) in named { + dict.push_str(&format!("3\n{name}\n350\n{h}\n")); + groups.push_str(&format!("0\nGROUP\n5\n{h}\n330\nD\n300\n{text}\n70\n0\n71\n1\n340\n2A\n")); + } + format!( + "0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n2A\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n\ + 0\nSECTION\n2\nOBJECTS\n{dict}{groups}0\nENDSEC\n0\nEOF\n" + ) +} + +/// Drawings can carry hundreds of thousands of empty anonymous groups (#862). Every group +/// arrives, and a name repeated in another case still keeps one group, the later definition. +#[test] +fn many_anonymous_groups_read_and_names_merge_case_insensitively() { + const N: usize = 20_000; + let dxf = groups_dxf(N, &[("Doors", "100", "first"), ("DOORS", "101", "second")]); + let d = cadcraft_io::read_dxf(dxf.as_bytes()).expect("read"); + assert_eq!(d.groups.len(), N + 1); + assert!(d.groups.iter().take(N).enumerate().all(|(i, g)| g.name == format!("*A{}", i + 1) && g.members.is_empty())); + let doors: Vec<(&str, &str, usize)> = d + .groups + .iter() + .filter(|g| g.name.eq_ignore_ascii_case("doors")) + .map(|g| (g.name.as_str(), g.description.as_str(), g.members.len())) + .collect(); + assert_eq!(doors, [("DOORS", "second", 1)]); +} + +/// Reading groups scales linearly (#862): 16 times the groups take about 16 to 20 times as long, +/// where the former quadratic lookups took about 256 times as long. The bound compares the two +/// sizes with each other, not with a wall-clock limit, and leaves room for timing noise; each +/// size is timed as the best of three reads. +#[test] +fn reading_groups_scales_linearly() { + let best_of_three = |n: usize| { + let dxf = groups_dxf(n, &[]); + (0..3) + .map(|_| { + let t = std::time::Instant::now(); + let d = cadcraft_io::read_dxf(dxf.as_bytes()).expect("read"); + assert_eq!(d.groups.len(), n); + t.elapsed() + }) + .min() + .unwrap_or_default() + }; + let small = best_of_three(2_500); + let large = best_of_three(40_000); + let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6); + assert!(ratio < 64.0, "16x the groups took {ratio:.1}x as long ({small:?} -> {large:?})"); +} From fbd6e676f8353c9e46a67717d8bc39a4d2b813b3 Mon Sep 17 00:00:00 2001 From: CA Date: Tue, 3 Nov 2026 01:26:14 +0400 Subject: [PATCH 104/151] DWG save keeps OLE objects (OLE2FRAME): stop reporting them as not saved (#2582) * DWG save keeps OLE objects (OLE2FRAME): stop reporting them as not saved acadrust reads OLE2FRAME from our DXF into its own entity and writes it to DWG of every version we save, frame and embedded data unchanged; only the "Not saved in the DWG file" warning (and the QSAVE loss prompt) listed it. not_saved_as now skips the types the DWG bridge writes (DWG_WRITES). Tests: a synthetic OLE2FRAME through DWG (resaved, and as 2000 and 2010 DWG) keeps every byte; OLE objects are not reported at any version. Co-Authored-By: Claude Opus 5.5 * DWG_WRITES matches the type as stored, not case-insensitively The DXF given to the DWG bridge carries an unknown entity's type as stored, and acadrust reads only "OLE2FRAME" into its OLE entity: any other spelling becomes its Unknown and is dropped, so it must stay in the "Not saved" report. The test adds a lower-case "ole2frame" that is still listed. (Review comment on #2582.) Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 11 +- crates/engine/tests/save_reports_not_saved.rs | 20 ++++ crates/io/tests/it/main.rs | 2 + crates/io/tests/it/ole2frame_roundtrip.rs | 113 ++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 crates/io/tests/it/ole2frame_roundtrip.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 234769ad4..3b24e87bd 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -742,8 +742,14 @@ pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap { /// store section, which is written back with the drawing (`Foreign::data_store`). const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"]; +/// Entity types CADCraft doesn't model that the DWG bridge writes all the same: acadrust reads +/// them from our DXF into entities of its own and writes them to DWG of every version we save, +/// an OLE object with its frame and its embedded object's bytes unchanged. Matched exactly: the +/// bridge gets the type as stored, and acadrust knows only this spelling of it. +const DWG_WRITES: [&str; 1] = ["OLE2FRAME"]; + /// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data -/// store has a record for keeps its geometry and is not counted. +/// store has a record for keeps its geometry and is not counted; neither are [`DWG_WRITES`]. pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap { let mut out: std::collections::BTreeMap = std::collections::BTreeMap::new(); let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() }; @@ -753,6 +759,9 @@ pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) { continue; } + if DWG_WRITES.contains(&u.dxf_type.as_str()) { + continue; + } let n = out.entry(u.dxf_type.clone()).or_insert(0); *n = n.saturating_add(1); } diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index 615028a21..24a2dff92 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -82,3 +82,23 @@ fn solids_whose_geometry_the_data_store_keeps_are_not_reported_from_2013() { // Before 2013 a file has no data store: every solid loses its geometry. assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 2), ("REGION".to_string(), 1)].into_iter().collect()); } + +/// The DWG bridge writes OLE objects (`ole2frame_roundtrip` in the io tests), so saving one as +/// DWG reports nothing for them, at any version, while other unmodelled types are still listed. +/// The type is matched as stored: the bridge gets it as stored too, and acadrust reads only the +/// upper-case spelling into its OLE entity, so any other spelling is still reported. +#[test] +fn ole_objects_are_not_reported() { + use cadcraft_engine::cmd::file::{Version, not_saved_as}; + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), unknown("OLE2FRAME")).unwrap(); + d.add(&Space::Model, Common::default(), unknown("MLINE")).unwrap(); + d.add(&Space::Model, Common::default(), unknown("ole2frame")).unwrap(); + let mut b = Block::new("Logo"); + let h = d.new_handle(); + b.entities.push(cadcraft_engine::doc::Entity { handle: h, common: Common::default(), kind: unknown("OLE2FRAME") }); + d.blocks.insert("Logo".into(), std::sync::Arc::new(b)); + for v in [Version::R2000, Version::R2010, Version::R2018] { + assert_eq!(not_saved_as(&d, v), [("MLINE".to_string(), 1), ("ole2frame".to_string(), 1)].into_iter().collect(), "{v:?}"); + } +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 1601fc0ea..7b49eb6bf 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -46,6 +46,8 @@ mod mtext_columns_roundtrip; mod mtext_embedded_object; mod mtext_line_spacing; mod multiline_attributes; +#[cfg(not(target_arch = "wasm32"))] +mod ole2frame_roundtrip; mod paperview_marker; mod passthrough_image_roundtrip; mod pdf_followups; diff --git a/crates/io/tests/it/ole2frame_roundtrip.rs b/crates/io/tests/it/ole2frame_roundtrip.rs new file mode 100644 index 000000000..2491d30d4 --- /dev/null +++ b/crates/io/tests/it/ole2frame_roundtrip.rs @@ -0,0 +1,113 @@ +//! OLE objects (OLE2FRAME: a picture, a spreadsheet or a document embedded in the drawing) +//! survive a DWG save. CADCraft doesn't model them and keeps them as read; the DWG bridge reads +//! them from our DXF into acadrust's own entity and writes them to DWG with their frame and the +//! embedded object's bytes, so a drawing saved as DWG and reopened still has them. + +use cadcraft_doc::EntityKind; + +/// Stand-in OLE data as a DWG carries it: a two-byte marker, the frame's four corners (upper +/// left, upper right, lower right, lower left; x, y, z doubles), then the object's bytes, here +/// opaque text long enough to take several 310 groups. +fn payload() -> Vec { + let mut data = vec![0x80, 0x00]; + for [x, y] in [[10.0f64, 40.0], [70.0, 40.0], [70.0, 5.0], [10.0, 5.0]] { + for v in [x, y, 0.0] { + data.extend_from_slice(&v.to_le_bytes()); + } + } + for i in 0..12u8 { + data.extend_from_slice(b"synthetic embedded object "); + data.push(i); + } + data +} + +fn hex(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02X}")).collect() +} + +/// A 2018 DXF with one embedded OLE object in model space on layer `Pictures`. +fn dxf() -> String { + let data = payload(); + let chunks: String = data.chunks(127).map(|c| format!("310\n{}\n", hex(c))).collect(); + format!( + "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n9\n$HANDSEED\n5\n100\n0\nENDSEC\n\ + 0\nSECTION\n2\nENTITIES\n\ + 0\nOLE2FRAME\n5\n2A\n330\n1F\n100\nAcDbEntity\n8\nPictures\n100\nAcDbOle2Frame\n70\n2\n\ + 10\n10\n20\n40\n30\n0\n11\n70\n21\n5\n31\n0\n71\n2\n72\n0\n73\n2\n90\n{}\n{chunks}1\nOLE\n\ + 0\nENDSEC\n0\nEOF\n", + data.len() + ) +} + +/// Each OLE2FRAME of DXF text as its (code, value) pairs. +fn frames(text: &str) -> Vec> { + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let mut out: Vec> = Vec::new(); + let mut inside = false; + for pair in lines.chunks(2) { + let [code, value] = pair else { break }; + if *code == "0" { + inside = *value == "OLE2FRAME"; + if inside { + out.push(Vec::new()); + } + } else if inside && let Some(rec) = out.last_mut() { + rec.push((code.to_string(), value.to_string())); + } + } + out +} + +fn group<'a>(rec: &'a [(String, String)], code: &str) -> Option<&'a str> { + rec.iter().find(|(c, _)| c == code).map(|(_, v)| v.as_str()) +} + +fn number(rec: &[(String, String)], code: &str) -> Option { + group(rec, code).and_then(|v| v.parse().ok()) +} + +/// The embedded object's bytes: the 310 groups joined. +fn data(rec: &[(String, String)]) -> String { + rec.iter().filter(|(c, _)| c == "310").map(|(_, v)| v.as_str()).collect() +} + +/// The frame, its object's bytes, type and layer as the DWG keeps them. +fn assert_kept(rec: &[(String, String)]) { + assert_eq!(data(rec), hex(&payload()), "the embedded object's bytes changed"); + assert_eq!(number(rec, "90"), Some(payload().len() as f64)); + let corners: Vec> = ["10", "20", "11", "21"].iter().map(|c| number(rec, c)).collect(); + assert_eq!(corners, [Some(10.0), Some(40.0), Some(70.0), Some(5.0)]); + assert_eq!(group(rec, "71"), Some("2"), "an embedded object"); + assert_eq!(group(rec, "73"), Some("2")); + assert_eq!(group(rec, "8"), Some("Pictures")); +} + +#[test] +fn dwg_keeps_ole_objects() { + let d = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap(); + let ole = |d: &cadcraft_doc::Drawing| d.model.iter().filter(|e| matches!(&e.kind, EntityKind::Unknown(u) if u.dxf_type == "OLE2FRAME")).count(); + assert_eq!(ole(&d), 1); + + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let back = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + let got = frames(&back); + assert_eq!(got.len(), 1, "{got:?}"); + assert_kept(&got[0]); + + // Reopened in CADCraft it is in model space again, and a second DWG save keeps it too. + let again = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(ole(&again), 1); + let dwg = cadcraft_io::write(&again, "x.dwg").unwrap(); + let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap()); + assert_eq!(got.len(), 1, "{got:?}"); + assert_kept(&got[0]); + + // Older DWG versions keep it the same way. + for version in [cadcraft_io::Version::R2000, cadcraft_io::Version::R2010] { + let dwg = cadcraft_io::write_with(&d, "x.dwg", &cadcraft_io::SaveOptions { version: Some(version), binary: false }).unwrap(); + let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap()); + assert_eq!(got.len(), 1, "{version:?}: {got:?}"); + assert_kept(&got[0]); + } +} From 25ca90581517a05eef4193fcb995836f91885124 Mon Sep 17 00:00:00 2001 From: austin62jake Date: Mon, 2 Nov 2026 15:26:18 -0600 Subject: [PATCH 105/151] fix(fonts): single-line TEXT runs left to right, as AutoCAD lays it out (#2500) (#2618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(fonts): single-line TEXT runs left to right, as AutoCAD lays it out (#2500) A TEXT "- פרוייקט" took its direction from its first strong character (Hebrew), so the leading dash went to the right of the word; AutoCAD shows it on the left. complex::shape now takes the base direction (shape_stroke_dir, ttf::shape_dir and shape_line_dir pass it on), and TEXT's layout (fonts::place and its frame) asks for left to right. MTEXT and the existing entry points keep taking the direction from the text. Co-Authored-By: Claude Opus 5.5 * fix(fonts): a right-to-left base reaches LTR-only lines; Fit TEXT is measured as placed From the review of #2618: shape_stroke_dir and ttf::shape_dir took the simple, left-to-right path whenever a line had no right-to-left letter, so an explicit right-to-left base did nothing there; such a line now takes the bidi path. And Fit/Aligned TEXT was measured with the direction from its content but placed left to right, so a line whose last glyph changed (a stroke-font gap) missed its second point; it is measured with the direction it is placed in. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/fonts/src/complex.rs | 23 +++++++------- crates/fonts/src/lib.rs | 60 ++++++++++++++++++++++++++++++++----- crates/fonts/src/ttf.rs | 12 ++++++-- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index 4a4ac461f..ce2fbcb61 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -64,20 +64,21 @@ impl Src { /// Shape one line containing right-to-left text in `primary` (None: the stroke font), with the /// same conventions as the left-to-right paths: baseline at y = 0, x from 0, text height = cap -/// height, `%%` codes decoded. `None` when the line is too long (the caller then lays it out one -/// glyph per character) or shaping failed. -pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { +/// height, `%%` codes decoded. `base_rtl` is the line's direction (`None`: that of its first +/// strong character). `None` when the line is too long (the caller then lays it out one glyph +/// per character) or shaping failed. +pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Option { let chars = crate::decode_controls(s); if chars.is_empty() || chars.len() > MAX_CHARS { return None; } // A last-resort guard around the bidi and shaping libraries: a panic in them must not take // the drawing down with it (the line then falls back to one glyph per character). - catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique))).ok() + catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique, base_rtl))).ok() } /// Lay out decoded directional runs, keeping advances and decoration spans for empty glyphs. -fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64) -> Shaped { +fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Shaped { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; let shear = oblique.tan().clamp(-10.0, 10.0); @@ -90,7 +91,7 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64 let mut x = 0.0; // Whether the rightmost glyph so far is a stroke-font glyph. let mut stroke_last = false; - for (run, rtl) in bidi::visual_runs(&cs, None) { + for (run, rtl) in bidi::visual_runs(&cs, base_rtl) { let mut pieces = itemize(&cs, run, primary); if rtl { pieces.reverse(); @@ -325,13 +326,13 @@ mod tests { #[test] fn empty_complex_glyph_keeps_advance_without_geometry() { let face = empty_glyph_face(); - let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0).expect("shapes empty glyph"); + let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0, None).expect("shapes empty glyph"); assert!(shaped.width > 0.0, "the font's advance is retained"); assert!(shaped.is_empty()); assert!(shaped.glyphs.is_empty()); assert!(shaped.text.is_empty()); - let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0).expect("shapes decorated empty glyph"); + let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0, None).expect("shapes decorated empty glyph"); assert_eq!(underlined.width, shaped.width); assert!(underlined.glyphs.is_empty()); assert!(underlined.text.is_empty()); @@ -344,7 +345,7 @@ mod tests { // missing-glyph boxes, the digits stay stroke glyphs, in the middle of the line (between // the two words), and the measured width matches the shaped one. let s = "قیمت 25 تومان"; - let sh = shape(None, s, 1.0, 1.0, 0.0).unwrap_or_default(); + let sh = shape(None, s, 1.0, 1.0, 0.0, None).unwrap_or_default(); assert!(sh.width > 0.0); assert!((crate::line_width(s, 1.0, 1.0) - sh.width).abs() < 1e-9); let digits: Vec = sh.strokes.iter().filter(|st| !(st.len() == 5 && st.first() == st.last())).flatten().map(|p| p.x).collect(); @@ -535,7 +536,7 @@ mod tests { let marks = format!("ก{}", "\u{0E48}".repeat(MAX_CHARS - 1)); assert!(crate::shape_stroke(&marks, 1.0, 1.0, 0.0).width > 0.0); let long = "ก".repeat(MAX_CHARS + 1); - assert!(shape(None, &long, 1.0, 1.0, 0.0).is_none()); + assert!(shape(None, &long, 1.0, 1.0, 0.0, None).is_none()); assert!(crate::shape_stroke(&long, 1.0, 1.0, 0.0).width > 0.0); } @@ -546,7 +547,7 @@ mod tests { let _ = crate::line_width(s, 0.0, 0.0); } let long = "\u{05D0}b ".repeat(MAX_CHARS); - assert!(shape(None, &long, 1.0, 1.0, 0.0).is_none()); + assert!(shape(None, &long, 1.0, 1.0, 0.0, None).is_none()); assert!(crate::shape_stroke(&long, 1.0, 1.0, 0.0).width > 0.0); } } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index beb8996ff..983110b4d 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -147,6 +147,12 @@ fn stroke_advance(c: char, h: f64, wf: f64) -> f64 { /// (radians). Characters the stroke font lacks are drawn from an installed font that has them /// (glyph outlines, sized like TrueType text), else as a missing-glyph box. pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped { + shape_stroke_dir(s, height, width_factor, oblique, None) +} + +/// [`shape_stroke`] in the direction `base_rtl` (`None`: that of the line's first strong +/// character). +pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Shaped { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; let sc = h / stroke::CAP; @@ -156,8 +162,10 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh let mut under_start: Option = None; let mut over_start: Option = None; let mut gap = stroke::GAP * sc * wf; - if complex::needed(s) - && let Some(sh) = complex::shape(None, s, h, wf, oblique) + // The simple path lays a line out left to right: one asked to run right to left goes through + // the bidi path even without right-to-left letters. + if (complex::needed(s) || base_rtl == Some(true)) + && let Some(sh) = complex::shape(None, s, h, wf, oblique, base_rtl) { return sh; } @@ -250,7 +258,7 @@ pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 { if complex::needed(s) { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; - if let Some(sh) = complex::shape(None, s, h, wf, 0.0) { + if let Some(sh) = complex::shape(None, s, h, wf, 0.0, None) { return sh.width; } } @@ -417,14 +425,24 @@ pub(crate) fn decorations(spans: &[(f64, f64, bool, bool)], h: f64) -> Vec Shaped { + shape_line_dir(font, s, height, width_factor, oblique, None) +} + +/// [`shape_line`] in the direction `base_rtl` (`None`: that of the line's first strong +/// character). +pub fn shape_line_dir(font: &TextFont, s: &str, height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Shaped { if let TextFont::Outline(face) = font - && let Some(sh) = ttf::shape(face, s, height, width_factor, oblique) + && let Some(sh) = ttf::shape_dir(face, s, height, width_factor, oblique, base_rtl) { return sh; } - shape_stroke(s, height, width_factor, oblique) + shape_stroke_dir(s, height, width_factor, oblique, base_rtl) } +/// Single-line TEXT (and attributes) runs left to right, as AutoCAD lays it out: neutral +/// characters before a right-to-left word ("- " in "- פרוייקט") stay on its left (#2500). +const TEXT_BASE_RTL: Option = Some(false); + /// Width of one line in `font`. pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f64 { if let TextFont::Outline(face) = font @@ -551,7 +569,7 @@ fn frame(font: &TextFont, s: &str, p: &TextParams) -> (Vec2, Vec2, f64, Vec2) { let rot = p.align_pt.map_or(rot, |p2| p.insert.angle_to(p2)); return (p.insert, Vec2::ZERO, rot, Vec2::new(1.0, 1.0)); } - let sh = shape_line(font, s, height, p.width_factor, p.oblique); + let sh = shape_line_dir(font, s, height, p.width_factor, p.oblique, TEXT_BASE_RTL); let origin = match (p.h, p.v) { (Align::Left, VAlign::Baseline) => p.insert, _ => p.align_pt.unwrap_or(p.insert), @@ -572,7 +590,9 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) { { let len = insert.dist(p2); rot = insert.angle_to(p2); - let w = text_width(font, s, height, wf); + // Measured as it is placed, left to right: the stroke font's trailing gap follows the + // glyph that ends the line. + let w = shape_line_dir(font, s, height, wf, p.oblique, TEXT_BASE_RTL).width; if w > 1e-12 && len > 1e-12 { let k = len / w; if p.h == Align::Aligned { @@ -582,7 +602,7 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) { } } } - let mut sh = shape_line(font, s, height, wf, p.oblique); + let mut sh = shape_line_dir(font, s, height, wf, p.oblique, TEXT_BASE_RTL); let width = sh.width; // Every justification but baseline-left (and aligned/fit, which span both points) is placed by // its alignment point (DXF group 11); the insertion point (group 10) is then a computed point. @@ -625,6 +645,30 @@ pub fn place_text( mod tests { use super::*; + /// A TEXT that starts with neutral characters before a Hebrew word ("- פרוייקט") keeps them + /// on the left, as AutoCAD draws it; the line's own direction (first strong character) would + /// put the dash on the right (#2500). + #[test] + fn text_runs_left_to_right_whatever_its_first_strong_character() { + let s = "- \u{05E4}\u{05E8}\u{05D5}\u{05D9}\u{05E7}\u{05D8}"; + let dash = shape_stroke("-", 1.0, 1.0, 0.0).strokes; + assert!(!dash.is_empty()); + let has_dash_first = |strokes: &[Vec]| dash.iter().all(|d| strokes.iter().any(|st| st == d)); + let (text, _) = place(&TextFont::Stroke, s, &TextParams::new(Vec2::ZERO, 1.0)); + assert!(has_dash_first(&text.strokes), "the dash starts the TEXT"); + // The line on its own (MTEXT pieces) still takes its direction from its first strong + // character: right to left here, the dash after the word. + let line = shape_stroke(s, 1.0, 1.0, 0.0); + assert!(!has_dash_first(&line.strokes), "the dash ends a right-to-left line"); + // A line asked to run right to left does so without right-to-left letters too. + assert!(has_dash_first(&shape_stroke_dir("- abc", 1.0, 1.0, 0.0, None).strokes)); + assert!(!has_dash_first(&shape_stroke_dir("- abc", 1.0, 1.0, 0.0, Some(true)).strokes), "the dash after the word"); + // Fit TEXT is measured as it is placed, so it spans its two points exactly. + let fit = TextParams { h: Align::Fit, align_pt: Some(Vec2::new(10.0, 0.0)), ..TextParams::new(Vec2::ZERO, 1.0) }; + let (text, _) = place(&TextFont::Stroke, s, &fit); + assert!((text.width - 10.0).abs() < 1e-9, "{}", text.width); + } + #[test] fn every_ascii_printable_has_a_glyph() { for c in ' '..='~' { diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index c426db243..fff4ecde7 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -894,9 +894,15 @@ pub(crate) fn place_glyph_at(g: &GlyphOutline, origin: Vec2, scale: f64, wf: f64 /// y = 0, x from 0) plus `%%u`/`%%o` decorations as strokes. Text height = cap height. /// Characters the face lacks come from a fallback font, scaled to its own cap height. pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { + shape_dir(face, s, height, width_factor, oblique, None) +} + +/// [`shape`] in the direction `base_rtl` (`None`: that of the line's first strong character). +pub fn shape_dir(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Option { face.font()?; - if crate::complex::needed(s) - && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, oblique) + // As in [`crate::shape_stroke_dir`]: a line asked to run right to left takes the bidi path. + if (crate::complex::needed(s) || base_rtl == Some(true)) + && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, oblique, base_rtl) { return Some(sh); } @@ -934,7 +940,7 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) pub fn width(face: &Face, s: &str, height: f64, width_factor: f64) -> Option { face.font()?; if crate::complex::needed(s) - && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, 0.0) + && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, 0.0, None) { return Some(sh.width); } From c0306b1af3d1f6f21c4563045a11963cc3d5dc59 Mon Sep 17 00:00:00 2001 From: Elarionus <29511515+Elarionus@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:26:23 -0800 Subject: [PATCH 106/151] Remember the main window's size, position and maximized state between sessions (#2595) (#2604) * Remember the main window's size, position and maximized state between sessions (#2595) The main window opened at its default size, centred, on every launch: eframe's window persistence was turned off (persist_window: false), and `centered` is applied over any saved position. The window state is now saved; on launch, if app.ron holds a saved window, it is restored (eframe keeps it on the connected monitors) and not centred. Only the first launch, with nothing saved, opens centred (#1129). Wayland places windows itself, so only size and maximized state return there. Co-Authored-By: Claude Opus 5.5 * Docs: leave the parity docs to the maintainers The affected rows are named in the PR description instead (CLAUDE.md: PRs do not edit the parity docs). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Ethan Dale Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/cadcraft/Cargo.toml | 3 ++ apps/cadcraft/src/main.rs | 61 +++++++++++++++++++++++++++++++-------- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81a772f6e..b5210faea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -632,6 +632,7 @@ dependencies = [ "muda", "objc2-app-kit 0.3.2", "rfd", + "ron", "serde_json", "sys-locale", "thiserror 2.0.21", diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 993668694..ee1b8b9c8 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -24,6 +24,9 @@ log = { workspace = true } clap = { workspace = true } thiserror = { workspace = true } chrono = { workspace = true } +# Reads eframe's app.ron to tell whether a window state was saved (main.rs `saved_window`). +# Already in Cargo.lock through eframe; same version so nothing is built twice. +ron = "0.12" [build-dependencies] winresource = "0.1" diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 98dd3c679..3454dc35f 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -127,7 +127,8 @@ impl eframe::App for App { self.cad.cancel_screenshots(); self.cad.shutdown_autosave(); } - /// Preferences (the interface theme) survive restarts; window and egui state are not kept. + /// Preferences (the interface theme) survive restarts; eframe saves the window state itself + /// (`persist_window`); egui state is not kept. fn save(&mut self, storage: &mut dyn eframe::Storage) { storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.cad.profile_json()); storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json()); @@ -194,8 +195,20 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] { filters } -/// The main window: its size, title bar, placement and rendering needs. -fn window_options() -> eframe::NativeOptions { +/// Whether eframe's storage file (`app.ron`, a map of keys to strings) holds a saved main window +/// state. An unreadable or malformed file counts as none, as it does for eframe. +fn saved_window(app_ron: &str) -> bool { + ron::from_str::>(app_ron).is_ok_and(|kv| kv.contains_key(WINDOW_KEY)) +} + +/// The window state from the last session is saved and will be restored (#2595). +fn restores_window() -> bool { + eframe::storage_dir(APP_ID).and_then(|dir| std::fs::read_to_string(dir.join("app.ron")).ok()).is_some_and(|ron| saved_window(&ron)) +} + +/// The main window: its size, title bar, placement and rendering needs. `restoring`: a window state +/// saved by the last session is restored instead of opening centred. +fn window_options(restoring: bool) -> eframe::NativeOptions { eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("CADCraft") @@ -206,10 +219,13 @@ fn window_options() -> eframe::NativeOptions { .with_titlebar_shown(false) .with_title_shown(false) .with_app_id(APP_ID), - persist_window: false, - // The window opens centred on its screen, not cascaded from the top-left corner (#1129). + // The size, position and maximized state are kept between sessions (#2595); eframe keeps a + // restored window on the monitors that are connected now. + persist_window: true, + // The first launch opens centred on its screen, not cascaded from the top-left corner + // (#1129); later launches go back where the window was (eframe would centre over that). // Wayland compositors place windows themselves. - centered: true, + centered: !restoring, // CADCraft installs its own capped font chain (`theme::install_fonts`); never run // system-font discovery as a hidden fallback. system_font_fallback: false, @@ -221,6 +237,9 @@ fn window_options() -> eframe::NativeOptions { const APP_ID: &str = "ai.storyteller.cadcraft"; +/// The key eframe saves the main window state under in its storage. +const WINDOW_KEY: &str = "window"; + /// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`). const STENCIL_BITS: u8 = 8; @@ -281,7 +300,7 @@ fn main() -> eframe::Result { // The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11 // swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync). wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface), - ..window_options() + ..window_options(restores_window()) }; if let Some(icon) = app_icon() { options.viewport = options.viewport.with_icon(icon); @@ -403,7 +422,7 @@ fn main() -> eframe::Result { #[cfg(test)] mod tests { - use super::{filters_for_save, follow_desktop, window_options}; + use super::{filters_for_save, follow_desktop, saved_window, window_options}; #[test] fn save_dialog_offers_dwg_with_the_drawing_format_first() { @@ -421,12 +440,30 @@ mod tests { } } - /// #1129: the window opens centred, not cascaded from the top-left corner. + /// #1129: the first launch opens centred, not cascaded from the top-left corner. #[test] - fn the_window_opens_centred() { - let o = window_options(); + fn the_first_launch_opens_centred() { + let o = window_options(false); assert!(o.centered, "centred on launch"); - assert!(!o.persist_window, "no saved position to restore instead"); + assert!(o.persist_window, "the window state is saved for the next launch"); + } + + /// #2595: a saved window state (size, position, maximized) is restored, not centred over. + #[test] + fn a_saved_window_reopens_where_it_was() { + let o = window_options(true); + assert!(!o.centered, "centring would move a restored window"); + assert!(o.persist_window); + } + + /// Only a readable `app.ron` with a window entry counts as a saved window state. + #[test] + fn a_saved_window_is_read_from_app_ron() { + assert!(saved_window(r#"{"cadcraft.prefs": "{}", "window": "(maximized: true)"}"#)); + assert!(!saved_window(r#"{"cadcraft.prefs": "{\"window\":1}"}"#), "a value mentioning it is not the key"); + assert!(!saved_window("")); + assert!(!saved_window("{\"window\": "), "truncated"); + assert!(!saved_window("not ron at all")); } /// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in; From 4a17ee78c24407cfc1470bb6a336ea3432ca0a60 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:26:28 +0100 Subject: [PATCH 107/151] Reject invalid fill angles in JSON arraypolar (#2655) Found while testing crates/engine/src/cmd/array.rs:380 (run_arraypolar, `fill: f64_or(p, "angle", 360.0)`) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/array.rs | 6 +++++- crates/engine/tests/it/array_commands.rs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 255555a28..da96ccfed 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -374,10 +374,14 @@ fn run_arraypolar(s: &mut Session, p: &Value) -> Result { check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?; let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c); let row_spacing = f64_or(p, "rowSpacing", 1.0); + let fill = f64_or(p, "angle", 360.0); + if !(fill.abs() >= 1e-9 && fill.abs() <= 360.0 + 1e-9) { + return Err(other("Requires a fill angle between -360 and 360 degrees (not 0).")); + } let kind = ArrayKind::Polar(PolarArray { center: c - base, items: count32(n), - fill: f64_or(p, "angle", 360.0), + fill: fill.clamp(-360.0, 360.0), rotate: bool_or(p, "rotate", true), rows: count32(rows), row_spacing, diff --git a/crates/engine/tests/it/array_commands.rs b/crates/engine/tests/it/array_commands.rs index d420c36d6..61d5a0d02 100644 --- a/crates/engine/tests/it/array_commands.rs +++ b/crates/engine/tests/it/array_commands.rs @@ -212,3 +212,20 @@ fn arrays_over_maxarray_are_refused_not_shrunk() { assert!(s.current_prompt().is_none(), "{}", prompt(&s)); assert_eq!(circle_centers(&s).len(), 2000); } + +#[test] +fn json_arraypolar_rejects_bad_fill_angles() { + let mut s = Session::new(); + let h = s.execute("circle", &json!({"center": [5, 0], "radius": 1})).unwrap()["handle"].clone(); + let before = s.doc().unwrap().model.iter().count(); + for bad in [0.0, 720.0, -400.0, 360.5] { + let p = json!({"handles": [h], "center": [0, 0], "count": 4, "angle": bad, "associative": false}); + let e = s.execute("arraypolar", &p).unwrap_err(); + assert!(e.to_string().contains("Requires a fill angle between -360 and 360 degrees"), "{bad}: {e}"); + assert_eq!(s.doc().unwrap().model.iter().count(), before, "{bad}"); + } + for ok in [360.0, -360.0] { + let p = json!({"handles": [h], "center": [0, 0], "count": 4, "angle": ok, "associative": false}); + s.execute("arraypolar", &p).unwrap(); + } +} From e6352d955ee4cc264fd5d26642d666c20cf8d062 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:26:33 +0100 Subject: [PATCH 108/151] Reject control characters in layer names (#2569) Found while testing crates/engine/src/cmd/layer.rs:184 (valid_name, used by run_new) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/tests/layer_name_control_chars.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/layer_name_control_chars.rs diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 806641ef6..c5fa89403 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -182,7 +182,7 @@ fn loaded_linetype(d: &cadcraft_doc::Drawing, cmd: &str, p: &Value) -> Result bool { - !n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) + !n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| c.is_control() || "<>/\\\":;?*|,=`".contains(c)) } fn run_new(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/tests/layer_name_control_chars.rs b/crates/engine/tests/layer_name_control_chars.rs new file mode 100644 index 000000000..4e00a1454 --- /dev/null +++ b/crates/engine/tests/layer_name_control_chars.rs @@ -0,0 +1,14 @@ +//! `layer.new` refuses control characters in a layer name (the DXF writer would turn them into +//! spaces and the name would change on reopen). + +use cadcraft_engine::Session; +use serde_json::json; + +#[test] +fn layer_new_rejects_control_characters() { + let mut s = Session::new(); + for name in ["A\nB", "A\tB", "A\u{0}B"] { + assert!(s.execute("layer.new", &json!({"name": name})).is_err(), "{name:?} was accepted"); + } + assert!(s.execute("layer.new", &json!({"name": "A B"})).is_ok()); +} From ad95791c206a2722294bf3b397f82df90f026a6e Mon Sep 17 00:00:00 2001 From: Ashok Ram Date: Tue, 3 Nov 2026 02:56:38 +0530 Subject: [PATCH 109/151] SVG import: a transform on a shape element moves the shape (#2602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVG 1.1 §7.5 lets every graphics element carry a transform. Only groups' transforms were applied, so a rect, circle, path or other shape with its own transform came in where it would be without one. on_element now composes the element's transform after its groups' for that element only, the way a group around it would. https://www.w3.org/TR/SVG11/coords.html#TransformAttribute Co-authored-by: ashokram-si <347905169+ashokram-si@users.noreply.github.com> --- crates/io/src/svg_read.rs | 17 ++++++++++++++-- crates/io/src/tests.rs | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/io/src/svg_read.rs b/crates/io/src/svg_read.rs index 21508fe7b..54ef9d846 100644 --- a/crates/io/src/svg_read.rs +++ b/crates/io/src/svg_read.rs @@ -1,8 +1,8 @@ //! SVG → Drawing: the static W3C SVG subset that drawing tools emit. //! //! Reads shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`), `path` -//! outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping with `transform` -//! attributes, `viewBox` and unit-bearing `width`/`height`, and Inkscape layers +//! outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping, `transform` on groups +//! and shapes, `viewBox` and unit-bearing `width`/`height`, and Inkscape layers //! (`inkscape:groupmode="layer"`). Geometry becomes centre-line entities in millimetres with //! the page's top-left corner at the origin and Y pointing up — where a laser or pen job //! wants it. @@ -262,6 +262,19 @@ impl Parser { } return Ok(()); } + // A shape's own `transform` (SVG 1.1 §7.5) maps it after its groups', for this element only. + let local = attr(e, b"transform").as_deref().and_then(parse_transform); + if let Some(local) = local { + self.stack.push(self.cur().then_before(local)); + } + let read = self.shape(name, e); + if local.is_some() { + self.stack.pop(); + } + read + } + + fn shape(&mut self, name: &[u8], e: &BytesStart) -> Result<()> { match name { b"use" => { self.left_out(LeftOut::Use); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 12d9f0a12..4aa427bdd 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -2299,6 +2299,48 @@ fn svg_group_transforms_apply() { assert!((l.b - Vec3::new(15.0, 8.0, 0.0)).len() < 1e-9, "{l:?}"); } +/// The geometry of everything read, for comparing two readings of one drawing. +fn geometry(d: &Drawing) -> String { + format!("{:?}", d.model.iter().map(|e| &e.kind).collect::>()) +} + +/// `transform` on a shape element (SVG 1.1 §7.5, every graphics element takes one) maps it as +/// the same transform on a group around it would: for every shape kind, composed after the +/// parent group's, and not passed on to the next sibling. +#[test] +fn svg_element_transforms_apply_like_a_group_around_the_element() { + let read_svg = |body: &str| read(format!("{SVG_PAGE}{body}").as_bytes(), "t.svg").unwrap(); + let shapes = [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ]; + for t in ["translate(5,2)", "rotate(30 5 5)", "scale(2,1)", "skewX(20)", "matrix(1 0.2 0 1 3 -1)"] { + for shape in shapes { + let on_element = shape.replacen("/>", &format!(r#" transform="{t}"/>"#), 1); + let in_group = format!(r#"{shape}"#); + assert_eq!(geometry(&read_svg(&on_element)), geometry(&read_svg(&in_group)), "{t} on {shape}"); + } + } + // A rotated circle stays a circle; a skewed one is an ellipse. + assert_eq!(kinds(&read_svg(r#""#)), ["Circle"]); + assert_eq!(kinds(&read_svg(r#""#)), ["Ellipse"]); + // Inside a group: the group's transform, then the element's. + let nested = read_svg(r#""#); + let both = read_svg(r#""#); + assert_eq!(geometry(&nested), geometry(&both)); + // Written with an end tag too, and the next sibling keeps its own place. + let d = read_svg(r#""#); + let lines: Vec = d.model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l.clone()) } else { None }).collect(); + assert_eq!(lines.len(), 2); + assert!((lines[0].a - Vec3::new(5.0, 10.0, 0.0)).len() < 1e-9, "{:?}", lines[0]); + assert!((lines[1].a - Vec3::new(0.0, 10.0, 0.0)).len() < 1e-9, "{:?}", lines[1]); +} + #[test] fn svg_viewbox_scaling_and_mm_units() { let svg = r#""#; From ea127d470204b2618e9e0fed3bfec0e838910fc4 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:26:43 +0700 Subject: [PATCH 110/151] Dynamic blocks: a point on a frame edge of any length is on it (#2640) * Dynamic blocks: a point on a frame edge of any length is on it The frame edge test used Line::dist, which takes a segment shorter than EPS (1e-9) for its first point: a point halfway along a frame edge that short was neither on the edge nor, by the even-odd test, inside, so its line moved whole instead of stretching. The distance to an edge is now the exact one, clamped to the segment. Tests also cover two rules from #2627 that had none: a point on an edge's line but past its end is outside the frame, and a polyline tilted by only 5e-10 (about a unit off at an elevation of 2e9) keeps its stored vertex list. Co-Authored-By: Claude Opus 5.5 * Dynamic blocks: measure a frame edge's residual from its start segment_dist added the projection back to the edge's start before measuring, so near x = 36 the residual was rounded to that magnitude's spacing (7e-15). A point 1.4e-15 off an edge 5e-10 long, outside the frame, then came out 4e-20 away, within the edge's 5e-19 tolerance, and was taken as on it. The residual is now measured from the edge's start, where those digits are kept. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/doc/src/dynblock/apply.rs | 19 ++++++++--- crates/io/tests/it/dynblock_read.rs | 50 ++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/dynblock/apply.rs b/crates/doc/src/dynblock/apply.rs index d5651faac..ecca36257 100644 --- a/crates/doc/src/dynblock/apply.rs +++ b/crates/doc/src/dynblock/apply.rs @@ -415,6 +415,17 @@ fn inside(frame: &[Vec2], points: impl Iterator) -> Option (!idx.is_empty()).then_some(idx) } +/// The distance from `p` to the segment `a`-`b`, however short (`Line::dist` takes one shorter than +/// `EPS` for its first point). It is measured from `a`, so a short segment far from the origin keeps +/// the digits rounding there would drop. +fn segment_dist(a: Vec2, b: Vec2, p: Vec2) -> f64 { + let d = b - a; + let w = p - a; + let l2 = d.len2(); + let t = if l2 > 0.0 { (w.dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + (w - d * t).len() +} + /// Whether point `p` lies inside a stretch frame (two corners, or a polygon). A point on its edge is /// inside, for a polygon as for two corners: within a billionth of the edge's length of it. fn in_frame(frame: &[Vec2], p: Vec2) -> bool { @@ -425,8 +436,7 @@ fn in_frame(frame: &[Vec2], p: Vec2) -> bool { let mut j = f.len() - 1; for i in 0..f.len() { let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break }; - let edge = cadcraft_geom::Line { a: *pj, b: *pi }; - if edge.dist(p) <= 1e-9 * pi.dist(*pj) { + if segment_dist(*pj, *pi, p) <= 1e-9 * pi.dist(*pj) { return true; } if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x { @@ -528,8 +538,9 @@ fn apply( // dynamic block has them, all of them when none is, as AutoCAD 2024 moves them. It // doesn't go by the vertex lists the action stores, which a block from another program // can get wrong. A polyline's vertices are in its own plane: in the block's when its - // extrusion is along z (a mirrored one is read flipped into it), so a tilted one keeps - // the stored list. + // extrusion is exactly along z (a mirrored one is read flipped into it). Any tilt, however + // small, moves them by as much as the elevation times the tilt, so a tilted one keeps the + // stored list. let idx = match def.entities.get(*h).map(|e| (&e.kind, e.common.extrusion)) { Some((EntityKind::Line(l), _)) if framed => inside(frame, [l.a.xy(), l.b.xy()].into_iter()), Some((EntityKind::LwPolyline(pl), n)) if framed && n.x == 0.0 && n.y == 0.0 => inside(frame, pl.vertices.iter().map(|v| v.p)), diff --git a/crates/io/tests/it/dynblock_read.rs b/crates/io/tests/it/dynblock_read.rs index bd9f2d1e1..251029410 100644 --- a/crates/io/tests/it/dynblock_read.rs +++ b/crates/io/tests/it/dynblock_read.rs @@ -482,13 +482,14 @@ fn a_zero_distance_multiplier_moves_as_one_without_its_angle_offset() { fn a_point_on_the_edge_of_a_polygon_frame_is_inside_it() { // Line 30 ends at (36, 0), and its start is outside every frame here. On the edge of the frame // (34,-2)-(36,2) given as two corners, the end is inside and moves alone. The same frame as a - // polygon, and triangles whose slanted edge passes through (36, 0), exactly or up to rounding, - // take it the same way. + // polygon, triangles whose slanted edge passes through (36, 0), exactly or up to rounding, and a + // square 5e-10 across with (36, 0) halfway along its right edge, take it the same way. let frames = [ "72 2\n1011 34\n1021 -2\n1011 36\n1021 2\n", "72 4\n1011 34\n1021 -2\n1011 36\n1021 -2\n1011 36\n1021 2\n1011 34\n1021 2\n", "72 3\n1011 34\n1021 -2\n1011 38\n1021 2\n1011 34\n1021 2\n", "72 3\n1011 35.7\n1021 -0.3\n1011 36.7\n1021 0.7\n1011 30\n1021 10\n", + "72 4\n1011 35.9999999995\n1021 -2.5e-10\n1011 36\n1021 -2.5e-10\n1011 36\n1021 2.5e-10\n1011 35.9999999995\n1021 2.5e-10\n", ]; for frame in frames { let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); @@ -512,29 +513,62 @@ fn a_point_just_outside_a_tiny_frame_is_outside_it() { assert_eq!(shapes(&d, r)[0], [(-6.0, 0.0), (30.0, 0.0)]); } +#[test] +fn a_point_less_than_a_rounding_off_a_short_edge_is_outside_it() { + // A triangle frame whose right edge runs from (35.99999999999999, -2e-10) to + // (36.00000000000001, 3e-10): line 30's end (36, 0) is 1.4e-15 off it, past a billionth of the + // edge's length (5e-19), and outside the frame, so the line moves whole. Measured from the + // edge's start, the distance keeps digits that rounding near x = 36 would drop. + let frame = "72 3\n1011 35.99999999999999\n1021 -2e-10\n1011 36.00000000000001\n1021 3e-10\n1011 35.999999999\n1021 0\n"; + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(-6.0, 0.0), (30.0, 0.0)]); +} + +#[test] +fn a_point_past_the_end_of_a_frame_edge_is_outside_it() { + // The frame's right edge runs along x = 36 from y = -2 to -1: line 30's end (36, 0) is on that + // edge's line but past its end, outside the frame, so the line moves whole. + let frame = "72 4\n1011 34\n1021 -2\n1011 36\n1021 -2\n1011 36\n1021 -1\n1011 34\n1021 -1\n"; + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(-6.0, 0.0), (30.0, 0.0)]); +} + #[test] fn a_tilted_polyline_keeps_the_stored_vertex_list() { // Polyline 34 lies in a plane tilted about the y axis (extrusion (1,0,0)): its vertices are in // that plane's coordinates, not the block's, so the frame isn't tested against them and the - // stored list (vertex 0) is used, as before. Polyline 35 is mirrored (extrusion (0,0,-1)): it + // stored list (vertex 0) is used, as before; so is polyline 36's, tilted by only 5e-10 (at an + // elevation of 2e9 that moves it about a unit). Polyline 35 is mirrored (extrusion (0,0,-1)): it // is read flipped into the block's plane, (-20, 1) as (20, 1), so the frame is tested. - let polyline = |h: &str, [x0, x1]: [i32; 2], [ex, ey, ez]: [i32; 3]| { + let polyline = |h: &str, [x0, x1]: [i32; 2], [ex, ey, ez]: [f64; 3], elevation: f64| { format!( - "0 LWPOLYLINE\n5 {h}\n330 1D\n100 AcDbEntity\n8 0\n100 AcDbPolyline\n90 2\n70 0\n10 {x0}\n20 1\n10 {x1}\n20 1\n210 {ex}\n220 {ey}\n230 {ez}\n" + "0 LWPOLYLINE\n5 {h}\n330 1D\n100 AcDbEntity\n8 0\n100 AcDbPolyline\n90 2\n70 0\n38 {elevation}\n10 {x0}\n20 1\n10 {x1}\n20 1\n210 {ex}\n220 {ey}\n230 {ez}\n" ) }; let src = fixture() - .replace("71 1\n330 30\n1010 40", "71 3\n330 30\n330 34\n330 35\n1010 40") - .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 3\n331 30\n74 1\n94 1\n331 34\n74 1\n94 0\n331 35\n74 1\n94 0\n75 0") + .replace("71 1\n330 30\n1010 40", "71 4\n330 30\n330 34\n330 35\n330 36\n1010 40") + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 4\n331 30\n74 1\n94 1\n331 34\n74 1\n94 0\n331 35\n74 1\n94 0\n331 36\n74 1\n94 0\n75 0") .replace( "21 25\n31 0\n0 ENDBLK\n", - &format!("21 25\n31 0\n{}{}0 ENDBLK\n", polyline("34", [20, 36], [1, 0, 0]), polyline("35", [-20, -36], [0, 0, -1])), + &format!( + "21 25\n31 0\n{}{}{}0 ENDBLK\n", + polyline("34", [20, 36], [1.0, 0.0, 0.0], 0.0), + polyline("35", [-20, -36], [0.0, 0.0, -1.0], 0.0), + polyline("36", [20, 36], [5e-10, 0.0, 1.0], 2e9) + ), ); let (mut d, r) = door_reference(&src); cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); let s = shapes(&d, r); assert_eq!(s[3], [(14.0, 1.0), (36.0, 1.0)]); assert_eq!(s[4], [(20.0, 1.0), (30.0, 1.0)]); + assert_eq!(s[5], [(14.0, 1.0), (36.0, 1.0)]); } #[test] From facae4a9a61ff8b894183cc2354e7d2246f6198d Mon Sep 17 00:00:00 2001 From: SoldierPePe Date: Tue, 3 Nov 2026 00:26:48 +0300 Subject: [PATCH 111/151] plot and exportpdf help name the `device` parameter (#2557) (#2567) * plot and exportpdf help name the `device` parameter (#2557) `cadcraft-cli commands plot` didn't list `device`, so the only way to find it was the `unknown plotter` error. PLOT's help now lists `device` with the built-in devices (CADCraft PDF, PNG, SVG), PLOTTERMANAGER plotters and "None". It also says the default is the page setup's device and that the device picks the output format and the papers. EXPORTPDF's help names `device` too, and notes that its output stays PDF. A test checks that both help texts name `device` and that PLOT's names every built-in device. * plot help: say what Model, "None" and an omitted paper do In model space, and with "None", no device is used and the plot is a PDF. When `paper` is omitted, a layout keeps its page setup's paper and model space takes the device's default. --- crates/engine/src/cmd/layout.rs | 4 ++-- crates/engine/src/cmd/layout/tests.rs | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index efe9bf13f..0c78e19cb 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -84,11 +84,11 @@ pub fn specs() -> Vec { .menu(&["File", "Print..."]) .key("Cmd+P") .alias(&["print"]) - .params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)") + .params("{path? (else returns base64 `data`), layout?: current|\"Model\", device?: \"CADCraft PDF\"|\"CADCraft PNG\"|\"CADCraft SVG\" | a plotter from PLOTTERMANAGER | \"None\" (default: a layout's page setup device; Model, or \"None\": no device, PDF; a device sets the output format and the papers), paper?: the device's paper (default: a layout keeps its page setup's; Model takes the device's default), landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)") .noundo(), CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf")) .menu(&["File", "Export to PDF..."]) - .params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)") + .params("{path? (else returns base64 `data`), layout?, device? (its papers; the output stays PDF), paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)") .noundo(), CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace) .alias(&["ms"]) diff --git a/crates/engine/src/cmd/layout/tests.rs b/crates/engine/src/cmd/layout/tests.rs index 03356b892..4a631ccb0 100644 --- a/crates/engine/src/cmd/layout/tests.rs +++ b/crates/engine/src/cmd/layout/tests.rs @@ -293,3 +293,17 @@ fn mspace_edits_model_through_viewport() { s.execute("layout.set", &json!({"name": "Model"})).unwrap(); assert!(s.state().unwrap().mspace.is_none()); } + +/// PLOT and EXPORTPDF help name the `device` parameter, and PLOT's help every built-in device (#2557). +#[test] +fn plot_help_names_the_device_and_the_built_in_devices() { + for id in ["plot", "exportpdf"] { + let help = crate::find_command(id).map(|c| c.params).unwrap_or_default(); + assert!(help.contains("device?"), "{id}: {help}"); + } + let help = crate::find_command("plot").map(|c| c.params).unwrap_or_default(); + for name in ["CADCraft PDF", "CADCraft PNG", "CADCraft SVG"] { + assert!(super::super::plotters::find_device(name).is_some(), "{name} is not a device"); + assert!(help.contains(name), "plot help lacks {name}"); + } +} From 0e7c71759cede6da1b2c0df6660e94fdaccf64fe Mon Sep 17 00:00:00 2001 From: Tyler Chiriboga Date: Mon, 2 Nov 2026 15:26:53 -0600 Subject: [PATCH 112/151] test(ui): the IME double-click test passes without craft-fonts (#2652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `double_clicked_text_takes_ime_composition_and_commit` commits 中文 at the command line. Without a CJK font (no CRAFT_FONTS_DIR, none installed, as on a plain Linux CI image) the kittest harness panics on the first missing glyph: "No glyph for 中 in Proportional". Code and tests must work without craft-fonts. The test now asks the harness's own fonts whether they can draw 中文 and, only when they can't, lets the command line draw boxes for it. The IME path it checks is unchanged: the pre-edit is shown and not saved, the commit is inserted once, Enter stores 中文 in the text. With a CJK font the harness still fails on any missing glyph. Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/tests/it/text_double_click.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/tests/it/text_double_click.rs b/crates/ui-egui/tests/it/text_double_click.rs index 5cedfbf61..f585ae46a 100644 --- a/crates/ui-egui/tests/it/text_double_click.rs +++ b/crates/ui-egui/tests/it/text_double_click.rs @@ -73,6 +73,16 @@ fn double_clicked_text_takes_ime_composition_and_commit() { let mut h = harness(); double_click(&mut h, Vec2::new(4.0, 1.2)); assert_eq!(h.state().session.current_prompt().unwrap().message, "Enter new text"); + // Without a CJK font (none installed, no craft-fonts) the command line draws 中文 as boxes, + // which the harness takes for a bug. The IME path below is the same either way. + let drawable = h.ctx.fonts_mut(|view| { + [egui::FontFamily::Proportional, egui::FontFamily::Monospace] + .into_iter() + .all(|family| view.has_glyphs(&egui::FontId::new(12.0, family), "中文")) + }); + if !drawable { + h.ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Tofu); + } // Composition is shown, not saved; the commit is inserted exactly once (#1761). h.event(Event::Ime(ImeEvent::Preedit { text: "zhong".into(), active_range_chars: None })); h.run_steps(1); From f2cb7e6e8f704c37b299b42d64f8bb9a9724d632 Mon Sep 17 00:00:00 2001 From: Rahul Date: Tue, 3 Nov 2026 02:56:59 +0530 Subject: [PATCH 113/151] i18n(zh): translate the 74 strings the report listed as missing (#2638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chinese catalog lacked 74 interface strings: the block editor prompts and dialog, the alias editor sentences, the selection visual effects and preview filter, units of new drawings, and the menu names Dimension, Draw, Modify and View. Adds them in the catalog's TSV format with the AutoCAD zh-CN vocabulary the catalog already uses (夹点, 图案填充, 外部参照, 多行文字, 孤岛). After this the report lists nothing missing for zh. Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- crates/ui-egui/src/i18n/zh.tsv | 76 ++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 21c86d1fe..9ca85fea7 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -1381,3 +1381,79 @@ Save Layout as Template 将布局另存为样板 Set Dynamic Property 设置动态特性 Units 单位 + +# The strings the report listed as missing for zh. + Add… 添加… + Alias 别名 + Alias file: {file} 别名文件:{file} + Associative 关联 + AutoLISP (LSP) AutoLISP (LSP) + B B + Backup copy 备份副本 + Block to create or edit 要创建或编辑的块 + Choose the command the alias starts. 选择别名要启动的命令。 + Click the ruler to add a tab stop, right-click to remove one 单击标尺可添加制表位,右键单击可将其删除 + Command 命令 + Create backup copy with each save 每次保存时创建备份副本 + Crossing selection color: 交叉选择颜色: + Custom 自定义 + Define each corner of the selection window with a separate click. 分别单击以定义选择窗口的各个角点。 + Discard 放弃 + Edit Block Definition 编辑块定义 + Edit… 编辑… + Exclude: 排除: + Font 字体 + Grips 夹点 + Hatches 图案填充 + Imperial (inches) 英制(英寸) + Indicate selection area 指示选择区域 + Interface scale 界面缩放 + Island 孤岛 + Italic 斜体 + Metric (millimetres) 公制(毫米) + More than {n} aliases. 超过 {n} 个别名。 + Multiline text 多行文字 + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). 启动时的新建图形和第一个图形使用这些单位,除非设置了默认样板 (QNEWTEMPLATE)。 + New block 新建块 + No alias file loaded. REINIT replaces these aliases with a file's. 未加载别名文件。REINIT 将用文件中的别名替换这些别名。 + Object grouping 对象编组 + Object selection limit for display of grips 显示夹点时对象选择的数目上限 + Objects on locked layers 锁定图层上的对象 + Open in Block Editor 在块编辑器中打开 + Origin: {x}, {y} 原点:{x}, {y} + Overline 上划线 + Save alias 保存别名 + Save changes to block "{name}"? 保存对块“{name}”的更改吗? + Select boundaries to remove 选择要删除的边界 + Select using two clicks only 仅用两次单击选择 + Selection Area Effect 选择区域效果 + Selection Preview Filter 选择预览过滤器 + Selection area opacity: 选择区域不透明度: + Selection effect color 选择效果颜色 + Selection modes 选择模式 + Selection preview 选择预览 + Show grips 显示夹点 + Tables 表格 + Text color 文字颜色 + This drawing 此图形 + Type an alias name. 输入别名名称。 + Underline 下划线 + Units of new drawings 新图形的单位 + Unsaved changes 未保存的更改 + Visual Effect Settings 视觉效果设置 + Visual Effect Settings... 视觉效果设置... + When a command is active 命令处于活动状态时 + When no command is active 无活动命令时 + Window selection color: 窗口选择颜色: + X X + Xrefs 外部参照 + Y Y + built-in 内置 + contains authoring elements. Open in Block Editor? 包含编写元素。在块编辑器中打开吗? + overrides built-in 覆盖内置 + {alias} is already defined; select it and use Edit. {alias} 已定义;请选择它并使用编辑。 + {command} is an unknown command. {command} 是未知命令。 +menu Dimension 标注 +menu Draw 绘图 +menu Modify 修改 +menu View 视图 From a60d437f0a5c325e462daecaeaaf79f551b91366 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 14:27:33 -0700 Subject: [PATCH 114/151] WBLOCK: new handles start above those of the copied blocks (#1996) (#2659) Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> --- crates/engine/src/cmd/blocks/wblock.rs | 5 ++ crates/engine/tests/wblock_units_handles.rs | 57 +++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 crates/engine/tests/wblock_units_handles.rs diff --git a/crates/engine/src/cmd/blocks/wblock.rs b/crates/engine/src/cmd/blocks/wblock.rs index bc2ff711a..e06e3a71a 100644 --- a/crates/engine/src/cmd/blocks/wblock.rs +++ b/crates/engine/src/cmd/blocks/wblock.rs @@ -113,6 +113,11 @@ fn with_objects(src: &Drawing, ents: impl Iterator) -> Drawing { d.scales = src.scales.clone(); d.blocks = src.blocks.clone(); d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO)); + // The copied blocks keep their objects' handles, so the new ones start above them. + let kept: Vec = d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.handle)).collect(); + for h in kept { + d.bump_handseed(h); + } for mut e in ents { e.handle = d.new_handle(); d.model.push(e); diff --git a/crates/engine/tests/wblock_units_handles.rs b/crates/engine/tests/wblock_units_handles.rs new file mode 100644 index 000000000..52a4d6053 --- /dev/null +++ b/crates/engine/tests/wblock_units_handles.rs @@ -0,0 +1,57 @@ +//! WBLOCK writes the source's units, only the blocks the written objects use, and handles that +//! never repeat. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use serde_json::json; + +/// Writes "insunits;block names;handles;distinct handles" so the test can read what would be saved. +fn write(d: &Drawing, _path: &str) -> Result, String> { + let mut handles: Vec = d.model.iter().map(|e| e.handle.0).collect(); + let mut names = Vec::new(); + for b in d.blocks.values() { + names.push(b.name.clone()); + handles.extend(b.entities.iter().map(|e| e.handle.0)); + } + let total = handles.len(); + handles.sort_unstable(); + handles.dedup(); + Ok(format!("{};{};{};{}", d.header.i64("INSUNITS", -1), names.join(","), total, handles.len()).into_bytes()) +} + +fn written(s: &mut Session, p: serde_json::Value) -> String { + let path = std::env::temp_dir().join(format!("cadcraft-wblock-units-{}.dxf", std::process::id())); + let mut p = p; + p["path"] = json!(path.to_string_lossy()); + s.execute("wblock", &p).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + text +} + +fn line(s: &mut Session, y: f64) -> String { + s.execute("line", &json!({ "points": [[0, y], [1, y]] })).unwrap()["handles"][0].as_str().unwrap().to_string() +} + +#[test] +fn wblock_keeps_units_drops_unused_blocks_and_repeats_no_handle() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + s.execute("units", &json!({ "insunits": 6 })).unwrap(); + // Block "Part" is made first, so its line has one of the drawing's lowest handles. + let p = line(&mut s, 0.0); + let ins = s.execute("block", &json!({ "name": "Part", "handles": [p], "keep": "convert" })).unwrap()["insert"].as_str().unwrap().to_string(); + let mut picked = vec![ins]; + for i in 1..300 { + picked.push(line(&mut s, f64::from(i) * 0.01)); + } + let u = line(&mut s, 5.0); + s.execute("block", &json!({ "name": "Unused", "handles": [u], "keep": "delete" })).unwrap(); + + // 300 objects (the insert and 299 lines) plus Part's line: the source's units, no Unused, + // and every handle distinct although the new objects are numbered from the start again. + assert_eq!(written(&mut s, json!({ "handles": picked })), "6;Part;301;301"); + // A block written by name: its objects go to model space, its definition stays behind. + assert_eq!(written(&mut s, json!({ "name": "Unused" })), "6;;1;1"); +} From 0f56c16d00902d7ea5475fc6858aea924c0e558e Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 14:27:45 -0700 Subject: [PATCH 115/151] EXPORT and WBLOCK say what a DWG file leaves out (#2240) (#2660) Co-authored-by: assamaral <217119886+assamaral@users.noreply.github.com> --- crates/engine/src/cmd/blocks/wblock.rs | 40 ++++++++++++++----- crates/engine/src/cmd/exchange.rs | 14 ++++--- crates/engine/src/cmd/file.rs | 2 +- crates/engine/tests/save_reports_not_saved.rs | 32 +++++++++++++++ 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/blocks/wblock.rs b/crates/engine/src/cmd/blocks/wblock.rs index e06e3a71a..fb03fe1af 100644 --- a/crates/engine/src/cmd/blocks/wblock.rs +++ b/crates/engine/src/cmd/blocks/wblock.rs @@ -1,11 +1,14 @@ //! WBLOCK (its JSON form) and -WBLOCK at the command line: objects, a block or the whole drawing //! written to a drawing file. +use std::collections::BTreeMap; + use cadcraft_doc::{Drawing, Entity, Handle}; use cadcraft_geom::{Mat3, Vec2, Vec3}; use serde_json::{Value, json}; use super::super::curves::KW; +use super::super::file::{drops_unknown, not_saved, not_saved_message, report_not_saved}; use super::super::machines::{SelOutcome, SelectPhase}; use super::super::modify::erase_kept; use super::super::{CommandSpec, bad, point_param, str_param, targets}; @@ -40,11 +43,13 @@ enum Source<'a> { fn run_wblock(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string(); let base = point_param(p, "base"); - let (entities, bytes) = match str_param(p, "name") { + let (entities, bytes, lost) = match str_param(p, "name") { Some(name) => write(s, "wblock", &path, Source::Block(name, base))?, None => write(s, "wblock", &path, Source::Objects(&targets(s, p)?, base.unwrap_or(Vec2::ZERO)))?, }; - Ok(json!({ "path": path, "entities": entities, "bytes": bytes })) + let mut r = json!({ "path": path, "entities": entities, "bytes": bytes }); + report_not_saved(s, &mut r, &lost); + Ok(r) } fn run_dash_wblock(s: &mut Session, p: &Value) -> Result { @@ -53,7 +58,7 @@ fn run_dash_wblock(s: &mut Session, p: &Value) -> Result { } let path = str_param(p, "path").ok_or_else(|| bad("-wblock", "`path` is required"))?.to_string(); let base = point_param(p, "base"); - let (entities, bytes) = match str_param(p, "name") { + let (entities, bytes, lost) = match str_param(p, "name") { Some(name) => write(s, "-wblock", &path, Source::Block(name, base))?, None => { let hs = targets(s, p)?; @@ -66,14 +71,17 @@ fn run_dash_wblock(s: &mut Session, p: &Value) -> Result { written } }; - Ok(json!({ "path": path, "entities": entities, "bytes": bytes })) + let mut r = json!({ "path": path, "entities": entities, "bytes": bytes }); + report_not_saved(s, &mut r, &lost); + Ok(r) } -/// Write `what` to `path` through the io hooks; the objects written and the file's size. The +/// Write `what` to `path` through the io hooks; the objects written, the file's size and what a +/// DWG file left out, for the caller to report as a save does. The /// written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its units /// ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches and /// scaled on insert, and its defaults (text size, current styles, annotation scales). -fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usize)> { +fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usize, BTreeMap)> { let src = s.doc()?; // The base point becomes the written drawing's origin: by default 0,0 for objects and the // block's base point for a block. @@ -94,7 +102,15 @@ fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usi // Through a temporary file, as SAVE writes, so a failed write keeps the file it replaces. #[cfg(not(target_arch = "wasm32"))] crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?; - Ok((d.model.len(), bytes.len())) + let lost = if drops_unknown(path) { not_saved(&d) } else { BTreeMap::new() }; + Ok((d.model.len(), bytes.len(), lost)) +} + +/// Say on the command line what the written DWG file left out. +fn echo_not_saved(s: &mut Session, lost: &BTreeMap) { + if let Some(msg) = not_saved_message(lost) { + s.echo(msg); + } } /// A new drawing of `ents`, with `src`'s settings and the layers, linetypes, styles and blocks @@ -310,8 +326,9 @@ impl DashWblockM { } fn write(&self, s: &mut Session, what: Source) -> Result { - if let Err(e) = write(s, "-wblock", &self.path, what) { - s.echo(e.to_string()); + match write(s, "-wblock", &self.path, what) { + Ok((.., lost)) => echo_not_saved(s, &lost), + Err(e) => s.echo(e.to_string()), } Ok(Step::Done) } @@ -321,7 +338,10 @@ impl DashWblockM { return Ok(Step::Done); } match write(s, "-wblock", &self.path, Source::Objects(hs, self.base)) { - Ok(_) => erase(s, hs)?, + Ok((.., lost)) => { + echo_not_saved(s, &lost); + erase(s, hs)?; + } Err(e) => s.echo(e.to_string()), } Ok(Step::Done) diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index f6ce1e9b6..d4993566a 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -117,14 +117,18 @@ fn run_export(s: &mut Session, p: &Value) -> Result { if !EXPORT_FORMATS.contains(&format.as_str()) { return Err(bad("export", "`format` must be dxf, dwg, svg, png or pdf (or give a `path` with one of those extensions)")); } - let bytes = (hooks.write)(s.doc()?, &format!("export.{format}")).map_err(|e| bad("export", e))?; - match path { + let file = format!("export.{format}"); + let lost = super::file::loss_on_save(s, &file); + let bytes = (hooks.write)(s.doc()?, &file).map_err(|e| bad("export", e))?; + let mut r = match path { Some(path) => { write_file("export", path, &bytes)?; - Ok(json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") })) + json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") }) } - None => Ok(json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() })), - } + None => json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() }), + }; + super::file::report_not_saved(s, &mut r, &lost); + Ok(r) } // ---------- IMPORT ---------- diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3b24e87bd..6a8d50384 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -801,7 +801,7 @@ fn is_drawing_file(path: impl AsRef) -> bool { } /// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`. -fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap) { +pub(super) fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap) { let Some(msg) = not_saved_message(lost) else { return }; s.echo(msg); if let Some(o) = r.as_object_mut() { diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index 24a2dff92..e31f2ec77 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -52,6 +52,38 @@ fn save_lists_the_entities_the_file_does_not_keep() { assert!(r.get("notSaved").is_none() && s.log.len() == n); } +#[test] +fn export_and_wblock_say_what_a_dwg_leaves_out() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let mut d = Drawing::new_imperial(); + let h = d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET")).unwrap().hex(); + let mut s = Session::empty(); + s.open_drawing(d, "foreign.dxf", None); + let file = |ext: &str| std::env::temp_dir().join(format!("cadcraft-leaves-out-{}.{ext}", std::process::id())).to_string_lossy().into_owned(); + let msg = "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): 1 ACME_WIDGET"; + + let (dwg, dxf) = (file("dwg"), file("dxf")); + for (cmd, p) in [("export", json!({ "path": dwg })), ("wblock", json!({ "path": dwg, "handles": [h] }))] { + s.log.clear(); + let r = s.execute(cmd, &p).unwrap(); + assert_eq!(r["notSaved"], json!({ "ACME_WIDGET": 1 }), "{cmd}"); + assert!(s.log.iter().any(|l| l == msg), "{cmd}: {:?}", s.log); + } + // -WBLOCK takes the objects out of the drawing, so last. + let r = s.execute("-wblock", &json!({ "path": dwg, "handles": [h] })).unwrap(); + assert_eq!(r["notSaved"], json!({ "ACME_WIDGET": 1 })); + let _ = std::fs::remove_file(&dwg); + + // DXF keeps them. + s.execute("undo", &json!({})).unwrap(); + for (cmd, p) in [("export", json!({ "path": dxf })), ("wblock", json!({ "path": dxf, "handles": [h] }))] { + s.log.clear(); + assert!(s.execute(cmd, &p).unwrap().get("notSaved").is_none(), "{cmd}"); + assert!(!s.log.iter().any(|l| l == msg), "{cmd}: {:?}", s.log); + } + let _ = std::fs::remove_file(&dxf); +} + #[test] fn templates_report_what_dwg_does_not_keep() { use cadcraft_engine::cmd::file::drops_unknown; From 97679d8cffa6d0d57b84d52eb25b18a14d429ebd Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 14:29:51 -0700 Subject: [PATCH 116/151] Ask before a save drops solids from a 2013+ DXF (#2334) The interactive save only knew about DWG, so overwriting a 2013+ DXF lost SAT-text solids on a command-line note. loss_on_save now lists them, and EXPORT reports them with the DXF wording. --- crates/engine/src/cmd/exchange.rs | 3 +- crates/engine/src/cmd/file.rs | 18 ++++++++++- crates/engine/tests/save_reports_not_saved.rs | 32 +++++++++++++++++++ crates/ui-egui/src/save_loss.rs | 10 ++++-- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index d4993566a..1be19a408 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -118,7 +118,6 @@ fn run_export(s: &mut Session, p: &Value) -> Result { return Err(bad("export", "`format` must be dxf, dwg, svg, png or pdf (or give a `path` with one of those extensions)")); } let file = format!("export.{format}"); - let lost = super::file::loss_on_save(s, &file); let bytes = (hooks.write)(s.doc()?, &file).map_err(|e| bad("export", e))?; let mut r = match path { Some(path) => { @@ -127,7 +126,7 @@ fn run_export(s: &mut Session, p: &Value) -> Result { } None => json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() }), }; - super::file::report_not_saved(s, &mut r, &lost); + super::file::report_loss(s, &mut r, &file); Ok(r) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 80244bc21..012a1d381 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -794,13 +794,29 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap) -> Op /// when the format keeps everything. The interactive save asks with this; [`save_reporting`] /// reports the same list afterwards to callers that never get a dialog. pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap { + let version = drawing_version(s, path); + if is_dxf_file(path) && version >= Version::R2013 { + // The drawing a file holds: the host while the Block Editor is open. + return s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default(); + } if !drops_unknown(path) { return Default::default(); } - let version = drawing_version(s, path); s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default() } +/// Report what the file `path`, just written at the drawing's own version, left out. +pub(super) fn report_loss(s: &mut Session, r: &mut Value, path: &str) { + if is_dxf_file(path) { + if drawing_version(s, path) >= Version::R2013 { + report_sat_text_dropped(s, r); + } + } else { + let lost = loss_on_save(s, path); + report_not_saved(s, r, &lost); + } +} + /// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG /// bridge drops types it doesn't know); DXF writes them back as read. pub fn drops_unknown(path: impl AsRef) -> bool { diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index cd73f637d..984d0d635 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -150,6 +150,38 @@ fn saving_sat_text_solids_as_dxf_2013_reports_them() { assert!(r.get("notSaved").is_none() && s.log.len() == n); } +/// The interactive save asks with the same list, and EXPORT reports it, so overwriting a 2013+ +/// DXF never drops solids on a command-line note alone (#2334). +#[test] +fn a_dxf_2013_loss_is_known_before_saving() { + use cadcraft_engine::cmd::file::loss_on_save; + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0 +EOF +".to_vec()), plot: None }); + let sat = |d: &mut Drawing| { + let tags = vec![RawTag { code: 8, value: "0".into() }, RawTag { code: 1, value: "400 1 1 0".into() }]; + d.add(&Space::Model, Common::default(), EntityKind::Unknown(Unknown { dxf_type: "REGION".into(), tags })).unwrap(); + }; + let mut modern = Drawing::new_imperial(); + modern.header.set_str("ACADVER", "AC1032"); + sat(&mut modern); + let mut s = Session::empty(); + s.open_drawing(modern, "region.dxf", None); + assert_eq!(loss_on_save(&s, "region.dxf"), [("REGION".to_string(), 1)].into_iter().collect()); + + let path = std::env::temp_dir().join(format!("cadcraft-sat-export-{}.dxf", std::process::id())); + let r = s.execute("export", &json!({ "path": path.to_string_lossy() })).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(r["notSaved"], json!({ "REGION": 1 })); + assert!(s.log.iter().any(|l| l.contains("Not saved in the DXF file")), "{:?}", s.log); + + let mut old = Drawing::new_imperial(); + old.header.set_str("ACADVER", "AC1024"); + sat(&mut old); + s.open_drawing(old, "region.dxf", None); + assert!(loss_on_save(&s, "region.dxf").is_empty(), "2010 keeps SAT text"); +} + /// In the Block Editor the file still holds the host drawing: its solids and version decide the /// note, not the edited block's (#2334). #[test] diff --git a/crates/ui-egui/src/save_loss.rs b/crates/ui-egui/src/save_loss.rs index 50e77978b..2b0a28c57 100644 --- a/crates/ui-egui/src/save_loss.rs +++ b/crates/ui-egui/src/save_loss.rs @@ -1,5 +1,6 @@ //! Asking before a save throws content away. DWG can't yet hold everything a drawing read from -//! another program keeps (`cadcraft_engine::cmd::file::not_saved`), and QSAVE replaces the file +//! another program keeps (`cadcraft_engine::cmd::file::not_saved`), a DXF of 2013 or later can't +//! hold a solid's SAT text (`cadcraft_doc::foreign::sat_text_dropped`), and QSAVE replaces the file //! that still has it, so File ▸ Save, its shortcut or typing QSAVE says what would go first: //! Save Anyway / Save As... / Cancel. Agreeing lasts for that same loss on that same file, so //! later saves in the session don't ask again; the file no longer holds what was agreed to. @@ -120,7 +121,12 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) { ui.label(RichText::new(crate::tf!("Saving {name} will remove content", name = file)).strong()); ui.add_space(4.0); ui.label(crate::tf!("This format can't keep these objects yet: {objects}.", objects = pending.summary())); - ui.label(RichText::new(crate::tl!("DXF keeps them. Use Save As to keep them in the drawing.")).color(t.text_dim)); + let keeps = if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("dxf")) { + crate::tl!("DXF 2010 and earlier keep them. Use Save As to keep them in the drawing.") + } else { + crate::tl!("DXF keeps them. Use Save As to keep them in the drawing.") + }; + ui.label(RichText::new(keeps).color(t.text_dim)); let on_disk = if save_keeps_current(path) { crate::tf!("The current file is kept as {name} until the next save.", name = backup) } else if isavebak() { From 8de925e1d32b397073e50786ac84fa130896a814 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:36:32 +0700 Subject: [PATCH 117/151] Write option prompts without "or", as AutoCAD does (#2540) * Write option prompts without "or", as AutoCAD does AutoCAD writes " or [" only when a prompt asks for something besides its keywords ("Enter block name or [?]"); a prompt asking for one of them has none ("Enter an option [...]", "Enter layout option [...]", "New color [Truecolor/COlorbook]"). The 27 messages read without "or" from AutoCAD 2024's command line are listed in NO_OR and displayed that way. Co-Authored-By: Claude Opus 5.5 * Address review: the window's command line drops "or" as the prompt does The command line in the window draws a prompt's keywords itself and wrote " or [" after every message, so -LAYER still read "Enter an option or [?/Make/...]" there, and questions such as AUDIT's had "or" too. Only the prompt text (the control channel, the state JSON, the tests) had dropped it. Prompt::keyword_lead gives what goes before the keywords; display() and the command line both use it, and the command line translates only " or [". A render test reads what the command line draws, in English and German, for -LAYER, LINE and AUDIT. Suggested in review. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/prompt.rs | 66 +++++++++++++++++++---- crates/engine/tests/it/assoc_array.rs | 2 +- crates/engine/tests/it/layout_prompts.rs | 2 +- crates/engine/tests/it/vplayer_prompts.rs | 4 +- crates/ui-egui/src/cmdline.rs | 7 ++- crates/ui-egui/src/i18n/tests.rs | 29 ++++++++++ 6 files changed, 96 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index 1563be702..e3498dcf0 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -26,6 +26,41 @@ impl Accept { pub const SELECT: Accept = Accept { point: true, number: false, text: false, select: true, enter: true }; } +/// Prompts that ask for one of their keywords, which AutoCAD 2024 writes without "or" before them: +/// "Enter an option [?/Make/Set/…]:", "Enter layout option [Copy/Delete/…/?] :", "New color +/// [Truecolor/COlorbook] :". A prompt asking for something else besides its keywords has +/// it: "Enter block name or [?]:", "Specify insertion base point or [Basepoint/…]:". Read from +/// AutoCAD's command line over measured runs, where no prompt was written both ways. +const NO_OR: [&str; 27] = [ + "Bring to front", + "Calculate rectangle dimensions based on", + "Choose mode for selected object conversion", + "Create annotative block", + "Enter a configuration option", + "Enter a plotting preference", + "Enter an annotation option", + "Enter an option", + "Enter an option to change", + "Enter attribute visibility setting", + "Enter justification", + "Enter layout option", + "Enter leader format option", + "Enter line spacing type", + "Enter object ordering option", + "Enter polyline linetype generation option", + "Enter property to change", + "Enter the type of array", + "Enter type of unused objects to purge", + "Enter viewport arrangement", + "Explode block", + "New color", + "Orient relative to sheet in paper space viewports", + "Select a content type", + "Select a leader type", + "Use landing", + "Viewport View Locking", +]; + /// A prompt shown on the command line. #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -148,15 +183,7 @@ impl Prompt { } let mut s = self.message.clone(); if !self.keywords.is_empty() { - // A question offers its answers ("Fix any errors detected? [Yes/No]"); anything else - // offers options besides what it asks for ("Specify next point or [Undo]"). - s.push_str(if self.message.is_empty() { - "[" - } else if self.message.ends_with('?') { - " [" - } else { - " or [" - }); + s.push_str(self.keyword_lead()); s.push_str(&self.keywords.join("/")); s.push(']'); } @@ -166,6 +193,20 @@ impl Prompt { s.push(':'); s } + /// What goes between the message and its keywords. A question offers its answers ("Fix any + /// errors detected? [Yes/No]"), and so does a prompt for an option ([`NO_OR`]): " [". Anything + /// else offers options besides what it asks for ("Specify next point or [Undo]"): " or [". With + /// no message, "[". The command line in the window draws the keywords itself and takes this + /// too. + pub fn keyword_lead(&self) -> &'static str { + if self.message.is_empty() { + "[" + } else if self.message.ends_with('?') || NO_OR.contains(&self.message.as_str()) { + " [" + } else { + " or [" + } + } /// Match typed text against keywords: full word or the capitalised shortcut, case-insensitive. pub fn match_keyword(&self, text: &str) -> Option { let t = text.trim(); @@ -338,6 +379,13 @@ mod tests { assert_eq!(p.display(), "Specify next point or [Undo/Close]:"); let q = Prompt::new("Erase polyline?", Accept::TEXT).kw(&["Yes", "No"]).default("No"); assert_eq!(q.display(), "Erase polyline? [Yes/No] :"); + // A prompt for an option has no "or", as AutoCAD writes it. + let o = Prompt::new("Enter an option", Accept::TEXT).kw(&["Close", "Join"]); + assert_eq!(o.display(), "Enter an option [Close/Join]:"); + let c = Prompt::new("New color", Accept::TEXT).kw(&["Truecolor", "COlorbook"]).default("BYLAYER"); + assert_eq!(c.display(), "New color [Truecolor/COlorbook] :"); + let b = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]); + assert_eq!(b.display(), "Enter block name or [?]:"); assert_eq!(p.match_keyword("u").as_deref(), Some("Undo")); assert_eq!(p.match_keyword("CL").as_deref(), Some("Close")); assert_eq!(p.match_keyword("x"), None); diff --git a/crates/engine/tests/it/assoc_array.rs b/crates/engine/tests/it/assoc_array.rs index 131210fa1..f672176b3 100644 --- a/crates/engine/tests/it/assoc_array.rs +++ b/crates/engine/tests/it/assoc_array.rs @@ -336,7 +336,7 @@ fn typed_arrayedit() { let a = rect_array(&mut s); typed(&mut s, &["arrayedit", "l"]); let p = s.prompt_text(); - assert!(p.contains("Enter an option or [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] "), "{p}"); + assert!(p.contains("Enter an option [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] "), "{p}"); typed(&mut s, &["c", "4", "t", "9", "r", "2", "5", ""]); assert!(s.prompt_text().contains("Enter an option"), "{}", s.prompt_text()); assert_eq!(centres(&s, &a).len(), 8); diff --git a/crates/engine/tests/it/layout_prompts.rs b/crates/engine/tests/it/layout_prompts.rs index afa784e0b..8b6ea6637 100644 --- a/crates/engine/tests/it/layout_prompts.rs +++ b/crates/engine/tests/it/layout_prompts.rs @@ -17,7 +17,7 @@ fn typed_layout_options_prompt_for_names() { s.cmdline("LAYOUT").unwrap(); let p = s.current_prompt().expect("LAYOUT prompts").display(); - assert_eq!(p, "Enter layout option or [Copy/Delete/New/Template/Rename/SAveas/Set/?] :"); + assert_eq!(p, "Enter layout option [Copy/Delete/New/Template/Rename/SAveas/Set/?] :"); s.cmdline("n").unwrap(); assert_eq!(s.current_prompt().unwrap().display(), "Enter name of new layout :"); s.cmdline("Sheet9").unwrap(); diff --git a/crates/engine/tests/it/vplayer_prompts.rs b/crates/engine/tests/it/vplayer_prompts.rs index f7f7e4a1a..393ad0958 100644 --- a/crates/engine/tests/it/vplayer_prompts.rs +++ b/crates/engine/tests/it/vplayer_prompts.rs @@ -33,9 +33,9 @@ fn typed_vplayer_changes_layers_in_chosen_viewports() { // Freeze in the current viewport (the default inside a viewport). s.cmdline("VPLAYER").unwrap(); - assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt]:"); + assert_eq!(s.current_prompt().unwrap().display(), "Enter an option [?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt]:"); s.script("F\n0\n").unwrap(); - assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [All/Select/Current/Except current] :"); + assert_eq!(s.current_prompt().unwrap().display(), "Enter an option [All/Select/Current/Except current] :"); s.script("\n").unwrap(); assert_eq!(frozen(&s, &a), ["0"]); assert!(frozen(&s, &b).is_empty()); diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 40397df99..fc25555e2 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -494,7 +494,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; if !pr.raw && !pr.keywords.is_empty() { - let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text); + // As the engine's prompt text says it; only " or [" has words to translate. + let lead = match pr.keyword_lead() { + " or [" => crate::tl!(" or ["), + lead => lead, + }; + let g = p.layout_no_wrap(lead.into(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 034f261e1..492b9e595 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -357,6 +357,35 @@ fn render(app: &mut CadApp, ctx: &egui::Context) -> String { text } +/// The command line in the window draws a prompt's keywords itself; it puts the same words before +/// them as the engine's prompt text: " [" after a prompt for an option ("Enter an option [?/Make/…") +/// and after a question ("Fix any errors detected? [Yes/No]"), " or [" where the keywords are +/// offered besides what is asked ("Specify next point or [Undo]"), " or [" translated. +#[test] +fn the_command_line_says_or_before_keywords_as_the_prompt_does() { + let ctx = egui::Context::default(); + for lang in ["en", "de"] { + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("ui.language", json!({ "lang": lang })).unwrap(); + app.logic(&ctx); + app.session.cmdline("-layer").unwrap(); + let text = render(&mut app, &ctx); + let option = format!("\n{}\n [\n", tr(lang, "Enter an option")); + assert!(text.contains(&option), "{lang}: {option:?} in {text:?}"); + app.session.cancel(); + app.session.cmdline("line").unwrap(); + app.session.cmdline("0,0").unwrap(); + let text = render(&mut app, &ctx); + let besides = format!("\n{}\n{}\n", tr(lang, "Specify next point"), tr(lang, " or [")); + assert!(text.contains(&besides), "{lang}: {besides:?} in {text:?}"); + app.session.cancel(); + app.session.cmdline("audit").unwrap(); + let text = render(&mut app, &ctx); + let question = format!("\n{}\n [\n", tr(lang, "Fix any errors detected?")); + assert!(text.contains(&question), "{lang}: {question:?} in {text:?}"); + } +} + #[test] fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() { let mut app = CadApp::new(Session::new(), Services::default()); From 990ff2ce9f43b8e38fde9266c6975061a91348cd Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:36:37 +0700 Subject: [PATCH 118/151] Say "or" once in prompts that offer options (#2563) * Say "or" once in prompts that offer options Twelve prompt messages ended in "or", and the command line adds "or" before a prompt's options, so they read "or or": HELIX height, XLINE angle and offset, BLEND, four LENGTHEN prompts, two NCOPY prompts, PEDIT and table breaks. They now read as AutoCAD's do. The interface catalogs are keyed by these messages: each key loses its "or" and each translation its trailing word for "or"; where the shorter key already had a translation, the longer row is removed. Co-Authored-By: Claude Opus 5.5 * Say "or" once in HELIX's radius prompts; rename the Vietnamese keys HELIX's base and top radius prompts ended in "or" before their Diameter option too, so they read "Specify base radius or or [Diameter]". They now read "Specify base radius or [Diameter] <1.0000>:", and their keys lose "or" in all 17 catalogs, as the other twelve did. The Vietnamese catalog, merged from main, gets the same renames as the other catalogs; where the shorter key already had a translation, the longer row goes. The unused "Specify rotation angle or" row goes from every catalog too: RECTANG has asked "Specify rotation angle" since #2554. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw2/curves3d.rs | 14 ++--- crates/engine/src/cmd/draw2/xline.rs | 4 +- crates/engine/src/cmd/modify2/blend.rs | 2 +- crates/engine/src/cmd/modify2/lengthen.rs | 8 +-- crates/engine/src/cmd/modify2/ncopy.rs | 4 +- crates/engine/src/cmd/modify2/pedit.rs | 2 +- crates/engine/src/cmd/table/breaks.rs | 2 +- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/prompt_or_once.rs | 77 +++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 24 +++---- crates/ui-egui/src/i18n/bs.tsv | 24 +++---- crates/ui-egui/src/i18n/cs.tsv | 24 +++---- crates/ui-egui/src/i18n/de.tsv | 24 +++---- crates/ui-egui/src/i18n/el.tsv | 24 +++---- crates/ui-egui/src/i18n/es.tsv | 24 +++---- crates/ui-egui/src/i18n/fr.tsv | 24 +++---- crates/ui-egui/src/i18n/hu.tsv | 24 +++---- crates/ui-egui/src/i18n/it.tsv | 24 +++---- crates/ui-egui/src/i18n/ko.tsv | 24 +++---- crates/ui-egui/src/i18n/pt_br.tsv | 24 +++---- crates/ui-egui/src/i18n/ru.tsv | 24 +++---- crates/ui-egui/src/i18n/sv.tsv | 24 +++---- crates/ui-egui/src/i18n/tests.rs | 11 ++++ crates/ui-egui/src/i18n/tr.tsv | 24 +++---- crates/ui-egui/src/i18n/uk.tsv | 24 +++---- crates/ui-egui/src/i18n/vi.tsv | 24 +++---- crates/ui-egui/src/i18n/zh.tsv | 24 +++---- 27 files changed, 276 insertions(+), 257 deletions(-) create mode 100644 crates/engine/tests/it/prompt_or_once.rs diff --git a/crates/engine/src/cmd/draw2/curves3d.rs b/crates/engine/src/cmd/draw2/curves3d.rs index 005c49f07..faad5442c 100644 --- a/crates/engine/src/cmd/draw2/curves3d.rs +++ b/crates/engine/src/cmd/draw2/curves3d.rs @@ -153,19 +153,17 @@ impl Interactive for HelixM { } match (self.center, self.rb, self.rt) { (None, ..) => Prompt::new("Specify center point of base", Accept::POINT), - (Some(c), None, _) => { - Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER) - .kw(if self.diameter { &[] } else { &["Diameter"] }) - .default("1.0000") - .base(c) - } + (Some(c), None, _) => Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius" }, Accept::POINT_OR_NUMBER) + .kw(if self.diameter { &[] } else { &["Diameter"] }) + .default("1.0000") + .base(c), (Some(c), Some(rb), None) => { - Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER) + Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius" }, Accept::POINT_OR_NUMBER) .kw(if self.diameter { &[] } else { &["Diameter"] }) .default(format!("{rb:.4}")) .base(c) } - (Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER) + (Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height", Accept::POINT_OR_NUMBER) .kw(&["Axis endpoint", "Turns", "turn Height", "tWist"]) .default("1.0000") .base(c), diff --git a/crates/engine/src/cmd/draw2/xline.rs b/crates/engine/src/cmd/draw2/xline.rs index 31f152d16..78ab5c610 100644 --- a/crates/engine/src/cmd/draw2/xline.rs +++ b/crates/engine/src/cmd/draw2/xline.rs @@ -119,7 +119,7 @@ impl Interactive for XlineM2 { Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b), }, XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT), - XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]), + XMode::AngAsk => Prompt::new("Enter angle of xline (0)", Accept::NUMBER).kw(&["Reference"]), XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT), XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"), XMode::Bisect => match self.bis.len() { @@ -129,7 +129,7 @@ impl Interactive for XlineM2 { }, XMode::OffDist => { let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0); - Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 { + Prompt::new("Specify offset distance", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 { "Through".into() } else { format!("{d:.4}") diff --git a/crates/engine/src/cmd/modify2/blend.rs b/crates/engine/src/cmd/modify2/blend.rs index 32b477eea..ef2258969 100644 --- a/crates/engine/src/cmd/modify2/blend.rs +++ b/crates/engine/src/cmd/modify2/blend.rs @@ -81,7 +81,7 @@ impl Interactive for BlendM { return Prompt::new("Enter continuity", curves::KW).kw(&["Tangent", "Smooth"]).default("Tangent"); } match self.first { - None => Prompt::new("Select first object or", Accept::POINT).kw(&["CONtinuity"]), + None => Prompt::new("Select first object", Accept::POINT).kw(&["CONtinuity"]), Some(_) => Prompt::new("Select second point", Accept::POINT), } } diff --git a/crates/engine/src/cmd/modify2/lengthen.rs b/crates/engine/src/cmd/modify2/lengthen.rs index 0c86c2c9c..7597ca771 100644 --- a/crates/engine/src/cmd/modify2/lengthen.rs +++ b/crates/engine/src/cmd/modify2/lengthen.rs @@ -194,14 +194,14 @@ impl Interactive for LengthenM { fn prompt(&self, _s: &Session) -> Prompt { match self.phase { LPhase::Main => { - Prompt::new("Select an object to measure or", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic") + Prompt::new("Select an object to measure", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic") } - LPhase::Delta => Prompt::new("Enter delta length or", Accept::NUMBER).kw(&["Angle"]).default("0.0000"), + LPhase::Delta => Prompt::new("Enter delta length", Accept::NUMBER).kw(&["Angle"]).default("0.0000"), LPhase::DeltaAngle => Prompt::new("Enter delta angle", Accept::NUMBER).default("0"), LPhase::Percent => Prompt::new("Enter percentage length", Accept::NUMBER).default("100.0000"), - LPhase::Total => Prompt::new("Specify total length or", Accept::NUMBER).kw(&["Angle"]).default("1.0000"), + LPhase::Total => Prompt::new("Specify total length", Accept::NUMBER).kw(&["Angle"]).default("1.0000"), LPhase::TotalAngle => Prompt::new("Specify total angle", Accept::NUMBER).default("57"), - LPhase::Change => Prompt::new("Select an object to change or", Accept::POINT).kw(&["Undo"]), + LPhase::Change => Prompt::new("Select an object to change", Accept::POINT).kw(&["Undo"]), LPhase::DynEnd(..) => Prompt::new("Specify new end point", Accept::POINT), } } diff --git a/crates/engine/src/cmd/modify2/ncopy.rs b/crates/engine/src/cmd/modify2/ncopy.rs index 475256e84..9b6f9eeb2 100644 --- a/crates/engine/src/cmd/modify2/ncopy.rs +++ b/crates/engine/src/cmd/modify2/ncopy.rs @@ -87,8 +87,8 @@ impl Interactive for NcopyM { } fn prompt(&self, _s: &Session) -> Prompt { match (self.selecting_done, self.base) { - (false, _) => Prompt::new("Select nested objects to copy or", Accept::POINT).kw(&["Settings"]), - (true, None) => Prompt::new("Specify base point or", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"), + (false, _) => Prompt::new("Select nested objects to copy", Accept::POINT).kw(&["Settings"]), + (true, None) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"), (true, Some(b)) => Prompt::new("Specify second point or ", Accept::POINT).base(b), } } diff --git a/crates/engine/src/cmd/modify2/pedit.rs b/crates/engine/src/cmd/modify2/pedit.rs index 0c1b64879..59d614323 100644 --- a/crates/engine/src/cmd/modify2/pedit.rs +++ b/crates/engine/src/cmd/modify2/pedit.rs @@ -398,7 +398,7 @@ impl Interactive for PeditM { fn prompt(&self, s: &Session) -> Prompt { match self.phase { PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_prompt(), - PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]), + PPhase::Select => Prompt::new("Select polyline", Accept::POINT).kw(&["Multiple"]), PPhase::Convert => { Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y") } diff --git a/crates/engine/src/cmd/table/breaks.rs b/crates/engine/src/cmd/table/breaks.rs index 38ed33bce..6f207ec50 100644 --- a/crates/engine/src/cmd/table/breaks.rs +++ b/crates/engine/src/cmd/table/breaks.rs @@ -112,7 +112,7 @@ impl Interactive for BreakM { fn prompt(&self, _s: &Session) -> Prompt { match self.table { None => Prompt::new("Select a table", Accept::SELECT), - Some(_) => Prompt::new("Enter maximum part height or", Accept::POINT_OR_NUMBER).kw(&["Off"]), + Some(_) => Prompt::new("Enter maximum part height", Accept::POINT_OR_NUMBER).kw(&["Off"]), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 54ea40675..d3c8d030d 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -107,6 +107,7 @@ mod point_modifiers; mod polyface_explode; mod post_command_passes; mod prompt_keywords; +mod prompt_or_once; mod props_bad_lineweight; mod props_insert_scale_attribs; mod purge_mlinestyles; diff --git a/crates/engine/tests/it/prompt_or_once.rs b/crates/engine/tests/it/prompt_or_once.rs new file mode 100644 index 000000000..c84c58b1c --- /dev/null +++ b/crates/engine/tests/it/prompt_or_once.rs @@ -0,0 +1,77 @@ +//! Prompts that offer options say "or" once, as AutoCAD 2024's do: "Enter angle of xline (0) or +//! [Reference]:". The prompt adds "or" before its options, so a message that ended in "or" read +//! "or or". + +use cadcraft_engine::Session; +use serde_json::json; + +fn shows(s: &Session, text: &str) { + let p = s.prompt_text(); + assert!(p.ends_with(text), "{p}"); + assert!(!p.contains(" or or "), "{p}"); +} + +#[test] +fn xline_angle_and_offset() { + let mut s = Session::new(); + s.cmdline("xline").unwrap(); + s.cmdline("a").unwrap(); + shows(&s, "Enter angle of xline (0) or [Reference]:"); + s.cancel(); + s.cmdline("xline").unwrap(); + s.cmdline("o").unwrap(); + shows(&s, "Specify offset distance or [Through] <1.0000>:"); +} + +#[test] +fn helix_radii_and_height() { + let mut s = Session::new(); + s.script("helix\n0,0\n").unwrap(); + shows(&s, "Specify base radius or [Diameter] <1.0000>:"); + s.cmdline("5").unwrap(); + shows(&s, "Specify top radius or [Diameter] <5.0000>:"); + s.cmdline("").unwrap(); + shows(&s, "Specify helix height or [Axis endpoint/Turns/turn Height/tWist] <1.0000>:"); +} + +#[test] +fn blend_first_object() { + let mut s = Session::new(); + s.cmdline("blend").unwrap(); + shows(&s, "Select first object or [CONtinuity]:"); +} + +#[test] +fn lengthen_options() { + let mut s = Session::new(); + s.cmdline("lengthen").unwrap(); + shows(&s, "Select an object to measure or [DElta/Percent/Total/DYnamic] :"); + s.cmdline("de").unwrap(); + shows(&s, "Enter delta length or [Angle] <0.0000>:"); + s.cmdline("2").unwrap(); + shows(&s, "Select an object to change or [Undo]:"); + s.cancel(); + s.cmdline("lengthen").unwrap(); + s.cmdline("t").unwrap(); + shows(&s, "Specify total length or [Angle] <1.0000>:"); +} + +#[test] +fn ncopy_pedit_and_table_break() { + let mut s = Session::new(); + s.script("line 25,-5 25,5\n\nline 0,0 10,0\n\n-block B 0,0 L\n\n").unwrap(); + s.execute("insert", &json!({ "name": "B", "at": [20, 0] })).unwrap(); + s.cmdline("zoom e").unwrap(); + s.cmdline("ncopy").unwrap(); + shows(&s, "Select nested objects to copy or [Settings]:"); + s.script("22,0\n\n").unwrap(); + shows(&s, "Specify base point or [Displacement/Multiple] :"); + s.cancel(); + s.cmdline("pedit").unwrap(); + shows(&s, "Select polyline or [Multiple]:"); + s.cancel(); + s.execute("table", &json!({ "at": [0, 20], "cells": [["1", ""], ["2", ""]], "rowHeight": 1.0, "colWidth": 3.0 })).unwrap(); + s.cmdline("table.break").unwrap(); + s.cmdline("1,19.5").unwrap(); + shows(&s, "Enter maximum part height or [Off]:"); +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index ccc9b72d5..7713378d9 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -305,7 +305,7 @@ Enter a vertex editing option أدخل خيار تحرير الرؤوس Enter an option أدخل خياراً Enter angle of xline أدخل زاوية الخط الإنشائي - Enter angle of xline (0) or أدخل زاوية الخط الإنشائي (0) أو + Enter angle of xline (0) أدخل زاوية الخط الإنشائي (0) Enter area of rectangle in current units أدخل مساحة المستطيل بالوحدات الحالية Enter attribute prompt أدخل موجه السمة Enter attribute tag name أدخل اسم وسم السمة @@ -316,7 +316,7 @@ Enter default attribute value أدخل قيمة السمة الافتراضية Enter degree of spline أدخل درجة السبلاين Enter delta angle أدخل زاوية دلتا - Enter delta length or أدخل طول دلتا أو + Enter delta length أدخل طول دلتا Enter dimension text أدخل نص التأشير Enter dimension text (<> = measurement) أدخل نص التأشير (<> = القياس) Enter dimension value or [name=expression] أدخل قيمة التأشير أو [name=expression] @@ -852,8 +852,8 @@ Select an arc or circle حدد قوساً أو دائرة Select an arc. حدد قوساً. Select an object حدد كائناً - Select an object to change or حدد كائناً لتغييره أو - Select an object to measure or حدد كائناً لقياسه أو + Select an object to change حدد كائناً لتغييره + Select an object to measure حدد كائناً لقياسه Select an object. حدد كائنًا. Select an open curve. حدد منحنى مفتوحاً. Select an open line, arc, polyline, ellipse arc or spline. حدد خطاً مفتوحاً أو قوساً أو متعدد خطوط أو قوس إهليلج أو سبلاين. @@ -867,9 +867,8 @@ Select first line حدد الخط الأول Select first line or arc حدد الخط الأول أو قوساً Select first object حدد الكائن الأول - Select first object or حدد الكائن الأول أو Select first point حدد النقطة الأولى - Select nested objects to copy or حدد الكائنات المتداخلة لنسخها أو + Select nested objects to copy حدد الكائنات المتداخلة لنسخها Select object حدد كائناً Select object to dimension حدد كائناً لتأشيره Select object to divide حدد كائناً لتقسيمه @@ -886,7 +885,7 @@ Select one polyline (Multiple is available through the JSON form). حدد متعدد خطوط واحداً (يتوفر الوضع المتعدد عبر نموذج JSON). Select path curve حدد منحنى المسار Select point or object حدد نقطة أو كائناً - Select polyline or حدد متعدد خطوط أو + Select polyline حدد متعدد خطوط Select second line حدد الخط الثاني Select second line or shift-select to apply corner حدد الخط الثاني أو حدد مع Shift لتطبيق الزاوية Select second object حدد الكائن الثاني @@ -956,9 +955,8 @@ Specify axis endpoint of ellipse حدد نقطة نهاية محور الإهليلج Specify base diameter حدد القطر الأساسي Specify base point حدد نقطة الأساس - Specify base point or حدد نقطة الأساس أو Specify base point or displacement (drag with the middle mouse button for realtime pan) حدد نقطة الأساس أو الإزاحة (اسحب بزر الفأرة الأوسط للإزاحة الفورية) - Specify base radius or حدد نصف القطر الأساسي أو + Specify base radius حدد نصف القطر الأساسي Specify center of donut حدد مركز الحلقة Specify center of ellipse حدد مركز الإهليلج Specify center of polygon حدد مركز المضلع @@ -1000,7 +998,7 @@ Specify first point on circle: _tan to حدد النقطة الأولى على الدائرة: _تماس إلى Specify first source point حدد نقطة المصدر الأولى Specify height حدد الارتفاع - Specify helix height or حدد ارتفاع الحلزون أو + Specify helix height حدد ارتفاع الحلزون Specify included angle حدد الزاوية المحصورة Specify insertion base point حدد نقطة الأساس للإدراج Specify insertion point حدد نقطة الإدراج @@ -1019,7 +1017,6 @@ Specify new width for all segments حدد عرضاً جديداً لكل المقاطع Specify next point حدد النقطة التالية Specify offset distance حدد مسافة الإزاحة - Specify offset distance or حدد مسافة الإزاحة أو Specify opposite corner حدد الزاوية المقابلة Specify other corner point حدد نقطة الزاوية الأخرى Specify other endpoint of axis حدد نقطة النهاية الأخرى للمحور @@ -1032,7 +1029,6 @@ Specify radius of circle حدد نصف قطر الدائرة Specify rotation angle حدد زاوية التدوير Specify rotation angle of text حدد زاوية تدوير النص - Specify rotation angle or حدد زاوية التدوير أو Specify scale factor حدد معامل المقياس Specify second angle endpoint حدد نقطة نهاية الزاوية الثانية Specify second break point حدد نقطة الفصل الثانية @@ -1065,9 +1061,9 @@ Specify third source point or حدد نقطة المصدر الثالثة أو <متابعة> Specify through point حدد نقطة العبور Specify top diameter حدد القطر العلوي - Specify top radius or حدد نصف القطر العلوي أو + Specify top radius حدد نصف القطر العلوي Specify total angle حدد الزاوية الكلية - Specify total length or حدد الطول الكلي أو + Specify total length حدد الطول الكلي Specify width for rectangles حدد العرض للمستطيلات Sphere كرة Spline سبلاين diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 460959a33..b349db278 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -281,7 +281,7 @@ Enter a vertex editing option Unesite opciju uređivanja tjemena Enter an option Unesite opciju Enter angle of xline Unesite ugao konstrukcijske linije - Enter angle of xline (0) or Unesite ugao konstrukcijske linije (0) ili + Enter angle of xline (0) Unesite ugao konstrukcijske linije (0) Enter area of rectangle in current units Unesite površinu pravougaonika u trenutnim jedinicama Enter attribute prompt Unesite upit atributa Enter attribute tag name Unesite naziv oznake atributa @@ -292,7 +292,7 @@ Enter default attribute value Unesite zadanu vrijednost atributa Enter degree of spline Unesite stepen splajna Enter delta angle Unesite delta ugao - Enter delta length or Unesite delta dužinu ili + Enter delta length Unesite delta dužinu Enter dimension text Unesite tekst kote Enter dimension text (<> = measurement) Unesite tekst kote (<> = mjera) Enter dimension value or [name=expression] Unesite vrijednost kote ili [naziv=izraz] @@ -788,8 +788,8 @@ Select an arc or circle Odaberite luk ili krug Select an arc. Odaberite luk. Select an object Odaberite objekat - Select an object to change or Odaberite objekat za promjenu ili - Select an object to measure or Odaberite objekat za mjerenje ili + Select an object to change Odaberite objekat za promjenu + Select an object to measure Odaberite objekat za mjerenje Select an object. Odaberite objekat. Select an open curve. Odaberite otvorenu krivu. Select an open line, arc, polyline, ellipse arc or spline. Odaberite otvorenu liniju, luk, poliliniju, eliptični luk ili splajn. @@ -803,14 +803,13 @@ Select first line Odaberite prvu liniju Select first line or arc Odaberite prvu liniju ili luk Select first object Odaberite prvi objekat - Select first object or Odaberite prvi objekat ili Select first point Odaberite prvu tačku Add to Working Set Dodaj u radni skup Edit Reference In-Place Uredi referencu na mjestu Save Reference Edits Sačuvaj izmjene reference Working Set Radni skup Xref and Block In-place Editing Uređivanje vanjskih referenci i blokova na mjestu - Select nested objects to copy or Odaberite ugniježđene objekte za kopiranje ili + Select nested objects to copy Odaberite ugniježđene objekte za kopiranje Select object Odaberite objekat Select object to dimension Odaberite objekat za kotiranje Select object to divide Odaberite objekat za dijeljenje @@ -826,7 +825,7 @@ Select one polyline (Multiple is available through the JSON form). Odaberite jednu poliliniju (Višestruko je dostupno putem JSON oblika). Select path curve Odaberite krivu putanje Select point or object Odaberite tačku ili objekat - Select polyline or Odaberite poliliniju ili + Select polyline Odaberite poliliniju Select second line Odaberite drugu liniju Select second line or shift-select to apply corner Odaberite drugu liniju ili Shift+odabir za primjenu ugla Select second object Odaberite drugi objekat @@ -884,9 +883,8 @@ Specify axis endpoint of ellipse Navedite krajnju tačku ose elipse Specify base diameter Navedite prečnik baze Specify base point Navedite baznu tačku - Specify base point or Navedite baznu tačku ili Specify base point or displacement (drag with the middle mouse button for realtime pan) Navedite baznu tačku ili pomak (prevucite srednjim dugmetom miša za pomicanje pogleda u stvarnom vremenu) - Specify base radius or Navedite poluprečnik baze ili + Specify base radius Navedite poluprečnik baze Specify center of donut Navedite središte prstena Specify center of ellipse Navedite središte elipse Specify center of polygon Navedite središte mnogougla @@ -928,7 +926,7 @@ Specify first point on circle: _tan to Navedite prvu tačku na krugu: _tan na Specify first source point Navedite prvu izvornu tačku Specify height Navedite visinu - Specify helix height or Navedite visinu spirale ili + Specify helix height Navedite visinu spirale Specify included angle Navedite obuhvaćeni ugao Specify insertion base point Navedite baznu tačku umetanja Specify insertion point Navedite tačku umetanja @@ -982,7 +980,6 @@ Specify new width for all segments Navedite novu širinu za sve segmente Specify next point Navedite sljedeću tačku Specify offset distance Navedite udaljenost odmaka - Specify offset distance or Navedite udaljenost odmaka ili Specify opposite corner Navedite suprotni ugao Specify other corner point Navedite tačku drugog ugla Specify other endpoint of axis Navedite drugu krajnju tačku ose @@ -995,7 +992,6 @@ Specify radius of circle Navedite poluprečnik kruga Specify rotation angle Navedite ugao rotacije Specify rotation angle of text Navedite ugao rotacije teksta - Specify rotation angle or Navedite ugao rotacije ili Specify scale factor Navedite faktor skale Specify second angle endpoint Navedite drugu krajnju tačku ugla Specify second break point Navedite drugu tačku prekida @@ -1028,9 +1024,9 @@ Specify third source point or Navedite treću izvornu tačku ili Specify through point Navedite tačku kroz koju prolazi Specify top diameter Navedite gornji prečnik - Specify top radius or Navedite gornji poluprečnik ili + Specify top radius Navedite gornji poluprečnik Specify total angle Navedite ukupni ugao - Specify total length or Navedite ukupnu dužinu ili + Specify total length Navedite ukupnu dužinu Specify width for rectangles Navedite širinu za pravougaonike Sphere Sfera Spline Splajn diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2419bf41f..61dc1b2b6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -279,7 +279,7 @@ Enter a vertex editing option Zadejte možnost úpravy vrcholů Enter an option Zadejte možnost Enter angle of xline Zadejte úhel přímky - Enter angle of xline (0) or Zadejte úhel přímky (0) nebo + Enter angle of xline (0) Zadejte úhel přímky (0) Enter area of rectangle in current units Zadejte plochu obdélníku v aktuálních jednotkách Enter attribute prompt Zadejte výzvu atributu Enter attribute tag name Zadejte označení atributu @@ -290,7 +290,7 @@ Enter default attribute value Zadejte výchozí hodnotu atributu Enter degree of spline Zadejte stupeň spline Enter delta angle Zadejte změnu úhlu - Enter delta length or Zadejte změnu délky nebo + Enter delta length Zadejte změnu délky Enter dimension text Zadejte text kóty Enter dimension text (<> = measurement) Zadejte text kóty (<> = naměřená hodnota) Enter dimension value or [name=expression] Zadejte hodnotu kóty nebo [name=expression] @@ -786,8 +786,8 @@ Select an arc or circle Vyberte oblouk nebo kružnici Select an arc. Vyberte oblouk. Select an object Vyberte objekt - Select an object to change or Vyberte objekt ke změně nebo - Select an object to measure or Vyberte objekt k měření nebo + Select an object to change Vyberte objekt ke změně + Select an object to measure Vyberte objekt k měření Select an object. Vyberte objekt. Select an open curve. Vyberte otevřenou křivku. Select an open line, arc, polyline, ellipse arc or spline. Vyberte otevřenou úsečku, oblouk, křivku, eliptický oblouk nebo spline. @@ -801,14 +801,13 @@ Select first line Vyberte první úsečku Select first line or arc Vyberte první úsečku nebo oblouk Select first object Vyberte první objekt - Select first object or Vyberte první objekt nebo Select first point Vyberte první bod Add to Working Set Přidat do pracovní sady Edit Reference In-Place Upravit referenci na místě Save Reference Edits Uložit úpravy reference Working Set Pracovní sada Xref and Block In-place Editing Úpravy xref a bloků na místě - Select nested objects to copy or Vyberte vnořené objekty ke kopírování nebo + Select nested objects to copy Vyberte vnořené objekty ke kopírování Select object Vyberte objekt Select object to dimension Vyberte objekt ke kótování Select object to divide Vyberte objekt k dělení @@ -824,7 +823,7 @@ Select one polyline (Multiple is available through the JSON form). Vyberte jednu křivku (možnost Multiple je dostupná ve formě JSON). Select path curve Vyberte křivku trajektorie Select point or object Vyberte bod nebo objekt - Select polyline or Vyberte křivku nebo + Select polyline Vyberte křivku Select second line Vyberte druhou úsečku Select second line or shift-select to apply corner Vyberte druhou úsečku; s klávesou Shift vytvoříte roh Select second object Vyberte druhý objekt @@ -882,9 +881,8 @@ Specify axis endpoint of ellipse Určete koncový bod osy elipsy Specify base diameter Určete průměr základny Specify base point Určete základní bod - Specify base point or Určete základní bod nebo Specify base point or displacement (drag with the middle mouse button for realtime pan) Určete základní bod nebo posunutí (tažením prostředním tlačítkem myši posunete pohled v reálném čase) - Specify base radius or Určete poloměr základny nebo + Specify base radius Určete poloměr základny Specify center of donut Určete střed prstence Specify center of ellipse Určete střed elipsy Specify center of polygon Určete střed mnohoúhelníku @@ -926,7 +924,7 @@ Specify first point on circle: _tan to Určete první bod na kružnici: _tan k Specify first source point Určete první zdrojový bod Specify height Určete výšku - Specify helix height or Určete výšku šroubovice nebo + Specify helix height Určete výšku šroubovice Specify included angle Určete středový úhel Specify insertion base point Určete základní bod vložení Specify insertion point Určete bod vložení @@ -980,7 +978,6 @@ Specify new width for all segments Určete novou šířku všech segmentů Specify next point Určete další bod Specify offset distance Určete vzdálenost ekvidistanty - Specify offset distance or Určete vzdálenost ekvidistanty nebo Specify opposite corner Určete protilehlý roh Specify other corner point Určete bod druhého rohu Specify other endpoint of axis Určete druhý koncový bod osy @@ -993,7 +990,6 @@ Specify radius of circle Určete poloměr kružnice Specify rotation angle Určete úhel otočení Specify rotation angle of text Určete úhel otočení textu - Specify rotation angle or Určete úhel otočení nebo Specify scale factor Určete faktor měřítka Specify second angle endpoint Určete druhý koncový bod úhlu Specify second break point Určete druhý bod přerušení @@ -1026,9 +1022,9 @@ Specify third source point or Určete třetí zdrojový bod nebo Specify through point Určete bod, kterým má procházet Specify top diameter Určete horní průměr - Specify top radius or Určete horní poloměr nebo + Specify top radius Určete horní poloměr Specify total angle Určete celkový úhel - Specify total length or Určete celkovou délku nebo + Specify total length Určete celkovou délku Specify width for rectangles Určete šířku obdélníků Sphere Koule Spline Spline diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2186fc743..f36e9ed02 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -222,7 +222,7 @@ Enter a vertex editing option Geben Sie eine Option zur Scheitelpunktbearbeitung ein Enter an option Geben Sie eine Option ein Enter angle of xline Geben Sie den Winkel der Konstruktionslinie ein - Enter angle of xline (0) or Geben Sie den Winkel der Konstruktionslinie ein (0) oder + Enter angle of xline (0) Geben Sie den Winkel der Konstruktionslinie ein (0) Enter area of rectangle in current units Geben Sie die Fläche des Rechtecks in den aktuellen Einheiten ein Enter attribute prompt Geben Sie die Attributaufforderung ein Enter attribute tag name Geben Sie den Attribut-Tag-Namen ein @@ -233,7 +233,7 @@ Enter default attribute value Geben Sie den Standard-Attributwert ein Enter degree of spline Geben Sie den Grad des Splines ein Enter delta angle Geben Sie den Delta-Winkel ein - Enter delta length or Geben Sie die Delta-Länge ein oder + Enter delta length Geben Sie die Delta-Länge ein Enter dimension text Geben Sie den Bemaßungstext ein Enter dimension text (<> = measurement) Geben Sie den Bemaßungstext ein (<> = Messwert) Enter dimension value or [name=expression] Geben Sie einen Bemaßungswert oder [name=expression] ein @@ -676,8 +676,8 @@ Select an arc or circle Wählen Sie einen Bogen oder Kreis Select an arc. Wählen Sie einen Bogen. Select an object Wählen Sie ein Objekt - Select an object to change or Wählen Sie ein zu änderndes Objekt oder - Select an object to measure or Wählen Sie ein zu messendes Objekt oder + Select an object to change Wählen Sie ein zu änderndes Objekt + Select an object to measure Wählen Sie ein zu messendes Objekt Select an object. Wählen Sie ein Objekt. Add to Working Set Zum Arbeitssatz hinzufügen Edit Reference In-Place Referenz an Ort und Stelle bearbeiten @@ -696,9 +696,8 @@ Select first line Wählen Sie die erste Linie Select first line or arc Wählen Sie die erste Linie oder den ersten Bogen Select first object Wählen Sie das erste Objekt - Select first object or Wählen Sie das erste Objekt oder Select first point Wählen Sie den ersten Punkt - Select nested objects to copy or Wählen Sie zu kopierende verschachtelte Objekte oder + Select nested objects to copy Wählen Sie zu kopierende verschachtelte Objekte Select object Wählen Sie ein Objekt Select object to dimension Wählen Sie das zu bemaßende Objekt Select object to divide Wählen Sie das zu teilende Objekt @@ -714,7 +713,7 @@ Select one polyline (Multiple is available through the JSON form). Wählen Sie eine Polylinie (Multiple ist über die JSON-Form verfügbar). Select path curve Wählen Sie die Pfadkurve Select point or object Wählen Sie einen Punkt oder ein Objekt - Select polyline or Wählen Sie eine Polylinie oder + Select polyline Wählen Sie eine Polylinie Select second line Wählen Sie die zweite Linie Select second line or shift-select to apply corner Wählen Sie die zweite Linie oder wählen Sie mit der Umschalttaste, um eine Ecke anzuwenden Select second object Wählen Sie das zweite Objekt @@ -767,9 +766,8 @@ Specify axis endpoint of ellipse Geben Sie den Achsenendpunkt der Ellipse an Specify base diameter Geben Sie den Basisdurchmesser an Specify base point Geben Sie den Basispunkt an - Specify base point or Geben Sie den Basispunkt an oder Specify base point or displacement (drag with the middle mouse button for realtime pan) Geben Sie den Basispunkt oder die Verschiebung an (ziehen Sie mit der mittleren Maustaste zum Schwenken in Echtzeit) - Specify base radius or Geben Sie den Basisradius an oder + Specify base radius Geben Sie den Basisradius an Specify center of donut Geben Sie den Mittelpunkt des Rings an Specify center of ellipse Geben Sie den Mittelpunkt der Ellipse an Specify center of polygon Geben Sie den Mittelpunkt des Polygons an @@ -811,7 +809,7 @@ Specify first point on circle: _tan to Geben Sie den ersten Punkt auf dem Kreis an: _tan zu Specify first source point Geben Sie den ersten Quellpunkt an Specify height Geben Sie die Höhe an - Specify helix height or Geben Sie die Höhe der Helix an oder + Specify helix height Geben Sie die Höhe der Helix an Specify included angle Geben Sie den eingeschlossenen Winkel an Specify insertion base point Geben Sie den Einfügebasispunkt an Specify insertion point Geben Sie den Einfügepunkt an @@ -830,7 +828,6 @@ Specify new width for all segments Geben Sie die neue Breite für alle Segmente an Specify next point Geben Sie den nächsten Punkt an Specify offset distance Geben Sie den Versatzabstand an - Specify offset distance or Geben Sie den Versatzabstand an oder Specify opposite corner Geben Sie die gegenüberliegende Ecke an Specify other corner point Geben Sie den anderen Eckpunkt an Specify other endpoint of axis Geben Sie den anderen Endpunkt der Achse an @@ -843,7 +840,6 @@ Specify radius of circle Geben Sie den Radius des Kreises an Specify rotation angle Geben Sie den Drehwinkel an Specify rotation angle of text Geben Sie den Drehwinkel des Textes an - Specify rotation angle or Geben Sie den Drehwinkel an oder Specify scale factor Geben Sie den Skalierfaktor an Specify second angle endpoint Geben Sie den zweiten Endpunkt des Winkels an Specify second break point Geben Sie den zweiten Unterbrechungspunkt an @@ -876,9 +872,9 @@ Specify third source point or Geben Sie den dritten Quellpunkt an oder Specify through point Geben Sie den Durchgangspunkt an Specify top diameter Geben Sie den oberen Durchmesser an - Specify top radius or Geben Sie den oberen Radius an oder + Specify top radius Geben Sie den oberen Radius an Specify total angle Geben Sie den Gesamtwinkel an - Specify total length or Geben Sie die Gesamtlänge an oder + Specify total length Geben Sie die Gesamtlänge an Specify width for rectangles Geben Sie die Breite für Rechtecke an Sphere Kugel Spline Spline diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 16fcecfeb..528dacf55 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -337,7 +337,7 @@ Enter a vertex editing option Εισαγάγετε επιλογή επεξεργασίας κορυφής Enter an option Εισαγάγετε επιλογή Enter angle of xline Εισαγάγετε γωνία βοηθητικής ευθείας - Enter angle of xline (0) or Εισαγάγετε γωνία βοηθητικής ευθείας (0) ή + Enter angle of xline (0) Εισαγάγετε γωνία βοηθητικής ευθείας (0) Enter area of rectangle in current units Εισαγάγετε το εμβαδόν ορθογωνίου στις τρέχουσες μονάδες Enter attribute prompt Εισαγάγετε μήνυμα προτροπής ιδιότητας Enter attribute tag name Εισαγάγετε όνομα ετικέτας ιδιότητας @@ -348,7 +348,7 @@ Enter default attribute value Εισαγάγετε προεπιλεγμένη τιμή ιδιότητας Enter degree of spline Εισαγάγετε βαθμό καμπύλης spline Enter delta angle Εισαγάγετε μεταβολή γωνίας - Enter delta length or Εισαγάγετε μεταβολή μήκους ή + Enter delta length Εισαγάγετε μεταβολή μήκους Enter dimension text Εισαγάγετε κείμενο διάστασης Enter dimension text (<> = measurement) Εισαγάγετε κείμενο διάστασης (<> = μέτρηση) Enter dimension value or [name=expression] Εισαγάγετε τιμή διάστασης ή [όνομα=έκφραση] @@ -919,8 +919,8 @@ Select an arc or circle Επιλέξτε τόξο ή κύκλο Select an arc. Επιλέξτε τόξο. Select an object Επιλέξτε αντικείμενο - Select an object to change or Επιλέξτε αντικείμενο για αλλαγή ή - Select an object to measure or Επιλέξτε αντικείμενο για μέτρηση ή + Select an object to change Επιλέξτε αντικείμενο για αλλαγή + Select an object to measure Επιλέξτε αντικείμενο για μέτρηση Select an object. Επιλέξτε αντικείμενο. Select an open curve. Επιλέξτε ανοικτή καμπύλη. Select an open line, arc, polyline, ellipse arc or spline. Επιλέξτε ανοικτή γραμμή, τόξο, πολυγραμμή, ελλειπτικό τόξο ή καμπύλη spline. @@ -934,9 +934,8 @@ Select first line Επιλέξτε πρώτη γραμμή Select first line or arc Επιλέξτε πρώτη γραμμή ή τόξο Select first object Επιλέξτε πρώτο αντικείμενο - Select first object or Επιλέξτε πρώτο αντικείμενο ή Select first point Επιλέξτε πρώτο σημείο - Select nested objects to copy or Επιλέξτε ένθετα αντικείμενα για αντιγραφή ή + Select nested objects to copy Επιλέξτε ένθετα αντικείμενα για αντιγραφή Select object Επιλέξτε αντικείμενο Select object to dimension Επιλέξτε αντικείμενο για διαστασιολόγηση Select object to divide Επιλέξτε αντικείμενο για διαίρεση @@ -954,7 +953,7 @@ Select one polyline (Multiple is available through the JSON form). Επιλέξτε μία πολυγραμμή (η επιλογή Multiple διατίθεται μέσω JSON). Select path curve Επιλέξτε καμπύλη διαδρομής Select point or object Επιλέξτε σημείο ή αντικείμενο - Select polyline or Επιλέξτε πολυγραμμή ή + Select polyline Επιλέξτε πολυγραμμή Select second line Επιλέξτε δεύτερη γραμμή Select second line or shift-select to apply corner Επιλέξτε δεύτερη γραμμή ή κρατήστε Shift για εφαρμογή γωνίας Select second object Επιλέξτε δεύτερο αντικείμενο @@ -1036,9 +1035,8 @@ Specify axis endpoint of ellipse Καθορίστε τελικό σημείο άξονα έλλειψης Specify base diameter Καθορίστε διάμετρο βάσης Specify base point Καθορίστε σημείο βάσης - Specify base point or Καθορίστε σημείο βάσης ή Specify base point or displacement (drag with the middle mouse button for realtime pan) Καθορίστε σημείο βάσης ή μετατόπιση (σύρετε με το μεσαίο πλήκτρο του ποντικιού για μετατόπιση όψης σε πραγματικό χρόνο) - Specify base radius or Καθορίστε ακτίνα βάσης ή + Specify base radius Καθορίστε ακτίνα βάσης Specify center of donut Καθορίστε κέντρο δακτυλίου Specify center of ellipse Καθορίστε κέντρο έλλειψης Specify center of polygon Καθορίστε κέντρο πολυγώνου @@ -1080,7 +1078,7 @@ Specify first point on circle: _tan to Καθορίστε πρώτο σημείο επάνω στον κύκλο: _tan προς Specify first source point Καθορίστε πρώτο σημείο προέλευσης Specify height Καθορίστε ύψος - Specify helix height or Καθορίστε ύψος έλικας ή + Specify helix height Καθορίστε ύψος έλικας Specify included angle Καθορίστε περιεχόμενη γωνία Specify insertion base point Καθορίστε σημείο βάσης εισαγωγής Specify insertion point Καθορίστε σημείο εισαγωγής @@ -1099,7 +1097,6 @@ Specify new width for all segments Καθορίστε νέο πλάτος για όλα τα τμήματα Specify next point Καθορίστε επόμενο σημείο Specify offset distance Καθορίστε απόσταση παράλληλης μετατόπισης (Offset) - Specify offset distance or Καθορίστε απόσταση παράλληλης μετατόπισης ή Specify opposite corner Καθορίστε απέναντι γωνία Specify other corner point Καθορίστε το άλλο γωνιακό σημείο Specify other endpoint of axis Καθορίστε το άλλο τελικό σημείο άξονα @@ -1112,7 +1109,6 @@ Specify radius of circle Καθορίστε ακτίνα κύκλου Specify rotation angle Καθορίστε γωνία περιστροφής Specify rotation angle of text Καθορίστε γωνία περιστροφής κειμένου - Specify rotation angle or Καθορίστε γωνία περιστροφής ή Specify scale factor Καθορίστε συντελεστή κλίμακας Specify second angle endpoint Καθορίστε δεύτερο τελικό σημείο γωνίας Specify second break point Καθορίστε δεύτερο σημείο διακοπής @@ -1145,9 +1141,9 @@ Specify third source point or Καθορίστε τρίτο σημείο προέλευσης ή <συνέχεια> Specify through point Καθορίστε σημείο διέλευσης Specify top diameter Καθορίστε άνω διάμετρο - Specify top radius or Καθορίστε άνω ακτίνα ή + Specify top radius Καθορίστε άνω ακτίνα Specify total angle Καθορίστε συνολική γωνία - Specify total length or Καθορίστε συνολικό μήκος ή + Specify total length Καθορίστε συνολικό μήκος Specify width for rectangles Καθορίστε πλάτος ορθογωνίων Sphere Σφαίρα Spherical Σφαιρικό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8c45b7f97..457341c36 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -295,7 +295,7 @@ Enter a vertex editing option Introduce una opción de edición de vértices Enter an option Introduce una opción Enter angle of xline Introduce el ángulo de la línea auxiliar - Enter angle of xline (0) or Introduce el ángulo de la línea auxiliar (0) o + Enter angle of xline (0) Introduce el ángulo de la línea auxiliar (0) Enter area of rectangle in current units Introduce el área del rectángulo en las unidades actuales Enter attribute prompt Introduce la solicitud del atributo Enter attribute tag name Introduce la etiqueta del atributo @@ -306,7 +306,7 @@ Enter default attribute value Introduce el valor predeterminado del atributo Enter degree of spline Introduce el grado de la spline Enter delta angle Introduce el incremento angular - Enter delta length or Introduce el incremento de longitud o + Enter delta length Introduce el incremento de longitud Enter dimension text Introduce el texto de cota Enter dimension text (<> = measurement) Introduce el texto de cota (<> = medida) Enter dimension value or [name=expression] Introduce el valor de cota o [nombre=expresión] @@ -885,8 +885,8 @@ Select an arc or circle Selecciona un arco o círculo Select an arc. Selecciona un arco. Select an object Selecciona un objeto - Select an object to change or Selecciona un objeto que modificar o - Select an object to measure or Selecciona un objeto que medir o + Select an object to change Selecciona un objeto que modificar + Select an object to measure Selecciona un objeto que medir Select an object. Selecciona un objeto. Select an open curve. Selecciona una curva abierta. Select an open line, arc, polyline, ellipse arc or spline. Selecciona una línea, arco, polilínea, arco elíptico o spline abierta. @@ -900,9 +900,8 @@ Select first line Selecciona la primera línea Select first line or arc Selecciona la primera línea o arco Select first object Selecciona el primer objeto - Select first object or Selecciona el primer objeto o Select first point Selecciona el primer punto - Select nested objects to copy or Selecciona objetos anidados para copiar o + Select nested objects to copy Selecciona objetos anidados para copiar Select object Selecciona un objeto Select object to dimension Selecciona un objeto para acotar Select object to divide Selecciona un objeto para dividir @@ -918,7 +917,7 @@ Select one polyline (Multiple is available through the JSON form). Selecciona una polilínea (la opción Multiple está disponible en la forma JSON). Select path curve Selecciona la curva del camino Select point or object Selecciona un punto u objeto - Select polyline or Selecciona una polilínea o + Select polyline Selecciona una polilínea Select second line Selecciona la segunda línea Select second line or shift-select to apply corner Selecciona la segunda línea o usa Mayús para aplicar una esquina Select second object Selecciona el segundo objeto @@ -971,9 +970,8 @@ Specify axis endpoint of ellipse Indica el extremo del eje de la elipse Specify base diameter Indica el diámetro de la base Specify base point Indica el punto base - Specify base point or Indica el punto base o Specify base point or displacement (drag with the middle mouse button for realtime pan) Indica el punto base o el desplazamiento (arrastra con el botón central para encuadrar) - Specify base radius or Indica el radio de la base o + Specify base radius Indica el radio de la base Specify center of donut Indica el centro de la arandela Specify center of ellipse Indica el centro de la elipse Specify center of polygon Indica el centro del polígono @@ -1015,7 +1013,7 @@ Specify first point on circle: _tan to Indica el primer punto del círculo: _tan a Specify first source point Indica el primer punto de origen Specify height Indica la altura - Specify helix height or Indica la altura de la hélice o + Specify helix height Indica la altura de la hélice Specify included angle Indica el ángulo incluido Specify insertion base point Indica el punto base de inserción Specify insertion point Indica el punto de inserción @@ -1034,7 +1032,6 @@ Specify new width for all segments Indica la nueva anchura de todos los segmentos Specify next point Indica el siguiente punto Specify offset distance Indica la distancia de desfase - Specify offset distance or Indica la distancia de desfase o Specify opposite corner Indica la esquina opuesta Specify other corner point Indica el punto de la otra esquina Specify other endpoint of axis Indica el otro extremo del eje @@ -1047,7 +1044,6 @@ Specify radius of circle Indica el radio del círculo Specify rotation angle Indica el ángulo de giro Specify rotation angle of text Indica el ángulo de giro del texto - Specify rotation angle or Indica el ángulo de giro o Specify scale factor Indica el factor de escala Specify second angle endpoint Indica el segundo extremo del ángulo Specify second break point Indica el segundo punto de ruptura @@ -1080,9 +1076,9 @@ Specify third source point or Indica el tercer punto de origen o Specify through point Indica el punto a atravesar Specify top diameter Indica el diámetro superior - Specify top radius or Indica el radio superior o + Specify top radius Indica el radio superior Specify total angle Indica el ángulo total - Specify total length or Indica la longitud total o + Specify total length Indica la longitud total Specify width for rectangles Indica la anchura de los rectángulos Sphere Esfera Spline Spline diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 548ed6a66..62c77c3f9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -221,7 +221,7 @@ Enter a vertex editing option Entrez une option d'édition de sommet Enter an option Entrez une option Enter angle of xline Entrez l'angle de la ligne de construction - Enter angle of xline (0) or Entrez l'angle de la ligne de construction (0) ou + Enter angle of xline (0) Entrez l'angle de la ligne de construction (0) Enter area of rectangle in current units Entrez l'aire du rectangle dans les unités courantes Enter attribute prompt Entrez l'invite de l'attribut Enter attribute tag name Entrez le nom de l'étiquette d'attribut @@ -232,7 +232,7 @@ Enter default attribute value Entrez la valeur par défaut de l'attribut Enter degree of spline Entrez le degré de la spline Enter delta angle Entrez l'écart d'angle - Enter delta length or Entrez l'écart de longueur ou + Enter delta length Entrez l'écart de longueur Enter dimension text Entrez le texte de cotation Enter dimension text (<> = measurement) Entrez le texte de cotation (<> = mesure) Enter dimension value or [name=expression] Entrez la valeur de cotation ou [name=expression] @@ -675,8 +675,8 @@ Select an arc or circle Sélectionner un arc ou un cercle Select an arc. Sélectionner un arc. Select an object Sélectionner un objet - Select an object to change or Sélectionner un objet à modifier ou - Select an object to measure or Sélectionner un objet à mesurer ou + Select an object to change Sélectionner un objet à modifier + Select an object to measure Sélectionner un objet à mesurer Select an object. Sélectionner un objet. Select an open curve. Sélectionner une courbe ouverte. Select an open line, arc, polyline, ellipse arc or spline. Sélectionner une ligne, un arc, une polyligne, un arc elliptique ou une spline ouverts. @@ -690,9 +690,8 @@ Select first line Sélectionner la première ligne Select first line or arc Sélectionner la première ligne ou le premier arc Select first object Sélectionner le premier objet - Select first object or Sélectionner le premier objet ou Select first point Sélectionner le premier point - Select nested objects to copy or Sélectionner des objets imbriqués à copier ou + Select nested objects to copy Sélectionner des objets imbriqués à copier Select object Sélectionner un objet Select object to dimension Sélectionner un objet à coter Select object to divide Sélectionner un objet à diviser @@ -708,7 +707,7 @@ Select one polyline (Multiple is available through the JSON form). Sélectionner une polyligne (l'option Multiple est disponible via le formulaire JSON). Select path curve Sélectionner la courbe de trajectoire Select point or object Sélectionner un point ou un objet - Select polyline or Sélectionner une polyligne ou + Select polyline Sélectionner une polyligne Select second line Sélectionner la deuxième ligne Select second line or shift-select to apply corner Sélectionner la deuxième ligne ou sélectionner avec Maj pour former le coin Select second object Sélectionner le deuxième objet @@ -761,9 +760,8 @@ Specify axis endpoint of ellipse Spécifier l'extrémité de l'axe de l'ellipse Specify base diameter Spécifiez le diamètre de la base Specify base point Spécifiez le point de base - Specify base point or Spécifiez le point de base ou Specify base point or displacement (drag with the middle mouse button for realtime pan) Spécifiez le point de base ou le déplacement (faites glisser avec le bouton central de la souris pour un panoramique en temps réel) - Specify base radius or Spécifiez le rayon de base ou + Specify base radius Spécifiez le rayon de base Specify center of donut Spécifiez le centre de l'anneau Specify center of ellipse Spécifiez le centre de l'ellipse Specify center of polygon Spécifiez le centre du polygone @@ -805,7 +803,7 @@ Specify first point on circle: _tan to Spécifiez le premier point sur le cercle : _tan à Specify first source point Spécifiez le premier point source Specify height Spécifiez la hauteur - Specify helix height or Spécifiez la hauteur de l'hélice ou + Specify helix height Spécifiez la hauteur de l'hélice Specify included angle Spécifiez l'angle inclus Specify insertion base point Spécifiez le point de base d'insertion Specify insertion point Spécifiez le point d'insertion @@ -824,7 +822,6 @@ Specify new width for all segments Spécifiez la nouvelle largeur de tous les segments Specify next point Spécifiez le point suivant Specify offset distance Spécifiez la distance de décalage - Specify offset distance or Spécifiez la distance de décalage ou Specify opposite corner Spécifiez le coin opposé Specify other corner point Spécifiez l'autre point d'angle Specify other endpoint of axis Spécifiez l'autre point final de l'axe @@ -837,7 +834,6 @@ Specify radius of circle Spécifiez le rayon du cercle Specify rotation angle Spécifiez l'angle de rotation Specify rotation angle of text Spécifiez l'angle de rotation du texte - Specify rotation angle or Spécifiez l'angle de rotation ou Specify scale factor Spécifiez le facteur d'échelle Specify second angle endpoint Spécifiez le deuxième point final de l'angle Specify second break point Spécifiez le deuxième point de rupture @@ -870,9 +866,9 @@ Specify third source point or Spécifiez le troisième point source ou Specify through point Spécifiez le point de passage Specify top diameter Spécifiez le diamètre supérieur - Specify top radius or Spécifiez le rayon supérieur ou + Specify top radius Spécifiez le rayon supérieur Specify total angle Spécifiez l'angle total - Specify total length or Spécifiez la longueur totale ou + Specify total length Spécifiez la longueur totale Specify width for rectangles Spécifiez la largeur des rectangles Sphere Sphère Spline Spline diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 926ad0e6c..e486f6337 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -350,7 +350,7 @@ Enter a vertex editing option Válassz egy csúcspont-szerkesztési lehetőséget Enter an option Válassz egy lehetőséget Enter angle of xline Add meg a szerkesztővonal szögét - Enter angle of xline (0) or Add meg a szerkesztővonal szögét (0) + Enter angle of xline (0) Add meg a szerkesztővonal szögét (0) Enter area of rectangle in current units Add meg a téglalap területét az aktuális mértékegységben Enter attribute prompt Add meg az attribútum bekérő szövegét Enter attribute tag name Add meg az attribútum címkéjét @@ -361,7 +361,7 @@ Enter default attribute value Add meg az attribútum alapértelmezett értékét Enter degree of spline Add meg a spline fokszámát Enter delta angle Add meg a szögváltozást - Enter delta length or Add meg a hosszváltozást + Enter delta length Add meg a hosszváltozást Enter dimension text Add meg a méretszámot Enter dimension text (<> = measurement) Add meg a méretszámot (<> = mért érték) Enter dimension value or [name=expression] Add meg a méret értékét vagy [név=kifejezés] @@ -931,8 +931,8 @@ Select an arc or circle Válassz egy ívet vagy kört Select an arc. Válassz egy ívet. Select an object Válassz egy objektumot - Select an object to change or Válaszd ki a módosítandó objektumot - Select an object to measure or Válaszd ki a mérendő objektumot + Select an object to change Válaszd ki a módosítandó objektumot + Select an object to measure Válaszd ki a mérendő objektumot Select an object. Válassz egy objektumot. Select an open curve. Válassz egy nyitott görbét. Select an open line, arc, polyline, ellipse arc or spline. Válassz egy nyitott vonalat, ívet, vonalláncot, ellipszisívet vagy spline-t. @@ -946,9 +946,8 @@ Select first line Válaszd ki az első vonalat Select first line or arc Válaszd ki az első vonalat vagy ívet Select first object Válaszd ki az első objektumot - Select first object or Válaszd ki az első objektumot Select first point Válaszd ki az első pontot - Select nested objects to copy or Válaszd ki a másolandó beágyazott objektumokat + Select nested objects to copy Válaszd ki a másolandó beágyazott objektumokat Select object Válassz objektumot Select object to dimension Válaszd ki a méretezendő objektumot Select object to divide Válaszd ki a felosztandó objektumot @@ -965,7 +964,7 @@ Select one polyline (Multiple is available through the JSON form). Egyetlen vonalláncot válassz (a Multiple a parancs JSON-hívásával érhető el). Select path curve Válaszd ki az útvonalgörbét Select point or object Válassz pontot vagy objektumot - Select polyline or Válassz vonalláncot + Select polyline Válassz vonalláncot Select second line Válaszd ki a második vonalat Select second line or shift-select to apply corner Válaszd ki a második vonalat, Shift+kijelöléssel éles sarkot képezhetsz Select second object Válaszd ki a második objektumot @@ -1046,9 +1045,8 @@ Specify axis endpoint of ellipse Add meg az ellipszis tengelyének végpontját Specify base diameter Add meg az alap átmérőjét Specify base point Add meg a bázispontot - Specify base point or Add meg a bázispontot Specify base point or displacement (drag with the middle mouse button for realtime pan) Add meg a bázispontot vagy az elmozdulást (a középső egérgombbal húzva a nézetet valós időben mozgathatod) - Specify base radius or Add meg az alap sugarát + Specify base radius Add meg az alap sugarát Specify center of donut Add meg a gyűrű középpontját Specify center of ellipse Add meg az ellipszis középpontját Specify center of polygon Add meg a sokszög középpontját @@ -1090,7 +1088,7 @@ Specify first point on circle: _tan to Add meg a kör első pontját: _tan to Specify first source point Add meg az első forráspontot Specify height Add meg a magasságot - Specify helix height or Add meg a csavarvonal magasságát + Specify helix height Add meg a csavarvonal magasságát Specify included angle Add meg a középponti szöget Specify insertion base point Add meg a beszúrási bázispontot Specify insertion point Add meg a beszúrási pontot @@ -1109,7 +1107,6 @@ Specify new width for all segments Add meg az összes szakasz új szélességét Specify next point Add meg a következő pontot Specify offset distance Add meg az eltolási távolságot - Specify offset distance or Add meg az eltolási távolságot Specify opposite corner Add meg az átellenes sarkot Specify other corner point Add meg a másik sarokpontot Specify other endpoint of axis Add meg a tengely másik végpontját @@ -1122,7 +1119,6 @@ Specify radius of circle Add meg a kör sugarát Specify rotation angle Add meg az elforgatási szöget Specify rotation angle of text Add meg a szöveg elforgatási szögét - Specify rotation angle or Add meg az elforgatási szöget Specify scale factor Add meg a léptéktényezőt Specify second angle endpoint Add meg a szög második végpontját Specify second break point Add meg a második megszakítási pontot @@ -1155,9 +1151,9 @@ Specify third source point or Add meg a harmadik forráspontot vagy Specify through point Add meg az átmenő pontot Specify top diameter Add meg a felső átmérőt - Specify top radius or Add meg a felső sugarat + Specify top radius Add meg a felső sugarat Specify total angle Add meg a teljes szöget - Specify total length or Add meg a teljes hosszt + Specify total length Add meg a teljes hosszt Specify width for rectangles Add meg a téglalapok szélességét Sphere Gömb Spherical Gömbszerű diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cec28d857..b437397b6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -288,7 +288,7 @@ menu Modify Edita Enter a vertex editing option Digitare un'opzione di modifica dei vertici Enter an option Digitare un'opzione Enter angle of xline Digitare l'angolo della linea di costruzione - Enter angle of xline (0) or Digitare l'angolo della linea di costruzione (0) o + Enter angle of xline (0) Digitare l'angolo della linea di costruzione (0) Enter area of rectangle in current units Digitare l'area del rettangolo nelle unità correnti Enter attribute prompt Digitare il messaggio dell'attributo Enter attribute tag name Digitare il nome dell'etichetta dell'attributo @@ -299,7 +299,7 @@ menu Modify Edita Enter default attribute value Digitare il valore predefinito dell'attributo Enter degree of spline Digitare il grado della spline Enter delta angle Digitare la variazione dell'angolo - Enter delta length or Digitare la variazione di lunghezza o + Enter delta length Digitare la variazione di lunghezza Enter dimension text Digitare il testo della quota Enter dimension text (<> = measurement) Digitare il testo della quota (<> = misura) Enter dimension value or [name=expression] Digitare il valore della quota o [nome=espressione] @@ -797,8 +797,8 @@ menu Modify Edita Select an arc or circle Selezionare un arco o un cerchio Select an arc. Selezionare un arco. Select an object Selezionare un oggetto - Select an object to change or Selezionare l'oggetto da modificare o - Select an object to measure or Selezionare l'oggetto da misurare o + Select an object to change Selezionare l'oggetto da modificare + Select an object to measure Selezionare l'oggetto da misurare Select an object. Selezionare un oggetto. Select an open curve. Selezionare una curva aperta. Select an open line, arc, polyline, ellipse arc or spline. Selezionare una linea, un arco, una polilinea, un arco ellittico o una spline aperti. @@ -811,9 +811,8 @@ menu Modify Edita Select first line Selezionare la prima linea Select first line or arc Selezionare la prima linea o il primo arco Select first object Selezionare il primo oggetto - Select first object or Selezionare il primo oggetto o Select first point Selezionare il primo punto - Select nested objects to copy or Selezionare gli oggetti nidificati da copiare o + Select nested objects to copy Selezionare gli oggetti nidificati da copiare Select object Selezionare l'oggetto Select object to dimension Selezionare l'oggetto da quotare Select object to divide Selezionare l'oggetto da dividere @@ -829,7 +828,7 @@ menu Modify Edita Select one polyline (Multiple is available through the JSON form). Selezionare una polilinea (Multiple è disponibile tramite il modulo JSON). Select path curve Selezionare la curva della traiettoria Select point or object Selezionare un punto o un oggetto - Select polyline or Selezionare la polilinea o + Select polyline Selezionare la polilinea Select second line Selezionare la seconda linea Select second line or shift-select to apply corner Selezionare la seconda linea o selezionare con Maiusc per applicare l'angolo Select second object Selezionare il secondo oggetto @@ -889,9 +888,8 @@ menu Modify Edita Specify axis endpoint of ellipse Specificare l'estremo dell'asse dell'ellisse Specify base diameter Specificare il diametro di base Specify base point Specificare il punto base - Specify base point or Specificare il punto base o Specify base point or displacement (drag with the middle mouse button for realtime pan) Specificare il punto base o lo spostamento (trascinare con il tasto centrale del mouse per il pan in tempo reale) - Specify base radius or Specificare il raggio di base o + Specify base radius Specificare il raggio di base Specify center of donut Specificare il centro dell'anello Specify center of ellipse Specificare il centro dell'ellisse Specify center of polygon Specificare il centro del poligono @@ -933,7 +931,7 @@ menu Modify Edita Specify first point on circle: _tan to Specificare il primo punto sul cerchio: _tan a Specify first source point Specificare il primo punto di origine Specify height Specificare l'altezza - Specify helix height or Specificare l'altezza dell'elica o + Specify helix height Specificare l'altezza dell'elica Specify included angle Specificare l'angolo compreso Specify insertion base point Specificare il punto base di inserimento Specify insertion point Specificare il punto di inserimento @@ -987,7 +985,6 @@ menu Modify Edita Specify new width for all segments Specificare la nuova larghezza di tutti i segmenti Specify next point Specificare il punto successivo Specify offset distance Specificare la distanza di offset - Specify offset distance or Specificare la distanza di offset o Specify opposite corner Specificare l'angolo opposto Specify other corner point Specificare l'altro angolo Specify other endpoint of axis Specificare l'altro estremo dell'asse @@ -1000,7 +997,6 @@ menu Modify Edita Specify radius of circle Specificare il raggio del cerchio Specify rotation angle Specificare l'angolo di rotazione Specify rotation angle of text Specificare l'angolo di rotazione del testo - Specify rotation angle or Specificare l'angolo di rotazione o Specify scale factor Specificare il fattore di scala Specify second angle endpoint Specificare il secondo estremo dell'angolo Specify second break point Specificare il secondo punto di interruzione @@ -1033,9 +1029,9 @@ menu Modify Edita Specify third source point or Specificare il terzo punto di origine o Specify through point Specificare il punto di passaggio Specify top diameter Specificare il diametro superiore - Specify top radius or Specificare il raggio superiore o + Specify top radius Specificare il raggio superiore Specify total angle Specificare l'angolo totale - Specify total length or Specificare la lunghezza totale o + Specify total length Specificare la lunghezza totale Specify width for rectangles Specificare la larghezza dei rettangoli Sphere Sfera Spline Spline diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f41128ba1..2f269b051 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -281,7 +281,7 @@ Enter a vertex editing option 정점 편집 옵션 입력 Enter an option 옵션 입력 Enter angle of xline 구성선 각도 입력 - Enter angle of xline (0) or 구성선 각도 입력(0) 또는 + Enter angle of xline (0) 구성선 각도 입력(0) Enter area of rectangle in current units 현재 단위로 직사각형 면적 입력 Enter attribute prompt 속성 프롬프트 입력 Enter attribute tag name 속성 태그 이름 입력 @@ -292,7 +292,7 @@ Enter default attribute value 기본 속성 값 입력 Enter degree of spline 스플라인 차수 입력 Enter delta angle 각도 변화량 입력 - Enter delta length or 길이 변화량 입력 또는 + Enter delta length 길이 변화량 입력 Enter dimension text 치수 문자 입력 Enter dimension text (<> = measurement) 치수 문자 입력(<> = 측정값) Enter dimension value or [name=expression] 치수 값 또는 [name=expression] 입력 @@ -788,8 +788,8 @@ Select an arc or circle 호 또는 원 선택 Select an arc. 호를 선택하세요. Select an object 객체 선택 - Select an object to change or 변경할 객체 선택 또는 - Select an object to measure or 측정할 객체 선택 또는 + Select an object to change 변경할 객체 선택 + Select an object to measure 측정할 객체 선택 Select an object. 객체를 선택하세요. Select an open curve. 열린 곡선을 선택하세요. Select an open line, arc, polyline, ellipse arc or spline. 열린 선, 호, 폴리선, 타원 호 또는 스플라인을 선택하세요. @@ -803,9 +803,8 @@ Select first line 첫 번째 선 선택 Select first line or arc 첫 번째 선 또는 호 선택 Select first object 첫 번째 객체 선택 - Select first object or 첫 번째 객체 선택 또는 Select first point 첫 번째 점 선택 - Select nested objects to copy or 복사할 내포된 객체 선택 또는 + Select nested objects to copy 복사할 내포된 객체 선택 Select object 객체 선택 Select object to dimension 치수를 기입할 객체 선택 Select object to divide 등분할할 객체 선택 @@ -821,7 +820,7 @@ Select one polyline (Multiple is available through the JSON form). 폴리선 하나를 선택하세요(다중 선택은 JSON 형식으로 사용할 수 있습니다). Select path curve 경로 곡선 선택 Select point or object 점 또는 객체 선택 - Select polyline or 폴리선 선택 또는 + Select polyline 폴리선 선택 Select second line 두 번째 선 선택 Select second line or shift-select to apply corner 두 번째 선 선택 또는 Shift를 누른 채 선택하여 각진 모서리 적용 Select second object 두 번째 객체 선택 @@ -881,9 +880,8 @@ Specify axis endpoint of ellipse 타원 축 끝점 지정 Specify base diameter 밑면 지름 지정 Specify base point 기준점 지정 - Specify base point or 기준점 지정 또는 Specify base point or displacement (drag with the middle mouse button for realtime pan) 기준점 또는 변위 지정(마우스 가운데 버튼으로 드래그하여 실시간 초점 이동) - Specify base radius or 밑면 반지름 지정 또는 + Specify base radius 밑면 반지름 지정 Specify center of donut 도넛 중심 지정 Specify center of ellipse 타원 중심 지정 Specify center of polygon 다각형 중심 지정 @@ -925,7 +923,7 @@ Specify first point on circle: _tan to 원 위의 첫 번째 점 지정: _tan 접점 Specify first source point 첫 번째 원본점 지정 Specify height 높이 지정 - Specify helix height or 나선 높이 지정 또는 + Specify helix height 나선 높이 지정 Specify included angle 사이각 지정 Specify insertion base point 삽입 기준점 지정 Specify insertion point 삽입점 지정 @@ -979,7 +977,6 @@ Specify new width for all segments 모든 구간의 새 폭 지정 Specify next point 다음 점 지정 Specify offset distance 간격띄우기 거리 지정 - Specify offset distance or 간격띄우기 거리 지정 또는 Specify opposite corner 반대 구석 지정 Specify other corner point 다른 구석점 지정 Specify other endpoint of axis 축의 다른 끝점 지정 @@ -992,7 +989,6 @@ Specify radius of circle 원 반지름 지정 Specify rotation angle 회전 각도 지정 Specify rotation angle of text 문자 회전 각도 지정 - Specify rotation angle or 회전 각도 지정 또는 Specify scale factor 축척 비율 지정 Specify second angle endpoint 두 번째 각도 끝점 지정 Specify second break point 두 번째 끊기점 지정 @@ -1025,9 +1021,9 @@ Specify third source point or 세 번째 원본점 지정 또는 <계속> Specify through point 통과점 지정 Specify top diameter 윗면 지름 지정 - Specify top radius or 윗면 반지름 지정 또는 + Specify top radius 윗면 반지름 지정 Specify total angle 전체 각도 지정 - Specify total length or 전체 길이 지정 또는 + Specify total length 전체 길이 지정 Specify width for rectangles 직사각형 폭 지정 Sphere 구 Spline 스플라인 diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 3e16b65cf..a000d9b09 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -291,7 +291,7 @@ Enter a vertex editing option Digite uma opção de edição de vértice Enter an option Digite uma opção Enter angle of xline Digite o ângulo da linha infinita - Enter angle of xline (0) or Digite o ângulo da linha infinita (0) ou + Enter angle of xline (0) Digite o ângulo da linha infinita (0) Enter area of rectangle in current units Digite a área do retângulo nas unidades atuais Enter attribute prompt Digite o texto de solicitação do atributo Enter attribute tag name Digite o nome do rótulo do atributo @@ -302,7 +302,7 @@ Enter default attribute value Digite o valor padrão do atributo Enter degree of spline Digite o grau da spline Enter delta angle Digite o ângulo delta - Enter delta length or Digite o comprimento delta ou + Enter delta length Digite o comprimento delta Enter dimension text Digite o texto da cota Enter dimension text (<> = measurement) Digite o texto da cota (<> = medida) Enter dimension value or [name=expression] Digite o valor da cota ou [nome=expressão] @@ -880,8 +880,8 @@ Select an arc or circle Selecione um arco ou círculo Select an arc. Selecione um arco. Select an object Selecione um objeto - Select an object to change or Selecione um objeto para alterar ou - Select an object to measure or Selecione um objeto para medir ou + Select an object to change Selecione um objeto para alterar + Select an object to measure Selecione um objeto para medir Select an object. Selecione um objeto. Select an open curve. Selecione uma curva aberta. Select an open line, arc, polyline, ellipse arc or spline. Selecione uma linha aberta, arco, polilinha, arco elíptico ou spline. @@ -895,9 +895,8 @@ Select first line Selecione a primeira linha Select first line or arc Selecione a primeira linha ou arco Select first object Selecione o primeiro objeto - Select first object or Selecione o primeiro objeto ou Select first point Selecione o primeiro ponto - Select nested objects to copy or Selecione objetos aninhados para copiar ou + Select nested objects to copy Selecione objetos aninhados para copiar Select object Selecione o objeto Select object to dimension Selecione o objeto a cotar Select object to divide Selecione o objeto a dividir @@ -913,7 +912,7 @@ Select one polyline (Multiple is available through the JSON form). Selecione uma polilinha (a opção Multiple está disponível pela forma JSON). Select path curve Selecione a curva da trajetória Select point or object Selecione um ponto ou objeto - Select polyline or Selecione uma polilinha ou + Select polyline Selecione uma polilinha Select second line Selecione a segunda linha Select second line or shift-select to apply corner Selecione a segunda linha ou use Shift para aplicar o canto Select second object Selecione o segundo objeto @@ -966,9 +965,8 @@ Specify axis endpoint of ellipse Especifique a extremidade do eixo da elipse Specify base diameter Especifique o diâmetro da base Specify base point Especifique o ponto base - Specify base point or Especifique o ponto base ou Specify base point or displacement (drag with the middle mouse button for realtime pan) Especifique o ponto base ou o deslocamento (arraste com o botão do meio do mouse para pan em tempo real) - Specify base radius or Especifique o raio da base ou + Specify base radius Especifique o raio da base Specify center of donut Especifique o centro do anel Specify center of ellipse Especifique o centro da elipse Specify center of polygon Especifique o centro do polígono @@ -1010,7 +1008,7 @@ Specify first point on circle: _tan to Especifique o primeiro ponto no círculo: _tan para Specify first source point Especifique o primeiro ponto de origem Specify height Especifique a altura - Specify helix height or Especifique a altura da hélice ou + Specify helix height Especifique a altura da hélice Specify included angle Especifique o ângulo incluído Specify insertion base point Especifique o ponto base de inserção Specify insertion point Especifique o ponto de inserção @@ -1029,7 +1027,6 @@ Specify new width for all segments Especifique a nova largura para todos os segmentos Specify next point Especifique o próximo ponto Specify offset distance Especifique a distância de offset - Specify offset distance or Especifique a distância de offset ou Specify opposite corner Especifique o canto oposto Specify other corner point Especifique o outro ponto de canto Specify other endpoint of axis Especifique a outra extremidade do eixo @@ -1042,7 +1039,6 @@ Specify radius of circle Especifique o raio do círculo Specify rotation angle Especifique o ângulo de rotação Specify rotation angle of text Especifique o ângulo de rotação do texto - Specify rotation angle or Especifique o ângulo de rotação ou Specify scale factor Especifique o fator de escala Specify second angle endpoint Especifique a segunda extremidade do ângulo Specify second break point Especifique o segundo ponto de quebra @@ -1075,9 +1071,9 @@ Specify third source point or Especifique o terceiro ponto de origem ou Specify through point Especifique o ponto de passagem Specify top diameter Especifique o diâmetro superior - Specify top radius or Especifique o raio superior ou + Specify top radius Especifique o raio superior Specify total angle Especifique o ângulo total - Specify total length or Especifique o comprimento total ou + Specify total length Especifique o comprimento total Specify width for rectangles Especifique a largura para retângulos Sphere Esfera Spline Spline diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7e4ba21d5..619988f45 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -221,7 +221,7 @@ Enter a vertex editing option Введите параметр редактирования вершины Enter an option Введите параметр Enter angle of xline Введите угол вспомогательной прямой - Enter angle of xline (0) or Введите угол вспомогательной прямой (0) или + Enter angle of xline (0) Введите угол вспомогательной прямой (0) Enter area of rectangle in current units Введите площадь прямоугольника в текущих единицах Enter attribute prompt Введите подсказку атрибута Enter attribute tag name Введите имя тега атрибута @@ -232,7 +232,7 @@ Enter default attribute value Введите значение атрибута по умолчанию Enter degree of spline Введите степень сплайна Enter delta angle Введите изменение угла - Enter delta length or Введите изменение длины или + Enter delta length Введите изменение длины Enter dimension text Введите размерный текст Enter dimension text (<> = measurement) Введите размерный текст (<> = измеренное значение) Enter dimension value or [name=expression] Введите размерное значение или [имя=выражение] @@ -675,8 +675,8 @@ Select an arc or circle Выберите дугу или круг Select an arc. Выберите дугу. Select an object Выберите объект - Select an object to change or Выберите объект для изменения или - Select an object to measure or Выберите объект для измерения или + Select an object to change Выберите объект для изменения + Select an object to measure Выберите объект для измерения Select an object. Выберите объект. Select an open curve. Выберите незамкнутую кривую. Select an open line, arc, polyline, ellipse arc or spline. Выберите незамкнутый отрезок, дугу, полилинию, эллиптическую дугу или сплайн. @@ -690,9 +690,8 @@ Select first line Выберите первый отрезок Select first line or arc Выберите первый отрезок или дугу Select first object Выберите первый объект - Select first object or Выберите первый объект или Select first point Выберите первую точку - Select nested objects to copy or Выберите вложенные объекты для копирования или + Select nested objects to copy Выберите вложенные объекты для копирования Select object Выберите объект Select object to dimension Выберите объект для нанесения размера Select object to divide Выберите объект для деления @@ -708,7 +707,7 @@ Select one polyline (Multiple is available through the JSON form). Выберите одну полилинию (Multiple доступно через JSON). Select path curve Выберите кривую траектории Select point or object Выберите точку или объект - Select polyline or Выберите полилинию или + Select polyline Выберите полилинию Select second line Выберите второй отрезок Select second line or shift-select to apply corner Выберите второй отрезок или выберите с Shift, чтобы построить угол Select second object Выберите второй объект @@ -761,9 +760,8 @@ Specify axis endpoint of ellipse Укажите конечную точку оси эллипса Specify base diameter Укажите базовый диаметр Specify base point Укажите базовую точку - Specify base point or Укажите базовую точку или Specify base point or displacement (drag with the middle mouse button for realtime pan) Укажите базовую точку или смещение (перетаскивайте средней кнопкой мыши для панорамирования) - Specify base radius or Укажите базовый радиус или + Specify base radius Укажите базовый радиус Specify center of donut Укажите центр кольца Specify center of ellipse Укажите центр эллипса Specify center of polygon Укажите центр многоугольника @@ -805,7 +803,7 @@ Specify first point on circle: _tan to Укажите первую точку на круге: _tan к Specify first source point Укажите первую исходную точку Specify height Укажите высоту - Specify helix height or Укажите высоту спирали или + Specify helix height Укажите высоту спирали Specify included angle Укажите угол между сторонами Specify insertion base point Укажите базовую точку вставки Specify insertion point Укажите точку вставки @@ -824,7 +822,6 @@ Specify new width for all segments Укажите новую ширину всех сегментов Specify next point Укажите следующую точку Specify offset distance Укажите расстояние смещения - Specify offset distance or Укажите расстояние смещения или Specify opposite corner Укажите противоположный угол Specify other corner point Укажите другую угловую точку Specify other endpoint of axis Укажите другую конечную точку оси @@ -837,7 +834,6 @@ Specify radius of circle Укажите радиус круга Specify rotation angle Укажите угол поворота Specify rotation angle of text Укажите угол поворота текста - Specify rotation angle or Укажите угол поворота или Specify scale factor Укажите масштабный коэффициент Specify second angle endpoint Укажите вторую конечную точку угла Specify second break point Укажите вторую точку разрыва @@ -870,9 +866,9 @@ Specify third source point or Укажите третью исходную точку или <продолжить> Specify through point Укажите точку, через которую проходит прямая Specify top diameter Укажите диаметр вершины - Specify top radius or Укажите радиус вершины или + Specify top radius Укажите радиус вершины Specify total angle Укажите полный угол - Specify total length or Укажите общую длину или + Specify total length Укажите общую длину Specify width for rectangles Укажите ширину прямоугольников Sphere Сфера Spline Сплайн diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 96114f857..ecfe35b40 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -295,7 +295,7 @@ Enter a vertex editing option Ange ett alternativ för hörnpunktsredigering Enter an option Ange ett alternativ Enter angle of xline Ange konstruktionslinjens vinkel - Enter angle of xline (0) or Ange konstruktionslinjens vinkel (0) eller + Enter angle of xline (0) Ange konstruktionslinjens vinkel (0) Enter area of rectangle in current units Ange rektangelns area i aktuella enheter Enter attribute prompt Ange attributets fråga Enter attribute tag name Ange attributets taggnamn @@ -306,7 +306,7 @@ Enter default attribute value Ange attributets standardvärde Enter degree of spline Ange splinens grad Enter delta angle Ange vinkeländring - Enter delta length or Ange längdändring eller + Enter delta length Ange längdändring Enter dimension text Ange måttal Enter dimension text (<> = measurement) Ange måttal (<> = mätvärde) Enter dimension value or [name=expression] Ange måttvärde eller [namn=uttryck] @@ -878,8 +878,8 @@ Select an arc or circle Markera en båge eller cirkel Select an arc. Markera en båge. Select an object Markera ett objekt - Select an object to change or Markera ett objekt att ändra eller - Select an object to measure or Markera ett objekt att mäta eller + Select an object to change Markera ett objekt att ändra + Select an object to measure Markera ett objekt att mäta Select an object. Markera ett objekt. Select an open curve. Markera en öppen kurva. Select an open line, arc, polyline, ellipse arc or spline. Markera en öppen linje, båge, polylinje, ellipsbåge eller spline. @@ -893,9 +893,8 @@ Select first line Markera första linjen Select first line or arc Markera första linjen eller bågen Select first object Markera första objektet - Select first object or Markera första objektet eller Select first point Ange första punkten - Select nested objects to copy or Markera nästlade objekt att kopiera eller + Select nested objects to copy Markera nästlade objekt att kopiera Select object Markera objekt Select object to dimension Markera objekt att måttsätta Select object to divide Markera objekt att dela in @@ -911,7 +910,7 @@ Select one polyline (Multiple is available through the JSON form). Markera en polylinje (Multiple finns i JSON-formen). Select path curve Markera banans kurva Select point or object Ange punkt eller markera objekt - Select polyline or Markera polylinje eller + Select polyline Markera polylinje Select second line Markera andra linjen Select second line or shift-select to apply corner Markera andra linjen, eller Shift-markera för att skapa hörn Select second object Markera andra objektet @@ -964,9 +963,8 @@ Specify axis endpoint of ellipse Ange ellipsens axeländpunkt Specify base diameter Ange basdiameter Specify base point Ange baspunkt - Specify base point or Ange baspunkt eller Specify base point or displacement (drag with the middle mouse button for realtime pan) Ange baspunkt eller förskjutning (dra med mittenknappen för att panorera i realtid) - Specify base radius or Ange basradie eller + Specify base radius Ange basradie Specify center of donut Ange ringens centrum Specify center of ellipse Ange ellipsens centrum Specify center of polygon Ange polygonens centrum @@ -1008,7 +1006,7 @@ Specify first point on circle: _tan to Ange första punkten på cirkeln: _tan to Specify first source point Ange första källpunkten Specify height Ange höjd - Specify helix height or Ange helixens höjd eller + Specify helix height Ange helixens höjd Specify included angle Ange medelpunktsvinkel Specify insertion base point Ange insättningens baspunkt Specify insertion point Ange insättningspunkt @@ -1027,7 +1025,6 @@ Specify new width for all segments Ange ny bredd för alla segment Specify next point Ange nästa punkt Specify offset distance Ange förskjutningsavstånd - Specify offset distance or Ange förskjutningsavstånd eller Specify opposite corner Ange motsatt hörn Specify other corner point Ange andra hörnpunkten Specify other endpoint of axis Ange axelns andra ändpunkt @@ -1040,7 +1037,6 @@ Specify radius of circle Ange cirkelns radie Specify rotation angle Ange rotationsvinkel Specify rotation angle of text Ange textens rotationsvinkel - Specify rotation angle or Ange rotationsvinkel eller Specify scale factor Ange skalfaktor Specify second angle endpoint Ange vinkelns andra ändpunkt Specify second break point Ange andra brytpunkten @@ -1073,9 +1069,9 @@ Specify third source point or Ange tredje källpunkten eller Specify through point Ange genomgångspunkt Specify top diameter Ange toppdiameter - Specify top radius or Ange toppradie eller + Specify top radius Ange toppradie Specify total angle Ange total vinkel - Specify total length or Ange total längd eller + Specify total length Ange total längd Specify width for rectangles Ange bredd för rektanglar Sphere Sfär Spline Spline diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 492b9e595..24fac6a79 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -40,6 +40,17 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() { assert_eq!(tr("unknown", "Open"), "Open"); } +/// A prompt with options is translated without its own "or": the command line adds the +/// translated " or [" before the options ("Select polyline or [Multiple]"). +#[test] +fn prompts_with_options_leave_or_to_the_command_line() { + assert_eq!(format!("{}{}", tr("de", "Select polyline"), tr("de", " or [")), "Wählen Sie eine Polylinie oder ["); + assert_eq!(format!("{}{}", tr("ko", "Specify helix height"), tr("ko", " or [")), "나선 높이 지정 또는 ["); + assert_eq!(format!("{}{}", tr("zh", "Enter delta length"), tr("zh", " or [")), "输入增量长度 或 ["); + assert_eq!(format!("{}{}", tr("vi", "Select polyline"), tr("vi", " or [")), "Chọn polyline hoặc ["); + assert_eq!(format!("{}{}", tr("vi", "Specify base radius"), tr("vi", " or [")), "Chỉ định bán kính đáy hoặc ["); +} + /// What each language shows in English for now. Missing translations never fail a build: /// `cargo xtask i18n`, a step of `cargo xtask ci`, runs this to print the list. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3fed6bc3b..cc8576aa7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -351,7 +351,7 @@ Enter a vertex editing option Bir köşe noktası düzenleme seçeneği girin Enter an option Bir seçenek girin Enter angle of xline Yapı çizgisinin açısını girin - Enter angle of xline (0) or Yapı çizgisinin açısını girin (0) veya + Enter angle of xline (0) Yapı çizgisinin açısını girin (0) Enter area of rectangle in current units Dikdörtgenin alanını geçerli birimlerle girin Enter attribute prompt Öznitelik istemini girin Enter attribute tag name Öznitelik etiket adını girin @@ -362,7 +362,7 @@ Enter default attribute value Varsayılan öznitelik değerini girin Enter degree of spline Spline derecesini girin Enter delta angle Açı farkını girin - Enter delta length or Uzunluk farkını girin veya + Enter delta length Uzunluk farkını girin Enter dimension text Ölçü metnini girin Enter dimension text (<> = measurement) Ölçü metnini girin (<> = ölçülen değer) Enter dimension value or [name=expression] Ölçü değerini girin veya [name=expression] @@ -932,8 +932,8 @@ Select an arc or circle Bir yay veya daire seçin Select an arc. Bir yay seçin. Select an object Bir nesne seçin - Select an object to change or Değiştirilecek nesneyi seçin veya - Select an object to measure or Ölçülecek nesneyi seçin veya + Select an object to change Değiştirilecek nesneyi seçin + Select an object to measure Ölçülecek nesneyi seçin Select an object. Bir nesne seçin. Select an open curve. Açık bir eğri seçin. Select an open line, arc, polyline, ellipse arc or spline. Açık bir çizgi, yay, çoklu çizgi, elips yayı veya spline seçin. @@ -947,9 +947,8 @@ Select first line İlk çizgiyi seçin Select first line or arc İlk çizgiyi veya yayı seçin Select first object İlk nesneyi seçin - Select first object or İlk nesneyi seçin veya Select first point İlk noktayı seçin - Select nested objects to copy or Kopyalanacak iç içe nesneleri seçin veya + Select nested objects to copy Kopyalanacak iç içe nesneleri seçin Select object Nesne seçin Select object to dimension Ölçülendirilecek nesneyi seçin Select object to divide Bölünecek nesneyi seçin @@ -966,7 +965,7 @@ Select one polyline (Multiple is available through the JSON form). Bir çoklu çizgi seçin (Multiple seçeneği JSON biçimiyle kullanılabilir). Select path curve Yol eğrisini seçin Select point or object Nokta veya nesne seçin - Select polyline or Çoklu çizgi seçin veya + Select polyline Çoklu çizgi seçin Select second line İkinci çizgiyi seçin Select second line or shift-select to apply corner İkinci çizgiyi seçin ya da köşe uygulamak için Shift ile seçin Select second object İkinci nesneyi seçin @@ -1047,9 +1046,8 @@ Specify axis endpoint of ellipse Elipsin eksen uç noktasını belirtin Specify base diameter Taban çapını belirtin Specify base point Taban noktasını belirtin - Specify base point or Taban noktasını belirtin veya Specify base point or displacement (drag with the middle mouse button for realtime pan) Taban noktasını veya yer değiştirmeyi belirtin (gerçek zamanlı kaydırma için orta fare düğmesiyle sürükleyin) - Specify base radius or Taban yarıçapını belirtin veya + Specify base radius Taban yarıçapını belirtin Specify center of donut Halkanın merkezini belirtin Specify center of ellipse Elipsin merkezini belirtin Specify center of polygon Çokgenin merkezini belirtin @@ -1091,7 +1089,7 @@ Specify first point on circle: _tan to Daire üzerindeki ilk noktayı belirtin: _tan to Specify first source point İlk kaynak noktayı belirtin Specify height Yüksekliği belirtin - Specify helix height or Helezon yüksekliğini belirtin veya + Specify helix height Helezon yüksekliğini belirtin Specify included angle İç açıyı belirtin Specify insertion base point Ekleme taban noktasını belirtin Specify insertion point Ekleme noktasını belirtin @@ -1110,7 +1108,6 @@ Specify new width for all segments Tüm parçalar için yeni genişliği belirtin Specify next point Sonraki noktayı belirtin Specify offset distance Öteleme uzaklığını belirtin - Specify offset distance or Öteleme uzaklığını belirtin veya Specify opposite corner Karşı köşeyi belirtin Specify other corner point Diğer köşe noktasını belirtin Specify other endpoint of axis Eksenin diğer uç noktasını belirtin @@ -1123,7 +1120,6 @@ Specify radius of circle Dairenin yarıçapını belirtin Specify rotation angle Döndürme açısını belirtin Specify rotation angle of text Metnin döndürme açısını belirtin - Specify rotation angle or Döndürme açısını belirtin veya Specify scale factor Ölçek faktörünü belirtin Specify second angle endpoint Açının ikinci uç noktasını belirtin Specify second break point İkinci kırma noktasını belirtin @@ -1156,9 +1152,9 @@ Specify third source point or Üçüncü kaynak noktayı belirtin veya Specify through point Geçiş noktasını belirtin Specify top diameter Üst çapı belirtin - Specify top radius or Üst yarıçapı belirtin veya + Specify top radius Üst yarıçapı belirtin Specify total angle Toplam açıyı belirtin - Specify total length or Toplam uzunluğu belirtin veya + Specify total length Toplam uzunluğu belirtin Specify width for rectangles Dikdörtgenler için genişliği belirtin Sphere Küre Spherical Küresel diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bbb70e489..8d8c6b875 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -286,7 +286,7 @@ Enter a vertex editing option Введіть параметр редагування вершини Enter an option Введіть параметр Enter angle of xline Введіть кут допоміжної прямої - Enter angle of xline (0) or Введіть кут допоміжної прямої (0) або + Enter angle of xline (0) Введіть кут допоміжної прямої (0) Enter area of rectangle in current units Введіть площу прямокутника в поточних одиницях Enter attribute prompt Введіть підказку атрибута Enter attribute tag name Введіть ім'я тегу атрибута @@ -297,7 +297,7 @@ Enter default attribute value Введіть типове значення атрибута Enter degree of spline Введіть степінь сплайна Enter delta angle Введіть зміну кута - Enter delta length or Введіть зміну довжини або + Enter delta length Введіть зміну довжини Enter dimension text Введіть розмірний текст Enter dimension text (<> = measurement) Введіть розмірний текст (<> = виміряне значення) Enter dimension value or [name=expression] Введіть розмірне значення або [name=expression] @@ -803,8 +803,8 @@ Select an arc or circle Виберіть дугу або коло Select an arc. Виберіть дугу. Select an object Виберіть об'єкт - Select an object to change or Виберіть об'єкт для зміни або - Select an object to measure or Виберіть об'єкт для вимірювання або + Select an object to change Виберіть об'єкт для зміни + Select an object to measure Виберіть об'єкт для вимірювання Select an object. Виберіть об'єкт. Select an open curve. Виберіть незамкнену криву. Select an open line, arc, polyline, ellipse arc or spline. Виберіть незамкнений відрізок, дугу, полілінію, еліптичну дугу або сплайн. @@ -818,9 +818,8 @@ Select first line Виберіть перший відрізок Select first line or arc Виберіть перший відрізок або дугу Select first object Виберіть перший об'єкт - Select first object or Виберіть перший об'єкт або Select first point Виберіть першу точку - Select nested objects to copy or Виберіть вкладені об'єкти для копіювання або + Select nested objects to copy Виберіть вкладені об'єкти для копіювання Select object Виберіть об'єкт Select object to dimension Виберіть об'єкт для нанесення розміру Select object to divide Виберіть об'єкт для поділу @@ -841,7 +840,7 @@ Select one polyline (Multiple is available through the JSON form). Виберіть одну полілінію (Multiple доступно через JSON). Select path curve Виберіть криву траєкторії Select point or object Виберіть точку або об'єкт - Select polyline or Виберіть полілінію або + Select polyline Виберіть полілінію Select second line Виберіть другий відрізок Select second line or shift-select to apply corner Виберіть другий відрізок або виберіть із Shift, щоб утворити кут Select second object Виберіть другий об'єкт @@ -901,9 +900,8 @@ Specify axis endpoint of ellipse Укажіть кінцеву точку осі еліпса Specify base diameter Укажіть діаметр основи Specify base point Укажіть базову точку - Specify base point or Укажіть базову точку або Specify base point or displacement (drag with the middle mouse button for realtime pan) Укажіть базову точку або зміщення (перетягуйте середньою кнопкою миші для панорамування) - Specify base radius or Укажіть радіус основи або + Specify base radius Укажіть радіус основи Specify center of donut Укажіть центр кільця Specify center of ellipse Укажіть центр еліпса Specify center of polygon Укажіть центр багатокутника @@ -945,7 +943,7 @@ Specify first point on circle: _tan to Укажіть першу точку на колі: _tan до Specify first source point Укажіть першу вихідну точку Specify height Укажіть висоту - Specify helix height or Укажіть висоту спіралі або + Specify helix height Укажіть висоту спіралі Specify included angle Укажіть кут між сторонами Specify insertion base point Укажіть базову точку вставлення Specify insertion point Укажіть точку вставлення @@ -1029,7 +1027,6 @@ Specify new width for all segments Укажіть нову ширину всіх сегментів Specify next point Укажіть наступну точку Specify offset distance Укажіть відстань зміщення - Specify offset distance or Укажіть відстань зміщення або Specify opposite corner Укажіть протилежний кут Specify other corner point Укажіть іншу кутову точку Specify other endpoint of axis Укажіть іншу кінцеву точку осі @@ -1042,7 +1039,6 @@ Specify radius of circle Укажіть радіус кола Specify rotation angle Укажіть кут повороту Specify rotation angle of text Укажіть кут повороту тексту - Specify rotation angle or Укажіть кут повороту або Specify scale factor Укажіть коефіцієнт масштабу Specify second angle endpoint Укажіть другу кінцеву точку кута Specify second break point Укажіть другу точку розриву @@ -1075,9 +1071,9 @@ Specify third source point or Укажіть третю вихідну точку або <продовжити> Specify through point Укажіть точку, через яку проходить пряма Specify top diameter Укажіть діаметр вершини - Specify top radius or Укажіть радіус вершини або + Specify top radius Укажіть радіус вершини Specify total angle Укажіть загальний кут - Specify total length or Укажіть загальну довжину або + Specify total length Укажіть загальну довжину Specify width for rectangles Укажіть ширину прямокутників Sphere Сфера Spline Сплайн diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8887bb45b..f1b8dca7b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -280,7 +280,7 @@ Enter a vertex editing option Nhập tùy chọn sửa đỉnh Enter an option Nhập một tùy chọn Enter angle of xline Nhập góc của đường dựng - Enter angle of xline (0) or Nhập góc của đường dựng (0) hoặc + Enter angle of xline (0) Nhập góc của đường dựng (0) Enter area of rectangle in current units Nhập diện tích hình chữ nhật theo đơn vị hiện hành Enter attribute prompt Nhập lời nhắc attribute Enter attribute tag name Nhập tên thẻ attribute @@ -291,7 +291,7 @@ Enter default attribute value Nhập giá trị attribute mặc định Enter degree of spline Nhập bậc của spline Enter delta angle Nhập góc chênh lệch - Enter delta length or Nhập chiều dài chênh lệch hoặc + Enter delta length Nhập chiều dài chênh lệch Enter dimension text Nhập chữ kích thước Enter dimension text (<> = measurement) Nhập chữ kích thước (<> = số đo) Enter dimension value or [name=expression] Nhập giá trị kích thước hoặc [name=expression] @@ -787,8 +787,8 @@ Select an arc or circle Chọn một cung tròn hoặc đường tròn Select an arc. Chọn một cung tròn. Select an object Chọn một đối tượng - Select an object to change or Chọn một đối tượng để thay đổi hoặc - Select an object to measure or Chọn một đối tượng để đo hoặc + Select an object to change Chọn một đối tượng để thay đổi + Select an object to measure Chọn một đối tượng để đo Select an object. Chọn một đối tượng. Select an open curve. Chọn một đường cong hở. Select an open line, arc, polyline, ellipse arc or spline. Chọn một đoạn thẳng, cung tròn, polyline, cung elip hoặc spline hở. @@ -802,14 +802,13 @@ Select first line Chọn đoạn thẳng thứ nhất Select first line or arc Chọn đoạn thẳng hoặc cung tròn thứ nhất Select first object Chọn đối tượng thứ nhất - Select first object or Chọn đối tượng thứ nhất hoặc Select first point Chọn điểm thứ nhất Add to Working Set Thêm vào Working Set Edit Reference In-Place Chỉnh sửa tham chiếu tại chỗ Save Reference Edits Lưu chỉnh sửa tham chiếu Working Set Working Set Xref and Block In-place Editing Chỉnh sửa Xref và Block tại chỗ - Select nested objects to copy or Chọn đối tượng lồng nhau để sao chép hoặc + Select nested objects to copy Chọn đối tượng lồng nhau để sao chép Select object Chọn đối tượng Select object to dimension Chọn đối tượng để ghi kích thước Select object to divide Chọn đối tượng để chia @@ -825,7 +824,7 @@ Select one polyline (Multiple is available through the JSON form). Chọn một polyline (có thể chọn nhiều qua dạng JSON). Select path curve Chọn đường cong làm đường dẫn Select point or object Chọn điểm hoặc đối tượng - Select polyline or Chọn polyline hoặc + Select polyline Chọn polyline Select second line Chọn đoạn thẳng thứ hai Select second line or shift-select to apply corner Chọn đoạn thẳng thứ hai hoặc chọn kèm Shift để áp dụng góc Select second object Chọn đối tượng thứ hai @@ -883,9 +882,8 @@ Specify axis endpoint of ellipse Chỉ định điểm cuối trục của elip Specify base diameter Chỉ định đường kính đáy Specify base point Chỉ định điểm gốc - Specify base point or Chỉ định điểm gốc hoặc Specify base point or displacement (drag with the middle mouse button for realtime pan) Chỉ định điểm gốc hoặc độ dời (kéo bằng nút giữa chuột để pan theo thời gian thực) - Specify base radius or Chỉ định bán kính đáy hoặc + Specify base radius Chỉ định bán kính đáy Specify center of donut Chỉ định tâm vành tròn Specify center of ellipse Chỉ định tâm elip Specify center of polygon Chỉ định tâm đa giác @@ -927,7 +925,7 @@ Specify first point on circle: _tan to Chỉ định điểm thứ nhất trên đường tròn: _tan to Specify first source point Chỉ định điểm nguồn thứ nhất Specify height Chỉ định chiều cao - Specify helix height or Chỉ định chiều cao đường xoắn ốc hoặc + Specify helix height Chỉ định chiều cao đường xoắn ốc Specify included angle Chỉ định góc chắn của cung Specify insertion base point Chỉ định điểm gốc chèn Specify insertion point Chỉ định điểm chèn @@ -981,7 +979,6 @@ Specify new width for all segments Chỉ định bề rộng mới cho tất cả các đoạn Specify next point Chỉ định điểm tiếp theo Specify offset distance Chỉ định khoảng cách offset - Specify offset distance or Chỉ định khoảng cách offset hoặc Specify opposite corner Chỉ định góc đối diện Specify other corner point Chỉ định điểm góc đối diện Specify other endpoint of axis Chỉ định đầu kia của trục @@ -994,7 +991,6 @@ Specify radius of circle Chỉ định bán kính đường tròn Specify rotation angle Chỉ định góc xoay Specify rotation angle of text Chỉ định góc xoay của chữ - Specify rotation angle or Chỉ định góc xoay hoặc Specify scale factor Chỉ định hệ số tỉ lệ Specify second angle endpoint Chỉ định đầu thứ hai của góc Specify second break point Chỉ định điểm ngắt thứ hai @@ -1027,9 +1023,9 @@ Specify third source point or Chỉ định điểm nguồn thứ ba hoặc Specify through point Chỉ định điểm đi qua Specify top diameter Chỉ định đường kính trên - Specify top radius or Chỉ định bán kính trên hoặc + Specify top radius Chỉ định bán kính trên Specify total angle Chỉ định tổng góc - Specify total length or Chỉ định tổng chiều dài hoặc + Specify total length Chỉ định tổng chiều dài Specify width for rectangles Chỉ định bề rộng cho hình chữ nhật Sphere Hình cầu Spline Spline diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 9ca85fea7..de4e6a633 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -222,7 +222,7 @@ Enter a vertex editing option 输入顶点编辑选项 Enter an option 输入选项 Enter angle of xline 输入构造线角度 - Enter angle of xline (0) or 输入构造线角度 (0) 或 + Enter angle of xline (0) 输入构造线角度 (0) Enter area of rectangle in current units 在当前单位中输入矩形面积 Enter attribute prompt 输入属性提示 Enter attribute tag name 输入属性标记名称 @@ -233,7 +233,7 @@ Enter default attribute value 输入默认属性值 Enter degree of spline 输入样条曲线次数 Enter delta angle 输入增量角度 - Enter delta length or 输入增量长度或 + Enter delta length 输入增量长度 Enter dimension text 输入标注文字 Enter dimension text (<> = measurement) 输入标注文字 (<> = 测量值) Enter dimension value or [name=expression] 输入标注值或 [名称=表达式] @@ -669,8 +669,8 @@ Select an arc or circle 选择圆弧或圆 Select an arc. 选择一段圆弧。 Select an object 选择对象 - Select an object to change or Select an object to change or - Select an object to measure or Select an object to measure or + Select an object to change Select an object to change + Select an object to measure Select an object to measure Add to Working Set 添加到工作集 Edit Reference In-Place 就地编辑参照 Save Reference Edits 保存参照编辑 @@ -689,9 +689,8 @@ Select first line 选择第一条直线 Select first line or arc 选择第一条直线或圆弧 Select first object 选择第一个对象 - Select first object or Select first object or Select first point 指定第一点 - Select nested objects to copy or Select nested objects to copy or + Select nested objects to copy Select nested objects to copy Select object 选择对象 Select object to dimension 选择要标注的对象 Select object to divide 选择要定数等分的对象 @@ -707,7 +706,7 @@ Select one polyline (Multiple is available through the JSON form). 选择一条多段线(可通过 JSON 表单选择多个)。 Select path curve 选择路径曲线 Select point or object 选择点或对象 - Select polyline or 选择多段线或 + Select polyline 选择多段线 Select second line 选择第二条直线 Select second line or shift-select to apply corner 选择第二条直线,或按 Shift 键应用角点 Select second object 选择第二个对象 @@ -760,9 +759,8 @@ Specify axis endpoint of ellipse 指定椭圆的轴端点 Specify base diameter 指定底面直径 Specify base point 指定基点 - Specify base point or Specify base point or Specify base point or displacement (drag with the middle mouse button for realtime pan) 指定基点或位移(按住鼠标中键拖动以进行实时平移) - Specify base radius or Specify base radius or + Specify base radius Specify base radius Specify center of donut 指定圆环中心 Specify center of ellipse 指定椭圆中心 Specify center of polygon 指定多边形中心 @@ -804,7 +802,7 @@ Specify first point on circle: _tan to 指定圆上的第一点: _tan 到 Specify first source point 指定第一个源点 Specify height 指定高度 - Specify helix height or Specify helix height or + Specify helix height Specify helix height Specify included angle 指定夹角 Specify insertion base point 指定插入基点 Specify insertion point 指定插入点 @@ -823,7 +821,6 @@ Specify new width for all segments 指定所有段的新宽度 Specify next point 指定下一点 Specify offset distance 指定偏移距离 - Specify offset distance or Specify offset distance or Specify opposite corner 指定对角点 Specify other corner point 指定另一角点 Specify other endpoint of axis 指定轴的另一端点 @@ -836,7 +833,6 @@ Specify radius of circle 指定圆半径 Specify rotation angle 指定旋转角度 Specify rotation angle of text 指定文字旋转角度 - Specify rotation angle or Specify rotation angle or Specify scale factor 指定比例因子 Specify second angle endpoint 指定第二角度端点 Specify second break point 指定第二打断点 @@ -869,9 +865,9 @@ Specify third source point or 指定第三个源点或 <继续> Specify through point 指定通过点 Specify top diameter 指定顶面直径 - Specify top radius or Specify top radius or + Specify top radius Specify top radius Specify total angle 指定总角度 - Specify total length or Specify total length or + Specify total length Specify total length Specify width for rectangles 指定矩形宽度 Sphere 球体 Spline 样条曲线 From beb6c48a591d499f3b6125147ca550eeb4ae546a Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:36:42 +0700 Subject: [PATCH 119/151] AREA, PERIMETER and DISTANCE system variables, as AutoCAD sets them (#2552) * feat(inquiry): AREA, PERIMETER and DISTANCE system variables, as AutoCAD sets them AREA, DIST and MEASUREGEOM now leave AREA, PERIMETER and DISTANCE for getvar, read-only and not saved: the area just measured (the running total in Add and Subtract mode) with the last area's perimeter, and the distance (the total of Multiple points). After DIST's two points LASTPOINT stays at the first point, as measured. AREA's Object option measures splines, closed by their chord like open polylines. Co-Authored-By: Claude Opus 5.5 * Address review: spline area over narrow spans, finite distances, list order - AREA of a spline samples it span by span (Spline::tessellate_spans), so a narrow knot span no longer drops out of the area. The spread over the spans is one helper shared with Spline::bounds, whose boxes are unchanged. - DIST refuses a distance that isn't finite on all three paths and leaves DISTANCE as it was. - sysvars::list puts the session's values after the drawing's header, so it shows what get returns (AREA, ORTHOMODE, LWDISPLAY...). - LASTPOINT after DIST's Multiple points is pinned to AutoCAD 2024's measured behaviour: the last point entered, also after Length and Undo. Co-Authored-By: Claude Opus 5.5 * Address review: measure every knot span of a spline in AREA A spline with more knot spans than half the sample cap fell back to an even grid over the domain, which can step over a narrow span and measure the wrong area: a 100 x 100 square as a degree-1 spline, its right and top edges two spans a millionth wide after 4,500 spans along the bottom, measured 4,998.75. Spread the samples over the spans at any span count: at least one segment per span, within the cap up to MAX_SAMPLES spans and one each past it, as Spline::length integrates every span. One evaluation per span costs 85-285 ms on a million spans in a release build, against 32-52 s for the length AREA already takes. Co-Authored-By: Claude Opus 5.5 * Address review: share AREA's spline samples by how much each span bends Past the sample cap, tessellate_spans gave each knot span one segment, so a bent span among many straight ones was measured by its chord. Example: a quadratic spline along the x-axis, one control point 1000 above it, past 4,500 spans. It measured 750; the area is 1750/3. Each span now keeps one segment. A span whose control points are collinear is straight, so its chord is exact. tessellate's count is shared among the spans that bend, by the cube root of a measure of their control points' convex hull, which holds the span and its chord. The shares are rounded on the running total, so they use the whole count however many spans share it. The example now measures 583.33343, below the cap and past it. The spreading by parameter width that this replaces goes, and span_params with it: bounds is main's code again. Co-Authored-By: Claude Opus 5.5 * DIST: a measurement refused as too large leaves LASTPOINT at the first point Session::feed sets LASTPOINT to the second point before DIST checks the distance, and the refusal returned before DIST put the first point back, as it does after a measurement. It now does so before refusing. Co-Authored-By: Claude Opus 5.5 * AREA: a spline too long to measure is too large, not a perimeter of 0 Spline::length reads a length past f64's range as 0, and AREA took that as the spline's perimeter. Spline::checked_length returns None there, and AREA reports the perimeter too large, as it does a polyline's. The overflow was hidden twice: in the sum, and before it in the derivative, which read as zero speed once past f64's range. The length integral now takes the derivative before that (Spline::velocity), so an overflowing speed counts too. param_at_length keeps the speed it had, and finite lengths are unchanged. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/inquiry.rs | 37 ++- crates/engine/src/lib.rs | 9 + crates/engine/src/sysvars.rs | 21 +- crates/engine/tests/it/inquiry_variables.rs | 271 ++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/geom/src/nurbs.rs | 68 +++-- crates/geom/src/spline.rs | 61 ++++- crates/geom/src/spline/tests.rs | 77 ++++++ 8 files changed, 521 insertions(+), 24 deletions(-) create mode 100644 crates/engine/tests/it/inquiry_variables.rs diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 119d727e1..364fa6796 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -217,6 +217,11 @@ fn run_dist(s: &mut Session, p: &Value) -> Result { let a = point3_req(s, "dist", p, "p1")?; let b = point3_req(s, "dist", p, "p2")?; let d = b - a; + if !d.len().is_finite() { + return Err(bad("dist", "the distance is too large")); + } + s.distance = d.len(); + s.last_point = a.xy(); Ok(json!({ "distance": d.len(), "angle": d.xy().angle().to_degrees(), @@ -430,6 +435,8 @@ fn run_area(s: &mut Session, p: &Value) -> Result { if let Some(what) = too_large(a, per) { return Err(bad("area", format!("the {what} is too large"))); } + s.area = a; + s.perimeter = per; Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) })) } @@ -516,6 +523,13 @@ pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> { total } EntityKind::Region(r) => (r.area(), r.perimeter()), + // Like an open polyline: the area closed by the chord, the perimeter its own length. + EntityKind::Spline(sp) if sp.is_valid() => { + let b = sp.bounds(); + let pts = sp.tessellate_spans((b.min.dist(b.max) * 1e-7).max(1e-12)); + // A length past f64's range is too large to report, as a polyline's is, not 0. + (cadcraft_geom::shoelace(&pts).abs(), sp.checked_length().unwrap_or(f64::INFINITY)) + } _ => return Err(bad("area", "object has no area")), }) } @@ -872,16 +886,25 @@ impl Interactive for DistM { self.first_source = s.picked_viewport; Ok(Step::Continue) } - (Some(a), Input::Point(b)) => { + (Some(first), Input::Point(b)) => { let (a, b) = match (self.first_source, s.picked_viewport) { (Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point), - _ => (a, b), + _ => (first, b), }; let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) }; - let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb)); + let (a, b) = (Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb)); + if !(b - a).len().is_finite() { + // The first point stays LASTPOINT, as after a measurement. + s.last_point = first; + return Err(bad("dist", "the distance is too large")); + } + let t = dist_text(s, a, b); for l in t.lines() { s.echo(l.to_string()); } + // As in AutoCAD, the second point doesn't become LASTPOINT: the first stays. + s.distance = (b - a).len(); + s.last_point = first; Ok(Step::Done) } (Some(a), Input::Keyword(k)) if k == "Multiple points" => { @@ -909,7 +932,11 @@ impl DistM { .as_deref() .or(self.multi.as_deref()) .map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) }); + if !d.is_finite() { + return Err(bad("dist", "the distance is too large")); + } s.echo(format!("Distance = {}", fmt_d(s, d))); + s.distance = d; Ok(Step::Done) } } @@ -1123,10 +1150,14 @@ impl AreaM { return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue }; } s.echo(area_text(s, a, per)); + // AREA: this area, or in Add and Subtract mode the running total; PERIMETER: this area's. + s.perimeter = per; if self.mode == AreaMode::Single { + s.area = a; return Step::Done; } self.total = total; + s.area = total; s.echo(format!("Total area = {}", fmt_d(s, self.total))); Step::Continue } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f84b35d7..d1fa2b03d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -92,6 +92,12 @@ pub struct Session { pub last_command: Option, /// LASTPOINT. pub last_point: Vec2, + /// AREA and PERIMETER: the last area AREA or MEASUREGEOM measured (in their Add and Subtract + /// modes the running total) and that area's perimeter. Not saved, as in AutoCAD. + pub area: f64, + pub perimeter: f64, + /// DISTANCE: the last distance DIST or MEASUREGEOM measured (with Multiple points, the total). + pub distance: f64, /// The last segment given at a point prompt (its base point and the point): polar angles /// relative to the last segment (POLARMODE bit 1) are measured from it. pub last_segment: Option<(Vec2, Vec2)>, @@ -173,6 +179,9 @@ impl Session { log: Vec::new(), last_command: None, last_point: Vec2::ZERO, + area: 0.0, + perimeter: 0.0, + distance: 0.0, last_segment: None, last_polar: None, point_mods: Vec::new(), diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 3fe47c052..fcecbd2cf 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -67,6 +67,9 @@ const SESSION_VARS: &[&str] = &[ "ZOOMFACTOR", "MEASUREINIT", "LASTPOINT", + "AREA", + "PERIMETER", + "DISTANCE", "FONTALT", "FONTFALLBACK", "SAVETIME", @@ -145,6 +148,9 @@ pub fn get(s: &Session, name: &str) -> Option { "ZOOMFACTOR" => json!(st.zoomfactor), "MEASUREINIT" => json!(i32::from(st.measureinit)), "LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]), + "AREA" => json!(s.area), + "PERIMETER" => json!(s.perimeter), + "DISTANCE" => json!(s.distance), // Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG. "ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()), "CONSTRAINTINFER" => json!(i32::from(s.doc().ok()?.parametric.settings.infer)), @@ -249,6 +255,9 @@ const READ_ONLY: &[&str] = &[ "BLOCKEDITOR", "CTAB", "LASTPOINT", + "AREA", + "PERIMETER", + "DISTANCE", "VIEWCTR", "VIEWSIZE", "EXTMIN", @@ -464,11 +473,6 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { pub fn list(s: &Session) -> Value { let mut m = serde_json::Map::new(); - for n in SESSION_VARS { - if let Some(v) = get(s, n) { - m.insert((*n).to_string(), v); - } - } if let Ok(d) = s.doc() { for (k, v) in &d.header.vars { if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT" | "CECOLOR") { @@ -478,6 +482,13 @@ pub fn list(s: &Session) -> Value { } } } + // The session's values last: a drawing's header can carry the same names (ORTHOMODE, + // LWDISPLAY, an AREA), and `get` reads the session's. + for n in SESSION_VARS { + if let Some(v) = get(s, n) { + m.insert((*n).to_string(), v); + } + } table::list(s, &mut m); Value::Object(m) } diff --git a/crates/engine/tests/it/inquiry_variables.rs b/crates/engine/tests/it/inquiry_variables.rs new file mode 100644 index 000000000..f42749458 --- /dev/null +++ b/crates/engine/tests/it/inquiry_variables.rs @@ -0,0 +1,271 @@ +//! AREA, PERIMETER and DISTANCE as AutoCAD 2024 leaves them after AREA, DIST and MEASUREGEOM +//! (measured with accoreconsole, running these scripts on these drawings): AREA is the area just +//! measured, or the running total in Add and Subtract mode, PERIMETER the perimeter of the last +//! area; DISTANCE the distance measured, or the total of DIST's Multiple points. After DIST's two +//! points LASTPOINT is still the first; after Multiple points it is the last. + +use cadcraft_engine::doc::{Common, EntityKind, Handle}; +use cadcraft_engine::geom::{Spline, Vec2}; +use cadcraft_engine::{Input, Session, sysvars}; +use serde_json::{Value, json}; + +fn var(s: &Session, name: &str) -> f64 { + sysvars::get(s, name).and_then(|v| v.as_f64()).unwrap_or_else(|| panic!("{name} not a number")) +} + +fn lastpoint(s: &Session) -> (f64, f64) { + let v = sysvars::get(s, "LASTPOINT").unwrap(); + (v[0].as_f64().unwrap(), v[1].as_f64().unwrap()) +} + +fn close(got: f64, want: f64) -> bool { + (got - want).abs() <= 1e-9 * (1.0 + want.abs()) +} + +fn handle(v: &Value) -> Handle { + Handle::parse_hex(v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap()).unwrap() +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); + assert!(s.running.is_none(), "{expr}: {}", s.prompt_text()); +} + +/// The closed polyline with an arc and the circle the Add-mode scripts pick. +fn p_and_c(s: &mut Session) -> (Handle, Handle) { + let p = handle(&s.execute("pline", &json!({ "vertices": [[0, 0], { "p": [40, 0], "bulge": 0.5 }, [40, 30], [0, 30]], "closed": true })).unwrap()); + let c = handle(&s.execute("circle", &json!({ "center": [150, 15], "radius": 10 })).unwrap()); + (p, c) +} + +#[test] +fn area_of_points_sets_area_and_perimeter() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\")"); + assert!(close(var(&s, "AREA"), 1200.0) && close(var(&s, "PERIMETER"), 140.0)); +} + +#[test] +fn add_mode_totals_the_area_and_keeps_the_last_perimeter() { + // Points, then the circle as an object. + let mut s = Session::new(); + let (_, c) = p_and_c(&mut s); + lisp( + &mut s, + &format!( + "(command \"_.AREA\" \"_A\" '(0.0 0.0) '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\" \"_O\" (list (handent \"{}\") '(160.0 15.0)) \"\" \"\")", + c.hex() + ), + ); + assert!(close(var(&s, "AREA"), 1514.159265358979), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 62.83185307179586), "{}", var(&s, "PERIMETER")); + // Both as objects. + let mut s = Session::new(); + let (p, c) = p_and_c(&mut s); + lisp( + &mut s, + &format!( + "(command \"_.AREA\" \"_A\" \"_O\" (list (handent \"{}\") '(0.0 15.0)) (list (handent \"{}\") '(160.0 15.0)) \"\" \"\")", + p.hex(), + c.hex() + ), + ); + assert!(close(var(&s, "AREA"), 1671.411490437671), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 62.83185307179586), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn subtract_mode_takes_the_area_off_the_total() { + // At large coordinates: the polyline added, the circle subtracted. + let mut s = Session::new(); + let p = handle( + &s.execute( + "pline", + &json!({ "vertices": [[-35000, 81000], { "p": [-33000, 81000], "bulge": 0.5 }, [-33000, 82500], [-35000, 82500]], "closed": true }), + ) + .unwrap(), + ); + let c = handle(&s.execute("circle", &json!({ "center": [-27000, 81750], "radius": 500 })).unwrap()); + lisp( + &mut s, + &format!( + "(command \"_.AREA\" \"_A\" \"_O\" (list (handent \"{}\") '(-35000.0 81750.0)) \"\" \"_S\" \"_O\" (list (handent \"{}\") '(-27000.0 81750.0)) \"\" \"\")", + p.hex(), + c.hex() + ), + ); + assert!(close(var(&s, "AREA"), 2607732.399299283), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 3141.592653589793), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn area_of_an_open_spline_closes_it_by_its_chord() { + let control = [(0.8, 0.25), (0.82, 0.24), (0.84, 0.25), (0.84, 0.28), (0.82, 0.29), (0.8, 0.28), (0.8, 0.25)]; + let spline = Spline { + degree: 3, + knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 4.0, 4.0, 4.0], + control: control.iter().map(|&(x, y)| Vec2::new(x, y)).collect(), + weights: Vec::new(), + fit: Vec::new(), + closed: false, + fit_opts: Default::default(), + }; + let mut s = Session::new(); + let space = s.space(); + let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap(); + lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.8 0.265)))", h.hex())); + assert!(close(var(&s, "AREA"), 0.001308333333333334), "{}", var(&s, "AREA")); + // The spline's length, 0.13046952991870 by an independent integration. AutoCAD reported + // 0.1304733129441191, 2.9e-5 longer than that: its own approximation of the length. + assert!(close(var(&s, "PERIMETER"), 0.1304695299187), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn area_of_a_spline_counts_its_narrow_knot_spans() { + // A degree-1 spline is its control polygon, here the triangle (0,0) (1,0) (0,1): area 0.5, + // perimeter 2 + sqrt(2), though two of its three knot spans are a billionth of the domain. + let control = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (0.0, 0.0)]; + let spline = Spline { + degree: 1, + knots: vec![0.0, 0.0, 1e-9, 2e-9, 1.0, 1.0], + control: control.iter().map(|&(x, y)| Vec2::new(x, y)).collect(), + ..Spline::default() + }; + let mut s = Session::new(); + let space = s.space(); + let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap(); + lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.5 0.0)))", h.hex())); + assert!(close(var(&s, "AREA"), 0.5), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 2.0 + 2f64.sqrt()), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn area_of_a_spline_with_more_spans_than_samples_counts_each() { + // The square (0,0) (100,0) (100,100) (0,100) as a degree-1 spline: 5000 spans along the bottom, + // then two a millionth wide up the right side and across the top. Area 10000, perimeter (the + // open spline's length) 300. + let mut control: Vec = (0..=5000).map(|i| Vec2::new(i as f64 / 50.0, 0.0)).collect(); + control.extend([Vec2::new(100.0, 100.0), Vec2::new(0.0, 100.0)]); + let mut knots: Vec = (0..=5000).map(f64::from).collect(); + knots.insert(0, 0.0); + knots.extend([5000.0 + 1e-6, 5000.0 + 2e-6, 5000.0 + 2e-6]); + let spline = Spline { degree: 1, knots, control, ..Spline::default() }; + let mut s = Session::new(); + let space = s.space(); + let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap(); + lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(50.0 0.0)))", h.hex())); + assert!(close(var(&s, "AREA"), 1e4), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 300.0), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn a_spline_too_long_to_measure_is_refused_and_not_kept() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(10.0 0.0) '(10.0 10.0) \"\")"); + // Finite ends 2e308 apart: its chord closes no area, but its length is past f64's range. That is + // too large to report, as a polyline's is, not a perimeter of 0. + let spline = Spline::from_line(Vec2::new(-1e308, 0.0), Vec2::new(1e308, 0.0), 1); + let space = s.space(); + let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap(); + let err = s.execute("area", &json!({ "handle": h.hex() })).unwrap_err(); + assert!(err.to_string().contains("the perimeter is too large"), "{err}"); + lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.0 0.0)))", h.hex())); + assert!(s.log.iter().any(|l| l.contains("The perimeter is too large.")), "{:?}", s.log); + assert!(close(var(&s, "AREA"), 50.0), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 20.0 + 200f64.sqrt()), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn dist_sets_the_distance_and_leaves_lastpoint_at_the_first_point() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.5 0.25) '(0.53 0.29))"); + assert!(close(var(&s, "DISTANCE"), 0.05), "{}", var(&s, "DISTANCE")); + let (x, y) = lastpoint(&s); + assert!(close(x, 0.5) && close(y, 0.25), "{x}, {y}"); +} + +#[test] +fn dist_with_multiple_points_sets_the_total_and_lastpoint_at_the_last() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\")"); + assert!(close(var(&s, "DISTANCE"), 110.0), "{}", var(&s, "DISTANCE")); + let (x, y) = lastpoint(&s); + assert!(close(x, 0.0) && close(y, 30.0), "{x}, {y}"); +} + +#[test] +fn measuregeom_sets_them_as_dist_and_area_do() { + let mut s = Session::new(); + // (AutoCAD's MEASUREGEOM asks for the next option after each measurement until eXit; here it + // ends after one.) + lisp(&mut s, "(command \"_.MEASUREGEOM\" \"_D\" '(0.0 0.0) '(30.0 40.0))"); + assert!(close(var(&s, "DISTANCE"), 50.0), "{}", var(&s, "DISTANCE")); + lisp(&mut s, "(command \"_.MEASUREGEOM\" \"_AR\" '(0.5 0.25) '(0.54 0.25) '(0.54 0.28) '(0.5 0.28) \"\")"); + assert!(close(var(&s, "AREA"), 0.0012) && close(var(&s, "PERIMETER"), 0.14), "{} {}", var(&s, "AREA"), var(&s, "PERIMETER")); +} + +#[test] +fn the_json_forms_set_them_too_and_they_are_read_only() { + let mut s = Session::new(); + s.execute("area", &json!({ "points": [[0, 0], [40, 0], [40, 30], [0, 30]] })).unwrap(); + s.execute("dist", &json!({ "p1": [0, 0], "p2": [30, 40] })).unwrap(); + assert_eq!((var(&s, "AREA"), var(&s, "PERIMETER"), var(&s, "DISTANCE")), (1200.0, 140.0, 50.0)); + for name in ["AREA", "PERIMETER", "DISTANCE"] { + assert!(s.execute("setvar", &json!({ "name": name, "value": 1 })).is_err(), "{name}"); + } +} + +#[test] +fn lastpoint_after_multiple_points_is_the_last_point_entered() { + // A length typed with Length extends the path, but LASTPOINT stays at the last point picked; + // after Undo it stays at the point undone (AutoCAD 2024 leaves LASTPOINT at 10,0 and 10,10). + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(10.0 0.0) \"_L\" 5.0 \"\")"); + assert!(close(var(&s, "DISTANCE"), 15.0), "{}", var(&s, "DISTANCE")); + let (x, y) = lastpoint(&s); + assert!(close(x, 10.0) && close(y, 0.0), "{x}, {y}"); + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(10.0 0.0) '(10.0 10.0) \"_U\" \"_T\")"); + assert!(close(var(&s, "DISTANCE"), 10.0), "{}", var(&s, "DISTANCE")); + let (x, y) = lastpoint(&s); + assert!(close(x, 10.0) && close(y, 10.0), "{x}, {y}"); +} + +#[test] +fn a_distance_too_large_to_measure_is_refused_and_not_kept() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.5 0.25) '(0.53 0.29))"); + assert!(close(var(&s, "DISTANCE"), 0.05)); + // Finite points whose distance overflows: the JSON form, two picked points, Multiple points. + assert!(s.execute("dist", &json!({ "p1": [-1e308, 0], "p2": [1e308, 0] })).is_err()); + s.cmdline("dist").unwrap(); + s.input(Input::Point(Vec2::new(-1e308, 0.0))).unwrap(); + s.input(Input::Point(Vec2::new(1e308, 0.0))).unwrap(); + assert!(s.log.last().is_some_and(|l| l.contains("the distance is too large")), "{:?}", s.log.last()); + // LASTPOINT stays at the first point, as after a measurement. + assert_eq!(lastpoint(&s), (-1e308, 0.0)); + s.cancel(); + s.cmdline("dist").unwrap(); + s.input(Input::Point(Vec2::new(-1e308, 0.0))).unwrap(); + s.cmdline("m").unwrap(); + s.input(Input::Point(Vec2::new(1e308, 0.0))).unwrap(); + s.input(Input::Enter).unwrap(); + assert!(s.log.last().is_some_and(|l| l.contains("the distance is too large")), "{:?}", s.log.last()); + s.cancel(); + assert!(close(var(&s, "DISTANCE"), 0.05), "{}", var(&s, "DISTANCE")); +} + +#[test] +fn the_variable_list_shows_the_sessions_values() { + // A drawing's header can carry these names; GETVAR reads the session's, and so does the list. + let mut s = Session::new(); + s.doc_mut().unwrap().header.set_f64("AREA", 99.0); + // Every DXF header carries ORTHOMODE; the session's ortho is off. + s.doc_mut().unwrap().header.set_i64("ORTHOMODE", 1); + lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(10.0 0.0) '(10.0 10.0) \"\")"); + let list = s.execute("sysvars", &json!({})).unwrap(); + assert!(close(var(&s, "AREA"), 50.0)); + assert_eq!(list["AREA"].as_f64(), Some(var(&s, "AREA")), "{}", list["AREA"]); + assert_eq!(list["ORTHOMODE"], sysvars::get(&s, "ORTHOMODE").unwrap()); + assert_eq!(list["ORTHOMODE"], 0); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index d3c8d030d..38fb4d005 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -60,6 +60,7 @@ mod hatch_numeric_validation; mod hatch_options; mod helix; mod inquiry_keywords; +mod inquiry_variables; mod inquiry_z; mod insert_attribute_prompts; mod insert_prompts; diff --git a/crates/geom/src/nurbs.rs b/crates/geom/src/nurbs.rs index 104ba8d3c..e07852340 100644 --- a/crates/geom/src/nurbs.rs +++ b/crates/geom/src/nurbs.rs @@ -131,6 +131,12 @@ impl Spline { /// [`Spline::is_valid`] already; integration calls this once per sample, and re-running the /// O(knots) validity scan each time would make measuring a valid spline quadratic (CWE-407). fn derivative_unchecked(&self, t: f64) -> Vec2 { + let d = self.velocity(t); + if d.is_finite() { d } else { Vec2::ZERO } + } + + /// [`Spline::derivative_unchecked`] before a value past f64's range is read as zero. + fn velocity(&self, t: f64) -> Vec2 { let p = self.degree; let Some(n) = self.control.len().checked_sub(1) else { return Vec2::ZERO }; let (lo, hi) = self.domain(); @@ -158,8 +164,7 @@ impl Spline { if w.abs() < 1e-300 { return Vec2::ZERO; } - let d = (da - a / w * dw) / w; - if d.is_finite() { d } else { Vec2::ZERO } + (da - a / w * dw) / w } /// Unit tangent at `t` in the direction of increasing parameter; a central difference stands @@ -477,22 +482,18 @@ impl Spline { out } - /// Arc length of one integration panel. The spline is validated once by the public callers - /// ([`Spline::length_between`]/[`Spline::length`]) before any panel is measured, so the speed - /// closure uses the unchecked derivative to avoid re-scanning the knots per sample. - fn panel_length(&self, a: f64, b: f64) -> f64 { - let speed = |t: f64| self.derivative_unchecked(t).len(); + /// Arc length of one integration panel, at the speed of `derivative`. The spline is validated + /// once by the callers ([`Spline::integrated_length`]/[`Spline::param_at_length`]) before any + /// panel is measured, so the derivative is an unchecked one, to avoid re-scanning the knots per + /// sample. + fn panel_length(&self, a: f64, b: f64, derivative: fn(&Spline, f64) -> Vec2) -> f64 { + let speed = |t: f64| derivative(self, t).len(); adaptive_gauss(a, b, gauss(a, b, speed), &speed, MAX_GAUSS_DEPTH) } /// Arc length between parameters `a` and `b` (adaptive Gauss–Legendre over each knot span). pub fn length_between(&self, a: f64, b: f64) -> f64 { - if !(self.is_valid() && a.is_finite() && b.is_finite()) { - return 0.0; - } - let (lo, hi) = self.domain(); - let (a, b) = (a.min(b).clamp(lo, hi), a.max(b).clamp(lo, hi)); - let l: f64 = self.length_intervals(a, b).into_iter().map(|(x, y)| self.panel_length(x, y)).sum(); + let l = self.integrated_length(a, b); if l.is_finite() { l } else { 0.0 } } @@ -502,6 +503,25 @@ impl Spline { self.length_between(lo, hi) } + /// Arc length of the whole spline, or `None` when it is past f64's range, where + /// [`Spline::length`] reads 0. An invalid spline's is 0. + pub fn checked_length(&self) -> Option { + let (lo, hi) = self.domain(); + let l = self.integrated_length(lo, hi); + l.is_finite().then_some(l) + } + + /// Arc length between `a` and `b`, infinite or NaN when it overflows (the length, or the speed + /// along the way); 0 for an invalid spline or a non-finite parameter. + fn integrated_length(&self, a: f64, b: f64) -> f64 { + if !(self.is_valid() && a.is_finite() && b.is_finite()) { + return 0.0; + } + let (lo, hi) = self.domain(); + let (a, b) = (a.min(b).clamp(lo, hi), a.max(b).clamp(lo, hi)); + self.length_intervals(a, b).into_iter().map(|(x, y)| self.panel_length(x, y, Spline::velocity)).sum() + } + /// The parameter `len` along the curve from parameter `from` (forward for positive `len`, /// backward for negative), clamped to the domain. pub fn param_at_length(&self, from: f64, len: f64) -> f64 { @@ -521,7 +541,7 @@ impl Spline { } let mut acc = 0.0; for (x0, x1) in ivs { - let l = self.panel_length(x0, x1); + let l = self.panel_length(x0, x1, Spline::derivative_unchecked); if acc + l >= target && l > 0.0 { // Bisect inside this interval, measuring from its near end. let rest = target - acc; @@ -529,7 +549,7 @@ impl Spline { let (mut s0, mut s1) = (near, far); for _ in 0..60 { let m = (s0 + s1) / 2.0; - let lm = self.panel_length(near.min(m), near.max(m)); + let lm = self.panel_length(near.min(m), near.max(m), Spline::derivative_unchecked); if lm < rest { s0 = m; } else { @@ -735,6 +755,24 @@ mod tests { assert_eq!(q.param_at_length(lo, 1e9), hi); } + #[test] + fn checked_length_tells_an_overflow_from_a_zero_length() { + let l = Spline::from_line(Vec2::ZERO, Vec2::new(3.0, 4.0), 3); + assert_eq!(l.checked_length(), Some(l.length())); + let dot = Spline::from_line(Vec2::new(1.0, 1.0), Vec2::new(1.0, 1.0), 1); + assert_eq!((dot.length(), dot.checked_length()), (0.0, Some(0.0))); + // Finite ends 2e308 apart: over a knot span of 4 the speed is in f64's range and the length + // past it; over a span of 1 the speed is past it too. `length` reads both as 0. + let ends = vec![Vec2::new(-1e308, 0.0), Vec2::new(1e308, 0.0)]; + for span in [4.0, 1.0] { + let huge = Spline { degree: 1, knots: vec![0.0, 0.0, span, span], control: ends.clone(), ..Spline::default() }; + assert_eq!((huge.length(), huge.checked_length()), (0.0, None), "{span}"); + } + let mut invalid = l.clone(); + invalid.knots.reverse(); + assert_eq!(invalid.checked_length(), Some(0.0)); + } + #[test] fn spline_arc_length_on_a_tight_bend() { // One Bezier span that turns sharply: four fixed Gauss panels come out 0.3% short, and diff --git a/crates/geom/src/spline.rs b/crates/geom/src/spline.rs index d954e66b1..1d5922d37 100644 --- a/crates/geom/src/spline.rs +++ b/crates/geom/src/spline.rs @@ -205,10 +205,69 @@ impl Spline { out } + /// Points along the curve for measuring it (AREA). Each knot span has a segment, so a narrow + /// span, which one grid over the whole domain can step over entirely, is measured too. The + /// count [`Spline::tessellate`] takes at `tol` is then shared out among the spans that bend + /// ([`Spline::span_bend`]), by the cube root of their bend: a span's chord error falls with + /// the square of its segments, so that share keeps the total error least. A straight span + /// keeps its one segment, which measures it exactly. The points number one per span plus + /// that count, past `MAX_SAMPLES` when the spans are that many, as [`Spline::length`] + /// integrates every span. An invalid spline gives its control points, as `tessellate` does. + pub fn tessellate_spans(&self, tol: f64) -> Vec { + if !self.is_valid() { + return self.control.clone(); + } + let (lo, hi) = self.domain(); + let n = self.sample_count(tol); + let spans: Vec<(f64, f64, f64)> = self.indexed_knot_spans().map(|(j, a, b)| (a, b, self.span_bend(j).cbrt())).collect(); + if spans.is_empty() { + return self.samples(n).collect(); + } + let total: f64 = spans.iter().map(|s| s.2).sum(); + let shared = total.is_finite() && total > 0.0; + let mut out = Vec::with_capacity(spans.len() + n + 1); + let (mut acc, mut used) = (0.0, 0); + for (a, b, share) in spans { + // Rounded on the running total, so the extra samples add up to `n` however thinly the + // shares are spread; the fraction first, at most 1, so a huge bend can't overflow. + acc += share; + let upto = if shared { (n as f64 * (acc / total).min(1.0)) as usize } else { 0 }; + let seg = 1 + upto.saturating_sub(used); + used = used.max(upto); + out.extend((0..seg).map(|i| self.eval_valid(a + (b - a) * i as f64 / seg as f64, lo, hi))); + } + out.push(self.eval_valid(hi, lo, hi)); + out + } + + /// How far knot span `j` (as [`Spline::indexed_knot_spans`] numbers it) can stray from its + /// chord, as an area: twice the largest triangle its first control point, the one farthest + /// from it and another make. That is between a quarter of the area of their convex hull and + /// twice it. With positive weights the span and its chord lie in that hull, so the area + /// between them is no more; it is zero when the points are collinear, and then the span is + /// straight. A weight that isn't positive lets the span leave the hull, so the square of the + /// points' polygon length counts instead. + fn span_bend(&self, j: usize) -> f64 { + let Some(pts) = self.control.get(j..=j + self.degree) else { return 0.0 }; + let Some(&a) = pts.first() else { return 0.0 }; + if !self.weights.get(j..=j + self.degree).is_none_or(|w| w.iter().all(|w| *w > 0.0)) { + let len: f64 = pts.windows(2).filter_map(|w| w.first().zip(w.get(1)).map(|(p, q)| p.dist(*q))).sum(); + return len * len; + } + let far = pts.iter().copied().max_by(|p, q| a.dist(*p).total_cmp(&a.dist(*q))).unwrap_or(a); + pts.iter().map(|p| (*p - a).cross(far - a).abs()).fold(0.0, f64::max) + } + /// The non-empty knot spans the curve is defined over, in parameter order. fn knot_spans(&self) -> impl Iterator + '_ { + self.indexed_knot_spans().map(|(_, a, b)| (a, b)) + } + + /// The non-empty knot spans with the index of the first of the `degree + 1` control points + /// that shape each. + fn indexed_knot_spans(&self) -> impl Iterator + '_ { let knots = self.knots.get(self.degree..=self.control.len()).unwrap_or(&[]); - knots.windows(2).filter_map(|w| w.first().zip(w.get(1)).map(|(a, b)| (*a, *b)).filter(|(a, b)| b > a)) + knots.windows(2).enumerate().filter_map(|(j, w)| w.first().zip(w.get(1)).map(|(a, b)| (j, *a, *b)).filter(|(_, a, b)| b > a)) } } diff --git a/crates/geom/src/spline/tests.rs b/crates/geom/src/spline/tests.rs index d953cfabd..820490d89 100644 --- a/crates/geom/src/spline/tests.rs +++ b/crates/geom/src/spline/tests.rs @@ -202,6 +202,83 @@ fn bounds_keep_their_cap_over_huge_knot_spans() { } } +#[test] +fn tessellate_spans_measures_narrow_knot_spans() { + // A degree-1 spline is its control polygon: the triangle (0,0) (1,0) (0,1), though two of its + // three spans are a billionth of the domain. One grid over the domain steps over them and + // sees no area; spread over the spans, the corners are there. + let control = vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::ZERO]; + let s = Spline { degree: 1, knots: vec![0.0, 0.0, 1e-9, 2e-9, 1.0, 1.0], control, ..Spline::default() }; + assert!(s.is_valid()); + assert!(crate::shoelace(&s.tessellate(1e-3)).abs() < 1e-6); + let pts = s.tessellate_spans(1e-3); + assert!(pts.iter().any(|p| p.near(Vec2::new(1.0, 0.0), 1e-12)), "{pts:?}"); + assert!((crate::shoelace(&pts).abs() - 0.5).abs() < 1e-12); + // An ordinary spline measures as `tessellate` does, within the sampling's sag. + let w = wiggly(false); + let (a, b) = (crate::shoelace(&w.tessellate(1e-4)), crate::shoelace(&w.tessellate_spans(1e-4))); + assert!((a - b).abs() <= 1e-4 * a.abs().max(1.0), "{a} {b}"); +} + +/// The square (0,0) (100,0) (100,100) (0,100) as a degree-1 spline: `edge` spans of width 1 +/// along the bottom, then two spans a millionth wide up the right side and across the top. +fn square_with_narrow_spans(edge: usize) -> Spline { + let mut control: Vec = (0..=edge).map(|i| Vec2::new(100.0 * i as f64 / edge as f64, 0.0)).collect(); + control.extend([Vec2::new(100.0, 100.0), Vec2::new(0.0, 100.0)]); + let end = edge as f64; + let mut knots: Vec = (0..=edge).map(|i| i as f64).collect(); + knots.insert(0, 0.0); + knots.extend([end + 1e-6, end + 2e-6, end + 2e-6]); + Spline { degree: 1, knots, control, ..Spline::default() } +} + +#[test] +fn tessellate_spans_covers_every_span_however_many() { + // A degree-1 spline's spans are straight: one segment each, its control points, however many + // there are. The narrow spans are measured, not stepped over by a grid. + for edge in [3000, MAX_SAMPLES + 500] { + let s = square_with_narrow_spans(edge); + assert!(s.is_valid()); + let pts = s.tessellate_spans(1e-9); + assert_eq!(pts.len(), s.control.len()); + assert!(pts.iter().zip(&s.control).all(|(p, c)| p.near(*c, 1e-9))); + assert!((crate::shoelace(&pts).abs() - 1e4).abs() < 1e-6, "{}", crate::shoelace(&pts)); + } + let s = square_with_narrow_spans(MAX_SAMPLES + 500); + assert!(crate::shoelace(&s.samples(MAX_SAMPLES).collect::>()).abs() < 0.6e4); +} + +#[test] +fn tessellate_spans_samples_where_the_curve_bends() { + // A quadratic spline along the x-axis but for one control point 1000 above it: three spans + // bend, and the area under them is 1750/3 (a fine grid gives 583.3333334). Their chords alone + // would give 750. Below the sample cap and past it, the straight spans keep one segment each + // (their samples are the only ones on the axis but the curve's end) and the bent spans take + // all the rest. + for edge in [40, MAX_SAMPLES + 500] { + let mut control: Vec = (0..=edge).map(|i| Vec2::new(i as f64, 0.0)).collect(); + control.extend([Vec2::new(edge as f64 + 0.5, 1000.0), Vec2::new(edge as f64 + 1.0, 0.0), Vec2::new(edge as f64 + 2.0, 0.0)]); + let s = Spline::from_control(control, 2); + let pts = s.tessellate_spans(1e-9); + let area = crate::shoelace(&pts).abs(); + assert!((area - 1750.0 / 3.0).abs() < 1e-6 * area, "{edge}: {area}"); + assert_eq!(pts.iter().filter(|p| p.y == 0.0).count(), edge + 1, "{edge}"); + assert_eq!(pts.len(), s.control.len() - 2 + s.sample_count(1e-9) + 1, "{edge}"); + } +} + +#[test] +fn span_bend_bounds_the_area_off_the_chord() { + let mut s = Spline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 2.0)], 2); + // Collinear control points: a straight span. + assert_eq!(s.span_bend(0), 0.0); + // (1,0) (3,0) (4,2): twice their triangle's area. + assert!((s.span_bend(1) - 4.0).abs() < 1e-12, "{}", s.span_bend(1)); + // A weight that isn't positive: the polygon's length squared. + s.weights = vec![1.0, -0.5, 1.0, 1.0]; + assert!((s.span_bend(0) - 9.0).abs() < 1e-12, "{}", s.span_bend(0)); +} + fn scaled(s: &Spline, k: f64) -> Spline { Spline { control: s.control.iter().map(|c| *c * k).collect(), fit: s.fit.iter().map(|c| *c * k).collect(), ..s.clone() } } From 6660d887855f872314151a61c6898a321b3e8de7 Mon Sep 17 00:00:00 2001 From: RicSchonfelder Date: Mon, 2 Nov 2026 18:36:48 -0300 Subject: [PATCH 120/151] i18n(pt-BR): translate the rows the sibling catalogs cover (#2581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n(pt-BR): translate the rows the sibling catalogs cover The Brazilian Portuguese catalog covered the reference catalog and every marked UI literal, but 191 strings that the German, French, Swedish and Ukrainian catalogs already translate still fell back to English: the Hatch and Gradient dialog, the Block Editor and dynamic-block grips, the plot and drawing units dialogs, multileader editing, selection preview settings and the unit names. Rows follow the catalog's vocabulary (dimension = cota, hatch = hachura, grip = pinça, layout = apresentação, multileader = multilinha, xref = referência externa), keep the prompt keywords, SPDX tokens and technology names in English, and are appended after the existing entries. * docs: leave localization parity updates to maintainers --- crates/ui-egui/src/i18n/pt_br.tsv | 197 ++++++++++++++++++++++++++++++ docs/localization.md | 4 +- 2 files changed, 199 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index a000d9b09..3d8f03449 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -1253,3 +1253,200 @@ Check for Updates… Verificar atualizações… CADCraft is up to date. O CADCraft está atualizado. Could not check for updates. Não foi possível verificar atualizações. +# Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31). + Add: Pick points Adicionar: indicar pontos + Add: Select objects Adicionar: selecionar objetos + Add… Adicionar… + Alias Alias + Alias file: {file} Arquivo de aliases: {file} + Align Multileaders Alinhar multilinhas + Analyze Usage Analisar uso + Angstroms Angströms + Apply to Layout Aplicar à apresentação + Architectural Arquitetônico + Arrow size Tamanho da seta + Associate Associar + Associative Associativo + Astronomical Units Unidades astronômicas + AutoLISP (LSP) AutoLISP (LSP) + Backup copy Cópia de backup + Block to create or edit Bloco a criar ou editar + Bottom left Inferior esquerdo + Bottom right Inferior direito + Boundaries Contornos + Boundaries picked: {n} Contornos selecionados: {n} + Break-line Symbol Símbolo de linha de quebra + built-in integrado + Center the plot Centralizar a plotagem + Centered Centralizado + Centimeters Centímetros + Change Properties Alterar propriedades + Choose the command the alias starts. Escolha o comando que o alias inicia. + Clear Recent Files Limpar arquivos recentes + Click the ruler to add a tab stop, right-click to remove one Clique na régua para adicionar uma parada de tabulação; clique com o botão direito para remover + Click to set new origin Clique para definir a nova origem + Close Block Editor Fechar o editor de blocos + Collect Coletar + Collect Multileaders Coletar multilinhas + Color 1 Cor 1 + Color 2 Cor 2 + Command Comando + contains authoring elements. Open in Block Editor? contém elementos de autoria. Abrir no editor de blocos? + Convert Text to Mtext Converter texto em Mtexto + Create backup copy with each save Criar cópia de backup a cada salvamento + Create separate hatches Criar hachuras separadas + Crossing selection color: Cor da seleção por cruzamento: + Curved Curvo + Custom Personalizado + Customize Personalizar + Decameters Decâmetros + Decimal Decimal + Decimal Degrees Graus decimais + Decimeters Decímetros + Default to boundary extents Padrão para a extensão do contorno + Deg/Min/Sec Gra/Min/Seg + Dim scale overall Escala geral de cotas + Discard Descartar + Don't Save Não salvar + Drag Dynamic Grip Arrastar pinça dinâmica + Drawing Units Unidades do desenho + Dynamic Block Data Dados de bloco dinâmico + Dynamic Block Grips Pinças de bloco dinâmico + Dynamic Block Reference Referência de bloco dinâmico + Edit Block Definition Editar definição de bloco + Edit Command Aliases (PGP)... Editar aliases de comandos (PGP)... + Edit Multileader Editar multilinha + Edit… Editar… + Engineering Engenharia + Enter block names to extract Insira os nomes dos blocos a extrair + Enter geometric characteristic Insira a característica geométrica + Enter name of LISP file to load Insira o nome do arquivo LISP a carregar + Evaluate LISP Avaliar LISP + Exclude: Excluir: + Explode Attributes to Text Explodir atributos em texto + Express Tools Ferramentas Express + Feet Pés + First corner Primeiro canto + Fit to paper Ajustar ao papel + Font Fonte + Fractional Fracionário + Gigameters Gigâmetros + Grads Grados + Grips Pinças + Groups Grupos + Hatch and Gradient Hachura e gradiente + Hatch origin Origem da hachura + Hatch Pick Seleção de hachura + Hatches Hachuras + Hectometers Hectômetros + Hemispherical Hemisférico + Ignore Ignorar + Imperial (inches) Imperial (polegadas) + Implied Loops Laços implícitos + Inches Polegadas + Indicate selection area Indicar área de seleção + Insertion scale Escala de inserção + Interface scale Escala da interface + Inverted Invertido + Island Ilha + Island detection Detecção de ilhas + Italic Itálico + Justify Justificar + Kilometers Quilômetros + Landscape Paisagem + Layouts Apresentações + Light Years Anos-luz + Limits Limites + Load Application... Carregar aplicativo... + Meters Metros + Metric (millimetres) Métrico (milímetros) + Microinches Micropolegadas + Microns Mícrons + Miles Milhas + Millimeters Milímetros + Mils Mils + More than {n} aliases. Mais de {n} aliases. + Move/Copy/Rotate Mover/Copiar/Girar + Multiline text Texto multilinha + Nanometers Nanômetros + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Novos desenhos e o primeiro desenho na inicialização usam estas unidades, a menos que um modelo padrão esteja definido (QNEWTEMPLATE). + New block Novo bloco + No alias file loaded. REINIT replaces these aliases with a file's. Nenhum arquivo de aliases carregado. O REINIT substitui estes aliases pelos de um arquivo. + Object grouping Agrupamento de objetos + Object selection limit for display of grips Limite de seleção de objetos para exibir pinças + Objects on locked layers Objetos em camadas bloqueadas + Open in Block Editor Abrir no editor de blocos + Open Recent Abrir recentes + Opposite corner Canto oposto + Orientation Orientação + Origin: {x}, {y} Origem: {x}, {y} + Outer Externo + Overline Sobrelinha + overrides built-in substitui o integrado + Paper size Tamanho do papel + Parsecs Parsecs + Plot area Área de plotagem + Plot object lineweights Plotar espessuras de linha dos objetos + Plot offset Deslocamento da plotagem + Plot transparency Plotar transparência + Portrait Retrato + Precision Precisão + Preview Visualizar + Printer/plotter Impressora/plotter + Radians Radianos + Sample output Amostra de saída + Save alias Salvar alias + Save Block Salvar bloco + Save Block As Salvar bloco como + Save changes to block "{name}"? Salvar alterações no bloco "{name}"? + Save changes to {name}? Salvar alterações em {name}? + Save Layout as Template Salvar apresentação como modelo + Scientific Científico + Select boundaries to remove Selecione contornos para remover + Selection Area Effect Efeito da área de seleção + Selection area opacity: Opacidade da área de seleção: + Selection effect color Cor do efeito de seleção + Selection modes Modos de seleção + Selection preview Visualização da seleção + Selection Preview Filter Filtro de visualização da seleção + Set Dynamic Property Definir propriedade dinâmica + Shape Forma + Sheet Books to PDF... Livros de folhas em PDF... + Show grips Mostrar pinças + Specified origin Origem especificada + Spherical Esférico + Surveyor's Units Unidades de topografia + Tables Tabelas + Text color Cor do texto + This drawing Este desenho + Top left Superior esquerdo + Top right Superior direito + Type an alias name. Digite um nome de alias. + Underline Sublinhado + Unitless Adimensional + Units of new drawings Unidades de novos desenhos + Units to scale inserted content Unidades para dimensionar conteúdo inserido + Unsaved changes Alterações não salvas + US Survey Feet Pés topográficos norte-americanos + US Survey Inch Polegada topográfica norte-americana + US Survey Mile Milha topográfica norte-americana + US Survey Yard Jarda topográfica norte-americana + Use current origin Usar origem atual + Visual Effect Settings Configurações de efeitos visuais + Visual Effect Settings... Configurações de efeitos visuais... + When a command is active Quando um comando está ativo + When no command is active Quando nenhum comando está ativo + Window < Janela < + Window selection color: Cor da seleção por janela: + X offset (mm) Deslocamento X (mm) + Xrefs Referências externas + Y offset (mm) Deslocamento Y (mm) + Yards Jardas + Your changes will be lost if you don't save them. Suas alterações serão perdidas se você não salvá-las. + {alias} is already defined; select it and use Edit. {alias} já está definido; selecione-o e use Editar. + {command} is an unknown command. {command} é um comando desconhecido. +# Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31). +menu Dimension Cota +menu Draw Desenho +menu Modify Modificar +menu View Exibir diff --git a/docs/localization.md b/docs/localization.md index 909fad9f8..998fbe0a0 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -46,8 +46,8 @@ The translations are original, AI-assisted Korean CAD terminology (for example, 치수, 모깎기, 모따기, 간격띄우기 and 객체 스냅). Independent native proofreading and platform-specific layout/IME validation remain follow-up work. -Brazilian Portuguese has 1,231 catalog rows and also covers the reference catalog and every marked UI -literal: commands, menus, tool groups and tooltips, object snaps, drafting prompts, dialogs and +Brazilian Portuguese has 1,447 catalog rows and also covers the reference catalog, every marked UI +literal and the strings the sibling catalogs translate: commands, menus, tool groups and tooltips, object snaps, drafting prompts, dialogs and Preferences; its gaps are now zero. Its plural rule is `one|other` (0 and 1 are `one`), as CLDR defines for `pt`, and the catalog's accented letters (`Á Â Í Ó Ú à á â ã ç é ê í ó ô õ ú`) render in the bundled fonts. The original catalog was contributed by @WladmirJunior; the remaining rows were completed From 7bc76f94ca080275eb866567e45f3110a6525c6c Mon Sep 17 00:00:00 2001 From: Tyler Chiriboga Date: Mon, 2 Nov 2026 15:36:53 -0600 Subject: [PATCH 121/151] OVERKILL: objects within the tolerance are duplicates wherever their numbers fall (#2653) * OVERKILL: objects within the tolerance are duplicates wherever their numbers fall OVERKILL compared objects by rounding every number to a multiple of the tolerance. Two numbers closer than the tolerance on either side of a rounding step round apart, so the objects were kept: at 0.01, circles at x = 80.001 and 80.003 were merged but circles at 50.004 and 50.006 were not. Arcs on one circle were bucketed the same way. Duplicates are now found by shape (the JSON without its floating-point numbers; counts and flags must match exactly) and a weighted sum of the numbers on a grid of the tolerance, looking in the neighbouring cells too, then compared pairwise to within the tolerance. Arcs and circles look in the neighbouring cells of centre and radius and are grouped with a union-find. Lines were already grouped by sorting and are unchanged. Co-Authored-By: Claude Opus 5.5 * OVERKILL: within the tolerance doesn't chain Arcs and circles were grouped with a union-find over pairwise matches, so A ~ B and B ~ C put A and C in one group even when A and C are farther apart than the tolerance: a circle C was deleted for matching only the arc between it and the kept circle A, and arcs were combined across the chain. Each object now joins the first group whose every member is within the tolerance of it, or starts its own. In the duplicates pass, a followed object displaced an earlier keeper that later objects had already matched, leaving them deleted up to twice the tolerance from what stays. Followed objects are now looked at first, so every deletion is within the tolerance of an object that stays. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/overkill.rs | 142 +++++++++++++++++++++-------- crates/engine/tests/it/overkill.rs | 86 +++++++++++++++++ 2 files changed, 190 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/overkill.rs b/crates/engine/src/cmd/overkill.rs index abfdff2eb..632001461 100644 --- a/crates/engine/src/cmd/overkill.rs +++ b/crates/engine/src/cmd/overkill.rs @@ -146,20 +146,49 @@ impl Opts { } } -/// Round every number of a JSON value to a multiple of `q`. -fn quantize(v: &mut Value, q: f64) { +/// Take the measurements (floating-point numbers) out of a JSON value, in order, leaving a marker +/// in their place: two objects are equal when what is left matches, counts and flags included, +/// and each pair of measurements is within the tolerance. +fn split_numbers(v: &mut Value, nums: &mut Vec) { match v { Value::Number(n) => { - if let Some(x) = n.as_f64() { - *v = json!((x / q).round()); + if n.is_f64() + && let Some(x) = n.as_f64() + { + nums.push(x); + *v = Value::String(NUMBER.into()); } } - Value::Array(a) => a.iter_mut().for_each(|x| quantize(x, q)), - Value::Object(o) => o.values_mut().for_each(|x| quantize(x, q)), + Value::Array(a) => a.iter_mut().for_each(|x| split_numbers(x, nums)), + Value::Object(o) => o.values_mut().for_each(|x| split_numbers(x, nums)), _ => {} } } +/// What [`split_numbers`] leaves where a number was. +const NUMBER: &str = "\u{0}#"; + +/// Whether two lists of numbers match pairwise to within `tol`. +fn within(a: &[f64], b: &[f64], tol: f64) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +/// The numbers summed with weights in [1, 2) that no simple pattern shares (golden-ratio steps), +/// so points on a diagonal of a grid don't all get the same sum; and the sum of the weights. +fn weighted_sum(nums: &[f64]) -> (f64, f64) { + nums.iter().enumerate().fold((0.0, 0.0), |(sum, total), (j, x)| { + let w = 1.0 + (j as f64 * 0.618_033_988_749_895).fract(); + (sum + w * x, total + w) + }) +} + +/// The cell of `x` on a grid of `step`. Values within one step of each other land in the same or +/// neighbouring cells; values too large for the grid share the outermost ones. +fn cell_of(x: f64, step: f64) -> i64 { + let k = (x / step).floor(); + if k.is_finite() { k.clamp(-9e15, 9e15) as i64 } else { i64::MIN } +} + #[derive(Default)] struct Report { duplicates: usize, @@ -234,33 +263,37 @@ fn overkill(s: &mut Session, hs: Option>, o: &Opts) -> Result = HashMap::new(); - for (i, c) in cands.iter().enumerate() { + // Keepers so far: (properties, shape) → cell of the sum → their numbers. + type Keepers = HashMap>>; + let mut seen: HashMap<(u32, String), Keepers> = HashMap::new(); + // Followed objects first, so a keeper is never displaced: everything deleted is within the + // tolerance of an object that stays. + let (followed, rest): (Vec, Vec) = (0..cands.len()).partition(|i| cands.get(*i).is_some_and(|c| c.protected)); + for i in followed.into_iter().chain(rest) { + let Some(c) = cands.get(i) else { continue }; let Ok(mut v) = serde_json::to_value(&c.e.kind) else { continue }; - if o.tol > 0.0 { - quantize(&mut v, o.q()); + let mut nums = Vec::new(); + split_numbers(&mut v, &mut nums); + let (sum, weights) = weighted_sum(&nums); + let cell = cell_of(sum, o.q() * weights.max(1.0)); + let cells = seen.entry((c.prop, v.to_string())).or_default(); + let found = [cell.saturating_sub(1), cell, cell.saturating_add(1)] + .into_iter() + .any(|k| cells.get(&k).is_some_and(|keepers| keepers.iter().any(|n| within(n, &nums, o.tol)))); + if !found || c.protected { + // A followed object stays even when it matches another. + cells.entry(cell).or_default().push(nums); + continue; } - let key = (c.prop, v.to_string()); - match seen.get(&key).copied() { - None => { - seen.insert(key, i); - } - Some(k) => { - let keeper_protected = cands.get(k).is_some_and(|x| x.protected); - let victim = match (keeper_protected, c.protected) { - (_, false) => i, - (false, true) => { - seen.insert(key, i); - k - } - (true, true) => continue, - }; - if let Some(g) = gone.get_mut(victim) { - *g = true; - rep.duplicates += 1; - } - } + if let Some(g) = gone.get_mut(i) { + *g = true; + rep.duplicates += 1; } } } @@ -421,8 +454,8 @@ fn collinear(members: &[&LineRec], cands: &[Cand], o: &Opts, gone: &mut [bool], fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap, rep: &mut Report) { let q = o.q(); let g = q.max(1e-9); - let cell = |x: f64| (x / g).round() as i64; - let mut buckets: HashMap<(u32, i64, i64, i64, i64), Vec> = HashMap::new(); + // (index, properties, centre and radius); cells of `g` hold them. + let mut recs: Vec<(usize, u32, [f64; 4])> = Vec::new(); for (i, c) in cands.iter().enumerate() { if gone.get(i).copied().unwrap_or(true) || c.protected { continue; @@ -442,12 +475,45 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap = buckets.keys().copied().collect(); - keys.sort_unstable(); - for key in keys { - let Some(group) = buckets.get(&key) else { continue }; + // Those within `g` of each other in centre and radius are on one circle. Each object joins the + // first group whose every member is within `g` of it, or starts one: matches don't chain, so + // nothing is deleted for matching only an object that itself matches the keeper. Members are + // looked for in the neighbouring cells too, so values on either side of a cell boundary meet. + let mut grid: HashMap<(u32, [i64; 4]), Vec> = HashMap::new(); + let mut group_of: Vec = Vec::with_capacity(recs.len()); + // Each group's records, in draw order; groups in the order of their first object. + let mut members: Vec> = Vec::new(); + for (ri, (_, prop, v)) in recs.iter().enumerate() { + let cells = v.map(|x| cell_of(x, g)); + let mut near_groups: Vec = Vec::new(); + for n in 0..81_i64 { + let near = [n % 3 - 1, n / 3 % 3 - 1, n / 9 % 3 - 1, n / 27 % 3 - 1]; + let mut key = cells; + for (k, d) in key.iter_mut().zip(near) { + *k = k.saturating_add(d); + } + near_groups.extend(grid.get(&(*prop, key)).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|rj| group_of.get(*rj))); + } + near_groups.sort_unstable(); + near_groups.dedup(); + let fits = |gi: &usize| members.get(*gi).is_some_and(|m| m.iter().all(|rk| recs.get(*rk).is_some_and(|(_, _, w)| within(v, w, g)))); + let gi = match near_groups.into_iter().find(fits) { + Some(gi) => gi, + None => { + members.push(Vec::new()); + members.len() - 1 + } + }; + if let Some(m) = members.get_mut(gi) { + m.push(ri); + } + group_of.push(gi); + grid.entry((*prop, cells)).or_default().push(ri); + } + let groups = members.iter().map(|m| m.iter().filter_map(|ri| recs.get(*ri).map(|r| r.0)).collect::>()); + for group in groups { if group.len() < 2 { continue; } @@ -465,7 +531,7 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap>(); + let mut s = Session::new(); + circle(&mut s, 50.0); + arc_at(&mut s, 50.008, 0.0, 90.0); + circle(&mut s, 50.016); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!((r["duplicates"].as_u64(), r["overlapping"].as_u64()), (Some(0), Some(1)), "{r}"); + assert_eq!(kinds(&s), ["Circle", "Circle"]); + let mut s = Session::new(); + arc_at(&mut s, 50.008, 0.0, 90.0); + circle(&mut s, 50.0); + circle(&mut s, 50.016); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["deleted"], 1, "{r}"); + assert_eq!(kinds(&s), ["Circle", "Circle"]); + // Arcs alone: the third is 0.016 from the first, so it is not combined with it. + let mut s = Session::new(); + arc_at(&mut s, 0.0, 0.0, 90.0); + arc_at(&mut s, 0.008, 45.0, 180.0); + arc_at(&mut s, 0.016, 100.0, 200.0); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["overlapping"], 1, "{r}"); + assert_eq!(kinds(&s), ["Arc", "Arc"]); + // A followed line displaces the first one, which a third line matched: the third is 0.016 from + // the followed line, so it stays. + let mut s = Session::new(); + let first = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let third = line(&mut s, [0.0, 0.008], [10.0, 0.008]); + let followed = line(&mut s, [0.0, -0.008], [10.0, -0.008]); + s.execute("dimlinear", &json!({ "object": followed.hex(), "at": [5, 5] })).unwrap(); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["deleted"], 1, "{r}"); + let d = s.doc().unwrap(); + assert!(d.entity(first).is_none() && d.entity(third).is_some() && d.entity(followed).is_some()); +} From 7690d36bb0aa43c73d266c56af828e985f2afb10 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 14:31:06 -0700 Subject: [PATCH 122/151] fmt --- crates/engine/tests/save_reports_not_saved.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index 984d0d635..d38e13e0b 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -155,9 +155,16 @@ fn saving_sat_text_solids_as_dxf_2013_reports_them() { #[test] fn a_dxf_2013_loss_is_known_before_saving() { use cadcraft_engine::cmd::file::loss_on_save; - set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0 + set_io(IoHooks { + read: |_, _| Err("no reader in this test".into()), + write: |_, _| { + Ok(b"0 EOF -".to_vec()), plot: None }); +" + .to_vec()) + }, + plot: None, + }); let sat = |d: &mut Drawing| { let tags = vec![RawTag { code: 8, value: "0".into() }, RawTag { code: 1, value: "400 1 1 0".into() }]; d.add(&Space::Model, Common::default(), EntityKind::Unknown(Unknown { dxf_type: "REGION".into(), tags })).unwrap(); From 86f3fdc9e99454fe096f94b33e8aa864d62fc158 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 14:34:47 -0700 Subject: [PATCH 123/151] Test: the DWG note's wording, and one-line fake bytes --- crates/engine/tests/save_reports_not_saved.rs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index d38e13e0b..80192bf53 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -60,14 +60,14 @@ fn export_and_wblock_say_what_a_dwg_leaves_out() { let mut s = Session::empty(); s.open_drawing(d, "foreign.dxf", None); let file = |ext: &str| std::env::temp_dir().join(format!("cadcraft-leaves-out-{}.{ext}", std::process::id())).to_string_lossy().into_owned(); - let msg = "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): 1 ACME_WIDGET"; + let said = |l: &String| l.starts_with("Not saved in the DWG file") && l.ends_with(": 1 ACME_WIDGET"); let (dwg, dxf) = (file("dwg"), file("dxf")); for (cmd, p) in [("export", json!({ "path": dwg })), ("wblock", json!({ "path": dwg, "handles": [h] }))] { s.log.clear(); let r = s.execute(cmd, &p).unwrap(); assert_eq!(r["notSaved"], json!({ "ACME_WIDGET": 1 }), "{cmd}"); - assert!(s.log.iter().any(|l| l == msg), "{cmd}: {:?}", s.log); + assert!(s.log.iter().any(said), "{cmd}: {:?}", s.log); } // -WBLOCK takes the objects out of the drawing, so last. let r = s.execute("-wblock", &json!({ "path": dwg, "handles": [h] })).unwrap(); @@ -79,7 +79,7 @@ fn export_and_wblock_say_what_a_dwg_leaves_out() { for (cmd, p) in [("export", json!({ "path": dxf })), ("wblock", json!({ "path": dxf, "handles": [h] }))] { s.log.clear(); assert!(s.execute(cmd, &p).unwrap().get("notSaved").is_none(), "{cmd}"); - assert!(!s.log.iter().any(|l| l == msg), "{cmd}: {:?}", s.log); + assert!(!s.log.iter().any(said), "{cmd}: {:?}", s.log); } let _ = std::fs::remove_file(&dxf); } @@ -155,16 +155,7 @@ fn saving_sat_text_solids_as_dxf_2013_reports_them() { #[test] fn a_dxf_2013_loss_is_known_before_saving() { use cadcraft_engine::cmd::file::loss_on_save; - set_io(IoHooks { - read: |_, _| Err("no reader in this test".into()), - write: |_, _| { - Ok(b"0 -EOF -" - .to_vec()) - }, - plot: None, - }); + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); let sat = |d: &mut Drawing| { let tags = vec![RawTag { code: 8, value: "0".into() }, RawTag { code: 1, value: "400 1 1 0".into() }]; d.add(&Space::Model, Common::default(), EntityKind::Unknown(Unknown { dxf_type: "REGION".into(), tags })).unwrap(); From 0bc025965ae80d09ecb6ec1ebd487ce901585b64 Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Mon, 2 Nov 2026 22:01:21 +0000 Subject: [PATCH 124/151] fix(dwg): preserve MTEXT caret-adjacent whitespace (#2250) (#2314) * fix(dwg): preserve literal spaces after MTEXT carets (#2250) * test(dwg): fix MTEXT fixture ownership and boxed entity --------- Co-authored-by: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com> --- crates/dwg/src/lib.rs | 95 +++++++++++++++++- crates/io/tests/it/main.rs | 1 + crates/io/tests/it/mtext_caret_spaces.rs | 118 +++++++++++++++++++++++ 3 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 crates/io/tests/it/mtext_caret_spaces.rs diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 037aa26b5..ffde17ee8 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -110,8 +110,11 @@ mod native { notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out")); } let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false }; - let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out))) - .map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?; + let written = std::panic::catch_unwind(AssertUnwindSafe(|| { + acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)?; + mtext_contents(&doc, &mut out) + })) + .map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?; if out.exceeded { return Err(format!( "DWG: converting this {} file produced more than {} of drawing data (the limit); \ @@ -124,6 +127,94 @@ mod native { Ok((add_header_vars(out.buf, &doc.header), notes)) } + /// acadrust 0.6.6's ASCII writer treats a caret followed by a literal space as already + /// escaped, and turns line feeds into paragraph codes. Serialize affected MTEXT contents + /// from the native values instead, using its lossless string writer. All other groups, + /// including embedded-object data, remain byte-for-byte as the DXF writer emitted them. + fn mtext_contents(doc: &acadrust::CadDocument, out: &mut CappedWriter) -> acadrust::Result<()> { + use acadrust::io::dxf::{DxfStreamWriter, DxfTextWriter}; + let contents: std::collections::HashMap<_, _> = doc + .entities() + .filter_map(|e| match e { + acadrust::EntityType::MText(t) if t.value.contains(['^', '\t', '\n']) => Some((t.common.handle.value(), t.value.as_str())), + _ => None, + }) + .collect(); + if contents.is_empty() { + return Ok(()); + } + let dxf = std::mem::take(&mut out.buf); + let mut lines = dxf.split_inclusive(|b| *b == b'\n'); + let (mut mtext, mut text, mut written) = (false, None, false); + while let Some(code) = lines.next() { + let value = lines.next().ok_or_else(|| std::io::Error::other("incomplete DWG bridge DXF pair"))?; + match code.trim_ascii() { + b"0" => { + mtext = value.trim_ascii() == b"MTEXT"; + text = None; + written = false; + } + b"5" if mtext => { + text = std::str::from_utf8(value.trim_ascii()).ok().and_then(|s| u64::from_str_radix(s, 16).ok()).and_then(|h| contents.get(&h)); + } + b"101" => mtext = false, + b"1" | b"3" if mtext && text.is_some() => { + if let Some(&text) = text + && !written + { + let mut writer = DxfTextWriter::new(&mut *out); + if doc.version < acadrust::DxfVersion::AC1021 + && let Some(encoding) = acadrust::io::dxf::code_page::encoding_from_code_page(&doc.header.code_page) + { + writer.set_encoding(encoding); + } + // Each character can expand to two caret-escape characters. Keep + // those pairs together and each group within 250 characters. + let mut rest = text; + while let Some((end, _)) = rest.char_indices().nth(125) { + let (chunk, tail) = rest.split_at(end); + writer.write_xrecord_string(3, chunk)?; + rest = tail; + } + writer.write_xrecord_string(1, rest)?; + written = true; + } + continue; + } + _ => {} + } + out.write_all(code)?; + out.write_all(value)?; + } + Ok(()) + } + + #[cfg(test)] + #[test] + fn mtext_contents_keeps_other_groups_and_the_output_limit() -> acadrust::Result<()> { + for version in [acadrust::DxfVersion::AC1015, acadrust::DxfVersion::AC1032] { + let mut doc = acadrust::CadDocument::new(); + doc.version = version; + doc.header.code_page = "ANSI_1252".into(); + let mut t = acadrust::MText::with_value("é ^ C", acadrust::Vector3::ZERO); + t.common.handle = acadrust::Handle::new(0x10); + doc.add_entity(acadrust::EntityType::MText(Box::new(t)))?; + // Only the MTEXT's own 1/3 groups change. The embedded object's groups and the + // following TEXT record must survive byte-for-byte, including their spaces. + let prefix = b" 0\r\nMTEXT\r\n 5\r\n10\r\n 8\r\n0\r\n"; + let suffix = b"101\r\nEmbedded Object\r\n 1\r\n^ embedded\r\n 3\r\n^ tail\r\n 0\r\nTEXT\r\n 1\r\n^ plain\r\n"; + let raw = [prefix.as_slice(), b" 3\r\nold\r\n 1\r\ntext\r\n", suffix.as_slice()].concat(); + let mut out = CappedWriter { buf: raw.clone(), limit: 4096, exceeded: false }; + mtext_contents(&doc, &mut out)?; + let value: &[u8] = if version == acadrust::DxfVersion::AC1015 { b"\xe9 ^ C" } else { "é ^ C".as_bytes() }; + assert_eq!(out.buf, [prefix.as_slice(), b" 1\r\n", value, b"\r\n", suffix.as_slice()].concat()); + let mut limited = CappedWriter { buf: raw, limit: 1, exceeded: false }; + assert!(mtext_contents(&doc, &mut limited).is_err()); + assert!(limited.exceeded); + } + Ok(()) + } + /// Most record types named in the skipped-records note. const NOTE_TYPES: usize = 6; diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 7b49eb6bf..289e87806 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -42,6 +42,7 @@ mod mleader_roundtrip; mod mline_roundtrip; #[cfg(not(target_arch = "wasm32"))] mod model_layout_plot_type; +mod mtext_caret_spaces; mod mtext_columns_roundtrip; mod mtext_embedded_object; mod mtext_line_spacing; diff --git a/crates/io/tests/it/mtext_caret_spaces.rs b/crates/io/tests/it/mtext_caret_spaces.rs new file mode 100644 index 000000000..8bb1ed071 --- /dev/null +++ b/crates/io/tests/it/mtext_caret_spaces.rs @@ -0,0 +1,118 @@ +//! Literal MTEXT carets and their adjacent whitespace survive DXF and DWG saves (#2250). + +use cadcraft_doc::{Common, Drawing, EntityKind, Handle, MText, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +type TestResult = Result<(), Box>; + +fn drawing() -> Result<(Drawing, Vec<(Handle, String)>), Box> { + let mut d = Drawing::new_metric(); + let mut expected = Vec::new(); + let mut cases: Vec = [ + "ordinary text", + "^ C", + "A ^ C B", + "^ C", + "A ^ C B", + "^ ", + "^^ C ^ ^ D^", + "^I ^J ^ C", + "\t^ C\nD\tE", + "^\t ^\n C", + r"A\P^ C\P^I ^J", + r"\S+0.1^-0.1; ^ C", + ] + .into_iter() + .map(str::to_string) + .collect(); + // Exercise multiple group-3 chunks and a caret at the output chunk boundary. + cases.push(format!("{}^ C", "A".repeat(124))); + cases.push(format!("{}^ C B", "A".repeat(260))); + for contents in cases { + let kind = EntityKind::MText(MText { + insert: Vec3::new(1.0, 2.0, 0.0), + height: 0.5, + width: 12.5, + attach: 1, + rotation: 0.0, + style: "Standard".into(), + contents: contents.clone(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }); + let handle = d.add(&Space::Model, Common::default(), kind)?; + expected.push((handle, contents)); + } + Ok((d, expected)) +} + +fn assert_contents(d: &Drawing, expected: &[(Handle, String)], context: &str) -> TestResult { + assert_eq!(d.model.len(), expected.len(), "{context}: entity count"); + for (handle, contents) in expected { + let e = d.entity(*handle).ok_or("MTEXT handle lost")?; + let EntityKind::MText(t) = &e.kind else { return Err("MTEXT entity type changed".into()) }; + assert_eq!(&t.contents, contents, "{context}: {handle:?}"); + } + Ok(()) +} + +#[test] +fn dxf_mtext_caret_spaces_round_trip() -> TestResult { + let (original, expected) = drawing()?; + for version in Version::ALL { + for binary in [false, true] { + let mut d = original.clone(); + for pass in 1..=2 { + let bytes = write_with(&d, "carets.dxf", &SaveOptions { version: Some(version), binary })?; + d = read(&bytes, "carets.dxf")?; + assert_contents(&d, &expected, &format!("{version:?}, binary={binary}, pass={pass}"))?; + } + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dwg_mtext_caret_spaces_round_trip() -> TestResult { + let (original, expected) = drawing()?; + for version in Version::ALL { + let mut d = original.clone(); + for pass in 1..=2 { + let bytes = write_with(&d, "carets.dwg", &SaveOptions { version: Some(version), binary: false })?; + assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code())); + // Inspect the stored DWG text before its DXF rendition: a compensating reader + // must not hide corruption on save. This uses the same codec, not an external oracle. + let native = acadrust::DwgReader::from_stream(std::io::Cursor::new(&bytes)).read()?; + for (handle, contents) in &expected { + let text = native.entities().find_map(|e| match e { + acadrust::EntityType::MText(t) if t.common.handle.value() == handle.0 => Some(&t.value), + _ => None, + }); + assert_eq!(text, Some(contents), "native DWG {version:?}, pass={pass}, {handle:?}"); + } + let dxf = cadcraft_dwg::dwg_to_dxf(&bytes)?; + let converted = read(&dxf, "bridge.dxf")?; + assert_contents(&converted, &expected, &format!("DWG bridge DXF {version:?}, pass={pass}"))?; + d = read(&bytes, "carets.dwg")?; + assert_contents(&d, &expected, &format!("DWG {version:?}, pass={pass}"))?; + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn bridge_caret_space_is_escaped_once_in_each_direction() -> TestResult { + let (d, _) = drawing()?; + let dxf = cadcraft_io::write_dxf(&d); + assert!(dxf.contains(" 1\r\n^ C\r\n"), "literal caret plus authored space needs two wire spaces"); + let native = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf.into_bytes()))?.read()?; + assert!(native.entities().any(|e| matches!(e, acadrust::EntityType::MText(t) if t.value == "^ C"))); + let dwg = write_with(&d, "carets.dwg", &SaveOptions { version: Some(Version::R2018), binary: false })?; + let returned = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg)?)?; + assert!(returned.contains(" 1\r\n^ C\r\n"), "the return bridge must encode the separator independently of authored whitespace"); + Ok(()) +} From 03101e8d77b49a4c847f182dd8c98c0cbaca0b25 Mon Sep 17 00:00:00 2001 From: Rahul Date: Tue, 3 Nov 2026 03:31:26 +0530 Subject: [PATCH 125/151] fix(io): a moved DIMENSION draws its block at its insertion point (group 12) (#2634) (#2636) A DIMENSION moved by an older AutoCAD keeps its *D block contents where they were and the move in DXF group 12, and is drawn translated by the insertion point. Group 12 was never read into Dimension, so the block rendered at its pre-move position and the extents reached there too. Read group 12 into a new Dimension::insertion_point, draw the block translated by it, move its bounds with it in the extents, test the block's text where it draws, and write group 12 back whenever the block written is the one read from the file. Regenerated geometry (edits drop the block link) sits at its definition points, so no offset is kept or written for it. Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- crates/doc/src/entity/dim.rs | 5 +++ crates/doc/src/extents.rs | 4 ++ crates/doc/src/tests.rs | 39 +++++++++++++++++ crates/engine/src/cmd/annotate/common.rs | 1 + crates/engine/src/sample.rs | 3 ++ crates/engine/src/select.rs | 14 ++++--- crates/engine/tests/compare.rs | 1 + crates/engine/tests/it/compare_export.rs | 4 ++ crates/io/src/dxf_read/entities.rs | 1 + crates/io/src/dxf_write/entity.rs | 6 +++ crates/io/src/tests.rs | 2 + crates/io/tests/dimassoc_angular_radial.rs | 1 + crates/io/tests/it/acad_interop.rs | 1 + .../io/tests/it/dim_block_resave_roundtrip.rs | 42 +++++++++++++++++++ crates/io/tests/it/dim_block_text.rs | 2 + crates/io/tests/it/dim_marks_roundtrip.rs | 1 + crates/io/tests/it/dimstyle_vars_roundtrip.rs | 1 + crates/io/tests/it/dimtfill_roundtrip.rs | 1 + crates/io/tests/it/roundtrip/cases/complex.rs | 1 + crates/io/tests/it/roundtrip_audit.rs | 1 + crates/io/tests/it/text_carets_roundtrip.rs | 1 + crates/render/src/dim/tests.rs | 1 + crates/render/src/draw.rs | 9 +++- crates/render/src/tests.rs | 1 + crates/render/tests/it/dim_angular_centre.rs | 1 + crates/render/tests/it/dim_post_tolerance.rs | 1 + crates/render/tests/it/dim_text_fill.rs | 1 + crates/render/tests/it/dim_text_format.rs | 1 + crates/render/tests/it/render_props.rs | 1 + 29 files changed, 142 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/entity/dim.rs b/crates/doc/src/entity/dim.rs index d10237b26..dc4635c7a 100644 --- a/crates/doc/src/entity/dim.rs +++ b/crates/doc/src/entity/dim.rs @@ -52,6 +52,11 @@ pub struct Dimension { /// Anonymous block with the rendered geometry (from files); regenerated when edited. #[serde(default)] pub block: Option, + /// Insertion point (DXF group 12): a dimension moved by an older AutoCAD keeps its offset + /// here with the `*D` block's contents left where they were, and is drawn translated by it. + /// Zero once the geometry is regenerated from the definition points. + #[serde(default)] + pub insertion_point: Vec3, /// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values, /// merged over the named style. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index 353a42b53..d87cdc9a2 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -212,6 +212,10 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap< inner } }; + // The block draws translated by the insertion point (group 12), so its bounds + // move with it (#2634); the cache above keeps the block's own bounds. + let off = dm.insertion_point.xy(); + let inner = if off == Vec2::ZERO { inner } else { Bounds2 { min: inner.min + off, max: inner.max + off } }; b = b.union(&inner); } b diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index f0f979f12..29d55e30f 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -55,6 +55,44 @@ fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() { assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0)); } +/// DXF group 12: a dimension moved by an older AutoCAD keeps its `*D` block where it was and +/// the move in the insertion point, and the block draws translated by it. Extents follow the +/// drawing, not the stale block contents (#2634). +#[test] +fn dimension_insertion_point_moves_its_block_in_extents() { + let moved = |ins: Vec3| { + let mut d = Drawing::new_metric(); + let mut blk = Block::new("*D1"); + blk.entities.push(on_layer("0", line((850.0, 920.0), (860.0, 920.0)), 2)); + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + let dm = Dimension { + kind: DimKind::Aligned, + defpt: Vec3::new(5.0, 1.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + insertion_point: ins, + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + d + }; + // Without the offset the block's far-away contents reach the extents. + assert!(moved(Vec3::ZERO).extents(&Space::Model).max.x > 800.0); + // With it, the block draws at the definition points and the extents stay at the drawing. + assert!(moved(Vec3::new(-850.0, -920.0, 0.0)).extents(&Space::Model).max.x < 20.0); +} + /// ZOOM Extents in AutoCAD 2024 (accoreconsole, EXTMIN/EXTMAX read back; AutoCAD pads them by a /// few hundredths): a layer turned off counts and a frozen one does not, for an entity, for an /// entity inside a block, and for a block reference itself. @@ -637,6 +675,7 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() { overrides: Default::default(), assoc: vec![], marks: Default::default(), + insertion_point: Vec3::ZERO, }), ) .unwrap(); diff --git a/crates/engine/src/cmd/annotate/common.rs b/crates/engine/src/cmd/annotate/common.rs index 398102797..6854b4fb4 100644 --- a/crates/engine/src/cmd/annotate/common.rs +++ b/crates/engine/src/cmd/annotate/common.rs @@ -22,6 +22,7 @@ pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }) } diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 85a222f64..4e615a4f2 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -32,6 +32,7 @@ fn dim(kind: DimKind, p13: Vec2, p14: Vec2, defpt: Vec2) -> EntityKind { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }) } @@ -182,6 +183,7 @@ pub fn bracket() -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }), ); add( @@ -203,6 +205,7 @@ pub fn bracket() -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }), ); diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 8047412fd..6721cdad8 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -117,11 +117,15 @@ fn in_text(d: &Drawing, e: &Entity, p: Vec2) -> bool { // A dimension with a block (as AutoCAD saves them) is drawn from that block: its text is // the text there, not the text the dimension's own geometry would place. EntityKind::Dimension(dm) => match dm.block.as_ref().and_then(|n| d.block(n)) { - Some(blk) => blk.entities.iter().any(|be| match &be.kind { - EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p), - EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p), - _ => false, - }), + // The block draws translated by the insertion point (group 12): test there (#2634). + Some(blk) => { + let p = p - dm.insertion_point.xy(); + blk.entities.iter().any(|be| match &be.kind { + EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p), + EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p), + _ => false, + }) + } None => { let g = cadcraft_render::dimension_in(d, dm); in_box(g.text.iter().flatten().chain(g.text_glyphs.iter().flatten().flatten()).copied(), g.text_angle, p) diff --git a/crates/engine/tests/compare.rs b/crates/engine/tests/compare.rs index 0e4486ccb..a346fbba2 100644 --- a/crates/engine/tests/compare.rs +++ b/crates/engine/tests/compare.rs @@ -208,6 +208,7 @@ fn derived_dimension_fields_are_not_differences() { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let mut s = Session::new(); s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim(30.0, 7.0))).unwrap(); diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index 1aee1b9db..740133c2b 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -314,6 +314,7 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition( overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let blocks = |dim_end: f64, first: u64| { let mut part = Block::new("PART"); @@ -400,6 +401,7 @@ fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_dra overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let mut part = Block::new("PART"); part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim))); @@ -514,6 +516,7 @@ fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let mut s = Session::new(); let mut drawn = Block::new("*D1"); @@ -833,6 +836,7 @@ fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; for k in 1..=MAX_BLOCK_DEPTH { let mut b = Block::new(&format!("B{k}")); diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index 2011c3d63..6ca0f30d1 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -197,6 +197,7 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef text_rotation: t.fd(53, 0.0).to_radians(), user_text_pos: t.i(70).unwrap_or(0) & 128 != 0, block: t.s(2).filter(|s| !s.is_empty()), + insertion_point: t.p(12), overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index 51b6b4388..ee9c18d33 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -399,6 +399,12 @@ fn write_dimension_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); let g = cadcraft_render::dimension_geometry(dm, &style, d.header.f64("DIMSCALE", 1.0)); w.p(11, if dm.user_text_pos { dm.text_mid } else { g.text_pos.to3(0.0) }); + // Group 12: a block read from a file keeps the dimension's moved-by offset (AutoCAD + // draws the block translated by it); regenerated geometry already sits where the + // definition points place it, so it writes no offset. + if dm.block.is_some() && dm.insertion_point != Vec3::ZERO { + w.p(12, dm.insertion_point); + } let ty: i64 = match dm.kind { DimKind::Linear { .. } => 0, DimKind::Aligned => 1, diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 4aa427bdd..5d08817c5 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -98,6 +98,7 @@ fn sample() -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }), ) .unwrap(); @@ -558,6 +559,7 @@ fn dim(kind: DimKind, p13: Vec3, p14: Vec3) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/io/tests/dimassoc_angular_radial.rs b/crates/io/tests/dimassoc_angular_radial.rs index f955ad7b5..1df635320 100644 --- a/crates/io/tests/dimassoc_angular_radial.rs +++ b/crates/io/tests/dimassoc_angular_radial.rs @@ -27,6 +27,7 @@ fn dim(kind: DimKind, defpt: Vec3, p13: Vec3, p14: Vec3, p15: Vec3, p16: Vec3, a overrides: Default::default(), assoc, marks: Default::default(), + insertion_point: Vec3::ZERO, }) } diff --git a/crates/io/tests/it/acad_interop.rs b/crates/io/tests/it/acad_interop.rs index 387f23334..bc9cc90e1 100644 --- a/crates/io/tests/it/acad_interop.rs +++ b/crates/io/tests/it/acad_interop.rs @@ -98,6 +98,7 @@ fn s25(kind: DimKind, p13: Vec3, p14: Vec3, defpt: Vec3) -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); d diff --git a/crates/io/tests/it/dim_block_resave_roundtrip.rs b/crates/io/tests/it/dim_block_resave_roundtrip.rs index f0705d6fd..195706157 100644 --- a/crates/io/tests/it/dim_block_resave_roundtrip.rs +++ b/crates/io/tests/it/dim_block_resave_roundtrip.rs @@ -27,6 +27,7 @@ fn linear(a: [f64; 2], b: [f64; 2], at: [f64; 2]) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } @@ -158,6 +159,47 @@ fn reopened_dimension_reuses_its_block_name_and_handles() { assert_eq!(again.handseed, back.handseed); } +#[test] +fn a_moved_dimension_keeps_its_group_12_offset() { + let mut d = Drawing::new_metric(); + // What an older AutoCAD left: the block contents where the dimension was measured, the + // move kept in group 12 (insertion point), as #2634's drawing has it. + let mut b = Block::new("*D3"); + let h = d.new_handle(); + b.entities.push(Entity::new(h, EntityKind::Line(Line { a: Vec3::new(150.0, 80.0, 0.0), b: Vec3::new(160.0, 80.0, 0.0) }))); + d.blocks.insert("*D3".into(), Arc::new(b)); + let mut dm = linear([0.0, 0.0], [10.0, 0.0], [5.0, 3.0]); + dm.block = Some("*D3".into()); + dm.insertion_point = Vec3::new(-150.0, -80.0, 0.0); + let h = d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + + // The block is written as read and the offset round-trips as group 12. + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + let Some(EntityKind::Dimension(dm)) = back.entity(h).map(|e| &e.kind) else { panic!("dimension lost") }; + assert_eq!(dm.insertion_point, Vec3::new(-150.0, -80.0, 0.0)); + assert_eq!(dm.block.as_deref(), Some("*D3")); + let p = pairs(&cadcraft_io::write_dxf(&d)); + let at = p.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap(); + let own = &p[at..p[at + 1..].iter().position(|(c, _)| *c == 0).map(|k| at + 1 + k).unwrap()]; + assert!(own.iter().any(|(c, v)| *c == 12 && v.parse::() == Ok(-150.0)), "no group 12 x"); + assert!(own.iter().any(|(c, v)| *c == 22 && v.parse::() == Ok(-80.0)), "no group 12 y"); + + // Edited (edits drop the block link): geometry regenerated from the definition points, + // so the save writes no offset. + let mut edited = back.clone(); + edited + .modify_entity(h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.block = None; + } + }) + .unwrap(); + let q = pairs(&cadcraft_io::write_dxf(&edited)); + let at = q.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap(); + let own = &q[at..q[at + 1..].iter().position(|(c, _)| *c == 0).map(|k| at + 1 + k).unwrap()]; + assert!(!own.iter().any(|(c, _)| *c == 12), "regenerated geometry keeps a stale offset"); +} + #[test] fn hostile_handle_seed_does_not_panic() { let mut d = Drawing::new_metric(); diff --git a/crates/io/tests/it/dim_block_text.rs b/crates/io/tests/it/dim_block_text.rs index 0c5fa0cab..244e9401c 100644 --- a/crates/io/tests/it/dim_block_text.rs +++ b/crates/io/tests/it/dim_block_text.rs @@ -34,6 +34,7 @@ fn height_written(style_scale: f64, overrides: serde_json::Value) -> f64 { overrides: overrides.as_object().cloned().unwrap_or_default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); block_mtext_height(&cadcraft_io::write_dxf(&d)) @@ -73,6 +74,7 @@ fn angular_drawing(separator_override: Option<&str>) -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; if let Some(separator) = separator_override { dm.overrides.insert("DIMDSEP".into(), serde_json::json!(separator)); diff --git a/crates/io/tests/it/dim_marks_roundtrip.rs b/crates/io/tests/it/dim_marks_roundtrip.rs index 40a85d89a..c442376de 100644 --- a/crates/io/tests/it/dim_marks_roundtrip.rs +++ b/crates/io/tests/it/dim_marks_roundtrip.rs @@ -24,6 +24,7 @@ fn dim(kind: DimKind, marks: DimMarks) -> EntityKind { overrides: Default::default(), assoc: Vec::new(), marks, + insertion_point: Vec3::ZERO, }) } diff --git a/crates/io/tests/it/dimstyle_vars_roundtrip.rs b/crates/io/tests/it/dimstyle_vars_roundtrip.rs index 061054bbc..b2beac3e2 100644 --- a/crates/io/tests/it/dimstyle_vars_roundtrip.rs +++ b/crates/io/tests/it/dimstyle_vars_roundtrip.rs @@ -60,6 +60,7 @@ fn dimension(overrides: serde_json::Value) -> EntityKind { overrides: overrides.as_object().cloned().unwrap_or_default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }) } diff --git a/crates/io/tests/it/dimtfill_roundtrip.rs b/crates/io/tests/it/dimtfill_roundtrip.rs index 5e1b4f1bf..48f7d9e32 100644 --- a/crates/io/tests/it/dimtfill_roundtrip.rs +++ b/crates/io/tests/it/dimtfill_roundtrip.rs @@ -174,6 +174,7 @@ fn linear() -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/io/tests/it/roundtrip/cases/complex.rs b/crates/io/tests/it/roundtrip/cases/complex.rs index ab13f4800..163effa49 100644 --- a/crates/io/tests/it/roundtrip/cases/complex.rs +++ b/crates/io/tests/it/roundtrip/cases/complex.rs @@ -118,6 +118,7 @@ pub(crate) fn dimensions() -> Vec { overrides: Default::default(), assoc: Vec::new(), marks: DimMarks::default(), + insertion_point: Vec3::ZERO, }), ); let mut block = Block::new("*D42"); diff --git a/crates/io/tests/it/roundtrip_audit.rs b/crates/io/tests/it/roundtrip_audit.rs index c2a326935..57f99dbe2 100644 --- a/crates/io/tests/it/roundtrip_audit.rs +++ b/crates/io/tests/it/roundtrip_audit.rs @@ -1034,6 +1034,7 @@ fn add_associated_dimension(d: &mut Drawing, a: cadcraft_doc::Handle, b: cadcraf overrides: Default::default(), assoc: vec![at("p13", a), at("p14", b)], marks: Default::default(), + insertion_point: Vec3::ZERO, }; d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).expect("add dimension"); } diff --git a/crates/io/tests/it/text_carets_roundtrip.rs b/crates/io/tests/it/text_carets_roundtrip.rs index 1c30dd1a3..d327c6694 100644 --- a/crates/io/tests/it/text_carets_roundtrip.rs +++ b/crates/io/tests/it/text_carets_roundtrip.rs @@ -38,6 +38,7 @@ fn dimension(label: &str) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/src/dim/tests.rs b/crates/render/src/dim/tests.rs index fe3b3c74e..15001c6b7 100644 --- a/crates/render/src/dim/tests.rs +++ b/crates/render/src/dim/tests.rs @@ -19,6 +19,7 @@ fn lin(p1: (f64, f64), p2: (f64, f64), on: (f64, f64), rot: f64) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 39b0ddfcc..e73659675 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -313,7 +313,10 @@ fn draw_dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dim if let Some(blk) = dm.block.as_ref().and_then(|n| ctx.d.block(n)) && ctx.depth < cadcraft_doc::MAX_BLOCK_DEPTH { - let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, Mat3::IDENTITY) }; + // DXF group 12: a moved dimension keeps its `*D` block where it was and the offset in + // the insertion point, so the block draws translated by it (#2634). + let off = dm.insertion_point.xy(); + let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, Mat3::translate(off)) }; // DIMTFILL: the block's MTEXT carries no background mask here, so a background // around each of the block's texts is drawn before the block's first text or first // line of the text's marks, except for a text that a SOLID of the block covers. @@ -333,6 +336,10 @@ fn draw_dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dim .take(MAX_FILL_TEXTS) .filter(|be| !covered(be)) .filter_map(|be| block_text_box(ctx.d, be, gap)) + .map(|mut corners| { + corners.iter_mut().for_each(|c| *c += off); + corners + }) .collect() }; let mut fill_pending = !boxes.is_empty(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index fe2dc6470..e4b314ff5 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -370,6 +370,7 @@ fn lin_dim() -> cadcraft_doc::Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/tests/it/dim_angular_centre.rs b/crates/render/tests/it/dim_angular_centre.rs index 9e9c42bdc..149638e0c 100644 --- a/crates/render/tests/it/dim_angular_centre.rs +++ b/crates/render/tests/it/dim_angular_centre.rs @@ -23,6 +23,7 @@ fn dim(kind: DimKind) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/tests/it/dim_post_tolerance.rs b/crates/render/tests/it/dim_post_tolerance.rs index 907271a7c..83177d6ec 100644 --- a/crates/render/tests/it/dim_post_tolerance.rs +++ b/crates/render/tests/it/dim_post_tolerance.rs @@ -22,6 +22,7 @@ fn text(vars: serde_json::Value) -> (String, String) { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let mut st = DimStyle::default(); let rejected = st.apply_fields(vars.as_object().unwrap()); diff --git a/crates/render/tests/it/dim_text_fill.rs b/crates/render/tests/it/dim_text_fill.rs index b90e1595d..46028a576 100644 --- a/crates/render/tests/it/dim_text_fill.rs +++ b/crates/render/tests/it/dim_text_fill.rs @@ -26,6 +26,7 @@ fn dimension() -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/tests/it/dim_text_format.rs b/crates/render/tests/it/dim_text_format.rs index e70deea08..779667e5b 100644 --- a/crates/render/tests/it/dim_text_format.rs +++ b/crates/render/tests/it/dim_text_format.rs @@ -23,6 +23,7 @@ fn linear(len: f64) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/tests/it/render_props.rs b/crates/render/tests/it/render_props.rs index f210dc4e1..a40b1988d 100644 --- a/crates/render/tests/it/render_props.rs +++ b/crates/render/tests/it/render_props.rs @@ -47,6 +47,7 @@ fn dimensions_take_the_viewport_layer_colour() { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; d.add(&Space::Model, on("A"), EntityKind::Dimension(dim)).unwrap(); d.add(&Space::Model, on("A"), EntityKind::Line(Line { a: Vec3::new(0.0, -1.0, 0.0), b: Vec3::new(10.0, -1.0, 0.0) })).unwrap(); From e4cc7ffc77d691589d134c3a3ad8481833af127a Mon Sep 17 00:00:00 2001 From: Coast Date: Mon, 2 Nov 2026 16:01:31 -0600 Subject: [PATCH 126/151] DWG: scale actions keep the byte AutoCAD reads after their connections (acadrust 0.6.6) (#1725) * DWG: scale actions keep the byte AutoCAD reads after their connections (acadrust 0.6.5) AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with one byte after its three connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD read past the object and refused every DWG CADCraft wrote with a dynamic block that scales ("Error Decrypting Data"), even an unchanged one. acadrust 0.6.5 keeps the byte (dwg_tail_byte). 0.6.5 also maps the dimension flags byte's bit 0 to text_user_positioned both ways, so the bridge no longer inverts it (it would now double-invert); it still sets bit 1, which acadrust keeps no DXF source for. Co-Authored-By: Claude Opus 5.5 * DWG: acadrust 0.6.6; multi-layout DWGs get unique paper space names 0.6.6 keeps the scale action byte and the dimension flag mapping this branch relies on, and writes only the active layout as *Paper_Space (others get unique *Paper_SpaceN names; hakanaktt/acadrust#143), so ACadSharp opens a DWG CADCraft saves with more than one layout. Co-Authored-By: Claude Opus 5.5 * io tests: a scale action survives CADCraft's own DWG save The cadcraft-dwg test builds its DXF with acadrust's writer; this one goes through cadcraft_io::write (the bridge DXF, then acadrust) with a scale action added to the synthetic Door fixture, and checks the DWG holds it with its connections and reads back as the same action. Co-Authored-By: Claude Opus 5.5 * ui: block description field ids from Ui::scope_id (egui 0.37 deprecates Ui::id) main's gate fails clippy -D warnings on the one Ui::id() call (#2113); Ui::scope_id is its new name. Co-Authored-By: Claude Opus 5.5 * DWG: the scale action byte doc names acadrust 0.6.6, the version in use Co-Authored-By: Claude Sonnet 5.5 * Docs: leave the parity docs to the maintainers (AGENTS.md: PRs don't edit them) Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/dwg/Cargo.toml | 2 +- crates/dwg/src/lib.rs | 49 ++++++++++++++++++++++++++++- crates/io/Cargo.toml | 2 +- crates/io/tests/it/dynblock_read.rs | 42 +++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml index 0be9fdf40..acb8d0aa7 100644 --- a/crates/dwg/Cargo.toml +++ b/crates/dwg/Cargo.toml @@ -13,4 +13,4 @@ workspace = true # 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800). [target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies] # MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE). -acadrust = "0.6.5" +acadrust = "0.6.6" diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index ffde17ee8..a5de9bffe 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -435,7 +435,7 @@ mod native { /// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification, /// common dimension data), which acadrust keeps no copy of from DXF: it is set here as /// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128, - /// acadrust 0.6.5 reads and writes itself. + /// acadrust 0.6.6 reads and writes itself. fn dimension_flags(doc: &mut acadrust::CadDocument) { for e in doc.entities_mut() { let acadrust::entities::EntityType::Dimension(d) = e else { continue }; @@ -1167,4 +1167,51 @@ mod tests { assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}"); assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]); } + + /// AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with a byte after its three + /// connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD + /// read past the object and refused every DWG CADCraft wrote with a dynamic block that scales + /// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also + /// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action + /// from DXF is written whole, with the byte AutoCAD writes (0). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() { + use acadrust::objects::*; + let scale_action = |doc: &acadrust::CadDocument| { + doc.objects.values().find_map(|o| match o { + ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()), + _ => None, + }) + }; + let mut doc = acadrust::CadDocument::new(); + let h = doc.allocate_handle(); + let conn = |code, name: &str| BlockConnection { code, name: name.into() }; + let mut action = DynamicBlockObject::new("BLOCKSCALEACTION", "AcDbBlockScaleAction"); + action.handle = h; + action.owner = doc.header.named_objects_dict_handle; + let mut a = BlockBasePointAction::default(); + a.action.action.element.name = "Scale".into(); + a.action.connections = vec![conn(2, "UpdatedBaseX"), conn(2, "UpdatedBaseY")]; + a.action.dependent = true; + a.connections = vec![conn(2, "Scale"), conn(2, "XScale"), conn(2, "YScale")]; + a.dwg_tail_byte = 7; + action.data = DynamicBlockData::ScaleAction(a.clone()); + doc.objects.insert(h, ObjectType::DynamicBlock(action)); + + let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap(); + let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + assert_eq!(scale_action(&back).map(|s| s.dwg_tail_byte), Some(7), "the byte isn't written"); + + let mut dxf = Vec::new(); + acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf).unwrap(); + for version in ["AC1018", "AC1032"] { + let dwg = dxf_to_dwg_as(&dxf, version).unwrap(); + let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + let read = scale_action(&back).unwrap_or_else(|| panic!("{version}: the scale action wasn't written")); + assert_eq!(read.connections, a.connections, "{version}"); + assert_eq!(read.action.connections, a.action.connections, "{version}"); + assert_eq!(read.dwg_tail_byte, 0, "{version}"); + } + } } diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 2e8b3cc80..49464e06c 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -25,7 +25,7 @@ quick-xml = { workspace = true } [dev-dependencies] # Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's). -acadrust = "0.6.5" +acadrust = "0.6.6" tiny-skia = { workspace = true } # Reads plotted PDFs back. craft-pdf = { workspace = true, features = ["read"] } diff --git a/crates/io/tests/it/dynblock_read.rs b/crates/io/tests/it/dynblock_read.rs index 251029410..189a2305b 100644 --- a/crates/io/tests/it/dynblock_read.rs +++ b/crates/io/tests/it/dynblock_read.rs @@ -86,6 +86,48 @@ fn a_saved_drawing_keeps_the_dynamic_block_as_read() { assert_eq!(by_node(dynamic_block(&back, "Door").unwrap()), by_node(before)); } +/// The Door fixture with a scale action on the Width parameter: graph node 6, object 68. +fn with_scale_action() -> String { + let node = "91 6\n93 32\n95 7\n360 68\n92 -1\n92 -1\n92 -1\n92 -1\n"; + let action = "0 BLOCKSCALEACTION\n5 68\n330 61\n100 AcDbEvalExpr\n90 7\n98 33\n99 232\n\ + 100 AcDbBlockElement\n300 Scale\n98 33\n99 232\n1071 0\n\ + 100 AcDbBlockAction\n70 0\n71 1\n330 30\n1010 0\n1020 0\n1030 0\n\ + 100 AcDbBlockActionWithBasePt\n92 2\n93 2\n301 UpdatedBaseX\n302 UpdatedBaseY\n\ + 1011 0\n1021 0\n1031 0\n280 1\n1012 0\n1022 0\n1032 0\n\ + 100 AcDbBlockScaleAction\n94 2\n95 2\n96 2\n303 Scale\n304 XScale\n305 YScale\n"; + fixture() + .replacen("96 6\n97 6\n", "96 7\n97 7\n", 1) + .replacen("0 BLOCKVISIBILITYPARAMETER\n", &format!("{node}0 BLOCKVISIBILITYPARAMETER\n"), 1) + .replacen("0 BLOCKLOOKUPPARAMETER\n", &format!("{action}0 BLOCKLOOKUPPARAMETER\n"), 1) +} + +/// A scale action goes through CADCraft's own DWG save (the bridge DXF, then acadrust) whole: +/// the DWG holds it with its connections, and it reads back as the same action. AutoCAD refused +/// such DWGs while acadrust left out the byte after its connections (cadcraft-dwg's +/// `scale_actions_keep_the_byte_autocad_reads_after_their_connections` covers the byte). +#[test] +fn a_scale_action_survives_a_dwg_save() { + use acadrust::objects::{DynamicBlockData, DynamicBlockObject, ObjectType}; + let d = read_dxf(dxf(&with_scale_action()).as_bytes()).unwrap(); + let before = dynamic_block(&d, "Door").unwrap(); + assert!(before.actions.iter().any(|a| a.node == 7 && matches!(a.kind, ActionKind::Scale)), "{:?}", before.actions); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + + let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap(); + let scale = doc.objects.values().find_map(|o| match o { + ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()), + _ => None, + }); + let scale = scale.expect("the DWG has no scale action"); + let names = |c: &[acadrust::objects::BlockConnection]| c.iter().map(|c| (c.code, c.name.clone())).collect::>(); + assert_eq!(names(&scale.connections), [(2, "Scale".to_string()), (2, "XScale".into()), (2, "YScale".into())]); + assert_eq!(names(&scale.action.connections), [(2, "UpdatedBaseX".to_string()), (2, "UpdatedBaseY".into())]); + assert_eq!(scale.dwg_tail_byte, 0); + + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(by_node(dynamic_block(&back, "Door").unwrap()), by_node(before)); +} + /// The model with each parameter and action object named by its graph id instead of its handle. fn by_node(mut m: DynamicBlock) -> DynamicBlock { let ids: std::collections::HashMap = m From 3bb515485d0699a7c25ac34db0d77e26309c13d8 Mon Sep 17 00:00:00 2001 From: Newton Kilgore Sightsinger Date: Mon, 2 Nov 2026 14:01:36 -0800 Subject: [PATCH 127/151] Xrefs: a saved path is found regardless of letter case on case-sensitive file systems (#1982) --- crates/engine/src/cmd/blocks/xref/load.rs | 39 ++++++++++++++++++++++- crates/engine/tests/xref.rs | 17 ++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/blocks/xref/load.rs b/crates/engine/src/cmd/blocks/xref/load.rs index fac289f23..b47aa00e0 100644 --- a/crates/engine/src/cmd/blocks/xref/load.rs +++ b/crates/engine/src/cmd/blocks/xref/load.rs @@ -208,7 +208,7 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result = { let _ = files; @@ -217,6 +217,43 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result Option { + use std::path::Component; + let mut out = PathBuf::new(); + for c in p.components() { + let Component::Normal(name) = c else { + out.push(c.as_os_str()); + continue; + }; + let exact = out.join(name); + if exact.exists() { + out = exact; + continue; + } + let want = name.to_str()?.to_lowercase(); + let mut hits = std::fs::read_dir(if out.as_os_str().is_empty() { Path::new(".") } else { &out }) + .ok()? + .take(MAX_CASE_SCAN) + .flatten() + .filter(|e| e.file_name().to_str().is_some_and(|n| n.to_lowercase() == want)); + let hit = hits.next()?; + if hits.next().is_some() { + return None; + } + out.push(hit.file_name()); + } + out.is_file().then_some(out) +} + /// Read a drawing file through the host's io hooks, counting its bytes against the load's total. fn read(file: &Path, cx: &mut Ctx) -> Result { let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?; diff --git a/crates/engine/tests/xref.rs b/crates/engine/tests/xref.rs index c67c627b2..27fa505c4 100644 --- a/crates/engine/tests/xref.rs +++ b/crates/engine/tests/xref.rs @@ -506,3 +506,20 @@ fn saved_xref_paths_are_followed_beside_the_host_or_on_this_computer_only() { s.execute("-xref", &json!({ "option": "path", "names": "unc", "path": far.to_str().unwrap() })).unwrap(); assert_eq!(status(&s, "unc"), Some(XrefStatus::Loaded)); } + +/// A reference saved on Windows with different letter case (`..\XR\PLAN.DWG` for +/// `../xr/plan.dxf`) is still found on case-sensitive file systems. +#[test] +fn saved_xref_paths_ignore_letter_case() { + hooks(); + let base = folder("case"); + std::fs::create_dir_all(base.join("host")).unwrap(); + std::fs::create_dir_all(base.join("Xr")).unwrap(); + save(&site(), &base.join("Xr").join("Plan.dxf")); + let host = base.join("host").join("host.dxf"); + save(&host_with_paths(&[("plan", r"..\XR\PLAN.DXF")]), &host); + let mut s = Session::new(); + open(&mut s, &host); + assert_eq!(status(&s, "plan"), Some(XrefStatus::Loaded), "{:?}", s.log); + assert!(objects(&s, "plan") > 0); +} From 4e874d089a10562a454b38d8d3ee9c34dfa1662c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:01:41 +0100 Subject: [PATCH 128/151] Persist current table style (CTABLESTYLE) in DXF header (#2117) Found while testing crates/io/src/dxf_write.rs:1964-1970 (xrecords written) and crates/io/src/dxf_read.rs:2049-2052 (apply) Co-authored-by: Claude Haiku 5.5 --- crates/io/src/dxf_write/header.rs | 1 + crates/io/tests/it/tables_objects_roundtrip.rs | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/io/src/dxf_write/header.rs b/crates/io/src/dxf_write/header.rs index 354c293c8..9b8c1f800 100644 --- a/crates/io/src/dxf_write/header.rs +++ b/crates/io/src/dxf_write/header.rs @@ -19,6 +19,7 @@ const HEADER_VARS: &[(&str, i32)] = &[ ("CMLJUST", 70), ("CMLSCALE", 40), ("TEXTSTYLE", 7), + ("CTABLESTYLE", 7), ("CLAYER", 8), ("CELTYPE", 6), ("CECOLOR", 62), diff --git a/crates/io/tests/it/tables_objects_roundtrip.rs b/crates/io/tests/it/tables_objects_roundtrip.rs index 0f75e4cd3..ec2fe7726 100644 --- a/crates/io/tests/it/tables_objects_roundtrip.rs +++ b/crates/io/tests/it/tables_objects_roundtrip.rs @@ -2,7 +2,9 @@ //! multileader styles, layout page setups, named views, UCSs and groups. use cadcraft_doc::geom::{Vec2, Vec3}; -use cadcraft_doc::{Common, DashElement, Drawing, EntityKind, Group, Line, Linetype, MLeaderStyle, NamedView, Space, TextStyle, Ucs, library}; +use cadcraft_doc::{ + Common, DashElement, Drawing, EntityKind, Group, Line, Linetype, MLeaderStyle, NamedView, Space, TableStyle, TextStyle, Ucs, library, +}; use cadcraft_io::{read_dxf, write_dxf}; fn round_trip(d: &Drawing) -> Drawing { @@ -82,6 +84,15 @@ fn multileader_styles_and_current_style_round_trip() { assert_eq!(r.header.str("CMLEADERSTYLE", ""), "Callout"); } +#[test] +fn current_table_style_round_trips() { + let mut d = Drawing::new_imperial(); + d.table_styles.push(TableStyle { name: "TS".into(), ..TableStyle::default() }); + d.header.set_str("CTABLESTYLE", "TS"); + let r = round_trip(&d); + assert_eq!(r.header.str("CTABLESTYLE", ""), "TS"); +} + #[test] fn layout_page_setup_and_paper_view_round_trip() { let mut d = Drawing::new_imperial(); From 3f57f8d3d49ca22e83bae85e4d1a8b445e385bc2 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:01:46 +0300 Subject: [PATCH 129/151] render: a display list past the block-expansion budget says what it left out (#1278) * render: a display list past the block-expansion budget says what it left out * Report viewport truncation only after the expansion budget is exceeded * fmt: keep ui-egui control.rs rustfmt-clean --- crates/io/tests/it/block_expansion.rs | 65 ++++++++++++++++++++++++++- crates/mcp/src/backend.rs | 2 +- crates/render/src/display.rs | 4 ++ crates/render/src/lib.rs | 6 ++- crates/render/src/viewports.rs | 1 + crates/ui-egui/src/control.rs | 20 +++++---- 6 files changed, 87 insertions(+), 11 deletions(-) diff --git a/crates/io/tests/it/block_expansion.rs b/crates/io/tests/it/block_expansion.rs index b190156f8..64fca189e 100644 --- a/crates/io/tests/it/block_expansion.rs +++ b/crates/io/tests/it/block_expansion.rs @@ -1,7 +1,10 @@ //! Nested, self-referencing and arrayed block references in a hostile DXF must not hang drawing //! or extents (issue #255): the expansion is bounded, not only its depth. -use cadcraft_doc::{Drawing, EntityKind, Space}; +use std::sync::Arc; + +use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, Space, Viewport}; +use cadcraft_geom::{Vec2, Vec3}; use cadcraft_render::{MAX_BLOCK_EXPANSION, Options, build}; fn read(text: &str) -> Drawing { @@ -63,3 +66,63 @@ fn ordinary_nested_and_arrayed_blocks_are_drawn_whole() { assert!(matches!(d.model.iter().next().map(|e| &e.kind), Some(EntityKind::Insert(_)))); assert_eq!(build(&d, &Space::Model, &Options::default()).prims.len(), cadcraft_doc::MAX_BLOCK_DEPTH); } + +#[test] +fn a_build_that_spends_the_expansion_budget_says_so() { + // 10,000 x 10,000 copies of a one-line block: far past the budget. The list stops at the + // budget and says so, so a screen, plot or export missing contents is not silent (#1070). + let d = read(&minsert(10_000, 10_000)); + let list = build(&d, &Space::Model, &Options::default()); + assert!(list.truncated, "block contents were left out without a word"); + assert!(list.prims.len() <= MAX_BLOCK_EXPANSION, "{}", list.prims.len()); + // A drawing within the budget is whole and says nothing. + let d = read(&minsert(3, 2)); + let list = build(&d, &Space::Model, &Options::default()); + assert!(!list.truncated); + assert_eq!(list.prims.len(), 6); +} + +#[test] +fn an_empty_followup_viewport_does_not_mark_an_exact_budget_truncated() { + let mut d = Drawing::new_imperial(); + d.ensure_layer("FROZEN"); + let mut block = Block::new("A"); + block.entities.push(Entity { + handle: Handle(0x200), + common: Common { layer: "FROZEN".into(), ..Common::default() }, + kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 1.0, 0.0) }), + }); + d.blocks.insert(block.name.clone(), Arc::new(block)); + let insert = Insert { + block: "A".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1_000, + rows: 1_000, + col_spacing: 0.0, + row_spacing: 0.0, + }; + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap(); + let viewport = |center: Vec3, view_center: Vec2, frozen_layers| Viewport { + center, + width: 100.0, + height: 100.0, + view_center, + view_height: 100.0, + id: 2, + locked: false, + frozen_layers, + layer_colors: Vec::new(), + clip: None, + on: true, + }; + let paper = Space::Paper("Layout1".into()); + d.add(&paper, Common::default(), EntityKind::Viewport(viewport(Vec3::new(50.0, 50.0, 0.0), Vec2::ZERO, vec!["FROZEN".into()]))).unwrap(); + d.add(&paper, Common::default(), EntityKind::Viewport(viewport(Vec3::new(200.0, 50.0, 0.0), Vec2::new(10_000.0, 10_000.0), Vec::new()))).unwrap(); + + let list = build(&d, &paper, &Options::default()); + assert_eq!(list.expanded, MAX_BLOCK_EXPANSION); + assert!(!list.truncated, "the first viewport consumed the budget exactly and the second saw no model content"); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 20a86fce7..1a36144ca 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -418,7 +418,7 @@ impl Backend for Headless { if let Some(path) = str_p("path") { std::fs::write(&path, &png).map_err(|e| format!("{path}: {e}"))?; } - Ok(json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h})) + Ok(json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h, "truncated": list.truncated})) } "app.open" => { let path = str_p("path").ok_or("missing path")?; diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs index e6ba6b7e9..9731ecbf7 100644 --- a/crates/render/src/display.rs +++ b/crates/render/src/display.rs @@ -59,6 +59,10 @@ pub struct DisplayList { pub sheet: Option, /// Block contents expanded while building (see [`MAX_BLOCK_EXPANSION`]). pub expanded: usize, + /// The expansion budget was spent: block contents past it are not in the list. The screen, + /// plots and exports draw what is here; the CLI and the control channel report this, so an + /// output that leaves contents out is never silent (#1070). + pub truncated: bool, /// Plot style end, join and fill style per primitive (parallel to `prims`; missing entries /// are "use object"), turned into geometry by [`pen::apply`]. pub pens: Vec, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6fa236e4c..e93c9fbff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -291,10 +291,14 @@ impl Builder<'_> { /// Take one unit of the block-expansion budget; false once it is spent. fn expand(&mut self) -> bool { self.list.expanded = self.list.expanded.saturating_add(1); - match self.shared_budget { + let ok = match self.shared_budget { Some(spent) => spent.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < MAX_BLOCK_EXPANSION, None => self.list.expanded <= MAX_BLOCK_EXPANSION, + }; + if !ok { + self.list.truncated = true; } + ok } } diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs index 6f1e57517..67c31c694 100644 --- a/crates/render/src/viewports.rs +++ b/crates/render/src/viewports.rs @@ -174,6 +174,7 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou // The contents are in paper units now: draw their pens before clipping. pen::apply(&mut sub.list, unit_mm); b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded); + b.list.truncated |= sub.list.truncated; append_clipped(&mut b.list, &sub.list, clip); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8d0a39bf4..4abc6b1f4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -269,7 +269,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value { "snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})), "session": app.session.summary(), "closePrompt": app.close_prompt(), - "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()}, + "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "truncated": app.canvas.list.as_ref().map(|l| l.truncated), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()}, }) } @@ -492,9 +492,9 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou if let Err(e) = std::fs::write(path, &png) { return err(e); } - return ok(render_reply(&png, w, h, Some(path))); + return ok(render_reply(&png, w, h, Some(path), list.truncated)); } - ok(render_reply(&png, w, h, None)) + ok(render_reply(&png, w, h, None, list.truncated)) } "app.open" => { let Some(path) = s("path") else { return err("missing path") }; @@ -537,9 +537,11 @@ fn render_view(bounds: &cadcraft_geom::Bounds2, center: Vec2, height: f64, fit: } } -/// The `ui.render` reply, matching the headless backend (`pngBase64`, `width`, `height`), plus `bytes` and `path`. -fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>) -> Value { - let mut v = json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(png), "width": w, "height": h, "bytes": png.len()}); +/// The `ui.render` reply, matching the headless backend (`pngBase64`, `width`, `height`), plus +/// `bytes`, `path` and `truncated` (the build left block contents out, see #1070). +fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>, truncated: bool) -> Value { + let mut v = + json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(png), "width": w, "height": h, "bytes": png.len(), "truncated": truncated}); if let (Some(path), Some(o)) = (path, v.as_object_mut()) { o.insert("path".to_string(), json!(path)); } @@ -816,12 +818,14 @@ mod tests { #[test] fn render_reply_has_base64_width_height_and_keeps_old_keys() { - let r = render_reply(b"abc", 7, 5, Some("x.png")); + let r = render_reply(b"abc", 7, 5, Some("x.png"), false); assert_eq!(r["pngBase64"], "YWJj"); assert_eq!(r["width"], 7); assert_eq!(r["height"], 5); assert_eq!(r["bytes"], 3); assert_eq!(r["path"], "x.png"); - assert!(render_reply(b"abc", 7, 5, None).get("path").is_none()); + assert_eq!(r["truncated"], false); + assert!(render_reply(b"abc", 7, 5, None, true).get("path").is_none()); + assert_eq!(render_reply(b"abc", 7, 5, None, true)["truncated"], true); } } From 5ec0d215d826108c2bb3132e3edb71220f28fa84 Mon Sep 17 00:00:00 2001 From: STE1200 <319441566+STE1200@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:01:51 +0100 Subject: [PATCH 130/151] CLI: info and convert report what a damaged DWG left out (read notes, #859) (#2337) A damaged DWG opens partially since #859 and the drawing's read notes say how many object records were left out. OPEN echoes them and MCP `open` returns them as `warnings`, but `cadcraft-cli info` and `convert` stayed silent: a corpus round trip wrote thousands of skipped records away without a word. - convert: each read note goes to stderr as `IN: `, next to the skipped-records warning (#872). The exit code stays 0. - drawing.inspect (and so info, MCP inspect_drawing and the control channel): new field `readNotes`, next to `skipped`. Existing fields are unchanged. - run already prints OPEN's notes with the session log. - README and docs/control-protocol.md say where the notes appear. Part of #1039. Co-authored-by: Claude Opus 5.5 --- README.md | 5 ++ apps/cadcraft-cli/src/convert.rs | 5 +- apps/cadcraft-cli/src/output.rs | 23 +++++++++ apps/cadcraft-cli/tests/read_notes.rs | 69 +++++++++++++++++++++++++++ crates/engine/src/cmd/inquiry.rs | 2 + docs/control-protocol.md | 2 +- 6 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 apps/cadcraft-cli/tests/read_notes.rs diff --git a/README.md b/README.md index a24b33980..611110c3c 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,11 @@ How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the mode rectangle, centred and scaled without distortion; `--view extents` or `--view current` forces either framing. To frame a selection set, `zoom {"mode":"object"}` after selecting. +When reading a file leaves something out (records a DXF reader can't place, object records of a +damaged DWG), `info` reports it (`skipped`, `readNotes`), `convert` warns on stderr and `run` +prints the DWG notes with its log; the exit code stays 0. MCP `open` replies with the notes as +`warnings`. + MCP tools include `command_line` (type at the prompt), `execute` (any command with JSON), `inspect_drawing`, `query_entities`, `render` (returns a PNG) and, when connected to the app, `screenshot` and `ui_click`. The desktop app's JSON control channel is documented in diff --git a/apps/cadcraft-cli/src/convert.rs b/apps/cadcraft-cli/src/convert.rs index 6a9c4edbf..35ffc6aed 100644 --- a/apps/cadcraft-cli/src/convert.rs +++ b/apps/cadcraft-cli/src/convert.rs @@ -1,6 +1,6 @@ use super::args::ConvertArgs; use super::error::Result; -use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message}; +use super::output::{Streams, export_framed, export_window, read_note_messages, skipped_message, undrawn_message}; use super::{open, unicode}; use cadcraft_engine::Session; @@ -9,7 +9,8 @@ pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result { let framing = a.framing.framing(); let mut s = Session::empty(); open(&mut s, &a.input)?; - if let Some(m) = skipped_message(s.doc()?, i) { + let d = s.doc()?; + for m in read_note_messages(d, i).into_iter().chain(skipped_message(d, i)) { io.errln(format_args!("{m}")); } if cadcraft_io::is_image(&a.output) diff --git a/apps/cadcraft-cli/src/output.rs b/apps/cadcraft-cli/src/output.rs index d783267dc..5716c486c 100644 --- a/apps/cadcraft-cli/src/output.rs +++ b/apps/cadcraft-cli/src/output.rs @@ -165,6 +165,12 @@ pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", "))) } +/// The reader's notes on what it left out of `path` (a damaged DWG's unreadable object records, +/// #859), one warning line each; empty for a complete read. +pub(crate) fn read_note_messages(d: &cadcraft_engine::doc::Drawing, path: &str) -> Vec { + d.read_notes.iter().map(|note| format!("{path}: {note}")).collect() +} + /// The warning for the records of `path` the reader could not place, or `None` when there are none. pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option { let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)); @@ -175,3 +181,20 @@ pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> let records = if n == 1 { "record" } else { "records" }; Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", "))) } + +#[cfg(test)] +mod tests { + use super::read_note_messages; + + #[test] + fn read_notes_become_one_warning_each() { + let mut d = cadcraft_engine::doc::Drawing::new_imperial(); + assert!(read_note_messages(&d, "a.dwg").is_empty()); + d.read_notes = + vec!["DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them".into()]; + assert_eq!( + read_note_messages(&d, "a.dwg"), + vec!["a.dwg: DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them"] + ); + } +} diff --git a/apps/cadcraft-cli/tests/read_notes.rs b/apps/cadcraft-cli/tests/read_notes.rs new file mode 100644 index 000000000..2f443f03a --- /dev/null +++ b/apps/cadcraft-cli/tests/read_notes.rs @@ -0,0 +1,69 @@ +//! A damaged DWG opens partially (#859); `info` lists the reader's notes on what it left out +//! (`readNotes`) and `convert` warns with them before writing the copy, so a script or agent sees +//! the loss. The exit code stays 0. + +use std::process::{Command, Output}; + +fn cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap() +} + +/// A DWG of 200 lines, and a copy damaged so that the reader skips some of its records (found the +/// way the reader's own test finds one: flipping a few bytes at a time across the objects area). +fn dwg_and_damaged_copy() -> (Vec, Vec) { + let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n"); + for i in 0..200 { + dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n"); + } + dxf += "0\nENDSEC\n0\nEOF\n"; + let d = cadcraft_io::read(dxf.as_bytes(), "lines.dxf").unwrap(); + let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2000), binary: false }; + let dwg = cadcraft_io::write_with(&d, "lines.dwg", &opts).unwrap(); + let damaged = (dwg.len() / 4..dwg.len() * 3 / 5) + .step_by(97) + .map(|at| { + let mut b = dwg.clone(); + b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5); + b + }) + .find(|b| cadcraft_io::read(b, "damaged.dwg").is_ok_and(|d| !d.read_notes.is_empty())) + .expect("no damaged copy read partially"); + (dwg, damaged) +} + +#[test] +fn info_lists_and_convert_warns_about_read_notes() { + let dir = std::env::temp_dir().join(format!("cc-cli-read-notes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (whole, damaged) = dwg_and_damaged_copy(); + let path = dir.join("damaged.dwg"); + std::fs::write(&path, &damaged).unwrap(); + let path = path.to_str().unwrap(); + + let out = cli(&["info", path]); + assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr)); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let notes = v["readNotes"].as_array().unwrap(); + assert_eq!(notes.len(), 1, "{v}"); + assert!(notes[0].as_str().unwrap().contains(" couldn't be read and "), "{v}"); + + let copy = dir.join("copy.dxf"); + let out = cli(&["convert", path, copy.to_str().unwrap()]); + assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr)); + let err = String::from_utf8_lossy(&out.stderr); + let warnings: Vec<&str> = err.lines().filter(|l| l.contains(" couldn't be read and ")).collect(); + assert_eq!(warnings.len(), 1, "{err}"); + assert!(warnings[0].starts_with(&format!("{path}: DWG: ")), "{err}"); + assert!(copy.exists()); + + // A complete read: no notes, and no warning. + let intact = dir.join("whole.dwg"); + std::fs::write(&intact, &whole).unwrap(); + let out = cli(&["info", intact.to_str().unwrap()]); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["readNotes"], serde_json::json!([])); + let out = cli(&["convert", intact.to_str().unwrap(), copy.to_str().unwrap()]); + assert!(out.status.success()); + assert!(!String::from_utf8_lossy(&out.stderr).contains("couldn't be read"), "{}", String::from_utf8_lossy(&out.stderr)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 364fa6796..283551dfd 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -596,6 +596,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result { "view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height }, // Records of the file the drawing came from that the reader could not place. "skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records }, + // What reading the file left out, in words (a damaged DWG's unreadable object records). + "readNotes": d.read_notes, }); if bool_or(p, "entities", true) && let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space)) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 98db28330..d10d43abb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,7 +49,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `cmdline.state` | | Current prompt, keywords, accepted input kinds, history tail. | | `engine.execute` | `{command, params}` | Run any command with JSON parameters. Never opens a dialog. `command` is an alias of this method, with the same parameters and result. | | `engine.commands` | | Every command: id, label, menu path, shortcut, aliases, params doc, enabled. | -| `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo) and entities with handles and geometry. | +| `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo, and what reading the file left out: `skipped` records by section, `readNotes` in words) and entities with handles and geometry. | | `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters (`uiFrames`, `passIndex` included); `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | | `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. | | `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). | From 2ac500925b6b53b6a3f00dc30e4428caf565ebbe Mon Sep 17 00:00:00 2001 From: Books Date: Tue, 3 Nov 2026 09:01:57 +1100 Subject: [PATCH 131/151] DWG: a table cell's text height is an override only when the file flags it (part of #2501) (#2588) * DWG: a table cell's text height is an override only when the file flags it (#2501) acadrust's DXF writer writes group 140 for any cell with a style record, even a default 0.18 with no override flag, and CADCraft read it as a per-cell height, so cells ignored the table style's height. The bridge now clears the height of a cell style that has neither the TEXT_HEIGHT property flag nor the content's height override bit (0 reads as no override). * Parity checklist: regenerated after merging main * Tests: inherited table cell heights must stay unset (review on #2588) A cell that inherits its height must read back as None, not an explicit 0.0625 override. * Parity checklist: regenerated after merging main * Test: a cell style with the TEXT_HEIGHT flag, content override bit 128 and a cell with no style record keep the right height (review on #2588) * DWG: a table cell's content height override (group 144) is kept (review on #2588) * Parity checklist: regenerated after merging main * DWG: table cell height takes the first content's override and the style's override flags (review on #2588) Only the first content's bit-128 height stands for the cell (one height for its text); an invalid content height no longer keeps the writer's default 0.18; a height marked only in the style's override flags (newer files) is kept. --- crates/dwg/src/lib.rs | 67 +++++++++ crates/io/tests/dwg_table_cell_height.rs | 173 +++++++++++++++++++++++ docs/parity-checklist.md | 4 +- 3 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 crates/io/tests/dwg_table_cell_height.rs diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index a5de9bffe..e5463aaec 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -332,12 +332,51 @@ mod native { /// can cover rows × cols cells, so the total is bounded too. const MAX_MERGE_CELLS: usize = 1 << 24; + /// A table cell's own text height only when the file says it overrides the table style's. + /// acadrust's DXF writer writes a cell style's height (group 140) whenever the cell has a + /// style record, even a default 0.18 with no override flag, and CADCraft reads a 140 as an + /// override (#2501). The override is the cell style's TEXT_HEIGHT property flag or the + /// content's format override bit 128; without either the height is cleared (0 is no + /// override), so the table style's height applies. + fn table_cell_heights(doc: &mut acadrust::CadDocument) { + use acadrust::entities::table::CellStylePropertyFlags; + const CONTENT_TEXT_HEIGHT: i32 = 128; + for e in doc.entities_mut() { + let acadrust::entities::EntityType::Table(t) = e else { continue }; + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + // The first content's own height (group 144) when its format override sets bit 128. + // CADCraft keeps one height for the cell's text, so a later content's override + // must not stand in for the first content's. + let content_height = cell + .contents + .first() + .filter(|c| c.format_override_flags & CONTENT_TEXT_HEIGHT != 0) + .map(|c| c.text_height) + .filter(|h| h.is_finite() && *h > 0.0); + if let Some(h) = content_height { + // CADCraft reads only the style's height (group 140), so carry it there. + cell.style.get_or_insert_with(acadrust::entities::table::CellStyle::new).text_height = h; + continue; + } + // A style overrides through its property flags or, in newer files, its override + // flags; anything else is the writer's default and is cleared. + if let Some(style) = cell.style.as_mut() + && !style.property_flags.contains(CellStylePropertyFlags::TEXT_HEIGHT) + && style.override_flags & CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32 == 0 + { + style.text_height = 0.0; + } + } + } + } + /// The fix-ups made to a document after the DWG read, returning how many orphaned DGN /// components were dropped. The document comes from a third-party reader and is hostile. fn post_process(doc: &mut acadrust::CadDocument) -> usize { super::fail_in_test("post_process"); current_style_names(doc); table_merges(doc); + table_cell_heights(doc); table_style_rows(doc, false); keep_sort_handles(doc); drop_orphaned_dgn_components(doc) @@ -821,6 +860,34 @@ mod native { assert_eq!(table_merges_within(&mut doc, usize::MAX), 100); } + /// A height marked only in the style's override flags (as newer files store it) is kept, + /// and the same height with no flag at all is cleared (review on #2588). Tested on the + /// read document because acadrust's DWG writer doesn't write the override flags. + #[test] + fn a_style_override_flag_keeps_the_cell_height() { + use acadrust::entities::table::{CellStyle, CellStylePropertyFlags}; + let height = |override_flags: i32| { + let mut doc = doc_with_table(1, 1, 0); + for e in doc.entities_mut() { + if let EntityType::Table(t) = e { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + let mut s = CellStyle::new(); + s.text_height = 0.5; + s.override_flags = override_flags; + cell.style = Some(s); + } + } + } + table_cell_heights(&mut doc); + doc.entities().find_map(|e| match e { + EntityType::Table(t) => t.rows.first()?.cells.first()?.style.as_ref().map(|s| s.text_height), + _ => None, + }) + }; + assert_eq!(height(CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32), Some(0.5)); + assert_eq!(height(0), Some(0.0)); + } + /// A panic in the post-read fix-ups is an error, not a crash (#1934). #[test] fn a_panic_in_post_processing_is_an_error() { diff --git a/crates/io/tests/dwg_table_cell_height.rs b/crates/io/tests/dwg_table_cell_height.rs new file mode 100644 index 000000000..11ee98c9d --- /dev/null +++ b/crates/io/tests/dwg_table_cell_height.rs @@ -0,0 +1,173 @@ +//! TABLE heights inherit from the table style unless the file records a cell override. +#![cfg(not(target_arch = "wasm32"))] + +use cadcraft_doc::{Common, Drawing, EntityKind, Space, Table, TableCell, TableStyle}; + +fn drawing() -> Drawing { + let mut d = Drawing::new_imperial(); + d.table_styles.push(TableStyle { name: "Small".into(), text_height: 0.0625, title: false, header: false, ..TableStyle::default() }); + let table = Table { + style: "Small".into(), + text_height: 0.0625, + title: false, + header: false, + row_heights: vec![0.2], + col_widths: vec![2.0; 3], + cells: vec![vec![ + TableCell { text: "plain".into(), ..TableCell::default() }, + TableCell { text: "{\\C256;formatted}".into(), ..TableCell::default() }, + TableCell { text: "explicit".into(), text_height: Some(0.125), ..TableCell::default() }, + ]], + ..Table::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Table(table)).unwrap(); + d +} + +fn check(d: &Drawing) { + let t = d + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Table(t) => Some(t), + _ => None, + }) + .unwrap(); + assert_eq!(t.style, "Small"); + assert_eq!(t.text_height, 0.0625); + for cell in &t.cells[0][..2] { + assert!(cell.text_height.is_none(), "unexpected override: {:?}", cell.text_height); + } + assert_eq!(t.cells[0][2].text_height, Some(0.125)); +} + +#[test] +fn table_cell_heights_keep_inheritance_through_dwg() { + let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap(); + // A style record can be present for metadata without overriding its text height. + // Write that synthetic representation directly so no earlier DXF conversion can + // turn the unused default into an explicit override before this import. + let mut doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(bytes)).read().unwrap(); + let table = doc + .entities_mut() + .find_map(|e| match e { + acadrust::entities::EntityType::Table(t) => Some(t), + _ => None, + }) + .unwrap(); + table.rows[0].cells[0].style = Some(acadrust::entities::table::CellStyle::new()); + let bytes = acadrust::DwgWriter::write_to_vec(&doc).unwrap(); + check(&cadcraft_io::read(&bytes, "height.dwg").unwrap()); +} + +#[test] +fn table_cell_heights_keep_inheritance_through_dxf() { + let bytes = cadcraft_io::write(&drawing(), "height.dxf").unwrap(); + check(&cadcraft_io::read(&bytes, "height.dxf").unwrap()); +} + +// ---- cells with their own style record (review on #2588) ---- + +use acadrust::entities::table::{CellContent, CellStyle, CellStylePropertyFlags}; + +/// Write the drawing as a DWG, edit its first cell through acadrust (`edit`), write it again as a +/// DWG (or, with `as_dxf`, as a DXF by acadrust's writer) and read it with CADCraft. Returns the first cell's +/// text height override. +fn first_cell_height(as_dxf: bool, edit: impl FnOnce(&mut acadrust::entities::table::TableCell)) -> Option { + let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap(); + let mut doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(bytes)).read().unwrap(); + let table = doc + .entities_mut() + .find_map(|e| match e { + acadrust::entities::EntityType::Table(t) => Some(t), + _ => None, + }) + .unwrap(); + edit(&mut table.rows[0].cells[0]); + let d = if as_dxf { + let bytes = acadrust::DxfWriter::new(&doc).write_to_vec().unwrap(); + cadcraft_io::read(&bytes, "height.dxf").unwrap() + } else { + let bytes = acadrust::DwgWriter::write_to_vec(&doc).unwrap(); + cadcraft_io::read(&bytes, "height.dwg").unwrap() + }; + d.model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Table(t) => Some(t.cells[0][0].text_height), + _ => None, + }) + .unwrap() +} + +fn styled(height: f64, flags: CellStylePropertyFlags) -> CellStyle { + let mut s = CellStyle::new(); + s.text_height = height; + s.property_flags = flags; + s +} + +#[test] +fn a_cell_style_with_the_text_height_flag_keeps_its_height() { + let h = first_cell_height(false, |c| c.style = Some(styled(0.5, CellStylePropertyFlags::TEXT_HEIGHT))); + assert_eq!(h, Some(0.5)); +} + +#[test] +fn content_with_format_override_bit_128_keeps_its_own_height() { + // acadrust writes the content's height as group 144 and the style's as 140; they differ here + // so the result shows which one CADCraft honoured. + let h = first_cell_height(false, |c| { + c.style = Some(styled(0.5, CellStylePropertyFlags::NONE)); + if c.contents.is_empty() { + c.contents.push(CellContent::new()); + } + c.contents[0].format_override_flags |= 128; + c.contents[0].text_height = 0.3125; + }); + assert_eq!(h, Some(0.3125)); +} + +/// Only the first content's override stands for the cell: a later content's height must not +/// replace the first content's inherited one (review on #2588). +#[test] +fn a_later_contents_height_does_not_replace_the_first_contents() { + let h = first_cell_height(false, |c| { + c.style = Some(styled(0.18, CellStylePropertyFlags::NONE)); + c.contents.clear(); + c.contents.push(CellContent::new()); + let mut later = CellContent::new(); + later.format_override_flags |= 128; + later.text_height = 0.3125; + c.contents.push(later); + }); + assert_eq!(h, None); +} + +/// Bit 128 with an unusable content height copies nothing, so the writer's default style height +/// is still cleared (review on #2588). +#[test] +fn bit_128_with_an_invalid_height_still_clears_the_default() { + for bad in [0.0, -1.0, f64::NAN] { + let h = first_cell_height(false, |c| { + c.style = Some(styled(0.18, CellStylePropertyFlags::NONE)); + if c.contents.is_empty() { + c.contents.push(CellContent::new()); + } + c.contents[0].format_override_flags |= 128; + c.contents[0].text_height = bad; + }); + assert_eq!(h, None, "content height {bad}"); + } +} + +#[test] +fn a_cell_with_no_style_record_inherits_the_table_style() { + assert_eq!(first_cell_height(false, |c| c.style = None), None); +} + +/// A style record with a height and neither override is not an override, through DWG. +#[test] +fn a_style_height_without_an_override_is_cleared() { + assert_eq!(first_cell_height(false, |c| c.style = Some(styled(0.18, CellStylePropertyFlags::NONE))), None); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 53ffffc96..a91ee2f25 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,10 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-30 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-11-01 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 298 / 491 items (61%)** · 444 registered commands. +**Menu breadth: 298 / 491 items (61%)** · 449 registered commands. | Menu | Items | Covered | % | |---|---|---|---| From eacdbe43eef1b58b7436317830751c8b6e93bf6e Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 17:10:53 -0700 Subject: [PATCH 132/151] Save: a folder that can't be made says so, not that the backup failed (#2669) The backup ran before the folders were made. On Linux, a path under a file gave 'the backup copy could not be written' instead of naming the folder. --- crates/engine/src/cmd/file.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 72d4ef692..76a53da46 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -440,7 +440,8 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result Result<()> { #[cfg(not(target_arch = "wasm32"))] { - if is_drawing_file(path) && crate::autosave::isavebak() { + // A file in a folder still to be made has nothing to back up. + if is_drawing_file(path) && crate::autosave::isavebak() && crate::atomic_write::missing_folder(std::path::Path::new(path)).is_none() { backup(std::path::Path::new(path)) .map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?; } From f389d6a72e3fc160ea125b4f264d68f64cbe2aba Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 19:26:12 -0500 Subject: [PATCH 133/151] Release 0.17.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b5210faea..98d98880f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -644,7 +644,7 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -660,14 +660,14 @@ dependencies = [ [[package]] name = "cadcraft-color" -version = "0.16.0" +version = "0.17.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -688,14 +688,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.16.0" +version = "0.17.0" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.16.0" +version = "0.17.0" dependencies = [ "encoding_rs", "log", @@ -704,7 +704,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -725,7 +725,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-geom", "harfrust 0.14.0", @@ -736,14 +736,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.16.0" +version = "0.17.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.16.0" +version = "0.17.0" dependencies = [ "acadrust", "cadcraft-color", @@ -764,7 +764,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -777,7 +777,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -791,7 +791,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -817,7 +817,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.16.0" +version = "0.17.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -5928,7 +5928,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.16.0" +version = "0.17.0" dependencies = [ "chrono", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b84651eee..f51aa8909 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.16.0" +version = "0.17.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From fdfe4d95a8e588dab40a174a45ec9e2586753f68 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 18:06:35 -0700 Subject: [PATCH 134/151] Autosave: failures are shown, and a crash is recovered at the first relaunch (#2670) * Autosave: a failed write is shown until one works (E6) A failure only reached the log, so someone could work for hours with no recovery copy. The autosaver keeps why each drawing's write failed; the app says it once at the command line and shows a notice that doesn't block saving. It ends when a write works or the drawing no longer needs a copy. * Crash recovery: offered at the first relaunch, not 5 minutes later (E5) A crashed app's lock file looked alive until it went stale, so a relaunch within 5 minutes skipped its autosaves, and a suspended app's looked dead. A running app now holds a system lock on the file, which the OS drops however the process ends. File age still decides where locks don't work and for lock files of older versions, which don't record holding one. --- crates/engine/src/autosave.rs | 112 +++++++++++++++++++---- crates/engine/tests/autosave.rs | 94 +++++++++++++++++++ crates/ui-egui/src/autosave.rs | 58 ++++++++++++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/tests/autosave_failure.rs | 82 +++++++++++++++++ 5 files changed, 331 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/tests/autosave_failure.rs diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 9ff7eab0b..92e97afeb 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -4,15 +4,18 @@ //! autosave is written as DXF to `/__.sv$`. The file goes //! away when the drawing is saved or closed and when the app exits normally, so a file left behind //! means the app (or the machine) went down; the next start offers it for recovery. A running app -//! keeps a lock file (`.lock`) fresh, so another copy running at the same time doesn't -//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from -//! an instance id ([`is_instance_id`]) are ever read as ours or deleted. +//! holds an operating-system lock on its lock file (`.lock`) and keeps the file fresh, so +//! another copy running at the same time doesn't offer its files. The system drops the lock the +//! moment a process ends, however it ended, so a relaunch after a crash can offer recovery at once. +//! Where locks aren't available, and for lock files of older versions, a file not refreshed for +//! [`STALE_LOCK_SECS`] counts as stopped. SAVEFILEPATH may be a folder other programs use too, so +//! only names built from an instance id ([`is_instance_id`]) are ever read as ours or deleted. //! //! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop //! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH //! are profile settings: process-wide, not saved in the drawing. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, Mutex, Weak}; @@ -29,6 +32,10 @@ pub const DEFAULT_SAVETIME: u32 = 10; pub const MAX_SAVETIME: u32 = 600; /// A lock file not refreshed for this long belongs to an app that is no longer running. pub const STALE_LOCK_SECS: u64 = 5 * 60; +/// How a lock file ends when its app holds the system lock on it, so a free lock proves the app +/// stopped. Older versions write only the process id and never hold one. +#[cfg(not(target_arch = "wasm32"))] +const LOCK_HELD: &str = " held"; /// How often a running app refreshes its lock file. #[cfg(not(target_arch = "wasm32"))] const HEARTBEAT_SECS: f64 = 60.0; @@ -149,13 +156,19 @@ pub struct Autosaver { docs: HashMap, /// Drawings whose autosave is being written right now. busy: HashSet, - /// Writes in progress; each tells whether its file was written. + /// Why the last autosave of each drawing failed, until one succeeds or the drawing no longer + /// needs it. Ordered, so [`Autosaver::failure`] doesn't change between frames. + failed: BTreeMap, + /// Writes in progress; each ends with why its file wasn't written, if it wasn't. #[cfg(not(target_arch = "wasm32"))] - running: Vec<(u64, std::thread::JoinHandle)>, + running: Vec<(u64, std::thread::JoinHandle>)>, #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant, #[cfg(not(target_arch = "wasm32"))] last_beat: Option, + /// The lock file this app holds the system lock on, open for as long as the lock is wanted. + #[cfg(not(target_arch = "wasm32"))] + lock: Option<(std::path::PathBuf, std::fs::File)>, } impl Autosaver { @@ -164,12 +177,15 @@ impl Autosaver { instance: instance.into(), docs: HashMap::new(), busy: HashSet::new(), + failed: BTreeMap::new(), #[cfg(not(target_arch = "wasm32"))] running: Vec::new(), #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant::now(), #[cfg(not(target_arch = "wasm32"))] last_beat: None, + #[cfg(not(target_arch = "wasm32"))] + lock: None, } } @@ -187,6 +203,11 @@ impl Autosaver { self.docs.values().filter_map(|t| t.file.clone()).collect() } + /// Why a drawing currently has no up-to-date recovery copy, for the host to show the user. + pub fn failure(&self) -> Option<&str> { + self.failed.values().next().map(String::as_str) + } + /// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder /// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the /// files of drawings that were saved (no unsaved changes left) or closed. @@ -220,6 +241,7 @@ impl Autosaver { jobs.push(Job::Delete { path }); } *t = Tracked::default(); + self.failed.remove(&st.uid); continue; } let same = |w: &Option>, d: &Arc| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d))); @@ -248,6 +270,8 @@ impl Autosaver { t.since = None; jobs.push(Job::Write { uid: st.uid, path, doc }); } + let docs = &self.docs; + self.failed.retain(|uid, _| docs.contains_key(uid)); jobs } @@ -277,12 +301,14 @@ pub use native::*; #[cfg(not(target_arch = "wasm32"))] mod native { + use std::fs::{File, OpenOptions, TryLockError}; + use std::io::{Read, Write}; use std::path::Path; use std::time::{Duration, SystemTime}; use super::{ - Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name, - savefilepath, savetime, + Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, LOCK_HELD, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, + parse_lock_name, savefilepath, savetime, }; /// A new instance id: the process id and the start time. @@ -307,6 +333,28 @@ mod native { Some(SystemTime::now().duration_since(m).unwrap_or_default()) } + /// Whether the app instance named by a lock file in `dir` may still be running: its system + /// lock answers at once, and the file's age decides when it can't (no lock support, a lock + /// file from an older version). + fn owner_alive(dir: &Path, instance: &str) -> bool { + let by_age = || instance_alive(lock_age(dir, instance)); + let path = lock_path(dir, instance); + // Only a plain file is opened: opening a FIFO planted in a shared folder would block. + if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_file()) { + return by_age(); + } + let Ok(file) = File::open(&path) else { return by_age() }; + match file.try_lock() { + Err(TryLockError::WouldBlock) => true, + Err(TryLockError::Error(_)) => by_age(), + Ok(()) => { + let mut text = String::new(); + let held = file.take(64).read_to_string(&mut text).is_ok() && text.ends_with(LOCK_HELD); + !held && by_age() + } + } + } + /// The autosave files in `dir` left behind by apps that are no longer running (never those of /// `own`, this app's instance), oldest first. pub fn recoverable(dir: &Path, own: &str) -> Vec { @@ -316,7 +364,7 @@ mod native { for e in entries.flatten().take(10_000) { let name = e.file_name().to_string_lossy().to_string(); let Some((title, instance, _)) = parse_file_name(&name) else { continue }; - if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) { + if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| owner_alive(dir, instance)) { continue; } let Ok(meta) = e.metadata() else { continue }; @@ -358,7 +406,7 @@ mod native { for n in &names { let Some(instance) = parse_lock_name(n) else { continue }; let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance)); - if !has_files && !instance_alive(lock_age(dir, instance)) { + if !has_files && !owner_alive(dir, instance) { let _ = std::fs::remove_file(dir.join(n)); } } @@ -425,22 +473,27 @@ mod native { /// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing /// whose write failed is no longer counted as autosaved: the next plan restarts its clock, - /// so it is tried again SAVETIME later even if it doesn't change. + /// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`] + /// says why until a write succeeds. pub fn reap(&mut self) { let (done, running) = std::mem::take(&mut self.running).into_iter().partition::, _>(|(_, h)| h.is_finished()); self.running = running; for (uid, h) in done { self.busy.remove(&uid); - if !h.join().unwrap_or(false) { - self.unwritten(uid); + match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) { + Ok(()) => { + self.failed.remove(&uid); + } + Err(e) => self.unwritten(uid, e), } } } - fn unwritten(&mut self, uid: u64) { + fn unwritten(&mut self, uid: u64, why: String) { if let Some(t) = self.docs.get_mut(&uid) { t.written = None; } + self.failed.insert(uid, why); } /// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now. @@ -451,16 +504,17 @@ mod native { Job::Write { uid, path, doc } => { let Some(write) = crate::cmd::file::io().map(|h| h.write) else { log::warn!("autosave: file formats are not available in this build"); + self.unwritten(uid, "file formats are not available in this build".to_string()); continue; }; let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) { Ok(()) => { log::info!("autosaved {path}"); - true + Ok(()) } Err(e) => { log::warn!("autosave {path}: {e}"); - false + Err(format!("{path}: {e}")) } }); match spawned { @@ -470,7 +524,7 @@ mod native { } Err(e) => { log::warn!("autosave: {e}"); - self.unwritten(uid); + self.unwritten(uid, e.to_string()); } } } @@ -485,12 +539,31 @@ mod native { } self.last_beat = Some(now); let dir = Path::new(dir); - let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string())); - if let Err(e) = r { + if let Err(e) = create_folder(dir).and_then(|()| self.refresh_lock(&lock_path(dir, &self.instance))) { log::warn!("autosave: lock file in {}: {e}", dir.display()); } } + /// Refresh the lock file at `path`, taking the system lock on it first if this app doesn't + /// hold it there yet. Without one the file still works by its age. + fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> { + if let Some((held, file)) = &self.lock + && held == path + { + return file.set_modified(SystemTime::now()); + } + self.lock = None; + let id = std::process::id(); + let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?; + if file.try_lock().is_err() { + return std::fs::write(path, id.to_string()); + } + file.set_len(0)?; + file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?; + self.lock = Some((path.to_path_buf(), file)); + Ok(()) + } + /// The app is closing normally: finish writes in progress, then delete this app's autosave /// files and its lock file. pub fn shutdown(&mut self) { @@ -502,6 +575,7 @@ mod native { discard(&path); } self.docs.clear(); + self.lock = None; let dir = savefilepath(); if !dir.is_empty() && is_instance_id(&self.instance) { let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance)); diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs index f44be5231..32d21fd05 100644 --- a/crates/engine/tests/autosave.rs +++ b/crates/engine/tests/autosave.rs @@ -169,6 +169,7 @@ fn recovery_offers_files_of_apps_that_stopped() { #[test] fn autosave_files_are_written_in_the_background_and_removed_on_exit() { + let _turn = folder_turn(); hooks(); let dir = temp_dir("write"); let mut s = Session::new(); @@ -255,6 +256,68 @@ fn a_new_autosave_folder_is_owner_only() { let _ = std::fs::remove_dir_all(&base); } +/// SAVEFILEPATH is process-wide: the tests that set it take turns. +static FOLDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn folder_turn() -> std::sync::MutexGuard<'static, ()> { + FOLDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Run autosave once in `dir`, so `a` writes a file and takes its lock, and wait for the file. +fn autosave_once(a: &mut Autosaver, s: &Session, dir: &std::path::Path) -> String { + autosave::set_savefilepath(&dir.to_string_lossy()); + assert_eq!(a.save_now(s), 1); + for _ in 0..500 { + a.reap(); + if !a.writing() && !a.files().is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + a.files().pop().unwrap() +} + +#[test] +fn a_crashed_app_is_offered_at_once_and_a_running_one_never() { + hooks(); + let _turn = folder_turn(); + let dir = temp_dir("instant"); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("41-e"); + let file = autosave_once(&mut a, &s, &dir); + let lock = dir.join("41-e.lock"); + assert!(std::fs::metadata(&lock).is_ok()); + + // Running: not offered, even once its lock file looks old (a suspended laptop). + std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now() - Duration::from_secs(3600)).ok(); + assert!(autosave::recoverable(&dir, "9-f").is_empty()); + autosave::prune_locks(&dir); + assert!(lock.exists(), "a running app's lock stays"); + + // Gone without closing, as in a crash: the file is fresh, and it is offered right away. + std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now()).ok(); + drop(a); + let found = autosave::recoverable(&dir, "9-f"); + assert_eq!(found.iter().map(|r| r.path.as_str()).collect::>(), vec![file.as_str()]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_normal_exit_removes_the_lock_file() { + hooks(); + let _turn = folder_turn(); + let dir = temp_dir("exit"); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("42-e"); + autosave_once(&mut a, &s, &dir); + assert!(dir.join("42-e.lock").exists()); + a.shutdown(); + assert!(!dir.join("42-e.lock").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_failed_autosave_is_tried_again_without_another_change() { hooks(); @@ -283,6 +346,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() { std::thread::sleep(Duration::from_millis(10)); } assert!(rearmed, "a failed autosave must be tried again"); + assert!(a.failure().is_some_and(|m| m.contains("out")), "{:?}", a.failure()); std::fs::remove_file(&out).unwrap(); let jobs = a.plan(&s, 180.0, 1, &dir); @@ -303,6 +367,36 @@ fn a_failed_autosave_is_tried_again_without_another_change() { std::thread::sleep(Duration::from_millis(10)); } assert_eq!(a.next_due(1e4, 1), None); + assert_eq!(a.failure(), None, "a write that worked clears the failure"); + a.shutdown(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_failure_ends_when_the_drawing_no_longer_needs_a_copy() { + hooks(); + let root = temp_dir("failed-close"); + let out = root.join("out"); + std::fs::write(&out, b"not a folder").unwrap(); + let dir = out.to_string_lossy().to_string(); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("32-e"); + a.plan(&s, 0.0, 1, &dir); + let jobs = a.plan(&s, 60.0, 1, &dir); + a.perform(jobs); + for _ in 0..500 { + a.reap(); + if a.failure().is_some() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(a.failure().is_some()); + let i = s.active; + s.execute("close", &json!({ "index": i })).unwrap(); + a.plan(&s, 120.0, 1, &dir); + assert_eq!(a.failure(), None); a.shutdown(); let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index 012231683..d09d95770 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -32,6 +32,15 @@ pub fn load_prefs(json: &str) { } } +/// What the autosave failure notice says (see [`failure_notice`]). +#[derive(Clone, Debug, PartialEq)] +pub struct AutosaveNotice { + pub message: String, + /// The user closed it. The failure stays recorded so the notice doesn't come back on every + /// retry; it does for the next failure after a write has worked. + pub dismissed: bool, +} + /// What the GPU error notice says (see [`gpu_notice`]). #[derive(Clone, Debug, PartialEq)] pub struct GpuNotice { @@ -90,6 +99,24 @@ impl CadApp { if let Some(wait) = self.autosave.as_mut().and_then(|s| s.tick(&self.session)) { _ctx.request_repaint_after(wait); } + #[cfg(not(target_arch = "wasm32"))] + self.note_autosave_failure(_ctx); + } + + /// Keep [`Self::autosave_notice`] in step with autosave: a new failure is said once at the + /// command line and shown until it is dismissed or a write works. + #[cfg(not(target_arch = "wasm32"))] + fn note_autosave_failure(&mut self, ctx: &egui::Context) { + let failure = self.autosave.as_ref().and_then(|s| s.failure()).map(str::to_string); + match (failure, &mut self.autosave_notice) { + (None, notice) => *notice = None, + (Some(message), Some(n)) => n.message = message, + (Some(message), notice) => { + self.session.echo(format!("Autosave failed: {message}")); + *notice = Some(AutosaveNotice { message, dismissed: false }); + ctx.request_repaint(); + } + } } /// Every frame: after a GPU error or device loss, autosave unsaved drawings at once (the @@ -242,6 +269,37 @@ pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) { } } +/// The notice that autosave isn't working, so unsaved changes have no recovery copy. It doesn't +/// block the drawing: the user needs to be able to save. +pub fn failure_notice(app: &mut CadApp, ctx: &egui::Context) { + let Some(n) = app.autosave_notice.as_ref().filter(|n| !n.dismissed) else { return }; + let t = Tokens::get(); + let mut dismiss = false; + egui::Window::new("Autosave failed") + .id(egui::Id::unique("cc_autosave_notice")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0)) + .show(ctx, |ui| { + ui.set_width(440.0); + ui.label("CADCraft could not write a recovery copy, so recent changes would be lost if the app stopped now."); + ui.label(RichText::new("Save your work. Autosave tries again after the SAVETIME interval; its folder is in Options.").strong()); + ui.add_space(4.0); + ui.collapsing("Details", |ui| { + ui.label(RichText::new(&n.message).color(t.text_dim).monospace()); + }); + // A row just as tall as the button (a plain right-to-left layout takes the rest). + ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Dismiss").clicked() { + dismiss = true; + } + }); + }); + if dismiss && let Some(n) = &mut app.autosave_notice { + n.dismissed = true; + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 142a589a3..03fa5285b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -305,6 +305,8 @@ pub struct CadApp { pub gpu_faults: std::sync::Arc, /// The notice shown after one (see [`autosave::gpu_notice`]). pub gpu_notice: Option, + /// The notice shown while autosave fails (see [`autosave::failure_notice`]). + pub autosave_notice: Option, /// The Command Aliases dialog while open ([`aliasdlg`]). pub alias_dlg: Option, /// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]). @@ -364,6 +366,7 @@ impl CadApp { recovery: autosave::Recovery::default(), gpu_faults: std::sync::Arc::default(), gpu_notice: None, + autosave_notice: None, alias_dlg: None, hatch_dlg: None, bedit: bedit::State::default(), @@ -903,6 +906,7 @@ impl CadApp { save_loss::prompt(self, ui.ctx()); autosave::show(self, ui.ctx()); autosave::gpu_notice(self, ui.ctx()); + autosave::failure_notice(self, ui.ctx()); self.frame_ms = now_ms() - t0; } diff --git a/crates/ui-egui/tests/autosave_failure.rs b/crates/ui-egui/tests/autosave_failure.rs new file mode 100644 index 000000000..28b6a66d0 --- /dev/null +++ b/crates/ui-egui/tests/autosave_failure.rs @@ -0,0 +1,82 @@ +//! A failed autosave reaches the user: a notice and one command-line message, gone once a write +//! works. SAVEFILEPATH is process-wide, so everything runs in one test. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use cadcraft_ui_egui::{CadApp, Services}; + +fn wait_for(app: &mut CadApp, ctx: &egui::Context, done: impl Fn(&CadApp) -> bool) { + for _ in 0..500 { + app.autosave_tick(ctx); + if done(app) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +/// The text the notice paints (the second frame: a new window sizes itself in the first). +fn painted_text(app: &mut CadApp, ctx: &egui::Context) -> String { + fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(t) => text.push_str(t.galley.text()), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, text)), + _ => {} + } + } + let mut text = String::new(); + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| cadcraft_ui_egui::autosave::failure_notice(app, ui.ctx())); + out.textures_delta.clear(); // no renderer here + text.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut text)); + } + text +} + +#[test] +fn a_failed_autosave_is_shown_until_a_write_works() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let root = std::env::temp_dir().join(format!("cadcraft-autosave-failure-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + // The folder can't be created while a file holds its name. + let dir = root.join("out"); + std::fs::write(&dir, b"not a folder").unwrap(); + cadcraft_engine::autosave::set_savefilepath(&dir.to_string_lossy()); + + let mut app = CadApp::new(Session::new(), Services::default()); + app.enable_autosave(&dir); + let ctx = egui::Context::default(); + app.autosave_tick(&ctx); + assert!(app.autosave_notice.is_none(), "nothing failed yet"); + + app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap(); + app.gpu_faults.error("forces an autosave now".into()); + app.gpu_fault_tick(&ctx); + wait_for(&mut app, &ctx, |a| a.autosave_notice.is_some()); + let n = app.autosave_notice.clone().expect("the failure reaches the notice"); + assert!(!n.dismissed && n.message.contains("out"), "{n:?}"); + let said = |app: &CadApp| app.session.log.iter().filter(|l| l.starts_with("Autosave failed:")).count(); + assert_eq!(said(&app), 1); + assert!(painted_text(&mut app, &ctx).contains("could not write a recovery copy")); + + // Dismissed, it stays away through retries and isn't repeated at the command line. + app.autosave_notice.as_mut().unwrap().dismissed = true; + app.autosave_tick(&ctx); + assert!(app.autosave_notice.as_ref().is_some_and(|n| n.dismissed)); + assert!(painted_text(&mut app, &ctx).is_empty()); + assert_eq!(said(&app), 1); + + // The folder comes back: the next write clears it. + std::fs::remove_file(&dir).unwrap(); + app.run("circle", serde_json::json!({"center": [5, 0], "radius": 1})).unwrap(); + app.gpu_faults.error("forces an autosave now".into()); + app.gpu_fault_tick(&ctx); + wait_for(&mut app, &ctx, |a| a.autosave_notice.is_none()); + assert!(app.autosave_notice.is_none(), "a write that worked ends the notice"); + + app.shutdown_autosave(); + let _ = std::fs::remove_dir_all(&root); +} From 6c4984f5ad05ef0cbd9b42028ce06521c7f29341 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 18:14:29 -0700 Subject: [PATCH 135/151] A panic in the interface saves the work and reports it (E4) (#2671) The frame now runs under a guard. A panic autosaves unsaved drawings at once, even with SAVETIME 0, and shows a notice that doesn't block saving. A panic hook writes a local report for every panic. Panics that keep coming stop the interface and leave a window that closes, with the recovery copies kept. --- apps/cadcraft/src/main.rs | 7 +- crates/engine/src/autosave.rs | 39 ++++- crates/engine/tests/autosave.rs | 20 +++ crates/ui-egui/src/autosave.rs | 5 +- crates/ui-egui/src/fault.rs | 235 ++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/tests/ui_faults.rs | 97 ++++++++++++ 7 files changed, 396 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/fault.rs create mode 100644 crates/ui-egui/tests/ui_faults.rs diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 3454dc35f..4b124ad1d 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -86,7 +86,7 @@ impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.cad.system_animations = self.system_animations.current(); // Consume control/open/screenshot queues and time-based autosave, including hidden ticks. - self.cad.logic(ctx); + self.cad.guarded_logic(ctx); // Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which // never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a // workspace changed while hidden is persisted. `current_pass_index()` is zero on the first @@ -116,7 +116,7 @@ impl eframe::App for App { cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.cad.ui(ui); + self.cad.guarded_ui(ui); #[cfg(target_os = "macos")] craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable); } @@ -289,6 +289,9 @@ fn main() -> eframe::Result { } install_io(); cadcraft_engine::settings_dir::set(vars.config_dir.clone()); + if let Some(dir) = &vars.config_dir { + cadcraft_ui_egui::fault::install_report_hook(dir.join(cadcraft_ui_egui::fault::REPORT_DIR)); + } if let Some(name) = &vars.font_alt { cadcraft_engine::fonts::ttf::set_font_alt(name); } diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 92e97afeb..ca6d6ede4 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -479,16 +479,41 @@ mod native { let (done, running) = std::mem::take(&mut self.running).into_iter().partition::, _>(|(_, h)| h.is_finished()); self.running = running; for (uid, h) in done { - self.busy.remove(&uid); - match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) { - Ok(()) => { - self.failed.remove(&uid); - } - Err(e) => self.unwritten(uid, e), - } + self.settle(uid, h); } } + fn settle(&mut self, uid: u64, h: std::thread::JoinHandle>) { + self.busy.remove(&uid); + match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) { + Ok(()) => { + self.failed.remove(&uid); + } + Err(e) => self.unwritten(uid, e), + } + } + + /// Write every drawing with changes not yet autosaved and wait until it is done, because + /// the app has gone wrong and may not get another frame. Unlike [`Autosaver::save_now`] + /// this also runs when SAVETIME is 0: unsaved work comes before that preference. Returns + /// how many drawings were written. + pub fn emergency_save(&mut self, s: &Session) -> usize { + for (uid, h) in std::mem::take(&mut self.running) { + self.settle(uid, h); + } + let now = self.start.elapsed().as_secs_f64(); + let dir = savefilepath(); + let jobs = self.plan_with(s, now, savetime().max(1), &dir, true); + let uids: Vec = jobs.iter().filter_map(|j| if let Job::Write { uid, .. } = j { Some(*uid) } else { None }).collect(); + self.perform(jobs); + self.last_beat = None; + self.heartbeat(&dir, now); + for (uid, h) in std::mem::take(&mut self.running) { + self.settle(uid, h); + } + uids.iter().filter(|uid| !self.failed.contains_key(uid)).count() + } + fn unwritten(&mut self, uid: u64, why: String) { if let Some(t) = self.docs.get_mut(&uid) { t.written = None; diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs index 32d21fd05..a878faae7 100644 --- a/crates/engine/tests/autosave.rs +++ b/crates/engine/tests/autosave.rs @@ -303,6 +303,26 @@ fn a_crashed_app_is_offered_at_once_and_a_running_one_never() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn an_emergency_save_finishes_before_it_returns_even_with_savetime_off() { + hooks(); + let _turn = folder_turn(); + let dir = temp_dir("emergency"); + autosave::set_savefilepath(&dir.to_string_lossy()); + let before = autosave::savetime(); + autosave::set_savetime(0); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("43-e"); + assert_eq!(a.save_now(&s), 0, "an ordinary save honours SAVETIME 0"); + assert_eq!(a.emergency_save(&s), 1); + autosave::set_savetime(before); + let file = a.files().pop().unwrap(); + assert!(std::fs::read(&file).unwrap().ends_with(b"EOF\n"), "the file is whole on return"); + assert_eq!(a.emergency_save(&s), 0, "nothing changed since"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_normal_exit_removes_the_lock_file() { hooks(); diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index d09d95770..0d50e2dcc 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -134,8 +134,9 @@ impl CadApp { /// The app closes normally: this app's autosave files are not needed any more. pub fn shutdown_autosave(&mut self) { + // After the interface gave up, the recovery copies are all that's left of the work. #[cfg(not(target_arch = "wasm32"))] - if let Some(s) = &mut self.autosave { + if let Some(s) = self.autosave.as_mut().filter(|_| !self.fault.broken) { s.shutdown(); } } @@ -143,7 +144,7 @@ impl CadApp { /// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). Past chrono's year 262143 range the proleptic /// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch. -fn utc(secs: u64) -> String { +pub(crate) fn utc(secs: u64) -> String { let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); let (y, mo, d) = civil_from_days(secs / 86_400); // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. diff --git a/crates/ui-egui/src/fault.rs b/crates/ui-egui/src/fault.rs new file mode 100644 index 000000000..7d087ed23 --- /dev/null +++ b/crates/ui-egui/src/fault.rs @@ -0,0 +1,235 @@ +//! What happens when the interface panics. Commands already run under their own guard +//! (`cadcraft_engine::guard`); this one covers the rest of the frame, so a panic in a panel keeps +//! the user's work: every drawing with unsaved changes is autosaved at once and the app says so. +//! A panic that keeps coming back leaves a plain window that only closes, with the recovery copies +//! kept for the next start. A panic hook writes a report for each, which stays on this machine. +//! None of this helps on the web, where a panic aborts. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use egui::{RichText, vec2}; + +use crate::theme::Tokens; +use crate::{CadApp, now_ms}; + +/// Where crash reports go, under the settings folder. +pub const REPORT_DIR: &str = "crash-reports"; +/// This many panics, each within [`QUIET_MS`] of the last, and the interface is given up on. +const MAX_STRIKES: u32 = 3; +/// A stretch this long without a panic means the app has recovered. +const QUIET_MS: f64 = 5_000.0; + +/// Panics caught around the frame. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct UiFault { + /// The last panic's message, until the user dismisses the notice. + pub message: Option, + /// Drawings autosaved because of panics so far. + pub autosaved: usize, + /// Panics in the current run of them. + pub strikes: u32, + /// When the last one happened. + pub last_ms: f64, + /// The interface keeps panicking: it no longer runs, and recovery copies are kept on exit. + pub broken: bool, +} + +impl CadApp { + /// [`CadApp::logic`] under the panic guard. + pub fn guarded_logic(&mut self, ctx: &egui::Context) { + self.guarded(ctx, |app| app.logic(ctx)); + } + + /// [`CadApp::ui`] under the panic guard, then the notice about any panic. Once the interface + /// has been given up on, only the window that closes the app is drawn. + pub fn guarded_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.guarded(&ctx, |app| app.ui(ui)); + self.show_fault(&ctx); + } + + /// The notice about a caught panic, or the window that closes the app. + pub fn show_fault(&mut self, ctx: &egui::Context) { + if self.fault.broken { + broken_window(self, ctx); + } else { + notice(self, ctx); + } + } + + /// Run `frame`; a panic in it autosaves unsaved drawings instead of ending the app. + pub fn guarded(&mut self, ctx: &egui::Context, frame: impl FnOnce(&mut CadApp)) { + if self.fault.broken { + return; + } + if let Err(p) = catch_unwind(AssertUnwindSafe(|| frame(self))) { + let message = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()); + self.on_panic(ctx, message.unwrap_or_else(|| "panic".into())); + } + } + + fn on_panic(&mut self, ctx: &egui::Context, message: String) { + let now = now_ms(); + let fault = &mut self.fault; + fault.strikes = if now - fault.last_ms < QUIET_MS { fault.strikes + 1 } else { 1 }; + fault.last_ms = now; + fault.broken = fault.strikes >= MAX_STRIKES; + log::error!("the interface panicked: {message}"); + #[cfg(not(target_arch = "wasm32"))] + if let Some(saver) = &mut self.autosave { + let session = &self.session; + // The save is the point of being here: a panic inside it must not end the app either. + self.fault.autosaved += catch_unwind(AssertUnwindSafe(|| saver.emergency_save(session))).unwrap_or(0); + } + self.fault.message = Some(message); + ctx.request_repaint(); + } +} + +/// The notice after a panic. It doesn't block the drawing: the user needs to be able to save. +fn notice(app: &mut CadApp, ctx: &egui::Context) { + let Some(message) = app.fault.message.clone() else { return }; + let t = Tokens::get(); + let mut dismiss = false; + egui::Window::new("Internal error") + .id(egui::Id::unique("cc_fault_notice")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0)) + .show(ctx, |ui| { + ui.set_width(440.0); + ui.label("Part of CADCraft failed, and the app kept running."); + ui.label(saved_text(app.fault.autosaved)); + ui.label(RichText::new("Save your work and restart CADCraft.").strong()); + ui.add_space(4.0); + details(ui, t, &message); + // A row just as tall as the button (a plain right-to-left layout takes the rest). + ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Dismiss").clicked() { + dismiss = true; + } + }); + }); + if dismiss { + app.fault.message = None; + } +} + +/// All that's left once the interface keeps panicking. +fn broken_window(app: &CadApp, ctx: &egui::Context) { + let t = Tokens::get(); + egui::Window::new("CADCraft has to close") + .id(egui::Id::unique("cc_fault_broken")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.set_width(440.0); + ui.label("Part of CADCraft keeps failing, so it can't continue."); + ui.label(saved_text(app.fault.autosaved)); + ui.label(RichText::new("Drawing Recovery offers them at the next start.").strong()); + ui.add_space(4.0); + if let Some(message) = &app.fault.message { + details(ui, t, message); + } + ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Close CADCraft").clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); +} + +fn saved_text(autosaved: usize) -> String { + match autosaved { + 0 => "No drawing needed a new recovery copy.".to_string(), + 1 => "A recovery copy of the drawing with unsaved changes was autosaved.".to_string(), + k => format!("Recovery copies of {k} drawings with unsaved changes were autosaved."), + } +} + +fn details(ui: &mut egui::Ui, t: Tokens, message: &str) { + ui.collapsing("Details", |ui| { + ui.label(RichText::new(message).color(t.text_dim).monospace()); + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = report::last() { + ui.label(RichText::new(format!("Report: {}", path.display())).color(t.text_dim).monospace()); + } + }); +} + +#[cfg(not(target_arch = "wasm32"))] +pub use report::install_report_hook; + +#[cfg(not(target_arch = "wasm32"))] +mod report { + use std::fmt::Write as _; + use std::panic::PanicHookInfo; + use std::path::{Path, PathBuf}; + use std::sync::Mutex; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// Reports one run may write: a panic every frame must not fill the disk. + const MAX_PER_RUN: u32 = 5; + /// Reports kept in the folder. + const KEEP: usize = 20; + + static WRITTEN: AtomicU32 = AtomicU32::new(0); + static LAST: Mutex> = Mutex::new(None); + + /// The report of the latest panic, once one was written. + pub(super) fn last() -> Option { + LAST.lock().ok().and_then(|g| g.clone()) + } + + /// Write a report into `dir` for every panic, on any thread, before the hook already in + /// place runs. Panics that a guard then catches get one too: they are still bugs. A panic that + /// can't be reported goes on as it would have: a report never makes things worse. + pub fn install_report_hook(dir: PathBuf) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if WRITTEN.fetch_add(1, Ordering::Relaxed) < MAX_PER_RUN + && let Ok(path) = write_report(&dir, info) + && let Ok(mut last) = LAST.lock() + { + *last = Some(path); + } + previous(info); + })); + } + + fn write_report(dir: &Path, info: &PanicHookInfo<'_>) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let mut text = String::new(); + let _ = writeln!(text, "CADCraft {} on {} {}", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); + let _ = writeln!(text, "Time: {} UTC", crate::autosave::utc(now.as_secs())); + let _ = writeln!(text, "Thread: {}", std::thread::current().name().unwrap_or("unnamed")); + if let Some(l) = info.location() { + let _ = writeln!(text, "Location: {}:{}:{}", l.file(), l.line(), l.column()); + } + let _ = writeln!(text, "Message: {}", info.payload_as_str().unwrap_or("(not text)")); + let _ = writeln!(text, "\n{}", std::backtrace::Backtrace::force_capture()); + let path = dir.join(format!("crash-{:012x}-{}.txt", now.as_millis(), std::process::id())); + cadcraft_engine::atomic_write::write_atomic(&path, text.as_bytes())?; + prune(dir); + Ok(path) + } + + /// Keep the newest [`KEEP`] reports. Names start with the time, so they sort by age. + fn prune(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut reports: Vec = entries + .flatten() + .take(10_000) + .map(|e| e.path()) + .filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("crash-") && n.ends_with(".txt"))) + .collect(); + reports.sort(); + let extra = reports.len().saturating_sub(KEEP); + for old in reports.drain(..extra) { + let _ = std::fs::remove_file(old); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 03fa5285b..3319e639c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod docking; +pub mod fault; pub mod hatchdlg; mod mtext_editor; mod panel_state; @@ -305,6 +306,8 @@ pub struct CadApp { pub gpu_faults: std::sync::Arc, /// The notice shown after one (see [`autosave::gpu_notice`]). pub gpu_notice: Option, + /// Panics caught around the frame ([`fault`]). + pub fault: fault::UiFault, /// The notice shown while autosave fails (see [`autosave::failure_notice`]). pub autosave_notice: Option, /// The Command Aliases dialog while open ([`aliasdlg`]). @@ -366,6 +369,7 @@ impl CadApp { recovery: autosave::Recovery::default(), gpu_faults: std::sync::Arc::default(), gpu_notice: None, + fault: fault::UiFault::default(), autosave_notice: None, alias_dlg: None, hatch_dlg: None, diff --git a/crates/ui-egui/tests/ui_faults.rs b/crates/ui-egui/tests/ui_faults.rs new file mode 100644 index 000000000..3c382e38f --- /dev/null +++ b/crates/ui-egui/tests/ui_faults.rs @@ -0,0 +1,97 @@ +//! A panic in the interface keeps the user's work: unsaved drawings are autosaved at once, the +//! app says so and writes a report, and a panic that keeps coming back leaves only a way to close +//! with the recovery copies kept. The panic hook is process-wide, so everything runs in one test. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use cadcraft_ui_egui::{CadApp, Services}; + +fn panic_in_a_nested_ui(app: &mut CadApp, ctx: &egui::Context, text: &'static str) -> String { + let mut painted = String::new(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let ctx = ui.ctx().clone(); + app.guarded(&ctx, |_| { + egui::CentralPanel::default().show(ui, |ui| { + ui.vertical(|ui| { + ui.label("before"); + std::panic::panic_any(text); + }); + }); + }); + app.show_fault(ui.ctx()); + }); + out.textures_delta.clear(); // no renderer here + out.shapes.iter().for_each(|s| collect(&s.shape, &mut painted)); + painted +} + +fn painted_text(app: &mut CadApp, ctx: &egui::Context) -> String { + let mut painted = String::new(); + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.show_fault(ui.ctx())); + out.textures_delta.clear(); // no renderer here + painted.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut painted)); + } + painted +} + +fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(t) => { + text.push_str(t.galley.text()); + text.push('\n'); + } + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, text)), + _ => {} + } +} + +fn recovery_copies(dir: &std::path::Path) -> usize { + std::fs::read_dir(dir).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "sv$")).count()).unwrap_or(0) +} + +#[test] +fn a_panic_saves_the_work_and_reports_and_repeated_ones_close_safely() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let root = std::env::temp_dir().join(format!("cadcraft-ui-faults-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let (auto, reports) = (root.join("autosave"), root.join("crash-reports")); + cadcraft_ui_egui::fault::install_report_hook(reports.clone()); + + let mut app = CadApp::new(Session::new(), Services::default()); + app.enable_autosave(&auto); + app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap(); + let ctx = egui::Context::default(); + + // One panic: the drawing has a whole recovery copy by the time the frame goes on. + panic_in_a_nested_ui(&mut app, &ctx, "a bug in a panel"); + assert_eq!(app.fault.message.as_deref(), Some("a bug in a panel")); + assert_eq!((app.fault.autosaved, app.fault.broken), (1, false)); + assert_eq!(recovery_copies(&auto), 1); + let text = painted_text(&mut app, &ctx); + assert!(text.contains("kept running") && text.contains("recovery copy"), "{text}"); + + let report = std::fs::read_dir(&reports).unwrap().flatten().next().expect("a report").path(); + let report = std::fs::read_to_string(report).unwrap(); + assert!(report.contains("Message: a bug in a panel") && report.contains("ui_faults.rs"), "{report}"); + assert!(report.starts_with("CADCraft "), "{report}"); + + // The app still works after it. + app.run("circle", serde_json::json!({"center": [5, 0], "radius": 1})).unwrap(); + + // Panics that keep coming: the interface stops running and only offers to close. + panic_in_a_nested_ui(&mut app, &ctx, "again"); + panic_in_a_nested_ui(&mut app, &ctx, "and again"); + assert!(app.fault.broken); + let mut ran = false; + app.guarded(&ctx, |_| ran = true); + assert!(!ran, "a broken interface isn't run again"); + assert!(painted_text(&mut app, &ctx).contains("Drawing Recovery offers them")); + + // Closing now keeps the copies for the next start. + app.shutdown_autosave(); + assert_eq!(recovery_copies(&auto), 1); + let _ = std::fs::remove_dir_all(&root); +} From 806654aedfea7bd9a7dcc2e978b7a2d46ccacc0d Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 18:24:51 -0700 Subject: [PATCH 136/151] Add a security policy (E33) (#2672) --- SECURITY.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..635ab902e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security policy + +## Reporting a vulnerability + +Please report it privately through [GitHub's private reporting](https://github.com/storytold/cadcraft/security/advisories/new), not in a public issue or pull request. Say which version you ran, on which platform, and how to reproduce it. A small sample file helps, as long as it holds nothing private. + +Maintainers read reports as they arrive, but there is no fixed response time yet. + +## Supported versions + +Only the latest release gets security fixes. CADCraft is pre-alpha and moves quickly, so please check that a problem still exists there before reporting it. + +## What counts + +CADCraft treats these as untrusted, and a way past these defences is a vulnerability: + +- **Drawing files.** Opening a DXF, DWG or other file must not run code, write outside the paths the user chose, or crash the app. Panics are bugs too, but memory exhaustion or a decoder abort on a crafted file is the more serious case. +- **AutoLISP in a drawing's folder.** A drawing must not run code that the user didn't choose to load. +- **The control channel.** It listens on loopback only and needs the session token from an owner-only file on every request. Another local account reaching it without that token is a vulnerability. +- **Temporary and autosave files.** Autosave files are unsaved drawings: another account must not be able to read them or redirect a write through them. +- **Updates.** A release must not be replaceable by someone other than the project. + +## Known limits + +These are known and being worked on, so they don't need a report: + +- Agents connected through MCP or the control channel can read and write any path the user can. There are no workspace roots yet. +- A local process can make the control channel buffer up to 512 MiB before its token is checked. The total is capped, so this can slow the app but not exhaust memory without limit. +- The DWG decoder runs inside the app, so a decoder abort ends every open drawing. Autosave limits what's lost. +- The web build has no autosave yet. + +## What stays on your machine + +Crash reports are written to the `crash-reports` folder in the settings folder and are never uploaded. They hold the version, the platform, the panic message and a stack trace, and a message can include a file path, so read a report before you attach it anywhere. From f6d84e28f31ff98224a2b63b04e5153f4899ae2b Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 18:47:29 -0700 Subject: [PATCH 137/151] DWG reader and writer run in a child process (E7) (#2673) * DWG reader and writer run in a child process (E7) An allocation failure or stack overflow in the third-party code ends its process, which catch_unwind can't stop, so one bad file took every open drawing with it. The app and CLI now run them in a copy of themselves over pipes. A worker that dies or runs past 30 minutes costs that one open or save. If no worker can be started, the work stays in the process, as before. * DWG worker: keep the reply's bytes instead of copying them; skip the test without a bridge --- Cargo.lock | 1 + SECURITY.md | 2 +- apps/cadcraft-cli/src/main.rs | 5 + apps/cadcraft-cli/tests/dwg_worker.rs | 33 +++ apps/cadcraft/src/main.rs | 5 + crates/dwg/Cargo.toml | 3 + crates/dwg/src/lib.rs | 29 ++- crates/dwg/src/worker.rs | 311 ++++++++++++++++++++++++++ crates/io/src/lib.rs | 11 +- 9 files changed, 393 insertions(+), 7 deletions(-) create mode 100644 apps/cadcraft-cli/tests/dwg_worker.rs create mode 100644 crates/dwg/src/worker.rs diff --git a/Cargo.lock b/Cargo.lock index 98d98880f..c2f4ce914 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -691,6 +691,7 @@ name = "cadcraft-dwg" version = "0.17.0" dependencies = [ "acadrust", + "log", ] [[package]] diff --git a/SECURITY.md b/SECURITY.md index 635ab902e..fbad5435b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,7 @@ These are known and being worked on, so they don't need a report: - Agents connected through MCP or the control channel can read and write any path the user can. There are no workspace roots yet. - A local process can make the control channel buffer up to 512 MiB before its token is checked. The total is capped, so this can slow the app but not exhaust memory without limit. -- The DWG decoder runs inside the app, so a decoder abort ends every open drawing. Autosave limits what's lost. +- The DWG worker process has no memory limit of its own yet, so a crafted file can use a lot of memory before it stops. It ends that one open or save, not the app. - The web build has no autosave yet. ## What stays on your machine diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 7561b96b5..866923c9a 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -1,6 +1,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] fn main() { + // A copy of this program started to read or write a DWG file does only that. + if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() { + std::process::exit(code); + } + cadcraft_io::dwg_worker::use_own_worker(); // `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be // reported as an error, not panic before anything is printed. let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr()); diff --git a/apps/cadcraft-cli/tests/dwg_worker.rs b/apps/cadcraft-cli/tests/dwg_worker.rs new file mode 100644 index 000000000..8656b84ff --- /dev/null +++ b/apps/cadcraft-cli/tests/dwg_worker.rs @@ -0,0 +1,33 @@ +//! The DWG reader and writer run in a child process, so a decoder that dies ends one open or save, +//! not the app. The worker setting is process-wide, so everything runs in one test. + +#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))] + +use cadcraft_io::dwg_worker; + +#[test] +fn a_real_worker_reads_and_writes_and_its_death_is_an_error_here() { + let drawing = cadcraft_engine::sample::bracket(); + let in_process = cadcraft_io::write(&drawing, "a.dwg").unwrap(); + + dwg_worker::use_worker(env!("CARGO_BIN_EXE_cadcraft-cli").into(), vec![dwg_worker::WORKER_ARG.into()]); + let written = cadcraft_io::write(&drawing, "a.dwg").unwrap(); + assert_eq!(written.get(..6), in_process.get(..6), "the same version of file"); + let read = cadcraft_io::read(&written, "a.dwg").unwrap(); + assert_eq!(read.model.len(), drawing.model.len()); + + // A decode error comes back as that error, not as the worker stopping. + let mut bad = in_process.clone(); + bad.truncate(64); + let e = cadcraft_io::read(&bad, "a.dwg").unwrap_err().to_string(); + assert!(!e.contains("worker"), "{e}"); + + // The worker aborts as the third-party code can. This process goes on. + let e = dwg_worker::crash_worker_for_test().unwrap_err(); + assert!(e.contains("worker stopped"), "{e}"); + assert!(cadcraft_io::read(&written, "a.dwg").is_ok(), "the next file gets a new worker"); + + // No worker to start: the work happens here, as without one. + dwg_worker::use_worker("no-such-program-here".into(), Vec::new()); + assert!(cadcraft_io::read(&written, "a.dwg").is_ok()); +} diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 4b124ad1d..71700095d 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -258,6 +258,11 @@ fn version_string() -> String { /// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe. fn main() -> eframe::Result { + // A copy of this program started to read or write a DWG file does only that. + if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() { + std::process::exit(code); + } + cadcraft_io::dwg_worker::use_own_worker(); // First, so every start-up record is captured; see `logging`. let logger = logging::install(); // The update helper relaunches this binary after a self-update; it runs and exits here. diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml index acb8d0aa7..23d1c82d2 100644 --- a/crates/dwg/Cargo.toml +++ b/crates/dwg/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true [lints] workspace = true +[dependencies] +log = { workspace = true } + # The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64, # where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname. # 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800). diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index e5463aaec..70646a19a 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -19,10 +19,8 @@ //! crashing. //! //! These bound the input- and output-size variants. A small file that declares a huge internal -//! table or section can still make the dependency allocate before any size is known; fully fixing -//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory -//! budget) or running the decode in a separate worker with an OS memory limit. The limits here -//! keep the blast radius bounded in the meantime. +//! table or section can still make the dependency allocate before any size is known, so the app +//! runs both directions in a child process ([`worker`]): what that kills is the child. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -64,6 +62,11 @@ fn mb(n: u64) -> String { if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") } } +#[cfg(not(no_dwg_bridge))] +pub mod worker; +#[cfg(not(no_dwg_bridge))] +pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, serve_if_asked, use_own_worker, use_worker}; + #[cfg(not(no_dwg_bridge))] mod native { use std::io::{Cursor, Write}; @@ -930,6 +933,24 @@ fn unsupported(version: &str) -> String { #[cfg(no_dwg_bridge)] const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" }; +/// There's no worker without a bridge. +#[cfg(no_dwg_bridge)] +pub const WORKER_ARG: &str = "--dwg-worker"; +#[cfg(no_dwg_bridge)] +pub fn serve_if_asked() -> Option { + None +} +#[cfg(no_dwg_bridge)] +pub fn use_own_worker() {} +#[cfg(no_dwg_bridge)] +pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + dwg_to_dxf_noted(bytes, limits) +} +#[cfg(no_dwg_bridge)] +pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + dxf_to_dwg_as(dxf, version) +} + #[cfg(no_dwg_bridge)] pub fn dwg_to_dxf(_bytes: &[u8]) -> Result, String> { Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) diff --git a/crates/dwg/src/worker.rs b/crates/dwg/src/worker.rs new file mode 100644 index 000000000..5ccf5cf6a --- /dev/null +++ b/crates/dwg/src/worker.rs @@ -0,0 +1,311 @@ +//! The DWG reader and writer in a child process. +//! +//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and +//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says +//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker +//! that reads one request from standard input and writes one reply to standard output. A file that +//! kills the worker then costs that one open or save, and every other open drawing stays safe. +//! +//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`] +//! run in the calling process, as they always did. There's no memory limit on the worker yet: that +//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate. +//! +//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as +//! untrusted input. + +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::sync::mpsc::{RecvTimeoutError, channel}; +use std::time::Duration; + +use super::Limits; + +/// The argument that makes the app a worker. +pub const WORKER_ARG: &str = "--dwg-worker"; +/// Bumped when the messages change: an app updated while running may start a newer copy. +const PROTOCOL: u8 = 1; +const DECODE: u8 = 1; +const ENCODE: u8 = 2; +const CRASH: u8 = 0xff; +/// How long one file may take before the worker is stopped. It can't be interrupted otherwise. +const TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// Most bytes accepted for what a reply carries besides the drawing. +const MAX_NOTES_BYTES: u64 = 64 << 20; +/// Most bytes accepted as a written DWG. +const MAX_DWG_REPLY: u64 = 2 << 30; +/// Most bytes one request holds: the largest input, whichever the direction, and its header. +const MAX_REQUEST: u64 = (2 << 30) + 256; + +struct Worker { + program: PathBuf, + args: Vec, +} + +static WORKER: Mutex> = Mutex::new(None); + +/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`]. +pub fn use_worker(program: PathBuf, args: Vec) { + if let Ok(mut w) = WORKER.lock() { + *w = Some(Worker { program, args }); + } +} + +/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be +/// found, the reader and writer stay in this process. +pub fn use_own_worker() { + match std::env::current_exe() { + Ok(program) => use_worker(program, vec![WORKER_ARG.into()]), + Err(e) => log::warn!("DWG worker: can't find this program: {e}"), + } +} + +/// Serve one request and return the exit code if this process was started as a worker. +pub fn serve_if_asked() -> Option { + (std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock())) +} + +/// [`super::dwg_to_dxf_noted`] in the worker, when there is one. +pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + // Before starting a process, and so the same limit holds with or without one. + if bytes.len() > limits.max_dwg_bytes { + return super::dwg_to_dxf_noted(bytes, limits); + } + let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES); + match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") { + Some(reply) => reply?, + None => super::dwg_to_dxf_noted(bytes, limits), + } +} + +/// [`super::dxf_to_dwg_as`] in the worker, when there is one. +pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + if u8::try_from(version.len()).is_err() { + return Err(super::unsupported(version)); + } + match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") { + Some(reply) => reply?.map(|(dwg, _)| dwg), + None => super::dxf_to_dwg_as(dxf, version), + } +} + +/// Make the worker abort, as the third-party code can: what the tests prove isolation with. +#[doc(hidden)] +pub fn crash_worker_for_test() -> Result<(), String> { + call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ()) +} + +fn request(op: u8, limits: Limits, version: &str) -> Vec { + let mut r = vec![PROTOCOL, op]; + r.extend((limits.max_dwg_bytes as u64).to_le_bytes()); + r.extend((limits.max_dxf_bytes as u64).to_le_bytes()); + r.push(u8::try_from(version.len()).unwrap_or(0)); + r.extend(version.as_bytes()); + r +} + +type Reply = Result<(Vec, Vec), String>; + +/// Run one request in a worker. `None`: none could be started, so the caller does the work. +/// `Err`: the worker went wrong, and `doing` says at what, for the user. +fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option> { + let mut command = { + let worker = WORKER.lock().ok()?; + let worker = worker.as_ref()?; + let mut c = Command::new(&worker.program); + c.args(&worker.args); + c + }; + command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = match command.spawn() { + Ok(c) => c, + Err(e) => { + log::warn!("DWG worker: can't start it: {e}"); + return None; + } + }; + let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + let received = std::thread::scope(|scope| { + // Written beside the read: a worker that replies before it has read everything, or dies, + // must block neither side. A failed write shows up as the reply going wrong. + scope.spawn(move || { + let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload)); + }); + let (tx, rx) = channel(); + scope.spawn(move || { + let mut reply = Vec::new(); + let read = from_child.take(reply_limit).read_to_end(&mut reply); + let _ = tx.send(read.map(|_| reply)); + }); + let received = rx.recv_timeout(TIMEOUT); + if matches!(received, Err(RecvTimeoutError::Timeout)) { + let _ = child.kill(); + } + received + }); + let status = child.wait(); + let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected"); + Some(match (received, status) { + (Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")), + (Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped), + _ => Err(stopped()), + }) +} + +/// Take `n` bytes off the front of `b`. +fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> { + let (head, tail) = b.split_at_checked(n)?; + *b = tail; + Some(head) +} + +fn take_u32(b: &mut &[u8]) -> Option { + let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?; + usize::try_from(u32::from_le_bytes(bytes)).ok() +} + +fn take_u64(b: &mut &[u8]) -> Option { + Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?)) +} + +/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1 +/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most +/// of the memory the app may use: they are kept, not copied. +fn parse_reply(mut reply: Vec) -> Option { + let total = reply.len(); + let mut b = &reply[..]; + match *take(&mut b, 1)?.first()? { + 0 => { + let count = take_u32(&mut b)?; + let mut notes = Vec::new(); + for _ in 0..count { + let n = take_u32(&mut b)?; + notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned()); + } + let n = usize::try_from(take_u64(&mut b)?).ok()?; + if b.len() != n { + return None; + } + reply.drain(..total - n); + Some(Ok((reply, notes))) + } + 1 => Some(Err(String::from_utf8_lossy(b).into_owned())), + _ => None, + } +} + +fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> { + match reply { + Ok((bytes, notes)) => { + out.write_all(&[0])?; + out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?; + for note in ¬es { + out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?; + if u32::try_from(note.len()).is_ok() { + out.write_all(note.as_bytes())?; + } + } + out.write_all(&(bytes.len() as u64).to_le_bytes())?; + out.write_all(&bytes)?; + } + Err(e) => { + out.write_all(&[1])?; + out.write_all(e.as_bytes())?; + } + } + out.flush() +} + +/// What a request asks for, or why it can't be served. +fn handle(mut b: &[u8]) -> Reply { + let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string(); + if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) { + return Err(bad()); + } + let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?; + let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?; + let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?; + match op { + DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }), + ENCODE => super::dxf_to_dwg_as(b, version).map(|dwg| (dwg, Vec::new())), + CRASH => std::process::abort(), + _ => Err(bad()), + } +} + +/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent. +fn serve(input: impl Read, mut output: impl Write) -> i32 { + let mut request = Vec::new(); + let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) { + Ok(_) => handle(&request), + Err(e) => Err(format!("the DWG worker could not read its request: {e}")), + }; + i32::from(write_reply(&mut output, reply).is_err()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wire(reply: Reply) -> Vec { + let mut out = Vec::new(); + write_reply(&mut out, reply).unwrap(); + out + } + + #[test] + fn replies_round_trip() { + let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()])); + assert_eq!(parse_reply(wire(ok.clone())), Some(ok)); + let failed: Reply = Err("DWG: no".into()); + assert_eq!(parse_reply(wire(failed.clone())), Some(failed)); + } + + #[test] + fn a_reply_cut_short_or_made_up_is_refused() { + let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()]))); + for n in 0..whole.len() { + assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}"); + } + assert_eq!(parse_reply(vec![7, 0, 0]), None); + // A note count far beyond what follows ends at the first missing note. + assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None); + assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None); + } + + #[test] + fn a_request_it_cannot_read_is_an_error_not_a_crash() { + for request in [ + &[][..], + &[PROTOCOL + 1, DECODE], + &[PROTOCOL], + &[PROTOCOL, DECODE, 1, 2, 3], + &[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ] { + assert!(handle(request).is_err(), "{request:?}"); + } + } + + #[test] + fn serving_reports_a_decode_failure_as_a_reply() { + let mut request = self::request(DECODE, Limits::DEFAULT, ""); + request.extend(b"AC1032 not really a drawing"); + let mut out = Vec::new(); + assert_eq!(serve(&request[..], &mut out), 0); + assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}"); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index ccfba201e..4863ddb34 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -55,6 +55,13 @@ pub enum IoError { pub type Result = std::result::Result; +/// How a program runs the DWG reader and writer in a child process (`cadcraft_dwg::worker`). +pub mod dwg_worker { + pub use cadcraft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker}; + #[cfg(all(not(target_arch = "wasm32"), not(all(target_os = "freebsd", target_arch = "aarch64"))))] + pub use cadcraft_dwg::{crash_worker_for_test, use_worker}; +} + fn ext(name: impl AsRef) -> String { name.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() } @@ -64,7 +71,7 @@ fn ext(name: impl AsRef) -> String { pub fn read(bytes: &[u8], name: impl AsRef) -> Result { let name = name.as_ref(); if cadcraft_dwg::is_dwg(bytes) { - let (dxf, notes) = cadcraft_dwg::dwg_to_dxf_noted(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?; + let (dxf, notes) = cadcraft_dwg::decode(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?; let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?; d.read_notes = notes; return Ok(d); @@ -106,7 +113,7 @@ pub fn write_with(d: &Drawing, name: impl AsRef, opts: &SaveOptions) -> Re // gradients, so a drawing from an R2000 DXF doesn't lose them by default. Some(Format::Dwg) if !opts.binary => { let version = opts.version.unwrap_or_else(|| version.max(Version::R2004)); - cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format) + cadcraft_dwg::encode_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format) } _ if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{}`", ext(name)))), _ => write_framed(d, name, None), From 44823163063ad33dc9593dfe381f87cdf0b1c629 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 21:16:44 -0500 Subject: [PATCH 138/151] constraints: find the next default parameter name in one pass (#2674) next_name tested up to 100,000 candidate names, each against every parameter and constraint name, so a drawing with many occupied names made adding one dimensional constraint quadratic. Collect the numeric suffixes in use once and take the first free one. --- crates/constraints/src/lib.rs | 20 ++++++++++++++++++-- crates/constraints/src/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 89c6276cd..0bf230323 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -30,7 +30,7 @@ mod lm; mod model; mod residual; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle}; use cadcraft_geom::Vec2; @@ -169,10 +169,26 @@ pub fn name_in_use(d: &Drawing, name: &str) -> bool { d.parametric.parameters.iter().any(|p| p.name == name) || d.constraints.iter().any(|c| c.name == name) } +/// Highest numbered default name [`next_name`] tries. +const MAX_NAME_SUFFIX: u32 = 100_000; + /// Next free parameter name for a dimensional constraint kind (`d1`, `rad2`, `ang1`, …). pub fn next_name(d: &Drawing, kind: ConstraintKind) -> String { let prefix = kind.name_prefix(); - (1..=100_000).map(|i| format!("{prefix}{i}")).find(|n| !name_in_use(d, n)).unwrap_or_else(|| format!("{prefix}_x")) + // Collect the suffixes in use in one pass, then take the first free one: checking every + // candidate against every name is quadratic in the number of names (CWE-407). Only a + // suffix spelled as `format!("{prefix}{i}")` spells it counts (`d07` does not take `d7`). + let taken: HashSet = d + .parametric + .parameters + .iter() + .map(|p| p.name.as_str()) + .chain(d.constraints.iter().map(|c| c.name.as_str())) + .filter_map(|n| n.strip_prefix(prefix)) + .filter(|s| !s.starts_with('0') && s.bytes().all(|b| b.is_ascii_digit())) + .filter_map(|s| s.parse::().ok()) + .collect(); + (1..=MAX_NAME_SUFFIX).find(|i| !taken.contains(i)).map_or_else(|| format!("{prefix}_x"), |i| format!("{prefix}{i}")) } /// Next free constraint id. diff --git a/crates/constraints/src/tests.rs b/crates/constraints/src/tests.rs index 35b73fea2..eadc7a3c2 100644 --- a/crates/constraints/src/tests.rs +++ b/crates/constraints/src/tests.rs @@ -489,3 +489,30 @@ fn arc_solved_with_a_negative_radius_keeps_its_points() { } } } + +/// The default name is the first free numbered one, found in one pass over many names in use. +#[test] +fn next_name_skips_names_in_use() { + let mut d = Drawing::default(); + assert_eq!(next_name(&d, ConstraintKind::Radius), "rad1"); + for i in (1..=20_000).filter(|i| *i != 15_000) { + d.parametric.parameters.push(cadcraft_doc::Parameter { name: format!("d{i}"), expr: "1".into(), description: String::new() }); + } + assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d15000"); + d.parametric.parameters.push(cadcraft_doc::Parameter { name: "d15000".into(), expr: "1".into(), description: String::new() }); + assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d20001"); + // A differently spelled suffix does not occupy the plain name. + let mut d = Drawing::default(); + for name in ["d01", "d+1", "d1x", "D1"] { + d.parametric.parameters.push(cadcraft_doc::Parameter { name: name.into(), expr: "1".into(), description: String::new() }); + } + assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d1"); + d.constraints.push(Constraint { + id: 1, + kind: ConstraintKind::Distance(DistAxis::Aligned), + refs: Vec::new(), + name: "d1".into(), + expr: "1".into(), + }); + assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2"); +} From 0d3b3d21c0d872b1ae6720d75ef4909f3be2e51d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 21:16:51 -0500 Subject: [PATCH 139/151] Regions: test a loop's probe only against loops whose box holds it, within a budget (#2680) Region::renest tested every loop's probe point against every other tessellated loop, scanning all of its edges each time, so the work was the loop count times the total vertex count. It now skips loops whose bounding box does not hold the probe, and stops once MAX_RENEST_EDGE_VISITS edge tests have been spent, leaving the flags unchanged and returning false; UNION, SUBTRACT and INTERSECT then report the regions as too complex to combine, as they already do for too many boundary pairs (CWE-407). --- crates/doc/src/entity/region.rs | 80 +++++++++++++++++++++--- crates/engine/src/cmd/inquiry/regions.rs | 4 +- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/entity/region.rs b/crates/doc/src/entity/region.rs index 0d2594ea8..8c63b43bf 100644 --- a/crates/doc/src/entity/region.rs +++ b/crates/doc/src/entity/region.rs @@ -9,6 +9,9 @@ use super::HatchLoop; /// Most loops one region has (hostile files and edits). pub const MAX_REGION_LOOPS: usize = 10_000; +/// Most polygon edges [`Region::renest`] tests, over all its point-in-loop tests. +pub const MAX_RENEST_EDGE_VISITS: usize = 50_000_000; + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Region { /// Closed loops with bulges; `outer` false for islands. @@ -76,20 +79,49 @@ impl Region { } /// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is - /// an outer loop, odd an island. - pub fn renest(&mut self) { + /// an outer loop, odd an island. `false`, with the flags left as they were, when that takes + /// more than [`MAX_RENEST_EDGE_VISITS`] polygon edge tests. + #[must_use] + pub fn renest(&mut self) -> bool { let polys = self.tessellate(self.fine_tol()); - let depth: Vec = polys + // A loop's probe can only be inside loops whose box holds it: test only those. A box with + // a non-finite corner says nothing (an edge through a non-finite point never counts, so + // such a polygon need not be closed), so its loop is always tested. + let boxes: Vec> = polys .iter() - .enumerate() - .map(|(i, l)| { - let Some(p) = probe(l) else { return 0 }; - polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count() + .map(|l| { + let b = Bounds2::from_points(l.iter().copied()); + (l.iter().all(|p| p.is_finite()) && b.min.is_finite() && b.max.is_finite()).then_some(b) }) .collect(); + let mut visits = 0usize; + let mut depth = Vec::with_capacity(polys.len()); + for (i, l) in polys.iter().enumerate() { + let Some(p) = probe(l) else { + depth.push(0); + continue; + }; + let mut d = 0; + for (j, (o, b)) in polys.iter().zip(&boxes).enumerate() { + if j == i || b.is_some_and(|b| !b.contains(p)) { + continue; + } + // Each loop against every other is loops x vertices: bounded so a region of many + // large nested loops cannot hold the thread (CWE-407). + visits = visits.saturating_add(o.len()); + if visits > MAX_RENEST_EDGE_VISITS { + return false; + } + if point_in_polygon(o, p) { + d += 1; + } + } + depth.push(d); + } for (l, d) in self.loops.iter_mut().zip(depth) { l.outer = d % 2 == 0; } + true } /// Area, perimeter, bounding box, centroid and second moments (outer loops counted @@ -128,3 +160,37 @@ fn probe(l: &[Vec2]) -> Option { (a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite()) }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn square(c: f64, half: f64, n: usize, outer: bool) -> HatchLoop { + // `n` vertices along the bottom edge, then the other three corners. + let mut vertices: Vec = + (0..n).map(|k| PolyVertex::new(Vec2::new(c - half + 2.0 * half * k as f64 / n as f64, c - half))).collect(); + vertices.extend([Vec2::new(c + half, c - half), Vec2::new(c + half, c + half), Vec2::new(c - half, c + half)].map(PolyVertex::new)); + HatchLoop { vertices, outer, sources: Vec::new() } + } + + #[test] + fn renest_sets_depth_parity_and_skips_loops_far_away() { + // Three nested squares, and one beside them. + let mut r = + Region { loops: vec![square(0.0, 3.0, 1, false), square(0.0, 2.0, 1, false), square(0.0, 1.0, 1, false), square(10.0, 1.0, 1, false)] }; + assert!(r.renest()); + assert_eq!(r.loops.iter().map(|l| l.outer).collect::>(), [true, false, true, true]); + } + + #[test] + fn renest_of_many_large_nested_loops_stops_at_its_budget() { + // Concentric loops of many vertices each: each probe is inside the box of every larger + // loop (about loops² / 2 pairs), so each of those tests is a full polygon scan. + let loops = 300; + let n = 3 * MAX_RENEST_EDGE_VISITS / (loops * loops); + let mut r = Region { loops: (0..loops).map(|i| square(0.0, 1.0 + i as f64, n, false)).collect() }; + let before = r.clone(); + assert!(!r.renest()); + assert_eq!(r, before, "flags left as they were"); + } +} diff --git a/crates/engine/src/cmd/inquiry/regions.rs b/crates/engine/src/cmd/inquiry/regions.rs index 9e6b11a49..033d6e0c2 100644 --- a/crates/engine/src/cmd/inquiry/regions.rs +++ b/crates/engine/src/cmd/inquiry/regions.rs @@ -128,7 +128,9 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result> { } loops.retain(|l| l.vertices.len() >= 2); let mut r = Region { loops }; - r.renest(); + if !r.renest() { + return Err(other("These regions are too complex to combine.")); + } Ok(Some(r)) } From 997f152cea9e3ece7f62f0cf19a866f8e03d706f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 21:16:56 -0500 Subject: [PATCH 140/151] Extents: share block-bounds memoization across all the entities measured (#2681) Each top-level bounds call started with an empty block cache, so measuring a space's extents or building a spatial-index chunk measured a block definition again for every reference to it: R references to a block of B entities cost R x B. BlockBoundsCache keeps the per-depth block bounds (separately for drawn bounds and extents, which differ on layers turned off) across one bulk operation; Drawing::extents and the spatial index use it (CWE-407). --- crates/doc/src/extents.rs | 82 +++++++++++++++++++++++++++++++++--- crates/doc/src/lib.rs | 7 ++- crates/engine/src/spatial.rs | 8 ++-- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index d87cdc9a2..e44809df4 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -94,25 +94,48 @@ fn mtext_box(t: &crate::MText) -> Bounds2 { /// The box of what `e` draws (block contents on layers turned off left out). pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { - bounds_of(d, e, depth, false) + bounds_of(d, e, depth, false, &mut HashMap::new()) } /// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer /// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not). pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 { - bounds_of(d, e, 0, true) + bounds_of(d, e, 0, true, &mut HashMap::new()) } -fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 { +/// The bounds of block contents measured so far, kept across the entities of one unchanged +/// drawing. Measuring many entities with [`entity_bounds_cached`] or [`extent_bounds_cached`] +/// and one cache measures each block definition once per depth for all of them; a fresh cache per +/// entity would measure a block again for every reference to it, R references to a block of B +/// entities costing R x B (CWE-407). Use a cache only while the drawing it measured is unchanged. +#[derive(Default)] +pub struct BlockBoundsCache<'a> { + /// By block name and depth, block contents on layers turned off left out ([`entity_bounds`]). + drawn: HashMap<(&'a str, usize), Bounds2>, + /// The same with those contents counted ([`extent_bounds`]). + extents: HashMap<(&'a str, usize), Bounds2>, +} + +/// [`entity_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`. +pub fn entity_bounds_cached<'a>(d: &'a Drawing, e: &Entity, depth: usize, cache: &mut BlockBoundsCache<'a>) -> Bounds2 { + bounds_of(d, e, depth, false, &mut cache.drawn) +} + +/// [`extent_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`. +pub fn extent_bounds_cached<'a>(d: &'a Drawing, e: &Entity, cache: &mut BlockBoundsCache<'a>) -> Bounds2 { + bounds_of(d, e, 0, true, &mut cache.extents) +} + +fn bounds_of<'a>(d: &'a Drawing, e: &Entity, depth: usize, off_counts: bool, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 { // A top-level annotative object measures as it draws at the current annotation scale. if depth == 0 { match d.anno_draw(e) { crate::AnnoDraw::Plain => {} crate::AnnoDraw::Hidden => return Bounds2::EMPTY, - crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts), + crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, blocks, off_counts), } } - bounds_in(d, e, depth, &mut HashMap::new(), off_counts) + bounds_in(d, e, depth, blocks, off_counts) } /// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed, @@ -442,4 +465,53 @@ mod tests { empty.text = Some(crate::MText { contents: " ".into(), ..mtext }); assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0); } + + #[test] + fn a_shared_block_cache_measures_as_a_fresh_one_for_each_entity() { + let line = |a: (f64, f64), b: (f64, f64), layer: &str| { + let mut e = Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })); + e.common.layer = layer.into(); + e + }; + let ins = |block: &str, at: f64| { + Entity::new( + crate::Handle(2), + EntityKind::Insert(crate::Insert { + block: block.into(), + insert: Vec3::new(at, at, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }), + ) + }; + let mut d = Drawing::new_imperial(); + d.ensure_layer("OFF"); + if let Some(l) = d.layer_mut("OFF") { + l.on = false; + } + // A block with a part on a layer turned off (left out when drawn, counted in extents), + // and a block that nests it. + let mut b = crate::Block::new("B"); + b.entities.push(line((0.0, 0.0), (1.0, 1.0), "0")); + b.entities.push(line((0.0, 0.0), (5.0, 5.0), "OFF")); + d.blocks.insert("B".into(), std::sync::Arc::new(b)); + let mut n = crate::Block::new("N"); + n.entities.push(ins("B", 10.0)); + d.blocks.insert("N".into(), std::sync::Arc::new(n)); + let ents: Vec = (0..50).map(|i| ins(if i % 2 == 0 { "B" } else { "N" }, f64::from(i))).collect(); + // One cache for both kinds of measure, interleaved: neither may answer for the other. + let mut cache = BlockBoundsCache::default(); + for e in &ents { + let (drawn, extents) = (entity_bounds_cached(&d, e, 0, &mut cache), extent_bounds_cached(&d, e, &mut cache)); + let (want_drawn, want_extents) = (entity_bounds(&d, e, 0), extent_bounds(&d, e)); + assert_eq!((drawn.min, drawn.max), (want_drawn.min, want_drawn.max)); + assert_eq!((extents.min, extents.max), (want_extents.min, want_extents.max)); + assert!(extents.width() > drawn.width()); + } + } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8f2a1e2e4..19f486240 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -27,7 +27,7 @@ pub use cadcraft_color as color; pub use cadcraft_geom as geom; pub use constraint::*; pub use entity::*; -pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds}; +pub use extents::{BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached}; pub use format::Format; pub use gdt::*; pub use header::{HVal, Header}; @@ -383,10 +383,13 @@ impl Drawing { /// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]). pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 { let mut b = cadcraft_geom::Bounds2::EMPTY; + // One block-bounds cache for the whole space: every reference to a block reuses its + // measured contents instead of measuring the definition again. + let mut cache = BlockBoundsCache::default(); if let Some(store) = self.space(space) { for e in store.iter() { if self.in_extents(e) { - b = b.union(&extent_bounds(self, e)); + b = b.union(&extent_bounds_cached(self, e, &mut cache)); } } } diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index dd2e0d593..a62537262 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -15,7 +15,7 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; -use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds}; +use cadcraft_doc::{Block, BlockBoundsCache, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds_cached}; use cadcraft_geom::Bounds2; use rstar::primitives::{GeomWithData, Rectangle}; use rstar::{AABB, RTree}; @@ -44,6 +44,8 @@ struct ChunkIx { impl ChunkIx { fn build(d: &Drawing, key: usize, items: &[Arc]) -> ChunkIx { let mut infinite = Vec::new(); + // One block-bounds cache for the chunk: references to the same block measure it once. + let mut cache = BlockBoundsCache::default(); let bounds = items .iter() .enumerate() @@ -51,7 +53,7 @@ impl ChunkIx { if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) { infinite.push(u32::try_from(i).unwrap_or(u32::MAX)); } - entity_bounds(d, e, 0) + entity_bounds_cached(d, e, 0, &mut cache) }) .collect(); let serial = NEXT_SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -200,7 +202,7 @@ impl SpatialIndex { let (c, l) = self.locate(i)?; c.ents.get(l) } - /// The bounds of the entity at draw index `i` (as [`entity_bounds`] computes them). + /// The bounds of the entity at draw index `i` (as [`cadcraft_doc::entity_bounds`] computes them). pub fn bounds(&self, i: u32) -> Bounds2 { self.locate(i).and_then(|(c, l)| c.bounds.get(l).copied()).unwrap_or(Bounds2::EMPTY) } From c956baa3493743b83c2a389869e0308cd7130f87 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 21:17:23 -0500 Subject: [PATCH 141/151] constraints: share geometry preparation across an operation and budget spline rebuilds (#2677) Endpoint residuals rebuilt a fit spline through a dense interpolation solve on every evaluation, so many Tangent/Smooth constraints on one spline, or numerical differentiation along other objects' parameters, repeated identical O(n^3) rebuilds; solving had no budget for them. purge_invalid built a fresh model per constraint, re-sampling the same block for every reference, and an Insert naming no block scanned every block definition case-insensitively. - Models built for one operation share a Prep: block samples are taken once per block (in block coordinates, transformed per Insert), block names resolve through a lazily built case-insensitive index, and the last rebuild of each spline is reused at the same defining points. - Comp::run and the Fix redundancy check refuse a system whose per-iteration residual work, including spline rebuilds, exceeds the limit DOF analysis already applied (MAX_EVAL_WORK). - Spline templates keep only what a rebuild reads: no fit points for a control spline, no control net for a fit spline. - purge_invalid validates every constraint with one Prep. --- crates/constraints/src/dof.rs | 44 +++++- crates/constraints/src/lib.rs | 65 ++++++-- crates/constraints/src/model.rs | 255 ++++++++++++++++++++++++-------- 3 files changed, 285 insertions(+), 79 deletions(-) diff --git a/crates/constraints/src/dof.rs b/crates/constraints/src/dof.rs index 559452223..e6128665c 100644 --- a/crates/constraints/src/dof.rs +++ b/crates/constraints/src/dof.rs @@ -11,16 +11,13 @@ use std::collections::HashSet; use cadcraft_doc::{Constraint, Drawing, Handle}; use serde::Serialize; -use crate::model::Model; -use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm}; +use crate::model::{Model, Prep}; +use crate::{Comp, MAX_CONSTRAINTS, MAX_EVAL_WORK, SolveError, SolveOptions, build_with, lm}; /// Most free parameters for which per-object freedom is analysed (null-space basis size). const MAX_NULL_COLS: usize = 600; /// Largest Jacobian (rows × columns × min) whose rank is computed. const MAX_RANK_WORK: f64 = 4e8; -/// Largest residual-evaluation work (rows × free × per-residual cost, including spline -/// reconstruction) allowed before building the Jacobian during numerical differentiation. -const MAX_EVAL_WORK: f64 = 2e9; /// Most objects listed in one report. const MAX_OBJECTS: usize = 10_000; @@ -139,8 +136,8 @@ fn plural(n: usize, one: &str, many: &str) -> String { /// (constrained or not) and the constraint components they belong to. pub fn dof(d: &Drawing, handles: &[Handle]) -> Result { let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect(); - let mut members_left = crate::model::MAX_RIGID_WORK; - let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?; + let mut prep = Prep::new(crate::model::MAX_RIGID_WORK); + let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?; let want = |h: Handle| handles.is_empty() || handles.contains(&h); let mut objects = Vec::new(); let mut seen = HashSet::new(); @@ -179,7 +176,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result { if seen.contains(&h) { continue; } - let m = Model::build_budgeted(d, &[h], &mut members_left); + let m = Model::build_with(d, &[h], &mut prep); let Some(s) = m.slots.first() else { continue }; seen.insert(h); free_objects = true; @@ -237,5 +234,36 @@ mod budget_tests { }; let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 }; assert!(analyse(&comp).dof.is_none()); + // Solving it is refused for the same reason, before any Jacobian is built. + assert!(matches!(comp.run(None), Err(SolveError::TooLarge))); + } + + /// Many Tangent constraints on one large fit spline rebuild it once while being interpreted, + /// and the solve is refused by its rebuild budget instead of running. + #[test] + fn repeated_spline_tangents_rebuild_once_and_solve_is_bounded() { + use cadcraft_doc::{Constraint, Line}; + use cadcraft_geom::Vec3; + let mut d = Drawing::default(); + let pts: Vec<_> = (0..400).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect(); + let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(Spline::fit_with(&pts, false, Default::default()))).unwrap(); + let l = + d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-1.0, 0.0, 0.0), b: Vec3::new(-1.0, 5.0, 0.0) })).unwrap(); + for id in 1..=200 { + d.constraints.push(Constraint { + id, + kind: ConstraintKind::Tangent, + refs: vec![GeomRef::whole(h), GeomRef::whole(l)], + name: String::new(), + expr: String::new(), + }); + } + let cons: Vec<&Constraint> = d.constraints.iter().collect(); + let (comps, skipped) = crate::build(&d, &cons, &SolveOptions::default()).unwrap(); + assert!(skipped.is_empty()); + let comp = comps.first().unwrap(); + assert_eq!(comp.built.len(), 200); + assert!(matches!(comp.run(None), Err(SolveError::TooLarge))); + assert!(crate::purge_invalid(&mut d).is_empty()); } } diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 0bf230323..5b01fc022 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -41,7 +41,7 @@ pub use dof::{DofReport, DofStatus, ObjectDof, dof}; pub use expr::{Env, ExprError}; pub use model::{ref_points, rigid_points, supported}; -use model::Model; +use model::{Model, Prep}; use residual::{Built, Form}; /// Most constraints one drawing may hold. @@ -50,6 +50,11 @@ pub const MAX_CONSTRAINTS: usize = 5000; const MAX_CONFLICT_PROBES: usize = 48; /// Solver iterations. const MAX_ITER: usize = 200; +/// Largest residual-evaluation work (including spline rebuilds) one numerical Jacobian may take, +/// shared by solving, redundancy checks and DOF analysis. +pub(crate) const MAX_EVAL_WORK: f64 = 2e9; +/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps). +const TRIAL_EVALS: f64 = 14.0; #[derive(Clone, Debug, PartialEq, thiserror::Error)] pub enum SolveError { @@ -267,12 +272,41 @@ impl Comp { pub(crate) fn rows(&self, skip: Option) -> usize { self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum() } + /// Work of one solver iteration's residual evaluations over `free`: every residual twice per + /// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds + /// are shared by residuals at the same points, see [`Model`]). + pub(crate) fn eval_work(&self, free: &[usize], skip: Option) -> f64 { + let rows = self.rows(skip) as f64; + let evals = 2.0 * free.len() as f64 + TRIAL_EVALS; + let mut splines: Vec = Vec::new(); + for (i, b) in self.built.iter().enumerate() { + if Some(i) != skip && b.spline_reconstruct_cost(&self.model) > 0.0 { + splines.extend(b.refs.iter().map(|r| r.handle).filter(|h| self.model.spline_reconstruct_cost(*h) > 0.0)); + } + } + splines.sort_unstable(); + splines.dedup(); + let rebuilds: f64 = splines + .iter() + .filter_map(|h| self.model.slot(*h)) + .map(|s| { + let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64; + (2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle) + }) + .sum(); + evals * rows + rebuilds + } fn run(&self, skip: Option) -> Result { let free = self.free(skip); let rows = self.rows(skip); if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 { return Err(SolveError::TooLarge); } + // Numerical differentiation rebuilds a fit spline through a dense solve for every move of + // its own fit points: refuse before iterating when that alone is too much (CWE-407). + if self.eval_work(&free, skip) > MAX_EVAL_WORK { + return Err(SolveError::TooLarge); + } let f = |x: &[f64], out: &mut Vec| self.eval(x, out, skip); let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER); // A solution the ellipses would be stored differently from (axes swapped, minor axis @@ -426,24 +460,24 @@ fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> { /// Build the component systems. Constraints that cannot be interpreted are returned as skipped. pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec, Vec<(u32, String)>), SolveError> { - let mut members_left = model::MAX_RIGID_WORK; - build_budgeted(d, cons, opts, &mut members_left) + build_with(d, cons, opts, &mut Prep::new(model::MAX_RIGID_WORK)) } -/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model. +/// Build constraint components sharing one caller-owned geometry preparation (block sampling and +/// its member budget, spline rebuilds) between every model. /// Return constraints that cannot be interpreted as skipped entries. -pub(crate) fn build_budgeted( +pub(crate) fn build_with( d: &Drawing, cons: &[&Constraint], opts: &SolveOptions, - members_left: &mut usize, + prep: &mut Prep, ) -> Result<(Vec, Vec<(u32, String)>), SolveError> { let mut e = env(d); let mut skipped = Vec::new(); let mut comps = Vec::new(); for group in components(cons) { let handles: Vec = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); - let mut model = Model::build_budgeted(d, &handles, members_left); + let mut model = Model::build_with(d, &handles, prep); let mut built = Vec::new(); for c in group { let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 }; @@ -656,6 +690,11 @@ pub fn all_satisfied(d: &Drawing) -> bool { /// Check that a constraint's references make sense for its type. pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> { + validate_with(d, c, &mut Prep::new(model::MAX_RIGID_WORK)) +} + +/// [`validate`] sharing block samples and spline rebuilds with other checks of the same drawing. +fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), String> { let handles: Vec = c.refs.iter().map(|r| r.handle).collect(); for h in &handles { match d.entity(*h) { @@ -664,7 +703,7 @@ pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> { _ => {} } } - let mut model = Model::build(d, &handles); + let mut model = Model::build_with(d, &handles, prep); interpret(c, &mut model, 1.0).map(|_| ()) } @@ -681,7 +720,12 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result Vec { let mut gone = Vec::new(); - let keep: Vec = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect(); + // One preparation for every check: each block is sampled and each spline rebuilt once, + // however many constraints refer to them (CWE-407). Its member budget is not shared, so + // a constraint is never dropped because earlier ones spent it: every block is still + // examined at most once, up to its own member limit. + let mut prep = Prep::new(usize::MAX); + let keep: Vec = d.constraints.iter().map(|c| validate_with(d, c, &mut prep).is_ok()).collect(); let mut i = 0; d.constraints.retain(|c| { let k = keep.get(i).copied().unwrap_or(true); @@ -768,6 +812,9 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result MAX_EVAL_WORK { + return Err(SolveError::TooLarge); + } let after = comp.free(None); let locked: Vec = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect(); let rows_old = comp.rows(Some(pos)); diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index d18646c32..f9ff51fe5 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -14,9 +14,11 @@ //! Constraints may append auxiliary unknowns after the entity parameters (contact parameters of //! a tangency between curves); they are never written back. +use std::cell::RefCell; use std::collections::HashMap; +use std::rc::Rc; -use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Insert, Sub}; +use cadcraft_doc::{Block, Drawing, EntityKind, GeomRef, Handle, Insert, Sub}; use cadcraft_geom::{Spline as GSpline, Vec2, ccw_sweep}; use crate::curves::{Ell, Frame, clamped, spline_end}; @@ -70,6 +72,9 @@ pub(crate) struct Slot { pub len: usize, } +/// The spline last rebuilt for each handle, with the defining points it was rebuilt from. +type SplineCache = HashMap, Rc)>; + #[derive(Clone, Debug, Default)] pub(crate) struct Model { pub slots: Vec, @@ -79,20 +84,75 @@ pub(crate) struct Model { splines: HashMap, /// Block reference rigid points relative to the insertion point at rotation 0. rigid: HashMap>, + /// Rebuilt splines, shared with every model of the same [`Prep`]: residuals that ask for the + /// same spline at the same defining points (several constraints on one spline, or derivatives + /// taken along other objects' parameters) reuse one rebuild instead of a dense solve each. + rebuilt: Rc>, +} + +/// Geometry preparation shared by every model built for one operation (a solve, a DOF report, a +/// purge): rigid samples per block, a case-insensitive block-name index and rebuilt splines. A +/// block referenced by many Inserts is sampled once, an unknown block name costs one hashed +/// lookup, and a spline referenced by many constraints is rebuilt once (CWE-407). +#[derive(Debug)] +pub(crate) struct Prep { + /// Block members that may still be examined across the operation. + members_left: usize, + /// Block-local rigid samples by the name an Insert gives (`None`: no such block). + blocks: HashMap>>, + /// Lower-cased block name to the stored name it resolves to, built on the first exact miss. + folded: Option>, + rebuilt: Rc>, +} + +impl Prep { + /// Preparation that may examine at most `members` block members in all. + pub(crate) fn new(members: usize) -> Prep { + Prep { members_left: members, blocks: HashMap::new(), folded: None, rebuilt: Rc::default() } + } + + /// The block an Insert names, as [`Drawing::block`] resolves it (exact name, else the first + /// stored name equal ignoring ASCII case), without scanning every block on each miss. + fn resolve<'d>(&mut self, d: &'d Drawing, name: &str) -> Option<&'d Block> { + if let Some(b) = d.blocks.get(name) { + return Some(b); + } + let folded = self.folded.get_or_insert_with(|| { + let mut m = HashMap::with_capacity(d.blocks.len()); + for k in d.blocks.keys() { + m.entry(k.to_ascii_lowercase()).or_insert_with(|| k.clone()); + } + m + }); + d.blocks.get(folded.get(&name.to_ascii_lowercase())?).map(|b| &**b) + } + + /// Finite characteristic points of a block in its own coordinates, sampled once per name. + fn block_points(&mut self, d: &Drawing, name: &str) -> Option> { + if let Some(s) = self.blocks.get(name) { + return s.clone(); + } + let sampled = self.resolve(d, name).map(|b| sample_block(b, &mut self.members_left)); + self.blocks.insert(name.to_string(), sampled.clone()); + sampled + } + + /// World positions of an Insert's rigid points, at most [`MAX_RIGID_POINTS`]. + fn rigid_points(&mut self, d: &Drawing, ins: &Insert) -> Vec { + let Some(local) = self.block_points(d, &ins.block) else { return Vec::new() }; + let Some(block) = self.resolve(d, &ins.block) else { return Vec::new() }; + let m = ins.transform(block.base.xy()); + local.iter().map(|p| m.apply(*p)).filter(|p| p.is_finite()).take(MAX_RIGID_POINTS).collect() + } } impl Model { /// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation /// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template. pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 { - let Some(s) = self.splines.get(&handle) else { return 0.0 }; - let n = s.fit.len() as f64; - if fit_mode(s) { - n * n * n - } else { - let n = s.control.len() as f64; - n * n - } + let Some(Shape::Spline { n, fit, .. }) = self.slot(handle).map(|s| s.shape) else { return 0.0 }; + let n = n as f64; + if fit { n * n * n } else { n * n } } } @@ -115,23 +175,18 @@ pub fn supported(kind: &EntityKind) -> bool { /// centres, vertices, nested insertion points) of the block's own objects, at most /// [`MAX_RIGID_POINTS`]. Nested blocks are not entered. pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec { - let mut members_left = MAX_RIGID_MEMBERS; - rigid_points_budgeted(d, ins, &mut members_left) + Prep::new(MAX_RIGID_MEMBERS).rigid_points(d, ins) } -/// Sample finite block points within per-insert and caller-shared member limits. -/// Members that contribute no points still consume traversal work; nested blocks are not entered. -fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec { - let Some(block) = d.block(&ins.block) else { return Vec::new() }; - let m = ins.transform(block.base.xy()); +/// Sample a block's finite characteristic points in block coordinates, within the per-block +/// member limit and the caller-shared `members_left`. Members that contribute no points still +/// consume traversal work; nested blocks are not entered. +fn sample_block(block: &Block, members_left: &mut usize) -> Rc<[Vec2]> { let mut out = Vec::new(); // Bound the members examined, not only the points collected: entities that contribute no - // points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for - // every Insert — would otherwise be unbounded (CWE-407). - let mut examined = 0usize; - for e in block.entities.iter() { - examined += 1; - if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 { + // points (Text, Ray, …) still cost traversal (CWE-407). + for (examined, e) in block.entities.iter().enumerate() { + if out.len() >= MAX_RIGID_POINTS || examined >= MAX_RIGID_MEMBERS || *members_left == 0 { break; } *members_left -= 1; @@ -148,10 +203,10 @@ fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> EntityKind::LwPolyline(p) => p.vertices.iter().take(MAX_RIGID_POINTS).map(|v| v.p).collect(), _ => Vec::new(), }; - out.extend(pts.into_iter().map(|p| m.apply(p)).filter(|p| p.is_finite())); + out.extend(pts.into_iter().filter(|p| p.is_finite())); } out.truncate(MAX_RIGID_POINTS); - out + out.into() } /// Constraint points of an object as the solver sees them (`Sub`, position): end points, @@ -187,13 +242,12 @@ fn fit_mode(s: &GSpline) -> bool { impl Model { /// Build a model for the given entities. Unsupported or missing entities are skipped. pub fn build(d: &Drawing, handles: &[Handle]) -> Model { - let mut members_left = MAX_RIGID_WORK; - Self::build_budgeted(d, handles, &mut members_left) + Self::build_with(d, handles, &mut Prep::new(MAX_RIGID_WORK)) } /// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is /// left out (too large, degenerate or unsupported). - fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option { + fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option { let shape = match kind { EntityKind::Line(l) => { m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]); @@ -243,15 +297,22 @@ impl Model { // fit point; a control polygon's ends are its first and last points when the // knots are clamped. let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true))); - // Missing weights default to 1 in eval; only excess weights are unused. - let mut kept = sp.clone(); + // Keep only what rebuilding reads, so each rebuild copies no unused data: missing + // weights default to 1 in eval and only excess weights are unused; a control + // spline's fit points are dropped when its control points move (write_back), and + // a fit spline is rebuilt from its parameters, not from its old control net. + let mut kept = if fit { + GSpline { fit_opts: sp.fit_opts, closed: sp.closed, ..GSpline::default() } + } else { + GSpline { fit: Vec::new(), ..sp.clone() } + }; kept.weights.truncate(kept.control.len()); m.splines.insert(h, kept); Shape::Spline { n, fit, ends } } EntityKind::Insert(ins) => { let base = Vec2::new(ins.insert.x, ins.insert.y); - let pts: Vec = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); + let pts: Vec = prep.rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]); let n = pts.len(); m.rigid.insert(h, pts); @@ -262,16 +323,17 @@ impl Model { Some(shape) } - /// Build solver parameters for supported entities while sharing rigid-member sampling work with other models. - pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model { - let mut m = Model::default(); + /// Build solver parameters for supported entities, sharing block sampling, its member budget + /// and spline rebuilds with every other model built from `prep`. + pub(crate) fn build_with(d: &Drawing, handles: &[Handle], prep: &mut Prep) -> Model { + let mut m = Model { rebuilt: Rc::clone(&prep.rebuilt), ..Model::default() }; for &h in handles { if m.index.contains_key(&h) { continue; } let Some(e) = d.entity(h) else { continue }; let off = m.x0.len(); - let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue }; + let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, prep) else { continue }; if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) { m.x0.truncate(off); continue; @@ -373,18 +435,20 @@ impl Model { self.slot(r.handle).is_some_and(|s| matches!(s.shape, Shape::Spline { .. })) } - /// The spline of a reference with the defining points taken from `x`. - fn spline(&self, x: &[f64], s: &Slot) -> Option { + /// The spline of a reference with the defining points taken from `x`. The last rebuild of + /// each spline is kept, so asking again at the same points costs a comparison, not a rebuild. + fn spline(&self, x: &[f64], s: &Slot) -> Option> { let Shape::Spline { n, fit, .. } = s.shape else { return None }; let t = self.splines.get(&s.handle)?; let pts = (0..n).map(|i| p2(x, s.off + 2 * i)).collect::>>()?; - Some(if fit { - t.refit(&pts, t.closed) - } else { - let mut sp = t.clone(); - sp.control = pts; - sp - }) + if let Some((at, sp)) = self.rebuilt.borrow().get(&s.handle) + && *at == pts + { + return Some(Rc::clone(sp)); + } + let sp = Rc::new(if fit { t.refit(&pts, t.closed) } else { GSpline { control: pts.clone(), ..t.clone() } }); + self.rebuilt.borrow_mut().insert(s.handle, (pts, Rc::clone(&sp))); + Some(sp) } /// The start (`at_end` false) or end of a curve reference, in the curve's own direction: @@ -421,7 +485,7 @@ impl Model { let e = self.ell_at(x, o, true)?; e.frame(if at_end { e.t1 } else { e.t0 }) } - (Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&self.spline(x, s)?, at_end), + (Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&*self.spline(x, s)?, at_end), _ => None, } } @@ -590,7 +654,8 @@ impl Model { } let shape = s.shape; // A control-point spline loses its fit data once its control points move. - let spline = self.spline(x, s).map(|mut sp| { + let spline = self.spline(x, s).map(|sp| { + let mut sp = GSpline::clone(&sp); if matches!(shape, Shape::Spline { fit: false, .. }) { sp.fit.clear(); } @@ -712,21 +777,21 @@ mod budget_tests { } } - /// A second model receives no sampled block points after the first model spends the shared member budget. - #[test] - fn rigid_sampling_budget_is_shared_between_models() { - let mut d = Drawing::default(); - let mut block = Block::new("BAR"); - for _ in 0..3 { + fn add_block(d: &mut Drawing, name: &str, lines: usize) { + let mut block = Block::new(name); + for _ in 0..lines { block.entities.push(Entity { handle: d.new_handle(), common: Common::default(), kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }), }); } - d.blocks.insert("BAR".into(), Arc::new(block)); - let ins = Insert { - block: "BAR".into(), + d.blocks.insert(name.into(), Arc::new(block)); + } + + fn insert(block: &str) -> Insert { + Insert { + block: block.into(), insert: Vec3::ZERO, scale: Vec3::new(1.0, 1.0, 1.0), rotation: 0.0, @@ -735,13 +800,79 @@ mod budget_tests { rows: 1, col_spacing: 0.0, row_spacing: 0.0, - }; - let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); - let mut budget = 2; - let first = Model::build_budgeted(&d, &[h], &mut budget); - assert_eq!(first.rigid[&h].len(), 4); - assert_eq!(budget, 0); - let next = Model::build_budgeted(&d, &[h], &mut budget); - assert!(next.rigid[&h].is_empty()); + } + } + + fn add_insert(d: &mut Drawing, block: &str) -> Handle { + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert(block))).unwrap() + } + + /// The member budget is shared by every model of one preparation, and a block it already + /// sampled is reused by later Inserts without examining its members again. + #[test] + fn rigid_sampling_budget_is_shared_and_blocks_sampled_once() { + let mut d = Drawing::default(); + add_block(&mut d, "BAR", 3); + add_block(&mut d, "BAZ", 3); + let (bar, baz, bar2) = (add_insert(&mut d, "BAR"), add_insert(&mut d, "BAZ"), add_insert(&mut d, "BAR")); + let mut prep = Prep::new(2); + let first = Model::build_with(&d, &[bar], &mut prep); + assert_eq!(first.rigid[&bar].len(), 4); + assert_eq!(prep.members_left, 0); + let other = Model::build_with(&d, &[baz], &mut prep); + assert!(other.rigid[&baz].is_empty()); + let again = Model::build_with(&d, &[bar2], &mut prep); + assert_eq!(again.rigid[&bar2].len(), 4); + } + + /// Block names resolve ignoring ASCII case, as `Drawing::block` does, through one index. + #[test] + fn block_names_resolve_ignoring_case() { + let mut d = Drawing::default(); + add_block(&mut d, "Bar", 2); + let (lower, missing) = (add_insert(&mut d, "bAR"), add_insert(&mut d, "nope")); + let mut prep = Prep::new(MAX_RIGID_WORK); + let m = Model::build_with(&d, &[lower, missing], &mut prep); + assert_eq!(m.rigid[&lower].len(), 4); + assert!(m.rigid[&missing].is_empty()); + assert!(prep.folded.as_ref().is_some_and(|f| f.len() == 1)); + assert_eq!(rigid_points(&d, &insert("BAR")).len(), 4); + } + + /// A spline asked for again at the same defining points is not rebuilt, also from another + /// model of the same preparation; moved points rebuild it. + #[test] + fn spline_rebuilds_are_reused_at_the_same_points() { + let mut d = Drawing::default(); + let pts: Vec = (0..6).map(|i| Vec2::new(i as f64, (i % 2) as f64)).collect(); + let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(GSpline::fit_with(&pts, false, Default::default()))).unwrap(); + let mut prep = Prep::new(MAX_RIGID_WORK); + let a = Model::build_with(&d, &[h], &mut prep); + let b = Model::build_with(&d, &[h], &mut prep); + let (sa, sb) = (a.slot(h).unwrap(), b.slot(h).unwrap()); + assert!(matches!(sa.shape, Shape::Spline { fit: true, .. })); + let first = a.spline(&a.x0, sa).unwrap(); + assert!(Rc::ptr_eq(&first, &a.spline(&a.x0, sa).unwrap())); + assert!(Rc::ptr_eq(&first, &b.spline(&b.x0, sb).unwrap())); + let mut moved = a.x0.clone(); + moved[1] += 0.5; + let other = a.spline(&moved, sa).unwrap(); + assert!(!Rc::ptr_eq(&first, &other)); + assert_eq!(other.fit[0], Vec2::new(0.0, 0.5)); + } + + /// A control spline's template keeps no fit points and no excess weights. + #[test] + fn control_template_drops_unused_data() { + let mut d = Drawing::default(); + let mut sp = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)], 3); + sp.fit = vec![Vec2::ZERO; 10_000]; + sp.fit_opts.tolerance = 0.5; + sp.weights = vec![1.0; 10_000]; + let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap(); + let m = Model::build(&d, &[h]); + let t = &m.splines[&h]; + assert!(t.fit.is_empty()); + assert_eq!(t.weights.len(), 4); } } From 2e9761249a023d45b97436ffe8a31f74595c17d7 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 19:22:30 -0700 Subject: [PATCH 142/151] Autosave: keep every folder's lock, and don't let a failure hide or wait (#2676) From review of #2670. Changing SAVEFILEPATH released the old folder's lock while files remained there, so another app could offer a running app's drawings. A write failing while the app was idle waited for the next input to be shown. A second drawing's failure stayed hidden behind a dismissed notice. --- crates/engine/src/autosave.rs | 52 ++++++++++++++++-------- crates/engine/tests/autosave.rs | 30 +++++++++++--- crates/ui-egui/src/autosave.rs | 33 +++++++++++---- crates/ui-egui/tests/autosave_failure.rs | 7 +++- 4 files changed, 93 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index ca6d6ede4..0caac6143 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -166,9 +166,11 @@ pub struct Autosaver { start: std::time::Instant, #[cfg(not(target_arch = "wasm32"))] last_beat: Option, - /// The lock file this app holds the system lock on, open for as long as the lock is wanted. + /// This app's lock files: one per folder, because SAVEFILEPATH can change while files remain + /// in the old folder. Each with the file it holds open for the system lock, or `None` where + /// locks don't work and only the file's age counts. #[cfg(not(target_arch = "wasm32"))] - lock: Option<(std::path::PathBuf, std::fs::File)>, + locks: Vec<(std::path::PathBuf, Option)>, } impl Autosaver { @@ -185,7 +187,7 @@ impl Autosaver { #[cfg(not(target_arch = "wasm32"))] last_beat: None, #[cfg(not(target_arch = "wasm32"))] - lock: None, + locks: Vec::new(), } } @@ -203,9 +205,9 @@ impl Autosaver { self.docs.values().filter_map(|t| t.file.clone()).collect() } - /// Why a drawing currently has no up-to-date recovery copy, for the host to show the user. - pub fn failure(&self) -> Option<&str> { - self.failed.values().next().map(String::as_str) + /// The drawings with no up-to-date recovery copy and why, for the host to show the user. + pub fn failures(&self) -> impl Iterator { + self.failed.iter().map(|(uid, why)| (*uid, why.as_str())) } /// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder @@ -471,6 +473,12 @@ mod native { self.running.iter().any(|(_, h)| !h.is_finished()) } + /// Whether a write's result hasn't been taken in yet, finished or not: the host needs + /// another [`Autosaver::tick`] to learn whether it failed. + pub fn results_pending(&self) -> bool { + !self.running.is_empty() + } + /// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing /// whose write failed is no longer counted as autosaved: the next plan restarts its clock, /// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`] @@ -569,23 +577,32 @@ mod native { } } - /// Refresh the lock file at `path`, taking the system lock on it first if this app doesn't - /// hold it there yet. Without one the file still works by its age. + /// Refresh this app's lock files, and start one at `path` if there isn't one yet, taking + /// the system lock on it if it can. Without that lock the file still works by its age. + /// Only trouble at `path` is an error: an older folder may be gone by now. fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> { - if let Some((held, file)) = &self.lock - && held == path - { - return file.set_modified(SystemTime::now()); - } - self.lock = None; let id = std::process::id(); + let mut current = None; + for (held, file) in &self.locks { + let refreshed = match file { + Some(file) => file.set_modified(SystemTime::now()), + None => std::fs::write(held, id.to_string()), + }; + if held == path { + current = Some(refreshed); + } + } + if let Some(refreshed) = current { + return refreshed; + } let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?; if file.try_lock().is_err() { + self.locks.push((path.to_path_buf(), None)); return std::fs::write(path, id.to_string()); } file.set_len(0)?; file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?; - self.lock = Some((path.to_path_buf(), file)); + self.locks.push((path.to_path_buf(), Some(file))); Ok(()) } @@ -600,7 +617,10 @@ mod native { discard(&path); } self.docs.clear(); - self.lock = None; + for (path, file) in self.locks.drain(..) { + drop(file); + let _ = std::fs::remove_file(path); + } let dir = savefilepath(); if !dir.is_empty() && is_instance_id(&self.instance) { let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance)); diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs index a878faae7..8fd70b6ae 100644 --- a/crates/engine/tests/autosave.rs +++ b/crates/engine/tests/autosave.rs @@ -323,6 +323,26 @@ fn an_emergency_save_finishes_before_it_returns_even_with_savetime_off() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn a_new_autosave_folder_keeps_the_old_folder_locked() { + hooks(); + let _turn = folder_turn(); + let (old, new) = (temp_dir("moved-old"), temp_dir("moved-new")); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("44-e"); + autosave_once(&mut a, &s, &old); + autosave::set_savefilepath(&new.to_string_lossy()); + draw(&mut s); + assert_eq!(a.emergency_save(&s), 1); + assert!(new.join("44-e.lock").exists()); + assert!(autosave::recoverable(&old, "9-f").is_empty(), "this app is still running"); + a.shutdown(); + assert!(!old.join("44-e.lock").exists() && !new.join("44-e.lock").exists()); + let _ = std::fs::remove_dir_all(&old); + let _ = std::fs::remove_dir_all(&new); +} + #[test] fn a_normal_exit_removes_the_lock_file() { hooks(); @@ -366,7 +386,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() { std::thread::sleep(Duration::from_millis(10)); } assert!(rearmed, "a failed autosave must be tried again"); - assert!(a.failure().is_some_and(|m| m.contains("out")), "{:?}", a.failure()); + assert!(a.failures().next().is_some_and(|(_, why)| why.contains("out"))); std::fs::remove_file(&out).unwrap(); let jobs = a.plan(&s, 180.0, 1, &dir); @@ -387,7 +407,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() { std::thread::sleep(Duration::from_millis(10)); } assert_eq!(a.next_due(1e4, 1), None); - assert_eq!(a.failure(), None, "a write that worked clears the failure"); + assert_eq!(a.failures().next(), None, "a write that worked clears the failure"); a.shutdown(); let _ = std::fs::remove_dir_all(&root); } @@ -407,16 +427,16 @@ fn a_failure_ends_when_the_drawing_no_longer_needs_a_copy() { a.perform(jobs); for _ in 0..500 { a.reap(); - if a.failure().is_some() { + if a.failures().next().is_some() { break; } std::thread::sleep(Duration::from_millis(10)); } - assert!(a.failure().is_some()); + assert!(a.failures().next().is_some()); let i = s.active; s.execute("close", &json!({ "index": i })).unwrap(); a.plan(&s, 120.0, 1, &dir); - assert_eq!(a.failure(), None); + assert_eq!(a.failures().next(), None); a.shutdown(); let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index 0d50e2dcc..716c27371 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -35,6 +35,9 @@ pub fn load_prefs(json: &str) { /// What the autosave failure notice says (see [`failure_notice`]). #[derive(Clone, Debug, PartialEq)] pub struct AutosaveNotice { + /// The drawings it has told the user about. Another drawing's failure is news, even after + /// the notice was dismissed. + pub reported: std::collections::BTreeSet, pub message: String, /// The user closed it. The failure stays recorded so the notice doesn't come back on every /// retry; it does for the next failure after a write has worked. @@ -100,23 +103,39 @@ impl CadApp { _ctx.request_repaint_after(wait); } #[cfg(not(target_arch = "wasm32"))] - self.note_autosave_failure(_ctx); + { + // A write that fails while the app is idle must still reach the notice. + if self.autosave.as_ref().is_some_and(cadcraft_engine::autosave::Autosaver::results_pending) { + _ctx.request_repaint_after(std::time::Duration::from_secs(1)); + } + self.note_autosave_failure(_ctx); + } } /// Keep [`Self::autosave_notice`] in step with autosave: a new failure is said once at the /// command line and shown until it is dismissed or a write works. #[cfg(not(target_arch = "wasm32"))] fn note_autosave_failure(&mut self, ctx: &egui::Context) { - let failure = self.autosave.as_ref().and_then(|s| s.failure()).map(str::to_string); - match (failure, &mut self.autosave_notice) { - (None, notice) => *notice = None, - (Some(message), Some(n)) => n.message = message, - (Some(message), notice) => { + let failures: Vec<(u64, String)> = self.autosave.iter().flat_map(|s| s.failures()).map(|(uid, why)| (uid, why.to_string())).collect(); + if failures.is_empty() { + self.autosave_notice = None; + return; + } + let notice = + self.autosave_notice.get_or_insert_with(|| AutosaveNotice { reported: Default::default(), message: String::new(), dismissed: false }); + notice.reported.retain(|uid| failures.iter().any(|(failed, _)| failed == uid)); + for (uid, message) in &failures { + if notice.reported.insert(*uid) { self.session.echo(format!("Autosave failed: {message}")); - *notice = Some(AutosaveNotice { message, dismissed: false }); + notice.message.clone_from(message); + notice.dismissed = false; ctx.request_repaint(); } } + // The drawing it names recovered while another still fails. + if let Some((_, message)) = failures.first().filter(|_| !failures.iter().any(|(_, m)| *m == notice.message)) { + notice.message.clone_from(message); + } } /// Every frame: after a GPU error or device loss, autosave unsaved drawings at once (the diff --git a/crates/ui-egui/tests/autosave_failure.rs b/crates/ui-egui/tests/autosave_failure.rs index 28b6a66d0..6e266fedd 100644 --- a/crates/ui-egui/tests/autosave_failure.rs +++ b/crates/ui-egui/tests/autosave_failure.rs @@ -67,7 +67,12 @@ fn a_failed_autosave_is_shown_until_a_write_works() { app.autosave_tick(&ctx); assert!(app.autosave_notice.as_ref().is_some_and(|n| n.dismissed)); assert!(painted_text(&mut app, &ctx).is_empty()); - assert_eq!(said(&app), 1); + + // Another drawing's failure is news, even after the first was dismissed. + app.autosave_notice.as_mut().unwrap().reported.clear(); + app.autosave_tick(&ctx); + assert!(app.autosave_notice.as_ref().is_some_and(|n| !n.dismissed)); + assert_eq!(said(&app), 2); // The folder comes back: the next write clears it. std::fs::remove_file(&dir).unwrap(); From d180b28ef61ca3a5a7dd796611b0b218249ea77a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 21:33:55 -0500 Subject: [PATCH 143/151] Multilines: find each segment's usable neighbour direction in linear time (#2679) * Multilines: find each segment's usable neighbour direction in two linear passes MLine::miters searched the direction array forwards and backwards from every vertex for the nearest non-degenerate segment, so a long run of coincident vertices made it quadratic. The nearest usable directions ahead and behind are now computed once, in one backward and one forward pass (CWE-407). * Multilines: test that a run of coincident vertices at a corner takes the corner's miter --- crates/doc/src/entity/mline.rs | 108 ++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/entity/mline.rs b/crates/doc/src/entity/mline.rs index 62ab1d8f3..f6ceef519 100644 --- a/crates/doc/src/entity/mline.rs +++ b/crates/doc/src/entity/mline.rs @@ -207,6 +207,41 @@ pub struct MLineGeometry { pub fill: Vec>, } +/// For each segment direction in `dirs`, the first non-zero direction at or after it and the +/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero). +/// +/// Two linear passes: searching from every segment separately is quadratic in a run of +/// coincident vertices, which a hostile file can make as long as it likes (CWE-407). +fn usable_directions(dirs: &[Vec2]) -> (Vec, Vec) { + let n = dirs.len(); + let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else { + return (vec![Vec2::X; n], vec![Vec2::X; n]); + }; + let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]); + // Walking backwards from the last usable direction, the latest one seen is the next ahead. + let mut cur = Vec2::X; + for k in 0..n { + let i = (last + n - k) % n; + if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) { + cur = *d; + } + if let Some(a) = ahead.get_mut(i) { + *a = cur; + } + } + // Walking forwards from the first usable direction, the latest one seen is the nearest behind. + for k in 0..n { + let i = (first + k) % n; + if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) { + cur = *d; + } + if let Some(b) = behind.get_mut(i) { + *b = cur; + } + } + (ahead, behind) +} + impl MLine { /// A multiline through `pts` in style `def`. pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine { @@ -268,16 +303,11 @@ impl MLine { let dirs = self.directions(); let n = self.vertices.len(); let nseg = dirs.len(); - // A degenerate segment takes its neighbour's direction. - let dir = |i: usize| -> Vec2 { - (0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X) - }; - let dir_back = |i: usize| -> Vec2 { - (0..nseg) - .map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default()) - .find(|d| *d != Vec2::ZERO) - .unwrap_or(Vec2::X) - }; + // A degenerate segment takes its neighbour's direction: the first usable one at or after + // it (`ahead`), or at or before it (`behind`), wrapping around. + let (ahead, behind) = usable_directions(&dirs); + let dir = |i: usize| -> Vec2 { ahead.get(i).copied().unwrap_or(Vec2::X) }; + let dir_back = |i: usize| -> Vec2 { behind.get(i).copied().unwrap_or(Vec2::X) }; let end = |d: Vec2, angle: f64| { let u = d.rotate(angle); let s = angle.sin(); @@ -426,3 +456,61 @@ impl crate::Drawing { self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default() } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The nearest usable directions found by searching from each segment, as miters did before. + fn searched(dirs: &[Vec2]) -> (Vec, Vec) { + let n = dirs.len(); + let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)); + let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)); + (ahead.collect(), behind.collect()) + } + + #[test] + fn usable_directions_match_a_search_from_each_segment() { + let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8)); + for dirs in [ + vec![], + vec![z], + vec![a], + vec![z, z, z], + vec![a, z, z, b], + vec![z, a, z, b, z], + vec![z, z, c], + vec![c, z, z], + vec![a, b, c], + vec![z, a, z, z, b, c, z], + ] { + assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}"); + } + } + + #[test] + fn coincident_vertices_at_a_corner_take_the_corner_miter() { + // Every vertex of a run on one point mitres between the segment before the run and the one after. + let style = MLineStyle::default(); + let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0); + let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)]; + let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0); + let (expected, got) = (corner.miters(), run.miters()); + assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..])); + assert_eq!((got.first(), got.last()), (expected.first(), expected.last())); + } + + #[test] + fn a_long_run_of_coincident_vertices_takes_linear_time() { + // 200 000 vertices on one point but the last: the miters must not search the run once per + // vertex (quadratic, minutes) to find the one usable direction. + let mut pts = vec![Vec2::ZERO; 200_000]; + pts.push(Vec2::new(1.0, 0.0)); + let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0); + let t = std::time::Instant::now(); + let miters = m.miters(); + assert_eq!(miters.len(), pts.len()); + assert!(miters.iter().all(|v| v.is_finite())); + assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed()); + } +} From e24c4553d2ca715073383720c9e2d7aa0e98f8dd Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 19:47:36 -0700 Subject: [PATCH 144/151] DXF: 100 million group codes by default, and memory for them that can be refused The tag list for a counted ASCII file is reserved once with a fallible allocation, so where the system refuses the memory, opening fails with an error instead of ending the app. Growing it would also take half as much again for a moment. 100 million rather than 150 bounds a crafted 512 MB file to about 4 GB of tags; CADCRAFT_MAX_DXF_TAGS raises it, and the error says so. --- crates/dxf/src/lib.rs | 33 +++++++++++++++++++++++++-------- crates/io/src/tests.rs | 2 +- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index ab2597a21..c31defc21 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -23,8 +23,11 @@ pub enum DxfError { Eof, /// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the /// limit was reached), `limit` the maximum. - #[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads")] + #[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")] TooLarge { tags: usize, limit: usize }, + /// The memory for `tags` group codes couldn't be had: an error, not the process ending. + #[error("not enough memory to read this drawing's {tags} DXF group codes")] + OutOfMemory { tags: usize }, } pub type Result = std::result::Result; @@ -164,10 +167,11 @@ fn typed(code: i32, text: &str) -> Value { const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0"; /// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this -/// bounds the tag list to roughly 6 GB plus text. It matches the 1 GB DXF rendition a DWG may -/// convert to (a 1 GB DXF holds well over 50 million group codes, so a lower cap rejected large -/// DWGs that converted fine, issue #1948). Set `CADCRAFT_MAX_DXF_TAGS` to raise or lower it. -pub const MAX_TAGS: usize = 150_000_000; +/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine +/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB +/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to +/// raise or lower it. +pub const MAX_TAGS: usize = 100_000_000; /// Environment variable overriding [`MAX_TAGS`] (a positive integer). pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS"; @@ -214,7 +218,7 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result> { if tags > max_tags { return Err(DxfError::TooLarge { tags, limit: max_tags }); } - parse_ascii_with_limit(&codepage::decode_ascii_file(bytes), max_tags) + parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags) } /// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one. @@ -286,12 +290,17 @@ fn unescape_unicode(s: &str) -> String { } pub fn parse_ascii(text: &str) -> Result> { - parse_ascii_with_limit(text, max_tags()) + parse_ascii_counted(text, max_tags(), 0) } +/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first, +/// with an allocation that can fail: where the system refuses that much memory, opening the file +/// is an error instead of the end of the app. Growing the list instead could also briefly take +/// half as much again. #[allow(clippy::while_let_loop)] -fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result> { +fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result> { let mut tags = Vec::new(); + tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?; let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate(); loop { let Some((ln, code_line)) = lines.next() else { break }; @@ -807,4 +816,12 @@ mod limit_tests { // Issue #1948: the default must admit a DXF rendition well above 50 million group codes. const { assert!(MAX_TAGS >= 100_000_000) }; } + + #[test] + fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() { + let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap(); + assert_eq!((tags.len(), tags.capacity()), (3, 3)); + let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err(); + assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}"); + } } diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 5d08817c5..fca680385 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1039,7 +1039,7 @@ fn oversized_dwg_and_dxf_are_rejected_early() { // Lines that aren't even group codes: the size is reported before anything is parsed. let text = "not a group code\nx\n".repeat(60); let e = cadcraft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err(); - assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads"); + assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)"); } /// Issue #800: one unreadable object record doesn't fail the whole DWG. The reader skips it, the From 31e6e16bc0bc9dc5eed9f87ee6dd7f6a3fb4526d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:51:07 -0500 Subject: [PATCH 145/151] constraints: evaluate a named expression without copying its text (#2675) * constraints: evaluate a named expression without copying its text Env::value cloned the stored expression before parse rejected it as too long, and failures are not cached, so an oversized imported expression referenced from many dimensional constraints was copied on every reference. Parse the stored text in place, and keep only a bounded prefix of an over-long expression when it is inserted (it still fails as too long). * constraints: test repeated evaluation of an oversized named expression --- crates/constraints/src/expr.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/constraints/src/expr.rs b/crates/constraints/src/expr.rs index c71ed3486..057ef3e1c 100644 --- a/crates/constraints/src/expr.rs +++ b/crates/constraints/src/expr.rs @@ -284,7 +284,14 @@ impl Env { pub fn new() -> Self { Env::default() } + /// Add or replace a named expression. Text longer than [`MAX_LEN`] could never parse, so + /// only a bounded prefix of it is kept: it still fails (as too long), without the environment + /// holding or copying the whole text. pub fn insert(&mut self, name: &str, expr: &str) { + let expr = match expr.char_indices().map(|(i, _)| i).find(|i| *i > MAX_LEN) { + Some(end) => expr.get(..end).unwrap_or(expr), + None => expr, + }; self.exprs.insert(name.to_string(), expr.to_string()); self.cache.clear(); } @@ -309,8 +316,9 @@ impl Env { if stack.len() >= MAX_DEPTH { return Err(ExprError::TooDeep); } - let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?; - let e = parse(&text)?; + // Parse the stored text in place: `parse` refuses an oversized expression before reading + // it, so a long imported expression referenced many times is never copied (CWE-400). + let e = parse(self.exprs.get(name).ok_or_else(|| ExprError::Unknown(name.to_string()))?)?; stack.push(name.to_string()); let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth); stack.pop(); @@ -400,6 +408,22 @@ mod tests { .unwrap(); } + /// An oversized named expression is kept only as a bounded prefix and still fails, however + /// often it is referenced. + #[test] + fn oversized_named_expression_is_bounded_and_fails() { + let mut env = Env::new(); + let long = format!("1+{}", "é".repeat(500_000)); + env.insert("big", &long); + assert!(env.exprs.get("big").is_some_and(|t| t.len() > MAX_LEN && t.len() <= MAX_LEN + 4)); + assert_eq!(env.value("big"), Err(ExprError::TooDeep)); + for _ in 0..200 { + assert_eq!(env.eval("big"), Err(ExprError::TooDeep)); + } + env.insert("ok", "1+1+1"); + assert_eq!(env.value("ok"), Ok(3.0)); + } + #[test] fn rename_replaces_whole_names_only() { assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)"); From 77142aa79ecaa9c6b9c49120658b4dea93e09dda Mon Sep 17 00:00:00 2001 From: qshh <101165772+qinshah@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:52:31 +0800 Subject: [PATCH 146/151] fonts: scan the Android and HarmonyOS system font directories (#2650) * fonts: scan the Android system font directories System fonts are the only fallback for text whose own font lacks a glyph (CJK in an SHX big font, for example), and Android was falling through to the XDG directories it does not have: a drawing came out in the stroke font with missing-glyph boxes for Chinese. Search /system/fonts and the product and vendor partitions too. The directory list moves out of scan() so the platform's main location is tested directly. * fonts: scan the HarmonyOS system font directories HarmonyOS targets are target_os = "linux" with target_env = "ohos", so they fell into the XDG branch and found no fonts at all: Chinese text in a drawing came out as missing-glyph boxes while the stroke font drew the ASCII around it. Search /system/fonts there too, as on Android. * contributors: add qinshah Credits them by the name on their public GitHub profile. * fonts: document the platform font directories --- contributors/people.toml | 3 +++ crates/fonts/src/ttf.rs | 46 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 994c68bb1..de6d22923 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -102,3 +102,6 @@ sync_github_name = true [people.starise] real_name = "Andrea Brandi" + +[people.qinshah] +sync_github_name = true diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index fff4ecde7..bcf610e0a 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -5,6 +5,11 @@ //! (`Noto Sans CJK TC`) among installed files with a similar name. TrueType collections //! (`.ttc` / `.otc`) are scanned too; a stem lookup takes their first face. //! +//! System fonts are searched in the platform's own directories: `/System/Library/Fonts` and +//! friends on macOS, `C:\Windows\Fonts` on Windows, `/system/fonts` and the product/vendor +//! partitions on Android and HarmonyOS, and the XDG directories elsewhere. Web builds have no +//! system fonts. +//! //! Two process-wide settings (like AutoCAD's profile variables) control substitution: //! - [`font_alt`] (`FONTALT`): the font used for a text style font that can't be found (an SHX //! file we don't read, a missing TTF). Empty = the built-in stroke font. @@ -270,15 +275,20 @@ fn lock() -> MutexGuard<'static, Db> { g } +/// The platform's system font directories, in search order. #[cfg(not(target_arch = "wasm32"))] -fn scan() -> HashMap { - let mut out = HashMap::new(); +fn font_dirs() -> Vec { let mut dirs: Vec = Vec::new(); if cfg!(target_os = "macos") { dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into)); if let Some(h) = std::env::var_os("HOME") { dirs.push(PathBuf::from(h).join("Library/Fonts")); } + } else if cfg!(any(target_os = "android", target_env = "ohos")) { + // Android and HarmonyOS keep system fonts under /system/fonts; Android devices also use + // the product and vendor partitions for faces of their own. An app may read all of them, + // and directories a device lacks are skipped. + dirs.extend(["/system/fonts", "/product/fonts", "/system_ext/fonts", "/vendor/fonts"].map(Into::into)); } else if cfg!(windows) { dirs.push("C:\\Windows\\Fonts".into()); if let Some(l) = std::env::var_os("LOCALAPPDATA") { @@ -291,7 +301,14 @@ fn scan() -> HashMap { dirs.push(PathBuf::from(h).join(".local/share/fonts")); } } - let mut stack = dirs; + dirs +} + +/// Every font file under [`font_dirs`], by lower-case file stem. +#[cfg(not(target_arch = "wasm32"))] +fn scan() -> HashMap { + let mut out = HashMap::new(); + let mut stack = font_dirs(); let mut seen = 0usize; while let Some(d) = stack.pop() { let Ok(rd) = std::fs::read_dir(&d) else { continue }; @@ -1080,3 +1097,26 @@ mod geometry_budget_tests { assert!(shaped.text.is_empty()); } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod scan_tests { + use super::*; + + /// Each platform searches its own system font directories. A branch that is missing or + /// misnamed leaves a device with no fonts at all, so the platform's main location is checked + /// here rather than discovered by a drawing full of missing-glyph boxes. + #[test] + fn the_platforms_system_font_directory_is_searched() { + let dirs = font_dirs(); + #[cfg(target_os = "macos")] + assert!(dirs.iter().any(|d| d == std::path::Path::new("/System/Library/Fonts")), "{dirs:?}"); + #[cfg(target_os = "android")] + assert!(dirs.iter().any(|d| d == std::path::Path::new("/system/fonts")), "{dirs:?}"); + #[cfg(target_env = "ohos")] + assert!(dirs.iter().any(|d| d == std::path::Path::new("/system/fonts")), "{dirs:?}"); + #[cfg(windows)] + assert!(dirs.iter().any(|d| d == std::path::Path::new("C:\\Windows\\Fonts")), "{dirs:?}"); + #[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_env = "ohos"))))] + assert!(dirs.iter().any(|d| d == std::path::Path::new("/usr/share/fonts")), "{dirs:?}"); + } +} From 1b7c705face307317158f8afe5600de97647ef4b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:52:35 -0500 Subject: [PATCH 147/151] Dynamic blocks: index kept objects once per listing; copy each value record once (#2678) * Dynamic blocks: index kept objects once per listing and copy each value record once Listing blocks rebuilt a handle index of every kept foreign object for each block with an extension dictionary, multiplying the block count by the object count. The listing now builds one ObjectIndex and asks is_dynamic_block_in per block; dynamic_block answers None before indexing when the block has no extension dictionary. templates() copied a value record's tags once per dictionary key, so a record listed under many keys was copied many times, and it looked up each dictionary entry by scanning every object. It now resolves entries through its handle index and copies each record at most once (CWE-400, CWE-407). * Dynamic blocks: list a block as dynamic from its graph object without walking the graph's members --- crates/doc/src/dynblock.rs | 47 ++++++++++++++--- crates/doc/src/dynblock/apply.rs | 53 +++++++++++++++++--- crates/engine/src/cmd/blocks.rs | 5 +- crates/engine/tests/it/dynamic_block_data.rs | 4 ++ 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index 392861388..409d403dd 100644 --- a/crates/doc/src/dynblock.rs +++ b/crates/doc/src/dynblock.rs @@ -323,8 +323,9 @@ impl<'a> Cursor<'a> { } } -/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one. -fn graph_members<'a>(objects: &HashMap, xdict: Handle) -> Option> { +/// `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph object, when the dictionary has one. Only the +/// dictionary's own tags are read, so asking whether a block is dynamic never walks the graph. +fn evaluation_graph<'a>(objects: &HashMap, xdict: Handle) -> Option<&'a ForeignObject> { let dict = objects.get(&xdict)?; let mut graph = None; let mut tags = dict.tags.iter(); @@ -334,7 +335,12 @@ fn graph_members<'a>(objects: &HashMap, xdict: Handle break; } } - let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?; + objects.get(&graph?).copied().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH")) +} + +/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one. +fn graph_members<'a>(objects: &HashMap, xdict: Handle) -> Option> { + let graph = evaluation_graph(objects, xdict)?; let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value)); // Deduplicate member handles before resolving them: a graph can list the same member many // times, and parsing each occurrence into its own owned parameter multiplies a single large @@ -343,13 +349,42 @@ fn graph_members<'a>(objects: &HashMap, xdict: Handle Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect()) } +/// The kept foreign objects of a drawing by handle. Building it walks every object, so a caller +/// that asks about many blocks (listing them) builds it once and passes it to +/// [`dynamic_block_in`] and [`is_dynamic_block_in`] instead of rebuilding it per block, which +/// multiplies the block count by the object count (CWE-407). +pub struct ObjectIndex<'a>(HashMap); + +impl<'a> ObjectIndex<'a> { + pub fn new(d: &'a Drawing) -> Self { + ObjectIndex(d.foreign.objects.iter().map(|o| (o.handle, o)).collect()) + } +} + +/// The extension dictionary of block `name`'s record, if it has one. +fn block_xdict(d: &Drawing, name: &str) -> Option { + d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict +} + /// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a /// block's own drawing), or `None` when it has none. pub fn dynamic_block(d: &Drawing, name: &str) -> Option { - let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?; - let objects: HashMap = d.foreign.objects.iter().map(|o| (o.handle, o)).collect(); + // A block without an extension dictionary is never dynamic: answer before indexing objects. + block_xdict(d, name)?; + dynamic_block_in(d, &ObjectIndex::new(d), name) +} + +/// Whether block `name` is a dynamic block (has an evaluation graph), without reading its +/// parameters and actions; `objects` is the drawing's [`ObjectIndex`]. +pub fn is_dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> bool { + block_xdict(d, name).and_then(|x| evaluation_graph(&objects.0, x)).is_some() +} + +/// [`dynamic_block`] with the drawing's [`ObjectIndex`] already built. +pub fn dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> Option { + let xdict = block_xdict(d, name)?; let mut out = DynamicBlock::default(); - for o in graph_members(&objects, xdict)? { + for o in graph_members(&objects.0, xdict)? { let secs = sections(&o.tags); let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90); let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300); diff --git a/crates/doc/src/dynblock/apply.rs b/crates/doc/src/dynblock/apply.rs index ecca36257..c22a6df86 100644 --- a/crates/doc/src/dynblock/apply.rs +++ b/crates/doc/src/dynblock/apply.rs @@ -54,7 +54,11 @@ fn record_handle(d: &Drawing, name: &str) -> Option { /// The object entry `key` of dictionary `dict` refers to. fn entry(d: &Drawing, dict: Handle, key: &str) -> Option { - let o = d.foreign.objects.iter().find(|o| o.handle == dict)?; + dictionary_entry(d.foreign.objects.iter().find(|o| o.handle == dict)?, key) +} + +/// The object entry `key` (any case) of dictionary object `o` names. +fn dictionary_entry(o: &ForeignObject, key: &str) -> Option { let mut tags = o.tags.iter(); while let Some(t) = tags.next() { if t.code == 3 && t.value.eq_ignore_ascii_case(key) { @@ -66,7 +70,11 @@ fn entry(d: &Drawing, dict: Handle, key: &str) -> Option { /// Every entry of dictionary `dict`: (key, object). fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> { - let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() }; + d.foreign.objects.iter().find(|o| o.handle == dict).map(dictionary_entries).unwrap_or_default() +} + +/// Every entry of dictionary object `o`: (key, object). +fn dictionary_entries(o: &ForeignObject) -> Vec<(String, Handle)> { let mut out = Vec::new(); let mut key: Option<&str> = None; for t in &o.tags { @@ -692,19 +700,33 @@ fn refill_hatches(b: &mut Block) { /// The value records of the references to the dynamic block whose record is `def_record` (from /// any one of them, by graph id): AutoCAD's layout of each record, class ids included. +/// +/// Each record is copied once at most: a key naming a record an earlier key already took is +/// skipped. Records are copied per key (and copied again per key when a new reference's data is +/// made), so a file listing one large record under many keys would otherwise multiply its size by +/// the key count (CWE-400); AutoCAD gives every key its own record. fn templates(d: &Drawing, def_record: Handle) -> HashMap> { let objects: HashMap = d.foreign.objects.iter().map(|o| (o.handle, o)).collect(); + // Dictionary entries looked up through the index, not by scanning every object per + // dictionary (which is quadratic in the object count). + let entry = |dict: Handle, key: &str| objects.get(&dict).and_then(|o| dictionary_entry(o, key)); let mut out: HashMap> = HashMap::new(); + let mut taken: HashSet = HashSet::new(); for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) { // A representation dictionary whose data names this dynamic block. - let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue }; + let Some(rd) = dictionary_entry(rep, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue }; if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) { continue; } - let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue }; - for (k, h) in entries(d, values) { + let Some(values) = dictionary_entry(rep, "AppDataCache").and_then(|c| entry(c, "ACAD_ENHANCEDBLOCKDATA")) else { continue }; + let Some(values) = objects.get(&values) else { continue }; + for (k, h) in dictionary_entries(values) { + if out.contains_key(&k) || taken.contains(&h) { + continue; + } if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) { - out.entry(k).or_insert_with(|| x.tags.clone()); + taken.insert(h); + out.insert(k, x.tags.clone()); } } } @@ -1044,4 +1066,23 @@ mod tests { assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]); assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]); } + + #[test] + fn a_value_record_listed_under_many_keys_is_copied_once() { + // One large XRECORD named by many keys of a reference's value dictionary: the templates + // keep one copy, not one per key. + let mut d = Drawing::new_imperial(); + let (def_record, rep, rd, cache, values, record) = (Handle(0x10), Handle(0x20), Handle(0x21), Handle(0x22), Handle(0x23), Handle(0x24)); + let keys: Vec = (0..1000).map(|i| i.to_string()).collect(); + let entries: Vec<(&str, Handle)> = keys.iter().map(|k| (k.as_str(), record)).collect(); + let f = std::sync::Arc::make_mut(&mut d.foreign); + f.objects.push(dictionary(rep, Handle(1), &[("AcDbRepData", rd), ("AppDataCache", cache)])); + f.objects.push(ForeignObject { handle: rd, kind: "ACDB_BLOCKREPRESENTATION_DATA".into(), tags: vec![tag(340, def_record.hex())] }); + f.objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", values)])); + f.objects.push(dictionary(values, cache, &entries)); + f.objects.push(ForeignObject { handle: record, kind: "XRECORD".into(), tags: (0..1000).map(|_| tag(1, "x")).collect() }); + let t = templates(&d, def_record); + assert_eq!(t.len(), 1, "one record, one template"); + assert_eq!(t.get("0").map(Vec::len), Some(1000)); + } } diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 258594ff0..45ab8ae15 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -379,8 +379,11 @@ fn run_base(s: &mut Session, p: &Value) -> Result { fn run_list(s: &mut Session, _p: &Value) -> Result { let d = s.doc()?; + // One object index for the whole listing: rebuilding it per block multiplies the block count + // by the drawing's kept object count (CWE-407). + let objects = cadcraft_doc::dynblock::ObjectIndex::new(d); let block = |b: &Block| { - let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some(); + let dynamic = cadcraft_doc::dynblock::is_dynamic_block_in(d, &objects, &b.name); json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic}) }; Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::>() })) diff --git a/crates/engine/tests/it/dynamic_block_data.rs b/crates/engine/tests/it/dynamic_block_data.rs index 5b4cb11e3..2e3f0baaa 100644 --- a/crates/engine/tests/it/dynamic_block_data.rs +++ b/crates/engine/tests/it/dynamic_block_data.rs @@ -83,6 +83,10 @@ fn blocks_without_dynamic_data_and_bad_names() { s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l] })).unwrap(); assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "Window" })).unwrap(), json!(null)); assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "*Model_Space" })).unwrap(), json!(null)); + let list = s.execute("blocks.list", &json!({})).unwrap(); + let flags: Vec<_> = list["blocks"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap().to_string(), b["dynamic"].clone())).collect(); + assert!(flags.contains(&("Window".into(), json!(false))), "{flags:?}"); + assert!(flags.contains(&("Door".into(), json!(true))), "{flags:?}"); for p in [json!({}), json!({ "name": 3 }), json!({ "name": "Nope" }), json!(null)] { assert!(s.execute("blocks.dynamic", &p).is_err(), "{p}"); } From 455b2a87cb2df9cbc67b7edec09c2c94e9c8358f Mon Sep 17 00:00:00 2001 From: Tyler Chiriboga Date: Mon, 2 Nov 2026 21:52:39 -0600 Subject: [PATCH 148/151] EXTEND: a polyline's arc end segment runs on round its circle (#2656) EXTEND on an open polyline ran a ray along the chord of an arc end segment and kept the old bulge, so the arc changed radius and centre, or the boundary was reported missed when only the circle reached it. The arc end segment now runs on round its own circle to the nearest crossing ahead, turning the same way, and its bulge becomes that of the total turn, as EXTEND does with an ARC. The polyline also keeps its constant width, elevation and linetype generation, which the rebuilt polyline dropped. Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/modify/trim.rs | 60 ++++++-- .../tests/it/extend_polyline_arc_end.rs | 137 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 3 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/it/extend_polyline_arc_end.rs diff --git a/crates/engine/src/cmd/modify/trim.rs b/crates/engine/src/cmd/modify/trim.rs index 66eab0be8..9f8e856dd 100644 --- a/crates/engine/src/cmd/modify/trim.rs +++ b/crates/engine/src/cmd/modify/trim.rs @@ -283,6 +283,28 @@ pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 { } } +/// A polyline arc segment on `ga`'s circle that ends at `end` after turning through `sweep` +/// (signed, positive counterclockwise), run on round the circle to the nearest boundary edge: the +/// new end point and the segment's new bulge. `None` when no edge crosses the rest of the circle. +fn extend_arc_segment(ga: &Arc, end: Vec2, sweep: f64, cut: &[Segment]) -> Option<(Vec2, f64)> { + let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*ga }, ccw: true }; + let a_end = ga.center.angle_to(end); + let ccw = sweep > 0.0; + // How far past `end` each crossing is, turning the way the segment turns; crossings on the + // segment itself (the last |sweep| of a turn) are not ahead of it. + let (x, more) = cut + .iter() + .flat_map(|c| intersect_ext(&full, c, false)) + .map(|x| { + let a = ga.center.angle_to(x); + (x, norm_angle(if ccw { a - a_end } else { a_end - a })) + }) + .filter(|(_, more)| *more > 1e-9 && *more + sweep.abs() < TAU - 1e-9) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + let total = sweep.abs() + more; + Some((x, cadcraft_geom::arc_to_bulge(if ccw { total } else { -total }))) +} + /// Extend the end of `h` nearest `pick` to the nearest boundary edge. pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> { if crate::cmd::curves::is_locked(s, h) { @@ -326,18 +348,34 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Re let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) }; let end = vs.get(i_end).map(|v| v.p).unwrap_or_default(); let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default(); - let dir = (end - prev).normalized(); - let ray = Segment::Line(Line::new(end, end + dir * 1e8)); - let np = cut - .iter() - .flat_map(|c| intersect_ext(&ray, c, false)) - .filter(|x| (*x - end).dot(dir) > 1e-9) - .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) - .ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; - if let Some(v) = vs.get_mut(i_end) { - v.p = np; + // The end segment's bulge, signed for travel from `prev` out to `end`. + let i_seg = if from_end { i_prev } else { i_end }; + let bulge = vs.get(i_seg).map_or(0.0, |v| if from_end { v.bulge } else { -v.bulge }); + let none = || EngineError::Other("Object does not intersect an edge.".into()); + if let Some((ga, _)) = cadcraft_geom::bulge_to_arc(prev, end, bulge) { + // An arc end segment runs on round its circle, as an arc does. + let (np, b) = extend_arc_segment(&ga, end, 4.0 * bulge.atan(), &cut).ok_or_else(none)?; + if let Some(v) = vs.get_mut(i_end) { + v.p = np; + } + if let Some(v) = vs.get_mut(i_seg) { + v.bulge = if from_end { b } else { -b }; + } + } else { + let dir = (end - prev).normalized(); + let ray = Segment::Line(Line::new(end, end + dir * 1e8)); + let np = cut + .iter() + .flat_map(|c| intersect_ext(&ray, c, false)) + .filter(|x| (*x - end).dot(dir) > 1e-9) + .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) + .ok_or_else(none)?; + if let Some(v) = vs.get_mut(i_end) { + v.p = np; + } } - lwpoly(vs, false) + // Width, elevation and linetype generation stay as they were. + EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, ..p.clone() }) } k => crate::cmd::modify2::extend_other(k, &cut, pick)?, }; diff --git a/crates/engine/tests/it/extend_polyline_arc_end.rs b/crates/engine/tests/it/extend_polyline_arc_end.rs new file mode 100644 index 000000000..496187767 --- /dev/null +++ b/crates/engine/tests/it/extend_polyline_arc_end.rs @@ -0,0 +1,137 @@ +//! EXTEND on an open polyline whose end segment is an arc: the arc runs on round its own circle +//! to the boundary, as EXTEND does with an ARC, instead of the end vertex sliding along the chord +//! with the old bulge (which changed the arc's radius and centre, or missed the boundary). The +//! polyline keeps its width, elevation and linetype generation. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Common, Entity, EntityKind, Handle, LwPolyline}; +use cadcraft_engine::geom::{PolyVertex, Vec2}; +use serde_json::json; + +const TOL: f64 = 1e-9; + +fn add(s: &mut Session, kind: EntityKind) -> String { + let space = s.space(); + let d = s.doc_mut().unwrap(); + let handle = d.new_handle(); + d.space_mut(&space).unwrap().push(Entity { handle, common: Common::default(), kind }); + handle.hex() +} + +fn v(x: f64, y: f64, bulge: f64) -> PolyVertex { + PolyVertex { p: Vec2::new(x, y), bulge, ..Default::default() } +} + +fn pline(vertices: Vec) -> LwPolyline { + LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false } +} + +fn edge(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String { + let r = s.execute("line", &json!({ "points": [a, b] })).unwrap(); + r["handles"][0].as_str().unwrap().to_string() +} + +fn polyline(s: &Session, h: &str) -> LwPolyline { + match &s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind { + EntityKind::LwPolyline(p) => p.clone(), + k => panic!("not a polyline: {k:?}"), + } +} + +fn near(a: Vec2, x: f64, y: f64) -> bool { + (a.x - x).abs() < 1e-6 && (a.y - y).abs() < 1e-6 +} + +/// tan(22.5°): the bulge of a quarter circle. +fn quarter() -> f64 { + (std::f64::consts::PI / 8.0).tan() +} + +/// The bulge of an arc turning through `deg` degrees (positive counterclockwise). +fn bulge(deg: f64) -> f64 { + (deg.to_radians() / 4.0).tan() +} + +#[test] +fn a_counterclockwise_arc_at_the_end_runs_on_round_its_circle() { + let mut s = Session::new(); + let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]); + // A line in from (20,0), then a quarter circle about the origin from (10,0) to (0,10). + let mut pl = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]); + pl.const_width = 0.5; + pl.elevation = 3.0; + pl.plinegen = true; + let h = add(&mut s, EntityKind::LwPolyline(pl)); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert_eq!(p.vertices.len(), 3); + // Round the circle (radius 10) through (-10,0) to y = -5: a 210° turn in all. + let end = p.vertices[2].p; + assert!(near(end, -(75.0f64.sqrt()), -5.0), "end {end:?}"); + assert!((p.vertices[1].bulge - bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge); + assert!(near(p.vertices[0].p, 20.0, 0.0) && near(p.vertices[1].p, 10.0, 0.0)); + assert_eq!((p.const_width, p.elevation, p.plinegen), (0.5, 3.0, true)); +} + +#[test] +fn an_arc_at_the_start_runs_on_backwards_round_its_circle() { + let mut s = Session::new(); + let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]); + // The same path drawn the other way: clockwise from (0,10) to (10,0), then out to (20,0). + let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(0.0, 10.0, -quarter()), v(10.0, 0.0, 0.0), v(20.0, 0.0, 0.0)]))); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[0].p, -(75.0f64.sqrt()), -5.0), "start {:?}", p.vertices[0].p); + assert!((p.vertices[0].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[0].bulge); + assert!(near(p.vertices[1].p, 10.0, 0.0) && near(p.vertices[2].p, 20.0, 0.0)); +} + +#[test] +fn a_clockwise_arc_at_the_end_runs_on_clockwise() { + let mut s = Session::new(); + let e = edge(&mut s, [-50.0, 5.0], [50.0, 5.0]); + // Clockwise about the origin from (10,0) to (0,-10); on round through (-10,0) to y = 5. + let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, -quarter()), v(0.0, -10.0, 0.0)]))); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, -9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[2].p, -(75.0f64.sqrt()), 5.0), "end {:?}", p.vertices[2].p); + assert!((p.vertices[1].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge); +} + +#[test] +fn the_nearest_crossing_ahead_on_the_circle_wins() { + let mut s = Session::new(); + // A boundary crossing the circle twice ahead of the end: x = -6 at about 127° and 233°. + let e = edge(&mut s, [-6.0, -50.0], [-6.0, 50.0]); + let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]))); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[2].p, -6.0, 8.0), "end {:?}", p.vertices[2].p); + let turn = 8.0f64.atan2(-6.0).to_degrees(); + assert!((p.vertices[1].bulge - bulge(turn)).abs() < TOL, "bulge {}", p.vertices[1].bulge); +} + +#[test] +fn an_arc_end_whose_circle_misses_every_edge_is_left_alone() { + let mut s = Session::new(); + // The chord (10,0)→(0,10) runs on to this edge, but the circle never reaches it. + let e = edge(&mut s, [-50.0, 30.0], [50.0, 30.0]); + let before = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]); + let h = add(&mut s, EntityKind::LwPolyline(before.clone())); + assert!(s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).is_err()); + assert_eq!(polyline(&s, &h), before); +} + +#[test] +fn a_straight_end_keeps_the_polyline_width_and_elevation() { + let mut s = Session::new(); + let e = edge(&mut s, [50.0, -50.0], [50.0, 50.0]); + let mut pl = pline(vec![v(0.0, 0.0, 0.0), v(10.0, 0.0, 0.0)]); + pl.const_width = 0.5; + pl.elevation = 3.0; + let h = add(&mut s, EntityKind::LwPolyline(pl)); + s.execute("extend", &json!({ "handle": h, "pick": [9.0, 0.0], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[1].p, 50.0, 0.0), "end {:?}", p.vertices[1].p); + assert_eq!((p.const_width, p.elevation), (0.5, 3.0)); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 38fb4d005..b6ec02d60 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -46,6 +46,7 @@ mod draworder; mod dyn_input; mod dynamic_block_data; mod dynamic_grips; +mod extend_polyline_arc_end; mod fillet_chamfer; mod fix_holds_grips; mod followups_626; From 7b6be7835f29e427aa2c785e8afcbe01c9082996 Mon Sep 17 00:00:00 2001 From: Thiago Matheus Campelo Ferreira <133534183+euthiferreira@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:53:20 -0300 Subject: [PATCH 149/151] fix(io): a solid HATCH keeps its angle, scale and double flag (#2665) * fix(io): a solid HATCH keeps its angle, scale and double flag The writer wrote groups 52 (angle), 41 (scale) and 77 (double) for pattern fills only, so a SOLID hatch read with them lost them on save. Write them for solid fills too, as read. Co-Authored-By: Claude Opus 5.5 * test(io): fixture for a solid HATCH keeps angle, scale and double flag Add a synthetic DXF with a SOLID and an ANSI31 hatch and a test that saves it and compares groups 52, 41 and 77 of each hatch by handle. Co-Authored-By: Claude Haiku 5.5 * docs(io): document the functions this change touches Add doc comments to the functions the diff changes whose complexity is above one, so the docstring coverage check passes. Co-Authored-By: Claude Haiku 5.5 * Drop the DXF fixture: the inline test covers the fix; test data belongs in a corpus repo Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/io/src/dxf_write/entity.rs | 11 ++++++++--- crates/io/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index ee9c18d33..6e1d84270 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -600,6 +600,9 @@ fn write_annotations_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, pap } } +/// Writes a HATCH: its pattern name, solid and associative flags, its boundary loops, and the +/// pattern angle, scale and double flag (written for solid hatches too). Pattern lines are written +/// only for non-solid hatches. fn write_hatch_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, _cx: &Ctx) { if let EntityKind::Hatch(h) = &e.kind { w.s(0, "HATCH"); @@ -634,10 +637,12 @@ fn write_hatch_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bo } w.i(75, i64::from(h.style)); w.i(76, 1); + // Angle, scale and double flag are written for solid fills too, as read (AutoCAD and + // other writers keep them on SOLID hatches; readers ignore them there). + w.f(52, h.angle.to_degrees()); + w.f(41, h.scale); + w.i(77, 0); if !h.solid { - w.f(52, h.angle.to_degrees()); - w.f(41, h.scale); - w.i(77, 0); // The lines read from the file, else the library's pattern of that name. let lines = if h.pattern_lines.is_empty() { cadcraft_doc::library::pattern_for(d, &h.pattern).map(|p| p.lines).unwrap_or_default() diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index fca680385..46536f24e 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -2896,3 +2896,19 @@ fn non_unicode_names_pick_the_format_by_extension() { assert!(write(&d, name("png")).unwrap().starts_with(b"\x89PNG")); assert!(write(&d, name("dwt")).is_ok(), "a template is written as DWG"); } + +/// A SOLID hatch keeps its angle (52), scale (41) and double flag (77) through a save: the writer +/// wrote them for pattern fills only. +#[test] +fn solid_hatch_keeps_angle_scale_and_double_flag() { + let src = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n5\n40\n8\n0\n100\nAcDbHatch\n10\n0\n20\n0\n30\n0\n2\nSOLID\n70\n1\n71\n0\n\ +91\n1\n92\n2\n72\n0\n73\n1\n93\n4\n10\n0\n20\n0\n10\n40\n20\n0\n10\n40\n20\n40\n10\n0\n20\n40\n97\n0\n\ +75\n0\n76\n1\n52\n30\n41\n2\n77\n0\n98\n0\n0\nENDSEC\n0\nEOF\n"; + let out = write_dxf(&read_dxf(src.as_bytes()).unwrap()); + let pairs = dxf_pairs(&out); + let h = dxf_records(&pairs, "HATCH")[0]; + let f = |code: &str| group(h, code).and_then(|v| v.parse::().ok()); + assert!(f("52").is_some_and(|a| (a - 30.0).abs() < 1e-9), "{h:?}"); + assert!(f("41").is_some_and(|s| (s - 2.0).abs() < 1e-12), "{h:?}"); + assert_eq!(group(h, "77"), Some("0"), "{h:?}"); +} From 4c00f8b9e3fa2515986f62809a5cf80f038a46d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9C=81=E4=BC=AA?= <56018602+r4x2t7@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:53:24 +0800 Subject: [PATCH 150/151] FIND: a Find and Replace window, not only the command line (#2607) * FIND: report the text around each match The result list needs to show what matched, not just its handle, so each entry now carries a snippet of the text around the first hit, the hit's offset in it and its length. Offsets count characters, not bytes, so a Chinese or Vietnamese drawing never splits a glyph. Two optional parameters let a window search part of the space: `types` narrows the search by object type name and `handles` limits it to the objects a caller has already listed, which is what makes replacing a single row possible. Both default to the old behaviour, so scripts, LISP and the MCP server get the answer they always got. * FIND: a Find and Replace window FIND only had a command line: it asked for the text, ran, and printed a count, and the palette's Find and Replace tile opened the same prompt. The answer lived in the drawing and the place to read it was the command line. This adds a window behind the same command. The result list shows each object with the hit lit up, a click selects that object and zooms to it, Replace edits the row under the cursor and Replace All the rows on the list. The window stays open while the drawing is edited, because finding and replacing is a loop: look, locate, edit, look again. A JSON call carries its parameters, so scripts, LISP and the MCP server still get the engine's answer; only the bare command opens the window. Cmd+F reaches it from anywhere, and the twenty labels it adds are in every interface language. * FIND: clear the window's state when it closes or the drawing changes The result rows hold handles, and a handle only means something in the drawing it was found in. The state now lives exactly as long as the window does, and an epoch change while the window sits open clears the rows instead of letting Replace All edit an object the search never matched. A successful replacement keeps the engine's replacement report rather than letting the re-search overwrite it with a match count. * FIND: an empty replacement is a deletion, and stale rows die on any change Replacing with nothing deletes the matched text, which is a legitimate replacement, so the buttons no longer require text in the Replace field. The result rows answer one question - this text, with these switches, in this drawing and space. Editing the search term or either switch now clears the rows and owes a fresh search, and so does switching between model space and a layout: a handle means nothing outside the space it was found in. * rustfmt: sort the module list after the merge Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/utility.rs | 107 ++++++++++- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/finddlg.rs | 294 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 12 ++ crates/ui-egui/src/i18n/bs.tsv | 12 ++ crates/ui-egui/src/i18n/cs.tsv | 12 ++ crates/ui-egui/src/i18n/de.tsv | 12 ++ crates/ui-egui/src/i18n/el.tsv | 12 ++ crates/ui-egui/src/i18n/es.tsv | 12 ++ crates/ui-egui/src/i18n/fr.tsv | 12 ++ crates/ui-egui/src/i18n/hu.tsv | 12 ++ crates/ui-egui/src/i18n/it.tsv | 12 ++ crates/ui-egui/src/i18n/ko.tsv | 12 ++ crates/ui-egui/src/i18n/pt_br.tsv | 12 ++ crates/ui-egui/src/i18n/ru.tsv | 12 ++ crates/ui-egui/src/i18n/sv.tsv | 12 ++ crates/ui-egui/src/i18n/tests.rs | 1 + crates/ui-egui/src/i18n/tr.tsv | 12 ++ crates/ui-egui/src/i18n/uk.tsv | 12 ++ crates/ui-egui/src/i18n/vi.tsv | 12 ++ crates/ui-egui/src/i18n/zh.tsv | 12 ++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 11 +- 23 files changed, 616 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/finddlg.rs diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs index 97678ae24..8faab4f9c 100644 --- a/crates/engine/src/cmd/utility.rs +++ b/crates/engine/src/cmd/utility.rs @@ -26,7 +26,10 @@ pub fn specs() -> Vec { .params("{mode: point|distance|angle|intersection, points: [[x,y],...]} → {value} | {point}") .noundo() .interactive(|_| Ok(Box::::default())), - CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"), + CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params( + "{find, replace?, matchCase?: bool, wholeWord?: bool, types?: [\"Text\", \"MText\", ...], handles?: [...]} → \ + {matches: [{handle, type, count, snippet, hit, hitLen}], total, message}", + ), CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom) .params("{mode: distance|radius|angle|area|volume, ...}") .noundo() @@ -194,6 +197,46 @@ fn run_quickcalc_pick(s: &mut Session, p: &Value) -> Result { // ---------------- FIND ---------------- +/// Characters of context kept on each side of a hit in the Find dialog's result list. +const SNIPPET_CONTEXT: usize = 18; + +/// The text around the first hit of `find` in `text`, for the result list: at most +/// [`SNIPPET_CONTEXT`] characters before the hit and as many after it, the hit's offset in the +/// snippet (in characters) and its length (in characters). +/// +/// The offset and length are character counts, not bytes: the dialog slices the snippet by +/// character so a Chinese or Vietnamese drawing never splits a glyph. Lower-casing can change a +/// string's character count in rare scripts, so every slice goes through `get` and a snippet that +/// cannot be cut is reported as absent rather than guessed at. +fn snippet_at(text: &str, find: &str, case: bool, whole: bool) -> Option<(String, usize, usize)> { + if find.is_empty() { + return None; + } + let (hay, needle) = if case { (text.to_string(), find.to_string()) } else { (text.to_lowercase(), find.to_lowercase()) }; + let mut at = 0; + let pos = loop { + let rest = hay.get(at..)?; + let p = rest.find(&needle)? + at; + let end = p + needle.len(); + let boundary = |i: usize| hay.get(..i).and_then(|s| s.chars().last()).is_none_or(|c| !c.is_alphanumeric()); + let after = hay.get(end..).and_then(|s| s.chars().next()).is_none_or(|c| !c.is_alphanumeric()); + if !whole || (boundary(p) && after) { + break p; + } + at = end.max(p + 1); + if at >= hay.len() { + return None; + } + }; + let start = hay.get(..pos)?.chars().count(); + let hit_len = needle.chars().count(); + let chars: Vec = text.chars().collect(); + let from = start.saturating_sub(SNIPPET_CONTEXT); + let to = start.saturating_add(hit_len).saturating_add(SNIPPET_CONTEXT).min(chars.len()); + let snippet: String = chars.get(from..to)?.iter().collect(); + Some((snippet, start.saturating_sub(from), hit_len)) +} + fn replace_in(text: &str, find: &str, rep: &str, case: bool, whole: bool) -> (String, usize) { if find.is_empty() { return (text.to_string(), 0); @@ -228,21 +271,40 @@ fn run_find(s: &mut Session, p: &Value) -> Result { let rep = str_param(p, "replace").map(str::to_string); let case = bool_or(p, "matchCase", false); let whole = bool_or(p, "wholeWord", false); + // Only the named object types, when the dialog narrows the search; `type_name()` is the same + // name the result list shows, so a caller never has to guess the spelling. + let types: Option> = + p.get("types").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect()); let space = s.space(); let d = s.doc()?; - let hs: Vec = d.space(&space).map(|st| st.handles()).unwrap_or_default(); + // The whole current space by default; the Find window narrows it to the objects its result + // list already holds, so Replace All touches exactly what the user can see. + let hs: Vec = match p.get("handles").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect(), + None => d.space(&space).map(|st| st.handles()).unwrap_or_default(), + }; let mut found: Vec = Vec::new(); let mut total = 0; let mut edits: Vec<(Handle, EntityKind)> = Vec::new(); for h in hs { let Some(e) = d.entity(h) else { continue }; + if let Some(names) = &types + && !names.iter().any(|n| n == &e.kind.type_name().to_ascii_lowercase()) + { + continue; + } let mut k = e.kind.clone(); let mut hits = 0; + // The first hit's surroundings, taken before a replacement rewrites the text. + let mut snippet: Option<(String, usize, usize)> = None; let r = rep.as_deref().unwrap_or(&find); let mut apply = |t: &mut String| { let (nt, n) = replace_in(t, &find, r, case, whole); if n > 0 { hits += n; + if snippet.is_none() { + snippet = snippet_at(t, &find, case, whole); + } if rep.is_some() { *t = nt; } @@ -263,7 +325,15 @@ fn run_find(s: &mut Session, p: &Value) -> Result { } if hits > 0 { total += hits; - found.push(json!({ "handle": h.hex(), "type": e.kind.type_name(), "count": hits })); + let (text, at, len) = snippet.unwrap_or_default(); + found.push(json!({ + "handle": h.hex(), + "type": e.kind.type_name(), + "count": hits, + "snippet": text, + "hit": at, + "hitLen": len, + })); if rep.is_some() { edits.push((h, k)); } @@ -577,4 +647,35 @@ mod tests { assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3)); assert_eq!(replace_in("x", "", "y", true, false).1, 0); } + + /// `snippet_at` returns the whole text when the hit fills it, with the hit at offset 0. + #[test] + fn snippet_reports_the_hit_offset() { + assert_eq!(snippet_at("BOLT-01", "BOLT-01", true, false), Some(("BOLT-01".to_string(), 0, 7))); + } + + /// Matching ignores case but the snippet keeps the text as it is written, so the result list + /// shows the drawing's own spelling. + #[test] + fn snippet_keeps_the_original_case() { + assert_eq!(snippet_at("法兰 bolt-01 安装孔", "BOLT-01", false, false), Some(("法兰 bolt-01 安装孔".to_string(), 3, 7))); + } + + /// A long text keeps [`SNIPPET_CONTEXT`] characters on each side of the hit, counted in + /// characters so a CJK hit is never split. + #[test] + fn snippet_is_windowed_in_characters() { + let text = format!("{}命中{}", "前".repeat(30), "后".repeat(30)); + let (snippet, hit, len) = snippet_at(&text, "命中", true, false).expect("a hit"); + assert_eq!((hit, len), (SNIPPET_CONTEXT, 2)); + assert_eq!(snippet.chars().count(), SNIPPET_CONTEXT * 2 + 2); + } + + /// A whole-word search skips an embedded hit, and text without a hit has no snippet. + #[test] + fn snippet_respects_whole_word() { + assert_eq!(snippet_at("BOLTS", "BOLT", true, true), None); + assert_eq!(snippet_at("a BOLT b", "BOLT", true, true).map(|s| s.1), Some(2)); + assert_eq!(snippet_at("nothing", "BOLT", true, false), None); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index acdddb552..1f8035434 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -23,6 +23,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "units" => units(app, ctx, &mut open), "hatch" => crate::hatchdlg::dialog(app, ctx, &mut open), "aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open), + "find" => crate::finddlg::dialog(app, ctx, &mut open), "about" => about(ctx, &mut open), "preview" => crate::exchange::dialog(app, ctx, &mut open), "commands" => commands(app, ctx, &mut open), diff --git a/crates/ui-egui/src/finddlg.rs b/crates/ui-egui/src/finddlg.rs new file mode 100644 index 000000000..7341f3cfa --- /dev/null +++ b/crates/ui-egui/src/finddlg.rs @@ -0,0 +1,294 @@ +//! Find and Replace: the window behind the FIND command. +//! +//! FIND used to be a command line that asked for its parameters and then printed a count. The +//! parameters are still what the engine takes; this window is the surface where a user can see +//! *what* matched, jump to it and replace one object at a time. It stays open while the drawing +//! is edited — find-and-replace in a drawing is a loop (look, locate, edit, look again), so a +//! window that closed on the first result would be closed and reopened all afternoon. +//! +//! Everything the engine cannot answer is left out rather than faked: the search covers the +//! current space, and the note under the switches says so in as many words. + +use egui::{Color32, RichText, Sense, TextFormat}; +use serde_json::{Value, json}; + +use crate::CadApp; +use crate::theme::{self, Tokens}; + +/// One object the search hit, as the engine reports it. +#[derive(Clone, Default)] +struct Row { + handle: String, + kind: String, + count: u64, + /// The text around the first hit, and where the hit sits inside it (characters, not bytes). + snippet: String, + hit: usize, + hit_len: usize, +} + +/// A `u64` from the reply as a `usize`, or 0 when it does not fit: an offset past the end of a +/// snippet is a bad reply, not a reason to fail the search that produced it. +fn as_index(v: Option<&Value>) -> usize { + v.and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0) +} + +impl Row { + /// Read one `matches` entry; anything missing keeps its default so a partially understood + /// reply still lists something. + fn from_json(v: &Value) -> Self { + Self { + handle: v.get("handle").and_then(Value::as_str).unwrap_or_default().to_string(), + kind: v.get("type").and_then(Value::as_str).unwrap_or_default().to_string(), + count: v.get("count").and_then(Value::as_u64).unwrap_or(0), + snippet: v.get("snippet").and_then(Value::as_str).unwrap_or_default().to_string(), + hit: as_index(v.get("hit")), + hit_len: as_index(v.get("hitLen")), + } + } + + /// The snippet in three pieces: before the hit, the hit, after it. Every slice is by + /// character and every range is clamped, so a reply with a nonsensical offset still draws. + fn pieces(&self) -> (String, String, String) { + let n = self.snippet.chars().count(); + let at = self.hit.min(n); + let end = at.saturating_add(self.hit_len).min(n); + let before: String = self.snippet.chars().take(at).collect(); + let hit: String = self.snippet.chars().skip(at).take(end.saturating_sub(at)).collect(); + let after: String = self.snippet.chars().skip(end).collect(); + (before, hit, after) + } +} + +/// The window's state between frames: kept in egui's temporary store, like the other dialogs, +/// so it lives exactly as long as the window does and never reaches the preferences file. +#[derive(Default, Clone)] +struct State { + find: String, + replace: String, + match_case: bool, + whole_word: bool, + /// The `doc_epoch` and space the rows were found in. A row's handle is only meaningful in + /// the drawing and the space it was found in, so when either moves the rows must go. + epoch: u64, + space: cadcraft_doc::Space, + rows: Vec, + /// The row Enter and Replace act on. + current: Option, + message: String, +} + +/// Run FIND and keep the parsed matches, or the engine's complaint in `message`. +fn search(app: &mut CadApp, st: &mut State) { + if st.find.trim().is_empty() { + st.rows.clear(); + st.current = None; + st.message = crate::i18n::t("Type something to find").to_string(); + return; + } + let params = json!({ "find": st.find, "matchCase": st.match_case, "wholeWord": st.whole_word }); + match app.run("find", params) { + Ok(v) => { + st.rows = v.get("matches").and_then(Value::as_array).map(|a| a.iter().map(Row::from_json).collect()).unwrap_or_default(); + st.current = if st.rows.is_empty() { None } else { Some(0) }; + let total = v.get("total").and_then(Value::as_u64).unwrap_or(0); + let objects = v.get("matches").and_then(Value::as_array).map_or(0, Vec::len); + st.message = format!("{total} match(es) in {objects} object(s)"); + } + Err(e) => { + st.rows.clear(); + st.current = None; + st.message = e; + } + } +} + +/// Select `row` in the drawing and zoom to it: the result list's rows navigate, they do not just +/// report. +fn reveal(app: &mut CadApp, st: &State, index: usize) { + let Some(row) = st.rows.get(index) else { return }; + let Some(h) = cadcraft_doc::Handle::parse_hex(&row.handle) else { return }; + app.session.set_selection(vec![h]); + let _ = app.run("zoom", json!({ "mode": "object" })); +} + +/// Replace inside the objects the result list already holds. `only` limits it to one row, so +/// Replace touches the object the user is looking at and Replace All touches the whole list. +fn replace(app: &mut CadApp, st: &mut State, only: Option) { + if st.find.trim().is_empty() { + return; + } + let handles: Vec = match only { + Some(i) => st.rows.get(i).map(|r| r.handle.clone()).into_iter().collect(), + None => st.rows.iter().map(|r| r.handle.clone()).collect(), + }; + if handles.is_empty() { + return; + } + let params = json!({ + "find": st.find, + "replace": st.replace, + "matchCase": st.match_case, + "wholeWord": st.whole_word, + "handles": handles, + }); + match app.run("find", params) { + Ok(v) => { + // The engine's reply reports what the replacement did; the re-search below would + // overwrite that message with a match count, so the replacement report is what + // the user ends up reading. + let replaced = v.get("message").and_then(Value::as_str).unwrap_or_default().to_string(); + search(app, st); + if !replaced.is_empty() { + st.message = replaced; + } + } + Err(e) => st.message = e, + } +} + +/// The Find and Replace window. +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let t = Tokens::get(); + let sid = egui::Id::unique("find_state"); + let mut st: State = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); + // A handle from an earlier drawing or space now names whatever object took its place; if + // Replace All ran on such a row it would edit an object the search never matched. Clearing + // the rows on an epoch or space change costs a re-click on Find All, which is the cheap + // side of that trade. + if *open && (st.epoch != app.session.doc_epoch || st.space != app.session.space()) { + st.epoch = app.session.doc_epoch; + st.space = app.session.space(); + st.rows.clear(); + st.current = None; + st.message.clear(); + } + // The rows answer one question — this text, with these switches, in this drawing. Editing + // any of them makes the rows stale: Replace All would rewrite with the old switches while + // the user is looking at the new ones, so the rows go and a fresh search is owed. + let criteria = (st.find.clone(), st.match_case, st.whole_word); + let mut do_search = false; + let mut do_replace: Option> = None; + let mut do_select_all = false; + let mut pick: Option = None; + egui::Window::new(crate::tl!("Find and Replace")) + .id(egui::Id::unique("Find and Replace")) + .open(open) + .collapsible(false) + .resizable(true) + .default_width(480.0) + .default_height(400.0) + .show(ctx, |ui| { + egui::Grid::new("find_fields").num_columns(2).spacing(egui::vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(crate::tl!("Find what:")); + let r = ui.add(egui::TextEdit::singleline(&mut st.find).desired_width(f32::INFINITY).hint_text(crate::tl!("Text to find"))); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + do_search = true; + } + ui.end_row(); + ui.label(crate::tl!("Replace with:")); + ui.add(egui::TextEdit::singleline(&mut st.replace).desired_width(f32::INFINITY).hint_text(crate::tl!("Replacement text"))); + ui.end_row(); + }); + ui.horizontal(|ui| { + ui.checkbox(&mut st.match_case, crate::tl!("Match case")); + ui.checkbox(&mut st.whole_word, crate::tl!("Whole words")); + }); + ui.label(RichText::new(crate::tl!("Searches the current space.")).color(t.text_faint).small()); + ui.add_space(2.0); + ui.separator(); + let found = if st.rows.is_empty() { crate::tl!("Nothing found").to_string() } else { st.rows.len().to_string() }; + ui.label(RichText::new(format!("{} · {found}", crate::tl!("Results"))).color(t.text_dim).small()); + let body_h = (ui.available_height() - 76.0).clamp(80.0, 280.0); + egui::Frame::NONE.fill(t.field).corner_radius(3).inner_margin(egui::Margin::same(2)).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(ui.available_width()); + for (i, row) in st.rows.iter().enumerate() { + let bg = if st.current == Some(i) { t.select_bg } else { Color32::TRANSPARENT }; + let inner = egui::Frame::NONE.fill(bg).corner_radius(2).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::t(&row.kind)).color(t.text_dim).small()); + ui.label(RichText::new(row.handle.as_str()).color(t.text_faint).small().monospace()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(format!("×{}", row.count)).color(t.text_faint).small()); + }); + }); + let (before, hit, after) = row.pieces(); + let mut job = egui::text::LayoutJob::default(); + let plain = TextFormat { font_id: theme::small(), color: t.text, ..Default::default() }; + let marked = TextFormat { font_id: theme::mono(), color: accent(), ..Default::default() }; + job.append(&before, 0.0, plain.clone()); + job.append(&hit, 0.0, marked); + job.append(&after, 0.0, plain); + ui.add(egui::Label::new(job).wrap()); + }); + let click = ui.interact(inner.response.rect, ui.scope_id().with(("find_row", i)), Sense::click()); + if click.clicked() { + st.current = Some(i); + pick = Some(i); + } + } + }); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button(crate::tl!("Find All")).clicked() { + do_search = true; + } + let has = !st.rows.is_empty(); + if ui.add_enabled(has, egui::Button::new(crate::tl!("Select All"))).clicked() { + do_select_all = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // An empty replacement is deleting the matched text, which is a legitimate + // replacement, so the buttons care only about having something to act on. + if ui.add_enabled(has, egui::Button::new(crate::tl!("Replace All"))).clicked() { + do_replace = Some(None); + } + if ui.add_enabled(st.current.is_some(), egui::Button::new(crate::tl!("Replace"))).clicked() { + do_replace = Some(st.current); + } + }); + }); + if !st.message.is_empty() { + ui.label(RichText::new(st.message.as_str()).color(t.text_faint).small()); + } + }); + if let Some(i) = pick { + reveal(app, &st, i); + } + if criteria != (st.find.clone(), st.match_case, st.whole_word) { + st.rows.clear(); + st.current = None; + if !do_search { + st.message.clear(); + } + } + if do_search { + search(app, &mut st); + } + if do_select_all { + let hs: Vec = st.rows.iter().filter_map(|r| cadcraft_doc::Handle::parse_hex(&r.handle)).collect(); + if !hs.is_empty() { + app.session.set_selection(hs); + st.message = format!("{} object(s) selected", st.rows.len()); + } + } + if let Some(only) = do_replace { + replace(app, &mut st, only); + } + // The state lives exactly as long as the window does: a closed window leaves nothing behind, + // so reopening it reads the drawing instead of a stale result list. + if *open { + ctx.data_mut(|d| d.insert_temp(sid, st)); + } else { + ctx.data_mut(|d| d.remove_temp::(sid)); + } +} + +/// The colour a hit is drawn in: the theme's point colour, the same orange the drawing uses for +/// points and grips, so the highlight belongs to the drawing rather than to the interface. +fn accent() -> Color32 { + Tokens::get().icon_point +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 7713378d9..cf5741cdd 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1368,3 +1368,15 @@ menu View عرض overrides built-in يتجاوز المدمج {alias} is already defined; select it and use Edit. {alias} معرَّف مسبقاً؛ حدده واستخدم «تحرير». {command} is an unknown command. {command} أمر غير معروف. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index b349db278..957c48993 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1444,3 +1444,15 @@ menu Modify Izmijeni Save changes to block "{name}"? Sačuvati promjene u bloku „{name}”? Layouts Rasporedi Associate Poveži + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 61dc1b2b6..67a6e90f6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1224,3 +1224,15 @@ menu Modify Modifikace Check for Updates… Zkontrolovat aktualizace… CADCraft is up to date. CADCraft je aktuální. Could not check for updates. Aktualizace se nepodařilo zkontrolovat. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f36e9ed02..0925cb6af 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1041,3 +1041,15 @@ Check for Updates… Nach Updates suchen… CADCraft is up to date. CADCraft ist auf dem neuesten Stand. Could not check for updates. Die Suche nach Updates ist fehlgeschlagen. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 528dacf55..3bcf59c0c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1335,3 +1335,15 @@ menu View Προβολή Check for Updates… Έλεγχος για ενημερώσεις… CADCraft is up to date. Το CADCraft είναι ενημερωμένο. Could not check for updates. Δεν ήταν δυνατός ο έλεγχος για ενημερώσεις. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 457341c36..f0e6471ee 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1255,3 +1255,15 @@ Check for Updates… Buscar actualizaciones… CADCraft is up to date. CADCraft está actualizado. Could not check for updates. No se pudo buscar actualizaciones. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62c77c3f9..2f391809e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1014,3 +1014,15 @@ Check for Updates… Rechercher des mises à jour… CADCraft is up to date. CADCraft est à jour. Could not check for updates. Impossible de rechercher les mises à jour. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e486f6337..b87edd3c3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1346,3 +1346,15 @@ menu View Nézet Check for Updates… Frissítések keresése… CADCraft is up to date. A CADCraft naprakész. Could not check for updates. A frissítések keresése nem sikerült. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b437397b6..d69b4fa6c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1364,3 +1364,15 @@ menu Modify Edita Check for Updates… Verifica aggiornamenti… CADCraft is up to date. CADCraft è aggiornato. Could not check for updates. Impossibile verificare gli aggiornamenti. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2f269b051..3f01bb695 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1244,3 +1244,15 @@ menu Modify 수정 Check for Updates… 업데이트 확인… CADCraft is up to date. CADCraft가 최신 버전입니다. Could not check for updates. 업데이트를 확인하지 못했습니다. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 3d8f03449..3d1791c1b 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -1253,6 +1253,18 @@ Check for Updates… Verificar atualizações… CADCraft is up to date. O CADCraft está atualizado. Could not check for updates. Não foi possível verificar atualizações. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found # Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31). Add: Pick points Adicionar: indicar pontos Add: Select objects Adicionar: selecionar objetos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 619988f45..f1a5124f0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1015,3 +1015,15 @@ Check for Updates… Проверить обновления… CADCraft is up to date. У вас последняя версия CADCraft. Could not check for updates. Не удалось проверить обновления. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ecfe35b40..4d86a21be 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1448,3 +1448,15 @@ menu Modify Ändra Check for Updates… Sök efter uppdateringar… CADCraft is up to date. CADCraft är uppdaterat. Could not check for updates. Det gick inte att söka efter uppdateringar. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 24fac6a79..f9f41c798 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -113,6 +113,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { "quick", "vpscale", "options/dialog", + "finddlg", ]; let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut files = Vec::new(); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cc8576aa7..26308975f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1349,3 +1349,15 @@ menu View Görünüm Check for Updates… Güncellemeleri denetle… CADCraft is up to date. CADCraft güncel. Could not check for updates. Güncellemeler denetlenemedi. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d8c6b875..5d0660112 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1373,3 +1373,15 @@ CADCraft is up to date. Встановлено найновішу версію CADCraft. Could not check for updates. Не вдалося перевірити оновлення. Save changes to block "{name}"? Зберегти зміни в блоці «{name}»? + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f1b8dca7b..34eb084eb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1448,3 +1448,15 @@ menu Modify Hiệu chỉnh Change Properties Đổi thuộc tính Font Phông chữ Save Layout as Template Lưu layout thành mẫu + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index de4e6a633..a8d979efa 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -1377,6 +1377,18 @@ Save Layout as Template 将布局另存为样板 Set Dynamic Property 设置动态特性 Units 单位 + Find All 查找全部 + Find what: 查找内容: + Match case 区分大小写 + Replace 替换 + Replace All 全部替换 + Replace with: 替换为: + Replacement text 替换文本 + Results 结果 + Searches the current space. 仅搜索当前空间。 + Text to find 要查找的文字 + Whole words 全字匹配 + Nothing found 未找到 # The strings the report listed as missing for zh. Add… 添加… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3319e639c..3e635ea2f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,8 @@ pub mod credits; pub mod dialogs; pub mod docking; pub mod fault; +// The Find and Replace window behind the FIND command. +pub mod finddlg; pub mod hatchdlg; mod mtext_editor; mod panel_state; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 55d058811..438c06d97 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -73,6 +73,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.cmdline.lines", "Command Line History Lines", &[], None), ("ui.cmdline.clear", "Clear Command History", &[], None), ("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None), + ("ui.dialog.find", "Find and Replace", &[], Some("Cmd+F")), ("ui.language", "Language", &[], None), ("ui.noop", "", &[], None), ("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")), @@ -332,6 +333,12 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { let picked = app.services.pick_open.as_ref().and_then(|f| f()); @@ -456,7 +463,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { + | "ui.dialog.parameters" + | "ui.dialog.find" => { app.ui.dialog = Some(id.trim_start_matches("ui.dialog.").to_string()); Ok(Value::Null) } @@ -816,6 +824,7 @@ pub(crate) fn builtin_shortcuts() -> Vec<(egui::KeyboardShortcut, &'static str)> (sc(cmd, Key::S), "qsave"), (sc(cmd, Key::P), "plot"), (sc(cmd, Key::A), "selectall"), + (sc(cmd, Key::F), "ui.dialog.find"), (sc(cmd_shift, Key::C), "copybase"), (sc(cmd, Key::C), "copyclip"), (sc(cmd, Key::X), "cutclip"), From 5ed192555ca8c3e0fd5c7092163ed3cf0c59ce9a Mon Sep 17 00:00:00 2001 From: Tyler Chiriboga Date: Mon, 2 Nov 2026 21:54:03 -0600 Subject: [PATCH 151/151] EXPLODE: a block reference with different X and Y scales gives ellipses, not circles (#2649) * EXPLODE: a block reference with different X and Y scales gives ellipses, not circles EXPLODE of a block reference whose X and Y scales differ kept its circles as circles with the mean scale, its arcs as circular arcs, its ellipses with their old ratio and its polyline arcs as circular bulges: not the shape the reference draws. BURST and INSERT with explode go the same way. When the reference's transform isn't a similarity, a circle now becomes a full ELLIPSE, an arc an elliptical arc, an ellipse gets its true axes, and a polyline with arc segments (and no widths) its lines and elliptical arcs. Everything else, and every evenly scaled or mirrored reference, transforms as before. The exact ellipse transform and the similarity test move unchanged from snap.rs to cadcraft-geom (Ellipse::transformed, Mat3::similarity_scale) and are shared by object snaps and EXPLODE. Co-Authored-By: Claude Opus 5.5 * contributors: add Tyler-Chiriboga Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 4 + crates/engine/src/cmd/modify/explode.rs | 61 ++++++++- crates/engine/src/snap.rs | 37 +----- crates/engine/tests/it/explode_stretched.rs | 140 ++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/geom/src/curve.rs | 19 ++- crates/geom/src/mat.rs | 8 ++ 7 files changed, 234 insertions(+), 36 deletions(-) create mode 100644 crates/engine/tests/it/explode_stretched.rs diff --git a/contributors/people.toml b/contributors/people.toml index de6d22923..8bb73b9ea 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -100,6 +100,10 @@ sync_github_name = true [people.GULERTOLGA] sync_github_name = true +[people.Tyler-Chiriboga] +real_name = "Tyler Chiriboga" +sync_github_name = true + [people.starise] real_name = "Andrea Brandi" diff --git a/crates/engine/src/cmd/modify/explode.rs b/crates/engine/src/cmd/modify/explode.rs index d7db29d35..b4edc2e80 100644 --- a/crates/engine/src/cmd/modify/explode.rs +++ b/crates/engine/src/cmd/modify/explode.rs @@ -1,19 +1,74 @@ +use cadcraft_geom::{Ellipse as GEllipse, Mat3}; + use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3}; // ---------------- explode / array / order / break / join ---------------- +/// `ell` placed by `m` as an ELLIPSE at elevation `z`; `None` when `m` flattens it. +fn placed_ellipse(ell: GEllipse, m: &Mat3, z: f64) -> Option { + let g = ell.transformed(m); + let len = g.major.len(); + (len.is_finite() && len > 0.0 && g.ratio.is_finite() && g.ratio > 0.0).then(|| { + EntityKind::Ellipse(cadcraft_doc::Ellipse { center: g.center.to3(z), major: g.major.to3(0.0), ratio: g.ratio, start: g.start, end: g.end }) + }) +} + +/// The pieces of block object `be` placed by `m`, a transform that stretches unevenly (a block +/// reference whose X and Y scales differ), where its own kind can't hold the result: a circle +/// or an arc becomes an ellipse or an elliptical arc, a polyline with arc segments (and no +/// widths) its lines and elliptical arcs. `None`: the object transforms as itself. +fn stretched_pieces(be: &Entity, m: &Mat3) -> Option> { + let circular = |center: Vec2, radius: f64, start: f64, end: f64| GEllipse { center, major: Vec2::X * radius, ratio: 1.0, start, end }; + let kinds = match &be.kind { + EntityKind::Circle(c) => vec![placed_ellipse(GEllipse::full(c.center.xy(), Vec2::X * c.radius, 1.0), m, c.center.z)?], + EntityKind::Arc(a) => vec![placed_ellipse(circular(a.center.xy(), a.radius, a.start, a.end), m, a.center.z)?], + EntityKind::Ellipse(el) => { + let g = GEllipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }; + vec![placed_ellipse(g, m, el.center.z)?] + } + EntityKind::LwPolyline(p) + if p.vertices.iter().any(|v| v.bulge != 0.0) + && p.const_width == 0.0 + && p.vertices.iter().all(|v| v.start_width == 0.0 && v.end_width == 0.0) => + { + let z = p.elevation; + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + pl.segments() + .into_iter() + .map(|s| match s { + Segment::Line(l) => Some(EntityKind::Line(cadcraft_doc::Line { a: m.apply(l.a).to3(z), b: m.apply(l.b).to3(z) })), + Segment::Arc { arc: a, .. } => placed_ellipse(circular(a.center, a.radius, a.start, a.end), m, z), + }) + .collect::>>()? + } + _ => return None, + }; + // The pieces of a polyline are new objects, as when it explodes itself: its xdata goes. + let mut common = be.common.clone(); + if matches!(be.kind, EntityKind::LwPolyline(_)) { + common.xdata.clear(); + } + Some(kinds.into_iter().map(|kind| Entity { handle: Handle(0), common: common.clone(), kind }).collect()) +} + fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option> { if let Some(items) = crate::cmd::array::explode_array(d, e) { return Some(items); } let blk = d.block(&ins.block)?; let m = ins.transform(blk.base.xy()); + let stretched = m.similarity_scale().is_none(); Some( blk.entities .iter() - .map(|be| { - let mut ne = (**be).clone(); - ne.kind.transform(&m); + .flat_map(|be| { + stretched.then(|| stretched_pieces(be, &m)).flatten().unwrap_or_else(|| { + let mut ne = (**be).clone(); + ne.kind.transform(&m); + vec![ne] + }) + }) + .map(|mut ne| { if ne.common.layer == "0" { ne.common.layer = e.common.layer.clone(); } diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 8def404d2..e2e24d9bc 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -813,22 +813,13 @@ fn prim_near(p: &Prim, probe: &Bounds2, aperture: f64) -> bool { b.expand(aperture).intersects(probe) } -/// The rotation-and-uniform-scale factor of `m`'s linear part (mirroring allowed), or `None` -/// when it distorts shapes (non-uniform scale or shear). -fn similarity_scale(m: &Mat3) -> Option { - let (u, v) = (Vec2::new(m.a, m.b), Vec2::new(m.c, m.d)); - let (lu, lv) = (u.len(), v.len()); - let tol = 1e-9 * lu.max(lv); - ((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu) -} - /// `p` placed by the affine transform `m`, exactly: circles and arcs under a non-uniform scale /// become ellipses and elliptical arcs. fn xform_prim(p: &Prim, m: &Mat3) -> Prim { let mirror = m.is_mirroring(); match p { Prim::Seg(Segment::Line(l)) => Prim::Seg(Segment::Line(cadcraft_geom::Line::new(m.apply(l.a), m.apply(l.b)))), - Prim::Seg(Segment::Arc { arc, ccw }) => match similarity_scale(m) { + Prim::Seg(Segment::Arc { arc, ccw }) => match m.similarity_scale() { Some(s) => { let c = m.apply(arc.center); let (a0, a1) = (c.angle_to(m.apply(arc.start_point())), c.angle_to(m.apply(arc.end_point()))); @@ -837,14 +828,14 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim { } None => { let e = Ellipse { center: arc.center, major: Vec2::X * arc.radius, ratio: 1.0, start: arc.start, end: arc.end }; - Prim::Ellipse(xform_ellipse(&e, m)) + Prim::Ellipse(e.transformed(m)) } }, - Prim::Circle(c) => match similarity_scale(m) { + Prim::Circle(c) => match m.similarity_scale() { Some(s) => Prim::Circle(Circle::new(m.apply(c.center), c.radius * s)), - None => Prim::Ellipse(xform_ellipse(&Ellipse::full(c.center, Vec2::X * c.radius, 1.0), m)), + None => Prim::Ellipse(Ellipse::full(c.center, Vec2::X * c.radius, 1.0).transformed(m)), }, - Prim::Ellipse(e) => Prim::Ellipse(xform_ellipse(e, m)), + Prim::Ellipse(e) => Prim::Ellipse(e.transformed(m)), Prim::Spline(s) => { let mut s = s.clone(); s.control.iter_mut().chain(s.fit.iter_mut()).for_each(|q| *q = m.apply(*q)); @@ -856,24 +847,6 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim { } } -/// The image of an ellipse (or elliptical arc) under an affine transform: the conjugate -/// semi-diameters `A·major`, `A·minor` give the new principal axes; parameters shift with them -/// (and reverse under a mirroring transform). -fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse { - let center = m.apply(e.center); - let (am, an) = (m.apply_vec(e.major), m.apply_vec(e.minor())); - // |am·cos t + an·sin t| is largest at t0: the new major axis. - let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an)); - let (s0, c0) = t0.sin_cos(); - let (u, v) = (am * c0 + an * s0, an * c0 - am * s0); - let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 }; - if e.is_full() { - return Ellipse::full(center, u, ratio); - } - let (start, end) = if u.cross(v) >= 0.0 { (e.start - t0, e.end - t0) } else { (t0 - e.end, t0 - e.start) }; - Ellipse { center, major: u, ratio, start: cadcraft_geom::norm_angle(start), end: cadcraft_geom::norm_angle(end) } -} - /// The nearest deferred tangent/perpendicular within `aperture` of `cursor`. fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip: Option) -> Option { let mut best: Option<(f64, SnapHit)> = None; diff --git a/crates/engine/tests/it/explode_stretched.rs b/crates/engine/tests/it/explode_stretched.rs new file mode 100644 index 000000000..d7a7ac843 --- /dev/null +++ b/crates/engine/tests/it/explode_stretched.rs @@ -0,0 +1,140 @@ +//! EXPLODE of a block reference whose X and Y scales differ: its circles, arcs, ellipses and +//! polyline arcs come out as the ellipses and elliptical arcs the reference draws, not as circles +//! scaled by the mean of the two scales. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Entity, EntityKind}; +use serde_json::json; + +const AT: [f64; 2] = [100.0, 50.0]; +const ROTATION: f64 = 30.0; + +/// Block S (base 0,0): a circle (r 5 at 0,0), an arc (r 4 at 20,0, 0°..90°), an elliptical arc +/// (centre 40,0, major 6 along X, ratio 0.5, 0°..180°), a slot polyline (two lines, two +/// semicircles of r 2 at 60,0 and 70,0) and a line. One reference to it at `AT`, rotated by +/// `ROTATION`, scaled by `scale`, exploded. +fn exploded(scale: [f64; 2]) -> Vec { + let mut s = Session::new(); + let mut hs = Vec::new(); + for (id, p) in [ + ("circle", json!({"center": [0, 0], "radius": 5})), + ("arc", json!({"center": [20, 0], "radius": 4, "start": 0, "end": 90})), + ("ellipse", json!({"center": [40, 0], "major": [6, 0], "ratio": 0.5, "start": 0, "end": 180})), + ("pline", json!({"vertices": [[60, -2], {"p": [70, -2], "bulge": 1}, [70, 2], {"p": [60, 2], "bulge": 1}], "closed": true})), + ("line", json!({"points": [[0, 10], [10, 10]]})), + ] { + s.execute(id, &p).unwrap(); + hs.push(s.doc().unwrap().model.iter().last().unwrap().handle.hex()); + } + s.execute("block", &json!({"name": "S", "base": [0, 0], "keep": "delete", "handles": hs})).unwrap(); + let r = s.execute("insert", &json!({"name": "S", "at": AT, "scale": scale, "rotation": ROTATION})).unwrap(); + s.execute("explode", &json!({"handles": [r["handle"]]})).unwrap(); + s.doc().unwrap().model.iter().map(|e| (**e).clone()).collect() +} + +/// A drawing point back in block coordinates. +fn to_block(p: [f64; 2], scale: [f64; 2]) -> [f64; 2] { + let (s, c) = (-ROTATION.to_radians()).sin_cos(); + let (dx, dy) = (p[0] - AT[0], p[1] - AT[1]); + [(dx * c - dy * s) / scale[0], (dx * s + dy * c) / scale[1]] +} + +/// Points along an ELLIPSE from its start to its end parameter. +fn samples(e: &cadcraft_engine::doc::Ellipse) -> Vec<[f64; 2]> { + let mut sweep = e.end - e.start; + if sweep <= 0.0 { + sweep += std::f64::consts::TAU; + } + let (mx, my) = (e.major.x, e.major.y); + let (nx, ny) = (-my * e.ratio, mx * e.ratio); + (0..=16) + .map(|i| { + let t = e.start + sweep * f64::from(i) / 16.0; + [e.center.x + mx * t.cos() + nx * t.sin(), e.center.y + my * t.cos() + ny * t.sin()] + }) + .collect() +} + +fn ellipses(v: &[Entity]) -> Vec { + v.iter() + .filter_map(|e| match &e.kind { + EntityKind::Ellipse(el) => Some(el.clone()), + _ => None, + }) + .collect() +} + +fn near(a: [f64; 2], b: [f64; 2]) -> bool { + (a[0] - b[0]).hypot(a[1] - b[1]) < 1e-9 +} + +/// Every point of `el`, taken back into the block, lies on the circle of radius `r` about `c` +/// in block coordinates, and its ends are `ends` (the whole circle when `None`). +fn on_circle(el: &cadcraft_engine::doc::Ellipse, scale: [f64; 2], c: [f64; 2], r: f64, ends: Option<([f64; 2], [f64; 2])>) { + let pts: Vec<[f64; 2]> = samples(el).into_iter().map(|p| to_block(p, scale)).collect(); + for q in &pts { + let d = (q[0] - c[0]).hypot(q[1] - c[1]); + assert!((d - r).abs() < 1e-9, "{q:?} is {d} from {c:?}, not {r}: {el:?}"); + } + if let Some((a, b)) = ends { + let (first, last) = (pts[0], pts[pts.len() - 1]); + assert!(near(first, a) && near(last, b) || near(first, b) && near(last, a), "ends {first:?} {last:?}, expected {a:?} {b:?}"); + } +} + +#[test] +fn explode_of_a_stretched_reference_draws_circles_and_arcs_as_ellipses() { + for scale in [[2.0, 0.5], [-2.0, 0.5], [1.0, 3.0]] { + let v = exploded(scale); + assert!(!v.iter().any(|e| matches!(e.kind, EntityKind::Circle(_) | EntityKind::Arc(_) | EntityKind::LwPolyline(_))), "{scale:?}: {v:?}"); + let els = ellipses(&v); + // Circle, arc, elliptical arc and the slot's two semicircles. + assert_eq!(els.len(), 5, "{scale:?}: {els:?}"); + let at = |c: [f64; 2]| els.iter().filter(move |e| near(to_block([e.center.x, e.center.y], scale), c)); + let circle = at([0.0, 0.0]).next().unwrap(); + on_circle(circle, scale, [0.0, 0.0], 5.0, None); + // Its axes are the scaled radii: 10 and 2.5 for 2 x 0.5. + let major = circle.major.x.hypot(circle.major.y); + let (big, small) = (5.0 * scale[0].abs().max(scale[1].abs()), 5.0 * scale[0].abs().min(scale[1].abs())); + assert!((major - big).abs() < 1e-9 && (major * circle.ratio - small).abs() < 1e-9, "{circle:?}"); + on_circle(at([20.0, 0.0]).next().unwrap(), scale, [20.0, 0.0], 4.0, Some(([24.0, 0.0], [20.0, 4.0]))); + on_circle(at([60.0, 0.0]).next().unwrap(), scale, [60.0, 0.0], 2.0, Some(([60.0, 2.0], [60.0, -2.0]))); + on_circle(at([70.0, 0.0]).next().unwrap(), scale, [70.0, 0.0], 2.0, Some(([70.0, -2.0], [70.0, 2.0]))); + // The elliptical arc: its points, back in the block, are on the block's ellipse + // ((x-40)/6)^2 + (y/3)^2 = 1, from (46,0) over the top to (34,0). + let ell = at([40.0, 0.0]).next().unwrap(); + let pts: Vec<[f64; 2]> = samples(ell).into_iter().map(|p| to_block(p, scale)).collect(); + for q in &pts { + let f = ((q[0] - 40.0) / 6.0).powi(2) + (q[1] / 3.0).powi(2); + assert!((f - 1.0).abs() < 1e-9 && q[1] > -1e-9, "{q:?}: {ell:?}"); + } + let (first, last) = (pts[0], pts[pts.len() - 1]); + assert!(near(first, [46.0, 0.0]) && near(last, [34.0, 0.0]) || near(first, [34.0, 0.0]) && near(last, [46.0, 0.0]), "{pts:?}"); + // The slot's straight sides and the line are lines, where the reference draws them. + let lines: Vec<[[f64; 2]; 2]> = v + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some([to_block([l.a.x, l.a.y], scale), to_block([l.b.x, l.b.y], scale)]), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 3); + for [a, b] in [[[60.0, -2.0], [70.0, -2.0]], [[70.0, 2.0], [60.0, 2.0]], [[0.0, 10.0], [10.0, 10.0]]] { + assert!(lines.iter().any(|l| near(l[0], a) && near(l[1], b)), "{a:?}-{b:?} not in {lines:?}"); + } + } +} + +#[test] +fn explode_of_an_evenly_scaled_or_mirrored_reference_keeps_circles_arcs_and_polylines() { + for scale in [[2.0, 2.0], [-1.0, 1.0]] { + let v = exploded(scale); + let count = |f: fn(&EntityKind) -> bool| v.iter().filter(|e| f(&e.kind)).count(); + assert_eq!(count(|k| matches!(k, EntityKind::Circle(_))), 1, "{scale:?}"); + assert_eq!(count(|k| matches!(k, EntityKind::Arc(_))), 1, "{scale:?}"); + assert_eq!(count(|k| matches!(k, EntityKind::LwPolyline(_))), 1, "{scale:?}"); + assert_eq!(count(|k| matches!(k, EntityKind::Ellipse(_))), 1, "{scale:?}"); + let r = v.iter().find_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None }).unwrap(); + assert!((r - 5.0 * scale[0].abs()).abs() < 1e-9); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index b6ec02d60..9e014d4b1 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -46,6 +46,7 @@ mod draworder; mod dyn_input; mod dynamic_block_data; mod dynamic_grips; +mod explode_stretched; mod extend_polyline_arc_end; mod fillet_chamfer; mod fix_holds_grips; diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index 2b799e433..04f665f88 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{Bounds2, EPS, PI, TAU, Vec2, angle_in_sweep, arc_segments, ccw_sweep, norm_angle}; +use crate::{Bounds2, EPS, Mat3, PI, TAU, Vec2, angle_in_sweep, arc_segments, ccw_sweep, norm_angle}; /// A straight segment. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] @@ -247,6 +247,23 @@ impl Ellipse { pub fn sweep(&self) -> f64 { ccw_sweep(self.start, self.end) } + /// The image under the affine transform `m`, exactly: the conjugate semi-diameters + /// `A·major`, `A·minor` give the new principal axes; parameters shift with them (and + /// reverse under a mirroring transform). + pub fn transformed(&self, m: &Mat3) -> Ellipse { + let center = m.apply(self.center); + let (am, an) = (m.apply_vec(self.major), m.apply_vec(self.minor())); + // |am·cos t + an·sin t| is largest at t0: the new major axis. + let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an)); + let (s0, c0) = t0.sin_cos(); + let (u, v) = (am * c0 + an * s0, an * c0 - am * s0); + let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 }; + if self.is_full() { + return Ellipse::full(center, u, ratio); + } + let (start, end) = if u.cross(v) >= 0.0 { (self.start - t0, self.end - t0) } else { (t0 - self.end, t0 - self.start) }; + Ellipse { center, major: u, ratio, start: norm_angle(start), end: norm_angle(end) } + } pub fn tessellate(&self, tol: f64, out: &mut Vec) { let r = self.major.len(); let sweep = self.sweep(); diff --git a/crates/geom/src/mat.rs b/crates/geom/src/mat.rs index 5f2613540..5f28b068d 100644 --- a/crates/geom/src/mat.rs +++ b/crates/geom/src/mat.rs @@ -98,6 +98,14 @@ impl Mat3 { pub fn is_mirroring(&self) -> bool { self.det() < 0.0 } + /// The rotation-and-uniform-scale factor of the linear part (mirroring allowed), or `None` + /// when it distorts shapes (non-uniform scale or shear). + pub fn similarity_scale(&self) -> Option { + let (u, v) = (Vec2::new(self.a, self.b), Vec2::new(self.c, self.d)); + let (lu, lv) = (u.len(), v.len()); + let tol = 1e-9 * lu.max(lv); + ((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu) + } } /// A 4x4 transform, row-major, for 3D views and block transforms with elevation.