mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 08:52:05 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82a4f9b97 |
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬es))),
|
||||
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(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip() {
|
||||
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -22,7 +22,6 @@ mod inquiry;
|
||||
mod layer;
|
||||
mod layout;
|
||||
mod lisp;
|
||||
mod merge;
|
||||
mod mleader_content;
|
||||
mod mleader_edit;
|
||||
mod modify;
|
||||
|
||||
@@ -157,7 +157,7 @@ fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), file::MAX_DRAWING_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
+15
-171
@@ -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))]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
@@ -85,7 +85,8 @@ fn read_drawing(path: &str) -> Result<Drawing> {
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("insert", "paths are not available on the web"))
|
||||
@@ -102,7 +103,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 +137,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 +153,35 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
merge(&mut d.mline_styles, &src.mline_styles, |t| &t.name);
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
||||
/// already has (or the block being defined) keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.insert(defining.to_ascii_uppercase());
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
|
||||
//! block empty with a status saying why; nothing here stops the host drawing from opening.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -340,15 +339,14 @@ fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &
|
||||
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
|
||||
Ok((x, file)) => match import(d, &key, &x, cx) {
|
||||
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
|
||||
Err(e) => {
|
||||
// Whatever made it in before the failure goes again. A fetch that failed imported
|
||||
// nothing, so it doesn't pay for a second scan of the block table.
|
||||
clear(d, &key);
|
||||
(XrefStatus::Unresolved, e)
|
||||
}
|
||||
Err(e) => (XrefStatus::Unresolved, e),
|
||||
},
|
||||
Err(e) => e,
|
||||
};
|
||||
if status != XrefStatus::Loaded {
|
||||
// Whatever made it in before the failure goes again.
|
||||
clear(d, &key);
|
||||
}
|
||||
set_status(d, &key, status);
|
||||
(status, detail)
|
||||
}
|
||||
@@ -392,15 +390,9 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
return Err(format!("more than {MAX_XREF_OBJECTS} objects"));
|
||||
}
|
||||
let dep = |sym: Sym, n: &str| (!shared(sym, n)).then(|| format!("{key}|{n}"));
|
||||
// The host's names per table, looked up in a set built once: both drawings are hostile input,
|
||||
// so a scan per imported record would be quadratic.
|
||||
fn names<T>(all: &[T], name: fn(&T) -> &str) -> HashSet<String> {
|
||||
all.iter().map(|t| name(t).to_ascii_uppercase()).collect()
|
||||
}
|
||||
let mut have = names(&d.layers, |l| &l.name);
|
||||
for l in &x.layers {
|
||||
if let Some(n) = dep(Sym::Layer, &l.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.layer(&n).is_none()
|
||||
{
|
||||
let mut l = l.clone();
|
||||
l.name = n;
|
||||
@@ -408,44 +400,39 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.linetypes, |t| &t.name);
|
||||
for t in &x.linetypes {
|
||||
if let Some(n) = dep(Sym::Linetype, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.linetype(&n).is_none()
|
||||
{
|
||||
d.linetypes.push(cadcraft_doc::Linetype { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.text_styles, |t| &t.name);
|
||||
for t in &x.text_styles {
|
||||
if let Some(n) = dep(Sym::Style, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.text_style(&n).is_none()
|
||||
{
|
||||
d.text_styles.push(cadcraft_doc::TextStyle { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.dim_styles, |t| &t.name);
|
||||
for t in &x.dim_styles {
|
||||
if let Some(n) = dep(Sym::DimStyle, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.dim_style(&n).is_none()
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.dim_styles.push(cadcraft_doc::DimStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.mleader_styles, |t| &t.name);
|
||||
for t in &x.mleader_styles {
|
||||
if let Some(n) = dep(Sym::MLeaderStyle, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& !d.mleader_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.mleader_styles.push(cadcraft_doc::MLeaderStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.table_styles, |t| &t.name);
|
||||
for t in &x.table_styles {
|
||||
if let Some(n) = dep(Sym::TableStyle, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& !d.table_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
{
|
||||
d.table_styles.push(cadcraft_doc::TableStyle { name: n, ..t.clone() });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
|
||||
//! end can put it on the system clipboard and paste it into another CADCraft window.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
|
||||
@@ -279,14 +279,20 @@ fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
|
||||
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
|
||||
/// their name in `defs` to their name in `d`.
|
||||
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
use crate::cmd::merge::merge_named as add;
|
||||
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
add(&mut d.layers, &defs.layers, |l| &l.name);
|
||||
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
|
||||
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
|
||||
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
|
||||
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
|
||||
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
|
||||
let renames = crate::cmd::merge::block_renames(d, defs, None);
|
||||
let renames = block_names(d, defs);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = defs.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -302,6 +308,34 @@ fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
renames
|
||||
}
|
||||
|
||||
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
|
||||
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
|
||||
/// already has keeps `d`'s definition (not copied).
|
||||
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
|
||||
for (name, b) in &defs.blocks {
|
||||
let upper = up(name);
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(up(&to));
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
|
||||
/// objects of the source drawing (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! stays I/O-agnostic; the JSON forms never open dialogs (the UI asks for files when these
|
||||
//! commands come from a menu, the tool bar or the command line).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Rgb;
|
||||
@@ -166,7 +166,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let (layers, styles) = merge_tables(d, src);
|
||||
let renames = crate::cmd::merge::block_renames(d, src, None);
|
||||
let renames = block_names(d, src);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -204,7 +204,8 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("import", "paths are not available on the web; pass `data`"))
|
||||
@@ -213,7 +214,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 +233,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 {
|
||||
|
||||
@@ -24,7 +24,7 @@ pub use template::{qnew_template, set_qnew_template};
|
||||
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
|
||||
/// through [`crate::capped_read`] rather than buffering to EOF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
pub(crate) const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
/// Largest `.scr` script accepted from a local path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
||||
|
||||
@@ -110,7 +110,7 @@ fn other_drawing(s: &Session, p: &Value) -> Result<(Arc<Drawing>, String)> {
|
||||
fn read_path(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), super::MAX_DRAWING_BYTES).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("compare", "paths are not available on the web: open the other drawing and compare with it by name (`document`)"))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -73,7 +73,7 @@ fn read_template(path: &str) -> Result<Drawing> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_bytes(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), super::MAX_DRAWING_BYTES).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -303,11 +303,9 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(_path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
|
||||
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
|
||||
}
|
||||
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
let bytes = crate::capped_read::read_capped(std::path::Path::new(_path), cadcraft_doc::MAX_TABLE_FILE)
|
||||
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
String::from_utf8(bytes).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad(cmd, "files can't be read here: pass `text`"));
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Largest linked file read (bytes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_LINK_BYTES: u64 = 8 << 20;
|
||||
const MAX_LINK_BYTES: usize = 8 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -238,11 +238,9 @@ fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
|
||||
let path = file.display();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
if meta.len() > MAX_LINK_BYTES {
|
||||
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
|
||||
}
|
||||
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
// The limit is enforced on the bytes read, not on reported metadata, so a device or FIFO
|
||||
// that misreports its length can't be buffered to EOF.
|
||||
let bytes = crate::capped_read::read_capped(file, MAX_LINK_BYTES).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
|
||||
}
|
||||
@@ -370,6 +368,16 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A linked path naming an endless device is refused instead of buffered to EOF.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn link_to_endless_device_is_rejected() {
|
||||
let dev = std::path::Path::new("/dev/zero");
|
||||
if dev.exists() {
|
||||
assert!(read_link("datalink", dev).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_parsing() {
|
||||
assert_eq!(parse_csv("a,b\r\n\"c,d\",\"e\"\"f\"\n", ','), vec![vec!["a", "b"], vec!["c,d", "e\"f"]]);
|
||||
|
||||
@@ -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))));
|
||||
|
||||
@@ -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.")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, <ype_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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -448,7 +448,6 @@
|
||||
Multiline Text 多行文字
|
||||
Multiline Text... 多行文字...
|
||||
Multiple 多个
|
||||
Multiple Insert 多重插入
|
||||
Multiple Point 多点
|
||||
My 我的
|
||||
My properties: not available yet 我的属性:暂不可用
|
||||
|
||||
|
@@ -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),
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user