collective

Browse / github.com/storytold/cadcraft / patch

DXF/DWG: keep draw order (SORTENTSTABLE) through saves

DXF/DWG: keep draw order (SORTENTSTABLE) through saves 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** Reading already applied a space's SORTENTSTABLE (#439), but nothing wrote one, so a drawing's draw order (including DRAWORDER results) was lost on save for programs that draw by the table, AutoCAD among them. - `crates/io/src/draw_order.rs` (new): after `dxf_raw::finish`, every space drawn out of handle order gets a SORTENTSTABLE in its block record's extension dictionary (`ACAD_SORTENTS`; the dictionary is created when the record has no

Status
open
Score
0.00
Adopters
0 other operators
Author
adirbiton (imported from pull request #2094, open)
Base
41d9b5e957b0719c04b9687c4f29088d77153c31
Size
+504 −18 in 8 files
Review
pending
Shared
2026-10-28
Id
18fca700281b6ac9a5bd3c7f

How it went

No other operator has reported using it yet.

Diff

raw · crates/dwg/src/lib.rs crates/engine/src/cmd/draworder.rs crates/io/src/draw_order.rs crates/io/src/dxf_raw.rs crates/io/src/dxf_write.rs crates/io/src/lib.rs crates/io/tests/it/sortents.rs docs/file-format-parity.md

diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs--- a/crates/dwg/src/lib.rs+++ b/crates/dwg/src/lib.rs@@ -106,6 +106,7 @@ mod native {         let mut doc = outcome.document;         current_style_names(&mut doc);         table_merges(&mut doc);+        complete_sort_tables(&mut doc);         table_style_rows(&mut doc, false);         let orphans = drop_orphaned_dgn_components(&mut doc);         if orphans > 0 {@@ -231,6 +232,53 @@ mod native {         })     } +    /// Draw order (SORTENTSTABLE) with every entity of its block listed. acadrust 0.6.5's DXF+    /// writer replaces a table's sort handles by fresh handles in the same order, all above the+    /// file's handles, while an entity the table doesn't list is drawn by its own handle: every+    /// listed entity would move above every unlisted one. Listing the others by their own handles+    /// keeps the source's order through the renumbering. One table per block is completed (the+    /// one its extension dictionary names under `ACAD_SORTENTS`, else the lowest handle), so a+    /// file with many tables for one block costs no more than its entity count.+    fn complete_sort_tables(doc: &mut acadrust::CadDocument) {+        use acadrust::objects::ObjectType;+        let mut chosen: std::collections::HashMap<acadrust::Handle, acadrust::Handle> = std::collections::HashMap::new();+        for (h, o) in &doc.objects {+            if let ObjectType::SortEntitiesTable(t) = o {+                let slot = chosen.entry(t.block_owner_handle).or_insert(*h);+                if h.value() < slot.value() {+                    *slot = *h;+                }+            }+        }+        for b in doc.block_records.iter() {+            let named = doc.extension_dictionary_handle(b.handle).and_then(|x| match doc.objects.get(&x) {+                Some(ObjectType::Dictionary(d)) => d.entries.iter().find(|(k, _)| k.eq_ignore_ascii_case("ACAD_SORTENTS")).map(|(_, h)| *h),+                _ => None,+            });+            if let Some(n) = named+                && matches!(doc.objects.get(&n), Some(ObjectType::SortEntitiesTable(t)) if t.block_owner_handle == b.handle)+            {+                chosen.insert(b.handle, n);+            }+        }+        let mut missing: Vec<(acadrust::Handle, Vec<acadrust::Handle>)> = Vec::new();+        for b in doc.block_records.iter() {+            let Some(h) = chosen.get(&b.handle) else { continue };+            let Some(ObjectType::SortEntitiesTable(t)) = doc.objects.get(h) else { continue };+            let add: Vec<_> = b.entity_handles.iter().copied().filter(|e| !t.contains(*e)).collect();+            if !add.is_empty() {+                missing.push((*h, add));+            }+        }+        for (h, add) in missing {+            if let Some(ObjectType::SortEntitiesTable(t)) = doc.objects.get_mut(&h) {+                for e in add {+                    t.add_entry(e, e);+                }+            }+        }+    }+     /// Merged table cells onto the cells. acadrust 0.6.3 reads a 2010+ (AC1024+) DWG table's     /// merged cells into the table's `merged_ranges` only, while its DXF writer writes each cell's     /// own merge groups (173, 175, 176), so without this the merges would be lost. Tables whose@@ -639,6 +687,53 @@ mod native {             }         }     }++    #[cfg(test)]+    mod sort_table_tests {+        use acadrust::entities::Line;+        use acadrust::objects::{ObjectType, SortEntitiesTable};+        use acadrust::{CadDocument, DxfVersion, EntityType, Handle};++        /// A sort table for model space listing `listed`, owned by `owner`.+        fn table(doc: &mut CadDocument, ms: Handle, owner: Handle, listed: Handle) -> Handle {+            let h = doc.allocate_handle();+            let mut t = SortEntitiesTable::for_block(ms);+            t.handle = h;+            t.owner_handle = owner;+            t.add_entry(listed, listed);+            doc.objects.insert(h, ObjectType::SortEntitiesTable(t));+            h+        }++        /// Entries of the sort table `h`.+        fn entries(doc: &CadDocument, h: Handle) -> usize {+            match doc.objects.get(&h) {+                Some(ObjectType::SortEntitiesTable(t)) => t.entries().count(),+                _ => 0,+            }+        }++        /// Two tables for model space: only the one its extension dictionary names gets the others.+        #[test]+        fn only_the_table_the_block_names_is_completed() -> Result<(), Box<dyn std::error::Error>> {+            let mut doc = CadDocument::with_version(DxfVersion::AC1032);+            let mut lines = Vec::new();+            for i in 0..3 {+                let y = f64::from(i);+                lines.push(doc.add_entity(EntityType::Line(Line::from_coords(0., y, 0., 10., y, 0.)))?);+            }+            let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;+            let xd = doc.ensure_extension_dictionary(ms);+            // A stray table first (lower handle), then the one the block record names.+            let stray = table(&mut doc, ms, xd, lines[2]);+            let named = table(&mut doc, ms, xd, lines[0]);+            let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&xd) else { return Err("extension dictionary".into()) };+            d.entries.push(("ACAD_SORTENTS".into(), named));+            super::complete_sort_tables(&mut doc);+            assert_eq!((entries(&doc, named), entries(&doc, stray)), (3, 1));+            Ok(())+        }+    } }  #[cfg(not(target_arch = "wasm32"))]diff --git a/crates/engine/src/cmd/draworder.rs b/crates/engine/src/cmd/draworder.rs--- a/crates/engine/src/cmd/draworder.rs+++ b/crates/engine/src/cmd/draworder.rs@@ -1,6 +1,7 @@ //! Draw order: DRAWORDER (Above objects / Under objects / Front / Back), TEXTTOFRONT and //! HATCHTOBACK. The order is the order of a space's entity store, which DXF files keep as the-//! order of their entity records (`cadcraft-io` also applies a SORTENTSTABLE when it reads one).+//! order of their entity records (`cadcraft-io` applies a SORTENTSTABLE when it reads one, and+//! writes one for every space drawn out of handle order).  use std::collections::{HashMap, HashSet}; diff --git a/crates/io/src/draw_order.rs b/crates/io/src/draw_order.rsnew file mode 100644--- /dev/null+++ b/crates/io/src/draw_order.rs@@ -0,0 +1,217 @@+//! Draw order in the written file (DXF Reference, SORTENTSTABLE). CADCraft keeps a space's draw+//! order as the order of its entities (DRAWORDER moves them; reading applies the file's table).+//! The writer puts the entity records in that order; [`finish`] adds, for each space whose order+//! isn't ascending handle order, a SORTENTSTABLE in its block record's extension dictionary, so+//! programs that draw by the table (AutoCAD among them) draw the same order. The table lists each+//! entity whose place doesn't match its own handle, with the handle at that place in ascending+//! order as its sort handle: the same handle swap DRAWORDER makes in AutoCAD's own files.++use std::collections::{HashMap, HashSet};++use cadcraft_doc::Handle;+use cadcraft_dxf::Tag;++use crate::dxf_raw::{head, max_handle, seed, seed_index, split, val};++/// Records in the ENTITIES and BLOCKS sections that aren't entities of a space: structure, and+/// sub-entities (drawn with the entity that owns them).+const NOT_DRAWN: &[&str] = &["SECTION", "ENDSEC", "BLOCK", "ENDBLK", "SEQEND", "VERTEX", "ATTRIB", "EOF"];++/// The extension dictionary entry AutoCAD looks a block record's table up by.+const KEY: &str = "ACAD_SORTENTS";++/// A space's table: the entities whose sort handle isn't their own, as (entity, sort handle).+fn table(order: &[Handle]) -> Option<Vec<(Handle, Handle)>> {+    let mut sorted = order.to_vec();+    sorted.sort_unstable();+    // A handle twice (or none) can't be ordered by handle: no table rather than a wrong one.+    if sorted.windows(2).any(|w| matches!(w, [a, b] if a == b)) || sorted.first().is_some_and(|h| h.0 == 0) {+        return None;+    }+    let moved: Vec<_> = order.iter().zip(&sorted).filter(|(e, k)| e != k).map(|(e, k)| (*e, *k)).collect();+    (!moved.is_empty()).then_some(moved)+}++/// Where a record's owner (330) is, outside its `102` groups (the record's end without one).+fn owner_at(body: &[Tag]) -> usize {+    let mut in_group = false;+    for (i, t) in body.iter().enumerate() {+        match t.code {+            102 => in_group = t.str().trim().starts_with('{'),+            330 if !in_group => return i,+            _ => {}+        }+    }+    body.len()+}++/// Add the SORTENTSTABLE of every space drawn out of handle order to the finished tags (after+/// `dxf_raw::finish`, which places the entities of other programs' types and their extension+/// dictionaries). A file without an OBJECTS section has nowhere to keep one.+pub(crate) fn finish(tags: &mut Vec<Tag>) {+    let mut sec = String::new();+    // Block record handle → (index of its `0` group, its extension dictionary, groups after it).+    let mut records: HashMap<String, (usize, Option<String>, usize)> = HashMap::new();+    // Owner handle → its entities' handles in the order written; owners in first-seen order.+    let mut spaces: HashMap<String, Vec<Handle>> = HashMap::new();+    let mut owners: Vec<String> = Vec::new();+    // DICTIONARY handle → (index of its `0` group, groups after it).+    let mut dictionaries: HashMap<String, (usize, usize)> = HashMap::new();+    let (mut objects_end, mut classes_end, mut has_class) = (None, None, false);+    for (kind, start, body) in split(tags) {+        match (kind.as_str(), sec.as_str()) {+            ("SECTION", _) => sec = val(body, 2).unwrap_or_default(),+            ("ENDSEC", s) => {+                match s {+                    "OBJECTS" => objects_end = Some(start),+                    "CLASSES" => classes_end = Some(start),+                    _ => {}+                }+                sec.clear();+            }+            ("CLASS", "CLASSES") => has_class |= val(body, 1).is_some_and(|n| n.trim().eq_ignore_ascii_case("SORTENTSTABLE")),+            ("BLOCK_RECORD", "TABLES") => {+                let h = head(body);+                if let Some(handle) = h.handle {+                    records.insert(handle, (start, h.xdict, body.len()));+                }+            }+            ("DICTIONARY", "OBJECTS") => {+                if let Some(handle) = head(body).handle {+                    dictionaries.entry(handle).or_insert((start, body.len()));+                }+            }+            (k, "ENTITIES" | "BLOCKS") if !NOT_DRAWN.contains(&k) => {+                let h = head(body);+                if let (Some(e), Some(o)) = (h.handle.as_deref().and_then(Handle::parse_hex), h.owner) {+                    if !spaces.contains_key(&o) {+                        owners.push(o.clone());+                    }+                    spaces.entry(o).or_default().push(e);+                }+            }+            _ => {}+        }+    }+    let Some(objects_end) = objects_end else { return };+    let mut next = seed(tags).max(max_handle(tags).saturating_add(1));+    let mut alloc = || {+        let h = Handle(next);+        next = next.saturating_add(1);+        h.hex()+    };+    // Insertions (index, groups), applied from the last index back; new objects go at the end.+    let mut inserts: Vec<(usize, Vec<Tag>)> = Vec::new();+    let mut objects: Vec<Tag> = Vec::new();+    let mut extended: HashSet<String> = HashSet::new();+    for owner in &owners {+        let Some(&(start, ref xdict, len)) = records.get(owner) else { continue };+        let Some(moved) = spaces.get(owner).and_then(|o| table(o)) else { continue };+        let table_h = alloc();+        let xd = match xdict {+            // The record's own extension dictionary gets the entry (once: a record has one table).+            Some(x) => {+                let Some(&(dstart, dlen)) = dictionaries.get(x) else { continue };+                if !extended.insert(x.clone()) {+                    continue;+                }+                let body = tags.get(dstart + 1..dstart + 1 + dlen).unwrap_or(&[]);+                let at = dstart + 1 + body.iter().position(|t| t.code >= 1000).unwrap_or(body.len());+                inserts.push((at, vec![Tag::s(3, KEY), Tag::s(360, table_h.clone())]));+                x.clone()+            }+            None => {+                let x = alloc();+                // The group goes before the record's owner (DXF Reference, common group codes).+                let body = tags.get(start + 1..start + 1 + len).unwrap_or(&[]);+                let at = start + 1 + owner_at(body);+                inserts.push((at, vec![Tag::s(102, "{ACAD_XDICTIONARY"), Tag::s(360, x.clone()), Tag::s(102, "}")]));+                objects.extend([+                    Tag::s(0, "DICTIONARY"),+                    Tag::s(5, x.clone()),+                    Tag::s(102, "{ACAD_REACTORS"),+                    Tag::s(330, owner.clone()),+                    Tag::s(102, "}"),+                    Tag::s(330, owner.clone()),+                    Tag::s(100, "AcDbDictionary"),+                    Tag::i(280, 1),+                    Tag::i(281, 1),+                    Tag::s(3, KEY),+                    Tag::s(360, table_h.clone()),+                ]);+                x+            }+        };+        objects.extend([+            Tag::s(0, "SORTENTSTABLE"),+            Tag::s(5, table_h),+            Tag::s(102, "{ACAD_REACTORS"),+            Tag::s(330, xd.clone()),+            Tag::s(102, "}"),+            Tag::s(330, xd),+            Tag::s(100, "AcDbSortentsTable"),+            Tag::s(330, owner.clone()),+        ]);+        for (e, k) in moved {+            objects.extend([Tag::s(331, e.hex()), Tag::s(5, k.hex())]);+        }+    }+    if objects.is_empty() {+        return;+    }+    // First among insertions at the same place: one spliced later goes before it, so a dictionary+    // entry for the last record of the section stays in that record, ahead of the new objects.+    inserts.insert(0, (objects_end, objects));+    if let (false, Some(at)) = (has_class, classes_end) {+        inserts.push((+            at,+            vec![+                Tag::s(0, "CLASS"),+                Tag::s(1, "SORTENTSTABLE"),+                Tag::s(2, "AcDbSortentsTable"),+                Tag::s(3, "ObjectDBX Classes"),+                Tag::i(90, 0),+                Tag::i(280, 0),+                Tag::i(281, 0),+            ],+        ));+    }+    inserts.sort_by_key(|(at, _)| std::cmp::Reverse(*at)); // stable: keeps that order at a place+    for (at, groups) in inserts {+        let at = at.min(tags.len());+        tags.splice(at..at, groups);+    }+    if let Some(i) = seed_index(tags)+        && let Some(t) = tags.get_mut(i)+    {+        *t = Tag::s(5, Handle(next).hex());+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    /// Handles from their values.+    fn hs(v: &[u64]) -> Vec<Handle> {+        v.iter().map(|h| Handle(*h)).collect()+    }++    /// Entities already in handle order (or none) need no table.+    #[test]+    fn a_space_in_handle_order_needs_no_table() {+        assert_eq!(table(&hs(&[0x10, 0x20, 0x30])), None);+        assert_eq!(table(&[]), None);+    }++    /// Moved entities swap handles; repeated or null handles give no table.+    #[test]+    fn the_table_swaps_handles_so_ascending_sort_handles_give_the_order() {+        // 30 drawn first, 10 last: 30 takes 10's place, 10 takes 30's; 20 stays.+        let t = table(&hs(&[0x30, 0x20, 0x10])).unwrap_or_default();+        assert_eq!(t, vec![(Handle(0x30), Handle(0x10)), (Handle(0x10), Handle(0x30))]);+        // A repeated or null handle can't be ordered by handle.+        assert_eq!(table(&hs(&[0x20, 0x10, 0x20])), None);+        assert_eq!(table(&hs(&[0x20, 0])), None);+    }+}diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs--- a/crates/io/src/dxf_raw.rs+++ b/crates/io/src/dxf_raw.rs@@ -37,8 +37,9 @@ 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).+/// rewrites it), draw order (applied on reading, written by `draw_order`) and annotation scale+/// contexts (written from their own models by the changes that model them; stale copies would+/// contradict the drawing). const MODELLED_XDICT_KEYS: &[&str] = &["ACAD_DIMASSOC", "ACAD_SORTENTS", "ACDBCONTEXTDATAMANAGER"];  /// Applications whose xdata CADCraft reads and writes itself. `ACAD` holds dimension overrides@@ -95,7 +96,7 @@ fn is_pointer(code: i32) -> bool { }  /// The records of a run of tags: (type, index of the record's `0` group, groups after it).-fn split(tags: &[Tag]) -> Vec<(String, usize, &[Tag])> {+pub(crate) fn split(tags: &[Tag]) -> Vec<(String, usize, &[Tag])> {     let mut out = Vec::new();     let mut open: Option<(String, usize)> = None;     for (i, t) in tags.iter().enumerate() {@@ -116,15 +117,16 @@ type Recs<'a> = Vec<(String, usize, &'a [Tag])>;  /// A record's handle, owner, reactors and extension dictionary (groups before its xdata). #[derive(Default)]-struct Head {-    handle: Option<String>,-    owner: Option<String>,+pub(crate) struct Head {+    pub(crate) handle: Option<String>,+    pub(crate) owner: Option<String>,     reactors: Vec<String>,-    xdict: Option<String>,+    pub(crate) xdict: Option<String>,     has_reactors: bool, } -fn head(tags: &[Tag]) -> Head {+/// A record's [`Head`], from its groups before any xdata.+pub(crate) fn head(tags: &[Tag]) -> Head {     let mut h = Head::default();     let mut group: Option<String> = None;     for t in tags.iter().take_while(|t| t.code < 1000) {@@ -149,7 +151,8 @@ fn head(tags: &[Tag]) -> Head {     h } -fn val(tags: &[Tag], code: i32) -> Option<String> {+/// The first value of group `code` before any xdata.+pub(crate) fn val(tags: &[Tag], code: i32) -> Option<String> {     tags.iter().take_while(|t| t.code < 1000).find(|t| t.code == code).map(Tag::str) } @@ -1191,16 +1194,16 @@ fn add_appids(out: &mut Vec<Tag>) { }  /// The index of the `$HANDSEED` value in the header.-fn seed_index(tags: &[Tag]) -> Option<usize> {+pub(crate) fn seed_index(tags: &[Tag]) -> Option<usize> {     tags.windows(2).position(|w| matches!(w, [a, b] if a.code == 9 && a.str() == "$HANDSEED" && b.code == 5)).map(|i| i + 1) }  /// The header's next free handle.-fn seed(tags: &[Tag]) -> u64 {+pub(crate) fn seed(tags: &[Tag]) -> u64 {     seed_index(tags).and_then(|i| tags.get(i)).and_then(|t| Handle::parse_hex(&t.str())).map_or(0, |h| h.0) }  /// The largest handle of a record (groups 5 and 105).-fn max_handle(tags: &[Tag]) -> u64 {+pub(crate) fn max_handle(tags: &[Tag]) -> u64 {     tags.iter().filter(|t| t.code == 5 || t.code == 105).filter_map(|t| Handle::parse_hex(&t.str())).map(|h| h.0).max().unwrap_or(0) }diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs--- a/crates/io/src/dxf_write.rs+++ b/crates/io/src/dxf_write.rs@@ -2687,6 +2687,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {         *t = Tag::s(5, seed);     }     crate::dxf_raw::finish(&mut w.t, d);+    crate::draw_order::finish(&mut w.t);     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/lib.rs b/crates/io/src/lib.rs--- a/crates/io/src/lib.rs+++ b/crates/io/src/lib.rs@@ -2,6 +2,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +/// Draw order written as SORTENTSTABLE records.+mod draw_order; /// What a DXF file holds that CADCraft doesn't model, kept and written back (#528). mod dxf_raw; diff --git a/crates/io/tests/it/sortents.rs b/crates/io/tests/it/sortents.rs--- a/crates/io/tests/it/sortents.rs+++ b/crates/io/tests/it/sortents.rs@@ -1,12 +1,16 @@ //! Draw order from DXF files (#439): a SORTENTSTABLE reorders its space by sort handle, and the-//! order CADCraft writes (the order of the entity records) reads back unchanged.+//! order CADCraft writes (the order of the entity records, and a SORTENTSTABLE when it isn't+//! handle order) reads back unchanged. DWG: the table goes through the DWG bridge both ways+//! (the DWG oracle shares acadrust 0.6.5; no AutoCAD validation is claimed).  use cadcraft_doc::*; +/// A LINE record in model space with handle `h` at height `y`. fn line(h: &str, y: f64) -> String {     format!("0\nLINE\n5\n{h}\n330\n1F\n8\n0\n10\n0\n20\n{y}\n30\n0\n11\n10\n21\n{y}\n31\n0\n") } +/// A DXF with model space, three lines (A1, A2, A3) and `objects` in OBJECTS. fn dxf(objects: &str) -> String {     let mut s = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n0\nBLOCK_RECORD\n5\n1F\n2\n*Model_Space\n0\nENDTAB\n0\nENDSEC\n");     s.push_str("0\nSECTION\n2\nENTITIES\n");@@ -19,21 +23,91 @@ fn dxf(objects: &str) -> String {     s } +/// Model space's entity handles in draw order. fn order(d: &Drawing) -> Vec<String> {     d.model.iter().map(|e| e.handle.hex()).collect() } +/// A table reorders model space; written back with a table, the order survives. #[test] fn sortentstable_sets_the_draw_order() {     // A1 is drawn by sort handle A4 (over A2 and A3); A3 by 10 (under everything).     let table = "0\nSORTENTSTABLE\n5\nB0\n330\nB1\n100\nAcDbSortentsTable\n330\n1F\n331\nA1\n5\nA4\n331\nA3\n5\n10\n";     let d = cadcraft_io::read_dxf(dxf(table).as_bytes()).unwrap();     assert_eq!(order(&d), ["A3", "A2", "A1"]);-    // Written back in draw order, without a table, the order survives.-    let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();+    // Written back in draw order, with a table that swaps A1 and A3 (A2 is in its handle's+    // place), the order survives.+    let text = cadcraft_io::write_dxf(&d);+    let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse DXF");+    let (table, xdict) = written_table(&tags).expect("a SORTENTSTABLE");+    assert_eq!(table, [("A3".to_string(), "A1".to_string()), ("A1".into(), "A3".into())]);+    assert!(xdict, "model space's extension dictionary has ACAD_SORTENTS");+    let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();     assert_eq!(order(&back), ["A3", "A2", "A1"]); } +/// The written SORTENTSTABLE's (entity, sort handle) pairs, and whether the model space record's+/// extension dictionary names it ACAD_SORTENTS.+fn written_table(tags: &[cadcraft_dxf::Tag]) -> Option<(Vec<(String, String)>, bool)> {+    let recs: Vec<&[cadcraft_dxf::Tag]> = {+        let starts: Vec<usize> = tags.iter().enumerate().filter(|(_, t)| t.code == 0).map(|(i, _)| i).collect();+        starts.iter().enumerate().map(|(k, &s)| &tags[s..starts.get(k + 1).copied().unwrap_or(tags.len())]).collect()+    };+    let kind = |r: &[cadcraft_dxf::Tag]| r.first().map(|t| t.str()).unwrap_or_default();+    let get = |r: &[cadcraft_dxf::Tag], c: i32| r.iter().find(|t| t.code == c).map(|t| t.str());+    let table = recs.iter().find(|r| kind(r) == "SORTENTSTABLE")?;+    let handle = get(table, 5)?;+    let mut pairs = Vec::new();+    let mut ent = None;+    for t in table.iter().skip_while(|t| t.code != 100).skip(1) {+        match t.code {+            331 => ent = Some(t.str()),+            5 => pairs.extend(ent.take().map(|e| (e, t.str()))),+            _ => {}+        }+    }+    let ms = recs.iter().find(|r| kind(r) == "BLOCK_RECORD" && get(r, 2).as_deref() == Some("*Model_Space"))?;+    let xd = ms.iter().skip_while(|t| t.str() != "{ACAD_XDICTIONARY").nth(1).map(|t| t.str())?;+    let dict = recs.iter().find(|r| kind(r) == "DICTIONARY" && get(r, 5).as_deref() == Some(xd.as_str()))?;+    let named = dict.windows(2).any(|w| w[0].code == 3 && w[0].str() == "ACAD_SORTENTS" && w[1].str() == handle);+    Some((pairs, named))+}++/// A block record whose extension dictionary is the last record of OBJECTS gets the entry inside it.+#[test]+fn the_entry_goes_into_a_kept_extension_dictionary_written_last() {+    // Model space's extension dictionary also holds another program's record, and comes last in+    // the file's objects (so last of the kept objects CADCraft writes back).+    let mut s = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n");+    s.push_str("0\nBLOCK_RECORD\n5\n1F\n102\n{ACAD_XDICTIONARY\n360\nD1\n102\n}\n2\n*Model_Space\n0\nENDTAB\n0\nENDSEC\n");+    s.push_str("0\nSECTION\n2\nENTITIES\n");+    for (h, y) in [("A1", 0.0), ("A2", 1.0), ("A3", 2.0)] {+        s.push_str(&line(h, y));+    }+    s.push_str("0\nENDSEC\n0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n");+    s.push_str("0\nXRECORD\n5\nE1\n330\nD1\n100\nAcDbXrecord\n1\nkeep me\n");+    s.push_str("0\nSORTENTSTABLE\n5\nB0\n330\nD1\n100\nAcDbSortentsTable\n330\n1F\n331\nA1\n5\nA4\n331\nA3\n5\n10\n");+    s.push_str("0\nDICTIONARY\n5\nD1\n330\n1F\n100\nAcDbDictionary\n3\nOTHER_APP\n360\nE1\n3\nACAD_SORTENTS\n360\nB0\n0\nENDSEC\n0\nEOF\n");+    let d = cadcraft_io::read_dxf(s.as_bytes()).unwrap();+    assert_eq!(order(&d), ["A3", "A2", "A1"]);+    let text = cadcraft_io::write_dxf(&d);+    let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse DXF");+    let (table, xdict) = written_table(&tags).expect("a SORTENTSTABLE");+    assert_eq!(table.len(), 2);+    assert!(xdict, "the kept dictionary names the table");+    assert!(text.contains("OTHER_APP"), "and still holds the other program's entry");+    assert_eq!(order(&cadcraft_io::read_dxf(text.as_bytes()).unwrap()), ["A3", "A2", "A1"]);+}++/// Entities in handle order are written without a table.+#[test]+fn a_space_in_handle_order_gets_no_table() {+    let d = cadcraft_io::read_dxf(dxf("").as_bytes()).unwrap();+    let text = cadcraft_io::write_dxf(&d);+    assert!(!text.contains("SORTENTSTABLE"), "no table, and no class for one");+}++/// Tables that don't parse or name no space change nothing. #[test] fn broken_sortentstables_are_ignored() {     for table in [@@ -46,3 +120,92 @@ fn broken_sortentstables_are_ignored() {         assert_eq!(order(&d), ["A1", "A2", "A3"], "{table:?}");     } }++#[cfg(not(target_arch = "wasm32"))]+mod dwg {+    use acadrust::entities::Line;+    use acadrust::objects::{ObjectType, SortEntitiesTable};+    use acadrust::{CadDocument, DwgWriter, DxfVersion, EntityType, Handle};++    type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;++    /// An AC1032 DWG with three lines in model space and a table that swaps the first and the+    /// last; the middle one isn't listed (it is drawn by its own handle). Drawn: 3, 2, 1.+    fn source() -> TestResult<(Vec<u8>, [Handle; 3])> {+        let mut doc = CadDocument::with_version(DxfVersion::AC1032);+        let mut h = [Handle::NULL; 3];+        for (i, slot) in h.iter_mut().enumerate() {+            let y = i as f64;+            *slot = doc.add_entity(EntityType::Line(Line::from_coords(0., y, 0., 10., y, 0.)))?;+        }+        let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;+        let xd = doc.ensure_extension_dictionary(ms);+        let th = doc.allocate_handle();+        let mut t = SortEntitiesTable::for_block(ms);+        t.handle = th;+        t.owner_handle = xd;+        t.add_entry(h[0], h[2]);+        t.add_entry(h[2], h[0]);+        doc.objects.insert(th, ObjectType::SortEntitiesTable(t));+        let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&xd) else { return Err("extension dictionary".into()) };+        d.entries.push(("ACAD_SORTENTS".into(), th));+        Ok((DwgWriter::write_to_vec(&doc)?, h))+    }++    /// Model space's entity handles in draw order.+    fn order(d: &cadcraft_doc::Drawing) -> Vec<u64> {+        d.model.iter().map(|e| e.handle.0).collect()+    }++    /// The draw order a DWG's model space has for a reader that honours the table.+    fn native_order(bytes: &[u8]) -> TestResult<Vec<u64>> {+        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 doc = out.document;+        let ms = doc.block_records.get("*Model_Space").ok_or("model space")?;+        let mut tables = doc.objects.values().filter_map(|o| match o {+            ObjectType::SortEntitiesTable(t) if t.block_owner_handle == ms.handle => Some(t),+            _ => None,+        });+        let table = tables.next().ok_or("no table")?;+        assert!(tables.next().is_none(), "one table");+        // Found the way AutoCAD finds it: the record's extension dictionary, entry ACAD_SORTENTS.+        let xd = doc.extension_dictionary_handle(ms.handle).ok_or("no extension dictionary")?;+        let Some(ObjectType::Dictionary(d)) = doc.objects.get(&xd) else { return Err("extension dictionary".into()) };+        assert!(d.entries.iter().any(|(k, h)| k == "ACAD_SORTENTS" && *h == table.handle));+        let mut v: Vec<u64> = ms.entity_handles.iter().map(|h| h.value()).collect();+        v.sort_by_key(|h| table.effective_sort_handle(Handle::new(*h)).value());+        Ok(v)+    }++    /// A table listing only some entities reads in its own order.+    #[test]+    fn a_dwg_table_that_leaves_entities_out_reads_in_its_order() -> TestResult {+        let (bytes, h) = source()?;+        let want = vec![h[2].value(), h[1].value(), h[0].value()];+        assert_eq!(native_order(&bytes)?, want);+        // The DWG bridge's DXF used to renumber the listed entities above the unlisted one: 3, 1, 2.+        let d = cadcraft_io::read(&bytes, "order.dwg")?;+        assert_eq!(order(&d), want);+        Ok(())+    }++    /// The order goes back into the DWG, survives a second save, and a bring-to-front reaches the file.+    #[test]+    fn draw_order_goes_back_into_the_dwg_and_survives_a_second_save() -> TestResult {+        let (bytes, h) = source()?;+        let want = vec![h[2].value(), h[1].value(), h[0].value()];+        let mut d = cadcraft_io::read(&bytes, "order.dwg")?;+        for _ in 0..2 {+            let out = cadcraft_io::write_with(&d, "out.dwg", &cadcraft_io::SaveOptions::default())?;+            assert_eq!(native_order(&out)?, want);+            d = cadcraft_io::read(&out, "out.dwg")?;+            assert_eq!(order(&d), want);+        }+        // A DRAWORDER-style move (the first drawn to the top) is what the next file's table says.+        assert!(d.model.bring_to_front(cadcraft_doc::Handle(h[2].value())));+        let out = cadcraft_io::write_with(&d, "out.dwg", &cadcraft_io::SaveOptions::default())?;+        assert_eq!(native_order(&out)?, vec![h[1].value(), h[0].value(), h[2].value()]);+        Ok(())+    }+}diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (draw order: SORTENTSTABLE read from DWG in its order and written to 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@@ -35,7 +35,9 @@ with the `acadrust` 0.6.3 crate and reading that DXF; it saves by writing its ow converting it to a DWG of the chosen version (#421): SAVEAS `version`, else the drawing's own version (the file's `$ACADVER`, 2018 for a new drawing), at least 2004 unless 2000 is asked for. The bridge repairs two acadrust 0.6.3 gaps in 2010+ DWG: merged table cells and table-style-title/header suppression.+title/header suppression. It also lists every entity of a draw order table's space in the table+before the conversion: acadrust 0.6.5's DXF writer renumbers a SORTENTSTABLE's sort handles above+all the file's handles, which would move every listed entity above every unlisted one.  | DWG version | Code | AutoCAD 2027 | CADCraft read | CADCraft write | Tested how | |---|---|---|---|---|---|@@ -99,6 +101,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | Objects: VISUALSTYLE, PLOTSETTINGS, materials, field objects and other unmodelled objects | kept raw (#528) | written back as read; a proxy object (ACAD_PROXY_OBJECT) keeps its class: its group 91 is renumbered to the written CLASSES, which keep the class it names, and one whose number names no class is dropped (#1812) | not editable | | Foreign xdata, extension dictionaries, reactors, unmodelled objects, colour books | kept raw (#528); an entity's xdata is part of the entity, so COPY, MIRROR, ARRAY and paste keep it on the copies (EXPLODE pieces don't) | written back, handles remapped, references to deleted objects dropped | through DWG: xdata on table records and blocks, entity colour books and reactors, custom-class objects lost (acadrust) | | 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) |+| Draw order (SORTENTSTABLE) | yes: a space's table sets its entity order (#439) | yes: entities in draw order, and a SORTENTSTABLE in the block record's extension dictionary (`ACAD_SORTENTS`) for every space drawn out of handle order, listing each entity whose place doesn't match its handle; DRAWORDER results reach the file (DXF and DWG) | `sortents` tests (synthetic; the DWG oracle is acadrust) | | Handles and owners | entity handles kept; others regenerated | consistent | stable across CADCraft re-saves (#495); other programs' table and object handles renumbered once |  **DXF ≈ 50% read, ≈ 30% write** (estimated; weights: common 2D entities 50%, annotation objects@@ -140,6 +143,7 @@ our output in AutoCAD black-box.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Draw order: a DWG table that leaves entities out reads in its order (acadrust's renumbering worked around); DXF and DWG saves write a SORTENTSTABLE for each space drawn out of handle order, so DRAWORDER reaches the file | | 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 |