From 9107479ade5e2a2986fe70a53ac24e65ffaba702 Mon Sep 17 00:00:00 2001 From: Francesco BV Date: Thu, 22 Oct 2026 15:17:08 +0200 Subject: [PATCH 1/3] io: open -> save -> reopen round-trip harness with loss report (FF5) cadcraft_io::roundtrip compares a drawing with itself saved and reopened (entities by handle, tables, blocks, layouts, unmodelled objects, xdata, classes, header variables; numbers within a tolerance), and roundtrip::corpus runs a folder of DXF/DWG files through DXF and DWG into report.md and report.json; exposed as `cadcraft-cli roundtrip` and `cargo xtask corpus`. The corpus itself stays outside the repo. The DWG bridge now says what its writer leaves out, per DXF type (cadcraft_dwg::dxf_to_dwg_noted, cadcraft_io::write_noted): the report shows those records as lost on save and a strict corpus run fails on them. OLE2FRAME, which the writer keeps with its data, is no longer listed as not saved in a DWG. CADCRAFT_CORPUS_STRICT=1 makes cadcraft-cli roundtrip (and so cargo xtask corpus) fail on the same strict failures as the corpus test; xtask makes a relative corpus folder and --out absolute before running the CLI. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/cadcraft-cli/src/main.rs | 126 ++ crates/dwg/src/lib.rs | 87 +- crates/engine/src/cmd/file.rs | 10 + crates/engine/tests/save_reports_not_saved.rs | 13 + crates/io/src/lib.rs | 35 +- crates/io/src/roundtrip.rs | 1148 +++++++++++++++++ crates/io/src/roundtrip/census.rs | 212 +++ crates/io/src/roundtrip/corpus.rs | 651 ++++++++++ crates/io/src/roundtrip/tests.rs | 559 ++++++++ docs/gaps.md | 7 +- xtask/src/main.rs | 50 +- 11 files changed, 2872 insertions(+), 26 deletions(-) create mode 100644 crates/io/src/roundtrip.rs create mode 100644 crates/io/src/roundtrip/census.rs create mode 100644 crates/io/src/roundtrip/corpus.rs create mode 100644 crates/io/src/roundtrip/tests.rs diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 99a8b4282..7b9916a55 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -9,6 +9,7 @@ //! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing //! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step //! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing +//! cadcraft-cli roundtrip [FILE|FOLDER] [--out DIR] open → save → reopen report (Markdown + JSON) //! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -63,6 +64,11 @@ const USAGE: &str = "usage: cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]] cadcraft-cli perf [N | FILE] cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png) + cadcraft-cli roundtrip [FILE | FOLDER] [--out DIR] [--keep-saved] [--same-format] [--no-census] [--tolerance T] + open each DXF/DWG, save it as DXF and DWG, reopen and compare; writes + report.md and report.json to DIR (default: the folder's `report`). + FOLDER defaults to $CADCRAFT_CORPUS_DIR. With CADCRAFT_CORPUS_STRICT=1 it + fails when a file doesn't open, save or reopen, or loses records on save. cadcraft-cli --version image framing (png 2400x1600, svg, pdf; always model space): @@ -819,6 +825,125 @@ mod mcp_args_tests { } } +/// What `roundtrip` was asked to do. +#[derive(Debug, PartialEq)] +struct RoundTripArgs { + path: Option, + out: Option, + keep_saved: bool, + opts: cadcraft_io::roundtrip::corpus::CorpusOptions, +} + +fn parse_roundtrip_args(args: &[String]) -> Result { + let mut r = RoundTripArgs { path: None, out: None, keep_saved: false, opts: Default::default() }; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--out" => r.out = Some(it.next().filter(|v| !v.starts_with("--")).ok_or("roundtrip: --out needs a folder")?.into()), + "--keep-saved" => r.keep_saved = true, + "--same-format" => r.opts.cross_format = false, + "--no-census" => r.opts.census = false, + "--tolerance" => { + let v = it.next().ok_or("roundtrip: --tolerance needs a number")?; + match v.parse::() { + Ok(t) if t.is_finite() && t >= 0.0 => r.opts.compare.abs_tol = t, + _ => return Err(format!("roundtrip: --tolerance must be a number >= 0, got `{v}`")), + } + } + s if s.starts_with("--") => return Err(format!("roundtrip: unknown option {s}")), + _ if r.path.is_none() => r.path = Some(a.into()), + _ => return Err("roundtrip: give one FILE or FOLDER".into()), + } + } + Ok(r) +} + +/// `roundtrip`: the corpus harness of `cadcraft_io::roundtrip::corpus` over a file or folder. +fn roundtrip(args: &[String]) -> Result<(), String> { + use cadcraft_io::roundtrip::corpus; + let mut a = parse_roundtrip_args(args)?; + let path = a + .path + .take() + .or_else(|| std::env::var_os("CADCRAFT_CORPUS_DIR").map(Into::into)) + .ok_or("roundtrip: give a FILE or FOLDER, or set CADCRAFT_CORPUS_DIR")?; + let (root, files, out) = if path.is_dir() { + let out = a.out.take().unwrap_or_else(|| path.join("report")); + let files = corpus::corpus_files(&path, std::slice::from_ref(&out)); + (path, files, out) + } else if path.is_file() { + let root = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default(); + let out = a.out.take().unwrap_or_else(|| root.join("report")); + (root, vec![path], out) + } else { + return Err(format!("roundtrip: no such file or folder: {}", path.display())); + }; + if files.is_empty() { + return Err(format!("roundtrip: no DXF or DWG files in {}", root.display())); + } + if a.keep_saved { + a.opts.keep_saved = Some(out.join("saved")); + } + let report = corpus::run(&root, &files, &a.opts, &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1)); + for f in &report.files { + let routes: Vec = f + .routes + .iter() + .map(|r| match (&r.save_error, &r.reopen_error) { + (Some(_), _) => format!("{}: save failed", r.route), + (None, Some(_)) => format!("{}: reopen failed", r.route), + (None, None) => { + let t = r.comparison.total(); + format!("{}: {} lost, {} added, {} changed", r.route, t.lost, t.added, t.changed) + } + }) + .collect(); + match &f.open_error { + Some(e) => outln!("{}: open failed: {e}", f.path), + None => outln!("{}: opened; {}", f.path, routes.join("; ")), + } + } + let (md, json) = report.write(&out).map_err(|e| format!("roundtrip: can't write the report to {}: {e}", out.display()))?; + outln!("report: {}", md.display()); + outln!(" {}", json.display()); + // CADCRAFT_CORPUS_STRICT=1: a file that doesn't open, save or reopen, or records a writer + // leaves out, fail the run (the report is written first). + if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") { + let failed = report.strict_failures(); + if !failed.is_empty() { + return Err(format!("roundtrip: {} strict failures:\n{}", failed.len(), failed.join("\n"))); + } + } + Ok(()) +} + +#[cfg(test)] +mod roundtrip_args_tests { + use super::*; + + fn args(a: &[&str]) -> Vec { + a.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn roundtrip_options_parse() { + let r = parse_roundtrip_args(&args(&["corpus", "--out", "rep", "--keep-saved", "--same-format", "--tolerance", "0.001"])).unwrap(); + assert_eq!(r.path, Some("corpus".into())); + assert_eq!(r.out, Some("rep".into())); + assert!(r.keep_saved && !r.opts.cross_format && r.opts.census); + assert_eq!(r.opts.compare.abs_tol, 0.001); + assert_eq!(parse_roundtrip_args(&args(&[])).unwrap().path, None); + for bad in [&["--out"][..], &["--tolerance", "nan"], &["--tolerance", "-1"], &["a", "b"], &["--typo"]] { + assert!(parse_roundtrip_args(&args(bad)).is_err(), "{bad:?}"); + } + } + + #[test] + fn roundtrip_reports_a_missing_folder() { + assert!(roundtrip(&args(&["no/such/folder/anywhere"])).is_err()); + } +} + fn main() -> ExitCode { let code = cli(); if STDOUT_ERROR.load(Ordering::Relaxed) { ExitCode::FAILURE } else { code } @@ -845,6 +970,7 @@ fn cli() -> ExitCode { outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); }), Some("mcp") => mcp(&rest), + Some("roundtrip") => roundtrip(&rest), Some("--version" | "-V") => { outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index b81bd00d1..d6efdac70 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -360,15 +360,22 @@ mod native { /// Leave out what acadrust can't write as this DWG version: other programs' custom entities /// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file /// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types - /// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many. - fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize { + /// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many + /// records of each DXF type were left out, so a save can say so. + fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten { let version = doc.version; + let mut dropped = super::Unwritten::new(); + let mut count = |name: &str| { + let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0); + *n = n.saturating_add(1); + }; let unwritable: Vec = doc .entities() .filter_map(|e| match e { acadrust::entities::EntityType::Unknown(u) if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 => { + count(&u.dxf_name); Some(u.common.handle) } _ => None, @@ -381,9 +388,10 @@ mod native { .objects .iter() .filter_map(|(h, o)| match o { - acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. } + acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. } if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 => { + count(type_name); Some(*h) } _ => None, @@ -392,7 +400,7 @@ mod native { for h in &objects { doc.objects.remove(h); } - unwritable.len() + objects.len() + dropped } /// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of @@ -460,12 +468,19 @@ mod native { /// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004 /// (AC1018). pub fn dxf_to_dwg(dxf: &[u8]) -> Result, String> { - convert(dxf, None) + convert(dxf, None).map(|(dwg, _)| dwg) } /// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"), /// whatever version the DXF declares. pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result, String> { + dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg) + } + + /// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each + /// DXF type it can't write (other programs' custom entities and objects that the DWG reader + /// didn't read from a DWG of this version), empty when the DWG keeps everything. + pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec, super::Unwritten), String> { let v = match version.trim().to_ascii_uppercase().as_str() { "AC1015" => acadrust::DxfVersion::AC1015, "AC1018" => acadrust::DxfVersion::AC1018, @@ -512,7 +527,7 @@ mod native { } } - fn convert(dxf: &[u8], version: Option) -> Result, String> { + fn convert(dxf: &[u8], version: Option) -> Result<(Vec, super::Unwritten), String> { // Reject a pathologically large DXF before handing it to the third-party decoder, which // allocates tables and section buffers from the input's declared sizes and cannot be // interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder @@ -539,13 +554,14 @@ mod native { other_layout_owners(&mut doc); table_style_rows(&mut doc, true); dimension_flags(&mut doc); - drop_unwritable(&mut doc); + let unwritten = drop_unwritable(&mut doc); current_dimstyle(&mut doc); add_variable_dictionary(&mut doc); current_text_style(&mut doc); - std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) + let dwg = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) .map_err(|_| "the DWG writer failed on this drawing".to_string())? - .map_err(|e| format!("DWG write: {e}")) + .map_err(|e| format!("DWG write: {e}"))?; + Ok((dwg, unwritten)) } /// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the @@ -642,7 +658,11 @@ mod native { } #[cfg(not(target_arch = "wasm32"))] -pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as}; +pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_noted}; + +/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer +/// can't write. +pub type Unwritten = std::collections::BTreeMap; /// The error for a DWG version CADCraft can't write. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] @@ -671,6 +691,10 @@ pub fn dxf_to_dwg(_dxf: &[u8]) -> Result, String> { pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result, String> { Err("DWG files can't be written in the web build yet; save as DXF".into()) } +#[cfg(target_arch = "wasm32")] +pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec, Unwritten), String> { + Err("DWG files can't be written in the web build yet; save as DXF".into()) +} #[cfg(test)] mod tests { @@ -911,4 +935,47 @@ mod tests { assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}"); assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]); } + + /// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF + /// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG + /// keeps whole reports nothing. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn saving_lists_the_types_the_dwg_writer_leaves_out() { + let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n"; + let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n"; + let dxf = |body: &str| { + format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n") + }; + for version in ["AC1015", "AC1018", "AC1032"] { + let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap(); + assert!(is_dwg(&dwg), "{version}"); + assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::(), "{version}"); + } + let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap(); + assert!(unwritten.is_empty(), "{unwritten:?}"); + } + + /// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to + /// the DWG with its data: it isn't in the list of what the writer left out. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn ole_frames_are_written_with_their_data() { + let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n"; + let dxf = + format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n"); + for version in ["AC1015", "AC1018", "AC1032"] { + let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap(); + assert!(unwritten.is_empty(), "{version} {unwritten:?}"); + let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + let frames: Vec> = back + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()), + _ => None, + }) + .collect(); + assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}"); + } + } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 95975a4e2..cfad2e61f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -534,6 +534,13 @@ pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap { /// store section, which is written back with the drawing (`Foreign::data_store`). const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"]; +/// Entity types CADCraft keeps without modelling them that the DWG bridge writes all the same: +/// its DWG library models them and writes them whole (an OLE2FRAME with its data bytes, for +/// every DWG version; `cadcraft-dwg`'s `ole_frames_are_written_with_their_data`). What the bridge +/// can't write it reports per type through `cadcraft_io::write_noted`, which the round-trip +/// harness reads; a save from here warns with [`not_saved_as`] only. +const DWG_WRITTEN_TYPES: [&str; 1] = ["OLE2FRAME"]; + /// [`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 { @@ -545,6 +552,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 DWG_WRITTEN_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) { + 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..a52fa7097 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -82,3 +82,16 @@ 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()); } + +/// An OLE2FRAME (an embedded OLE object) is kept by CADCraft as its DXF groups and written to +/// DWG whole, so saving doesn't report it as not saved (it was: "… 4 OLE2FRAME"). +#[test] +fn ole_frames_are_not_reported_as_not_saved() { + use cadcraft_engine::cmd::file::{Version, not_saved_as}; + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), unknown("OLE2FRAME")).unwrap(); + d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET")).unwrap(); + for v in [Version::R2000, Version::R2018] { + assert_eq!(not_saved_as(&d, v), [("ACME_WIDGET".to_string(), 1)].into_iter().collect(), "{v:?}"); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index ef56e6602..39afd2df9 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -14,6 +14,8 @@ mod dxf_write; mod dxf_xref; pub mod pdf; pub mod plotter; +/// Open → save → reopen comparison and the real-file corpus harness (gaps FF1, FF5). +pub mod roundtrip; pub mod svg; mod svg_read; @@ -60,18 +62,25 @@ fn ext(name: &str) -> String { /// Read a drawing; the format comes from the content (DXF ASCII/binary, DWG) or the name /// (SVG and gzip-compressed SVGZ). pub fn read(bytes: &[u8], name: &str) -> Result { + read_source(bytes, name).map(|(d, _)| d) +} + +/// [`read`], also returning the DXF rendition a DWG was read through (`None` for other files), +/// which the round-trip harness counts. +pub(crate) fn read_source(bytes: &[u8], name: &str) -> Result<(Drawing, Option>)> { if cadcraft_dwg::is_dwg(bytes) { let (dxf, notes) = cadcraft_dwg::dwg_to_dxf_noted(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?; let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?; d.read_notes = notes; - return Ok(d); + return Ok((d, Some(dxf))); } - match ext(name).as_str() { + let d = match ext(name).as_str() { "svg" | "svgz" => svg_read::read(bytes), "dxf" | "" => dxf_read::read(bytes), e if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())), e => Err(IoError::Unsupported(e.to_string())), - } + }?; + Ok((d, None)) } /// Write a drawing in the format chosen by the name's extension. Images (PNG, SVG, PDF) show @@ -97,15 +106,27 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result // The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without // one, at least 2004: R2000 DWG has no true colours, transparency, table styles or // gradients, so a drawing from an R2000 DXF doesn't lose them by default. - "dwg" if !opts.binary => { - let version = opts.version.unwrap_or_else(|| version.max(Version::R2004)); - cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format) - } + "dwg" if !opts.binary => write_dwg(d, opts.version.unwrap_or_else(|| version.max(Version::R2004))).map(|(dwg, _)| dwg), e if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{e}`"))), _ => write_framed(d, name, None), } } +/// [`write_with`], also returning what the file leaves out of the drawing: for a DWG, how many +/// records of each DXF type the DWG writer can't write (other programs' custom entities and +/// objects); for other formats, nothing. +pub fn write_noted(d: &Drawing, name: &str, opts: &SaveOptions) -> Result<(Vec, cadcraft_dwg::Unwritten)> { + if ext(name) == "dwg" && !opts.binary { + return write_dwg(d, opts.version.unwrap_or_else(|| save_version(d).max(Version::R2004))); + } + write_with(d, name, opts).map(|b| (b, cadcraft_dwg::Unwritten::new())) +} + +/// The DWG bridge reads our R2000 DXF and writes the DWG at `version`. +fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec, cadcraft_dwg::Unwritten)> { + cadcraft_dwg::dxf_to_dwg_noted(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format) +} + /// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in /// drawing units, centred and scaled to fit the image without distortion) instead of the /// extents. Formats that are not images (DXF, DWG) ignore the window. The window is checked with diff --git a/crates/io/src/roundtrip.rs b/crates/io/src/roundtrip.rs new file mode 100644 index 000000000..e801e663d --- /dev/null +++ b/crates/io/src/roundtrip.rs @@ -0,0 +1,1148 @@ +//! Round-trip checks (gaps FF1 and FF5): open a drawing, save it, open the saved file again and +//! list everything that differs between the two drawings. +//! +//! [`compare`] works on CADCraft's own model ([`Drawing`]), so it measures what saving and +//! reopening lose or change: entities per type in every space and block (matched by handle, +//! geometry and properties within a tolerance), table records, blocks, layouts, the data CADCraft +//! keeps without modelling it (other programs' objects and entities, dictionaries, CLASS records, +//! extension dictionaries, reactors, xdata) and header variables. [`census`] counts what the +//! file itself holds, so opening a file can be checked against it too. +//! +//! The corpus harness ([`corpus`], native only) runs both over a folder of real files and writes +//! a Markdown and JSON report; `cadcraft-cli roundtrip` and `cargo xtask corpus` run it. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use cadcraft_doc::foreign::{Foreign, ForeignExtra}; +use cadcraft_doc::{Drawing, Entity, EntityKind, EntityStore, Handle, RawTag}; +use serde::Serialize; +use serde_json::Value; + +mod census; +#[cfg(not(target_arch = "wasm32"))] +pub mod corpus; + +pub use census::{Census, census, census_of, compare_census}; + +/// What a difference is about. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Category { + /// Opening the file: what the file holds that the drawing read from it doesn't. + Read, + /// Entities of a type in a space or block. + Count, + /// Entity, object or dictionary entry handles. + Handle, + /// An entity's geometry and type-specific data. + Geometry, + /// An entity's common properties (layer, colour, linetype, lineweight, transparency…). + Properties, + /// Entities CADCraft doesn't model, kept as their DXF groups. + Opaque, + /// Other applications' extended data. + Xdata, + /// Extension dictionaries and persistent reactors. + ExtDict, + /// Table records: layers, linetypes, text, dimension, multileader, table and multiline styles, + /// views, viewport configurations, UCSs, layer states, groups, scales, plot style tables. + Table, + /// Block definitions (their own properties; their entities are under the other categories). + Block, + /// Layouts and their page setups. + Layout, + /// Non-graphical objects CADCraft doesn't model, kept as their DXF groups; constraints. + Object, + /// Entries of the named object dictionary. + Dictionary, + /// CLASS records. + Class, + /// Header variables. + Header, +} + +impl Category { + pub const ALL: [Category; 15] = [ + Category::Read, + Category::Count, + Category::Handle, + Category::Geometry, + Category::Properties, + Category::Opaque, + Category::Xdata, + Category::ExtDict, + Category::Table, + Category::Block, + Category::Layout, + Category::Object, + Category::Dictionary, + Category::Class, + Category::Header, + ]; + + /// A short name for reports. + pub fn label(self) -> &'static str { + match self { + Category::Read => "read", + Category::Count => "entity count", + Category::Handle => "handles", + Category::Geometry => "geometry", + Category::Properties => "properties", + Category::Opaque => "unknown entities", + Category::Xdata => "xdata", + Category::ExtDict => "ext. dictionaries/reactors", + Category::Table => "table records", + Category::Block => "blocks", + Category::Layout => "layouts", + Category::Object => "objects", + Category::Dictionary => "dictionary entries", + Category::Class => "classes", + Category::Header => "header", + } + } +} + +/// Whether something went missing, appeared, or is there with other values. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Change { + Lost, + Added, + Changed, +} + +impl Change { + pub fn label(self) -> &'static str { + match self { + Change::Lost => "lost", + Change::Added => "added", + Change::Changed => "changed", + } + } +} + +/// One difference between two drawings. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Difference { + pub category: Category, + pub change: Change, + /// Where: `Model LINE 1A`, `Block DOOR`, `LAYER Walls`, `$LTSCALE`… + pub place: String, + /// What: the values before and after, or the items lost. + pub detail: String, + /// How many items this difference stands for (several entities lost of one type: one row). + pub count: usize, +} + +/// How many items were lost, added and changed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Tally { + pub lost: usize, + pub added: usize, + pub changed: usize, +} + +impl Tally { + pub fn total(&self) -> usize { + self.lost.saturating_add(self.added).saturating_add(self.changed) + } + fn add(&mut self, change: Change, n: usize) { + let slot = match change { + Change::Lost => &mut self.lost, + Change::Added => &mut self.added, + Change::Changed => &mut self.changed, + }; + *slot = slot.saturating_add(n); + } +} + +/// How two drawings are compared. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Options { + /// Absolute tolerance for numbers (drawing units, radians…). + pub abs_tol: f64, + /// Relative tolerance for numbers, of the larger magnitude. + pub rel_tol: f64, + /// Most differences listed; the rest are only counted ([`Comparison::omitted`]). + pub max_differences: usize, + /// Most changed values named for one object. + pub max_paths: usize, +} + +impl Default for Options { + fn default() -> Self { + Options { abs_tol: 1e-6, rel_tol: 1e-9, max_differences: 5000, max_paths: 6 } + } +} + +/// Every difference found between two drawings. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct Comparison { + pub differences: Vec, + /// Differences found beyond [`Options::max_differences`] (counted in `totals`, not listed). + pub omitted: usize, + /// Items lost, added and changed per category. + pub totals: BTreeMap, +} + +impl Comparison { + pub fn is_empty(&self) -> bool { + self.totals.values().all(|t| t.total() == 0) + } + /// Items lost, added and changed in all categories. + pub fn total(&self) -> Tally { + let mut all = Tally::default(); + for t in self.totals.values() { + all.add(Change::Lost, t.lost); + all.add(Change::Added, t.added); + all.add(Change::Changed, t.changed); + } + all + } + /// The tally of one category. + pub fn tally(&self, c: Category) -> Tally { + self.totals.get(&c).copied().unwrap_or_default() + } + /// Add another comparison's differences (within this one's limit). + pub fn merge(&mut self, other: Comparison, max: usize) { + for (c, t) in other.totals { + let mine = self.totals.entry(c).or_default(); + mine.add(Change::Lost, t.lost); + mine.add(Change::Added, t.added); + mine.add(Change::Changed, t.changed); + } + for d in other.differences { + if self.differences.len() < max { + self.differences.push(d); + } else { + self.omitted = self.omitted.saturating_add(1); + } + } + self.omitted = self.omitted.saturating_add(other.omitted); + } +} + +/// Collects differences within the limit. +struct Out<'o> { + opts: &'o Options, + cmp: Comparison, + /// Anonymous blocks saved under another name (`*D344` → `*D10`), upper case: references + /// to them compare equal. + renamed: HashMap, + /// Things that only got new handles, summed up in one row each: (what) → (how many, an + /// example). + renumbered: BTreeMap, + /// Anonymous blocks of the first drawing that nothing refers to and that the second one + /// doesn't have (saving drops them, as PURGE would), upper case: reported in one row. + orphans: BTreeSet, +} + +/// What values are compared with: the tolerances and the renamed anonymous blocks. +struct Norm<'a> { + o: &'a Options, + renamed: &'a HashMap, +} + +impl Norm<'_> { + /// Equal strings, or an anonymous block's name and the name it was saved under. + fn same_str(&self, a: &str, b: &str) -> bool { + a == b || (a.starts_with('*') && self.renamed.get(&a.to_ascii_uppercase()).is_some_and(|n| n.eq_ignore_ascii_case(b))) + } +} + +impl<'o> Out<'o> { + fn new(opts: &'o Options) -> Self { + Out { opts, cmp: Comparison::default(), renamed: HashMap::new(), renumbered: BTreeMap::new(), orphans: BTreeSet::new() } + } + fn norm(&self) -> Norm<'_> { + Norm { o: self.opts, renamed: &self.renamed } + } + /// Note that one of `what` only got a new handle. + fn renumber(&mut self, what: &str, example: impl FnOnce() -> String) { + match self.renumbered.get_mut(what) { + Some(slot) => slot.0 = slot.0.saturating_add(1), + None => { + self.renumbered.insert(what.to_string(), (1, example())); + } + } + } + /// The rows of [`Out::renumber`]. + fn flush_renumbered(&mut self) { + for (what, (n, example)) in std::mem::take(&mut self.renumbered) { + self.push_n(Category::Handle, Change::Changed, n, what.clone(), format!("{n} {what} got new handles (e.g. {example})")); + } + } + fn push(&mut self, category: Category, change: Change, place: impl Into, detail: impl Into) { + self.push_n(category, change, 1, place, detail); + } + fn push_n(&mut self, category: Category, change: Change, count: usize, place: impl Into, detail: impl Into) { + self.cmp.totals.entry(category).or_default().add(change, count); + if self.cmp.differences.len() < self.opts.max_differences { + self.cmp.differences.push(Difference { category, change, place: place.into(), detail: detail.into(), count }); + } else { + self.cmp.omitted = self.cmp.omitted.saturating_add(1); + } + } +} + +/// Header variables that change on every save (time stamps, the handle seed, GUIDs): not +/// compared. +pub const VOLATILE_HEADER: &[&str] = + &["HANDSEED", "TDCREATE", "TDUCREATE", "TDUPDATE", "TDUUPDATE", "TDINDWG", "TDUSRTIMER", "VERSIONGUID", "FINGERPRINTGUID"]; + +/// Compare `before` (the drawing as first opened) with `after` (the same drawing saved and +/// reopened). Differences are reported as what `after` lost, gained or changed. +pub fn compare(before: &Drawing, after: &Drawing, opts: &Options) -> Comparison { + let mut out = Out::new(opts); + compare_entities(before, after, &mut out); + compare_tables(before, after, &mut out); + compare_foreign(&before.foreign, &after.foreign, &mut out); + compare_header(before, after, &mut out); + out.flush_renumbered(); + out.cmp +} + +// --------------------------------------------------------------------------------------------- +// Values + +fn close(a: f64, b: f64, o: &Options) -> bool { + a == b || (a - b).abs() <= o.abs_tol + o.rel_tol * a.abs().max(b.abs()) +} + +/// The changed values of one object, as `path: before → after`. +struct Paths { + list: Vec, + cap: usize, + more: usize, +} + +impl Paths { + fn new(cap: usize) -> Self { + Paths { list: Vec::new(), cap: cap.max(1), more: 0 } + } + fn add(&mut self, s: String) { + if self.list.len() < self.cap { + self.list.push(s); + } else { + self.more = self.more.saturating_add(1); + } + } + fn is_empty(&self) -> bool { + self.list.is_empty() + } + /// Far more changes than can be listed: stop looking. + fn saturated(&self) -> bool { + self.more > 1000 + } + fn text(&self) -> String { + let mut s = self.list.join("; "); + if self.more > 0 { + s.push_str(&format!("; and {} more", if self.saturated() { "many".to_string() } else { self.more.to_string() })); + } + s + } +} + +/// A value for a report: strings quoted, at most 60 characters. +fn short(v: &Value) -> String { + let s = match v { + Value::String(s) => format!("\"{s}\""), + Value::Array(a) => format!("[{} items]", a.len()), + Value::Object(_) => "{…}".to_string(), + other => other.to_string(), + }; + clip(&s, 60) +} + +/// `s` cut to `n` characters, with an ellipsis when cut. +pub(crate) fn clip(s: &str, n: usize) -> String { + if s.chars().count() <= n { + s.to_string() + } else { + let mut t: String = s.chars().take(n.saturating_sub(1)).collect(); + t.push('…'); + t + } +} + +fn show(path: &str) -> &str { + if path.is_empty() { "value" } else { path } +} + +const MAX_DEPTH: usize = 64; + +/// Whether a value's path names an angle (in radians in the model): `rotation`, `start`, +/// `end`, `angle`, `twist`, `oblique`. +fn is_angle(path: &str) -> bool { + let last = path.rsplit('.').next().unwrap_or(path); + let last = last.split('[').next().unwrap_or(last).to_ascii_lowercase(); + ["rotation", "start", "end", "angle", "twist", "oblique"].iter().any(|k| last.ends_with(k)) +} + +/// The same angle, a whole number of turns apart (-π/4 and 7π/4). +fn same_angle(a: f64, b: f64, o: &Options) -> bool { + let d = (a - b).rem_euclid(std::f64::consts::TAU); + let tol = o.abs_tol + o.rel_tol * a.abs().max(b.abs()); + d <= tol || std::f64::consts::TAU - d <= tol +} + +fn diff_values(a: &Value, b: &Value, path: &mut String, n: &Norm, p: &mut Paths, depth: usize) { + if p.saturated() { + return; + } + if depth > MAX_DEPTH { + if a != b { + p.add(format!("{}: differs", show(path))); + } + return; + } + match (a, b) { + (Value::Number(x), Value::Number(y)) => { + let same = match (x.as_i64(), y.as_i64()) { + (Some(i), Some(j)) => i == j, + _ => match (x.as_f64(), y.as_f64()) { + (Some(f), Some(g)) => close(f, g, n.o) || (is_angle(path) && same_angle(f, g, n.o)), + _ => x == y, + }, + }; + if !same { + p.add(format!("{}: {x} → {y}", show(path))); + } + } + (Value::String(x), Value::String(y)) => { + if !n.same_str(x, y) { + p.add(format!("{}: {} → {}", show(path), short(a), short(b))); + } + } + (Value::Object(x), Value::Object(y)) => { + let keys: BTreeSet<&String> = x.keys().chain(y.keys()).collect(); + for k in keys { + let len = path.len(); + if !path.is_empty() { + path.push('.'); + } + path.push_str(k); + match (x.get(k.as_str()), y.get(k.as_str())) { + (Some(u), Some(v)) => diff_values(u, v, path, n, p, depth + 1), + (Some(Value::Null), None) | (None, Some(Value::Null)) | (None, None) => {} + (Some(u), None) => p.add(format!("{}: {} → (none)", show(path), short(u))), + (None, Some(v)) => p.add(format!("{}: (none) → {}", show(path), short(v))), + } + path.truncate(len); + } + } + (Value::Array(x), Value::Array(y)) => { + if x.len() != y.len() { + p.add(format!("{}: {} items → {}", show(path), x.len(), y.len())); + } + for (i, (u, v)) in x.iter().zip(y).enumerate() { + if p.saturated() { + return; + } + let len = path.len(); + path.push_str(&format!("[{i}]")); + diff_values(u, v, path, n, p, depth + 1); + path.truncate(len); + } + } + _ if a == b => {} + _ => p.add(format!("{}: {} → {}", show(path), short(a), short(b))), + } +} + +/// The values that differ between two serializable things. +fn diff_ser(a: &T, b: &T, n: &Norm) -> Paths { + let mut p = Paths::new(n.o.max_paths); + match (serde_json::to_value(a), serde_json::to_value(b)) { + (Ok(x), Ok(y)) => diff_values(&x, &y, &mut String::new(), n, &mut p, 0), + _ if a != b => p.add("differs (not comparable field by field)".into()), + _ => {} + } + p +} + +/// A key for matching things that may have been renumbered: the value with numbers rounded. +fn signature(v: &Value, out: &mut String, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match v { + Value::Number(n) => match n.as_f64() { + Some(f) if f.is_finite() => { + let r = if f.abs() < 1e-9 { 0.0 } else { f }; + out.push_str(&format!("{r:.6e}")); + } + _ => out.push_str(&n.to_string()), + }, + Value::Array(a) => { + out.push('['); + for x in a { + signature(x, out, depth + 1); + out.push(','); + } + out.push(']'); + } + Value::Object(m) => { + out.push('{'); + for (k, x) in m { + out.push_str(k); + out.push(':'); + signature(x, out, depth + 1); + out.push(','); + } + out.push('}'); + } + other => out.push_str(&other.to_string()), + } +} + +fn sig_of(t: &T) -> String { + let mut s = String::new(); + if let Ok(v) = serde_json::to_value(t) { + signature(&v, &mut s, 0); + } + s +} + +/// Pair up the items of `a` and `b` that weren't matched by handle: equal signatures first, then +/// in order. Returns the pairs and the items left over on each side. +fn pair_up<'x, T>(a: Vec<&'x T>, b: Vec<&'x T>, sig: impl Fn(&T) -> String) -> (Vec<(&'x T, &'x T)>, Vec<&'x T>, Vec<&'x T>) { + let mut by_sig: HashMap> = HashMap::new(); + for (i, y) in b.iter().enumerate().rev() { + by_sig.entry(sig(y)).or_default().push(i); + } + let mut used = vec![false; b.len()]; + let mut pairs = Vec::new(); + let mut rest_a = Vec::new(); + for x in a { + match by_sig.get_mut(&sig(x)).and_then(Vec::pop) { + Some(i) => { + if let (Some(y), Some(u)) = (b.get(i), used.get_mut(i)) { + *u = true; + pairs.push((x, *y)); + } + } + None => rest_a.push(x), + } + } + let mut rest_b: Vec<&T> = b.iter().zip(&used).filter(|(_, u)| !**u).map(|(y, _)| *y).collect(); + let n = rest_a.len().min(rest_b.len()); + let tail_a = rest_a.split_off(n); + let tail_b = rest_b.split_off(n); + pairs.extend(rest_a.into_iter().zip(rest_b)); + (pairs, tail_a, tail_b) +} + +fn hex_list(hs: impl Iterator, n: usize) -> String { + let all: Vec = hs.map(Handle::hex).collect(); + let mut s = all.iter().take(n).cloned().collect::>().join(", "); + if all.len() > n { + s.push_str(", …"); + } + s +} + +/// Group codes that hold handles (DXF Reference: 5, 105, 320–369, 390–399, 480–481, xdata +/// 1005). A file's objects can be renumbered on save (gap FF7), so a handle compares equal to +/// any other handle; only a reference that became null (or stopped being null) counts. +fn is_handle_code(code: i32) -> bool { + matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480 | 481 | 1005) +} + +fn null_handle(v: &str) -> bool { + v.trim().trim_start_matches('0').is_empty() +} + +/// Whether two groups hold the same value: the same text, numbers within the tolerance, or two +/// (non-null) handles. +fn tag_eq(a: &RawTag, b: &RawTag, o: &Options) -> bool { + if a.code != b.code { + return false; + } + if is_handle_code(a.code) { + return null_handle(&a.value) == null_handle(&b.value); + } + a.value == b.value + || a.value.trim() == b.value.trim() + || matches!((a.value.trim().parse::(), b.value.trim().parse::()), (Ok(x), Ok(y)) if close(x, y, o)) +} + +/// How two group lists differ, or `None` when they hold the same values. +fn tags_diff(a: &[RawTag], b: &[RawTag], o: &Options) -> Option { + let first = a.iter().zip(b).position(|(x, y)| !tag_eq(x, y, o)); + if first.is_none() && a.len() == b.len() { + return None; + } + let mut s = String::new(); + if a.len() != b.len() { + s.push_str(&format!("{} groups → {}; ", a.len(), b.len())); + } + let at = first.unwrap_or(a.len().min(b.len())); + let show_tag = |t: Option<&RawTag>| t.map_or("(end)".to_string(), |t| format!("{} `{}`", t.code, clip(&t.value, 40))); + s.push_str(&format!("first difference at group #{at}: {} → {}", show_tag(a.get(at)), show_tag(b.get(at)))); + Some(s) +} + +// --------------------------------------------------------------------------------------------- +// Entities + +/// An entity collection of a drawing: model space, a layout's paper space or a block. `key` is +/// upper case (`BLOCK *D12`), `name` for reports (`Block *D12`). +struct Container<'d> { + key: String, + name: String, + store: &'d EntityStore, +} + +fn containers(d: &Drawing) -> Vec> { + let mut v = vec![Container { key: "MODEL".into(), name: "Model".into(), store: &d.model }]; + for l in &d.layouts { + v.push(Container { key: format!("LAYOUT {}", l.name.to_ascii_uppercase()), name: format!("Layout {}", l.name), store: &l.entities }); + } + for (name, b) in &d.blocks { + v.push(Container { key: format!("BLOCK {}", name.to_ascii_uppercase()), name: format!("Block {name}"), store: &b.entities }); + } + v +} + +/// Which container each entity is in. +fn locate(cs: &[Container]) -> HashMap { + cs.iter().enumerate().flat_map(|(i, c)| c.store.iter().map(move |e| (e.handle, i))).collect() +} + +/// Anonymous blocks of `a` whose entities (by handle) are, at least half of them, in another +/// anonymous block of `b`: saving renamed them (`*D344` → `*D10`). Upper-case names. +fn anonymous_renames(ca: &[Container], cb: &[Container], where_b: &HashMap) -> HashMap { + let mut renamed = HashMap::new(); + let mut taken: HashSet = HashSet::new(); + for c in ca { + let Some(name) = c.key.strip_prefix("BLOCK ").filter(|n| n.starts_with('*')) else { continue }; + if c.store.is_empty() { + continue; + } + let mut votes: BTreeMap = BTreeMap::new(); + for e in c.store.iter() { + if let Some(j) = where_b.get(&e.handle) { + *votes.entry(*j).or_default() += 1; + } + } + let best = votes.into_iter().max_by(|x, y| x.1.cmp(&y.1).then(y.0.cmp(&x.0))); + let Some((j, n)) = best else { continue }; + let Some(target) = cb.get(j).and_then(|t| t.key.strip_prefix("BLOCK ")) else { continue }; + if n.saturating_mul(2) >= c.store.len() && target != name && target.starts_with('*') && taken.insert(j) { + renamed.insert(name.to_string(), target.to_string()); + } + } + renamed +} + +struct Ctx<'d> { + a: &'d Drawing, + b: &'d Drawing, + cb: &'d [Container<'d>], + where_a: &'d HashMap, + where_b: &'d HashMap, +} + +fn compare_entities(a: &Drawing, b: &Drawing, out: &mut Out) { + let ca = containers(a); + let cb = containers(b); + let where_a = locate(&ca); + let where_b = locate(&cb); + out.renamed = anonymous_renames(&ca, &cb, &where_b); + let index_b: HashMap<&str, usize> = cb.iter().enumerate().map(|(i, c)| (c.key.as_str(), i)).collect(); + let target = |c: &Container| match c.key.strip_prefix("BLOCK ").and_then(|n| out.renamed.get(n)) { + Some(n) => index_b.get(format!("BLOCK {n}").as_str()).copied(), + None => index_b.get(c.key.as_str()).copied(), + }; + let targets: Vec> = ca.iter().map(target).collect(); + // A block renamed onto the name another block had: that one has no counterpart by name. + let renamed_onto: HashSet = ca + .iter() + .zip(&targets) + .filter(|(c, _)| c.key.strip_prefix("BLOCK ").is_some_and(|n| out.renamed.contains_key(n))) + .filter_map(|(_, t)| *t) + .collect(); + let ctx = Ctx { a, b, cb: &cb, where_a: &where_a, where_b: &where_b }; + let empty = EntityStore::new(); + let mut matched: HashSet = HashSet::new(); + let mut moves: BTreeMap<(String, String), Vec> = BTreeMap::new(); + let referenced = referenced_blocks(&ca); + let mut orphan_entities = 0usize; + for (c, t) in ca.iter().zip(&targets) { + let renamed = c.key.strip_prefix("BLOCK ").is_some_and(|n| out.renamed.contains_key(n)); + let j = t.filter(|j| renamed || !renamed_onto.contains(j)).filter(|j| matched.insert(*j)); + if j.is_none() + && let Some(name) = c.key.strip_prefix("BLOCK ").filter(|n| n.starts_with('*') && !referenced.contains(*n)) + && c.store.iter().all(|e| !where_b.contains_key(&e.handle)) + { + out.orphans.insert(name.to_string()); + orphan_entities = orphan_entities.saturating_add(c.store.len()); + continue; + } + let sb = j.and_then(|j| cb.get(j)).map_or(&empty, |x| x.store); + compare_store(&c.name, c.store, sb, &ctx, &mut moves, out); + } + if let Some(first) = out.orphans.first().cloned() { + let n = out.orphans.len(); + let detail = format!("{orphan_entities} entities of {n} anonymous blocks nothing refers to (e.g. {first}) were not saved"); + out.push_n(Category::Count, Change::Lost, orphan_entities, "unreferenced anonymous blocks", detail); + } + for (j, c) in cb.iter().enumerate() { + if !matched.contains(&j) { + compare_store(&c.name, &empty, c.store, &ctx, &mut moves, out); + } + } + for ((from, to), hs) in moves { + let detail = format!("{} entities moved from {from} to {to} ({})", hs.len(), hex_list(hs.iter().copied(), 6)); + out.push_n(Category::Handle, Change::Changed, hs.len(), from, detail); + } +} + +/// The blocks a drawing's entities refer to (block references, dimensions, multileaders), upper +/// case. +fn referenced_blocks(cs: &[Container]) -> HashSet { + let mut out = HashSet::new(); + for e in cs.iter().flat_map(|c| c.store.iter()) { + match &e.kind { + EntityKind::Insert(i) => { + out.insert(i.block.to_ascii_uppercase()); + } + EntityKind::Dimension(d) => out.extend(d.block.as_ref().map(|b| b.to_ascii_uppercase())), + EntityKind::MLeader(m) => out.extend(m.block.as_ref().map(|b| b.block.to_ascii_uppercase())), + _ => {} + } + } + out +} + +fn kind_sig(e: &Entity) -> String { + sig_of(&e.kind) +} + +fn compare_store(name: &str, sa: &EntityStore, sb: &EntityStore, ctx: &Ctx, moves: &mut BTreeMap<(String, String), Vec>, out: &mut Out) { + let ib: HashMap = sb.iter().map(|e| (e.handle, e.as_ref())).collect(); + let ia: HashSet = sa.iter().map(|e| e.handle).collect(); + let mut count_a: BTreeMap = BTreeMap::new(); + let mut count_b: BTreeMap = BTreeMap::new(); + let mut un_a: BTreeMap> = BTreeMap::new(); + let mut un_b: BTreeMap> = BTreeMap::new(); + for e in sa.iter() { + let t = e.kind.dxf_name().to_string(); + *count_a.entry(t.clone()).or_default() += 1; + if let Some(f) = ib.get(&e.handle) { + compare_entity(name, e, f, ctx, out); + } else if let Some(other) = ctx.where_b.get(&e.handle).and_then(|j| ctx.cb.get(*j)) { + moves.entry((name.to_string(), other.name.clone())).or_default().push(e.handle); + if let Some(f) = other.store.get(e.handle) { + compare_entity(name, e, f, ctx, out); + } + } else { + un_a.entry(t).or_default().push(e); + } + } + for e in sb.iter() { + let t = e.kind.dxf_name().to_string(); + *count_b.entry(t.clone()).or_default() += 1; + if !ia.contains(&e.handle) && !ctx.where_a.contains_key(&e.handle) { + un_b.entry(t).or_default().push(e); + } + } + let types: BTreeSet = un_a.keys().chain(un_b.keys()).cloned().collect(); + for t in types { + let xs = un_a.remove(&t).unwrap_or_default(); + let ys = un_b.remove(&t).unwrap_or_default(); + let (pairs, lost, added) = pair_up(xs, ys, kind_sig); + for (x, y) in &pairs { + out.renumber(&format!("{t} entities"), || format!("{} → {} in {name}", x.handle.hex(), y.handle.hex())); + compare_entity(name, x, y, ctx, out); + } + let (na, nb) = (count_a.get(&t).copied().unwrap_or(0), count_b.get(&t).copied().unwrap_or(0)); + if !lost.is_empty() { + let detail = format!("{t}: {na} → {nb} ({} lost: {})", lost.len(), hex_list(lost.iter().map(|e| e.handle), 8)); + out.push_n(Category::Count, Change::Lost, lost.len(), format!("{name} {t}"), detail); + } + if !added.is_empty() { + let detail = format!("{t}: {na} → {nb} ({} added: {})", added.len(), hex_list(added.iter().map(|e| e.handle), 8)); + out.push_n(Category::Count, Change::Added, added.len(), format!("{name} {t}"), detail); + } + } +} + +fn compare_entity(container: &str, x: &Entity, y: &Entity, ctx: &Ctx, out: &mut Out) { + let handles = if x.handle == y.handle { x.handle.hex() } else { format!("{}→{}", x.handle.hex(), y.handle.hex()) }; + let place = format!("{container} {} {handles}", x.kind.dxf_name()); + if std::mem::discriminant(&x.kind) != std::mem::discriminant(&y.kind) || x.kind.dxf_name() != y.kind.dxf_name() { + out.push(Category::Geometry, Change::Changed, place, format!("type {} → {}", x.kind.dxf_name(), y.kind.dxf_name())); + return; + } + match (&x.kind, &y.kind) { + (EntityKind::Unknown(u), EntityKind::Unknown(v)) => { + if let Some(d) = tags_diff(&u.tags, &v.tags, out.opts) { + out.push(Category::Opaque, Change::Changed, place.clone(), d); + } + } + _ => { + let p = diff_ser(&x.kind, &y.kind, &out.norm()); + if !p.is_empty() { + out.push(Category::Geometry, Change::Changed, place.clone(), p.text()); + } + } + } + let common = |e: &Entity| { + let mut v = serde_json::to_value(&e.common).unwrap_or(Value::Null); + if let Value::Object(m) = &mut v { + m.remove("xdata"); + } + v + }; + let mut p = Paths::new(out.opts.max_paths); + diff_values(&common(x), &common(y), &mut String::new(), &out.norm(), &mut p, 0); + if !p.is_empty() { + out.push(Category::Properties, Change::Changed, place.clone(), p.text()); + } + compare_xdata(&place, &x.common.xdata, &y.common.xdata, out); + compare_extra(&place, ctx.a.foreign.entities.get(&x.handle), ctx.b.foreign.entities.get(&y.handle), out); +} + +/// Extended data split per application (from each 1001 group). +fn xdata_apps(tags: &[RawTag]) -> Vec<(String, &[RawTag])> { + let starts: Vec = tags.iter().enumerate().filter(|(_, t)| t.code == 1001).map(|(i, _)| i).collect(); + let mut v = Vec::new(); + for (k, &s) in starts.iter().enumerate() { + let end = starts.get(k + 1).copied().unwrap_or(tags.len()); + let name = tags.get(s).map(|t| t.value.trim().to_ascii_uppercase()).unwrap_or_default(); + v.push((name, tags.get(s..end).unwrap_or(&[]))); + } + if starts.first().is_some_and(|&s| s > 0) || (starts.is_empty() && !tags.is_empty()) { + let end = starts.first().copied().unwrap_or(tags.len()); + v.insert(0, (String::new(), tags.get(..end).unwrap_or(&[]))); + } + v +} + +fn compare_xdata(place: &str, a: &[RawTag], b: &[RawTag], out: &mut Out) { + if a.is_empty() && b.is_empty() { + return; + } + let (xa, xb) = (xdata_apps(a), xdata_apps(b)); + let mb: HashMap<&str, &[RawTag]> = xb.iter().map(|(n, t)| (n.as_str(), *t)).collect(); + let ma: HashSet<&str> = xa.iter().map(|(n, _)| n.as_str()).collect(); + for (name, ta) in &xa { + let label = if name.is_empty() { "(no application)" } else { name }; + match mb.get(name.as_str()) { + None => out.push(Category::Xdata, Change::Lost, place, format!("application {label} ({} groups)", ta.len())), + Some(tb) => { + if let Some(d) = tags_diff(ta, tb, out.opts) { + out.push(Category::Xdata, Change::Changed, place, format!("application {label}: {d}")); + } + } + } + } + for (name, tb) in &xb { + if !ma.contains(name.as_str()) { + let label = if name.is_empty() { "(no application)" } else { name }; + out.push(Category::Xdata, Change::Added, place, format!("application {label} ({} groups)", tb.len())); + } + } +} + +fn compare_extra(place: &str, a: Option<&ForeignExtra>, b: Option<&ForeignExtra>, out: &mut Out) { + let none = ForeignExtra::default(); + let (a, b) = (a.unwrap_or(&none), b.unwrap_or(&none)); + if a == b { + return; + } + match (a.xdict, b.xdict) { + (Some(h), None) => out.push(Category::ExtDict, Change::Lost, place, format!("extension dictionary {}", h.hex())), + (None, Some(h)) => out.push(Category::ExtDict, Change::Added, place, format!("extension dictionary {}", h.hex())), + (Some(h), Some(k)) if h != k => out.renumber("extension dictionaries", || format!("{} → {} of {place}", h.hex(), k.hex())), + _ => {} + } + if a.reactors.len() > b.reactors.len() { + let n = a.reactors.len() - b.reactors.len(); + out.push_n(Category::ExtDict, Change::Lost, n, place, format!("reactors: {} → {}", a.reactors.len(), b.reactors.len())); + } else if b.reactors.len() > a.reactors.len() { + let n = b.reactors.len() - a.reactors.len(); + out.push_n(Category::ExtDict, Change::Added, n, place, format!("reactors: {} → {}", a.reactors.len(), b.reactors.len())); + } + compare_xdata(place, &a.xdata, &b.xdata, out); + if a.color_book != b.color_book { + let show = |c: &Option<(String, String)>| c.as_ref().map_or("(none)".to_string(), |(book, tc)| format!("`{}` ({tc})", clip(book, 40))); + let change = match (&a.color_book, &b.color_book) { + (Some(_), None) => Change::Lost, + (None, Some(_)) => Change::Added, + _ => Change::Changed, + }; + out.push(Category::Properties, change, place, format!("colour book colour {} → {}", show(&a.color_book), show(&b.color_book))); + } + if let Some(d) = tags_diff(&a.props, &b.props, out.opts) { + out.push(Category::Properties, Change::Changed, place, format!("other property groups (material, shadows…): {d}")); + } +} + +// --------------------------------------------------------------------------------------------- +// Tables, blocks, layouts + +/// Compare two lists of named records, matched by name without case. +fn compare_named(out: &mut Out, cat: Category, kind: &str, a: &[&T], b: &[&T], name: impl Fn(&T) -> &str) { + let mut mb: HashMap = HashMap::new(); + for y in b.iter().rev() { + mb.insert(name(y).to_ascii_uppercase(), *y); + } + let mut seen: HashSet = HashSet::new(); + for x in a { + let key = name(x).to_ascii_uppercase(); + if !seen.insert(key.clone()) { + continue; + } + let place = format!("{kind} {}", name(x)); + match mb.get(&key) { + None => out.push(cat, Change::Lost, place, String::new()), + Some(y) => { + let p = diff_ser(*x, *y, &out.norm()); + if !p.is_empty() { + out.push(cat, Change::Changed, place, p.text()); + } + } + } + } + let mut seen_b: HashSet = HashSet::new(); + for y in b { + let key = name(y).to_ascii_uppercase(); + if !seen.contains(&key) && seen_b.insert(key) { + out.push(cat, Change::Added, format!("{kind} {}", name(y)), String::new()); + } + } +} + +fn refs(v: &[T]) -> Vec<&T> { + v.iter().collect() +} + +fn compare_tables(a: &Drawing, b: &Drawing, out: &mut Out) { + use Category::{Block, Layout, Table}; + compare_named(out, Table, "LAYER", &refs(&a.layers), &refs(&b.layers), |x| &x.name); + compare_named(out, Table, "LTYPE", &refs(&a.linetypes), &refs(&b.linetypes), |x| &x.name); + compare_named(out, Table, "STYLE", &refs(&a.text_styles), &refs(&b.text_styles), |x| &x.name); + compare_named(out, Table, "DIMSTYLE", &refs(&a.dim_styles), &refs(&b.dim_styles), |x| &x.name); + compare_named(out, Table, "MLEADERSTYLE", &refs(&a.mleader_styles), &refs(&b.mleader_styles), |x| &x.name); + compare_named(out, Table, "TABLESTYLE", &refs(&a.table_styles), &refs(&b.table_styles), |x| &x.name); + compare_named(out, Table, "MLINESTYLE", &refs(&a.mline_styles), &refs(&b.mline_styles), |x| &x.name); + compare_named(out, Table, "VIEW", &refs(&a.views), &refs(&b.views), |x| &x.name); + compare_named(out, Table, "VPORT", &refs(&a.vports), &refs(&b.vports), |x| &x.name); + compare_named(out, Table, "UCS", &refs(&a.ucss), &refs(&b.ucss), |x| &x.name); + compare_named(out, Table, "LAYERSTATE", &refs(&a.layer_states), &refs(&b.layer_states), |x| &x.name); + compare_named(out, Table, "GROUP", &refs(&a.groups), &refs(&b.groups), |x| &x.name); + compare_named(out, Table, "SCALE", &refs(&a.scales), &refs(&b.scales), |x| &x.name); + compare_named(out, Table, "PLOTSTYLE", &refs(&a.plot_style_tables), &refs(&b.plot_style_tables), |x| &x.name); + // Blocks: renamed anonymous blocks are compared with what they were saved as. + let renamed_to: HashSet = out.renamed.values().cloned().collect(); + let ba: Vec<&cadcraft_doc::Block> = a + .blocks + .values() + .map(|x| x.as_ref()) + .filter(|x| { + let up = x.name.to_ascii_uppercase(); + !out.renamed.contains_key(&up) && !out.orphans.contains(&up) + }) + .collect(); + if let Some(first) = out.orphans.first().cloned() { + let n = out.orphans.len(); + out.push_n( + Block, + Change::Lost, + n, + "unreferenced anonymous blocks", + format!("{n} anonymous blocks nothing refers to (e.g. {first}) were not saved"), + ); + } + let bb: Vec<&cadcraft_doc::Block> = + b.blocks.values().map(|x| x.as_ref()).filter(|x| !renamed_to.contains(&x.name.to_ascii_uppercase())).collect(); + let mut pairs: Vec<(&cadcraft_doc::Block, &cadcraft_doc::Block)> = Vec::new(); + for (from, to) in &out.renamed { + if let (Some(x), Some(y)) = (a.block(from), b.block(to)) { + pairs.push((x.as_ref(), y.as_ref())); + } + } + compare_named(out, Block, "BLOCK", &ba, &bb, |x| &x.name); + pairs.sort_by(|x, y| x.0.name.cmp(&y.0.name)); + if let Some((x, y)) = pairs.first() { + let detail = format!("{} anonymous blocks saved under new names (e.g. {} → {})", pairs.len(), x.name, y.name); + out.push_n(Block, Change::Changed, pairs.len(), "anonymous blocks", detail); + } + for (x, y) in pairs { + let p = diff_ser(x, y, &out.norm()); + if !p.is_empty() { + out.push(Block, Change::Changed, format!("BLOCK {} → {}", x.name, y.name), p.text()); + } + } + compare_named(out, Layout, "LAYOUT", &refs(&a.layouts), &refs(&b.layouts), |x| &x.name); + for (place, p) in + [("CONSTRAINTS", diff_ser(&a.constraints, &b.constraints, &out.norm())), ("PARAMETERS", diff_ser(&a.parametric, &b.parametric, &out.norm()))] + { + if !p.is_empty() { + out.push(Category::Object, Change::Changed, place, p.text()); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Data kept without modelling it + +fn class_name(c: &[RawTag]) -> String { + c.iter().find(|t| t.code == 1).map(|t| t.value.trim().to_string()).unwrap_or_default() +} + +/// A CLASS record without its instance count (group 91), which follows the objects written. +fn class_groups(c: &[RawTag]) -> Vec { + c.iter().filter(|t| t.code != 91).cloned().collect() +} + +fn compare_foreign(a: &Foreign, b: &Foreign, out: &mut Out) { + let o = out.opts; + type Obj = cadcraft_doc::foreign::ForeignObject; + // Objects, by handle, then by content. + let ib: HashMap = b.objects.iter().map(|x| (x.handle, x)).collect(); + let ia: HashSet = a.objects.iter().map(|x| x.handle).collect(); + let mut un_a: BTreeMap> = BTreeMap::new(); + let mut un_b: BTreeMap> = BTreeMap::new(); + for x in &a.objects { + match ib.get(&x.handle) { + Some(y) if y.kind != x.kind => { + out.push(Category::Object, Change::Changed, format!("{} {}", x.kind, x.handle.hex()), format!("type {} → {}", x.kind, y.kind)) + } + Some(y) => { + if let Some(d) = tags_diff(&x.tags, &y.tags, o) { + out.push(Category::Object, Change::Changed, format!("{} {}", x.kind, x.handle.hex()), d); + } + } + None => un_a.entry(x.kind.clone()).or_default().push(x), + } + } + for y in &b.objects { + if !ia.contains(&y.handle) { + un_b.entry(y.kind.clone()).or_default().push(y); + } + } + // Content without handles, so renumbered objects pair up. + let content = |x: &Obj| sig_of(&x.tags.iter().filter(|t| !is_handle_code(t.code)).map(|t| (t.code, &t.value)).collect::>()); + let kinds: BTreeSet = un_a.keys().chain(un_b.keys()).cloned().collect(); + for k in kinds { + let xs = un_a.remove(&k).unwrap_or_default(); + let ys = un_b.remove(&k).unwrap_or_default(); + let (pairs, lost, added) = pair_up(xs, ys, content); + for (x, y) in &pairs { + out.renumber(&format!("{k} objects"), || format!("{} → {}", x.handle.hex(), y.handle.hex())); + if let Some(d) = tags_diff(&x.tags, &y.tags, o) { + out.push(Category::Object, Change::Changed, format!("{k} {}→{}", x.handle.hex(), y.handle.hex()), d); + } + } + if !lost.is_empty() { + out.push_n( + Category::Object, + Change::Lost, + lost.len(), + format!("objects {k}"), + format!("{} lost: {}", lost.len(), hex_list(lost.iter().map(|x| x.handle), 8)), + ); + } + if !added.is_empty() { + out.push_n( + Category::Object, + Change::Added, + added.len(), + format!("objects {k}"), + format!("{} added: {}", added.len(), hex_list(added.iter().map(|x| x.handle), 8)), + ); + } + } + // Named object dictionary entries. + let db: HashMap = b.dictionary.iter().map(|(k, h)| (k.to_ascii_uppercase(), *h)).collect(); + let da: HashSet = a.dictionary.iter().map(|(k, _)| k.to_ascii_uppercase()).collect(); + for (k, h) in &a.dictionary { + match db.get(&k.to_ascii_uppercase()) { + None => out.push(Category::Dictionary, Change::Lost, format!("NOD {k}"), format!("entry for {}", h.hex())), + Some(g) if g != h => out.renumber("dictionary entries", || format!("{k}: {} → {}", h.hex(), g.hex())), + Some(_) => {} + } + } + for (k, h) in &b.dictionary { + if !da.contains(&k.to_ascii_uppercase()) { + out.push(Category::Dictionary, Change::Added, format!("NOD {k}"), format!("entry for {}", h.hex())); + } + } + // CLASS records. + let cb: HashMap> = b.classes.iter().map(|c| (class_name(c).to_ascii_uppercase(), c)).collect(); + let ca: HashSet = a.classes.iter().map(|c| class_name(c).to_ascii_uppercase()).collect(); + for c in &a.classes { + let n = class_name(c); + match cb.get(&n.to_ascii_uppercase()) { + None => out.push(Category::Class, Change::Lost, format!("CLASS {n}"), String::new()), + Some(d) => { + if let Some(t) = tags_diff(&class_groups(c), &class_groups(d), o) { + out.push(Category::Class, Change::Changed, format!("CLASS {n}"), t); + } + } + } + } + for c in &b.classes { + let n = class_name(c); + if !ca.contains(&n.to_ascii_uppercase()) { + out.push(Category::Class, Change::Added, format!("CLASS {n}"), String::new()); + } + } + // Extras of table records, tables and blocks (renamed anonymous blocks by their new name). + let key_of = |k: &(String, String)| -> (String, String) { + match out.renamed.get(&k.1.to_ascii_uppercase()) { + Some(n) if k.0.starts_with("BLOCK") => (k.0.clone(), n.clone()), + _ => k.clone(), + } + }; + let ra: BTreeMap<(String, String), &ForeignExtra> = a.records.iter().map(|(k, v)| (key_of(k), v)).collect(); + let rb: BTreeMap<(String, String), &ForeignExtra> = b.records.iter().map(|(k, v)| ((k.0.clone(), k.1.to_ascii_uppercase()), v)).collect(); + let ra: BTreeMap<(String, String), &ForeignExtra> = ra.into_iter().map(|((k0, k1), v)| ((k0, k1.to_ascii_uppercase()), v)).collect(); + let keys: BTreeSet<&(String, String)> = ra.keys().chain(rb.keys()).collect(); + for k in keys { + compare_extra(&format!("{} {}", k.0, k.1), ra.get(k).copied(), rb.get(k).copied(), out); + } + // The data store (ACIS data of 2013+ files). + if let Some(d) = tags_diff(&a.data_store, &b.data_store, o) { + let change = match (a.data_store.is_empty(), b.data_store.is_empty()) { + (false, true) => Change::Lost, + (true, false) => Change::Added, + _ => Change::Changed, + }; + out.push(Category::Object, change, "ACDSDATA", d); + } +} + +// --------------------------------------------------------------------------------------------- +// Header + +fn compare_header(a: &Drawing, b: &Drawing, out: &mut Out) { + let skip = |k: &str| VOLATILE_HEADER.contains(&k); + for (k, va) in &a.header.vars { + if skip(k) { + continue; + } + let place = format!("${k}"); + match b.header.vars.get(k) { + None => out.push(Category::Header, Change::Lost, place, va.display()), + Some(vb) => { + let p = diff_ser(va, vb, &out.norm()); + if !p.is_empty() { + out.push(Category::Header, Change::Changed, place, format!("{} → {}", clip(&va.display(), 60), clip(&vb.display(), 60))); + } + } + } + } + for (k, vb) in &b.header.vars { + if !skip(k) && !a.header.vars.contains_key(k) { + out.push(Category::Header, Change::Added, format!("${k}"), vb.display()); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/io/src/roundtrip/census.rs b/crates/io/src/roundtrip/census.rs new file mode 100644 index 000000000..bf0d4488e --- /dev/null +++ b/crates/io/src/roundtrip/census.rs @@ -0,0 +1,212 @@ +//! What a DXF file holds, counted from its groups, and the same counts of a drawing read from it: +//! together they show what opening a file leaves out (the DXF rendition of a DWG for DWG files). + +use std::collections::{BTreeMap, HashSet}; + +use cadcraft_doc::Drawing; +use serde::Serialize; + +use super::{Category, Change, Comparison, Options, Out}; + +/// Counts of what a file (or a drawing) holds. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Census { + /// Entities of model space and every layout, per DXF type. + pub entities: BTreeMap, + /// Entities of block definitions (other than the model and paper space blocks), per type. + pub block_entities: BTreeMap, + /// Block definitions other than the model and paper space blocks and the generated ones. + pub blocks: usize, + /// Blocks CADCraft generates and so doesn't read (tables' blocks, standard arrowheads + /// nothing inserts); their entities are not counted. + pub generated_blocks: usize, + /// Table records per table type (LAYER, LTYPE, STYLE, DIMSTYLE, VIEW, UCS…). + pub tables: BTreeMap, + /// Non-graphical objects per type. For a drawing: only those kept without being modelled. + pub objects: BTreeMap, + /// CLASS records. For a drawing: only those kept for unmodelled objects. + pub classes: usize, +} + +/// Records that belong to the record before them (or delimit blocks), not entities of their own. +const SUB_RECORDS: &[&str] = &["VERTEX", "SEQEND", "ATTRIB", "BLOCK", "ENDBLK"]; + +fn bump(m: &mut BTreeMap, k: &str) { + let n = m.entry(k.to_string()).or_insert(0); + *n = n.saturating_add(1); +} + +/// A layout's own paper-space viewport (VIEWPORT with ID 1, group 69): part of the layout, not +/// an entity of its own for CADCraft. +fn is_overall_viewport(t: &str, g: &[cadcraft_dxf::Tag]) -> bool { + t == "VIEWPORT" && g.iter().any(|x| x.code == 69 && x.i64() == 1) +} + +fn is_space_block(name: &str) -> bool { + let up = name.trim().to_ascii_uppercase(); + up.starts_with("*MODEL_SPACE") || up.starts_with("*PAPER_SPACE") +} + +/// Count what a DXF file (ASCII or binary) holds; `None` when it can't be parsed. +pub fn census(dxf: &[u8]) -> Option { + let tags = cadcraft_dxf::parse(dxf).ok()?; + let mut c = Census::default(); + // Blocks CADCraft generates (tables' blocks, standard arrowheads nothing inserts) are left out + // on read and rebuilt on save: counted apart. + let mut table_blocks: HashSet = HashSet::new(); + let mut inserted: HashSet = HashSet::new(); + let mut blocks: Vec<(String, BTreeMap)> = Vec::new(); + let mut note_refs = |t: &str, g: &[cadcraft_dxf::Tag]| { + let name = || g.iter().find(|x| x.code == 2).map(|x| x.str().trim().to_ascii_uppercase()); + match t { + "ACAD_TABLE" => table_blocks.extend(name()), + "INSERT" => inserted.extend(name()), + _ => {} + } + }; + for s in cadcraft_dxf::sections(&tags) { + let records = cadcraft_dxf::records(&s.tags); + match s.name.trim().to_ascii_uppercase().as_str() { + "ENTITIES" => { + for (t, g) in &records { + note_refs(t, g); + if !SUB_RECORDS.contains(&t.as_str()) && !is_overall_viewport(t, g) { + bump(&mut c.entities, t); + } + } + } + "BLOCKS" => { + let mut space = false; + for (t, g) in &records { + note_refs(t, g); + match t.as_str() { + "BLOCK" => { + let name = g.iter().find(|x| x.code == 2).map(cadcraft_dxf::Tag::str).unwrap_or_default(); + space = is_space_block(&name); + if !space { + blocks.push((name.trim().to_ascii_uppercase(), BTreeMap::new())); + } + } + t if SUB_RECORDS.contains(&t) || is_overall_viewport(t, g) => {} + t if space => bump(&mut c.entities, t), + t => { + if let Some((_, m)) = blocks.last_mut() { + bump(m, t); + } + } + } + } + } + "TABLES" => { + for (t, _) in &records { + if t != "TABLE" && t != "ENDTAB" { + bump(&mut c.tables, t); + } + } + } + "OBJECTS" => { + for (t, _) in &records { + bump(&mut c.objects, t); + } + } + "CLASSES" => c.classes = c.classes.saturating_add(records.iter().filter(|(t, _)| t == "CLASS").count()), + _ => {} + } + } + for (name, m) in blocks { + if table_blocks.contains(&name) || (crate::dxf_ext::is_standard_arrow(&name) && !inserted.contains(&name)) { + c.generated_blocks = c.generated_blocks.saturating_add(1); + continue; + } + c.blocks = c.blocks.saturating_add(1); + for (t, n) in m { + let slot = c.block_entities.entry(t).or_insert(0); + *slot = slot.saturating_add(n); + } + } + Some(c) +} + +/// The same counts of a drawing: its entities, blocks and table records, and the objects and +/// classes it keeps without modelling them. +pub fn census_of(d: &Drawing) -> Census { + let mut c = Census::default(); + for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) { + bump(&mut c.entities, e.kind.dxf_name()); + } + for b in d.blocks.values() { + for e in b.entities.iter() { + bump(&mut c.block_entities, e.kind.dxf_name()); + } + } + c.blocks = d.blocks.len(); + for (t, n) in [ + ("LAYER", d.layers.len()), + ("LTYPE", d.linetypes.len()), + ("STYLE", d.text_styles.len()), + ("DIMSTYLE", d.dim_styles.len()), + ("VIEW", d.views.len()), + ("UCS", d.ucss.len()), + ] { + c.tables.insert(t.to_string(), n); + } + for o in &d.foreign.objects { + bump(&mut c.objects, &o.kind); + } + c.classes = d.foreign.classes.len(); + c +} + +/// Polylines may change between the old-style POLYLINE and LWPOLYLINE when read: counted +/// together. +fn merged(m: &BTreeMap) -> BTreeMap { + let mut out = BTreeMap::new(); + for (k, n) in m { + let key = if k == "POLYLINE" || k == "LWPOLYLINE" { "POLYLINE+LWPOLYLINE" } else { k.as_str() }; + let slot = out.entry(key.to_string()).or_insert(0usize); + *slot = slot.saturating_add(*n); + } + out +} + +fn compare_counts(out: &mut Out, place: &str, file: &BTreeMap, read: &BTreeMap) { + let (file, read) = (merged(file), merged(read)); + let keys: std::collections::BTreeSet<&String> = file.keys().chain(read.keys()).collect(); + for k in keys { + let (f, r) = (file.get(k).copied().unwrap_or(0), read.get(k).copied().unwrap_or(0)); + if r < f { + out.push_n(Category::Read, Change::Lost, f - r, format!("{place} {k}"), format!("{k}: {f} in the file, {r} read")); + } else if r > f { + out.push_n(Category::Read, Change::Added, r - f, format!("{place} {k}"), format!("{k}: {f} in the file, {r} read")); + } + } +} + +/// What a drawing read from a file holds less (or more) than the file: entities per type in the +/// spaces and in blocks, blocks, and the table records CADCraft models. Blocks CADCraft generates +/// (tables' blocks, unused standard arrowheads) are left out on both sides. +pub fn compare_census(file: &Census, d: &Drawing, opts: &Options) -> Comparison { + let read = census_of(d); + let mut out = Out::new(opts); + compare_counts(&mut out, "spaces", &file.entities, &read.entities); + compare_counts(&mut out, "blocks", &file.block_entities, &read.block_entities); + if file.blocks != read.blocks { + let change = if read.blocks < file.blocks { Change::Lost } else { Change::Added }; + out.push_n( + Category::Read, + change, + file.blocks.abs_diff(read.blocks), + "BLOCK definitions", + format!("{} in the file, {} read", file.blocks, read.blocks), + ); + } + for t in ["LAYER", "LTYPE", "STYLE", "DIMSTYLE", "VIEW", "UCS"] { + let (f, r) = (file.tables.get(t).copied().unwrap_or(0), read.tables.get(t).copied().unwrap_or(0)); + if f != r { + let change = if r < f { Change::Lost } else { Change::Added }; + out.push_n(Category::Read, change, f.abs_diff(r), format!("{t} table"), format!("{f} records in the file, {r} read")); + } + } + out.cmp +} diff --git a/crates/io/src/roundtrip/corpus.rs b/crates/io/src/roundtrip/corpus.rs new file mode 100644 index 000000000..72f78ce53 --- /dev/null +++ b/crates/io/src/roundtrip/corpus.rs @@ -0,0 +1,651 @@ +//! The corpus harness: every DXF and DWG file of a folder is opened, saved as DXF and as DWG, +//! reopened and compared with the drawing first opened; the findings go to a Markdown and a JSON +//! report. A file that fails, or even makes CADCraft panic, is a reported failure: each stage runs +//! under `catch_unwind` here, as a last resort of the harness only. + +use std::collections::BTreeMap; +use std::panic::AssertUnwindSafe; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use cadcraft_doc::Drawing; +use serde::Serialize; + +use super::{Category, Census, Change, Comparison, Options, Tally, census, census_of, clip, compare, compare_census}; + +/// A drawing file format the harness checks. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Format { + Dxf, + Dwg, +} + +impl Format { + pub fn ext(self) -> &'static str { + match self { + Format::Dxf => "dxf", + Format::Dwg => "dwg", + } + } + pub fn label(self) -> &'static str { + match self { + Format::Dxf => "DXF", + Format::Dwg => "DWG", + } + } + /// The format of a file's bytes: DWG by its magic, otherwise DXF. + pub fn of(bytes: &[u8]) -> Format { + if cadcraft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf } + } + fn other(self) -> Format { + match self { + Format::Dxf => Format::Dwg, + Format::Dwg => Format::Dxf, + } + } +} + +/// How the harness runs. +#[derive(Clone, Debug, PartialEq)] +pub struct CorpusOptions { + pub compare: Options, + /// Also save in the other format (DWG → DXF, DXF → DWG). + pub cross_format: bool, + /// Count what each file holds, to report what opening it leaves out. + pub census: bool, + /// Keep the saved files in this folder (and reopen them from there). + pub keep_saved: Option, +} + +impl Default for CorpusOptions { + fn default() -> Self { + CorpusOptions { compare: Options::default(), cross_format: true, census: true, keep_saved: None } + } +} + +/// Largest file the harness opens. +pub const MAX_FILE_BYTES: u64 = 1 << 30; +/// Deepest folder nesting walked. +const MAX_DEPTH: usize = 32; +/// Most files taken from one corpus. +const MAX_FILES: usize = 100_000; + +/// One save → reopen of a drawing. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteReport { + /// `dwg → dxf`. + pub route: String, + pub saved_bytes: usize, + /// Where the saved file was kept, when it was. + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_path: Option, + pub save_error: Option, + pub reopen_error: Option, + /// What reopening reported (records the reader couldn't place or decode). + pub reopen_notes: Vec, + /// What the writer itself left out of the saved file: records per DXF type (upper case) it + /// can't write in that format (for DWG: other programs' custom entities and objects). Each + /// is lost; the reopened drawing's entity counts show the same entities missing, and a strict + /// corpus run fails on any. + pub not_written: BTreeMap, + /// The reopened drawing against the drawing first opened. + pub comparison: Comparison, + pub seconds: f64, +} + +impl RouteReport { + pub fn ok(&self) -> bool { + self.save_error.is_none() && self.reopen_error.is_none() + } + /// Saved and reopened with no difference at all. + pub fn lossless(&self) -> bool { + self.ok() && self.not_written.is_empty() && self.comparison.is_empty() + } + /// How many records the writer left out ([`Self::not_written`]). + pub fn not_written_total(&self) -> usize { + self.not_written.values().fold(0usize, |a, n| a.saturating_add(*n)) + } +} + +/// `2 ACME_WIDGET, 1 OLE2FRAME`. +fn per_type(m: &BTreeMap) -> String { + m.iter().map(|(t, n)| format!("{n} {t}")).collect::>().join(", ") +} + +/// Everything found for one file. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileReport { + /// The path relative to the corpus folder. + pub path: String, + pub format: Option, + /// `AC1032` for a DWG, the `$ACADVER` of a DXF. + pub version: String, + pub bytes: u64, + pub open_error: Option, + /// What opening reported (a DWG's records the reader skipped, damaged records). + pub open_notes: Vec, + pub open_seconds: f64, + /// What the file holds (for a DWG: its DXF rendition), when counted. + pub file_census: Option, + /// What the drawing read from it holds. + pub drawing_census: Option, + /// What the drawing holds less or more than the file. + pub read: Comparison, + pub routes: Vec, +} + +impl FileReport { + pub fn opened(&self) -> bool { + self.open_error.is_none() + } +} + +impl CorpusReport { + /// What makes a strict corpus run fail, one line each: a file that didn't open, a round trip + /// that didn't save or reopen, and records a writer left out ([`RouteReport::not_written`]). + /// Other differences are reported, not failed on. + pub fn strict_failures(&self) -> Vec { + let mut out = Vec::new(); + for f in &self.files { + if !f.opened() { + out.push(format!("{}: failed to open", f.path)); + } + for r in &f.routes { + if !r.ok() { + out.push(format!("{} ({}): failed to save or reopen", f.path, r.route)); + } + if !r.not_written.is_empty() { + out.push(format!("{} ({}): lost on save, not written: {}", f.path, r.route, per_type(&r.not_written))); + } + } + } + out + } +} + +/// The whole corpus run. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CorpusReport { + pub corpus: String, + /// UTC, `2026-10-21 09:30`. + pub generated: String, + pub cadcraft_version: String, + pub abs_tol: f64, + pub rel_tol: f64, + pub files: Vec, +} + +/// Run `f`, turning a panic into an error message. +fn guard(f: impl FnOnce() -> T) -> Result { + std::panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| { + let msg = + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "no message".into()); + format!("CRASH (panic): {msg}") + }) +} + +/// A drawing's notes on what reading it left out. +fn notes(d: &Drawing) -> Vec { + let mut v = d.read_notes.clone(); + for (section, n) in &d.skipped_records { + v.push(format!("{n} record(s) of the {section} section couldn't be placed and were skipped")); + } + v +} + +/// Save `d` as `to`, reopen it and compare. `keep` is the file to keep the saved bytes in (and +/// reopen them from). +pub fn round_trip(d: &Drawing, from: Format, to: Format, opts: &Options, keep: Option<&Path>) -> RouteReport { + let start = Instant::now(); + let mut r = RouteReport { route: format!("{} → {}", from.ext(), to.ext()), ..RouteReport::default() }; + let name = format!("roundtrip.{}", to.ext()); + let saved = match guard(|| crate::write_noted(d, &name, &crate::SaveOptions::default())) { + Ok(Ok((b, not_written))) => { + r.not_written = not_written; + b + } + Ok(Err(e)) => { + r.save_error = Some(e.to_string()); + r.seconds = start.elapsed().as_secs_f64(); + return r; + } + Err(e) => { + r.save_error = Some(e); + r.seconds = start.elapsed().as_secs_f64(); + return r; + } + }; + r.saved_bytes = saved.len(); + let mut bytes = saved; + if let Some(path) = keep { + let kept = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|_| std::fs::write(path, &bytes)).and_then(|_| std::fs::read(path)); + match kept { + Ok(b) => { + bytes = b; + r.saved_path = Some(path.display().to_string()); + } + Err(e) => r.reopen_notes.push(format!("couldn't keep the saved file at {}: {e}", path.display())), + } + } + let reopened = match guard(|| crate::read(&bytes, &name)) { + Ok(Ok(d2)) => d2, + Ok(Err(e)) => { + r.reopen_error = Some(e.to_string()); + r.seconds = start.elapsed().as_secs_f64(); + return r; + } + Err(e) => { + r.reopen_error = Some(e); + r.seconds = start.elapsed().as_secs_f64(); + return r; + } + }; + r.reopen_notes.extend(notes(&reopened)); + match guard(|| compare(d, &reopened, opts)) { + Ok(c) => r.comparison = c, + Err(e) => r.reopen_error = Some(format!("comparing: {e}")), + } + attribute_unwritten(&mut r, to); + r.seconds = start.elapsed().as_secs_f64(); + r +} + +/// Say on the lost entity rows of a type the writer couldn't write that this is why they are +/// lost, so a writer's gap reads as one and isn't taken for a reader's or a comparison's. +fn attribute_unwritten(r: &mut RouteReport, to: Format) { + if r.not_written.is_empty() { + return; + } + for d in r.comparison.differences.iter_mut().filter(|d| d.category == Category::Count && d.change == Change::Lost) { + if let Some(t) = r.not_written.keys().find(|t| d.detail.starts_with(&format!("{t}: "))) { + d.detail.push_str(&format!(" (not written: the {} writer can't write {t})", to.label())); + } + } +} + +/// The kept saved file of `rel` saved as `to`, in `dir`. +fn kept_path(dir: &Path, rel: &str, to: Format) -> PathBuf { + let flat: String = rel.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '_' } else { c }).collect(); + dir.join(format!("{flat}.roundtrip.{}", to.ext())) +} + +/// Check one file's bytes: open, count, save in each format, reopen, compare. `rel` names it in +/// the report. +pub fn check_bytes(bytes: &[u8], rel: &str, opts: &CorpusOptions) -> FileReport { + let format = Format::of(bytes); + let mut f = FileReport { path: rel.to_string(), format: Some(format), bytes: bytes.len() as u64, ..FileReport::default() }; + if format == Format::Dwg { + f.version = cadcraft_dwg::version(bytes).unwrap_or_default(); + } + let start = Instant::now(); + let name = format!("source.{}", format.ext()); + let opened = guard(|| crate::read_source(bytes, &name)); + f.open_seconds = start.elapsed().as_secs_f64(); + let (d, dxf) = match opened { + Ok(Ok(x)) => x, + Ok(Err(e)) => { + f.open_error = Some(e.to_string()); + return f; + } + Err(e) => { + f.open_error = Some(e); + return f; + } + }; + if format == Format::Dxf { + f.version = d.header.str("ACADVER", ""); + } + f.open_notes = notes(&d); + f.drawing_census = guard(|| census_of(&d)).ok(); + if opts.census { + let source = dxf.as_deref().unwrap_or(bytes); + match guard(|| census(source)) { + Ok(Some(c)) => { + if let Ok(read) = guard(|| compare_census(&c, &d, &opts.compare)) { + f.read = read; + } + f.file_census = Some(c); + } + Ok(None) => f.open_notes.push("the file's groups couldn't be counted".into()), + Err(e) => f.open_notes.push(format!("counting the file's groups: {e}")), + } + } + let mut targets = vec![format]; + if opts.cross_format { + targets.push(format.other()); + } + for to in targets { + let keep = opts.keep_saved.as_deref().map(|dir| kept_path(dir, rel, to)); + f.routes.push(round_trip(&d, format, to, &opts.compare, keep.as_deref())); + } + f +} + +/// Check one file on disk; `rel` names it in the report. +pub fn check_file(path: &Path, rel: &str, opts: &CorpusOptions) -> FileReport { + let fail = |e: String| FileReport { path: rel.to_string(), open_error: Some(e), ..FileReport::default() }; + match std::fs::metadata(path) { + Ok(m) if m.len() > MAX_FILE_BYTES => return fail(format!("the file is {} bytes, more than the {MAX_FILE_BYTES} the harness opens", m.len())), + Ok(_) => {} + Err(e) => return fail(format!("can't read the file: {e}")), + } + match std::fs::read(path) { + Ok(bytes) => check_bytes(&bytes, rel, opts), + Err(e) => fail(format!("can't read the file: {e}")), + } +} + +/// Whether a path names a DXF or DWG file (by extension). +pub fn is_drawing_file(path: &Path) -> bool { + path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).is_some_and(|e| e == "dxf" || e == "dwg") +} + +/// Every DXF and DWG file under `dir`, sorted, leaving out hidden folders and the folders in +/// `skip` (the report folder). Symbolic links are not followed. +pub fn corpus_files(dir: &Path, skip: &[PathBuf]) -> Vec { + let skip: Vec = skip.iter().map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())).collect(); + let mut out = Vec::new(); + let mut stack = vec![(dir.to_path_buf(), 0usize)]; + while let Some((d, depth)) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&d) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_dir() { + let hidden = entry.file_name().to_string_lossy().starts_with('.'); + let canon = path.canonicalize().unwrap_or_else(|_| path.clone()); + if !hidden && depth < MAX_DEPTH && !skip.contains(&canon) { + stack.push((path, depth + 1)); + } + } else if ft.is_file() && is_drawing_file(&path) && out.len() < MAX_FILES { + out.push(path); + } + } + } + out.sort(); + out +} + +/// `path` relative to `root`, with forward slashes. +pub fn relative(path: &Path, root: &Path) -> String { + let rel = path.strip_prefix(root).unwrap_or(path); + rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::>().join("/") +} + +/// Check `files` (paths under `root`, which names the corpus in the report), calling `progress` +/// with (index, count, relative path) before each. +pub fn run(root: &Path, files: &[PathBuf], opts: &CorpusOptions, progress: &mut dyn FnMut(usize, usize, &str)) -> CorpusReport { + let mut report = CorpusReport { + corpus: root.display().to_string(), + generated: now_utc(), + cadcraft_version: env!("CARGO_PKG_VERSION").to_string(), + abs_tol: opts.compare.abs_tol, + rel_tol: opts.compare.rel_tol, + files: Vec::new(), + }; + for (i, path) in files.iter().enumerate() { + let rel = relative(path, root); + progress(i, files.len(), &rel); + let f = match guard(|| check_file(path, &rel, opts)) { + Ok(f) => f, + Err(e) => FileReport { path: rel, open_error: Some(e), ..FileReport::default() }, + }; + report.files.push(f); + } + report +} + +/// The current time, UTC, as `2026-10-21 09:30`. +fn now_utc() -> String { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let days = i64::try_from(secs / 86_400).unwrap_or(0); + let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); + // Days to a civil date (proleptic Gregorian), after Howard Hinnant's public-domain algorithm. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!("{year:04}-{month:02}-{day:02} {h:02}:{m:02} UTC") +} + +// --------------------------------------------------------------------------------------------- +// Rendering + +/// Most differences listed per round trip in the Markdown report (all are in the JSON). +const MD_ROWS: usize = 300; + +/// Text for a Markdown table cell. +fn cell(s: &str) -> String { + s.replace('|', "\\|").replace(['\r', '\n'], " ") +} + +fn size(n: u64) -> String { + if n >= 1 << 20 { + format!("{:.1} MB", n as f64 / f64::from(1u32 << 20)) + } else if n >= 1 << 10 { + format!("{:.0} KB", n as f64 / 1024.0) + } else { + format!("{n} B") + } +} + +fn status(err: &Option) -> String { + match err { + None => "ok".into(), + Some(e) if e.starts_with("CRASH") => "**CRASH**".into(), + Some(_) => "**failed**".into(), + } +} + +fn tally_cells(t: Tally) -> String { + format!("{} | {} | {}", t.lost, t.added, t.changed) +} + +impl CorpusReport { + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}")) + } + + /// Totals per category over every round trip. + pub fn totals(&self) -> std::collections::BTreeMap { + let mut all: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for r in self.files.iter().flat_map(|f| f.routes.iter()) { + for (c, t) in &r.comparison.totals { + let slot = all.entry(*c).or_default(); + slot.add(Change::Lost, t.lost); + slot.add(Change::Added, t.added); + slot.add(Change::Changed, t.changed); + } + } + all + } + + pub fn to_markdown(&self) -> String { + let mut s = String::new(); + let opened = self.files.iter().filter(|f| f.opened()).count(); + let routes: Vec<&RouteReport> = self.files.iter().flat_map(|f| f.routes.iter()).collect(); + let save_failed = routes.iter().filter(|r| r.save_error.is_some()).count(); + let reopen_failed = routes.iter().filter(|r| r.reopen_error.is_some()).count(); + let lossless = routes.iter().filter(|r| r.lossless()).count(); + let crashes = self.files.iter().filter(|f| f.open_error.as_deref().is_some_and(|e| e.starts_with("CRASH"))).count() + + routes.iter().filter(|r| [&r.save_error, &r.reopen_error].iter().any(|e| e.as_deref().is_some_and(|e| e.contains("CRASH")))).count(); + s.push_str("# CADCraft round-trip report\n\n"); + s.push_str(&format!("- Corpus: `{}`\n", self.corpus)); + s.push_str(&format!("- Generated: {} by CADCraft {}\n", self.generated, self.cadcraft_version)); + s.push_str(&format!("- Tolerance for numbers: {:e} absolute + {:e} relative\n", self.abs_tol, self.rel_tol)); + s.push_str(&format!( + "- Files: {} ({opened} opened, {} failed to open); round trips: {} ({save_failed} failed to save, {reopen_failed} failed to reopen, {lossless} without any difference); crashes: {crashes}\n", + self.files.len(), + self.files.len() - opened, + routes.len() + )); + let mut unwritten: BTreeMap = BTreeMap::new(); + for r in &routes { + for (t, n) in &r.not_written { + let slot = unwritten.entry(format!("{t} ({})", r.route)).or_insert(0); + *slot = slot.saturating_add(*n); + } + } + if unwritten.is_empty() { + s.push_str("- Lost on save (records a writer can't write): none\n\n"); + } else { + s.push_str(&format!("- **Lost on save** (records a writer can't write): {}\n\n", per_type(&unwritten))); + } + s.push_str( + "Each file is opened, saved in its own format and in the other one, and the saved file is reopened and compared with \ + the drawing first opened. *Read* counts what the file holds that the drawing read from it doesn't (for a DWG: its \ + DXF rendition). Lost/added/changed count items (entities, records, values).\n\n", + ); + s.push_str("## Summary\n\n"); + s.push_str("| File | Format | Size | Open | Read lost | Round trip | Save | Reopen | Not written | Lost | Added | Changed |\n"); + s.push_str("|---|---|---|---|---|---|---|---|---|---|---|---|\n"); + for f in &self.files { + let fmt = f.format.map_or("?".to_string(), |x| format!("{} {}", x.label(), f.version).trim().to_string()); + let open = if f.opened() { format!("ok ({:.1} s)", f.open_seconds) } else { status(&f.open_error) }; + let lead = format!("| `{}` | {} | {} | {} | {} ", cell(&f.path), cell(&fmt), size(f.bytes), open, f.read.tally(Category::Read).lost); + if f.routes.is_empty() { + s.push_str(&format!("{lead}| — | — | — | — | — | — | — |\n")); + } + for (i, r) in f.routes.iter().enumerate() { + let head = if i == 0 { lead.clone() } else { "| | | | | ".to_string() }; + let t = r.comparison.total(); + let reopen = if r.save_error.is_some() { "—".to_string() } else { status(&r.reopen_error) }; + let unwritten = if r.not_written.is_empty() { "0".to_string() } else { format!("**{}**", r.not_written_total()) }; + s.push_str(&format!("{head}| {} | {} | {} | {unwritten} | {} |\n", r.route, status(&r.save_error), reopen, tally_cells(t))); + } + } + s.push_str("\n## By category (all round trips)\n\n| Category | Lost | Added | Changed |\n|---|---|---|---|\n"); + let totals = self.totals(); + for c in Category::ALL { + if let Some(t) = totals.get(&c).filter(|t| t.total() > 0) { + s.push_str(&format!("| {} | {} |\n", c.label(), tally_cells(*t))); + } + } + if totals.values().all(|t| t.total() == 0) { + s.push_str("| (no differences) | 0 | 0 | 0 |\n"); + } + s.push_str("\n## Files\n"); + for f in &self.files { + self.file_markdown(f, &mut s); + } + s + } + + fn file_markdown(&self, f: &FileReport, s: &mut String) { + s.push_str(&format!("\n### `{}`\n\n", f.path)); + let fmt = f.format.map_or("unknown format".to_string(), |x| format!("{} {}", x.label(), f.version).trim().to_string()); + s.push_str(&format!("- {fmt}, {}\n", size(f.bytes))); + match &f.open_error { + Some(e) => { + s.push_str(&format!("- Open: {}: {}\n", status(&f.open_error), cell(e))); + return; + } + None => { + let what = f.drawing_census.as_ref().map_or(String::new(), |c| { + let n: usize = c.entities.values().sum(); + let nb: usize = c.block_entities.values().sum(); + format!( + ": {n} entities in model and paper space, {} blocks holding {nb} entities, {} layers, {} unmodelled objects kept", + c.blocks, + c.tables.get("LAYER").copied().unwrap_or(0), + c.objects.values().sum::() + ) + }); + s.push_str(&format!("- Open: ok in {:.1} s{what}\n", f.open_seconds)); + } + } + for n in &f.open_notes { + s.push_str(&format!("- Note on opening: {}\n", cell(n))); + } + if let Some(c) = &f.file_census + && !c.objects.is_empty() + { + let kept = f.drawing_census.as_ref().map(|d| &d.objects); + let list: Vec = c + .objects + .iter() + .map(|(k, n)| match kept.and_then(|m| m.get(k)) { + Some(m) => format!("{k} {n} ({m} kept as unmodelled)"), + None => format!("{k} {n}"), + }) + .collect(); + s.push_str(&format!("- Objects in the file: {}\n", cell(&list.join(", ")))); + } + if !f.read.is_empty() { + s.push_str("\n#### Read (file → drawing)\n\n"); + diff_table(&f.read, s); + } + for r in &f.routes { + s.push_str(&format!("\n#### {}\n\n", r.route)); + match (&r.save_error, &r.reopen_error) { + (Some(e), _) => s.push_str(&format!("- Save: {}: {}\n", status(&r.save_error), cell(e))), + (None, Some(e)) => { + s.push_str(&format!("- Save: ok ({})\n", size(r.saved_bytes as u64))); + s.push_str(&format!("- Reopen: {}: {}\n", status(&r.reopen_error), cell(e))); + } + (None, None) => { + let t = r.comparison.total(); + s.push_str(&format!( + "- Saved {} and reopened in {:.1} s: {} lost, {} added, {} changed\n", + size(r.saved_bytes as u64), + r.seconds, + t.lost, + t.added, + t.changed + )); + } + } + if !r.not_written.is_empty() { + s.push_str(&format!("- **Lost on save**, not written (the writer can't write these types): {}\n", per_type(&r.not_written))); + } + if let Some(p) = &r.saved_path { + s.push_str(&format!("- Saved file: `{p}`\n")); + } + for n in &r.reopen_notes { + s.push_str(&format!("- Note on reopening: {}\n", cell(n))); + } + if !r.comparison.is_empty() { + s.push('\n'); + diff_table(&r.comparison, s); + } + } + } + + /// Write `report.md` and `report.json` into `dir` (created if needed); returns their paths. + pub fn write(&self, dir: &Path) -> std::io::Result<(PathBuf, PathBuf)> { + std::fs::create_dir_all(dir)?; + let (md, json) = (dir.join("report.md"), dir.join("report.json")); + std::fs::write(&md, self.to_markdown())?; + std::fs::write(&json, self.to_json())?; + Ok((md, json)) + } +} + +/// A comparison's differences as a Markdown table (the first [`MD_ROWS`]). +fn diff_table(c: &Comparison, s: &mut String) { + let tallies: Vec = Category::ALL + .iter() + .filter_map(|cat| { + let t = c.tally(*cat); + (t.total() > 0).then(|| format!("{} {}/{}/{}", cat.label(), t.lost, t.added, t.changed)) + }) + .collect(); + s.push_str(&format!("By category (lost/added/changed): {}\n\n", tallies.join(" · "))); + s.push_str("| Category | Change | Where | Detail |\n|---|---|---|---|\n"); + for d in c.differences.iter().take(MD_ROWS) { + s.push_str(&format!("| {} | {} | {} | {} |\n", d.category.label(), d.change.label(), cell(&clip(&d.place, 80)), cell(&clip(&d.detail, 400)))); + } + let more = c.differences.len().saturating_sub(MD_ROWS).saturating_add(c.omitted); + if more > 0 { + s.push_str(&format!("\n… and {more} more differences (see report.json; differences beyond the listing limit are only counted).\n")); + } +} diff --git a/crates/io/src/roundtrip/tests.rs b/crates/io/src/roundtrip/tests.rs new file mode 100644 index 000000000..57b77a74a --- /dev/null +++ b/crates/io/src/roundtrip/tests.rs @@ -0,0 +1,559 @@ +use std::sync::Arc; + +use cadcraft_color::Color; +use cadcraft_doc::foreign::{ForeignExtra, ForeignObject}; +use cadcraft_doc::*; +use cadcraft_geom::{PolyVertex, Vec2, Vec3}; + +use super::*; + +fn tag(code: i32, value: &str) -> RawTag { + RawTag { code, value: value.into() } +} + +fn line(a: (f64, f64), b: (f64, f64)) -> EntityKind { + EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }) +} + +/// A small drawing touching every part the comparator looks at. +fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() }); + let walls = Common { layer: "Walls".into(), ..Common::default() }; + d.add(&Space::Model, walls.clone(), line((0.0, 0.0), (10.0, 0.0))).unwrap(); + d.add(&Space::Model, walls, line((0.0, 5.0), (10.0, 5.0))).unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(3.0, 4.0, 0.0), radius: 2.5 })).unwrap(); + let tagged = Common { xdata: vec![tag(1001, "ACME"), tag(1000, "door"), tag(1040, "2.5")], ..Common::default() }; + d.add( + &Space::Model, + tagged, + EntityKind::LwPolyline(LwPolyline { + vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::with_bulge(Vec2::new(4.0, 0.0), 0.5), PolyVertex::new(Vec2::new(4.0, 3.0))], + closed: true, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }), + ) + .unwrap(); + d.add( + &Space::Model, + Common::default(), + EntityKind::Unknown(Unknown { dxf_type: "ACME_WIDGET".into(), tags: vec![tag(100, "AcDbEntity"), tag(10, "1.0"), tag(20, "2.0")] }), + ) + .unwrap(); + d.add(&Space::Paper("Layout1".into()), Common::default(), line((1.0, 1.0), (2.0, 2.0))).unwrap(); + let mut b = Block::new("DOOR"); + let h = d.new_handle(); + b.entities.push(Entity::new(h, line((0.0, 0.0), (0.0, 1.0)))); + d.blocks.insert("DOOR".into(), Arc::new(b)); + let f = Arc::make_mut(&mut d.foreign); + f.objects.push(ForeignObject { handle: Handle(0x500), kind: "XRECORD".into(), tags: vec![tag(100, "AcDbXrecord"), tag(1, "kept")] }); + f.dictionary.push(("ACME_DATA".into(), Handle(0x500))); + f.classes.push(vec![tag(1, "ACME_WIDGET"), tag(2, "AcmeWidget"), tag(3, "Acme")]); + f.records.insert(("LAYER".into(), "WALLS".into()), ForeignExtra { xdata: vec![tag(1001, "ACME"), tag(1070, "3")], ..ForeignExtra::default() }); + d +} + +fn cmp(a: &Drawing, b: &Drawing) -> Comparison { + compare(a, b, &Options::default()) +} + +fn only(c: &Comparison, cat: Category, change: Change) -> Vec<&Difference> { + c.differences.iter().filter(|d| d.category == cat && d.change == change).collect() +} + +fn first_handle(d: &Drawing, kind: &str) -> Handle { + d.model.iter().find(|e| e.kind.dxf_name() == kind).map(|e| e.handle).unwrap() +} + +#[test] +fn identical_drawings_have_no_differences() { + let d = drawing(); + let c = cmp(&d, &d.clone()); + assert!(c.is_empty(), "{:#?}", c.differences); + assert_eq!(c.total(), Tally::default()); +} + +#[test] +fn geometry_is_compared_by_handle_within_the_tolerance() { + let a = drawing(); + let h = first_handle(&a, "LINE"); + let mut b = a.clone(); + b.modify_entity(h, |e| { + if let EntityKind::Line(l) = &mut e.kind { + l.b.x += 1e-9; + } + }) + .unwrap(); + assert!(cmp(&a, &b).is_empty(), "a change within the tolerance is no difference"); + b.modify_entity(h, |e| { + if let EntityKind::Line(l) = &mut e.kind { + l.b.x = 11.0; + } + }) + .unwrap(); + let c = cmp(&a, &b); + let g = only(&c, Category::Geometry, Change::Changed); + assert_eq!(g.len(), 1, "{:#?}", c.differences); + assert!(g[0].place.contains(&h.hex()) && g[0].place.starts_with("Model LINE"), "{:?}", g[0]); + assert!(g[0].detail.contains("b.x: 10") && g[0].detail.contains("11"), "{:?}", g[0]); + assert_eq!(c.tally(Category::Geometry).changed, 1); +} + +#[test] +fn angles_a_whole_turn_apart_are_the_same() { + let mut a = drawing(); + let h = a + .add(&Space::Model, Common::default(), EntityKind::Arc(cadcraft_doc::Arc { center: Vec3::ZERO, radius: 2.0, start: -0.5, end: 1.0 })) + .unwrap(); + let mut b = a.clone(); + b.modify_entity(h, |e| { + if let EntityKind::Arc(x) = &mut e.kind { + x.start += std::f64::consts::TAU; + x.end -= std::f64::consts::TAU; + } + }) + .unwrap(); + assert!(cmp(&a, &b).is_empty(), "{:#?}", cmp(&a, &b).differences); + // A radius is no angle. + b.modify_entity(h, |e| { + if let EntityKind::Arc(x) = &mut e.kind { + x.radius += std::f64::consts::TAU; + } + }) + .unwrap(); + assert_eq!(cmp(&a, &b).tally(Category::Geometry).changed, 1); +} + +#[test] +fn properties_and_type_changes_are_reported() { + let a = drawing(); + let h = first_handle(&a, "CIRCLE"); + let mut b = a.clone(); + b.modify_entity(h, |e| e.common.color = Color::Index(3)).unwrap(); + let c = cmp(&a, &b); + assert_eq!(only(&c, Category::Properties, Change::Changed).len(), 1, "{:#?}", c.differences); + b.modify_entity(h, |e| e.kind = line((0.0, 0.0), (1.0, 1.0))).unwrap(); + let c = cmp(&a, &b); + let g = only(&c, Category::Geometry, Change::Changed); + assert!(g.iter().any(|d| d.detail == "type CIRCLE → LINE"), "{:#?}", c.differences); +} + +#[test] +fn lost_and_added_entities_are_counted_per_type_and_space() { + let a = drawing(); + let h = first_handle(&a, "CIRCLE"); + let mut b = a.clone(); + b.remove_entity(h); + let mut paper = b.clone(); + paper.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 })).unwrap(); + let c = cmp(&a, &paper); + let lost = only(&c, Category::Count, Change::Lost); + assert_eq!(lost.len(), 1, "{:#?}", c.differences); + assert_eq!(lost[0].place, "Model CIRCLE"); + assert!(lost[0].detail.contains("1 → 0") && lost[0].detail.contains(&h.hex()), "{:?}", lost[0]); + let added = only(&c, Category::Count, Change::Added); + assert_eq!(added.len(), 1); + assert_eq!(added[0].place, "Layout Layout1 POINT"); + assert_eq!(c.tally(Category::Count), Tally { lost: 1, added: 1, changed: 0 }); +} + +#[test] +fn renumbered_entities_are_matched_by_content() { + let a = drawing(); + let mut b = a.clone(); + // Every line gets a new handle, in reverse order, and one of them moves. + let lines: Vec> = b.model.iter().filter(|e| e.kind.dxf_name() == "LINE").cloned().collect(); + for (i, e) in lines.iter().rev().enumerate() { + b.model.remove(e.handle); + let mut e = Entity::clone(e); + e.handle = Handle(0x9000 + i as u64); + b.model.push(e); + } + let c = cmp(&a, &b); + assert_eq!(c.tally(Category::Count), Tally::default(), "{:#?}", c.differences); + assert_eq!(c.tally(Category::Geometry), Tally::default(), "pairs by content, not by order: {:#?}", c.differences); + assert_eq!(c.tally(Category::Handle).changed, 2); +} + +#[test] +fn entities_moving_between_spaces_are_reported() { + let a = drawing(); + let h = first_handle(&a, "CIRCLE"); + let mut b = a.clone(); + let e = b.remove_entity(h).unwrap(); + if let Some(l) = b.layouts.first_mut() { + l.entities.push(Entity::clone(&e)); + } + let c = cmp(&a, &b); + let moved = only(&c, Category::Handle, Change::Changed); + assert_eq!(moved.len(), 1, "{:#?}", c.differences); + assert_eq!(moved[0].detail, format!("1 entities moved from Model to Layout Layout1 ({})", h.hex())); + assert_eq!(c.tally(Category::Count), Tally::default()); +} + +#[test] +fn unreferenced_anonymous_blocks_dropped_on_save_are_one_row() { + let mut a = drawing(); + for name in ["*D5", "*D6"] { + let mut blk = Block::new(name); + for _ in 0..2 { + let h = a.new_handle(); + blk.entities.push(Entity::new(h, line((0.0, 0.0), (1.0, 0.0)))); + } + a.blocks.insert(name.into(), Arc::new(blk)); + } + let mut b = a.clone(); + b.blocks.remove("*D5"); + b.blocks.remove("*D6"); + let c = cmp(&a, &b); + let lost = only(&c, Category::Count, Change::Lost); + assert_eq!(lost.len(), 1, "{:#?}", c.differences); + assert_eq!(lost[0].detail, "4 entities of 2 anonymous blocks nothing refers to (e.g. *D5) were not saved"); + assert_eq!(c.tally(Category::Count).lost, 4); + assert_eq!(c.tally(Category::Block), Tally { lost: 2, added: 0, changed: 0 }, "{:#?}", c.differences); + assert_eq!(only(&c, Category::Block, Change::Lost).len(), 1); +} + +#[test] +fn renamed_anonymous_blocks_are_matched_by_their_entities() { + let mut a = drawing(); + let mut blk = Block::new("*D1"); + let h = a.new_handle(); + blk.entities.push(Entity::new(h, line((0.0, 0.0), (5.0, 0.0)))); + a.blocks.insert("*D1".into(), Arc::new(blk.clone())); + let key = ("BLOCK_RECORD".to_string(), "*D1".to_string()); + Arc::make_mut(&mut a.foreign).records.insert(key, ForeignExtra { xdata: vec![tag(1001, "ACME"), tag(1070, "1")], ..ForeignExtra::default() }); + let mut b = a.clone(); + b.blocks.remove("*D1"); + blk.name = "*D7".into(); + b.blocks.insert("*D7".into(), Arc::new(blk)); + let f = Arc::make_mut(&mut b.foreign); + let extra = f.records.remove(&("BLOCK_RECORD".to_string(), "*D1".to_string())).unwrap(); + f.records.insert(("BLOCK_RECORD".into(), "*D7".into()), extra); + let c = cmp(&a, &b); + assert_eq!(c.tally(Category::Count), Tally::default(), "{:#?}", c.differences); + assert_eq!(c.tally(Category::Xdata), Tally::default(), "{:#?}", c.differences); + assert_eq!(c.tally(Category::Block), Tally { lost: 0, added: 0, changed: 1 }, "{:#?}", c.differences); + assert!(c.differences.iter().any(|d| d.detail == "1 anonymous blocks saved under new names (e.g. *D1 → *D7)"), "{:#?}", c.differences); +} + +#[test] +fn xdata_extension_dictionaries_and_opaque_entities_are_compared() { + let a = drawing(); + let pl = first_handle(&a, "LWPOLYLINE"); + let unknown = first_handle(&a, "ACME_WIDGET"); + let mut b = a.clone(); + b.modify_entity(pl, |e| e.common.xdata.clear()).unwrap(); + b.modify_entity(unknown, |e| { + if let EntityKind::Unknown(u) = &mut e.kind { + u.tags.pop(); + } + }) + .unwrap(); + Arc::make_mut(&mut b.foreign).records.clear(); + let mut a2 = a.clone(); + Arc::make_mut(&mut a2.foreign) + .entities + .insert(pl, ForeignExtra { xdict: Some(Handle(0x600)), reactors: vec![Handle(0x601)], ..ForeignExtra::default() }); + let c = cmp(&a2, &b); + let x = only(&c, Category::Xdata, Change::Lost); + assert_eq!(x.len(), 2, "the entity's and the layer's: {:#?}", c.differences); + assert!(x.iter().any(|d| d.place == "LAYER WALLS" && d.detail.starts_with("application ACME")), "{x:#?}"); + assert!(x.iter().any(|d| d.place.contains("LWPOLYLINE")), "{x:#?}"); + assert_eq!(c.tally(Category::ExtDict).lost, 2, "the dictionary and a reactor: {:#?}", c.differences); + let o = only(&c, Category::Opaque, Change::Changed); + assert_eq!(o.len(), 1, "{:#?}", c.differences); + assert!(o[0].detail.contains("3 groups → 2"), "{:?}", o[0]); + // Numbers in kept groups compare within the tolerance. + let mut b2 = a.clone(); + b2.modify_entity(unknown, |e| { + if let EntityKind::Unknown(u) = &mut e.kind { + u.tags[1].value = "1.0000000000001".into(); + } + }) + .unwrap(); + assert!(cmp(&a, &b2).is_empty()); +} + +#[test] +fn tables_blocks_and_layouts_are_compared_by_name() { + let a = drawing(); + let mut b = a.clone(); + b.layers.retain(|l| l.name != "Walls"); + b.linetypes.push(Linetype { name: "Dashed".into(), description: String::new(), pattern: Vec::new() }); + if let Some(s) = b.text_styles.first_mut() { + s.height = 7.0; + } + b.blocks.remove("DOOR"); + b.layouts.retain(|l| l.name != "Layout2"); + let c = cmp(&a, &b); + let t = |cat, ch, place: &str| c.differences.iter().any(|d| d.category == cat && d.change == ch && d.place == place); + assert!(t(Category::Table, Change::Lost, "LAYER Walls"), "{:#?}", c.differences); + assert!(t(Category::Table, Change::Added, "LTYPE Dashed")); + assert!(t(Category::Table, Change::Changed, "STYLE Standard")); + assert!(t(Category::Block, Change::Lost, "BLOCK DOOR")); + assert!(t(Category::Layout, Change::Lost, "LAYOUT Layout2")); + // The block's entity is lost with it, counted where it was. + assert!(t(Category::Count, Change::Lost, "Block DOOR LINE"), "{:#?}", c.differences); + // Names match without case. + let mut upper = a.clone(); + if let Some(l) = upper.layers.iter_mut().find(|l| l.name == "Walls") { + l.name = "WALLS".into(); + } + let c = cmp(&a, &upper); + assert_eq!(c.tally(Category::Table).lost, 0, "{:#?}", c.differences); +} + +#[test] +fn unmodelled_objects_dictionary_entries_and_classes_are_compared() { + let a = drawing(); + let mut b = a.clone(); + let f = Arc::make_mut(&mut b.foreign); + f.objects.clear(); + f.dictionary.clear(); + f.classes.clear(); + let c = cmp(&a, &b); + assert_eq!(c.tally(Category::Object).lost, 1, "{:#?}", c.differences); + assert_eq!(c.tally(Category::Dictionary).lost, 1); + assert_eq!(c.tally(Category::Class).lost, 1); + // A renumbered object is matched by its groups. + let mut b = a.clone(); + Arc::make_mut(&mut b.foreign).objects[0].handle = Handle(0x700); + let c = cmp(&a, &b); + assert_eq!(c.tally(Category::Object), Tally::default(), "{:#?}", c.differences); + assert_eq!(c.tally(Category::Handle).changed, 1); +} + +#[test] +fn header_variables_are_compared_without_volatile_ones() { + let a = drawing(); + let mut b = a.clone(); + b.header.set_f64("LTSCALE", 2.0); + b.header.vars.remove("TEXTSIZE"); + b.header.set_str("HANDSEED", "FFFF"); + b.header.set_str("TDUPDATE", "2461000.5"); + // 1 and 1.0 are the same value. + b.header.set_i64("DIMSCALE", 1); + let c = cmp(&a, &b); + assert!(only(&c, Category::Header, Change::Changed).iter().any(|d| d.place == "$LTSCALE"), "{:#?}", c.differences); + assert!(only(&c, Category::Header, Change::Lost).iter().any(|d| d.place == "$TEXTSIZE")); + assert_eq!(c.tally(Category::Header).total(), 2, "{:#?}", c.differences); +} + +#[test] +fn the_listing_limit_keeps_counting() { + let a = drawing(); + let mut b = a.clone(); + for i in 0..20 { + b.header.set_i64(&format!("USERI{i}"), i); + } + let c = compare(&a, &b, &Options { max_differences: 5, ..Options::default() }); + assert_eq!(c.differences.len(), 5); + assert_eq!(c.omitted, 15); + assert_eq!(c.tally(Category::Header).added, 20); +} + +#[test] +fn census_counts_what_a_dxf_holds() { + let dxf = " 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nLAYER\n 0\nLAYER\n 2\n0\n 0\nLAYER\n 2\nA\n 0\nENDTAB\n 0\nENDSEC\n\ + 0\nSECTION\n 2\nBLOCKS\n 0\nBLOCK\n 2\n*Model_Space\n 0\nENDBLK\n 0\nBLOCK\n 2\n*Paper_Space\n 0\nLINE\n 0\nENDBLK\n\ + 0\nBLOCK\n 2\nB1\n 0\nCIRCLE\n 0\nPOLYLINE\n 0\nVERTEX\n 0\nSEQEND\n 0\nENDBLK\n 0\nENDSEC\n\ + 0\nSECTION\n 2\nENTITIES\n 0\nLINE\n 0\nINSERT\n 0\nATTRIB\n 0\nSEQEND\n 0\nENDSEC\n\ + 0\nSECTION\n 2\nOBJECTS\n 0\nDICTIONARY\n 0\nXRECORD\n 0\nXRECORD\n 0\nENDSEC\n 0\nEOF\n"; + let c = census(dxf.as_bytes()).unwrap(); + assert_eq!(c.entities.get("LINE"), Some(&2)); + assert_eq!(c.entities.get("INSERT"), Some(&1)); + assert!(!c.entities.contains_key("ATTRIB") && !c.entities.contains_key("SEQEND")); + assert_eq!(c.block_entities.get("CIRCLE"), Some(&1)); + assert_eq!(c.block_entities.get("POLYLINE"), Some(&1)); + assert_eq!(c.blocks, 1); + assert_eq!(c.tables.get("LAYER"), Some(&2)); + assert_eq!(c.objects.get("XRECORD"), Some(&2)); + let _ = census(b""); +} + +#[test] +fn read_census_reports_entities_the_reader_left_out() { + let d = drawing(); + let mut file = census_of(&d); + *file.entities.entry("LINE".into()).or_default() += 2; + *file.entities.entry("WEIRD".into()).or_default() += 1; + let c = compare_census(&file, &d, &Options::default()); + let lost = only(&c, Category::Read, Change::Lost); + assert_eq!(c.tally(Category::Read).lost, 3, "{:#?}", c.differences); + assert!(lost.iter().any(|x| x.place == "spaces LINE" && x.detail == "LINE: 5 in the file, 3 read"), "{lost:#?}"); + assert!(compare_census(&census_of(&d), &d, &Options::default()).is_empty()); +} + +/// The round trips of a synthetic drawing through CADCraft's own DXF and DWG writers: whatever +/// they lose shows in the comparison. Plain geometry, layers, xdata and unmodelled objects must +/// come back from DXF. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dxf_round_trip_of_plain_geometry_is_lossless() { + use corpus::{Format, round_trip}; + let mut d = drawing(); + // What the comparator should see in a round trip: the drawing as read from its own DXF. + d = crate::read(crate::write(&d, "a.dxf").unwrap().as_slice(), "a.dxf").unwrap(); + let r = round_trip(&d, Format::Dxf, Format::Dxf, &Options::default(), None); + assert!(r.ok(), "{r:#?}"); + let core = [Category::Count, Category::Geometry, Category::Table, Category::Xdata, Category::Object, Category::Opaque]; + for cat in core { + assert_eq!(r.comparison.tally(cat), Tally::default(), "{cat:?}: {:#?}", r.comparison.differences); + } + let dwg = round_trip(&d, Format::Dxf, Format::Dwg, &Options::default(), None); + assert!(dwg.save_error.is_none() && dwg.reopen_error.is_none(), "{dwg:#?}"); + assert!(dwg.saved_bytes > 0); + // Through DWG, only the custom-class entity is lost (gap FF1: acadrust drops other programs' + // entities without DWG data of their own) — and the comparator says so. + let lost: Vec<&str> = dwg.comparison.differences.iter().filter(|d| d.category == Category::Count).map(|d| d.place.as_str()).collect(); + assert_eq!(lost, ["Model ACME_WIDGET"], "{:#?}", dwg.comparison.differences); + assert_eq!(dwg.comparison.tally(Category::Geometry), Tally::default(), "{:#?}", dwg.comparison.differences); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn hostile_files_are_reported_not_crashed() { + use corpus::{CorpusOptions, check_bytes}; + let opts = CorpusOptions::default(); + for (bytes, name) in [ + (&b""[..], "empty.dxf"), + (b"AC1032 not really a dwg file at all", "fake.dwg"), + (b" 0\nSECTION\n 2\nENTITIES\n 0\nLINE\n 10\nnan\n 20\n1e400\n 0\nENDSEC\n 0\nEOF\n", "odd.dxf"), + (&[0xFFu8; 64][..], "noise.dxf"), + ] { + let f = check_bytes(bytes, name, &opts); + assert_eq!(f.path, name); + if f.opened() { + assert!(!f.routes.is_empty()); + } else { + assert!(f.routes.is_empty() && f.open_error.as_deref().is_some_and(|e| !e.is_empty()), "{f:#?}"); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn report_renders_as_markdown_and_json() { + use corpus::{CorpusOptions, CorpusReport, check_bytes}; + let d = drawing(); + let dxf = crate::write(&d, "a.dxf").unwrap(); + let opts = CorpusOptions::default(); + let report = CorpusReport { + corpus: "corpus".into(), + generated: "2026-10-21 09:30 UTC".into(), + cadcraft_version: "0".into(), + abs_tol: 1e-6, + rel_tol: 1e-9, + files: vec![check_bytes(&dxf, "synthetic/a.dxf", &opts), check_bytes(b"AC1032 broken", "dwg/broken.dwg", &opts)], + }; + let md = report.to_markdown(); + assert!(md.starts_with("# CADCraft round-trip report"), "{md}"); + assert!(md.contains("| `synthetic/a.dxf` | DXF AC1032 |"), "{md}"); + assert!(md.contains("dxf → dxf") && md.contains("dxf → dwg"), "{md}"); + assert!(md.contains("| `dwg/broken.dwg` | DWG AC1032 |") && md.contains("**failed**"), "{md}"); + assert!(md.contains("## By category")); + let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap(); + assert_eq!(json["files"][0]["path"], "synthetic/a.dxf"); + assert_eq!(json["files"][0]["routes"][0]["route"], "dxf → dxf"); + assert!(json["files"][1]["openError"].as_str().is_some_and(|e| !e.is_empty()), "{json}"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn corpus_files_walks_folders_and_skips_the_report() { + use corpus::corpus_files; + let dir = std::env::temp_dir().join(format!("cadcraft-corpus-walk-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for p in ["dxf/a.DXF", "dwg/b.dwg", "dwg/sub/c.dwg", "notes.txt", "report/a.roundtrip.dxf", ".git/x.dxf"] { + let path = dir.join(p); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"x").unwrap(); + } + let files = corpus_files(&dir, &[dir.join("report")]); + let rel: Vec = files.iter().map(|f| corpus::relative(f, &dir)).collect(); + assert_eq!(rel, ["dwg/b.dwg", "dwg/sub/c.dwg", "dxf/a.DXF"]); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The real-file corpus: set `CADCRAFT_CORPUS_DIR` to a folder of DXF/DWG files (a checkout of +/// `storytold/cadcraft-corpus`) to open, save and reopen each one and write `report.md` and +/// `report.json` to `CADCRAFT_CORPUS_REPORT` (default: the corpus folder's `report`). Skipped +/// when the variable is unset, so CI passes without the corpus. It reports, it doesn't fail on +/// differences; set `CADCRAFT_CORPUS_STRICT=1` to fail when a file doesn't open, save or reopen, +/// or when a writer leaves records out of a saved file ([`corpus::CorpusReport::strict_failures`]). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn corpus_round_trip_report() { + let Some(dir) = std::env::var_os("CADCRAFT_CORPUS_DIR").map(std::path::PathBuf::from) else { + eprintln!("CADCRAFT_CORPUS_DIR is not set: corpus round trip skipped"); + return; + }; + let out = std::env::var_os("CADCRAFT_CORPUS_REPORT").map_or_else(|| dir.join("report"), std::path::PathBuf::from); + let files = corpus::corpus_files(&dir, std::slice::from_ref(&out)); + let report = corpus::run(&dir, &files, &corpus::CorpusOptions::default(), &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1)); + let (md, json) = report.write(&out).unwrap(); + eprintln!("corpus report: {} and {}", md.display(), json.display()); + if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") { + let failed = report.strict_failures(); + assert!(failed.is_empty(), "corpus failures:\n{}", failed.join("\n")); + } +} + +/// A type the DWG writer can't write is reported as lost on save, per type: in the route's +/// `not_written`, on the lost entity rows, in the Markdown and as a strict failure. A type it +/// writes (OLE2FRAME, kept by CADCraft without modelling it) is none of these. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn types_the_dwg_writer_cannot_write_are_reported_lost() { + use corpus::{CorpusReport, FileReport, Format, round_trip}; + let unknown = |t: &str, tags: Vec| EntityKind::Unknown(Unknown { dxf_type: t.into(), tags }); + let mut d = Drawing::new_metric(); + let widget = vec![tag(100, "AcDbEntity"), tag(100, "AcmeWidget"), tag(10, "1.0"), tag(20, "2.0"), tag(30, "0.0")]; + d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET", widget.clone())).unwrap(); + d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET", widget)).unwrap(); + // Its data has no DWG wrapper, which is where a DWG keeps the corners: the reader's defaults. + let ole = ["100", "AcDbEntity", "8", "0", "100", "AcDbOle2Frame", "70", "2", "10", "1.0", "20", "1.0", "30", "0.0", "11", "0.0", "21", "0.0"] + .chunks(2) + .map(|g| tag(g[0].parse().unwrap(), g[1])) + .chain( + ["31", "0.0", "71", "2", "72", "0", "73", "0", "90", "4", "310", "C0FFEE00", "1", "OLE"] + .chunks(2) + .map(|g| tag(g[0].parse().unwrap(), g[1])), + ) + .collect(); + d.add(&Space::Model, Common::default(), unknown("OLE2FRAME", ole)).unwrap(); + let d = crate::read(&crate::write(&d, "a.dxf").unwrap(), "a.dxf").unwrap(); + + let r = round_trip(&d, Format::Dxf, Format::Dwg, &Options::default(), None); + assert!(r.ok(), "{r:#?}"); + assert_eq!(r.not_written, [("ACME_WIDGET".to_string(), 2)].into_iter().collect()); + let lost: Vec<&Difference> = r.comparison.differences.iter().filter(|x| x.category == Category::Count && x.change == Change::Lost).collect(); + assert_eq!(lost.len(), 1, "{lost:#?}"); + assert!(lost[0].place.ends_with("ACME_WIDGET") && lost[0].detail.contains("not written: the DWG writer can't write ACME_WIDGET"), "{lost:#?}"); + // The OLE2FRAME is written (only the CLASS record AutoCAD's files have is added). + assert!( + !r.comparison.differences.iter().any(|x| x.category != Category::Class && x.place.contains("OLE2FRAME")), + "{:#?}", + r.comparison.differences + ); + assert!(!r.lossless()); + + let report = + CorpusReport { files: vec![FileReport { path: "a.dxf".into(), routes: vec![r], ..FileReport::default() }], ..CorpusReport::default() }; + assert_eq!(report.strict_failures(), ["a.dxf (dxf → dwg): lost on save, not written: 2 ACME_WIDGET"]); + let md = report.to_markdown(); + assert!(md.contains("- **Lost on save** (records a writer can't write): 2 ACME_WIDGET (dxf → dwg)"), "{md}"); + assert!(md.contains("| dxf → dwg | ok | ok | **2** |"), "{md}"); + assert!(md.contains("- **Lost on save**, not written (the writer can't write these types): 2 ACME_WIDGET"), "{md}"); + + // DXF keeps everything: nothing lost on save, nothing to fail on. + let r = round_trip(&d, Format::Dxf, Format::Dxf, &Options::default(), None); + assert!(r.not_written.is_empty()); + let report = + CorpusReport { files: vec![FileReport { path: "a.dxf".into(), routes: vec![r], ..FileReport::default() }], ..CorpusReport::default() }; + assert!(report.strict_failures().is_empty()); + assert!(report.to_markdown().contains("- Lost on save (records a writer can't write): none")); +} diff --git a/docs/gaps.md b/docs/gaps.md index a4a12cff8..7ab8202fa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (open → save → reopen round-trip harness with loss report: FF5 narrowed) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -14,7 +14,7 @@ do them first. | # | Gap | Why it ranks here | Est. | Doc | |---|---|---|---|---| | 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) | -| 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) | +| 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | The open → save → reopen harness exists (FF5), but no real files run through it yet and all file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 8–12 + human | [file formats](file-format-parity.md#tests) | | 3 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) | | 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place or clipped** (xrefs, #525, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) | | 5 | **No autosave in the web build** (the desktop app has SAVETIME autosave, Drawing Recovery, AUDIT and RECOVER since #406) | Browser users lose unsaved work when the tab or the browser goes down | 3–5 | [features](#features) | @@ -77,7 +77,7 @@ Detail: [file-format-parity.md](file-format-parity.md). | FF2 | Multileader leftovers (native MULTILEADER read/write landed, #459): several landings per multileader (extra leader roots are folded into the lines), per-object overrides (leader colour and linetype, arrowhead symbol, text frame), annotative scale contexts; check the text placement of our MULTILEADER output in AutoCAD | Medium | 3–5 | | FF3 | Save-version leftovers (DXF/DWG 2000–2018, binary DXF, SAVEAS `version`/`binary` and the default save version landed, #421): no R14 DWG or R12 DXF output; the Save dialog can't pick the version per file (the file picker doesn't report the chosen type; File > Default Save Format sets it); no command-line SAVEAS format prompt | Low | 2–4 | | FF4 | Code-page leftovers (pre-2007 DXF read in their `$DWGCODEPAGE` landed, #505): Johab (ANSI_1361) and DOS 855/857/860–865/869 read as ANSI_1252; saving declares `ANSI_1252` instead of the file's code page (the text is pure ASCII, so still correct); acadrust approximates DOS code pages in pre-2007 DWG (DOS437 as 866, DOS850 as 1252) | Low | 1–2 | -| FF5 | **[alpha blocker]** Real-file corpus (`storytold/cadcraft-corpus`), open → save → reopen tests, black-box check in AutoCAD | High | 10–15 + human | +| FF5 | **[alpha blocker]** Real-file corpus (`storytold/cadcraft-corpus`) and black-box check in AutoCAD. The open → save → reopen harness landed 2026-10-22 (`cadcraft_io::roundtrip`, `cadcraft-cli roundtrip`, `cargo xtask corpus`: entities by handle, tables, objects, xdata, classes and header compared, Markdown and JSON report, with the records the DWG writer leaves out). Left: the corpus itself (no real files run in CI yet); nobody has opened CADCraft's output in AutoCAD | High | 8–12 + human | | FF6 | **[alpha blocker]** DWG throughput: a 10 MB DWG took ~2 min to fail (#57) | High for real projects | 6–10 | | FF7 | Table record and object handles from other programs' files are renumbered on the first save (entity handles are kept; `*D` blocks no longer duplicate and once reopened, CADCraft's own files keep every handle across saves, #495) | Low | 4–6 | | FF8 | SHX fonts (shape and big fonts) not read | Medium-high (text metrics differ) | 8–12 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Round-trip harness: `cadcraft_io::roundtrip` compares a drawing with itself saved and reopened (entities by handle, tables, blocks, layouts, unmodelled objects, xdata, classes, header variables; numbers within a tolerance), `roundtrip::corpus` runs a folder of DXF/DWG files through DXF and DWG into `report.md`/`report.json`, exposed as `cadcraft-cli roundtrip` and `cargo xtask corpus`; the harness reports per type the records the DWG writer leaves out (`cadcraft_dwg::dxf_to_dwg_noted`, `cadcraft_io::write_noted`), and OLE2FRAME, which it writes, is no longer listed as not saved: FF5 narrowed | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d5ef7dcab..8d8d78631 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -23,8 +23,10 @@ commands: test cargo nextest run (cargo test without nextest) plus the doctests i18n print the strings each interface language shows in English (never fails) ci fmt --check, clippy -D warnings, test, i18n, assets, layers, wasm (stops at first failure) - corpus [--download] - show where test corpora live; --download fetches PngSuite into corpus/pngsuite + corpus [DIR] [--out DIR] [--keep-saved] [--same-format] + open -> save -> reopen every DXF/DWG of a real-file corpus (DIR, default + $CADCRAFT_CORPUS_DIR or ../cadcraft-corpus) and write report.md + report.json + (default DIR/report); runs `cadcraft-cli roundtrip` in release mode stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) parity recompute the AutoCAD menu-breadth checklist in docs/parity-checklist.md ico ... @@ -41,7 +43,7 @@ fn main() -> ExitCode { Some("test") => cmd_test(), Some("i18n") => cmd_i18n(), Some("ci") => cmd_ci(), - Some("corpus") => cmd_corpus(rest.contains(&"--download")), + Some("corpus") => cmd_corpus(&rest), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("parity") => parity::run(&root()), Some("ico") => ico::run(&rest), @@ -226,7 +228,43 @@ fn cmd_ci() -> Result<(), String> { Ok(()) } -fn cmd_corpus(_download: bool) -> Result<(), String> { - println!("Test corpora live in storytold/cadcraft-corpus (planned); never commit large binaries here."); - Ok(()) +/// `corpus [DIR] [options]`: the round-trip report over a real-file corpus (gaps FF1, FF5). The +/// corpus lives outside this repo (storytold/cadcraft-corpus); its files are never committed here. +fn cmd_corpus(args: &[&str]) -> Result<(), String> { + // `cadcraft-cli` runs from the repository root: relative paths are made absolute against the + // folder `cargo xtask` was run from first, so they mean what the user typed. + let here = std::env::current_dir().map_err(|e| format!("current folder: {e}"))?; + let mut pass: Vec = Vec::new(); + let mut dir: Option = None; + let mut it = args.iter(); + while let Some(a) = it.next() { + match *a { + "--out" => { + let v = it.next().ok_or_else(|| format!("{a} needs a value"))?; + pass.extend([a.to_string(), here.join(v).display().to_string()]); + } + "--tolerance" => { + let v = it.next().ok_or_else(|| format!("{a} needs a value"))?; + pass.extend([a.to_string(), v.to_string()]); + } + s if s.starts_with("--") => pass.push(s.to_string()), + s if dir.is_none() => dir = Some(s.to_string()), + _ => return Err("corpus takes one DIR".into()), + } + } + let dir = dir + .or_else(|| std::env::var("CADCRAFT_CORPUS_DIR").ok()) + .unwrap_or_else(|| root().parent().map(|p| p.join("cadcraft-corpus").display().to_string()).unwrap_or_else(|| "../cadcraft-corpus".into())); + let dir = here.join(&dir); + if !dir.is_dir() { + return Err(format!( + "no corpus at `{}`: clone storytold/cadcraft-corpus next to this repo, set CADCRAFT_CORPUS_DIR or pass DIR \ + (never commit corpus files to this repo)", + dir.display() + )); + } + let dir = dir.display().to_string(); + let mut c = cargo(); + c.args(["run", "--release", "-q", "-p", "cadcraft-cli", "--", "roundtrip", &dir]).args(&pass); + run(c, &format!("cadcraft-cli roundtrip {dir} {}", pass.join(" "))) } From 9d83f219102b89cbb10df56146ae6395827b6726 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 20:36:01 -0700 Subject: [PATCH 2/3] Round-trip harness: typed tolerance error, report folders it couldn't read, --out=DIR From review of #2685. The ast-grep string-error rule refused the tolerance parser's String error. A folder the walk couldn't read was skipped silently, so a strict run could pass on part of the corpus; it is now said on stderr and fails a strict run. xtask corpus resolves --out=DIR against the caller's folder, as --out DIR. --- apps/cadcraft-cli/src/args.rs | 6 ++- apps/cadcraft-cli/src/roundtrip.rs | 25 ++++++++++-- crates/io/src/roundtrip/corpus.rs | 62 +++++++++++++++++++++++++----- crates/io/src/roundtrip/tests.rs | 7 ++++ xtask/src/main.rs | 4 ++ 5 files changed, 89 insertions(+), 15 deletions(-) diff --git a/apps/cadcraft-cli/src/args.rs b/apps/cadcraft-cli/src/args.rs index 1df9bacbe..f3b70fdc8 100644 --- a/apps/cadcraft-cli/src/args.rs +++ b/apps/cadcraft-cli/src/args.rs @@ -336,10 +336,10 @@ pub(crate) struct RoundtripArgs { } /// `--tolerance`: a finite number, 0 or more. -fn tolerance(v: &str) -> Result { +fn tolerance(v: &str) -> Result { match v.parse::() { Ok(t) if t.is_finite() && t >= 0.0 => Ok(t), - _ => Err(format!("must be a number >= 0, got `{v}`")), + _ => Err(ValueError::Tolerance(v.to_string())), } } @@ -395,6 +395,8 @@ pub(crate) enum ValueError { CmdId, #[error(transparent)] CmdJson(#[from] serde_json::Error), + #[error("expected a number of 0 or more, got `{0}`")] + Tolerance(String), } /// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2). diff --git a/apps/cadcraft-cli/src/roundtrip.rs b/apps/cadcraft-cli/src/roundtrip.rs index 00f7595ea..8cc4bb11e 100644 --- a/apps/cadcraft-cli/src/roundtrip.rs +++ b/apps/cadcraft-cli/src/roundtrip.rs @@ -19,13 +19,28 @@ pub(crate) enum RoundtripError { Strict(Vec), } +/// What the walk couldn't look at, so the report isn't taken for the whole corpus: said on +/// stderr, and failures of a strict run. +fn left_out(w: &cadcraft_io::roundtrip::corpus::Walk) -> Vec { + let mut out: Vec = w.unreadable.iter().map(|u| format!("not read: {u}")).collect(); + if w.truncated { + out.push("left out: files or folders past the harness limits".to_string()); + } + out +} + pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result { use cadcraft_io::roundtrip::corpus; let path = a.path.clone().or_else(|| std::env::var_os("CADCRAFT_CORPUS_DIR").map(Into::into)).ok_or(RoundtripError::NoPath)?; + let mut missed = Vec::new(); let (root, files, out) = if path.is_dir() { let out = a.out.clone().unwrap_or_else(|| path.join("report")); - let files = corpus::corpus_files(&path, std::slice::from_ref(&out)); - (path, files, out) + let walk = corpus::walk(&path, std::slice::from_ref(&out)); + missed = left_out(&walk); + for m in &missed { + io.errln(format_args!("roundtrip: {m}")); + } + (path, walk.files, out) } else if path.is_file() { let root = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default(); let out = a.out.clone().unwrap_or_else(|| root.join("report")); @@ -34,7 +49,8 @@ pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result { return Err(RoundtripError::Missing(path).into()); }; if files.is_empty() { - return Err(RoundtripError::Empty(root).into()); + // A folder it couldn't read isn't an empty one. + return Err(if missed.is_empty() { RoundtripError::Empty(root) } else { RoundtripError::Strict(missed) }.into()); } let mut opts = corpus::CorpusOptions { cross_format: !a.same_format, census: !a.no_census, ..Default::default() }; opts.compare.abs_tol = a.tolerance; @@ -66,7 +82,8 @@ pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result { // CADCRAFT_CORPUS_STRICT=1: a file that doesn't open, save or reopen, or records a writer // leaves out, fail the run (the report is written first). if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") { - let failed = report.strict_failures(); + let mut failed = report.strict_failures(); + failed.extend(missed); if !failed.is_empty() { return Err(CliError::from(RoundtripError::Strict(failed))); } diff --git a/crates/io/src/roundtrip/corpus.rs b/crates/io/src/roundtrip/corpus.rs index 36895527e..fe5f2abdf 100644 --- a/crates/io/src/roundtrip/corpus.rs +++ b/crates/io/src/roundtrip/corpus.rs @@ -347,27 +347,71 @@ pub fn is_drawing_file(path: &Path) -> bool { /// Every DXF and DWG file under `dir`, sorted, leaving out hidden folders and the folders in /// `skip` (the report folder). Symbolic links are not followed. pub fn corpus_files(dir: &Path, skip: &[PathBuf]) -> Vec { + walk(dir, skip).files +} + +/// What [`walk`] found, and what it couldn't look at, so a report never passes for a whole one. +#[derive(Debug, Default)] +pub struct Walk { + pub files: Vec, + /// Folders and entries that couldn't be read, with why. + pub unreadable: Vec, + /// Files past [`MAX_FILES`] or folders past the depth limit were left out. + pub truncated: bool, +} + +/// [`corpus_files`], also saying what it had to leave out. +pub fn walk(dir: &Path, skip: &[PathBuf]) -> Walk { let skip: Vec = skip.iter().map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())).collect(); - let mut out = Vec::new(); + let mut w = Walk::default(); let mut stack = vec![(dir.to_path_buf(), 0usize)]; while let Some((d, depth)) = stack.pop() { - let Ok(entries) = std::fs::read_dir(&d) else { continue }; - for entry in entries.flatten() { + let entries = match std::fs::read_dir(&d) { + Ok(entries) => entries, + Err(e) => { + w.unreadable.push(format!("{}: {e}", d.display())); + continue; + } + }; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + w.unreadable.push(format!("{}: {e}", d.display())); + continue; + } + }; let path = entry.path(); - let Ok(ft) = entry.file_type() else { continue }; + let ft = match entry.file_type() { + Ok(ft) => ft, + Err(e) => { + w.unreadable.push(format!("{}: {e}", path.display())); + continue; + } + }; if ft.is_dir() { let hidden = entry.file_name().to_string_lossy().starts_with('.'); let canon = path.canonicalize().unwrap_or_else(|_| path.clone()); - if !hidden && depth < MAX_DEPTH && !skip.contains(&canon) { + if hidden || skip.contains(&canon) { + continue; + } + if depth < MAX_DEPTH { stack.push((path, depth + 1)); + } else { + w.truncated = true; + } + } else if ft.is_file() && is_drawing_file(&path) { + if w.files.len() < MAX_FILES { + w.files.push(path); + } else { + w.truncated = true; } - } else if ft.is_file() && is_drawing_file(&path) && out.len() < MAX_FILES { - out.push(path); } } } - out.sort(); - out + w.files.sort(); + w.unreadable.sort(); + w } /// `path` relative to `root`, with forward slashes. diff --git a/crates/io/src/roundtrip/tests.rs b/crates/io/src/roundtrip/tests.rs index 57b77a74a..2107fb48f 100644 --- a/crates/io/src/roundtrip/tests.rs +++ b/crates/io/src/roundtrip/tests.rs @@ -462,6 +462,13 @@ fn report_renders_as_markdown_and_json() { } #[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_folder_it_cannot_read_is_reported_not_skipped() { + let w = corpus::walk(std::path::Path::new("no/such/corpus/folder"), &[]); + assert!(w.files.is_empty()); + assert_eq!(w.unreadable.len(), 1, "{:?}", w.unreadable); +} + #[test] fn corpus_files_walks_folders_and_skips_the_report() { use corpus::corpus_files; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 8d8d78631..5d8bd5111 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -247,6 +247,10 @@ fn cmd_corpus(args: &[&str]) -> Result<(), String> { let v = it.next().ok_or_else(|| format!("{a} needs a value"))?; pass.extend([a.to_string(), v.to_string()]); } + s if s.starts_with("--out=") => { + let v = s.trim_start_matches("--out="); + pass.extend(["--out".to_string(), here.join(v).display().to_string()]); + } s if s.starts_with("--") => pass.push(s.to_string()), s if dir.is_none() => dir = Some(s.to_string()), _ => return Err("corpus takes one DIR".into()), From a4ff30d11eb97c9a34556f37a8b68e7d2399864a Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 2 Nov 2026 20:49:27 -0700 Subject: [PATCH 3/3] Round-trip harness: never read the kept copies back as corpus files --- apps/cadcraft-cli/src/roundtrip.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cadcraft-cli/src/roundtrip.rs b/apps/cadcraft-cli/src/roundtrip.rs index 8cc4bb11e..dba9c8d04 100644 --- a/apps/cadcraft-cli/src/roundtrip.rs +++ b/apps/cadcraft-cli/src/roundtrip.rs @@ -35,7 +35,8 @@ pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result { let mut missed = Vec::new(); let (root, files, out) = if path.is_dir() { let out = a.out.clone().unwrap_or_else(|| path.join("report")); - let walk = corpus::walk(&path, std::slice::from_ref(&out)); + // The report folder, and the saved copies in it, are never read back as corpus files. + let walk = corpus::walk(&path, &[out.clone(), out.join("saved")]); missed = left_out(&walk); for m in &missed { io.errln(format_args!("roundtrip: {m}"));