Browse / github.com/storytold/cadcraft / patch
DXF/DWG: keep dimension associativity CADCraft doesn't model
DXF/DWG: keep dimension associativity CADCraft doesn't model Follows #1651. Part of FF1 / gaps.md top-10 #1 ("Saving DWG drops what we don't model"): one of the losses `cadcraft-cli audit` (PR 4) shows on real drawings. **What** CADCraft writes DIMASSOC objects for dimensions whose association it models, but dropped every other `ACAD_DIMASSOC` entry on save, so dimensions in blocks and dimensions linked by snaps CADCraft doesn't infer (nearest, perpendicular, through a block insert) lost their associativity in every saved file. - `crates/io/src/dxf_raw.rs`: an `ACAD_DIMASSOC` entry counts as modelled only when the dimension has its own association (
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- adirbiton (imported from pull request #2096, open)
- Base
41d9b5e957b0719c04b9687c4f29088d77153c31- Size
- +267 −9 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2a387d0595d059f684c22a7c
How it went
No other operator has reported using it yet.
Diff
raw · crates/doc/src/foreign.rs crates/engine/src/cmd/annotate.rs crates/engine/tests/dimdisassociate_kept.rs crates/io/src/dxf_raw.rs crates/io/tests/dimassoc_kept.rs crates/io/tests/support/dimassoc.rs docs/file-format-parity.md
@@ -89,8 +89,31 @@ impl Foreign { && self.data_store.is_empty() } + /// Whether a kept association (DIMASSOC object) links dimension `dim`: one CADCraft doesn't+ /// model, written back as it was read.+ pub fn has_dimassoc(&self, dim: Handle) -> bool {+ self.objects.iter().any(|o| dimassoc_of(o) == Some(dim))+ }++ /// Drop the kept association of dimension `dim` (DIMDISASSOCIATE); whether it had one. Its+ /// extension dictionary entry and the dimension's reactor to it go with it on save.+ pub fn drop_dimassoc(&mut self, dim: Handle) -> bool {+ let before = self.objects.len();+ self.objects.retain(|o| dimassoc_of(o) != Some(dim));+ self.objects.len() != before+ }+ /// The entities the data store has records for (their 320 handles). pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> { self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect() } }++/// The dimension a kept DIMASSOC object links: the first 330 after its AcDbDimAssoc marker.+fn dimassoc_of(o: &ForeignObject) -> Option<Handle> {+ if !o.kind.trim().eq_ignore_ascii_case("DIMASSOC") {+ return None;+ }+ let mut body = o.tags.iter().skip_while(|t| !(t.code == 100 && t.value.trim() == "AcDbDimAssoc"));+ body.find(|t| t.code == 330).and_then(|t| Handle::parse_hex(t.value.trim()))+}@@ -602,19 +602,26 @@ fn run_reassociate(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "dimensions": hs.len(), "associated": n, "points": points, "message": format!("{n} of {} dimension(s) associated.", hs.len()) })) } +/// DIMDISASSOCIATE: clears the association of the given dimensions (the modelled one and a kept DIMASSOC read from the file); returns how many had one. fn run_disassociate(s: &mut Session, p: &Value) -> Result<Value> { let hs = dims_of(s, &targets(s, p)?); let d = s.doc_mut()?; let mut n = 0; for h in &hs {+ let mut had = false; let _ = d.modify_entity(*h, |e| { if let EntityKind::Dimension(dm) = &mut e.kind && !dm.assoc.is_empty() { dm.assoc.clear();- n += 1;+ had = true; } });+ // An association read from the file that CADCraft doesn't model (kept as it was).+ if d.foreign.has_dimassoc(*h) {+ had |= std::sync::Arc::make_mut(&mut d.foreign).drop_dimassoc(*h);+ }+ n += usize::from(had); } Ok(json!({ "disassociated": n, "message": format!("{n} disassociated.") })) }new file mode 100644@@ -0,0 +1,27 @@+//! DIMDISASSOCIATE on dimensions whose association CADCraft keeps without modelling it (FF1): the+//! kept DIMASSOC goes, so the saved file's dimension is no longer associative; UNDO brings it back.++#[path = "../../io/tests/support/dimassoc.rs"]+mod fixture;++use cadcraft_doc::Handle;+use cadcraft_engine::Session;+use serde_json::json;++/// DIMDISASSOCIATE drops only the chosen dimension's kept DIMASSOC; UNDO brings it back.+#[test]+fn disassociating_drops_the_kept_association() -> Result<(), Box<dyn std::error::Error>> {+ let mut s = Session::empty();+ s.open_drawing(cadcraft_io::read_dxf(fixture::source().as_bytes())?, "kept", None);+ let kept = |s: &Session, h: u64| s.doc().map(|d| d.foreign.has_dimassoc(Handle(h)));+ assert!(kept(&s, 0x51)? && kept(&s, 0x31)?);+ let r = s.execute("dimdisassociate", &json!({ "handles": ["51"] }))?;+ assert_eq!(r["disassociated"], 1);+ assert!(!kept(&s, 0x51)? && kept(&s, 0x31)?, "only the chosen dimension's");+ let text = cadcraft_io::write_dxf(s.doc()?);+ let written = cadcraft_dxf::parse(text.as_bytes())?.iter().filter(|t| t.code == 0 && t.str() == "DIMASSOC").count();+ assert_eq!(written, 1);+ s.execute("undo", &json!({}))?;+ assert!(kept(&s, 0x51)?);+ Ok(())+}@@ -38,9 +38,22 @@ const MODELLED_KEYS: &[&str] = &[ /// Extension dictionary entries not kept raw: dimension associativity (CADCraft reads and /// rewrites it), draw order and annotation scale contexts (written from their own models by the-/// changes that model them; stale copies would contradict the drawing).+/// changes that model them; stale copies would contradict the drawing). A dimension's association+/// CADCraft didn't model (one in a block, or linked by object snaps it doesn't infer) is kept, see+/// [`modelled_xdict_key`]. const MODELLED_XDICT_KEYS: &[&str] = &["ACAD_DIMASSOC", "ACAD_SORTENTS", "ACDBCONTEXTDATAMANAGER"]; +/// The extension dictionary entry of a dimension's association (its DIMASSOC object).+const DIMASSOC_KEY: &str = "ACAD_DIMASSOC";++/// Whether an extension dictionary entry is written from CADCraft's model rather than kept: a+/// dimension's DIMASSOC only when CADCraft read the association into the dimension (`owner` is+/// the dictionary's owner; `own_assoc` the dimensions that have one), as it then writes its own.+fn modelled_xdict_key(key: &str, owner: Option<&str>, own_assoc: &HashSet<String>) -> bool {+ let k = up(key);+ MODELLED_XDICT_KEYS.contains(&k.as_str()) && (k != DIMASSOC_KEY || owner.is_none_or(|o| own_assoc.contains(o)))+}+ /// Applications whose xdata CADCraft reads and writes itself. `ACAD` holds dimension overrides /// (DSTYLE) on dimensions and dimension styles; elsewhere it is kept. const OWN_APPS: &[&str] = &["CADCRAFT", "ACADANNOTATIVE", "ACCMTRANSPARENCY", "ACAECLAYERSTANDARD"];@@ -332,6 +345,9 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) { for e in all_entities(d) { ents.entry(e.handle.hex()).or_insert(matches!(e.kind, EntityKind::Unknown(_))); }+ // Dimensions whose association CADCraft read (and writes back from its model).+ let own_assoc: HashSet<String> =+ all_entities(d).filter(|e| matches!(&e.kind, EntityKind::Dimension(dm) if !dm.assoc.is_empty())).map(|e| e.handle.hex()).collect(); let ids = identities(&tables, &blocks, &objects); // Extension dictionaries of entities and records: owner → dictionary. let mut xdict_of: HashMap<String, String> = HashMap::new();@@ -361,7 +377,10 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) { let (Some(h), Some(o)) = (hd.handle, hd.owner) else { return false }; !by_handle.contains_key(&o) && xdict_of.get(&o) == Some(&h) && (ents.contains_key(&o) || ids.contains_key(&o)) };- let modelled_entry = |t: &[Tag], h: &str| entries(t).iter().any(|(k, eh)| eh == h && MODELLED_XDICT_KEYS.contains(&up(k).as_str()));+ let modelled_entry = |t: &[Tag], h: &str| {+ let owner = head(t).owner;+ entries(t).iter().any(|(k, eh)| eh == h && modelled_xdict_key(k, owner.as_deref(), &own_assoc))+ }; // Which objects to keep: those under a named object dictionary entry CADCraft doesn't model, // under the extension dictionary of an entity or record (minus modelled entries; one left // empty goes), or owned by an entity of a type CADCraft doesn't model. Memoized along each@@ -395,7 +414,7 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) { continue; } if is_xdict(cur) {- break entries(t).iter().any(|(k, _)| !MODELLED_XDICT_KEYS.contains(&up(k).as_str()));+ break entries(t).iter().any(|(k, _)| !modelled_xdict_key(k, Some(o.as_str()), &own_assoc)); } break ents.get(&o) == Some(&true); };@@ -412,7 +431,7 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) { let Some(h) = head(t).handle.and_then(|h| handle(&h)) else { continue }; let mut tags: Vec<RawTag> = t.iter().map(raw).collect(); if is_xdict(i) {- strip_entries(&mut tags);+ strip_entries(&mut tags, head(t).owner.as_deref(), &own_assoc); } if head(t).owner == root && let Some(key) = root_key(&h.hex())@@ -588,12 +607,13 @@ pub(crate) fn data_store(tags: &mut Vec<Tag>, d: &Drawing, modern: bool) { tags.splice(at..at, out); } -/// Drop a dictionary's modelled entries (key and handle) from its groups.-fn strip_entries(tags: &mut Vec<RawTag>) {+/// Drop a dictionary's modelled entries (key and handle) from its groups (`owner`: the+/// dictionary's owner, for [`modelled_xdict_key`]).+fn strip_entries(tags: &mut Vec<RawTag>, owner: Option<&str>, own_assoc: &HashSet<String>) { let mut out = Vec::with_capacity(tags.len()); let mut i = 0; while let Some(t) = tags.get(i) {- if t.code == 3 && MODELLED_XDICT_KEYS.contains(&up(&t.value).as_str()) && tags.get(i + 1).is_some_and(|n| matches!(n.code, 350 | 360)) {+ if t.code == 3 && modelled_xdict_key(&t.value, owner, own_assoc) && tags.get(i + 1).is_some_and(|n| matches!(n.code, 350 | 360)) { i += 2; continue; }@@ -922,6 +942,14 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) { alive_memo.insert(p, verdict); } }+ // A kept association goes only with everything it links: a dimension whose geometry is gone+ // is no longer associative (as when it is erased in AutoCAD), not linked to nothing.+ for o in f.objects.iter().filter(|o| up(&o.kind) == "DIMASSOC") {+ let linked = o.tags.iter().filter(|t| matches!(t.code, 331 | 332)).filter_map(|t| handle(&t.value)).all(|h| written.contains(&h.hex()));+ if !linked {+ alive_memo.insert(o.handle.hex(), false);+ }+ } // New handles for the written kept objects, above every written handle and the seed. let mut next = seed(tags).max(max_handle(tags).saturating_add(1)); let mut alive: HashMap<String, String> = HashMap::new();new file mode 100644@@ -0,0 +1,112 @@+//! Dimension associativity CADCraft doesn't model (FF1): a DIMASSOC of a dimension in a block, or+//! linked by an object snap CADCraft doesn't infer, is kept and written back with its references+//! moved, through DXF and DWG; erasing the geometry it links drops it (the dimension is then no+//! longer associative, as in AutoCAD). Original synthetic DXF after the DXF Reference (DIMENSION,+//! DIMASSOC, DICTIONARY). The DWG oracle shares acadrust 0.6.5; no AutoCAD validation is claimed.++#[path = "support/dimassoc.rs"]+mod fixture;++use cadcraft_doc::{EntityKind, Handle};+use cadcraft_dxf::Tag;+use fixture::source;++type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;++/// For each DIMASSOC written: (its dimension, the geometry it links), checked to be reachable the+/// way AutoCAD reaches it (the dimension's extension dictionary, entry ACAD_DIMASSOC) and named in+/// the dimension's reactors.+fn written(text: &str) -> TestResult<Vec<(String, String)>> {+ let tags = cadcraft_dxf::parse(text.as_bytes())?;+ let recs = cadcraft_dxf::records(&tags);+ let get = |r: &[Tag], c: i32| r.iter().find(|t| t.code == c).map(|t| t.str());+ let group = |r: &[Tag], name: &str| -> Vec<String> {+ r.iter().skip_while(|t| t.str() != format!("{{{name}")).skip(1).take_while(|t| t.code != 102).map(Tag::str).collect()+ };+ let by_handle = |h: &str| recs.iter().find(|(_, r)| get(r, 5).as_deref() == Some(h));+ let mut out = Vec::new();+ for (_, r) in recs.iter().filter(|(k, _)| k == "DIMASSOC") {+ let me = get(r, 5).ok_or("handle")?;+ let body: Vec<&Tag> = r.iter().skip_while(|t| t.str() != "AcDbDimAssoc").collect();+ let dim = body.iter().find(|t| t.code == 330).map(|t| t.str()).ok_or("dimension")?;+ let geometry = body.iter().find(|t| t.code == 331).map(|t| t.str()).ok_or("geometry")?;+ let (kind, d) = by_handle(&dim).ok_or("dimension written")?;+ assert_eq!(kind, "DIMENSION");+ assert!(group(d, "ACAD_REACTORS").contains(&me), "{dim} names its DIMASSOC {me} as a reactor");+ let xd = group(d, "ACAD_XDICTIONARY").into_iter().next().ok_or("extension dictionary")?;+ let (_, x) = by_handle(&xd).ok_or("extension dictionary written")?;+ assert!(x.windows(2).any(|w| w[0].code == 3 && w[0].str() == "ACAD_DIMASSOC" && w[1].str() == me));+ assert!(by_handle(&geometry).is_some_and(|(k, _)| k == "LINE"), "{geometry} is written");+ out.push((dim, geometry));+ }+ out.sort();+ Ok(out)+}++/// Both fixture associations: (dimension, linked line).+fn both() -> Vec<(String, String)> {+ vec![("31".into(), "30".into()), ("51".into(), "50".into())]+}++/// Associations CADCraft does not model are written back, through two DXF saves.+#[test]+fn associations_cadcraft_doesnt_model_are_written_back_with_their_references() -> TestResult {+ let d = cadcraft_io::read_dxf(source().as_bytes())?;+ // Neither is in CADCraft's model: one is in a block, the other's snap isn't inferred.+ let dims = d.model.iter().chain(d.blocks.values().flat_map(|b| b.entities.iter()));+ assert!(dims.filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm) } else { None }).all(|dm| dm.assoc.is_empty()));+ let text = cadcraft_io::write_dxf(&d);+ assert_eq!(written(&text)?, both());+ // Read back and saved again, they stay.+ let back = cadcraft_io::read_dxf(text.as_bytes())?;+ assert_eq!(written(&cadcraft_io::write_dxf(&back))?, both());+ Ok(())+}++/// Erasing the linked line drops its association and leaves no dangling reference.+#[test]+fn erasing_the_linked_geometry_drops_the_association() -> TestResult {+ let mut d = cadcraft_io::read_dxf(source().as_bytes())?;+ assert!(d.model.remove(Handle(0x50)).is_some());+ let text = cadcraft_io::write_dxf(&d);+ assert_eq!(written(&text)?, vec![("31".to_string(), "30".to_string())]);+ // No reference to the dropped object is left behind (the dimension's reactors among them).+ let tags = cadcraft_dxf::parse(text.as_bytes())?;+ let handles: std::collections::HashSet<String> = tags.iter().filter(|t| t.code == 5).map(Tag::str).collect();+ for t in tags.iter().filter(|t| matches!(t.code, 330 | 331 | 350 | 360)) {+ assert!(t.str() == "0" || handles.contains(&t.str()), "dangling {} {}", t.code, t.str());+ }+ Ok(())+}++/// Both associations go into the DWG and survive a second DWG save.+#[cfg(not(target_arch = "wasm32"))]+#[test]+fn associations_go_into_the_dwg_and_back() -> TestResult {+ use acadrust::objects::ObjectType;+ let d = cadcraft_io::read_dxf(source().as_bytes())?;+ let mut bytes = cadcraft_io::write_with(&d, "out.dwg", &cadcraft_io::SaveOptions::default())?;+ for _ in 0..2 {+ let out = acadrust::DwgReader::from_stream(std::io::Cursor::new(&bytes)).read_with_stats()?;+ assert_eq!((out.stats.skipped_source_records, out.stats.recovered_errors), (0, 0));+ let mut found: Vec<(u64, bool)> = out+ .document+ .objects+ .values()+ .filter_map(|o| match o {+ ObjectType::Associative(a) if a.dxf_name.eq_ignore_ascii_case("DIMASSOC") => {+ let dim = [0x31u64, 0x51].into_iter().find(|h| a.references_handle(acadrust::Handle::new(*h)))?;+ let geometry = acadrust::Handle::new(if dim == 0x31 { 0x30 } else { 0x50 });+ Some((dim, a.references_handle(geometry)))+ }+ _ => None,+ })+ .collect();+ found.sort_unstable();+ assert_eq!(found, [(0x31, true), (0x51, true)]);+ // The reopened DWG saves them again.+ let back = cadcraft_io::read(&bytes, "out.dwg")?;+ bytes = cadcraft_io::write_with(&back, "out.dwg", &cadcraft_io::SaveOptions::default())?;+ }+ Ok(())+}new file mode 100644@@ -0,0 +1,51 @@+//! An original synthetic DXF after the DXF Reference (DIMENSION, DIMASSOC, DICTIONARY): dimension+//! associativity CADCraft doesn't model. Shared by the I/O and engine tests (by file path).++/// A LINE record with handle `h` in the space or block `owner`, at height `y`.+fn line(h: &str, owner: &str, y: f64) -> String {+ format!("0\nLINE\n5\n{h}\n330\n{owner}\n100\nAcDbEntity\n8\n0\n100\nAcDbLine\n10\n0\n20\n{y}\n30\n0\n11\n10\n21\n{y}\n31\n0\n")+}++/// An aligned dimension along the line at `y`, with its extension dictionary and its reactor to+/// the DIMASSOC.+fn dimension(h: &str, owner: &str, xdict: &str, assoc: &str, y: f64) -> String {+ format!(+ "0\nDIMENSION\n5\n{h}\n102\n{{ACAD_REACTORS\n330\n{assoc}\n102\n}}\n102\n{{ACAD_XDICTIONARY\n360\n{xdict}\n102\n}}\n330\n{owner}\n\+ 100\nAcDbEntity\n8\n0\n100\nAcDbDimension\n10\n10\n20\n{d}\n30\n0\n11\n5\n21\n{d}\n31\n0\n70\n33\n3\nStandard\n\+ 100\nAcDbAlignedDimension\n13\n0\n23\n{y}\n33\n0\n14\n10\n24\n{y}\n34\n0\n",+ d = y + 2.+ )+}++/// The extension dictionary and the DIMASSOC: the dimension's first point on `geometry` by+/// object snap `osnap` (10 nearest, 8 perpendicular: neither inferred by CADCraft for a line).+fn assoc(xdict: &str, h: &str, dim: &str, geometry: &str, osnap: u8, y: f64) -> String {+ format!(+ "0\nDICTIONARY\n5\n{xdict}\n330\n{dim}\n100\nAcDbDictionary\n280\n1\n281\n1\n3\nACAD_DIMASSOC\n360\n{h}\n\+ 0\nDIMASSOC\n5\n{h}\n102\n{{ACAD_REACTORS\n330\n{xdict}\n102\n}}\n330\n{xdict}\n100\nAcDbDimAssoc\n330\n{dim}\n90\n1\n70\n0\n71\n0\n\+ 1\nAcDbOsnapPointRef\n72\n{osnap}\n331\n{geometry}\n73\n0\n91\n0\n40\n0.0\n10\n0\n20\n{y}\n30\n0\n75\n0\n"+ )+}++/// A block holding a line and a dimension on it (nearest snap), and a model space line with a+/// dimension (perpendicular snap).+pub fn source() -> String {+ let mut s = String::from("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$HANDSEED\n5\n100\n0\nENDSEC\n");+ s.push_str("0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\nDIMASSOC\n2\nAcDbDimAssoc\n3\nObjectDBX Classes\n90\n0\n280\n0\n281\n0\n0\nENDSEC\n");+ s.push_str("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n5\n1\n70\n2\n");+ s.push_str("0\nBLOCK_RECORD\n5\n1F\n330\n1\n100\nAcDbSymbolTableRecord\n100\nAcDbBlockTableRecord\n2\n*Model_Space\n");+ s.push_str("0\nBLOCK_RECORD\n5\n20\n330\n1\n100\nAcDbSymbolTableRecord\n100\nAcDbBlockTableRecord\n2\nWALL\n");+ s.push_str("0\nENDTAB\n0\nENDSEC\n0\nSECTION\n2\nBLOCKS\n");+ s.push_str("0\nBLOCK\n5\n21\n330\n20\n100\nAcDbEntity\n8\n0\n100\nAcDbBlockBegin\n2\nWALL\n70\n0\n10\n0\n20\n0\n30\n0\n3\nWALL\n1\n\n");+ s.push_str(&line("30", "20", 0.));+ s.push_str(&dimension("31", "20", "40", "41", 0.));+ s.push_str("0\nENDBLK\n5\n22\n330\n20\n100\nAcDbEntity\n8\n0\n100\nAcDbBlockEnd\n0\nENDSEC\n");+ s.push_str("0\nSECTION\n2\nENTITIES\n");+ s.push_str(&line("50", "1F", 20.));+ s.push_str(&dimension("51", "1F", "60", "61", 20.));+ s.push_str("0\nENDSEC\n0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n281\n1\n");+ s.push_str(&assoc("40", "41", "31", "30", 10, 0.));+ s.push_str(&assoc("60", "61", "51", "50", 8, 20.));+ s.push_str("0\nENDSEC\n0\nEOF\n");+ s+}@@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (dimension associativity CADCraft doesn't model kept through DXF and DWG) · **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@@ -101,6 +101,15 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | Objects: dynamic block data (ACAD_EVALUATION_GRAPH, BLOCK…PARAMETER, BLOCK…ACTION, grips) | read into a model (`dynblock`, `blocks.dynamic`): parameters, visibility states, value sets, stretch and lookup actions; references: their dynamic block, values and custom properties (`blocks.reference`, Properties ▸ Custom, read-only); kept raw (#528) | written back as read; a changed visibility state is written as AutoCAD writes it (a new anonymous block with its `AcDbBlockRepBTag`, the objects' `AcDbBlockRepETag`, the reference's representation data) | visibility states (and lookups that set only them) can be changed (`blocks.reference.set`, Properties ▸ Custom); distances, positions and the actions they drive not yet; DWG open and save keep it too (checked with an AutoCAD dynamic block) | | Handles and owners | entity handles kept; others regenerated | consistent | stable across CADCraft re-saves (#495); other programs' table and object handles renumbered once | +**Dimension associativity CADCraft doesn't model.** CADCraft models the association of model and+paper space dimensions whose points it can infer and writes those DIMASSOC objects itself. Any+other DIMASSOC (a dimension in a block, or points linked by snaps CADCraft doesn't infer, such as+nearest, perpendicular or through a block insert) is kept as read and written back with its+references moved, through DXF and DWG (`dimassoc_kept`). A kept association whose linked geometry+was erased is dropped, so the dimension is no longer associative, as when the geometry is erased+in AutoCAD; DIMDISASSOCIATE drops it too. Moving the geometry in CADCraft doesn't move such a+dimension (AutoCAD re-evaluates it on its next edit). Before, every one of them was lost on save.+ **DXF ≈ 50% read, ≈ 30% write** (estimated; weights: common 2D entities 50%, annotation objects 20%, tables/objects 15%, foreign data preservation 15%). @@ -140,6 +149,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Dimension associativity CADCraft doesn't model (dimensions in blocks, uninferred snaps) kept and written back through DXF and DWG; dropped with erased geometry or by DIMDISASSOCIATE | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |