diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 03d756226..39732d1c8 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -589,6 +589,103 @@ mod native { } } + /// The DXF names of an ASCII DXF's CLASS records, in CLASSES order: a proxy's class number + /// (group 91) is 500 plus its class's place here (DXF Reference, ACAD_PROXY_OBJECT). Empty for + /// a binary DXF or one without a CLASSES section. + pub(super) fn dxf_class_names(dxf: &[u8]) -> Vec { + let mut names = Vec::new(); + if dxf.starts_with(b"AutoCAD Binary DXF") { + return names; + } + let mut lines = dxf.split(|b| *b == b'\n').map(|l| l.trim_ascii()); + let (mut in_classes, mut in_class) = (false, false); + while let (Some(code), Some(value)) = (lines.next(), lines.next()) { + match (code, value) { + (b"0", b"ENDSEC") if in_classes => break, + (b"2", b"CLASSES") => in_classes = true, + (b"0", v) if in_classes => in_class = v == b"CLASS", + (b"1", v) if in_class => { + names.push(String::from_utf8_lossy(v).into_owned()); + in_class = false; + } + _ => {} + } + } + names + } + + /// Proxy objects' and entities' class numbers as the DWG numbers its classes. A DXF proxy + /// names its class by its place in the DXF's CLASSES (`classes`, from 500), but acadrust's + /// reader puts its own default classes first and numbers the DXF's after them, so the DXF + /// number named another class in the DWG: a 2018 drawing's piping proxies became + /// LARGE_RADIAL_DIMENSION and SECTIONOBJECT, and the ODA File Converter refused the file. A + /// number that names no class of the DXF is left as it is. + /// + /// Before 2013 (AC1027) acadrust writes only the classes it knows a pre-2013 DWG needs, and a + /// proxy's class is not one of them: the proxy would name a missing class (the ODA refuses + /// the file: "No ClassId") or, by its old number, some other class. Such a DWG leaves the + /// proxies out instead, as it does other programs' data it can't write (`not_saved` reports + /// the entities). Returns how many proxies were left out. + fn proxy_classes(doc: &mut acadrust::CadDocument, classes: &[String]) -> usize { + if doc.version < acadrust::DxfVersion::AC1027 { + let entities: Vec = doc + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Extended(x) if matches!(x.data, acadrust::entities::ExtendedEntityData::Proxy(_)) => { + Some(x.common.handle) + } + _ => None, + }) + .collect(); + let objects: Vec = + doc.objects.iter().filter_map(|(h, o)| matches!(o, acadrust::objects::ObjectType::ProxyObject(_)).then_some(*h)).collect(); + for h in &entities { + doc.remove_entity(*h); + } + for h in &objects { + doc.objects.remove(h); + } + return entities.len() + objects.len(); + } + let number = |id: i32| -> Option { + let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?; + doc.classes.get_by_name(name).map(|c| i32::from(c.class_number)) + }; + let objects: Vec<(acadrust::Handle, i32)> = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + acadrust::objects::ObjectType::ProxyObject(p) => number(p.class_id).map(|n| (*h, n)), + _ => None, + }) + .collect(); + let entities: Vec<(acadrust::Handle, i32)> = doc + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Extended(x) => match &x.data { + acadrust::entities::ExtendedEntityData::Proxy(p) => number(p.class_id).map(|n| (x.common.handle, n)), + _ => None, + }, + _ => None, + }) + .collect(); + for (h, n) in objects { + if let Some(acadrust::objects::ObjectType::ProxyObject(p)) = doc.objects.get_mut(&h) { + p.class_id = n; + } + } + let entities: std::collections::HashMap = entities.into_iter().collect(); + for e in doc.entities_mut() { + if let acadrust::entities::EntityType::Extended(x) = e { + let Some(n) = entities.get(&x.common.handle).copied() else { continue }; + if let acadrust::entities::ExtendedEntityData::Proxy(p) = &mut x.data { + p.class_id = n; + } + } + } + 0 + } + /// `kept`: the version the DXF's carried-over groups were read from ([`dxf_to_dwg_kept_as`]). fn convert(dxf: &[u8], version: Option, kept: Option) -> Result, String> { // Reject a pathologically large DXF before handing it to the third-party decoder, which @@ -614,6 +711,7 @@ mod native { Some(d) => d, None => dxf.to_vec(), }; + let classes = dxf_class_names(&data); let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) .map_err(|_| "the DXF→DWG conversion failed".to_string())? .map_err(|e| format!("DXF: {e}"))?; @@ -625,6 +723,7 @@ mod native { None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION, None => {} } + proxy_classes(&mut doc, &classes); other_layout_owners(&mut doc); table_style_rows(&mut doc, true); dimension_flags(&mut doc); @@ -1071,4 +1170,14 @@ mod tests { assert_eq!(dxf_to_dwg_kept_as(&dxf, "AC1032", "AC9999"), dxf_to_dwg_as(&dxf, "AC1032")); assert!(dxf_to_dwg_kept_as(&dxf, "AC0000", "AC1032").is_err(), "an unwritable version is still an error"); } + + /// The CLASS names in CLASSES order; nothing for a binary DXF or one without CLASSES. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn dxf_class_names_in_classes_order() { + let dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 1\r\nWIDGETA\r\n 2\r\nAcDbWidgetA\r\n 3\r\nApp\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 2\r\nAcDbWidgetB\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\nNOT_A_CLASS\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + assert_eq!(native::dxf_class_names(dxf), ["WIDGETA", "WIDGETB"]); + assert!(native::dxf_class_names(b" 0\nSECTION\n 2\nHEADER\n 0\nENDSEC\n 0\nEOF\n").is_empty()); + assert!(native::dxf_class_names(b"AutoCAD Binary DXF\r\n\x1a\0").is_empty()); + } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3a5296e88..c5973acf4 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -538,7 +538,9 @@ pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap { 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. +/// store has a record for keeps its geometry and is not counted, and neither is another program's +/// proxy entity: the DWG writes it with its class (before 2013 it has no class to name, and the +/// DWG leaves it out). 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() }; @@ -548,6 +550,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 version >= Version::R2013 && u.dxf_type.eq_ignore_ascii_case("ACAD_PROXY_ENTITY") { + 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..6753151fd 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -82,3 +82,19 @@ 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()); } + +/// Another program's proxy entity reaches a 2013 or later DWG with its class; before 2013 the DWG +/// leaves it out and says so. +#[test] +fn proxy_entities_are_reported_only_before_2013() { + use cadcraft_engine::cmd::file::{Version, not_saved_as}; + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), unknown("ACAD_PROXY_ENTITY")).unwrap(); + d.add(&Space::Model, Common::default(), unknown("MLINE")).unwrap(); + for v in [Version::R2013, Version::R2018] { + assert_eq!(not_saved_as(&d, v), [("MLINE".to_string(), 1)].into_iter().collect(), "{v:?}"); + } + for v in [Version::R2004, Version::R2010] { + assert_eq!(not_saved_as(&d, v), [("ACAD_PROXY_ENTITY".to_string(), 1), ("MLINE".to_string(), 1)].into_iter().collect(), "{v:?}"); + } +} diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 977a7c184..ec29cc0fa 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -2814,6 +2814,50 @@ fn proxies_keep_their_class_when_classes_are_rewritten() { assert_eq!(names, ["VISUALSTYLE", "WIDGETX"]); } +/// The proxies of [`proxy_file`] keep their classes in a DWG too. A proxy names its class by +/// number, 500 plus the class's place in the CLASSES the file lists; acadrust numbers the classes +/// it writes in its own order (its default classes first), so the DXF's numbers named other +/// classes there: a 2018 drawing's piping proxies came out as LARGE_RADIAL_DIMENSION and +/// SECTIONOBJECT, and the ODA File Converter refused the file ("AcDbProxyObject can't be cast to +/// AcDbBlockTableRecord"). A DWG before 2013 leaves them out (acadrust writes no class for them). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn proxies_keep_their_class_in_dwg() { + use acadrust::entities::{EntityType, ExtendedEntityData}; + use acadrust::objects::ObjectType; + let d = read(&proxy_file(), "p.dxf").unwrap(); + for version in [Version::R2018, Version::R2013, Version::R2010, Version::R2004] { + let dwg = write_with(&d, "p.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + let class_of = |id: i32| doc.classes.iter().find(|c| i32::from(c.class_number) == id).map(|c| c.dxf_name.clone()); + let mut objects: Vec> = doc + .objects + .values() + .filter_map(|o| match o { + ObjectType::ProxyObject(p) => Some(class_of(p.class_id)), + _ => None, + }) + .collect(); + objects.sort(); + // Before 2013 acadrust writes no class for a proxy, so the DWG leaves the proxies out. + let legacy = version < Version::R2013; + let expected = if legacy { vec![] } else { vec![Some("VISUALSTYLE".to_string()), Some("WIDGETX".to_string())] }; + assert_eq!(objects, expected, "{version:?}: proxy objects"); + let entities: Vec> = doc + .entities() + .filter_map(|e| match e { + EntityType::Extended(x) => match &x.data { + ExtendedEntityData::Proxy(p) => Some(class_of(p.class_id)), + _ => None, + }, + _ => None, + }) + .collect(); + let expected = if legacy { vec![] } else { vec![Some("WIDGETX".to_string())] }; + assert_eq!(entities, expected, "{version:?}: proxy entity"); + } +} + /// A name that isn't valid Unicode still picks its format by extension. #[cfg(unix)] #[test]