diff --git a/apps/cadcraft-cli/src/args.rs b/apps/cadcraft-cli/src/args.rs index 16b57eeb7..f3b70fdc8 100644 --- a/apps/cadcraft-cli/src/args.rs +++ b/apps/cadcraft-cli/src/args.rs @@ -81,6 +81,7 @@ pub(crate) enum Command { Mcp(McpArgs), Perf(PerfArgs), Sample(SampleArgs), + Roundtrip(RoundtripArgs), } /// Print a drawing's summary as JSON @@ -308,6 +309,40 @@ fn perf_target(p: PathBuf) -> PerfTarget { } } +/// Open each DXF/DWG, save it as DXF and DWG, reopen and compare; writes report.md and report.json +/// +/// 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. +#[derive(Args)] +pub(crate) struct RoundtripArgs { + /// A drawing, or a folder of them + #[arg(value_name = "FILE|FOLDER")] + pub(crate) path: Option, + /// Where the report goes (default: `report` beside the files) + #[arg(long, value_name = "DIR")] + pub(crate) out: Option, + /// Keep the saved files, in `saved` under the report folder + #[arg(long)] + pub(crate) keep_saved: bool, + /// Save only in each file's own format + #[arg(long)] + pub(crate) same_format: bool, + /// Don't count what each file holds (faster; no "read lost" column) + #[arg(long)] + pub(crate) no_census: bool, + /// How far numbers may move and still count as equal + #[arg(long, value_name = "T", default_value_t = cadcraft_io::roundtrip::Options::default().abs_tol, value_parser = tolerance)] + pub(crate) tolerance: f64, +} + +/// `--tolerance`: a finite number, 0 or more. +fn tolerance(v: &str) -> Result { + match v.parse::() { + Ok(t) if t.is_finite() && t >= 0.0 => Ok(t), + _ => Err(ValueError::Tolerance(v.to_string())), + } +} + /// Write a built-in sample drawing #[derive(Args)] pub(crate) struct SampleArgs { @@ -360,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/error.rs b/apps/cadcraft-cli/src/error.rs index 51b816890..359c6775c 100644 --- a/apps/cadcraft-cli/src/error.rs +++ b/apps/cadcraft-cli/src/error.rs @@ -1,5 +1,6 @@ use crate::output::OutputError; use crate::perf::PerfError; +use crate::roundtrip::RoundtripError; use crate::run::RunError; /// Why a subcommand failed: printed as `error: {e}`, exit 1. @@ -19,6 +20,8 @@ pub(crate) enum CliError { Output(#[from] OutputError), #[error(transparent)] Perf(#[from] PerfError), + #[error(transparent)] + Roundtrip(#[from] RoundtripError), /// The engine and io crates take file names as text. #[error("{}: the file name is not valid Unicode", .0.display())] NotUnicode(std::path::PathBuf), diff --git a/apps/cadcraft-cli/src/lib.rs b/apps/cadcraft-cli/src/lib.rs index c858417e2..1862f23f0 100644 --- a/apps/cadcraft-cli/src/lib.rs +++ b/apps/cadcraft-cli/src/lib.rs @@ -16,6 +16,7 @@ mod info; mod mcp; mod output; mod perf; +mod roundtrip; mod run; mod sample; @@ -32,6 +33,7 @@ use output::Streams; #[cfg(test)] use output::{export_window, model_views, write_atomic}; use perf::perf; +use roundtrip::roundtrip; use run::run_cmds; #[cfg(test)] use run::{fill_last, returned_handle}; @@ -99,7 +101,7 @@ where Command::Run(a) => a.settings.apply(), Command::Mcp(a) => a.settings.apply(), Command::Sample(a) => a.settings.apply(), - Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {} + Command::Info(_) | Command::Commands(_) | Command::Perf(_) | Command::Roundtrip(_) => {} } let mut io = Streams::new(out, err); let result = match &cli.command { @@ -110,6 +112,7 @@ where Command::Mcp(a) => mcp(a), Command::Perf(a) => perf(a.target.as_ref(), &mut io), Command::Sample(a) => sample(a, &mut io), + Command::Roundtrip(a) => roundtrip(a, &mut io), }; let mut code = 0; if let Err(e) = result { diff --git a/apps/cadcraft-cli/src/roundtrip.rs b/apps/cadcraft-cli/src/roundtrip.rs new file mode 100644 index 000000000..dba9c8d04 --- /dev/null +++ b/apps/cadcraft-cli/src/roundtrip.rs @@ -0,0 +1,93 @@ +//! `roundtrip`: the corpus harness of `cadcraft_io::roundtrip::corpus` over a file or folder. + +use super::args::RoundtripArgs; +use super::error::{CliError, Result}; +use super::output::Streams; + +/// Why `roundtrip` stopped. +#[derive(Debug, thiserror::Error)] +pub(crate) enum RoundtripError { + #[error("roundtrip: give a FILE or FOLDER, or set CADCRAFT_CORPUS_DIR")] + NoPath, + #[error("roundtrip: no such file or folder: {}", .0.display())] + Missing(std::path::PathBuf), + #[error("roundtrip: no DXF or DWG files in {}", .0.display())] + Empty(std::path::PathBuf), + #[error("roundtrip: can't write the report to {}: {source}", .path.display())] + Report { path: std::path::PathBuf, source: std::io::Error }, + #[error("roundtrip: {} strict failures:\n{}", .0.len(), .0.join("\n"))] + 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")); + // 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}")); + } + (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")); + (root, vec![path], out) + } else { + return Err(RoundtripError::Missing(path).into()); + }; + if files.is_empty() { + // 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; + if a.keep_saved { + opts.keep_saved = Some(out.join("saved")); + } + let report = corpus::run(&root, &files, &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) => io.outln(format_args!("{}: open failed: {e}", f.path)), + None => io.outln(format_args!("{}: opened; {}", f.path, routes.join("; "))), + } + } + let (md, json) = report.write(&out).map_err(|source| RoundtripError::Report { path: out.clone(), source })?; + io.outln(format_args!("report: {}", md.display())); + io.outln(format_args!(" {}", 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 mut failed = report.strict_failures(); + failed.extend(missed); + if !failed.is_empty() { + return Err(CliError::from(RoundtripError::Strict(failed))); + } + } + Ok(()) +} diff --git a/apps/cadcraft-cli/tests/man_and_completions.rs b/apps/cadcraft-cli/tests/man_and_completions.rs index df6dea90c..9e3e8c65e 100644 --- a/apps/cadcraft-cli/tests/man_and_completions.rs +++ b/apps/cadcraft-cli/tests/man_and_completions.rs @@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() { let mut written: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect(); written.sort(); let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"]; - let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"]; + let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample", "roundtrip"]; let mut expected: Vec = completions.map(String::from).into(); expected.push("cadcraft-cli.1".into()); expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1"))); diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 70646a19a..f143b7be8 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -65,7 +65,7 @@ fn mb(n: u64) -> String { #[cfg(not(no_dwg_bridge))] pub mod worker; #[cfg(not(no_dwg_bridge))] -pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, serve_if_asked, use_own_worker, use_worker}; +pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_noted, serve_if_asked, use_own_worker, use_worker}; #[cfg(not(no_dwg_bridge))] mod native { @@ -544,15 +544,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, @@ -565,9 +572,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, @@ -576,7 +584,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 @@ -644,12 +652,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, @@ -707,7 +722,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 @@ -732,21 +747,23 @@ mod native { None => {} } // The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash. - guarded(AssertUnwindSafe(|| { + let unwritten = guarded(AssertUnwindSafe(|| { super::fail_in_test("convert"); 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); current_layer(&mut doc); + unwritten })) .map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?; - 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 @@ -903,7 +920,11 @@ mod native { } #[cfg(not(no_dwg_bridge))] -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; // Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The // stage then panics on entry, so a test through the public API shows whether it is guarded. @@ -950,6 +971,10 @@ pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), St pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { dxf_to_dwg_as(dxf, version) } +#[cfg(no_dwg_bridge)] +pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec, Unwritten), String> { + dxf_to_dwg_noted(dxf, version) +} #[cfg(no_dwg_bridge)] pub fn dwg_to_dxf(_bytes: &[u8]) -> Result, String> { @@ -971,6 +996,10 @@ pub fn dxf_to_dwg(_dxf: &[u8]) -> Result, String> { pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result, String> { Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) } +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec, Unwritten), String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} #[cfg(test)] mod tests { @@ -1302,4 +1331,47 @@ mod tests { assert_eq!(read.dwg_tail_byte, 0, "{version}"); } } + + /// 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(no_dwg_bridge))] + #[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(no_dwg_bridge))] + #[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/dwg/src/worker.rs b/crates/dwg/src/worker.rs index 5ccf5cf6a..f55c7d170 100644 --- a/crates/dwg/src/worker.rs +++ b/crates/dwg/src/worker.rs @@ -82,15 +82,31 @@ pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), St /// [`super::dxf_to_dwg_as`] in the worker, when there is one. pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + encode_noted(dxf, version).map(|(dwg, _)| dwg) +} + +/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes +/// back as notes of `TYPEcount`. +pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec, super::Unwritten), String> { if u8::try_from(version.len()).is_err() { return Err(super::unsupported(version)); } match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") { - Some(reply) => reply?.map(|(dwg, _)| dwg), - None => super::dxf_to_dwg_as(dxf, version), + Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))), + None => super::dxf_to_dwg_noted(dxf, version), } } +fn unwritten_from(notes: &[String]) -> super::Unwritten { + notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::().ok()?))).fold( + super::Unwritten::new(), + |mut m, (kind, n)| { + *m.entry(kind).or_insert(0) = n; + m + }, + ) +} + /// Make the worker abort, as the third-party code can: what the tests prove isolation with. #[doc(hidden)] pub fn crash_worker_for_test() -> Result<(), String> { @@ -241,7 +257,7 @@ fn handle(mut b: &[u8]) -> Reply { let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?; match op { DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }), - ENCODE => super::dxf_to_dwg_as(b, version).map(|dwg| (dwg, Vec::new())), + ENCODE => super::dxf_to_dwg_noted(b, version).map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())), CRASH => std::process::abort(), _ => Err(bad()), } @@ -267,6 +283,12 @@ mod tests { out } + #[test] + fn what_the_writer_left_out_survives_the_reply() { + let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()]; + assert_eq!(unwritten_from(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect()); + } + #[test] fn replies_round_trip() { let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()])); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 4863ddb34..16a4c8539 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; @@ -69,19 +71,25 @@ fn ext(name: impl AsRef) -> 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: impl AsRef) -> Result { - let name = name.as_ref(); + read_source(bytes, name.as_ref()).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: &Path) -> Result<(Drawing, Option>)> { if cadcraft_dwg::is_dwg(bytes) { let (dxf, notes) = cadcraft_dwg::decode(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 (Format::from_path(name), ext(name).as_str()) { + let d = match (Format::from_path(name), ext(name).as_str()) { (Some(Format::Svg | Format::Svgz), _) => svg_read::read(bytes), (Some(Format::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 @@ -111,15 +119,29 @@ pub fn write_with(d: &Drawing, name: impl AsRef, opts: &SaveOptions) -> Re // 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. - Some(Format::Dwg) if !opts.binary => { - let version = opts.version.unwrap_or_else(|| version.max(Version::R2004)); - cadcraft_dwg::encode_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format) - } + Some(Format::Dwg) if !opts.binary => write_dwg(d, opts.version.unwrap_or_else(|| version.max(Version::R2004))).map(|(dwg, _)| dwg), _ if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{}`", ext(name)))), _ => 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: impl AsRef, opts: &SaveOptions) -> Result<(Vec, cadcraft_dwg::Unwritten)> { + let name = name.as_ref(); + if matches!(Format::from_path(name), Some(Format::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`, in the DWG worker when +/// there is one. +fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec, cadcraft_dwg::Unwritten)> { + cadcraft_dwg::encode_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..fe5f2abdf --- /dev/null +++ b/crates/io/src/roundtrip/corpus.rs @@ -0,0 +1,695 @@ +//! 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, std::path::Path::new(&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 { + 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 w = Walk::default(); + let mut stack = vec![(dir.to_path_buf(), 0usize)]; + while let Some((d, depth)) = stack.pop() { + 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 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 || 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; + } + } + } + } + w.files.sort(); + w.unreadable.sort(); + w +} + +/// `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..2107fb48f --- /dev/null +++ b/crates/io/src/roundtrip/tests.rs @@ -0,0 +1,566 @@ +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 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; + 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/xtask/src/main.rs b/xtask/src/main.rs index d5ef7dcab..5d8bd5111 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,47 @@ 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("--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()), + } + } + 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(" "))) }