mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 00:42:46 -06:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b04273e897 | ||
|
|
a4ff30d11e | ||
|
|
9d83f21910 | ||
|
|
7caf369555 | ||
|
|
9107479ade |
@@ -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<PathBuf>,
|
||||
/// Where the report goes (default: `report` beside the files)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub(crate) out: Option<PathBuf>,
|
||||
/// 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<f64, ValueError> {
|
||||
match v.parse::<f64>() {
|
||||
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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>),
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
let mut out: Vec<String> = 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<String> = 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(())
|
||||
}
|
||||
@@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() {
|
||||
let mut written: Vec<String> = 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<String> = completions.map(String::from).into();
|
||||
expected.push("cadcraft-cli.1".into());
|
||||
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
|
||||
|
||||
+84
-12
@@ -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<acadrust::Handle> = 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<Vec<u8>, 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<Vec<u8>, 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<u8>, 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<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, 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<String, usize>;
|
||||
|
||||
// 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<u8>, Vec<String>), St
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_as(dxf, version)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
dxf_to_dwg_noted(dxf, version)
|
||||
}
|
||||
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
@@ -971,6 +996,10 @@ pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, 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<u8>, 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::<Unwritten>(), "{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<Vec<u8>> = 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +82,31 @@ pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), St
|
||||
|
||||
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, 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 `TYPE<tab>count`.
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, 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::<usize>().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()]));
|
||||
|
||||
@@ -22,7 +22,6 @@ mod inquiry;
|
||||
mod layer;
|
||||
mod layout;
|
||||
mod lisp;
|
||||
mod merge;
|
||||
mod mleader_content;
|
||||
mod mleader_edit;
|
||||
mod modify;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (names this drawing already has keep this drawing's definitions). The file is read through the
|
||||
//! host's io hooks like OPEN, so the engine stays I/O-agnostic.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle};
|
||||
@@ -102,7 +102,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
// Redefining keeps the existing definition's name (block names are case-insensitive).
|
||||
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
merge_tables(&mut d, src);
|
||||
let renames = crate::cmd::merge::block_renames(&d, src, Some(&name));
|
||||
let renames = block_names(&d, src, &name);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -136,7 +136,13 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition).
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
merge(&mut d.linetypes, &src.linetypes, |l| &l.name);
|
||||
merge(&mut d.text_styles, &src.text_styles, |t| &t.name);
|
||||
@@ -146,6 +152,35 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
merge(&mut d.mline_styles, &src.mline_styles, |t| &t.name);
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
||||
/// already has (or the block being defined) keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.insert(defining.to_ascii_uppercase());
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
|
||||
//! block empty with a status saying why; nothing here stops the host drawing from opening.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -340,15 +339,14 @@ fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &
|
||||
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
|
||||
Ok((x, file)) => match import(d, &key, &x, cx) {
|
||||
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
|
||||
Err(e) => {
|
||||
// Whatever made it in before the failure goes again. A fetch that failed imported
|
||||
// nothing, so it doesn't pay for a second scan of the block table.
|
||||
clear(d, &key);
|
||||
(XrefStatus::Unresolved, e)
|
||||
}
|
||||
Err(e) => (XrefStatus::Unresolved, e),
|
||||
},
|
||||
Err(e) => e,
|
||||
};
|
||||
if status != XrefStatus::Loaded {
|
||||
// Whatever made it in before the failure goes again.
|
||||
clear(d, &key);
|
||||
}
|
||||
set_status(d, &key, status);
|
||||
(status, detail)
|
||||
}
|
||||
@@ -392,15 +390,9 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
return Err(format!("more than {MAX_XREF_OBJECTS} objects"));
|
||||
}
|
||||
let dep = |sym: Sym, n: &str| (!shared(sym, n)).then(|| format!("{key}|{n}"));
|
||||
// The host's names per table, looked up in a set built once: both drawings are hostile input,
|
||||
// so a scan per imported record would be quadratic.
|
||||
fn names<T>(all: &[T], name: fn(&T) -> &str) -> HashSet<String> {
|
||||
all.iter().map(|t| name(t).to_ascii_uppercase()).collect()
|
||||
}
|
||||
let mut have = names(&d.layers, |l| &l.name);
|
||||
for l in &x.layers {
|
||||
if let Some(n) = dep(Sym::Layer, &l.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.layer(&n).is_none()
|
||||
{
|
||||
let mut l = l.clone();
|
||||
l.name = n;
|
||||
@@ -408,44 +400,39 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.linetypes, |t| &t.name);
|
||||
for t in &x.linetypes {
|
||||
if let Some(n) = dep(Sym::Linetype, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.linetype(&n).is_none()
|
||||
{
|
||||
d.linetypes.push(cadcraft_doc::Linetype { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.text_styles, |t| &t.name);
|
||||
for t in &x.text_styles {
|
||||
if let Some(n) = dep(Sym::Style, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.text_style(&n).is_none()
|
||||
{
|
||||
d.text_styles.push(cadcraft_doc::TextStyle { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.dim_styles, |t| &t.name);
|
||||
for t in &x.dim_styles {
|
||||
if let Some(n) = dep(Sym::DimStyle, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.dim_style(&n).is_none()
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.dim_styles.push(cadcraft_doc::DimStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.mleader_styles, |t| &t.name);
|
||||
for t in &x.mleader_styles {
|
||||
if let Some(n) = dep(Sym::MLeaderStyle, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& !d.mleader_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.mleader_styles.push(cadcraft_doc::MLeaderStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.table_styles, |t| &t.name);
|
||||
for t in &x.table_styles {
|
||||
if let Some(n) = dep(Sym::TableStyle, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& !d.table_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
{
|
||||
d.table_styles.push(cadcraft_doc::TableStyle { name: n, ..t.clone() });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
|
||||
//! end can put it on the system clipboard and paste it into another CADCraft window.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
|
||||
@@ -279,14 +279,20 @@ fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
|
||||
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
|
||||
/// their name in `defs` to their name in `d`.
|
||||
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
use crate::cmd::merge::merge_named as add;
|
||||
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
add(&mut d.layers, &defs.layers, |l| &l.name);
|
||||
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
|
||||
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
|
||||
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
|
||||
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
|
||||
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
|
||||
let renames = crate::cmd::merge::block_renames(d, defs, None);
|
||||
let renames = block_names(d, defs);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = defs.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -302,6 +308,34 @@ fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
renames
|
||||
}
|
||||
|
||||
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
|
||||
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
|
||||
/// already has keeps `d`'s definition (not copied).
|
||||
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
|
||||
for (name, b) in &defs.blocks {
|
||||
let upper = up(name);
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(up(&to));
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
|
||||
/// objects of the source drawing (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! stays I/O-agnostic; the JSON forms never open dialogs (the UI asks for files when these
|
||||
//! commands come from a menu, the tool bar or the command line).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Rgb;
|
||||
@@ -166,7 +166,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let (layers, styles) = merge_tables(d, src);
|
||||
let renames = crate::cmd::merge::block_renames(d, src, None);
|
||||
let renames = block_names(d, src);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -213,7 +213,16 @@ fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition). Returns how many layers and how many other records were added.
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
||||
let mut n = 0;
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
let layers = merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
let others = merge(&mut d.linetypes, &src.linetypes, |l| &l.name)
|
||||
+ merge(&mut d.text_styles, &src.text_styles, |t| &t.name)
|
||||
@@ -223,6 +232,35 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
||||
(layers, others)
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
||||
/// already has keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let anonymous = b.anonymous || name.starts_with('*');
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if anonymous {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, Layer, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad};
|
||||
@@ -197,7 +197,6 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
d.skipped_records.clear();
|
||||
d.handseed = d.handseed.max(other.handseed);
|
||||
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
merge(&mut d.layers, &other.layers, |t| &t.name);
|
||||
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
|
||||
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
|
||||
@@ -236,17 +235,22 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
|
||||
}
|
||||
|
||||
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
|
||||
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
|
||||
for t in from {
|
||||
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
|
||||
to.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add copies of `objs` to model space with new handles, their block references renamed by
|
||||
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
|
||||
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
|
||||
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
|
||||
let mut members = Vec::new();
|
||||
// The layers this drawing has, looked up once per object rather than scanned.
|
||||
let mut layers: HashSet<String> = d.layers.iter().map(|l| l.name.to_ascii_uppercase()).collect();
|
||||
for (e, color) in objs {
|
||||
if layers.insert(e.common.layer.to_ascii_uppercase()) {
|
||||
d.layers.push(Layer::new(&e.common.layer));
|
||||
}
|
||||
d.ensure_layer(&e.common.layer);
|
||||
let h = d.new_handle();
|
||||
let mut copy = Entity { handle: h, ..(*e).clone() };
|
||||
detach(&mut copy.kind);
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
//! Bringing another drawing's symbol tables and blocks into this one (INSERT of a file, IMPORT,
|
||||
//! paste, COMPARE export).
|
||||
//!
|
||||
//! Both drawings are hostile input, so the work is linear in their sizes: names are looked up in a
|
||||
//! set built once per table, and a generated block name continues from the last one allocated for
|
||||
//! its prefix instead of probing every taken number again (CWE-407).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
|
||||
/// Add the entries of `theirs` whose names `ours` lacks (compared without letter case; a name that
|
||||
/// exists keeps `ours`' definition). Returns how many were added.
|
||||
pub(crate) fn merge_named<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
||||
let mut have: HashSet<String> = ours.iter().map(|o| name(o).to_ascii_uppercase()).collect();
|
||||
let mut added = 0;
|
||||
for t in theirs {
|
||||
if have.insert(name(t).to_ascii_uppercase()) {
|
||||
ours.push(t.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Which blocks of `src` to copy into `d`, and under which name: a new name keeps its own, a
|
||||
/// generated (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named
|
||||
/// block `d` already has (or `defining`, the block being defined from `src`) keeps `d`'s
|
||||
/// definition (not copied).
|
||||
pub(crate) fn block_renames(d: &Drawing, src: &Drawing, defining: Option<&str>) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.extend(defining.map(str::to_ascii_uppercase));
|
||||
// The next number to try per generated prefix: numbers below it are taken.
|
||||
let mut next: HashMap<String, usize> = HashMap::new();
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
let cursor = next.entry(prefix.to_ascii_uppercase()).or_insert(1);
|
||||
match (*cursor..=1_000_000).map(|i| (i, format!("{prefix}{i}"))).find(|(_, n)| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some((i, n)) => {
|
||||
*cursor = i + 1;
|
||||
n
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Layer};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_keeps_ours_and_adds_the_rest_without_regard_to_case() {
|
||||
let mut ours = vec![Layer::new("Walls")];
|
||||
let theirs = vec![Layer::new("WALLS"), Layer::new("Doors"), Layer::new("doors")];
|
||||
assert_eq!(merge_named(&mut ours, &theirs, |l| &l.name), 1);
|
||||
assert_eq!(ours.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Walls", "Doors"]);
|
||||
}
|
||||
|
||||
/// Many colliding anonymous blocks get consecutive names in one pass, not a rescan each.
|
||||
#[test]
|
||||
fn anonymous_collisions_take_consecutive_numbers() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut src = Drawing::new_imperial();
|
||||
for i in 1..=5000 {
|
||||
let mut b = Block::new(&format!("*U{i}"));
|
||||
b.anonymous = true;
|
||||
d.blocks.insert(b.name.clone(), Arc::new(b.clone()));
|
||||
src.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let renames = block_renames(&d, &src, None);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert_eq!(renames.len(), 5000);
|
||||
assert_eq!(renames.get("*U1").map(String::as_str), Some("*U5001"));
|
||||
let distinct: HashSet<&String> = renames.values().collect();
|
||||
assert_eq!(distinct.len(), 5000);
|
||||
// Every new name is the next free number: nothing below 5001 is free, nothing above 10000 is needed.
|
||||
assert!(renames.values().all(|n| n.strip_prefix("*U").and_then(|i| i.parse::<u32>().ok()).is_some_and(|i| (5001..=10000).contains(&i))));
|
||||
}
|
||||
}
|
||||
+30
-8
@@ -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<Path>) -> 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<Path>) -> Result<Drawing> {
|
||||
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<Vec<u8>>)> {
|
||||
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<Path>, 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<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, 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<u8>, 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String, usize>,
|
||||
/// Entities of block definitions (other than the model and paper space blocks), per type.
|
||||
pub block_entities: BTreeMap<String, usize>,
|
||||
/// 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<String, usize>,
|
||||
/// Non-graphical objects per type. For a drawing: only those kept without being modelled.
|
||||
pub objects: BTreeMap<String, usize>,
|
||||
/// 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<String, usize>, 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<Census> {
|
||||
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<String> = HashSet::new();
|
||||
let mut inserted: HashSet<String> = HashSet::new();
|
||||
let mut blocks: Vec<(String, BTreeMap<String, usize>)> = 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<String, usize>) -> BTreeMap<String, usize> {
|
||||
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<String, usize>, read: &BTreeMap<String, usize>) {
|
||||
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
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub save_error: Option<String>,
|
||||
pub reopen_error: Option<String>,
|
||||
/// What reopening reported (records the reader couldn't place or decode).
|
||||
pub reopen_notes: Vec<String>,
|
||||
/// 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<String, usize>,
|
||||
/// 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, usize>) -> String {
|
||||
m.iter().map(|(t, n)| format!("{n} {t}")).collect::<Vec<_>>().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<Format>,
|
||||
/// `AC1032` for a DWG, the `$ACADVER` of a DXF.
|
||||
pub version: String,
|
||||
pub bytes: u64,
|
||||
pub open_error: Option<String>,
|
||||
/// What opening reported (a DWG's records the reader skipped, damaged records).
|
||||
pub open_notes: Vec<String>,
|
||||
pub open_seconds: f64,
|
||||
/// What the file holds (for a DWG: its DXF rendition), when counted.
|
||||
pub file_census: Option<Census>,
|
||||
/// What the drawing read from it holds.
|
||||
pub drawing_census: Option<Census>,
|
||||
/// What the drawing holds less or more than the file.
|
||||
pub read: Comparison,
|
||||
pub routes: Vec<RouteReport>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<FileReport>,
|
||||
}
|
||||
|
||||
/// Run `f`, turning a panic into an error message.
|
||||
fn guard<T>(f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
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::<String>().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<String> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf>,
|
||||
/// Folders and entries that couldn't be read, with why.
|
||||
pub unreadable: Vec<String>,
|
||||
/// 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<PathBuf> = 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::<Vec<_>>().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>) -> 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<Category, Tally> {
|
||||
let mut all: std::collections::BTreeMap<Category, Tally> = 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<String, usize> = 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::<usize>()
|
||||
)
|
||||
});
|
||||
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<String> = 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<String> = 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Arc<Entity>> = 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<String> = 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<RawTag>| 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"));
|
||||
}
|
||||
+48
-6
@@ -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 <out.ico> <in.png>...
|
||||
@@ -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<String> = Vec::new();
|
||||
let mut dir: Option<String> = 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(" ")))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user