Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 8505cee19b fix(engine): move LISP output to the command history in one pass
lisp_flush drained each line from the front of the output buffer, shifting everything after it every time, so newline-dense PRINC output cost quadratic time outside the interpreter's step budget. It now walks the buffer once and drops the printed prefix once.
2026-11-03 13:55:27 +00:00
40 changed files with 230 additions and 3714 deletions
-37
View File
@@ -81,7 +81,6 @@ pub(crate) enum Command {
Mcp(McpArgs),
Perf(PerfArgs),
Sample(SampleArgs),
Roundtrip(RoundtripArgs),
}
/// Print a drawing's summary as JSON
@@ -309,40 +308,6 @@ 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 {
@@ -395,8 +360,6 @@ 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).
-3
View File
@@ -1,6 +1,5 @@
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.
@@ -20,8 +19,6 @@ 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),
+1 -4
View File
@@ -16,7 +16,6 @@ mod info;
mod mcp;
mod output;
mod perf;
mod roundtrip;
mod run;
mod sample;
@@ -33,7 +32,6 @@ 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};
@@ -101,7 +99,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::Roundtrip(_) => {}
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
}
let mut io = Streams::new(out, err);
let result = match &cli.command {
@@ -112,7 +110,6 @@ 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 {
-93
View File
@@ -1,93 +0,0 @@
//! `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", "roundtrip"];
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
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")));
+2 -6
View File
@@ -362,13 +362,9 @@ impl EntityKind {
i.scale.y *= s;
i.scale.z *= s;
i.rotation = cadcraft_geom::norm_angle(i.rotation + rot);
// Mirrored as AutoCAD 2024 mirrors a block reference: the X scale factor
// negated, the rotation 180 degrees plus twice the mirror line's angle less the
// old one, and a MINSERT grid's columns running the other way.
if mirror {
i.scale.x = -i.scale.x;
i.rotation = cadcraft_geom::norm_angle(std::f64::consts::PI + rot - (i.rotation - rot));
i.col_spacing = -i.col_spacing;
i.scale.y = -i.scale.y;
i.rotation = cadcraft_geom::norm_angle(rot - (i.rotation - rot));
}
for a in &mut i.attribs {
transform_text(&mut a.text, m, s, rot, false);
+12 -84
View File
@@ -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, encode_noted, serve_if_asked, use_own_worker, use_worker};
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, serve_if_asked, use_own_worker, use_worker};
#[cfg(not(no_dwg_bridge))]
mod native {
@@ -544,22 +544,15 @@ 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
/// records of each DXF type were left out, so a save can say so.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten {
/// 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 {
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,
@@ -572,10 +565,9 @@ mod native {
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. }
acadrust::objects::ObjectType::Unknown { 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,
@@ -584,7 +576,7 @@ mod native {
for h in &objects {
doc.objects.remove(h);
}
dropped
unwritable.len() + objects.len()
}
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
@@ -652,19 +644,12 @@ 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).map(|(dwg, _)| dwg)
convert(dxf, None)
}
/// 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,
@@ -722,7 +707,7 @@ mod native {
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), String> {
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, 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
@@ -747,23 +732,21 @@ 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.
let unwritten = guarded(AssertUnwindSafe(|| {
guarded(AssertUnwindSafe(|| {
super::fail_in_test("convert");
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
let unwritten = drop_unwritable(&mut doc);
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())?;
let dwg = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
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}"))?;
Ok((dwg, unwritten))
.map_err(|e| format!("DWG write: {e}"))
}
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
@@ -920,11 +903,7 @@ 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, 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>;
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
// 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.
@@ -971,10 +950,6 @@ 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> {
@@ -996,10 +971,6 @@ 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 {
@@ -1331,47 +1302,4 @@ 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}");
}
}
}
+3 -25
View File
@@ -82,31 +82,15 @@ 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, notes)| (dwg, unwritten_from(&notes))),
None => super::dxf_to_dwg_noted(dxf, version),
Some(reply) => reply?.map(|(dwg, _)| dwg),
None => super::dxf_to_dwg_as(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> {
@@ -257,7 +241,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_noted(b, version).map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())),
ENCODE => super::dxf_to_dwg_as(b, version).map(|dwg| (dwg, Vec::new())),
CRASH => std::process::abort(),
_ => Err(bad()),
}
@@ -283,12 +267,6 @@ 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(&notes), [("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()]));
+4 -5
View File
@@ -234,10 +234,9 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
missing("table style", "Standard", out);
d.table_styles.insert(0, TableStyle::default());
}
// Name sets built once: both tables come from the drawing, so a scan per reference is quadratic.
let linetypes: HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
for l in &mut d.layers {
if !linetypes.contains(&l.linetype.to_ascii_lowercase()) {
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
out.push(issue(
"missingLinetype",
"tables",
@@ -248,8 +247,8 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
l.linetype = "Continuous".into();
}
}
let styles: HashSet<String> = d.text_styles.iter().map(|s| s.name.to_ascii_lowercase()).collect();
let known = |n: &str| styles.contains(&n.to_ascii_lowercase());
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
for s in &mut d.dim_styles {
if !known(&s.text_style) {
out.push(issue(
-1
View File
@@ -22,7 +22,6 @@ mod inquiry;
mod layer;
mod layout;
mod lisp;
mod merge;
mod mleader_content;
mod mleader_edit;
mod modify;
+15 -171
View File
@@ -43,9 +43,6 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["i", "-insert", "ddinsert"])
.params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}")
.interactive(|_| Ok(Box::new(InsertM::default()))),
CommandSpec::new("minsert", "Multiple Insert", run_minsert)
.params("{name, file?: DXF/DWG path (as INSERT), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y], attribs?: {TAG: value}, rows?: 1..32767 (default 1), columns?: 1..32767 (default 1), rowSpacing?, columnSpacing? (along the reference's own axes, not scaled with it)}")
.interactive(|_| Ok(Box::new(InsertM { minsert: true, ..InsertM::default() }))),
CommandSpec::new("attdef", "Define Attributes...", run_attdef)
.menu(&["Draw", "Block", "Define Attributes..."])
.alias(&["att", "-attdef"])
@@ -236,33 +233,6 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option
ins
}
/// MINSERT's rectangular array of a block reference: rows and columns, and the distances between
/// them along the reference's rotated axes (not scaled with it). One row and one column is a plain
/// reference.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Grid {
rows: u32,
cols: u32,
row_spacing: f64,
col_spacing: f64,
}
impl Grid {
const ONE: Grid = Grid { rows: 1, cols: 1, row_spacing: 0.0, col_spacing: 0.0 };
/// The most rows or columns MINSERT takes, as AutoCAD 2024 asks for them.
const MAX: u32 = 32767;
/// A distance is kept only along an axis with more than one copy: AutoCAD 2024 asks for no
/// other and stores 0 for it (one row of three columns: 44 = 20, 45 = 0).
fn new(rows: u32, cols: u32, row_spacing: f64, col_spacing: f64) -> Grid {
Grid { rows, cols, row_spacing: if rows > 1 { row_spacing } else { 0.0 }, col_spacing: if cols > 1 { col_spacing } else { 0.0 } }
}
fn apply(self, ins: &mut Insert) {
(ins.rows, ins.cols, ins.row_spacing, ins.col_spacing) = (self.rows, self.cols, self.row_spacing, self.col_spacing);
}
}
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
/// [`insert_entity`]).
pub(crate) fn insert_with(
@@ -274,12 +244,10 @@ pub(crate) fn insert_with(
base: Option<Vec2>,
values: &serde_json::Map<String, Value>,
) -> Result<Handle> {
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values, Grid::ONE)
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
}
/// [`insert_with`] with a Z scale factor of its own, and MINSERT's grid. A grid has the one set
/// of attributes (AutoCAD 2024 asks for their values once).
#[allow(clippy::too_many_arguments)]
/// [`insert_with`] with a Z scale factor of its own.
fn insert_scaled(
s: &mut Session,
name: &str,
@@ -288,11 +256,9 @@ fn insert_scaled(
rotation: f64,
base: Option<Vec2>,
values: &serde_json::Map<String, Value>,
grid: Grid,
) -> Result<Handle> {
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
let mut ins = insert_entity(&blk, at, scale, rotation, base);
grid.apply(&mut ins);
let m = ins.transform(blk.base.xy());
for e in blk.entities.iter() {
if let EntityKind::AttDef(ad) = &e.kind {
@@ -344,52 +310,24 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
}
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
insert_json(s, p, "insert", Grid::ONE)
}
/// MINSERT's JSON form: INSERT's parameters and the grid.
fn run_minsert(s: &mut Session, p: &Value) -> Result<Value> {
let count = |key: &str| -> Result<u32> {
match p.get(key) {
None | Some(Value::Null) => Ok(1),
Some(v) => v
.as_f64()
.filter(|n| n.fract() == 0.0 && *n >= 1.0 && *n <= f64::from(Grid::MAX))
.map(|n| n as u32)
.ok_or_else(|| bad("minsert", format!("`{key}` must be an integer between 1 and {}", Grid::MAX))),
}
};
// A distance given must be a number: `"15"` is refused, not taken as 0.
let spacing = |key: &str| -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(0.0),
Some(v) => v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("minsert", format!("`{key}` must be a number"))),
}
};
let grid = Grid::new(count("rows")?, count("columns")?, spacing("rowSpacing")?, spacing("columnSpacing")?);
insert_json(s, p, "minsert", grid)
}
/// INSERT's and MINSERT's JSON form.
fn insert_json(s: &mut Session, p: &Value, cmd: &str, grid: Grid) -> Result<Value> {
let p = &insert_file::json_params(s, p)?;
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
let at = point_req(cmd, p, "at")?;
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
let at = point_req("insert", p, "at")?;
// `scale`: one factor, or [x, y] for different X and Y scale factors.
let scale = match p.get("scale") {
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad(cmd, "`scale` must be a number or [x, y]"))?,
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?,
_ => {
let f = f64_or(p, "scale", 1.0);
Vec2::new(f, f)
}
};
if scale.x == 0.0 || scale.y == 0.0 {
return Err(bad(cmd, "scale cannot be 0"));
return Err(bad("insert", "scale cannot be 0"));
}
let rot = f64_or(p, "rotation", 0.0).to_radians();
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
let h = insert_scaled(s, &name, at, Vec3::new(scale.x, scale.y, scale.x), rot, point_param(p, "basePoint"), &vals, grid)?;
if cmd == "insert" && bool_or(p, "explode", false) {
let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?;
if bool_or(p, "explode", false) {
// The EXPLODE command body, not `execute`: INSERT records the one undo step.
let explode = find_command("explode").ok_or_else(|| EngineError::UnknownCommand("explode".into()))?;
return (explode.run)(s, &json!({ "handles": [h.hex()] }));
@@ -622,15 +560,6 @@ enum InsStage {
/// XYZ: the Z scale factor, after X and Y.
ZScale,
Rotation,
/// MINSERT: the number of rows, then of columns.
Rows,
Cols,
/// MINSERT: the distance between rows, or a unit cell's first corner.
RowSpacing,
/// MINSERT: the unit cell's opposite corner (its width between columns, its height between
/// rows).
Cell(Vec2),
ColSpacing,
Attribs,
/// A Verify attribute asked again.
Verify,
@@ -663,12 +592,6 @@ struct InsertM {
explode: bool,
/// The file waiting for the Redefine answer.
file: Option<insert_file::FileRef>,
/// MINSERT: after the rotation, the grid's rows and columns and the distances between them.
minsert: bool,
rows: Option<u32>,
cols: Option<u32>,
row_spacing: Option<f64>,
col_spacing: Option<f64>,
}
impl InsertM {
@@ -678,8 +601,6 @@ impl InsertM {
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
} else if self.rotation.is_none() {
InsStage::Rotation
} else if let Some(g) = self.grid_stage() {
g
} else {
InsStage::Attribs
};
@@ -698,7 +619,7 @@ impl InsertM {
&& self.values.len() >= self.attdefs.len()
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
{
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values, self.grid())?;
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
if self.explode {
insert_file::explode_insert(s, h)?;
}
@@ -707,30 +628,6 @@ impl InsertM {
Ok(Step::Continue)
}
/// MINSERT's next grid prompt, if any is left (as AutoCAD 2024 asks them): the rows, the
/// columns, then the distance between rows when there is more than one row and between
/// columns when there is more than one column. A unit cell gives both distances.
fn grid_stage(&self) -> Option<InsStage> {
if !self.minsert {
return None;
}
let (Some(rows), Some(cols)) = (self.rows, self.cols) else {
return Some(if self.rows.is_none() { InsStage::Rows } else { InsStage::Cols });
};
if rows > 1 && self.row_spacing.is_none() {
Some(InsStage::RowSpacing)
} else if cols > 1 && self.col_spacing.is_none() {
Some(InsStage::ColSpacing)
} else {
None
}
}
/// The grid answered so far (one copy for INSERT).
fn grid(&self) -> Grid {
Grid::new(self.rows.unwrap_or(1), self.cols.unwrap_or(1), self.row_spacing.unwrap_or(0.0), self.col_spacing.unwrap_or(0.0))
}
/// The Verify attributes asked for.
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
self.attdefs.iter().filter(|a| a.3).collect()
@@ -801,18 +698,13 @@ impl InsertM {
}
}
/// A distance typed at MINSERT's grid prompts: any finite number, negative or 0 too.
fn distance(t: &str) -> Result<f64> {
number(t).filter(|v| v.is_finite()).ok_or_else(|| EngineError::Other("Requires a distance.".into()))
}
fn nonzero(t: &str) -> Result<f64> {
number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))
}
impl Interactive for InsertM {
fn name(&self) -> &'static str {
if self.minsert { "MINSERT" } else { "INSERT" }
"INSERT"
}
fn prompt(&self, s: &Session) -> Prompt {
let at = self.at.unwrap_or_default();
@@ -839,16 +731,9 @@ impl Interactive for InsertM {
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
InsStage::Rows => Prompt::new("Enter number of rows (---)", Accept::NUMBER).default("1"),
InsStage::Cols => Prompt::new("Enter number of columns (|||)", Accept::NUMBER).default("1"),
InsStage::RowSpacing => Prompt::new("Enter distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
InsStage::Cell(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
InsStage::ColSpacing => Prompt::new("Specify distance between columns (|||)", Accept::NUMBER),
InsStage::Attribs => {
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
let p = Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT);
// No default shown when there is none ("Value:", AutoCAD 2024), not "<>".
if default.is_empty() { p } else { p.default(default) }
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
}
InsStage::Verify => {
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
@@ -870,9 +755,9 @@ impl Interactive for InsertM {
s.echo(format!("Defined blocks: {}", names.join(", ")));
return Ok(Step::Continue);
}
// `*name`: insert exploded (INSERT only: a grid has no exploded form).
self.explode = !self.minsert && n.starts_with('*');
let n = if self.explode { n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string() } else { n };
// `*name`: insert exploded.
self.explode = n.starts_with('*');
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
match insert_file::file_ref(s, &n) {
Some(f) if f.ask_redefine(s) => {
self.file = Some(f);
@@ -1012,45 +897,6 @@ impl Interactive for InsertM {
});
return self.advance(s);
}
InsStage::Rows | InsStage::Cols => {
let n = match i {
Input::Text(t) => t
.trim()
.parse::<f64>()
.ok()
.filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= f64::from(Grid::MAX))
.map(|v| v as u32)
.ok_or_else(|| EngineError::Other(format!("Requires an integer between 1 and {}.", Grid::MAX)))?,
Input::Enter => 1,
_ => return Ok(Step::Continue),
};
if self.stage == InsStage::Rows {
self.rows = Some(n);
} else {
self.cols = Some(n);
}
return self.advance(s);
}
InsStage::RowSpacing => match i {
Input::Point(a) => self.stage = InsStage::Cell(a),
Input::Text(t) => {
self.row_spacing = Some(distance(&t)?);
return self.advance(s);
}
_ => {}
},
InsStage::Cell(a) => {
if let Input::Point(b) = i {
(self.col_spacing, self.row_spacing) = (Some(b.x - a.x), Some(b.y - a.y));
return self.advance(s);
}
}
InsStage::ColSpacing => {
if let Input::Text(t) = i {
self.col_spacing = Some(distance(&t)?);
return self.advance(s);
}
}
InsStage::Attribs => {
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
let v = match i {
@@ -1091,9 +937,7 @@ impl Interactive for InsertM {
_ => Vec2::new(1.0, 1.0),
});
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
let mut ins = insert_entity(b, at, self.scale3(scale), rot, self.base);
self.grid().apply(&mut ins);
vec![EntityKind::Insert(ins)]
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
}
}
+38 -3
View File
@@ -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 {
+11 -24
View File
@@ -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() });
}
+37 -3
View File
@@ -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 {
+41 -3
View File
@@ -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 {
+11 -7
View File
@@ -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);
+4 -32
View File
@@ -408,24 +408,12 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("layerstate.restore", "`name` is required"))?;
let d = s.doc_mut()?;
let st = d.layer_states.iter().find(|st| st.name == name).cloned().ok_or_else(|| bad("layerstate.restore", "no such layer state"))?;
restore_layers(d, st.layers);
ok()
}
/// Put the saved layers back over the drawing's layers of the same name (without regard to case);
/// saved layers the drawing no longer has are skipped. Both lists can come from a file, so the
/// drawing's layers are looked up in an index rather than scanned per saved layer.
pub(crate) fn restore_layers(d: &mut cadcraft_doc::Drawing, saved: Vec<Layer>) {
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::with_capacity(d.layers.len());
for (i, l) in d.layers.iter().enumerate() {
// The first of names that differ only in case, as `Drawing::layer_mut` finds it.
index.entry(l.name.to_ascii_lowercase()).or_insert(i);
}
for layer in saved {
if let Some(l) = index.get(&layer.name.to_ascii_lowercase()).and_then(|&i| d.layers.get_mut(i)) {
*l = layer;
for saved in st.layers {
if let Some(l) = d.layer_mut(&saved.name) {
*l = saved;
}
}
ok()
}
fn run_state_list(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -473,22 +461,6 @@ mod tests {
use crate::Session;
/// Restoring many saved layers, most of them missing, takes one pass and matches without case.
#[test]
fn restore_layers_matches_by_name_in_linear_time() {
let mut d = cadcraft_doc::Drawing::new_imperial();
d.layers = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("L{i}"))).collect();
let mut saved: Vec<cadcraft_doc::Layer> = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("missing{i}"))).collect();
let mut back = cadcraft_doc::Layer::new("l7");
back.frozen = true;
saved.push(back);
let started = std::time::Instant::now();
super::restore_layers(&mut d, saved);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(d.layer("L7").is_some_and(|l| l.frozen));
assert_eq!(d.layers.len(), 20_000);
}
#[test]
fn layer_states_save_list_restore_delete() {
let mut s = Session::new();
-99
View File
@@ -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))));
}
}
-19
View File
@@ -55,10 +55,6 @@ fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::
if let Some(items) = crate::cmd::array::explode_array(d, e) {
return Some(items);
}
// A MINSERT grid stays whole, as in AutoCAD 2024 (see `explode`).
if minserted(ins) {
return None;
}
let blk = d.block(&ins.block)?;
let m = ins.transform(blk.base.xy());
let stretched = m.similarity_scale().is_none();
@@ -227,24 +223,9 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
}
pub(super) fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
// AutoCAD 2024 refuses a MINSERT grid: "The object is minserted." (Locked ones are left
// without a word, as `explode_with` leaves them.)
let d = s.doc()?;
let grids = hs
.iter()
.filter(|h| !crate::cmd::curves::is_locked(s, **h) && d.entity(**h).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if minserted(i))))
.count();
for _ in 0..grids {
s.echo("The object is minserted.");
}
explode_with(s, hs, explode_kind)
}
/// A block reference with more than one row or column (MINSERT).
fn minserted(i: &cadcraft_doc::Insert) -> bool {
i.rows > 1 || i.cols > 1
}
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
pub(crate) fn explode_with(
+4 -28
View File
@@ -1,8 +1,6 @@
//! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d<a]`), arithmetic and vector
//! arithmetic, numeric and geometric functions; unit conversion; values picked from the drawing.
use std::collections::HashMap;
use cadcraft_geom::Vec2;
use serde_json::{Map, Value, json};
@@ -69,9 +67,6 @@ struct Parser<'a> {
s: &'a [u8],
i: usize,
vars: &'a Map<String, Value>,
/// `vars` by ASCII-lower-cased name (the first of names that differ only in case), so a
/// case-insensitive lookup is not a scan of the map per reference.
folded: HashMap<String, &'a Value>,
depth: usize,
unary_depth: usize,
}
@@ -235,7 +230,7 @@ impl Parser<'_> {
match name.as_str() {
"pi" => Ok(Val::Num(std::f64::consts::PI)),
"e" => Ok(Val::Num(std::f64::consts::E)),
_ => var(self.vars, &self.folded, &name).ok_or_else(|| format!("unknown name `{name}`")),
_ => var(self.vars, &name).ok_or_else(|| format!("unknown name `{name}`")),
}
}
_ => Err("expected a number".into()),
@@ -244,8 +239,8 @@ impl Parser<'_> {
}
/// A variable: a number, or a point as `[x, y]`. Names are case-insensitive.
fn var(vars: &Map<String, Value>, folded: &HashMap<String, &Value>, name: &str) -> Option<Val> {
let v = vars.get(name).or_else(|| folded.get(&name.to_ascii_lowercase()).copied())?;
fn var(vars: &Map<String, Value>, name: &str) -> Option<Val> {
let v = vars.get(name).or_else(|| vars.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?;
if let Some(n) = v.as_f64() {
return Some(Val::Num(n));
}
@@ -416,11 +411,7 @@ pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
if expr.len() > MAX_LEN {
return Err("expression too long".into());
}
let mut folded = HashMap::with_capacity(vars.len());
for (k, v) in vars {
folded.entry(k.to_ascii_lowercase()).or_insert(v);
}
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, folded, depth: 0, unary_depth: 0 };
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0, unary_depth: 0 };
let v = p.expr()?;
if p.peek().is_some() {
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
@@ -596,21 +587,6 @@ mod tests {
eval_val(s, &Map::new())
}
/// Variables are found without regard to case; an exact match wins over a case variant.
/// (Names are read lower-cased, so `X` finds `x` too.)
#[test]
fn variables_ignore_case_and_prefer_exact_names() {
let mut vars = Map::new();
for i in 0..10_000 {
vars.insert(format!("v{i}"), json!(i));
}
vars.insert("X".into(), json!(1));
vars.insert("x".into(), json!(2));
assert!(matches!(eval_val("V9999 + 1", &vars), Ok(Val::Num(n)) if n == 10_000.0));
assert!(matches!(eval_val("x", &vars), Ok(Val::Num(n)) if n == 2.0));
assert!(eval_val("nope", &vars).is_err());
}
#[test]
fn points_and_vectors() {
assert_eq!(ev("[1,2]+[3,4]*2"), Ok(Val::Pt(Vec2::new(7.0, 10.0))));
+7 -1
View File
@@ -185,7 +185,13 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
if let Some(state) = &v.layer_state {
let d = s.doc_mut()?;
match d.layer_states.iter().find(|l| l.name.eq_ignore_ascii_case(state)).cloned() {
Some(saved) => super::super::layer::restore_layers(d, saved.layers),
Some(saved) => {
for layer in saved.layers {
if let Some(l) = d.layer_mut(&layer.name) {
*l = layer;
}
}
}
None => msg.push_str(&format!(" Its layer snapshot \"{state}\" is missing.")),
}
}
+7 -3
View File
@@ -415,9 +415,12 @@ impl Session {
/// Move printed text to the command history: complete lines, and with `all` the rest too.
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
while let Some(i) = env.out.find('\n') {
let line: String = env.out.drain(..=i).collect();
let line = line.trim_end_matches(['\n', '\r']).to_string();
// One pass over the buffer, dropping the printed lines once at the end: draining each line
// from the front shifted the rest every time, quadratic in newline-dense output.
let mut done = 0;
while let Some(i) = env.out.get(done..).and_then(|rest| rest.find('\n')) {
let line = env.out.get(done..done + i).unwrap_or_default().trim_end_matches('\r').to_string();
done += i + 1;
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
// leaving an empty one; after LISP's own line it does print an empty line.
if line.is_empty() && env.printed_at != Some(self.log.len()) {
@@ -427,6 +430,7 @@ impl Session {
self.echo(line);
env.printed_at = Some(self.log.len());
}
env.out.drain(..done);
if all && !env.out.is_empty() {
let line = std::mem::take(&mut env.out);
self.echo(line);
+10
View File
@@ -307,6 +307,16 @@ fn printing() {
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
}
/// Output with many lines moves to the history in one pass, not one buffer shift per line.
#[test]
fn printing_many_lines_is_linear() {
let mut s = session();
let started = std::time::Instant::now();
ok(&mut s, "(setq x \"a\\r\\n\") (repeat 17 (setq x (strcat x x))) (princ x) (princ)");
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
assert_eq!(s.log.last().map(String::as_str), Some("a"));
}
#[test]
fn command_draws_and_one_routine_is_one_undo_step() {
let mut s = session();
-1
View File
@@ -81,7 +81,6 @@ mod malformed_handles;
mod matchprop;
mod measure_distance_snapping;
mod metric_linetypes;
mod minsert;
mod mirror_degenerate_line;
mod mleader_content;
mod mleader_remember;
-218
View File
@@ -1,218 +0,0 @@
//! MINSERT, one block reference repeated in rows and columns, as AutoCAD 2024 makes and edits it
//! (accoreconsole: the prompts, and `entget` of the reference after MINSERT, EXPLODE, MIRROR,
//! SCALE and ROTATE).
use std::f64::consts::PI;
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Insert};
use serde_json::{Value, json};
fn handle(v: &Value) -> String {
v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_owned()
}
/// A session with block B (a line from (0, 0) to (10, 0) and a circle of radius 3 round (5, 5),
/// base point (0, 0)), after the AutoLISP `code`.
fn run(code: &str) -> Session {
let mut s = Session::new();
let a = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
let c = handle(&s.execute("circle", &json!({"center": [5, 5], "radius": 3})).unwrap());
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [a, c]})).unwrap();
s.cmdline(code).unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
s
}
fn inserts(s: &Session) -> Vec<Insert> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Insert(i) => Some(i.clone()),
_ => None,
})
.collect()
}
/// The grid as `entget` gives it: 70 columns, 71 rows, 44 between columns, 45 between rows (a
/// plain reference, one row and one column, as 1/1/0/0).
fn grid(i: &Insert) -> (u32, u32, f64, f64) {
(i.cols, i.rows, i.col_spacing, i.row_spacing)
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn the_grid_is_stored_as_autocad_2024_stores_it() {
// (MINSERT's answers after the block name and insertion point, the grid AutoCAD stored.)
let cases = [
// One row: no distance between rows asked, and 0 stored for it.
("1 \"\" 0 1 3 20.0", (3, 1, 20.0, 0.0)),
("1 \"\" 0 3 1 15.0", (1, 3, 0.0, 15.0)),
// One of each: a plain reference.
("1 \"\" 0 1 1", (1, 1, 0.0, 0.0)),
// A unit cell: its width between columns, its height between rows, signed.
("1 \"\" 0 2 3 '(0.0 0.0) '(25.0 12.0)", (3, 2, 25.0, 12.0)),
("1 \"\" 0 2 3 '(25.0 12.0) '(0.0 0.0)", (3, 2, -25.0, -12.0)),
// Its sides along the drawing's axes, the reference rotated or not.
("1 \"\" 30 2 3 '(0.0 0.0) '(25.0 12.0)", (3, 2, 25.0, 12.0)),
// Typed distances are kept as typed, negative or 0 too.
("1 \"\" 0 2 2 -15.0 -20.0", (2, 2, -20.0, -15.0)),
("1 \"\" 0 2 2 0.0 20.0", (2, 2, 20.0, 0.0)),
// The distances are not scaled with the reference.
("2 \"\" 30 2 2 15.0 20.0", (2, 2, 20.0, 15.0)),
];
for (answers, want) in cases {
let s = run(&format!("(command \"_.MINSERT\" \"B\" '(20.0 10.0) {answers})"));
let [i] = inserts(&s).try_into().unwrap();
let got = grid(&i);
assert!(got.0 == want.0 && got.1 == want.1 && near(got.2, want.2) && near(got.3, want.3), "{answers}: {got:?}");
assert!(near(i.insert.x, 20.0) && near(i.insert.y, 10.0));
}
let s = run("(command \"_.MINSERT\" \"B\" '(20.0 10.0) 2 \"\" 30 2 2 15.0 20.0)");
let [i] = inserts(&s).try_into().unwrap();
assert!(near(i.scale.x, 2.0) && near(i.scale.y, 2.0) && near(i.rotation, PI / 6.0));
}
#[test]
fn the_prompts_follow_autocad_2024() {
let mut s = Session::new();
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap();
// Each prompt, and what was written to the command line on the way to it.
let mut asked = Vec::new();
for input in ["minsert", "B", "20,10", "1", "", "0", "0", "2", "3", "x", "15", "20"] {
let logged = s.log.len();
s.cmdline(input).unwrap();
asked.extend(s.log.iter().skip(logged).filter(|l| l.starts_with("Requires")).cloned());
if let Some(p) = s.current_prompt() {
asked.push(p.display());
}
}
// The grid's prompts and messages are AutoCAD 2024's; the ones before are INSERT's.
assert_eq!(
asked,
[
"Enter block name or [?]:",
"Specify insertion point or [Basepoint/Scale/X/Y/Z/Rotate]:",
"Enter X scale factor, specify opposite corner or [Corner/XYZ] <1>:",
"Enter Y scale factor <use X scale factor>:",
"Specify rotation angle <0>:",
"Enter number of rows (---) <1>:",
"Requires an integer between 1 and 32767.",
"Enter number of rows (---) <1>:",
"Enter number of columns (|||) <1>:",
"Enter distance between rows or specify unit cell (---):",
"Requires a distance.",
"Enter distance between rows or specify unit cell (---):",
"Specify distance between columns (|||):",
]
);
assert!(s.running.is_none());
let [i] = inserts(&s).try_into().unwrap();
assert_eq!(grid(&i), (3, 2, 20.0, 15.0));
}
#[test]
fn counts_run_from_1_to_32767() {
// AutoCAD 2024: "Requires an integer between 1 and 32767."
let mut s = Session::new();
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap();
for input in ["minsert", "B", "0,0", "1", "", "0", "32768", "32767", "1", "2"] {
s.cmdline(input).unwrap();
}
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(s.log.iter().filter(|l| l.starts_with("Requires an integer between 1 and 32767.")).count(), 1, "{:?}", s.log);
let [i] = inserts(&s).try_into().unwrap();
assert_eq!(grid(&i), (1, 32767, 0.0, 2.0));
}
#[test]
fn attribute_values_are_asked_once_after_the_grid() {
let mut s = Session::new();
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
let a = handle(&s.execute("attdef", &json!({"tag": "TAG", "prompt": "Value", "at": [0, 2], "height": 1})).unwrap());
s.execute("-block", &json!({"name": "BA", "base": [0, 0], "handles": [l, a]})).unwrap();
for input in ["minsert", "BA", "0,700", "1", "", "0", "5", "4", "15", "20"] {
s.cmdline(input).unwrap();
}
// After the grid, with no "<>" for an empty default (AutoCAD 2024: "Value: hello").
assert_eq!(s.current_prompt().unwrap().display(), "Value:");
s.cmdline("hello").unwrap();
assert!(s.running.is_none());
let [i] = inserts(&s).try_into().unwrap();
assert_eq!(grid(&i), (4, 5, 20.0, 15.0));
assert_eq!(i.attribs.iter().map(|a| a.text.value.as_str()).collect::<Vec<_>>(), ["hello"]);
}
#[test]
fn explode_leaves_a_grid_whole() {
let s = run("(progn (command \"_.MINSERT\" \"B\" '(20.0 10.0) 1 \"\" 0 2 2 15.0 20.0) (command \"_.EXPLODE\" (entlast) \"\"))");
let [i] = inserts(&s).try_into().unwrap();
assert_eq!(grid(&i), (2, 2, 20.0, 15.0));
assert_eq!(s.doc().unwrap().model.iter().count(), 1);
assert!(s.log.iter().any(|l| l.contains("The object is minserted.")), "{:?}", s.log);
}
#[test]
fn edits_carry_the_grid_as_in_autocad_2024() {
let made = "(command \"_.MINSERT\" \"B\" '(20.0 10.0) 1 \"\" 0 2 2 15.0 20.0)";
// (the edit, the copy it leaves last: insertion point, X and Y scale, rotation, grid)
let cases = [
("(command \"_.SCALE\" (entlast) \"\" '(20.0 10.0) 2)", ([20.0, 10.0], [2.0, 2.0], 0.0, (2, 2, 40.0, 30.0))),
("(command \"_.ROTATE\" (entlast) \"\" '(20.0 10.0) 30)", ([20.0, 10.0], [1.0, 1.0], PI / 6.0, (2, 2, 20.0, 15.0))),
("(command \"_.MOVE\" (entlast) \"\" '(20.0 10.0) '(25.0 13.0))", ([25.0, 13.0], [1.0, 1.0], 0.0, (2, 2, 20.0, 15.0))),
// Mirrored about the vertical through the insertion point, and about the horizontal: X
// scale -1, and the columns run the other way.
("(command \"_.MIRROR\" (entlast) \"\" '(20.0 10.0) '(20.0 30.0) \"_N\")", ([20.0, 10.0], [-1.0, 1.0], 0.0, (2, 2, -20.0, 15.0))),
("(command \"_.MIRROR\" (entlast) \"\" '(20.0 10.0) '(40.0 10.0) \"_N\")", ([20.0, 10.0], [-1.0, 1.0], PI, (2, 2, -20.0, 15.0))),
];
for (edit, (at, scale, rot, want)) in cases {
let s = run(&format!("(progn {made} {edit})"));
let i = inserts(&s).pop().unwrap();
let got = grid(&i);
assert!(
near(i.insert.x, at[0])
&& near(i.insert.y, at[1])
&& near(i.scale.x, scale[0])
&& near(i.scale.y, scale[1])
&& near(i.rotation, rot)
&& got.0 == want.0
&& got.1 == want.1
&& near(got.2, want.2)
&& near(got.3, want.3),
"{edit}: {i:?}"
);
}
}
#[test]
fn the_json_form_makes_the_same_grid() {
let mut s = Session::new();
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap();
s.execute("minsert", &json!({"name": "B", "at": [20, 10], "rows": 2, "columns": 3, "rowSpacing": 15, "columnSpacing": 20})).unwrap();
// One row: its distance is not kept.
s.execute("minsert", &json!({"name": "B", "at": [20, 100], "rows": 1, "columns": 3, "rowSpacing": 15, "columnSpacing": 20})).unwrap();
let got: Vec<_> = inserts(&s).iter().map(grid).collect();
assert_eq!(got, [(3, 2, 20.0, 15.0), (3, 1, 20.0, 0.0)]);
for bad in [
json!({"name": "B", "at": [0, 0], "rows": 0}),
json!({"name": "B", "at": [0, 0], "columns": 32768}),
json!({"name": "B", "at": [0, 0], "rows": 2.5}),
// A distance that is not a number is refused, not taken as 0.
json!({"name": "B", "at": [0, 0], "rows": 2, "rowSpacing": "15"}),
json!({"name": "B", "at": [0, 0], "columns": 2, "columnSpacing": true}),
] {
assert!(s.execute("minsert", &bad).is_err(), "{bad}");
}
// A whole number written as 2.0 is a count; `explode` is INSERT's and leaves the grid made.
let r = s.execute("minsert", &json!({"name": "B", "at": [0, 200], "rows": 2.0, "rowSpacing": 15, "explode": true})).unwrap();
assert!(r["handle"].is_string(), "{r}");
assert_eq!(inserts(&s).last().map(grid), Some((1, 2, 0.0, 15.0)));
}
+3 -32
View File
@@ -178,32 +178,14 @@ pub(super) fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx:
(paper_block_for, first_layout)
}
/// The drawing's layer names (lower-cased), so adding the layer an entity names is a set lookup
/// rather than a scan of the layer table per entity: many distinct names made that quadratic
/// (CWE-407).
struct LayerSet(std::collections::HashSet<String>);
impl LayerSet {
fn new(d: &Drawing) -> Self {
Self(d.layers.iter().map(|l| l.name.to_ascii_lowercase()).collect())
}
/// [`Drawing::ensure_layer`] through the set.
fn ensure(&mut self, d: &mut Drawing, name: &str) {
if self.0.insert(name.to_ascii_lowercase()) {
d.layers.push(cadcraft_doc::Layer::new(name));
}
}
}
/// Place the BLOCKS entities: model and paper space blocks go to their space, the rest become
/// block definitions.
pub(super) fn place_blocks(d: &mut Drawing, blocks: Vec<BlockDef>, rx: &mut Rx, paper_block_for: &HashMap<String, String>, first_layout: &str) {
let mut layers = LayerSet::new(d);
for (name, base, description, ents, xref) in blocks {
let up = name.to_ascii_uppercase();
if up == "*MODEL_SPACE" {
for (_, _, e) in ents {
layers.ensure(d, &e.common.layer);
d.ensure_layer(&e.common.layer);
d.model.push(e);
}
continue;
@@ -259,9 +241,8 @@ pub(super) fn place_entities(
paper_block_for: &HashMap<String, String>,
first_layout: &str,
) {
let mut layers = LayerSet::new(d);
for (owner, paper, e) in entities {
layers.ensure(d, &e.common.layer);
d.ensure_layer(&e.common.layer);
let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase());
let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE"));
if to_paper {
@@ -333,17 +314,7 @@ pub(super) fn apply_sortents(
paper_for: &HashMap<String, String>,
first_layout: &str,
) {
// Each sort rebuilds its whole space, so a space named by many tables is sorted once, by the
// last of them (which decided its order anyway); repeated tables made import quadratic.
let target = |owner: &String| {
let up = brs.get(owner)?.to_ascii_uppercase();
Some(if up.starts_with("*PAPER_SPACE") { paper_for.get(&up).cloned().unwrap_or_else(|| first_layout.to_string()) + "\0paper" } else { up })
};
let last: HashMap<String, usize> = tables.iter().enumerate().filter_map(|(i, (owner, _))| Some((target(owner)?, i))).collect();
for (i, (owner, map)) in tables.iter().enumerate() {
if target(owner).and_then(|t| last.get(&t)) != Some(&i) {
continue;
}
for (owner, map) in tables {
let Some(name) = brs.get(owner) else { continue };
let up = name.to_ascii_uppercase();
// A sort handle of 0 draws last (ezdxf `reorder`, which notes AutoCAD defines it, undocumented).
+1 -3
View File
@@ -299,9 +299,7 @@ pub(super) fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table {
// Large declared sizes must be backed by cell records (no allocation from a bare count).
let n_cells = tail.iter().filter(|x| x.code == 171).count();
let declared = rows.saturating_mul(cols);
// A table with no rows or no columns has no cells: keep neither dimension, so its row heights
// and column widths (allocated per dimension, outside the cell budget) are not allocated.
if declared == 0 || declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) {
if declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) {
rows = 0;
cols = 0;
}
+3 -10
View File
@@ -110,9 +110,6 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
// appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer
// names make import quadratic (CWE-407).
let mut layer_index: HashMap<String, usize> = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect();
// The same for linetypes (first definition wins) and text styles (last definition wins).
let mut linetype_names: std::collections::HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
let mut style_index: HashMap<String, usize> = d.text_styles.iter().enumerate().map(|(i, s)| (s.name.to_ascii_lowercase(), i)).collect();
for (kind, tg) in recs {
let t = T(&tg);
match kind.as_str() {
@@ -162,10 +159,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
}
"LTYPE" => {
let name = t.s(2).unwrap_or_default();
if name.is_empty() || linetype_names.contains(&name.to_ascii_lowercase()) || d.linetypes.len() >= MAX_OBJECTS {
if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS {
continue;
}
linetype_names.insert(name.to_ascii_lowercase());
let pattern = ltype_pattern(&tg, &ltype_styles);
d.linetypes.push(Linetype { name, description: t.s(3).unwrap_or_default(), pattern });
}
@@ -188,12 +184,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
vertical: t.i(70).unwrap_or(0) & 4 != 0,
annotative: annotative(&tg),
};
match style_index.get(&name.to_ascii_lowercase()).and_then(|&i| d.text_styles.get_mut(i)) {
match d.text_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = st,
None => {
style_index.insert(name.to_ascii_lowercase(), d.text_styles.len());
d.text_styles.push(st);
}
None => d.text_styles.push(st),
}
}
// The first *ACTIVE viewport holds the current snap/grid settings.
+8 -30
View File
@@ -14,8 +14,6 @@ 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;
@@ -71,25 +69,19 @@ 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> {
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>>)> {
let name = name.as_ref();
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, Some(dxf)));
return Ok(d);
}
let d = match (Format::from_path(name), ext(name).as_str()) {
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
@@ -119,29 +111,15 @@ 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 => write_dwg(d, opts.version.unwrap_or_else(|| version.max(Version::R2004))).map(|(dwg, _)| dwg),
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)
}
_ 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
-212
View File
@@ -1,212 +0,0 @@
//! 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
}
-695
View File
@@ -1,695 +0,0 @@
//! 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"));
}
}
-566
View File
@@ -1,566 +0,0 @@
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"));
}
-58
View File
@@ -337,64 +337,6 @@ fn entity_transparency_roundtrips_as_group_440() {
assert_eq!(d.model.iter().map(|e| e.common.transparency).collect::<Vec<_>>(), [Transparency::ByLayer, Transparency::Percent(90)]);
}
/// Several SORTENTSTABLEs for one space sort it once, in the last table's order.
#[test]
fn repeated_sortents_tables_use_the_last() {
let table =
|a: u32, b: u32| format!("0\nSORTENTSTABLE\n5\n{:X}\n100\nAcDbSortentsTable\n330\n1F\n331\nA\n5\n{a:X}\n331\nB\n5\n{b:X}\n", 0x100 + a);
let mut t = String::from(
"0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n0\nBLOCK_RECORD\n5\n1F\n2\n*Model_Space\n0\nENDTAB\n0\nENDSEC\n\
0\nSECTION\n2\nENTITIES\n0\nLINE\n5\nA\n330\n1F\n10\n0\n20\n0\n11\n1\n21\n1\n0\nLINE\n5\nB\n330\n1F\n10\n0\n20\n0\n11\n2\n21\n2\n0\nENDSEC\n\
0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n",
);
for _ in 0..1000 {
t.push_str(&table(2, 1));
}
t.push_str(&table(1, 2));
t.push_str("0\nENDSEC\n0\nEOF\n");
let d = read(t.as_bytes(), "s.dxf").unwrap();
assert_eq!(d.model.iter().map(|e| e.handle.0).collect::<Vec<_>>(), [0xA, 0xB]);
let reversed = t.replacen(&table(1, 2), &table(2, 1), 1);
let d = read(reversed.as_bytes(), "s.dxf").unwrap();
assert_eq!(d.model.iter().map(|e| e.handle.0).collect::<Vec<_>>(), [0xB, 0xA]);
}
/// A table declaring 10 000 rows and no columns (or the reverse) is read as an empty table.
#[test]
fn table_with_an_empty_dimension_allocates_nothing() {
for (rows, cols) in [(10_000, 0), (0, 10_000)] {
let t = format!("0\nSECTION\n2\nENTITIES\n0\nACAD_TABLE\n100\nAcDbTable\n91\n{rows}\n92\n{cols}\n0\nENDSEC\n0\nEOF\n");
let d = read(t.as_bytes(), "t.dxf").unwrap();
let tables: Vec<_> = d.model.iter().filter_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t) } else { None }).collect();
assert_eq!(tables.len(), 1, "the table is read");
for t in tables {
assert!(t.row_heights.is_empty() && t.col_widths.is_empty() && t.cells.is_empty());
}
}
}
/// Many distinct layer, linetype and style names (in the tables and on entities) read in linear
/// time, with the duplicate rules kept: first LTYPE wins, last STYLE wins, layers case-insensitive.
#[test]
fn many_distinct_symbol_names_read_quickly() {
const N: usize = 40_000;
let mut t = String::from("0\nSECTION\n2\nTABLES\n");
for i in 0..N {
t.push_str(&format!("0\nLTYPE\n2\nLT{i}\n3\nfirst\n0\nSTYLE\n2\nST{i}\n40\n1\n"));
}
t.push_str("0\nLTYPE\n2\nlt0\n3\nsecond\n0\nSTYLE\n2\nst0\n40\n2\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n");
for i in 0..N {
t.push_str(&format!("0\nLINE\n8\nL{i}\n10\n0\n20\n0\n11\n1\n21\n1\n"));
}
t.push_str("0\nLINE\n8\nl0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n");
let started = std::time::Instant::now();
let d = read(t.as_bytes(), "many.dxf").unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(20), "took {:?}", started.elapsed());
assert!(d.layers.len() > N && d.layers.len() < N + 3, "{}", d.layers.len());
assert_eq!(d.linetype("LT0").map(|l| l.description.as_str()), Some("first"));
assert_eq!(d.text_style("ST0").map(|s| s.height), Some(2.0));
}
#[test]
fn hostile_dxf_does_not_panic() {
for t in [
-20
View File
@@ -1460,23 +1460,3 @@ menu Modify Ändra
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
Layouts Layouter
Associate Associera
Change Ändra
Change Properties Ändra egenskaper
Save Layout as Template Spara layout som mall
Units Enheter
Backup copy Säkerhetskopia
Create backup copy with each save Skapa en säkerhetskopia vid varje sparande
Font Typsnitt
Save changes to block "{name}"? Spara ändringar i blocket "{name}"?
Backup copies are off, so the current file will be replaced. Säkerhetskopior är avstängda, så den aktuella filen kommer att ersättas.
DXF 2010 and earlier keep them. Use Save As to keep them in the drawing. DXF 2010 och tidigare behåller dem. Använd Spara som för att behålla dem i ritningen.
DXF keeps them. Use Save As to keep them in the drawing. DXF behåller dem. Använd Spara som för att behålla dem i ritningen.
Save Anyway Spara ändå
Save cancelled: {path} was not changed. Sparandet avbröts: {path} ändrades inte.
Saving {name} will remove content Om du sparar {name} tas innehåll bort
The current file doesn't look whole, so {name} stays as it is and the current file will be replaced. Den aktuella filen verkar inte vara hel, så {name} lämnas som den är och den aktuella filen kommer att ersättas.
The current file is kept as {name} until the next save. Den aktuella filen sparas som {name} till nästa sparande.
This format can't keep these objects yet: {objects}. Det här formatet kan ännu inte behålla följande objekt: {objects}.
{count} more types {count} typer till
Can't render this file because it contains an unexpected character in line 1472 and column 24.
-1
View File
@@ -533,7 +533,6 @@
Multiline Text Chữ nhiều dòng
Multiline Text... Chữ nhiều dòng...
Multiple Nhiều
Multiple Insert Chèn nhiều
Multiple Point Nhiều điểm
My Của tôi
My properties: not available yet Thuộc tính của tôi: chưa khả dụng
1 # CADCraft Vietnamese interface catalog — original translations by @2ez4gcx with Claude Code (AI-assisted).
533
534
535
536
537
538
-1
View File
@@ -448,7 +448,6 @@
Multiline Text 多行文字
Multiline Text... 多行文字...
Multiple 多个
Multiple Insert 多重插入
Multiple Point 多点
My 我的
My properties: not available yet 我的属性:暂不可用
1 # CADCraft Simplified Chinese interface catalog — original translations.
448
449
450
451
452
453
+1 -7
View File
@@ -570,13 +570,7 @@ impl CadApp {
/// Add the files whose background read has finished; report the ones that failed as a failed
/// OPEN is reported. Called every frame.
pub fn finish_opening(&mut self) {
for (path, outcome) in self.opening.poll() {
// A recent file that is gone can never be opened again: drop it from the list. A path
// whose existence can't be checked (a folder that denies access) stays.
if outcome.is_err() && matches!(path.try_exists(), Ok(false)) {
let path = path.to_string_lossy();
self.ui.recent_files.retain(|p| *p != path);
}
for (_, outcome) in self.opening.poll() {
let r = match outcome {
Ok(f) => self.session.finish_open(f).map_err(|e| e.to_string()),
Err(e) => Err(e),
-12
View File
@@ -135,15 +135,3 @@ fn start_page_lists_recent_files_and_opens_one_on_click() {
assert_eq!(app.session.state().ok().and_then(|s| s.path.clone()), Some(plan.clone()));
assert_eq!(app.ui.recent_files, [plan, section]);
}
#[test]
fn a_recent_file_that_no_longer_exists_is_dropped_when_opening_it_fails() {
let (_dir, plan, _section) = drawings("missing");
let mut app = CadApp::new(Session::empty(), Services::default());
let gone = "/nonexistent/dir/gone.dxf".to_string();
app.ui.recent_files = vec![gone.clone(), plan.clone()];
let id = open_recent(&app).into_iter().find(|(l, _)| l == "gone.dxf").map(|(_, id)| id).unwrap_or_default();
menus::activate(&mut app, &id);
settle(&mut app);
assert_eq!(app.ui.recent_files, [plan], "the stale entry is removed, the others stay");
}
+6 -48
View File
@@ -23,10 +23,8 @@ 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 [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
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
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>...
@@ -43,7 +41,7 @@ fn main() -> ExitCode {
Some("test") => cmd_test(),
Some("i18n") => cmd_i18n(),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(&rest),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("parity") => parity::run(&root()),
Some("ico") => ico::run(&rest),
@@ -228,47 +226,7 @@ fn cmd_ci() -> Result<(), String> {
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(" ")))
fn cmd_corpus(_download: bool) -> Result<(), String> {
println!("Test corpora live in storytold/cadcraft-corpus (planned); never commit large binaries here.");
Ok(())
}