diff --git a/crates/doc/src/foreign.rs b/crates/doc/src/foreign.rs index e1b3acc..a200132 100644 --- a/crates/doc/src/foreign.rs +++ b/crates/doc/src/foreign.rs @@ -65,6 +65,11 @@ pub struct Foreign { pub records: BTreeMap, /// The roles of the file handles the kept data refers to, for objects CADCraft rewrites. pub targets: BTreeMap, + /// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups + /// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and + /// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD` + /// names its entity with a 320 handle (see [`Foreign::data_store_entities`]). + pub data_store: Vec, } impl Foreign { @@ -75,5 +80,11 @@ impl Foreign { && self.records.is_empty() && self.classes.is_empty() && self.targets.is_empty() + && self.data_store.is_empty() + } + + /// The entities the data store has records for (their 320 handles). + 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() } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4bd37cf..829e9aa 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -400,7 +400,8 @@ fn run_switch(s: &mut Session, p: &Value) -> Result { fn run_bytes(s: &mut Session, p: &Value) -> Result { let fmt = str_param(p, "format").unwrap_or("dxf"); let opts = save_options("document.bytes", p)?; - let lost = not_saved(s.doc()?); + let version = opts.version.unwrap_or_else(|| drawing_version(s, &format!("drawing.{fmt}"))); + let lost = not_saved_as(s.doc()?, version); let bytes = write_bytes(s, "document.bytes", &format!("drawing.{fmt}"), &opts)?; let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() }); if drops_unknown(&format!("drawing.{fmt}")) { @@ -411,12 +412,28 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result { /// Entities a DXF or DWG file doesn't keep: those of object types CADCraft doesn't model /// (3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but -/// not written), counted by DXF type across model space, layouts and block definitions. +/// not written), counted by DXF type across model space, layouts and block definitions. Saved +/// at the drawing's own version; see [`not_saved_as`]. pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap { + let v = d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver).unwrap_or_default(); + not_saved_as(d, v) +} + +/// Solid modeler entities whose ACIS geometry a file of AutoCAD 2013 and later keeps in its data +/// store section, which is written back with the drawing (`Foreign::data_store`). +const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"]; + +/// [`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. +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() }; 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) { + continue; + } let n = out.entry(u.dxf_type.clone()).or_insert(0); *n = n.saturating_add(1); } @@ -457,7 +474,10 @@ 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 { - let lost = s.doc().map(not_saved).unwrap_or_default(); + // What the file will hold depends on the version it is saved as (the data store from 2013). + 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 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) { report_not_saved(s, &mut r, &lost); diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index 5b08ca3..615028a 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -58,3 +58,27 @@ fn templates_report_what_dwg_does_not_keep() { assert!(drops_unknown("a.dwg") && drops_unknown("B.DWT")); assert!(!drops_unknown("a.dxf")); } + +#[test] +fn solids_whose_geometry_the_data_store_keeps_are_not_reported_from_2013() { + use cadcraft_engine::cmd::file::{Version, not_saved_as}; + use cadcraft_engine::doc::Handle; + use cadcraft_engine::doc::foreign::Foreign; + let mut d = Drawing::new_imperial(); + let kept = d.add(&Space::Model, Common::default(), unknown("3DSOLID")).unwrap(); + d.add(&Space::Model, Common::default(), unknown("3DSOLID")).unwrap(); + d.add(&Space::Model, Common::default(), unknown("REGION")).unwrap(); + let record = |h: Handle| { + vec![ + RawTag { code: 0, value: "ACDSRECORD".into() }, + RawTag { code: 320, value: h.hex() }, + RawTag { code: 2, value: "ASM_Data".into() }, + RawTag { code: 310, value: "00".into() }, + ] + }; + d.foreign = std::sync::Arc::new(Foreign { data_store: record(kept), ..Foreign::default() }); + // The solid with a data store record keeps its geometry from 2013 on; the others don't. + assert_eq!(not_saved_as(&d, Version::R2018), [("3DSOLID".to_string(), 1), ("REGION".to_string(), 1)].into_iter().collect()); + // 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()); +} diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index 00b0942..63a9565 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -450,11 +450,53 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) { f.targets.insert(h, k.clone()); } } + // The data store (ACDSDATA): the ACIS geometry of solids, regions and bodies from 2013 on. + if let Some(ds) = secs.iter().find(|s| s.name.eq_ignore_ascii_case("ACDSDATA")) { + f.data_store = ds.tags.iter().take(MAX_DATA_STORE_GROUPS).map(raw).collect(); + } if !f.is_empty() { d.foreign = Arc::new(f); } } +/// Most groups of a data store section kept (a damaged file's can be endless). +const MAX_DATA_STORE_GROUPS: usize = 1 << 26; + +/// Write the drawing's kept data store section (ACDSDATA) before the end of `tags`, without the +/// records of entities that are no longer written. Only for files of AutoCAD 2013 and later +/// (`modern`), whose solids, regions and bodies keep their geometry there; the DWG bridge reads it +/// too. Nothing is written when no record's entity remains. +pub(crate) fn data_store(tags: &mut Vec, d: &Drawing, modern: bool) { + let ds = &d.foreign.data_store; + if !modern || ds.is_empty() { + return; + } + let written: HashSet = tags.iter().filter(|t| t.code == 5).map(|t| norm(&t.str())).collect(); + // The section's own groups before its first record, then its schemas and records. + let mut out: Vec = vec![Tag::s(0, "SECTION"), Tag::s(2, "ACDSDATA")]; + 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(&[]); + 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; + } + out.push(Tag::s(0, "ENDSEC")); + let at = tags.iter().rposition(|t| t.code == 0 && t.str() == "EOF").unwrap_or(tags.len()); + tags.splice(at..at, out); +} + /// Drop a dictionary's modelled entries (key and handle) from its groups. fn strip_entries(tags: &mut Vec) { let mut out = Vec::with_capacity(tags.len()); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 70a3d57..c79be65 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -2318,6 +2318,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { *t = Tag::s(5, seed); } crate::dxf_raw::finish(&mut w.t, d); + 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); w.t diff --git a/crates/io/tests/acis_data_store.rs b/crates/io/tests/acis_data_store.rs new file mode 100644 index 0000000..137b31f --- /dev/null +++ b/crates/io/tests/acis_data_store.rs @@ -0,0 +1,104 @@ +//! The ACIS geometry of 3D solids and regions survives a save. From AutoCAD 2013 on those entities +//! don't carry it themselves: it lives in the file's data store section (ACDSDATA), one record per +//! entity bound by a 320 handle. CADCraft doesn't model solids, so it keeps the section as read and +//! writes it back (and the DWG bridge reads it), without the records of deleted entities. +#![cfg(not(target_arch = "wasm32"))] + +use cadcraft_doc::EntityKind; + +/// Stand-in modeler data, carried as opaque bytes: the binary ACIS magic, some bytes, and the +/// end-of-data marker DWG readers delimit each body with. +const SAB_HEX: &str = "414349532042696E61727946696C65000102030405060708090A0B0C0D0E0F456E642D6F662D414349532D64617461"; + +/// A 2018 DXF with two 3D solids (2A, 2B) and their data store records. +fn dxf() -> String { + let solid = |h: &str| { + format!( + "0\n3DSOLID\n5\n{h}\n330\n1F\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n290\n0\n2\n{{00000000-0000-0000-0000-000000000000}}\n100\nAcDb3dSolid\n350\n0\n" + ) + }; + let record = |h: &str| { + format!("0\nACDSRECORD\n90\n1\n2\nAcDbDs::ID\n280\n10\n320\n{h}\n2\nASM_Data\n280\n15\n94\n{}\n310\n{SAB_HEX}\n", SAB_HEX.len() / 2) + }; + format!( + "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n9\n$HANDSEED\n5\n100\n0\nENDSEC\n\ + 0\nSECTION\n2\nENTITIES\n{}{}0\nENDSEC\n\ + 0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n0\nENDSEC\n\ + 0\nSECTION\n2\nACDSDATA\n70\n2\n71\n2\n\ + 0\nACDSSCHEMA\n90\n1\n1\nAcDb3DSolid_ASM_Data\n2\nAcDbDs::ID\n280\n10\n91\n8\n2\nASM_Data\n280\n15\n91\n0\n\ + {}{}0\nENDSEC\n0\nEOF\n", + solid("2A"), + solid("2B"), + record("2A"), + record("2B") + ) +} + +/// The handles the ACDSDATA section of DXF text binds ASM_Data to, with each one's hex data. +fn asm_data(text: &str) -> Vec<(String, String)> { + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let Some(start) = lines.windows(2).position(|w| w == ["2", "ACDSDATA"]) else { return Vec::new() }; + let mut out: Vec<(String, String)> = Vec::new(); + let (mut handle, mut asm, mut hex) = (None::, false, String::new()); + for pair in lines[start..].chunks(2) { + let [code, value] = pair else { break }; + if *code == "0" { + if let (Some(h), true, false) = (handle.take(), asm, hex.is_empty()) { + out.push((h.to_ascii_uppercase(), std::mem::take(&mut hex))); + } + asm = false; + hex.clear(); + if *value == "ENDSEC" { + break; + } + } + match *code { + "320" => handle = Some(value.to_string()), + "2" => asm = *value == "ASM_Data", + "310" if asm => hex.push_str(value), + _ => {} + } + } + out +} + +#[test] +fn dxf_keeps_the_data_store_and_drops_records_of_deleted_entities() { + let mut d = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap(); + assert_eq!(d.foreign.data_store_entities().len(), 2); + let out = String::from_utf8(cadcraft_io::write(&d, "x.dxf").unwrap()).unwrap(); + assert_eq!(asm_data(&out), vec![("2A".to_string(), SAB_HEX.to_string()), ("2B".to_string(), SAB_HEX.to_string())]); + + // Deleting a solid drops its record; the other stays. + let h = d.model.iter().find(|e| e.handle.hex() == "2B").map(|e| e.handle).unwrap(); + d.remove_entity(h).unwrap(); + let out = String::from_utf8(cadcraft_io::write(&d, "x.dxf").unwrap()).unwrap(); + assert_eq!(asm_data(&out), vec![("2A".to_string(), SAB_HEX.to_string())]); + + // An R2000 file has no data store (its solids would carry SAT text instead). + let r2000 = String::from_utf8( + cadcraft_io::write_with(&d, "x.dxf", &cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2000), binary: false }).unwrap(), + ) + .unwrap(); + assert!(!r2000.contains("ACDSDATA")); + + // No solid left: no section. + let h = d.model.iter().find(|e| matches!(e.kind, EntityKind::Unknown(_))).map(|e| e.handle).unwrap(); + d.remove_entity(h).unwrap(); + let out = String::from_utf8(cadcraft_io::write(&d, "x.dxf").unwrap()).unwrap(); + assert!(!out.contains("ACDSDATA")); +} + +#[test] +fn dwg_keeps_the_solids_geometry() { + let d = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap(); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + // The DWG read back (as the bridge's DXF) binds the same bytes to both solids. + let back = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + let mut got = asm_data(&back); + got.sort(); + assert_eq!(got, vec![("2A".to_string(), SAB_HEX.to_string()), ("2B".to_string(), SAB_HEX.to_string())]); + // And reopening it in CADCraft keeps them for the next save. + let again = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(again.foreign.data_store_entities().len(), 2); +}