Keep the ACIS geometry of 3D solids, regions and bodies on save (ACDSDATA) (#781)
From AutoCAD 2013 on, a 3DSOLID, REGION or BODY doesn't carry its ACIS geometry: the binary SAB lives in the file's data store section (ACDSDATA), one ACDSRECORD per entity bound by a 320 handle. CADCraft kept those entities (as unknown entities, with their handles) but dropped the section, so a drawing saved as DWG or DXF had empty solids, and saving reported them as not saved. - doc: Foreign::data_store keeps the section's groups as read; data_store_entities() lists the entities it has records for. - io: the reader keeps the section; the writer puts it back for files of 2013 and later (and in the DXF the DWG bridge converts, which acadrust reads into the DWG), without the records of entities no longer written. Nothing is written for older files or when no record's entity remains. - engine: not_saved_as(d, version): from 2013 a solid, region or body with a data store record isn't reported; saves and document.bytes pass the version being written. On a 39,600-object permit set with 625 3D solids and 2 regions, DWG -> CADCraft -> DWG now keeps all 627 bodies with byte-identical SAB (before: none), and the save no longer reports them. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6ab800e64c
commit
e9d8462828
|
|
@ -65,6 +65,11 @@ pub struct Foreign {
|
|||
pub records: BTreeMap<ForeignKey, ForeignExtra>,
|
||||
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
|
||||
pub targets: BTreeMap<Handle, ForeignKey>,
|
||||
/// 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<RawTag>,
|
||||
}
|
||||
|
||||
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<Handle> {
|
||||
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,7 +400,8 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
|||
|
||||
/// 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<String, usize> {
|
||||
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<String, usize> {
|
||||
let mut out: std::collections::BTreeMap<String, usize> = 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<Value>) -> Result<Value> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Tag>, d: &Drawing, modern: bool) {
|
||||
let ds = &d.foreign.data_store;
|
||||
if !modern || ds.is_empty() {
|
||||
return;
|
||||
}
|
||||
let written: HashSet<String> = 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<Tag> = 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<RawTag>) {
|
||||
let mut out = Vec::with_capacity(tags.len());
|
||||
|
|
|
|||
|
|
@ -2318,6 +2318,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::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
|
||||
|
|
|
|||
|
|
@ -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::<String>, 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);
|
||||
}
|
||||
Loading…
Reference in New Issue