mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 00:42:46 -06:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b04273e897 | ||
|
|
5ed192555c | ||
|
|
4c00f8b9e3 | ||
|
|
7b6be7835f | ||
|
|
455b2a87cb | ||
|
|
1b7c705fac | ||
|
|
77142aa79e | ||
|
|
31e6e16bc0 | ||
|
|
a4ff30d11e | ||
|
|
9d83f21910 | ||
|
|
7caf369555 | ||
|
|
f691d0b349 | ||
|
|
e24c4553d2 | ||
|
|
c602a402c3 | ||
|
|
d180b28ef6 | ||
|
|
2e9761249a | ||
|
|
c956baa349 | ||
|
|
997f152cea | ||
|
|
0d3b3d21c0 | ||
|
|
4482316306 | ||
|
|
9107479ade | ||
|
|
a13870f376 | ||
|
|
a6ba747329 |
Generated
+19
-23
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.6.8"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb201e11e1c75584e4deefc2b30afb83e2f34d16338733439d38664cb9599929"
|
||||
checksum = "8923c99ea1f151cb7fc17ce2fc14f8b1e330fa22611e535602d985c0cc3c89a2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -729,9 +729,9 @@ name = "cadcraft-fonts"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust 0.15.0",
|
||||
"harfrust 0.14.0",
|
||||
"icu_segmenter",
|
||||
"skrifa 0.49.0",
|
||||
"skrifa 0.48.0",
|
||||
"unicode-bidi",
|
||||
]
|
||||
|
||||
@@ -1560,9 +1560,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.44"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
@@ -2130,15 +2130,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "harfrust"
|
||||
version = "0.15.0"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "865dcc4105d51c323d8fedbb2d75b5e9c8ecddf5613f74c82f851d0afbc97114"
|
||||
checksum = "86aa776d6d32b072119e1f1650f4db843ff4382a4aaa6a2c9c4e35703cd377cb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"read-fonts 0.46.0",
|
||||
"read-fonts 0.45.1",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2812,9 +2811,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lyon_geom"
|
||||
@@ -3906,15 +3905,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.46.0"
|
||||
version = "0.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdaa4fffe9ff3ca2c353fa9729792805b9b3e324695ff5a081f860612e81b330"
|
||||
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash 0.2.0",
|
||||
"font-types 0.12.7",
|
||||
"once_cell",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
@@ -4000,11 +3998,12 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.3"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
|
||||
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
@@ -4370,15 +4369,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.49.0"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7459fb60888ad9a5f367dd293fa2fb9df142cb451aa6bfd6cd66a7d6a9b739d"
|
||||
checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"once_cell",
|
||||
"read-fonts 0.46.0",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
"read-fonts 0.45.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-2
@@ -46,10 +46,10 @@ rayon = "1"
|
||||
# rendering / formats
|
||||
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] }
|
||||
lyon_tessellation = "1.0.22"
|
||||
skrifa = "0.49"
|
||||
skrifa = "0.48"
|
||||
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; egui's epaint is still on 0.12,
|
||||
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
|
||||
harfrust = "0.15"
|
||||
harfrust = "0.14"
|
||||
unicode-bidi = "0.3.18"
|
||||
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
|
||||
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
|
||||
|
||||
@@ -81,6 +81,7 @@ pub(crate) enum Command {
|
||||
Mcp(McpArgs),
|
||||
Perf(PerfArgs),
|
||||
Sample(SampleArgs),
|
||||
Roundtrip(RoundtripArgs),
|
||||
}
|
||||
|
||||
/// Print a drawing's summary as JSON
|
||||
@@ -308,6 +309,40 @@ fn perf_target(p: PathBuf) -> PerfTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open each DXF/DWG, save it as DXF and DWG, reopen and compare; writes report.md and report.json
|
||||
///
|
||||
/// FOLDER defaults to $CADCRAFT_CORPUS_DIR. With CADCRAFT_CORPUS_STRICT=1 it fails when a file
|
||||
/// doesn't open, save or reopen, or loses records on save.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RoundtripArgs {
|
||||
/// A drawing, or a folder of them
|
||||
#[arg(value_name = "FILE|FOLDER")]
|
||||
pub(crate) path: Option<PathBuf>,
|
||||
/// Where the report goes (default: `report` beside the files)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub(crate) out: Option<PathBuf>,
|
||||
/// Keep the saved files, in `saved` under the report folder
|
||||
#[arg(long)]
|
||||
pub(crate) keep_saved: bool,
|
||||
/// Save only in each file's own format
|
||||
#[arg(long)]
|
||||
pub(crate) same_format: bool,
|
||||
/// Don't count what each file holds (faster; no "read lost" column)
|
||||
#[arg(long)]
|
||||
pub(crate) no_census: bool,
|
||||
/// How far numbers may move and still count as equal
|
||||
#[arg(long, value_name = "T", default_value_t = cadcraft_io::roundtrip::Options::default().abs_tol, value_parser = tolerance)]
|
||||
pub(crate) tolerance: f64,
|
||||
}
|
||||
|
||||
/// `--tolerance`: a finite number, 0 or more.
|
||||
fn tolerance(v: &str) -> Result<f64, ValueError> {
|
||||
match v.parse::<f64>() {
|
||||
Ok(t) if t.is_finite() && t >= 0.0 => Ok(t),
|
||||
_ => Err(ValueError::Tolerance(v.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a built-in sample drawing
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SampleArgs {
|
||||
@@ -360,6 +395,8 @@ pub(crate) enum ValueError {
|
||||
CmdId,
|
||||
#[error(transparent)]
|
||||
CmdJson(#[from] serde_json::Error),
|
||||
#[error("expected a number of 0 or more, got `{0}`")]
|
||||
Tolerance(String),
|
||||
}
|
||||
|
||||
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::output::OutputError;
|
||||
use crate::perf::PerfError;
|
||||
use crate::roundtrip::RoundtripError;
|
||||
use crate::run::RunError;
|
||||
|
||||
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
|
||||
@@ -19,6 +20,8 @@ pub(crate) enum CliError {
|
||||
Output(#[from] OutputError),
|
||||
#[error(transparent)]
|
||||
Perf(#[from] PerfError),
|
||||
#[error(transparent)]
|
||||
Roundtrip(#[from] RoundtripError),
|
||||
/// The engine and io crates take file names as text.
|
||||
#[error("{}: the file name is not valid Unicode", .0.display())]
|
||||
NotUnicode(std::path::PathBuf),
|
||||
|
||||
@@ -16,6 +16,7 @@ mod info;
|
||||
mod mcp;
|
||||
mod output;
|
||||
mod perf;
|
||||
mod roundtrip;
|
||||
mod run;
|
||||
mod sample;
|
||||
|
||||
@@ -32,6 +33,7 @@ use output::Streams;
|
||||
#[cfg(test)]
|
||||
use output::{export_window, model_views, write_atomic};
|
||||
use perf::perf;
|
||||
use roundtrip::roundtrip;
|
||||
use run::run_cmds;
|
||||
#[cfg(test)]
|
||||
use run::{fill_last, returned_handle};
|
||||
@@ -99,7 +101,7 @@ where
|
||||
Command::Run(a) => a.settings.apply(),
|
||||
Command::Mcp(a) => a.settings.apply(),
|
||||
Command::Sample(a) => a.settings.apply(),
|
||||
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
|
||||
Command::Info(_) | Command::Commands(_) | Command::Perf(_) | Command::Roundtrip(_) => {}
|
||||
}
|
||||
let mut io = Streams::new(out, err);
|
||||
let result = match &cli.command {
|
||||
@@ -110,6 +112,7 @@ where
|
||||
Command::Mcp(a) => mcp(a),
|
||||
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
|
||||
Command::Sample(a) => sample(a, &mut io),
|
||||
Command::Roundtrip(a) => roundtrip(a, &mut io),
|
||||
};
|
||||
let mut code = 0;
|
||||
if let Err(e) = result {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! `roundtrip`: the corpus harness of `cadcraft_io::roundtrip::corpus` over a file or folder.
|
||||
|
||||
use super::args::RoundtripArgs;
|
||||
use super::error::{CliError, Result};
|
||||
use super::output::Streams;
|
||||
|
||||
/// Why `roundtrip` stopped.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum RoundtripError {
|
||||
#[error("roundtrip: give a FILE or FOLDER, or set CADCRAFT_CORPUS_DIR")]
|
||||
NoPath,
|
||||
#[error("roundtrip: no such file or folder: {}", .0.display())]
|
||||
Missing(std::path::PathBuf),
|
||||
#[error("roundtrip: no DXF or DWG files in {}", .0.display())]
|
||||
Empty(std::path::PathBuf),
|
||||
#[error("roundtrip: can't write the report to {}: {source}", .path.display())]
|
||||
Report { path: std::path::PathBuf, source: std::io::Error },
|
||||
#[error("roundtrip: {} strict failures:\n{}", .0.len(), .0.join("\n"))]
|
||||
Strict(Vec<String>),
|
||||
}
|
||||
|
||||
/// What the walk couldn't look at, so the report isn't taken for the whole corpus: said on
|
||||
/// stderr, and failures of a strict run.
|
||||
fn left_out(w: &cadcraft_io::roundtrip::corpus::Walk) -> Vec<String> {
|
||||
let mut out: Vec<String> = w.unreadable.iter().map(|u| format!("not read: {u}")).collect();
|
||||
if w.truncated {
|
||||
out.push("left out: files or folders past the harness limits".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result {
|
||||
use cadcraft_io::roundtrip::corpus;
|
||||
let path = a.path.clone().or_else(|| std::env::var_os("CADCRAFT_CORPUS_DIR").map(Into::into)).ok_or(RoundtripError::NoPath)?;
|
||||
let mut missed = Vec::new();
|
||||
let (root, files, out) = if path.is_dir() {
|
||||
let out = a.out.clone().unwrap_or_else(|| path.join("report"));
|
||||
// The report folder, and the saved copies in it, are never read back as corpus files.
|
||||
let walk = corpus::walk(&path, &[out.clone(), out.join("saved")]);
|
||||
missed = left_out(&walk);
|
||||
for m in &missed {
|
||||
io.errln(format_args!("roundtrip: {m}"));
|
||||
}
|
||||
(path, walk.files, out)
|
||||
} else if path.is_file() {
|
||||
let root = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
let out = a.out.clone().unwrap_or_else(|| root.join("report"));
|
||||
(root, vec![path], out)
|
||||
} else {
|
||||
return Err(RoundtripError::Missing(path).into());
|
||||
};
|
||||
if files.is_empty() {
|
||||
// A folder it couldn't read isn't an empty one.
|
||||
return Err(if missed.is_empty() { RoundtripError::Empty(root) } else { RoundtripError::Strict(missed) }.into());
|
||||
}
|
||||
let mut opts = corpus::CorpusOptions { cross_format: !a.same_format, census: !a.no_census, ..Default::default() };
|
||||
opts.compare.abs_tol = a.tolerance;
|
||||
if a.keep_saved {
|
||||
opts.keep_saved = Some(out.join("saved"));
|
||||
}
|
||||
let report = corpus::run(&root, &files, &opts, &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
|
||||
for f in &report.files {
|
||||
let routes: Vec<String> = f
|
||||
.routes
|
||||
.iter()
|
||||
.map(|r| match (&r.save_error, &r.reopen_error) {
|
||||
(Some(_), _) => format!("{}: save failed", r.route),
|
||||
(None, Some(_)) => format!("{}: reopen failed", r.route),
|
||||
(None, None) => {
|
||||
let t = r.comparison.total();
|
||||
format!("{}: {} lost, {} added, {} changed", r.route, t.lost, t.added, t.changed)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
match &f.open_error {
|
||||
Some(e) => io.outln(format_args!("{}: open failed: {e}", f.path)),
|
||||
None => io.outln(format_args!("{}: opened; {}", f.path, routes.join("; "))),
|
||||
}
|
||||
}
|
||||
let (md, json) = report.write(&out).map_err(|source| RoundtripError::Report { path: out.clone(), source })?;
|
||||
io.outln(format_args!("report: {}", md.display()));
|
||||
io.outln(format_args!(" {}", json.display()));
|
||||
// CADCRAFT_CORPUS_STRICT=1: a file that doesn't open, save or reopen, or records a writer
|
||||
// leaves out, fail the run (the report is written first).
|
||||
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
|
||||
let mut failed = report.strict_failures();
|
||||
failed.extend(missed);
|
||||
if !failed.is_empty() {
|
||||
return Err(CliError::from(RoundtripError::Strict(failed)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() {
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||||
written.sort();
|
||||
let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"];
|
||||
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
|
||||
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample", "roundtrip"];
|
||||
let mut expected: Vec<String> = completions.map(String::from).into();
|
||||
expected.push("cadcraft-cli.1".into());
|
||||
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
|
||||
|
||||
@@ -100,5 +100,12 @@ sync_github_name = true
|
||||
[people.GULERTOLGA]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Tyler-Chiriboga]
|
||||
real_name = "Tyler Chiriboga"
|
||||
sync_github_name = true
|
||||
|
||||
[people.starise]
|
||||
real_name = "Andrea Brandi"
|
||||
|
||||
[people.qinshah]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -11,16 +11,13 @@ use std::collections::HashSet;
|
||||
use cadcraft_doc::{Constraint, Drawing, Handle};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
|
||||
use crate::model::{Model, Prep};
|
||||
use crate::{Comp, MAX_CONSTRAINTS, MAX_EVAL_WORK, SolveError, SolveOptions, build_with, lm};
|
||||
|
||||
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
|
||||
const MAX_NULL_COLS: usize = 600;
|
||||
/// Largest Jacobian (rows × columns × min) whose rank is computed.
|
||||
const MAX_RANK_WORK: f64 = 4e8;
|
||||
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
|
||||
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
|
||||
const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Most objects listed in one report.
|
||||
const MAX_OBJECTS: usize = 10_000;
|
||||
|
||||
@@ -139,8 +136,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
|
||||
/// (constrained or not) and the constraint components they belong to.
|
||||
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let mut members_left = crate::model::MAX_RIGID_WORK;
|
||||
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
|
||||
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
|
||||
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -179,7 +176,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
if seen.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
let m = Model::build_budgeted(d, &[h], &mut members_left);
|
||||
let m = Model::build_with(d, &[h], &mut prep);
|
||||
let Some(s) = m.slots.first() else { continue };
|
||||
seen.insert(h);
|
||||
free_objects = true;
|
||||
@@ -237,5 +234,36 @@ mod budget_tests {
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
// Solving it is refused for the same reason, before any Jacobian is built.
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
}
|
||||
|
||||
/// Many Tangent constraints on one large fit spline rebuild it once while being interpreted,
|
||||
/// and the solve is refused by its rebuild budget instead of running.
|
||||
#[test]
|
||||
fn repeated_spline_tangents_rebuild_once_and_solve_is_bounded() {
|
||||
use cadcraft_doc::{Constraint, Line};
|
||||
use cadcraft_geom::Vec3;
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<_> = (0..400).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(Spline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let l =
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-1.0, 0.0, 0.0), b: Vec3::new(-1.0, 5.0, 0.0) })).unwrap();
|
||||
for id in 1..=200 {
|
||||
d.constraints.push(Constraint {
|
||||
id,
|
||||
kind: ConstraintKind::Tangent,
|
||||
refs: vec![GeomRef::whole(h), GeomRef::whole(l)],
|
||||
name: String::new(),
|
||||
expr: String::new(),
|
||||
});
|
||||
}
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().collect();
|
||||
let (comps, skipped) = crate::build(&d, &cons, &SolveOptions::default()).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let comp = comps.first().unwrap();
|
||||
assert_eq!(comp.built.len(), 200);
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
assert!(crate::purge_invalid(&mut d).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +284,14 @@ impl Env {
|
||||
pub fn new() -> Self {
|
||||
Env::default()
|
||||
}
|
||||
/// Add or replace a named expression. Text longer than [`MAX_LEN`] could never parse, so
|
||||
/// only a bounded prefix of it is kept: it still fails (as too long), without the environment
|
||||
/// holding or copying the whole text.
|
||||
pub fn insert(&mut self, name: &str, expr: &str) {
|
||||
let expr = match expr.char_indices().map(|(i, _)| i).find(|i| *i > MAX_LEN) {
|
||||
Some(end) => expr.get(..end).unwrap_or(expr),
|
||||
None => expr,
|
||||
};
|
||||
self.exprs.insert(name.to_string(), expr.to_string());
|
||||
self.cache.clear();
|
||||
}
|
||||
@@ -309,8 +316,9 @@ impl Env {
|
||||
if stack.len() >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
|
||||
let e = parse(&text)?;
|
||||
// Parse the stored text in place: `parse` refuses an oversized expression before reading
|
||||
// it, so a long imported expression referenced many times is never copied (CWE-400).
|
||||
let e = parse(self.exprs.get(name).ok_or_else(|| ExprError::Unknown(name.to_string()))?)?;
|
||||
stack.push(name.to_string());
|
||||
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
|
||||
stack.pop();
|
||||
@@ -400,6 +408,22 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An oversized named expression is kept only as a bounded prefix and still fails, however
|
||||
/// often it is referenced.
|
||||
#[test]
|
||||
fn oversized_named_expression_is_bounded_and_fails() {
|
||||
let mut env = Env::new();
|
||||
let long = format!("1+{}", "é".repeat(500_000));
|
||||
env.insert("big", &long);
|
||||
assert!(env.exprs.get("big").is_some_and(|t| t.len() > MAX_LEN && t.len() <= MAX_LEN + 4));
|
||||
assert_eq!(env.value("big"), Err(ExprError::TooDeep));
|
||||
for _ in 0..200 {
|
||||
assert_eq!(env.eval("big"), Err(ExprError::TooDeep));
|
||||
}
|
||||
env.insert("ok", "1+1+1");
|
||||
assert_eq!(env.value("ok"), Ok(3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_replaces_whole_names_only() {
|
||||
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
|
||||
|
||||
@@ -30,7 +30,7 @@ mod lm;
|
||||
mod model;
|
||||
mod residual;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
@@ -41,7 +41,7 @@ pub use dof::{DofReport, DofStatus, ObjectDof, dof};
|
||||
pub use expr::{Env, ExprError};
|
||||
pub use model::{ref_points, rigid_points, supported};
|
||||
|
||||
use model::Model;
|
||||
use model::{Model, Prep};
|
||||
use residual::{Built, Form};
|
||||
|
||||
/// Most constraints one drawing may hold.
|
||||
@@ -50,6 +50,11 @@ pub const MAX_CONSTRAINTS: usize = 5000;
|
||||
const MAX_CONFLICT_PROBES: usize = 48;
|
||||
/// Solver iterations.
|
||||
const MAX_ITER: usize = 200;
|
||||
/// Largest residual-evaluation work (including spline rebuilds) one numerical Jacobian may take,
|
||||
/// shared by solving, redundancy checks and DOF analysis.
|
||||
pub(crate) const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps).
|
||||
const TRIAL_EVALS: f64 = 14.0;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
pub enum SolveError {
|
||||
@@ -169,10 +174,26 @@ pub fn name_in_use(d: &Drawing, name: &str) -> bool {
|
||||
d.parametric.parameters.iter().any(|p| p.name == name) || d.constraints.iter().any(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// Highest numbered default name [`next_name`] tries.
|
||||
const MAX_NAME_SUFFIX: u32 = 100_000;
|
||||
|
||||
/// Next free parameter name for a dimensional constraint kind (`d1`, `rad2`, `ang1`, …).
|
||||
pub fn next_name(d: &Drawing, kind: ConstraintKind) -> String {
|
||||
let prefix = kind.name_prefix();
|
||||
(1..=100_000).map(|i| format!("{prefix}{i}")).find(|n| !name_in_use(d, n)).unwrap_or_else(|| format!("{prefix}_x"))
|
||||
// Collect the suffixes in use in one pass, then take the first free one: checking every
|
||||
// candidate against every name is quadratic in the number of names (CWE-407). Only a
|
||||
// suffix spelled as `format!("{prefix}{i}")` spells it counts (`d07` does not take `d7`).
|
||||
let taken: HashSet<u32> = d
|
||||
.parametric
|
||||
.parameters
|
||||
.iter()
|
||||
.map(|p| p.name.as_str())
|
||||
.chain(d.constraints.iter().map(|c| c.name.as_str()))
|
||||
.filter_map(|n| n.strip_prefix(prefix))
|
||||
.filter(|s| !s.starts_with('0') && s.bytes().all(|b| b.is_ascii_digit()))
|
||||
.filter_map(|s| s.parse::<u32>().ok())
|
||||
.collect();
|
||||
(1..=MAX_NAME_SUFFIX).find(|i| !taken.contains(i)).map_or_else(|| format!("{prefix}_x"), |i| format!("{prefix}{i}"))
|
||||
}
|
||||
|
||||
/// Next free constraint id.
|
||||
@@ -251,12 +272,41 @@ impl Comp {
|
||||
pub(crate) fn rows(&self, skip: Option<usize>) -> usize {
|
||||
self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum()
|
||||
}
|
||||
/// Work of one solver iteration's residual evaluations over `free`: every residual twice per
|
||||
/// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds
|
||||
/// are shared by residuals at the same points, see [`Model`]).
|
||||
pub(crate) fn eval_work(&self, free: &[usize], skip: Option<usize>) -> f64 {
|
||||
let rows = self.rows(skip) as f64;
|
||||
let evals = 2.0 * free.len() as f64 + TRIAL_EVALS;
|
||||
let mut splines: Vec<Handle> = Vec::new();
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) != skip && b.spline_reconstruct_cost(&self.model) > 0.0 {
|
||||
splines.extend(b.refs.iter().map(|r| r.handle).filter(|h| self.model.spline_reconstruct_cost(*h) > 0.0));
|
||||
}
|
||||
}
|
||||
splines.sort_unstable();
|
||||
splines.dedup();
|
||||
let rebuilds: f64 = splines
|
||||
.iter()
|
||||
.filter_map(|h| self.model.slot(*h))
|
||||
.map(|s| {
|
||||
let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64;
|
||||
(2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle)
|
||||
})
|
||||
.sum();
|
||||
evals * rows + rebuilds
|
||||
}
|
||||
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
|
||||
let free = self.free(skip);
|
||||
let rows = self.rows(skip);
|
||||
if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
// Numerical differentiation rebuilds a fit spline through a dense solve for every move of
|
||||
// its own fit points: refuse before iterating when that alone is too much (CWE-407).
|
||||
if self.eval_work(&free, skip) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
|
||||
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
|
||||
// A solution the ellipses would be stored differently from (axes swapped, minor axis
|
||||
@@ -410,24 +460,24 @@ fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
|
||||
|
||||
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
|
||||
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut members_left = model::MAX_RIGID_WORK;
|
||||
build_budgeted(d, cons, opts, &mut members_left)
|
||||
build_with(d, cons, opts, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
|
||||
/// Build constraint components sharing one caller-owned geometry preparation (block sampling and
|
||||
/// its member budget, spline rebuilds) between every model.
|
||||
/// Return constraints that cannot be interpreted as skipped entries.
|
||||
pub(crate) fn build_budgeted(
|
||||
pub(crate) fn build_with(
|
||||
d: &Drawing,
|
||||
cons: &[&Constraint],
|
||||
opts: &SolveOptions,
|
||||
members_left: &mut usize,
|
||||
prep: &mut Prep,
|
||||
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut e = env(d);
|
||||
let mut skipped = Vec::new();
|
||||
let mut comps = Vec::new();
|
||||
for group in components(cons) {
|
||||
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
let mut model = Model::build_budgeted(d, &handles, members_left);
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
let mut built = Vec::new();
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
@@ -640,6 +690,11 @@ pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
|
||||
/// Check that a constraint's references make sense for its type.
|
||||
pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
|
||||
validate_with(d, c, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// [`validate`] sharing block samples and spline rebuilds with other checks of the same drawing.
|
||||
fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), String> {
|
||||
let handles: Vec<Handle> = c.refs.iter().map(|r| r.handle).collect();
|
||||
for h in &handles {
|
||||
match d.entity(*h) {
|
||||
@@ -648,7 +703,7 @@ pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut model = Model::build(d, &handles);
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
interpret(c, &mut model, 1.0).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -665,7 +720,12 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result<f6
|
||||
/// Returns the removed ids.
|
||||
pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
|
||||
let mut gone = Vec::new();
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect();
|
||||
// One preparation for every check: each block is sampled and each spline rebuilt once,
|
||||
// however many constraints refer to them (CWE-407). Its member budget is not shared, so
|
||||
// a constraint is never dropped because earlier ones spent it: every block is still
|
||||
// examined at most once, up to its own member limit.
|
||||
let mut prep = Prep::new(usize::MAX);
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate_with(d, c, &mut prep).is_ok()).collect();
|
||||
let mut i = 0;
|
||||
d.constraints.retain(|c| {
|
||||
let k = keep.get(i).copied().unwrap_or(true);
|
||||
@@ -752,6 +812,9 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
&& comp.built.get(pos).is_some_and(|b| b.form == Form::Lock)
|
||||
{
|
||||
let before = comp.free(Some(pos));
|
||||
if comp.eval_work(&before, Some(pos)) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let after = comp.free(None);
|
||||
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
|
||||
let rows_old = comp.rows(Some(pos));
|
||||
|
||||
+193
-62
@@ -14,9 +14,11 @@
|
||||
//! Constraints may append auxiliary unknowns after the entity parameters (contact parameters of
|
||||
//! a tangency between curves); they are never written back.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
|
||||
use cadcraft_doc::{Block, Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
|
||||
use cadcraft_geom::{Spline as GSpline, Vec2, ccw_sweep};
|
||||
|
||||
use crate::curves::{Ell, Frame, clamped, spline_end};
|
||||
@@ -70,6 +72,9 @@ pub(crate) struct Slot {
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
/// The spline last rebuilt for each handle, with the defining points it was rebuilt from.
|
||||
type SplineCache = HashMap<Handle, (Vec<Vec2>, Rc<GSpline>)>;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Model {
|
||||
pub slots: Vec<Slot>,
|
||||
@@ -79,20 +84,75 @@ pub(crate) struct Model {
|
||||
splines: HashMap<Handle, GSpline>,
|
||||
/// Block reference rigid points relative to the insertion point at rotation 0.
|
||||
rigid: HashMap<Handle, Vec<Vec2>>,
|
||||
/// Rebuilt splines, shared with every model of the same [`Prep`]: residuals that ask for the
|
||||
/// same spline at the same defining points (several constraints on one spline, or derivatives
|
||||
/// taken along other objects' parameters) reuse one rebuild instead of a dense solve each.
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
/// Geometry preparation shared by every model built for one operation (a solve, a DOF report, a
|
||||
/// purge): rigid samples per block, a case-insensitive block-name index and rebuilt splines. A
|
||||
/// block referenced by many Inserts is sampled once, an unknown block name costs one hashed
|
||||
/// lookup, and a spline referenced by many constraints is rebuilt once (CWE-407).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Prep {
|
||||
/// Block members that may still be examined across the operation.
|
||||
members_left: usize,
|
||||
/// Block-local rigid samples by the name an Insert gives (`None`: no such block).
|
||||
blocks: HashMap<String, Option<Rc<[Vec2]>>>,
|
||||
/// Lower-cased block name to the stored name it resolves to, built on the first exact miss.
|
||||
folded: Option<HashMap<String, String>>,
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
impl Prep {
|
||||
/// Preparation that may examine at most `members` block members in all.
|
||||
pub(crate) fn new(members: usize) -> Prep {
|
||||
Prep { members_left: members, blocks: HashMap::new(), folded: None, rebuilt: Rc::default() }
|
||||
}
|
||||
|
||||
/// The block an Insert names, as [`Drawing::block`] resolves it (exact name, else the first
|
||||
/// stored name equal ignoring ASCII case), without scanning every block on each miss.
|
||||
fn resolve<'d>(&mut self, d: &'d Drawing, name: &str) -> Option<&'d Block> {
|
||||
if let Some(b) = d.blocks.get(name) {
|
||||
return Some(b);
|
||||
}
|
||||
let folded = self.folded.get_or_insert_with(|| {
|
||||
let mut m = HashMap::with_capacity(d.blocks.len());
|
||||
for k in d.blocks.keys() {
|
||||
m.entry(k.to_ascii_lowercase()).or_insert_with(|| k.clone());
|
||||
}
|
||||
m
|
||||
});
|
||||
d.blocks.get(folded.get(&name.to_ascii_lowercase())?).map(|b| &**b)
|
||||
}
|
||||
|
||||
/// Finite characteristic points of a block in its own coordinates, sampled once per name.
|
||||
fn block_points(&mut self, d: &Drawing, name: &str) -> Option<Rc<[Vec2]>> {
|
||||
if let Some(s) = self.blocks.get(name) {
|
||||
return s.clone();
|
||||
}
|
||||
let sampled = self.resolve(d, name).map(|b| sample_block(b, &mut self.members_left));
|
||||
self.blocks.insert(name.to_string(), sampled.clone());
|
||||
sampled
|
||||
}
|
||||
|
||||
/// World positions of an Insert's rigid points, at most [`MAX_RIGID_POINTS`].
|
||||
fn rigid_points(&mut self, d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let Some(local) = self.block_points(d, &ins.block) else { return Vec::new() };
|
||||
let Some(block) = self.resolve(d, &ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(block.base.xy());
|
||||
local.iter().map(|p| m.apply(*p)).filter(|p| p.is_finite()).take(MAX_RIGID_POINTS).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
|
||||
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
|
||||
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
|
||||
let Some(s) = self.splines.get(&handle) else { return 0.0 };
|
||||
let n = s.fit.len() as f64;
|
||||
if fit_mode(s) {
|
||||
n * n * n
|
||||
} else {
|
||||
let n = s.control.len() as f64;
|
||||
n * n
|
||||
}
|
||||
let Some(Shape::Spline { n, fit, .. }) = self.slot(handle).map(|s| s.shape) else { return 0.0 };
|
||||
let n = n as f64;
|
||||
if fit { n * n * n } else { n * n }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,23 +175,18 @@ pub fn supported(kind: &EntityKind) -> bool {
|
||||
/// centres, vertices, nested insertion points) of the block's own objects, at most
|
||||
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
|
||||
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let mut members_left = MAX_RIGID_MEMBERS;
|
||||
rigid_points_budgeted(d, ins, &mut members_left)
|
||||
Prep::new(MAX_RIGID_MEMBERS).rigid_points(d, ins)
|
||||
}
|
||||
|
||||
/// Sample finite block points within per-insert and caller-shared member limits.
|
||||
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
|
||||
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
|
||||
let Some(block) = d.block(&ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(block.base.xy());
|
||||
/// Sample a block's finite characteristic points in block coordinates, within the per-block
|
||||
/// member limit and the caller-shared `members_left`. Members that contribute no points still
|
||||
/// consume traversal work; nested blocks are not entered.
|
||||
fn sample_block(block: &Block, members_left: &mut usize) -> Rc<[Vec2]> {
|
||||
let mut out = Vec::new();
|
||||
// Bound the members examined, not only the points collected: entities that contribute no
|
||||
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
|
||||
// every Insert — would otherwise be unbounded (CWE-407).
|
||||
let mut examined = 0usize;
|
||||
for e in block.entities.iter() {
|
||||
examined += 1;
|
||||
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
|
||||
// points (Text, Ray, …) still cost traversal (CWE-407).
|
||||
for (examined, e) in block.entities.iter().enumerate() {
|
||||
if out.len() >= MAX_RIGID_POINTS || examined >= MAX_RIGID_MEMBERS || *members_left == 0 {
|
||||
break;
|
||||
}
|
||||
*members_left -= 1;
|
||||
@@ -148,10 +203,10 @@ fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) ->
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().take(MAX_RIGID_POINTS).map(|v| v.p).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
out.extend(pts.into_iter().map(|p| m.apply(p)).filter(|p| p.is_finite()));
|
||||
out.extend(pts.into_iter().filter(|p| p.is_finite()));
|
||||
}
|
||||
out.truncate(MAX_RIGID_POINTS);
|
||||
out
|
||||
out.into()
|
||||
}
|
||||
|
||||
/// Constraint points of an object as the solver sees them (`Sub`, position): end points,
|
||||
@@ -187,13 +242,12 @@ fn fit_mode(s: &GSpline) -> bool {
|
||||
impl Model {
|
||||
/// Build a model for the given entities. Unsupported or missing entities are skipped.
|
||||
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
|
||||
let mut members_left = MAX_RIGID_WORK;
|
||||
Self::build_budgeted(d, handles, &mut members_left)
|
||||
Self::build_with(d, handles, &mut Prep::new(MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
|
||||
/// left out (too large, degenerate or unsupported).
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option<Shape> {
|
||||
let shape = match kind {
|
||||
EntityKind::Line(l) => {
|
||||
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
|
||||
@@ -243,15 +297,22 @@ impl Model {
|
||||
// fit point; a control polygon's ends are its first and last points when the
|
||||
// knots are clamped.
|
||||
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
|
||||
// Missing weights default to 1 in eval; only excess weights are unused.
|
||||
let mut kept = sp.clone();
|
||||
// Keep only what rebuilding reads, so each rebuild copies no unused data: missing
|
||||
// weights default to 1 in eval and only excess weights are unused; a control
|
||||
// spline's fit points are dropped when its control points move (write_back), and
|
||||
// a fit spline is rebuilt from its parameters, not from its old control net.
|
||||
let mut kept = if fit {
|
||||
GSpline { fit_opts: sp.fit_opts, closed: sp.closed, ..GSpline::default() }
|
||||
} else {
|
||||
GSpline { fit: Vec::new(), ..sp.clone() }
|
||||
};
|
||||
kept.weights.truncate(kept.control.len());
|
||||
m.splines.insert(h, kept);
|
||||
Shape::Spline { n, fit, ends }
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
||||
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
let pts: Vec<Vec2> = prep.rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||
let n = pts.len();
|
||||
m.rigid.insert(h, pts);
|
||||
@@ -262,16 +323,17 @@ impl Model {
|
||||
Some(shape)
|
||||
}
|
||||
|
||||
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
|
||||
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
|
||||
let mut m = Model::default();
|
||||
/// Build solver parameters for supported entities, sharing block sampling, its member budget
|
||||
/// and spline rebuilds with every other model built from `prep`.
|
||||
pub(crate) fn build_with(d: &Drawing, handles: &[Handle], prep: &mut Prep) -> Model {
|
||||
let mut m = Model { rebuilt: Rc::clone(&prep.rebuilt), ..Model::default() };
|
||||
for &h in handles {
|
||||
if m.index.contains_key(&h) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let off = m.x0.len();
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, prep) else { continue };
|
||||
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
|
||||
m.x0.truncate(off);
|
||||
continue;
|
||||
@@ -373,18 +435,20 @@ impl Model {
|
||||
self.slot(r.handle).is_some_and(|s| matches!(s.shape, Shape::Spline { .. }))
|
||||
}
|
||||
|
||||
/// The spline of a reference with the defining points taken from `x`.
|
||||
fn spline(&self, x: &[f64], s: &Slot) -> Option<GSpline> {
|
||||
/// The spline of a reference with the defining points taken from `x`. The last rebuild of
|
||||
/// each spline is kept, so asking again at the same points costs a comparison, not a rebuild.
|
||||
fn spline(&self, x: &[f64], s: &Slot) -> Option<Rc<GSpline>> {
|
||||
let Shape::Spline { n, fit, .. } = s.shape else { return None };
|
||||
let t = self.splines.get(&s.handle)?;
|
||||
let pts = (0..n).map(|i| p2(x, s.off + 2 * i)).collect::<Option<Vec<_>>>()?;
|
||||
Some(if fit {
|
||||
t.refit(&pts, t.closed)
|
||||
} else {
|
||||
let mut sp = t.clone();
|
||||
sp.control = pts;
|
||||
sp
|
||||
})
|
||||
if let Some((at, sp)) = self.rebuilt.borrow().get(&s.handle)
|
||||
&& *at == pts
|
||||
{
|
||||
return Some(Rc::clone(sp));
|
||||
}
|
||||
let sp = Rc::new(if fit { t.refit(&pts, t.closed) } else { GSpline { control: pts.clone(), ..t.clone() } });
|
||||
self.rebuilt.borrow_mut().insert(s.handle, (pts, Rc::clone(&sp)));
|
||||
Some(sp)
|
||||
}
|
||||
|
||||
/// The start (`at_end` false) or end of a curve reference, in the curve's own direction:
|
||||
@@ -421,7 +485,7 @@ impl Model {
|
||||
let e = self.ell_at(x, o, true)?;
|
||||
e.frame(if at_end { e.t1 } else { e.t0 })
|
||||
}
|
||||
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&self.spline(x, s)?, at_end),
|
||||
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&*self.spline(x, s)?, at_end),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -590,7 +654,8 @@ impl Model {
|
||||
}
|
||||
let shape = s.shape;
|
||||
// A control-point spline loses its fit data once its control points move.
|
||||
let spline = self.spline(x, s).map(|mut sp| {
|
||||
let spline = self.spline(x, s).map(|sp| {
|
||||
let mut sp = GSpline::clone(&sp);
|
||||
if matches!(shape, Shape::Spline { fit: false, .. }) {
|
||||
sp.fit.clear();
|
||||
}
|
||||
@@ -712,21 +777,21 @@ mod budget_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A second model receives no sampled block points after the first model spends the shared member budget.
|
||||
#[test]
|
||||
fn rigid_sampling_budget_is_shared_between_models() {
|
||||
let mut d = Drawing::default();
|
||||
let mut block = Block::new("BAR");
|
||||
for _ in 0..3 {
|
||||
fn add_block(d: &mut Drawing, name: &str, lines: usize) {
|
||||
let mut block = Block::new(name);
|
||||
for _ in 0..lines {
|
||||
block.entities.push(Entity {
|
||||
handle: d.new_handle(),
|
||||
common: Common::default(),
|
||||
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
|
||||
});
|
||||
}
|
||||
d.blocks.insert("BAR".into(), Arc::new(block));
|
||||
let ins = Insert {
|
||||
block: "BAR".into(),
|
||||
d.blocks.insert(name.into(), Arc::new(block));
|
||||
}
|
||||
|
||||
fn insert(block: &str) -> Insert {
|
||||
Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
@@ -735,13 +800,79 @@ mod budget_tests {
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
|
||||
let mut budget = 2;
|
||||
let first = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert_eq!(first.rigid[&h].len(), 4);
|
||||
assert_eq!(budget, 0);
|
||||
let next = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert!(next.rigid[&h].is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn add_insert(d: &mut Drawing, block: &str) -> Handle {
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert(block))).unwrap()
|
||||
}
|
||||
|
||||
/// The member budget is shared by every model of one preparation, and a block it already
|
||||
/// sampled is reused by later Inserts without examining its members again.
|
||||
#[test]
|
||||
fn rigid_sampling_budget_is_shared_and_blocks_sampled_once() {
|
||||
let mut d = Drawing::default();
|
||||
add_block(&mut d, "BAR", 3);
|
||||
add_block(&mut d, "BAZ", 3);
|
||||
let (bar, baz, bar2) = (add_insert(&mut d, "BAR"), add_insert(&mut d, "BAZ"), add_insert(&mut d, "BAR"));
|
||||
let mut prep = Prep::new(2);
|
||||
let first = Model::build_with(&d, &[bar], &mut prep);
|
||||
assert_eq!(first.rigid[&bar].len(), 4);
|
||||
assert_eq!(prep.members_left, 0);
|
||||
let other = Model::build_with(&d, &[baz], &mut prep);
|
||||
assert!(other.rigid[&baz].is_empty());
|
||||
let again = Model::build_with(&d, &[bar2], &mut prep);
|
||||
assert_eq!(again.rigid[&bar2].len(), 4);
|
||||
}
|
||||
|
||||
/// Block names resolve ignoring ASCII case, as `Drawing::block` does, through one index.
|
||||
#[test]
|
||||
fn block_names_resolve_ignoring_case() {
|
||||
let mut d = Drawing::default();
|
||||
add_block(&mut d, "Bar", 2);
|
||||
let (lower, missing) = (add_insert(&mut d, "bAR"), add_insert(&mut d, "nope"));
|
||||
let mut prep = Prep::new(MAX_RIGID_WORK);
|
||||
let m = Model::build_with(&d, &[lower, missing], &mut prep);
|
||||
assert_eq!(m.rigid[&lower].len(), 4);
|
||||
assert!(m.rigid[&missing].is_empty());
|
||||
assert!(prep.folded.as_ref().is_some_and(|f| f.len() == 1));
|
||||
assert_eq!(rigid_points(&d, &insert("BAR")).len(), 4);
|
||||
}
|
||||
|
||||
/// A spline asked for again at the same defining points is not rebuilt, also from another
|
||||
/// model of the same preparation; moved points rebuild it.
|
||||
#[test]
|
||||
fn spline_rebuilds_are_reused_at_the_same_points() {
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<Vec2> = (0..6).map(|i| Vec2::new(i as f64, (i % 2) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(GSpline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let mut prep = Prep::new(MAX_RIGID_WORK);
|
||||
let a = Model::build_with(&d, &[h], &mut prep);
|
||||
let b = Model::build_with(&d, &[h], &mut prep);
|
||||
let (sa, sb) = (a.slot(h).unwrap(), b.slot(h).unwrap());
|
||||
assert!(matches!(sa.shape, Shape::Spline { fit: true, .. }));
|
||||
let first = a.spline(&a.x0, sa).unwrap();
|
||||
assert!(Rc::ptr_eq(&first, &a.spline(&a.x0, sa).unwrap()));
|
||||
assert!(Rc::ptr_eq(&first, &b.spline(&b.x0, sb).unwrap()));
|
||||
let mut moved = a.x0.clone();
|
||||
moved[1] += 0.5;
|
||||
let other = a.spline(&moved, sa).unwrap();
|
||||
assert!(!Rc::ptr_eq(&first, &other));
|
||||
assert_eq!(other.fit[0], Vec2::new(0.0, 0.5));
|
||||
}
|
||||
|
||||
/// A control spline's template keeps no fit points and no excess weights.
|
||||
#[test]
|
||||
fn control_template_drops_unused_data() {
|
||||
let mut d = Drawing::default();
|
||||
let mut sp = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)], 3);
|
||||
sp.fit = vec![Vec2::ZERO; 10_000];
|
||||
sp.fit_opts.tolerance = 0.5;
|
||||
sp.weights = vec![1.0; 10_000];
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap();
|
||||
let m = Model::build(&d, &[h]);
|
||||
let t = &m.splines[&h];
|
||||
assert!(t.fit.is_empty());
|
||||
assert_eq!(t.weights.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,3 +489,30 @@ fn arc_solved_with_a_negative_radius_keeps_its_points() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The default name is the first free numbered one, found in one pass over many names in use.
|
||||
#[test]
|
||||
fn next_name_skips_names_in_use() {
|
||||
let mut d = Drawing::default();
|
||||
assert_eq!(next_name(&d, ConstraintKind::Radius), "rad1");
|
||||
for i in (1..=20_000).filter(|i| *i != 15_000) {
|
||||
d.parametric.parameters.push(cadcraft_doc::Parameter { name: format!("d{i}"), expr: "1".into(), description: String::new() });
|
||||
}
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d15000");
|
||||
d.parametric.parameters.push(cadcraft_doc::Parameter { name: "d15000".into(), expr: "1".into(), description: String::new() });
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d20001");
|
||||
// A differently spelled suffix does not occupy the plain name.
|
||||
let mut d = Drawing::default();
|
||||
for name in ["d01", "d+1", "d1x", "D1"] {
|
||||
d.parametric.parameters.push(cadcraft_doc::Parameter { name: name.into(), expr: "1".into(), description: String::new() });
|
||||
}
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d1");
|
||||
d.constraints.push(Constraint {
|
||||
id: 1,
|
||||
kind: ConstraintKind::Distance(DistAxis::Aligned),
|
||||
refs: Vec::new(),
|
||||
name: "d1".into(),
|
||||
expr: "1".into(),
|
||||
});
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2");
|
||||
}
|
||||
|
||||
@@ -323,8 +323,9 @@ impl<'a> Cursor<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
|
||||
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
|
||||
/// `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph object, when the dictionary has one. Only the
|
||||
/// dictionary's own tags are read, so asking whether a block is dynamic never walks the graph.
|
||||
fn evaluation_graph<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<&'a ForeignObject> {
|
||||
let dict = objects.get(&xdict)?;
|
||||
let mut graph = None;
|
||||
let mut tags = dict.tags.iter();
|
||||
@@ -334,7 +335,12 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
|
||||
break;
|
||||
}
|
||||
}
|
||||
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
|
||||
objects.get(&graph?).copied().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))
|
||||
}
|
||||
|
||||
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
|
||||
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
|
||||
let graph = evaluation_graph(objects, xdict)?;
|
||||
let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
|
||||
// Deduplicate member handles before resolving them: a graph can list the same member many
|
||||
// times, and parsing each occurrence into its own owned parameter multiplies a single large
|
||||
@@ -343,13 +349,42 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
|
||||
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
|
||||
}
|
||||
|
||||
/// The kept foreign objects of a drawing by handle. Building it walks every object, so a caller
|
||||
/// that asks about many blocks (listing them) builds it once and passes it to
|
||||
/// [`dynamic_block_in`] and [`is_dynamic_block_in`] instead of rebuilding it per block, which
|
||||
/// multiplies the block count by the object count (CWE-407).
|
||||
pub struct ObjectIndex<'a>(HashMap<Handle, &'a ForeignObject>);
|
||||
|
||||
impl<'a> ObjectIndex<'a> {
|
||||
pub fn new(d: &'a Drawing) -> Self {
|
||||
ObjectIndex(d.foreign.objects.iter().map(|o| (o.handle, o)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension dictionary of block `name`'s record, if it has one.
|
||||
fn block_xdict(d: &Drawing, name: &str) -> Option<Handle> {
|
||||
d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict
|
||||
}
|
||||
|
||||
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
|
||||
/// block's own drawing), or `None` when it has none.
|
||||
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
|
||||
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
|
||||
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
|
||||
// A block without an extension dictionary is never dynamic: answer before indexing objects.
|
||||
block_xdict(d, name)?;
|
||||
dynamic_block_in(d, &ObjectIndex::new(d), name)
|
||||
}
|
||||
|
||||
/// Whether block `name` is a dynamic block (has an evaluation graph), without reading its
|
||||
/// parameters and actions; `objects` is the drawing's [`ObjectIndex`].
|
||||
pub fn is_dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> bool {
|
||||
block_xdict(d, name).and_then(|x| evaluation_graph(&objects.0, x)).is_some()
|
||||
}
|
||||
|
||||
/// [`dynamic_block`] with the drawing's [`ObjectIndex`] already built.
|
||||
pub fn dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> Option<DynamicBlock> {
|
||||
let xdict = block_xdict(d, name)?;
|
||||
let mut out = DynamicBlock::default();
|
||||
for o in graph_members(&objects, xdict)? {
|
||||
for o in graph_members(&objects.0, xdict)? {
|
||||
let secs = sections(&o.tags);
|
||||
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
|
||||
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
|
||||
|
||||
@@ -54,7 +54,11 @@ fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
|
||||
|
||||
/// The object entry `key` of dictionary `dict` refers to.
|
||||
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
|
||||
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
|
||||
dictionary_entry(d.foreign.objects.iter().find(|o| o.handle == dict)?, key)
|
||||
}
|
||||
|
||||
/// The object entry `key` (any case) of dictionary object `o` names.
|
||||
fn dictionary_entry(o: &ForeignObject, key: &str) -> Option<Handle> {
|
||||
let mut tags = o.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
|
||||
@@ -66,7 +70,11 @@ fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
|
||||
|
||||
/// Every entry of dictionary `dict`: (key, object).
|
||||
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
|
||||
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
|
||||
d.foreign.objects.iter().find(|o| o.handle == dict).map(dictionary_entries).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every entry of dictionary object `o`: (key, object).
|
||||
fn dictionary_entries(o: &ForeignObject) -> Vec<(String, Handle)> {
|
||||
let mut out = Vec::new();
|
||||
let mut key: Option<&str> = None;
|
||||
for t in &o.tags {
|
||||
@@ -692,19 +700,33 @@ fn refill_hatches(b: &mut Block) {
|
||||
|
||||
/// The value records of the references to the dynamic block whose record is `def_record` (from
|
||||
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
|
||||
///
|
||||
/// Each record is copied once at most: a key naming a record an earlier key already took is
|
||||
/// skipped. Records are copied per key (and copied again per key when a new reference's data is
|
||||
/// made), so a file listing one large record under many keys would otherwise multiply its size by
|
||||
/// the key count (CWE-400); AutoCAD gives every key its own record.
|
||||
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
|
||||
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
|
||||
// Dictionary entries looked up through the index, not by scanning every object per
|
||||
// dictionary (which is quadratic in the object count).
|
||||
let entry = |dict: Handle, key: &str| objects.get(&dict).and_then(|o| dictionary_entry(o, key));
|
||||
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
|
||||
let mut taken: HashSet<Handle> = HashSet::new();
|
||||
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
|
||||
// A representation dictionary whose data names this dynamic block.
|
||||
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
|
||||
let Some(rd) = dictionary_entry(rep, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
|
||||
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
|
||||
continue;
|
||||
}
|
||||
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
|
||||
for (k, h) in entries(d, values) {
|
||||
let Some(values) = dictionary_entry(rep, "AppDataCache").and_then(|c| entry(c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
|
||||
let Some(values) = objects.get(&values) else { continue };
|
||||
for (k, h) in dictionary_entries(values) {
|
||||
if out.contains_key(&k) || taken.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
|
||||
out.entry(k).or_insert_with(|| x.tags.clone());
|
||||
taken.insert(h);
|
||||
out.insert(k, x.tags.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1044,4 +1066,23 @@ mod tests {
|
||||
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
|
||||
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_record_listed_under_many_keys_is_copied_once() {
|
||||
// One large XRECORD named by many keys of a reference's value dictionary: the templates
|
||||
// keep one copy, not one per key.
|
||||
let mut d = Drawing::new_imperial();
|
||||
let (def_record, rep, rd, cache, values, record) = (Handle(0x10), Handle(0x20), Handle(0x21), Handle(0x22), Handle(0x23), Handle(0x24));
|
||||
let keys: Vec<String> = (0..1000).map(|i| i.to_string()).collect();
|
||||
let entries: Vec<(&str, Handle)> = keys.iter().map(|k| (k.as_str(), record)).collect();
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
f.objects.push(dictionary(rep, Handle(1), &[("AcDbRepData", rd), ("AppDataCache", cache)]));
|
||||
f.objects.push(ForeignObject { handle: rd, kind: "ACDB_BLOCKREPRESENTATION_DATA".into(), tags: vec![tag(340, def_record.hex())] });
|
||||
f.objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", values)]));
|
||||
f.objects.push(dictionary(values, cache, &entries));
|
||||
f.objects.push(ForeignObject { handle: record, kind: "XRECORD".into(), tags: (0..1000).map(|_| tag(1, "x")).collect() });
|
||||
let t = templates(&d, def_record);
|
||||
assert_eq!(t.len(), 1, "one record, one template");
|
||||
assert_eq!(t.get("0").map(Vec::len), Some(1000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,41 @@ pub struct MLineGeometry {
|
||||
pub fill: Vec<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
/// For each segment direction in `dirs`, the first non-zero direction at or after it and the
|
||||
/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero).
|
||||
///
|
||||
/// Two linear passes: searching from every segment separately is quadratic in a run of
|
||||
/// coincident vertices, which a hostile file can make as long as it likes (CWE-407).
|
||||
fn usable_directions(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else {
|
||||
return (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
};
|
||||
let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
// Walking backwards from the last usable direction, the latest one seen is the next ahead.
|
||||
let mut cur = Vec2::X;
|
||||
for k in 0..n {
|
||||
let i = (last + n - k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(a) = ahead.get_mut(i) {
|
||||
*a = cur;
|
||||
}
|
||||
}
|
||||
// Walking forwards from the first usable direction, the latest one seen is the nearest behind.
|
||||
for k in 0..n {
|
||||
let i = (first + k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(b) = behind.get_mut(i) {
|
||||
*b = cur;
|
||||
}
|
||||
}
|
||||
(ahead, behind)
|
||||
}
|
||||
|
||||
impl MLine {
|
||||
/// A multiline through `pts` in style `def`.
|
||||
pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine {
|
||||
@@ -268,16 +303,11 @@ impl MLine {
|
||||
let dirs = self.directions();
|
||||
let n = self.vertices.len();
|
||||
let nseg = dirs.len();
|
||||
// A degenerate segment takes its neighbour's direction.
|
||||
let dir = |i: usize| -> Vec2 {
|
||||
(0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)
|
||||
};
|
||||
let dir_back = |i: usize| -> Vec2 {
|
||||
(0..nseg)
|
||||
.map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default())
|
||||
.find(|d| *d != Vec2::ZERO)
|
||||
.unwrap_or(Vec2::X)
|
||||
};
|
||||
// A degenerate segment takes its neighbour's direction: the first usable one at or after
|
||||
// it (`ahead`), or at or before it (`behind`), wrapping around.
|
||||
let (ahead, behind) = usable_directions(&dirs);
|
||||
let dir = |i: usize| -> Vec2 { ahead.get(i).copied().unwrap_or(Vec2::X) };
|
||||
let dir_back = |i: usize| -> Vec2 { behind.get(i).copied().unwrap_or(Vec2::X) };
|
||||
let end = |d: Vec2, angle: f64| {
|
||||
let u = d.rotate(angle);
|
||||
let s = angle.sin();
|
||||
@@ -426,3 +456,61 @@ impl crate::Drawing {
|
||||
self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The nearest usable directions found by searching from each segment, as miters did before.
|
||||
fn searched(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
(ahead.collect(), behind.collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_directions_match_a_search_from_each_segment() {
|
||||
let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8));
|
||||
for dirs in [
|
||||
vec![],
|
||||
vec![z],
|
||||
vec![a],
|
||||
vec![z, z, z],
|
||||
vec![a, z, z, b],
|
||||
vec![z, a, z, b, z],
|
||||
vec![z, z, c],
|
||||
vec![c, z, z],
|
||||
vec![a, b, c],
|
||||
vec![z, a, z, z, b, c, z],
|
||||
] {
|
||||
assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_vertices_at_a_corner_take_the_corner_miter() {
|
||||
// Every vertex of a run on one point mitres between the segment before the run and the one after.
|
||||
let style = MLineStyle::default();
|
||||
let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0);
|
||||
let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)];
|
||||
let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0);
|
||||
let (expected, got) = (corner.miters(), run.miters());
|
||||
assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..]));
|
||||
assert_eq!((got.first(), got.last()), (expected.first(), expected.last()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_run_of_coincident_vertices_takes_linear_time() {
|
||||
// 200 000 vertices on one point but the last: the miters must not search the run once per
|
||||
// vertex (quadratic, minutes) to find the one usable direction.
|
||||
let mut pts = vec![Vec2::ZERO; 200_000];
|
||||
pts.push(Vec2::new(1.0, 0.0));
|
||||
let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0);
|
||||
let t = std::time::Instant::now();
|
||||
let miters = m.miters();
|
||||
assert_eq!(miters.len(), pts.len());
|
||||
assert!(miters.iter().all(|v| v.is_finite()));
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ use super::HatchLoop;
|
||||
/// Most loops one region has (hostile files and edits).
|
||||
pub const MAX_REGION_LOOPS: usize = 10_000;
|
||||
|
||||
/// Most polygon edges [`Region::renest`] tests, over all its point-in-loop tests.
|
||||
pub const MAX_RENEST_EDGE_VISITS: usize = 50_000_000;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Region {
|
||||
/// Closed loops with bulges; `outer` false for islands.
|
||||
@@ -76,20 +79,49 @@ impl Region {
|
||||
}
|
||||
|
||||
/// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is
|
||||
/// an outer loop, odd an island.
|
||||
pub fn renest(&mut self) {
|
||||
/// an outer loop, odd an island. `false`, with the flags left as they were, when that takes
|
||||
/// more than [`MAX_RENEST_EDGE_VISITS`] polygon edge tests.
|
||||
#[must_use]
|
||||
pub fn renest(&mut self) -> bool {
|
||||
let polys = self.tessellate(self.fine_tol());
|
||||
let depth: Vec<usize> = polys
|
||||
// A loop's probe can only be inside loops whose box holds it: test only those. A box with
|
||||
// a non-finite corner says nothing (an edge through a non-finite point never counts, so
|
||||
// such a polygon need not be closed), so its loop is always tested.
|
||||
let boxes: Vec<Option<Bounds2>> = polys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let Some(p) = probe(l) else { return 0 };
|
||||
polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count()
|
||||
.map(|l| {
|
||||
let b = Bounds2::from_points(l.iter().copied());
|
||||
(l.iter().all(|p| p.is_finite()) && b.min.is_finite() && b.max.is_finite()).then_some(b)
|
||||
})
|
||||
.collect();
|
||||
let mut visits = 0usize;
|
||||
let mut depth = Vec::with_capacity(polys.len());
|
||||
for (i, l) in polys.iter().enumerate() {
|
||||
let Some(p) = probe(l) else {
|
||||
depth.push(0);
|
||||
continue;
|
||||
};
|
||||
let mut d = 0;
|
||||
for (j, (o, b)) in polys.iter().zip(&boxes).enumerate() {
|
||||
if j == i || b.is_some_and(|b| !b.contains(p)) {
|
||||
continue;
|
||||
}
|
||||
// Each loop against every other is loops x vertices: bounded so a region of many
|
||||
// large nested loops cannot hold the thread (CWE-407).
|
||||
visits = visits.saturating_add(o.len());
|
||||
if visits > MAX_RENEST_EDGE_VISITS {
|
||||
return false;
|
||||
}
|
||||
if point_in_polygon(o, p) {
|
||||
d += 1;
|
||||
}
|
||||
}
|
||||
depth.push(d);
|
||||
}
|
||||
for (l, d) in self.loops.iter_mut().zip(depth) {
|
||||
l.outer = d % 2 == 0;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Area, perimeter, bounding box, centroid and second moments (outer loops counted
|
||||
@@ -128,3 +160,37 @@ fn probe(l: &[Vec2]) -> Option<Vec2> {
|
||||
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square(c: f64, half: f64, n: usize, outer: bool) -> HatchLoop {
|
||||
// `n` vertices along the bottom edge, then the other three corners.
|
||||
let mut vertices: Vec<PolyVertex> =
|
||||
(0..n).map(|k| PolyVertex::new(Vec2::new(c - half + 2.0 * half * k as f64 / n as f64, c - half))).collect();
|
||||
vertices.extend([Vec2::new(c + half, c - half), Vec2::new(c + half, c + half), Vec2::new(c - half, c + half)].map(PolyVertex::new));
|
||||
HatchLoop { vertices, outer, sources: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renest_sets_depth_parity_and_skips_loops_far_away() {
|
||||
// Three nested squares, and one beside them.
|
||||
let mut r =
|
||||
Region { loops: vec![square(0.0, 3.0, 1, false), square(0.0, 2.0, 1, false), square(0.0, 1.0, 1, false), square(10.0, 1.0, 1, false)] };
|
||||
assert!(r.renest());
|
||||
assert_eq!(r.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), [true, false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renest_of_many_large_nested_loops_stops_at_its_budget() {
|
||||
// Concentric loops of many vertices each: each probe is inside the box of every larger
|
||||
// loop (about loops² / 2 pairs), so each of those tests is a full polygon scan.
|
||||
let loops = 300;
|
||||
let n = 3 * MAX_RENEST_EDGE_VISITS / (loops * loops);
|
||||
let mut r = Region { loops: (0..loops).map(|i| square(0.0, 1.0 + i as f64, n, false)).collect() };
|
||||
let before = r.clone();
|
||||
assert!(!r.renest());
|
||||
assert_eq!(r, before, "flags left as they were");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,25 +94,48 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
|
||||
/// The box of what `e` draws (block contents on layers turned off left out).
|
||||
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false)
|
||||
bounds_of(d, e, depth, false, &mut HashMap::new())
|
||||
}
|
||||
|
||||
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
|
||||
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
|
||||
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true)
|
||||
bounds_of(d, e, 0, true, &mut HashMap::new())
|
||||
}
|
||||
|
||||
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
|
||||
/// The bounds of block contents measured so far, kept across the entities of one unchanged
|
||||
/// drawing. Measuring many entities with [`entity_bounds_cached`] or [`extent_bounds_cached`]
|
||||
/// and one cache measures each block definition once per depth for all of them; a fresh cache per
|
||||
/// entity would measure a block again for every reference to it, R references to a block of B
|
||||
/// entities costing R x B (CWE-407). Use a cache only while the drawing it measured is unchanged.
|
||||
#[derive(Default)]
|
||||
pub struct BlockBoundsCache<'a> {
|
||||
/// By block name and depth, block contents on layers turned off left out ([`entity_bounds`]).
|
||||
drawn: HashMap<(&'a str, usize), Bounds2>,
|
||||
/// The same with those contents counted ([`extent_bounds`]).
|
||||
extents: HashMap<(&'a str, usize), Bounds2>,
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
|
||||
pub fn entity_bounds_cached<'a>(d: &'a Drawing, e: &Entity, depth: usize, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false, &mut cache.drawn)
|
||||
}
|
||||
|
||||
/// [`extent_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
|
||||
pub fn extent_bounds_cached<'a>(d: &'a Drawing, e: &Entity, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true, &mut cache.extents)
|
||||
}
|
||||
|
||||
fn bounds_of<'a>(d: &'a Drawing, e: &Entity, depth: usize, off_counts: bool, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
|
||||
// A top-level annotative object measures as it draws at the current annotation scale.
|
||||
if depth == 0 {
|
||||
match d.anno_draw(e) {
|
||||
crate::AnnoDraw::Plain => {}
|
||||
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, blocks, off_counts),
|
||||
}
|
||||
}
|
||||
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
|
||||
bounds_in(d, e, depth, blocks, off_counts)
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
@@ -442,4 +465,53 @@ mod tests {
|
||||
empty.text = Some(crate::MText { contents: " ".into(), ..mtext });
|
||||
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_block_cache_measures_as_a_fresh_one_for_each_entity() {
|
||||
let line = |a: (f64, f64), b: (f64, f64), layer: &str| {
|
||||
let mut e = Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }));
|
||||
e.common.layer = layer.into();
|
||||
e
|
||||
};
|
||||
let ins = |block: &str, at: f64| {
|
||||
Entity::new(
|
||||
crate::Handle(2),
|
||||
EntityKind::Insert(crate::Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::new(at, at, 0.0),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
)
|
||||
};
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.ensure_layer("OFF");
|
||||
if let Some(l) = d.layer_mut("OFF") {
|
||||
l.on = false;
|
||||
}
|
||||
// A block with a part on a layer turned off (left out when drawn, counted in extents),
|
||||
// and a block that nests it.
|
||||
let mut b = crate::Block::new("B");
|
||||
b.entities.push(line((0.0, 0.0), (1.0, 1.0), "0"));
|
||||
b.entities.push(line((0.0, 0.0), (5.0, 5.0), "OFF"));
|
||||
d.blocks.insert("B".into(), std::sync::Arc::new(b));
|
||||
let mut n = crate::Block::new("N");
|
||||
n.entities.push(ins("B", 10.0));
|
||||
d.blocks.insert("N".into(), std::sync::Arc::new(n));
|
||||
let ents: Vec<Entity> = (0..50).map(|i| ins(if i % 2 == 0 { "B" } else { "N" }, f64::from(i))).collect();
|
||||
// One cache for both kinds of measure, interleaved: neither may answer for the other.
|
||||
let mut cache = BlockBoundsCache::default();
|
||||
for e in &ents {
|
||||
let (drawn, extents) = (entity_bounds_cached(&d, e, 0, &mut cache), extent_bounds_cached(&d, e, &mut cache));
|
||||
let (want_drawn, want_extents) = (entity_bounds(&d, e, 0), extent_bounds(&d, e));
|
||||
assert_eq!((drawn.min, drawn.max), (want_drawn.min, want_drawn.max));
|
||||
assert_eq!((extents.min, extents.max), (want_extents.min, want_extents.max));
|
||||
assert!(extents.width() > drawn.width());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub use cadcraft_color as color;
|
||||
pub use cadcraft_geom as geom;
|
||||
pub use constraint::*;
|
||||
pub use entity::*;
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
|
||||
pub use extents::{BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached};
|
||||
pub use format::Format;
|
||||
pub use gdt::*;
|
||||
pub use header::{HVal, Header};
|
||||
@@ -383,10 +383,13 @@ impl Drawing {
|
||||
/// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]).
|
||||
pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
|
||||
let mut b = cadcraft_geom::Bounds2::EMPTY;
|
||||
// One block-bounds cache for the whole space: every reference to a block reuses its
|
||||
// measured contents instead of measuring the definition again.
|
||||
let mut cache = BlockBoundsCache::default();
|
||||
if let Some(store) = self.space(space) {
|
||||
for e in store.iter() {
|
||||
if self.in_extents(e) {
|
||||
b = b.union(&extent_bounds(self, e));
|
||||
b = b.union(&extent_bounds_cached(self, e, &mut cache));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+84
-12
@@ -65,7 +65,7 @@ fn mb(n: u64) -> String {
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub mod worker;
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, serve_if_asked, use_own_worker, use_worker};
|
||||
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_noted, serve_if_asked, use_own_worker, use_worker};
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod native {
|
||||
@@ -544,15 +544,22 @@ mod native {
|
||||
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
|
||||
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
|
||||
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many
|
||||
/// records of each DXF type were left out, so a save can say so.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten {
|
||||
let version = doc.version;
|
||||
let mut dropped = super::Unwritten::new();
|
||||
let mut count = |name: &str| {
|
||||
let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
};
|
||||
let unwritable: Vec<acadrust::Handle> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Unknown(u)
|
||||
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
count(&u.dxf_name);
|
||||
Some(u.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
@@ -565,9 +572,10 @@ mod native {
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(h, o)| match o {
|
||||
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
|
||||
acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. }
|
||||
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
count(type_name);
|
||||
Some(*h)
|
||||
}
|
||||
_ => None,
|
||||
@@ -576,7 +584,7 @@ mod native {
|
||||
for h in &objects {
|
||||
doc.objects.remove(h);
|
||||
}
|
||||
unwritable.len() + objects.len()
|
||||
dropped
|
||||
}
|
||||
|
||||
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
|
||||
@@ -644,12 +652,19 @@ mod native {
|
||||
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
|
||||
/// (AC1018).
|
||||
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
convert(dxf, None)
|
||||
convert(dxf, None).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"),
|
||||
/// whatever version the DXF declares.
|
||||
pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each
|
||||
/// DXF type it can't write (other programs' custom entities and objects that the DWG reader
|
||||
/// didn't read from a DWG of this version), empty when the DWG keeps everything.
|
||||
pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
let v = match version.trim().to_ascii_uppercase().as_str() {
|
||||
"AC1015" => acadrust::DxfVersion::AC1015,
|
||||
"AC1018" => acadrust::DxfVersion::AC1018,
|
||||
@@ -707,7 +722,7 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
// Reject a pathologically large DXF before handing it to the third-party decoder, which
|
||||
// allocates tables and section buffers from the input's declared sizes and cannot be
|
||||
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
|
||||
@@ -732,21 +747,23 @@ mod native {
|
||||
None => {}
|
||||
}
|
||||
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
|
||||
guarded(AssertUnwindSafe(|| {
|
||||
let unwritten = guarded(AssertUnwindSafe(|| {
|
||||
super::fail_in_test("convert");
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
let unwritten = drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
current_layer(&mut doc);
|
||||
unwritten
|
||||
}))
|
||||
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
let dwg = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
|
||||
.map_err(|e| format!("DWG write: {e}"))
|
||||
.map_err(|e| format!("DWG write: {e}"))?;
|
||||
Ok((dwg, unwritten))
|
||||
}
|
||||
|
||||
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
|
||||
@@ -903,7 +920,11 @@ mod native {
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_noted};
|
||||
|
||||
/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer
|
||||
/// can't write.
|
||||
pub type Unwritten = std::collections::BTreeMap<String, usize>;
|
||||
|
||||
// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The
|
||||
// stage then panics on entry, so a test through the public API shows whether it is guarded.
|
||||
@@ -950,6 +971,10 @@ pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), St
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_as(dxf, version)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
dxf_to_dwg_noted(dxf, version)
|
||||
}
|
||||
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
@@ -971,6 +996,10 @@ pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1302,4 +1331,47 @@ mod tests {
|
||||
assert_eq!(read.dwg_tail_byte, 0, "{version}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF
|
||||
/// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG
|
||||
/// keeps whole reports nothing.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn saving_lists_the_types_the_dwg_writer_leaves_out() {
|
||||
let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n";
|
||||
let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n";
|
||||
let dxf = |body: &str| {
|
||||
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n")
|
||||
};
|
||||
for version in ["AC1015", "AC1018", "AC1032"] {
|
||||
let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap();
|
||||
assert!(is_dwg(&dwg), "{version}");
|
||||
assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::<Unwritten>(), "{version}");
|
||||
}
|
||||
let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap();
|
||||
assert!(unwritten.is_empty(), "{unwritten:?}");
|
||||
}
|
||||
|
||||
/// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to
|
||||
/// the DWG with its data: it isn't in the list of what the writer left out.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn ole_frames_are_written_with_their_data() {
|
||||
let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n";
|
||||
let dxf =
|
||||
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n");
|
||||
for version in ["AC1015", "AC1018", "AC1032"] {
|
||||
let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap();
|
||||
assert!(unwritten.is_empty(), "{version} {unwritten:?}");
|
||||
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
let frames: Vec<Vec<u8>> = back
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +82,31 @@ pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), St
|
||||
|
||||
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
encode_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
|
||||
/// back as notes of `TYPE<tab>count`.
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
if u8::try_from(version.len()).is_err() {
|
||||
return Err(super::unsupported(version));
|
||||
}
|
||||
match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") {
|
||||
Some(reply) => reply?.map(|(dwg, _)| dwg),
|
||||
None => super::dxf_to_dwg_as(dxf, version),
|
||||
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))),
|
||||
None => super::dxf_to_dwg_noted(dxf, version),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten_from(notes: &[String]) -> super::Unwritten {
|
||||
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
|
||||
super::Unwritten::new(),
|
||||
|mut m, (kind, n)| {
|
||||
*m.entry(kind).or_insert(0) = n;
|
||||
m
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
|
||||
#[doc(hidden)]
|
||||
pub fn crash_worker_for_test() -> Result<(), String> {
|
||||
@@ -241,7 +257,7 @@ fn handle(mut b: &[u8]) -> Reply {
|
||||
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
match op {
|
||||
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
|
||||
ENCODE => super::dxf_to_dwg_as(b, version).map(|dwg| (dwg, Vec::new())),
|
||||
ENCODE => super::dxf_to_dwg_noted(b, version).map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())),
|
||||
CRASH => std::process::abort(),
|
||||
_ => Err(bad()),
|
||||
}
|
||||
@@ -267,6 +283,12 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_writer_left_out_survives_the_reply() {
|
||||
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
|
||||
assert_eq!(unwritten_from(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip() {
|
||||
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
|
||||
|
||||
+57
-9
@@ -23,8 +23,11 @@ pub enum DxfError {
|
||||
Eof,
|
||||
/// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the
|
||||
/// limit was reached), `limit` the maximum.
|
||||
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads")]
|
||||
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")]
|
||||
TooLarge { tags: usize, limit: usize },
|
||||
/// The memory for `tags` group codes couldn't be had: an error, not the process ending.
|
||||
#[error("not enough memory to read this drawing's {tags} DXF group codes")]
|
||||
OutOfMemory { tags: usize },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DxfError>;
|
||||
@@ -163,16 +166,32 @@ fn typed(code: i32, text: &str) -> Value {
|
||||
}
|
||||
|
||||
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
/// Most group codes read from one file. A `Tag` takes 40 bytes plus its text, so this bounds the
|
||||
/// tag list to a few GB; real drawings stay far below (a 10 MB DWG is typically a few million).
|
||||
pub const MAX_TAGS: usize = 50_000_000;
|
||||
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
|
||||
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
|
||||
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
|
||||
/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to
|
||||
/// raise or lower it.
|
||||
pub const MAX_TAGS: usize = 100_000_000;
|
||||
|
||||
/// Environment variable overriding [`MAX_TAGS`] (a positive integer).
|
||||
pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS";
|
||||
|
||||
/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else
|
||||
/// [`MAX_TAGS`].
|
||||
pub fn max_tags() -> usize {
|
||||
max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
fn max_tags_from(v: Option<&str>) -> usize {
|
||||
v.and_then(|s| s.trim().replace('_', "").parse::<usize>().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS)
|
||||
}
|
||||
|
||||
mod codepage;
|
||||
pub use codepage::decode_code_page;
|
||||
|
||||
/// Parse a DXF file (ASCII or binary).
|
||||
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_with_limit(bytes, MAX_TAGS)
|
||||
parse_with_limit(bytes, max_tags())
|
||||
}
|
||||
|
||||
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
|
||||
@@ -199,7 +218,7 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if tags > max_tags {
|
||||
return Err(DxfError::TooLarge { tags, limit: max_tags });
|
||||
}
|
||||
parse_ascii_with_limit(&codepage::decode_ascii_file(bytes), max_tags)
|
||||
parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags)
|
||||
}
|
||||
|
||||
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
|
||||
@@ -271,12 +290,17 @@ fn unescape_unicode(s: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
|
||||
parse_ascii_with_limit(text, MAX_TAGS)
|
||||
parse_ascii_counted(text, max_tags(), 0)
|
||||
}
|
||||
|
||||
/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first,
|
||||
/// with an allocation that can fail: where the system refuses that much memory, opening the file
|
||||
/// is an error instead of the end of the app. Growing the list instead could also briefly take
|
||||
/// half as much again.
|
||||
#[allow(clippy::while_let_loop)]
|
||||
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
|
||||
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
|
||||
let mut tags = Vec::new();
|
||||
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
|
||||
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
|
||||
loop {
|
||||
let Some((ln, code_line)) = lines.next() else { break };
|
||||
@@ -357,7 +381,7 @@ impl<'a> Cur<'a> {
|
||||
|
||||
/// Binary DXF (R13+: 2-byte group codes).
|
||||
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_binary_with_limit(b, MAX_TAGS)
|
||||
parse_binary_with_limit(b, max_tags())
|
||||
}
|
||||
|
||||
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
@@ -777,3 +801,27 @@ mod tests {
|
||||
assert_eq!(parse(&b).unwrap()[0].str(), "图号");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod limit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tag_limit_defaults_and_env_override() {
|
||||
assert_eq!(max_tags_from(None), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("0")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("abc")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000);
|
||||
// Issue #1948: the default must admit a DXF rendition well above 50 million group codes.
|
||||
const { assert!(MAX_TAGS >= 100_000_000) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() {
|
||||
let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap();
|
||||
assert_eq!((tags.len(), tags.capacity()), (3, 3));
|
||||
let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err();
|
||||
assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,9 +166,11 @@ pub struct Autosaver {
|
||||
start: std::time::Instant,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: Option<f64>,
|
||||
/// The lock file this app holds the system lock on, open for as long as the lock is wanted.
|
||||
/// This app's lock files: one per folder, because SAVEFILEPATH can change while files remain
|
||||
/// in the old folder. Each with the file it holds open for the system lock, or `None` where
|
||||
/// locks don't work and only the file's age counts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
lock: Option<(std::path::PathBuf, std::fs::File)>,
|
||||
locks: Vec<(std::path::PathBuf, Option<std::fs::File>)>,
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -185,7 +187,7 @@ impl Autosaver {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
lock: None,
|
||||
locks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,9 +205,9 @@ impl Autosaver {
|
||||
self.docs.values().filter_map(|t| t.file.clone()).collect()
|
||||
}
|
||||
|
||||
/// Why a drawing currently has no up-to-date recovery copy, for the host to show the user.
|
||||
pub fn failure(&self) -> Option<&str> {
|
||||
self.failed.values().next().map(String::as_str)
|
||||
/// The drawings with no up-to-date recovery copy and why, for the host to show the user.
|
||||
pub fn failures(&self) -> impl Iterator<Item = (u64, &str)> {
|
||||
self.failed.iter().map(|(uid, why)| (*uid, why.as_str()))
|
||||
}
|
||||
|
||||
/// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder
|
||||
@@ -471,6 +473,12 @@ mod native {
|
||||
self.running.iter().any(|(_, h)| !h.is_finished())
|
||||
}
|
||||
|
||||
/// Whether a write's result hasn't been taken in yet, finished or not: the host needs
|
||||
/// another [`Autosaver::tick`] to learn whether it failed.
|
||||
pub fn results_pending(&self) -> bool {
|
||||
!self.running.is_empty()
|
||||
}
|
||||
|
||||
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
|
||||
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
|
||||
/// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`]
|
||||
@@ -569,23 +577,32 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the lock file at `path`, taking the system lock on it first if this app doesn't
|
||||
/// hold it there yet. Without one the file still works by its age.
|
||||
/// Refresh this app's lock files, and start one at `path` if there isn't one yet, taking
|
||||
/// the system lock on it if it can. Without that lock the file still works by its age.
|
||||
/// Only trouble at `path` is an error: an older folder may be gone by now.
|
||||
fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> {
|
||||
if let Some((held, file)) = &self.lock
|
||||
&& held == path
|
||||
{
|
||||
return file.set_modified(SystemTime::now());
|
||||
}
|
||||
self.lock = None;
|
||||
let id = std::process::id();
|
||||
let mut current = None;
|
||||
for (held, file) in &self.locks {
|
||||
let refreshed = match file {
|
||||
Some(file) => file.set_modified(SystemTime::now()),
|
||||
None => std::fs::write(held, id.to_string()),
|
||||
};
|
||||
if held == path {
|
||||
current = Some(refreshed);
|
||||
}
|
||||
}
|
||||
if let Some(refreshed) = current {
|
||||
return refreshed;
|
||||
}
|
||||
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
|
||||
if file.try_lock().is_err() {
|
||||
self.locks.push((path.to_path_buf(), None));
|
||||
return std::fs::write(path, id.to_string());
|
||||
}
|
||||
file.set_len(0)?;
|
||||
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
|
||||
self.lock = Some((path.to_path_buf(), file));
|
||||
self.locks.push((path.to_path_buf(), Some(file)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -600,7 +617,10 @@ mod native {
|
||||
discard(&path);
|
||||
}
|
||||
self.docs.clear();
|
||||
self.lock = None;
|
||||
for (path, file) in self.locks.drain(..) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
let dir = savefilepath();
|
||||
if !dir.is_empty() && is_instance_id(&self.instance) {
|
||||
let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance));
|
||||
|
||||
@@ -379,8 +379,11 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
// One object index for the whole listing: rebuilding it per block multiplies the block count
|
||||
// by the drawing's kept object count (CWE-407).
|
||||
let objects = cadcraft_doc::dynblock::ObjectIndex::new(d);
|
||||
let block = |b: &Block| {
|
||||
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
|
||||
let dynamic = cadcraft_doc::dynblock::is_dynamic_block_in(d, &objects, &b.name);
|
||||
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
|
||||
};
|
||||
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
|
||||
|
||||
@@ -128,7 +128,9 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result<Option<Region>> {
|
||||
}
|
||||
loops.retain(|l| l.vertices.len() >= 2);
|
||||
let mut r = Region { loops };
|
||||
r.renest();
|
||||
if !r.renest() {
|
||||
return Err(other("These regions are too complex to combine."));
|
||||
}
|
||||
Ok(Some(r))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,74 @@
|
||||
use cadcraft_geom::{Ellipse as GEllipse, Mat3};
|
||||
|
||||
use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3};
|
||||
|
||||
// ---------------- explode / array / order / break / join ----------------
|
||||
|
||||
/// `ell` placed by `m` as an ELLIPSE at elevation `z`; `None` when `m` flattens it.
|
||||
fn placed_ellipse(ell: GEllipse, m: &Mat3, z: f64) -> Option<EntityKind> {
|
||||
let g = ell.transformed(m);
|
||||
let len = g.major.len();
|
||||
(len.is_finite() && len > 0.0 && g.ratio.is_finite() && g.ratio > 0.0).then(|| {
|
||||
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: g.center.to3(z), major: g.major.to3(0.0), ratio: g.ratio, start: g.start, end: g.end })
|
||||
})
|
||||
}
|
||||
|
||||
/// The pieces of block object `be` placed by `m`, a transform that stretches unevenly (a block
|
||||
/// reference whose X and Y scales differ), where its own kind can't hold the result: a circle
|
||||
/// or an arc becomes an ellipse or an elliptical arc, a polyline with arc segments (and no
|
||||
/// widths) its lines and elliptical arcs. `None`: the object transforms as itself.
|
||||
fn stretched_pieces(be: &Entity, m: &Mat3) -> Option<Vec<Entity>> {
|
||||
let circular = |center: Vec2, radius: f64, start: f64, end: f64| GEllipse { center, major: Vec2::X * radius, ratio: 1.0, start, end };
|
||||
let kinds = match &be.kind {
|
||||
EntityKind::Circle(c) => vec![placed_ellipse(GEllipse::full(c.center.xy(), Vec2::X * c.radius, 1.0), m, c.center.z)?],
|
||||
EntityKind::Arc(a) => vec![placed_ellipse(circular(a.center.xy(), a.radius, a.start, a.end), m, a.center.z)?],
|
||||
EntityKind::Ellipse(el) => {
|
||||
let g = GEllipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
vec![placed_ellipse(g, m, el.center.z)?]
|
||||
}
|
||||
EntityKind::LwPolyline(p)
|
||||
if p.vertices.iter().any(|v| v.bulge != 0.0)
|
||||
&& p.const_width == 0.0
|
||||
&& p.vertices.iter().all(|v| v.start_width == 0.0 && v.end_width == 0.0) =>
|
||||
{
|
||||
let z = p.elevation;
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
pl.segments()
|
||||
.into_iter()
|
||||
.map(|s| match s {
|
||||
Segment::Line(l) => Some(EntityKind::Line(cadcraft_doc::Line { a: m.apply(l.a).to3(z), b: m.apply(l.b).to3(z) })),
|
||||
Segment::Arc { arc: a, .. } => placed_ellipse(circular(a.center, a.radius, a.start, a.end), m, z),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
// The pieces of a polyline are new objects, as when it explodes itself: its xdata goes.
|
||||
let mut common = be.common.clone();
|
||||
if matches!(be.kind, EntityKind::LwPolyline(_)) {
|
||||
common.xdata.clear();
|
||||
}
|
||||
Some(kinds.into_iter().map(|kind| Entity { handle: Handle(0), common: common.clone(), kind }).collect())
|
||||
}
|
||||
|
||||
fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option<Vec<Entity>> {
|
||||
if let Some(items) = crate::cmd::array::explode_array(d, e) {
|
||||
return Some(items);
|
||||
}
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let stretched = m.similarity_scale().is_none();
|
||||
Some(
|
||||
blk.entities
|
||||
.iter()
|
||||
.map(|be| {
|
||||
let mut ne = (**be).clone();
|
||||
ne.kind.transform(&m);
|
||||
.flat_map(|be| {
|
||||
stretched.then(|| stretched_pieces(be, &m)).flatten().unwrap_or_else(|| {
|
||||
let mut ne = (**be).clone();
|
||||
ne.kind.transform(&m);
|
||||
vec![ne]
|
||||
})
|
||||
})
|
||||
.map(|mut ne| {
|
||||
if ne.common.layer == "0" {
|
||||
ne.common.layer = e.common.layer.clone();
|
||||
}
|
||||
|
||||
@@ -283,6 +283,28 @@ pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A polyline arc segment on `ga`'s circle that ends at `end` after turning through `sweep`
|
||||
/// (signed, positive counterclockwise), run on round the circle to the nearest boundary edge: the
|
||||
/// new end point and the segment's new bulge. `None` when no edge crosses the rest of the circle.
|
||||
fn extend_arc_segment(ga: &Arc, end: Vec2, sweep: f64, cut: &[Segment]) -> Option<(Vec2, f64)> {
|
||||
let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*ga }, ccw: true };
|
||||
let a_end = ga.center.angle_to(end);
|
||||
let ccw = sweep > 0.0;
|
||||
// How far past `end` each crossing is, turning the way the segment turns; crossings on the
|
||||
// segment itself (the last |sweep| of a turn) are not ahead of it.
|
||||
let (x, more) = cut
|
||||
.iter()
|
||||
.flat_map(|c| intersect_ext(&full, c, false))
|
||||
.map(|x| {
|
||||
let a = ga.center.angle_to(x);
|
||||
(x, norm_angle(if ccw { a - a_end } else { a_end - a }))
|
||||
})
|
||||
.filter(|(_, more)| *more > 1e-9 && *more + sweep.abs() < TAU - 1e-9)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let total = sweep.abs() + more;
|
||||
Some((x, cadcraft_geom::arc_to_bulge(if ccw { total } else { -total })))
|
||||
}
|
||||
|
||||
/// Extend the end of `h` nearest `pick` to the nearest boundary edge.
|
||||
pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> {
|
||||
if crate::cmd::curves::is_locked(s, h) {
|
||||
@@ -326,18 +348,34 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Re
|
||||
let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) };
|
||||
let end = vs.get(i_end).map(|v| v.p).unwrap_or_default();
|
||||
let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default();
|
||||
let dir = (end - prev).normalized();
|
||||
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
|
||||
let np = cut
|
||||
.iter()
|
||||
.flat_map(|c| intersect_ext(&ray, c, false))
|
||||
.filter(|x| (*x - end).dot(dir) > 1e-9)
|
||||
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
|
||||
.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?;
|
||||
if let Some(v) = vs.get_mut(i_end) {
|
||||
v.p = np;
|
||||
// The end segment's bulge, signed for travel from `prev` out to `end`.
|
||||
let i_seg = if from_end { i_prev } else { i_end };
|
||||
let bulge = vs.get(i_seg).map_or(0.0, |v| if from_end { v.bulge } else { -v.bulge });
|
||||
let none = || EngineError::Other("Object does not intersect an edge.".into());
|
||||
if let Some((ga, _)) = cadcraft_geom::bulge_to_arc(prev, end, bulge) {
|
||||
// An arc end segment runs on round its circle, as an arc does.
|
||||
let (np, b) = extend_arc_segment(&ga, end, 4.0 * bulge.atan(), &cut).ok_or_else(none)?;
|
||||
if let Some(v) = vs.get_mut(i_end) {
|
||||
v.p = np;
|
||||
}
|
||||
if let Some(v) = vs.get_mut(i_seg) {
|
||||
v.bulge = if from_end { b } else { -b };
|
||||
}
|
||||
} else {
|
||||
let dir = (end - prev).normalized();
|
||||
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
|
||||
let np = cut
|
||||
.iter()
|
||||
.flat_map(|c| intersect_ext(&ray, c, false))
|
||||
.filter(|x| (*x - end).dot(dir) > 1e-9)
|
||||
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
|
||||
.ok_or_else(none)?;
|
||||
if let Some(v) = vs.get_mut(i_end) {
|
||||
v.p = np;
|
||||
}
|
||||
}
|
||||
lwpoly(vs, false)
|
||||
// Width, elevation and linetype generation stay as they were.
|
||||
EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, ..p.clone() })
|
||||
}
|
||||
k => crate::cmd::modify2::extend_other(k, &cut, pick)?,
|
||||
};
|
||||
|
||||
@@ -26,7 +26,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params("{mode: point|distance|angle|intersection, points: [[x,y],...]} → {value} | {point}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<PickM>::default())),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params(
|
||||
"{find, replace?, matchCase?: bool, wholeWord?: bool, types?: [\"Text\", \"MText\", ...], handles?: [...]} → \
|
||||
{matches: [{handle, type, count, snippet, hit, hitLen}], total, message}",
|
||||
),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
|
||||
.params("{mode: distance|radius|angle|area|volume, ...}")
|
||||
.noundo()
|
||||
@@ -194,6 +197,46 @@ fn run_quickcalc_pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------- FIND ----------------
|
||||
|
||||
/// Characters of context kept on each side of a hit in the Find dialog's result list.
|
||||
const SNIPPET_CONTEXT: usize = 18;
|
||||
|
||||
/// The text around the first hit of `find` in `text`, for the result list: at most
|
||||
/// [`SNIPPET_CONTEXT`] characters before the hit and as many after it, the hit's offset in the
|
||||
/// snippet (in characters) and its length (in characters).
|
||||
///
|
||||
/// The offset and length are character counts, not bytes: the dialog slices the snippet by
|
||||
/// character so a Chinese or Vietnamese drawing never splits a glyph. Lower-casing can change a
|
||||
/// string's character count in rare scripts, so every slice goes through `get` and a snippet that
|
||||
/// cannot be cut is reported as absent rather than guessed at.
|
||||
fn snippet_at(text: &str, find: &str, case: bool, whole: bool) -> Option<(String, usize, usize)> {
|
||||
if find.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (hay, needle) = if case { (text.to_string(), find.to_string()) } else { (text.to_lowercase(), find.to_lowercase()) };
|
||||
let mut at = 0;
|
||||
let pos = loop {
|
||||
let rest = hay.get(at..)?;
|
||||
let p = rest.find(&needle)? + at;
|
||||
let end = p + needle.len();
|
||||
let boundary = |i: usize| hay.get(..i).and_then(|s| s.chars().last()).is_none_or(|c| !c.is_alphanumeric());
|
||||
let after = hay.get(end..).and_then(|s| s.chars().next()).is_none_or(|c| !c.is_alphanumeric());
|
||||
if !whole || (boundary(p) && after) {
|
||||
break p;
|
||||
}
|
||||
at = end.max(p + 1);
|
||||
if at >= hay.len() {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let start = hay.get(..pos)?.chars().count();
|
||||
let hit_len = needle.chars().count();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let from = start.saturating_sub(SNIPPET_CONTEXT);
|
||||
let to = start.saturating_add(hit_len).saturating_add(SNIPPET_CONTEXT).min(chars.len());
|
||||
let snippet: String = chars.get(from..to)?.iter().collect();
|
||||
Some((snippet, start.saturating_sub(from), hit_len))
|
||||
}
|
||||
|
||||
fn replace_in(text: &str, find: &str, rep: &str, case: bool, whole: bool) -> (String, usize) {
|
||||
if find.is_empty() {
|
||||
return (text.to_string(), 0);
|
||||
@@ -228,21 +271,40 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rep = str_param(p, "replace").map(str::to_string);
|
||||
let case = bool_or(p, "matchCase", false);
|
||||
let whole = bool_or(p, "wholeWord", false);
|
||||
// Only the named object types, when the dialog narrows the search; `type_name()` is the same
|
||||
// name the result list shows, so a caller never has to guess the spelling.
|
||||
let types: Option<Vec<String>> =
|
||||
p.get("types").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect());
|
||||
let space = s.space();
|
||||
let d = s.doc()?;
|
||||
let hs: Vec<Handle> = d.space(&space).map(|st| st.handles()).unwrap_or_default();
|
||||
// The whole current space by default; the Find window narrows it to the objects its result
|
||||
// list already holds, so Replace All touches exactly what the user can see.
|
||||
let hs: Vec<Handle> = match p.get("handles").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect(),
|
||||
None => d.space(&space).map(|st| st.handles()).unwrap_or_default(),
|
||||
};
|
||||
let mut found: Vec<Value> = Vec::new();
|
||||
let mut total = 0;
|
||||
let mut edits: Vec<(Handle, EntityKind)> = Vec::new();
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
if let Some(names) = &types
|
||||
&& !names.iter().any(|n| n == &e.kind.type_name().to_ascii_lowercase())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut k = e.kind.clone();
|
||||
let mut hits = 0;
|
||||
// The first hit's surroundings, taken before a replacement rewrites the text.
|
||||
let mut snippet: Option<(String, usize, usize)> = None;
|
||||
let r = rep.as_deref().unwrap_or(&find);
|
||||
let mut apply = |t: &mut String| {
|
||||
let (nt, n) = replace_in(t, &find, r, case, whole);
|
||||
if n > 0 {
|
||||
hits += n;
|
||||
if snippet.is_none() {
|
||||
snippet = snippet_at(t, &find, case, whole);
|
||||
}
|
||||
if rep.is_some() {
|
||||
*t = nt;
|
||||
}
|
||||
@@ -263,7 +325,15 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if hits > 0 {
|
||||
total += hits;
|
||||
found.push(json!({ "handle": h.hex(), "type": e.kind.type_name(), "count": hits }));
|
||||
let (text, at, len) = snippet.unwrap_or_default();
|
||||
found.push(json!({
|
||||
"handle": h.hex(),
|
||||
"type": e.kind.type_name(),
|
||||
"count": hits,
|
||||
"snippet": text,
|
||||
"hit": at,
|
||||
"hitLen": len,
|
||||
}));
|
||||
if rep.is_some() {
|
||||
edits.push((h, k));
|
||||
}
|
||||
@@ -577,4 +647,35 @@ mod tests {
|
||||
assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3));
|
||||
assert_eq!(replace_in("x", "", "y", true, false).1, 0);
|
||||
}
|
||||
|
||||
/// `snippet_at` returns the whole text when the hit fills it, with the hit at offset 0.
|
||||
#[test]
|
||||
fn snippet_reports_the_hit_offset() {
|
||||
assert_eq!(snippet_at("BOLT-01", "BOLT-01", true, false), Some(("BOLT-01".to_string(), 0, 7)));
|
||||
}
|
||||
|
||||
/// Matching ignores case but the snippet keeps the text as it is written, so the result list
|
||||
/// shows the drawing's own spelling.
|
||||
#[test]
|
||||
fn snippet_keeps_the_original_case() {
|
||||
assert_eq!(snippet_at("法兰 bolt-01 安装孔", "BOLT-01", false, false), Some(("法兰 bolt-01 安装孔".to_string(), 3, 7)));
|
||||
}
|
||||
|
||||
/// A long text keeps [`SNIPPET_CONTEXT`] characters on each side of the hit, counted in
|
||||
/// characters so a CJK hit is never split.
|
||||
#[test]
|
||||
fn snippet_is_windowed_in_characters() {
|
||||
let text = format!("{}命中{}", "前".repeat(30), "后".repeat(30));
|
||||
let (snippet, hit, len) = snippet_at(&text, "命中", true, false).expect("a hit");
|
||||
assert_eq!((hit, len), (SNIPPET_CONTEXT, 2));
|
||||
assert_eq!(snippet.chars().count(), SNIPPET_CONTEXT * 2 + 2);
|
||||
}
|
||||
|
||||
/// A whole-word search skips an embedded hit, and text without a hit has no snippet.
|
||||
#[test]
|
||||
fn snippet_respects_whole_word() {
|
||||
assert_eq!(snippet_at("BOLTS", "BOLT", true, true), None);
|
||||
assert_eq!(snippet_at("a BOLT b", "BOLT", true, true).map(|s| s.1), Some(2));
|
||||
assert_eq!(snippet_at("nothing", "BOLT", true, false), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,22 +813,13 @@ fn prim_near(p: &Prim, probe: &Bounds2, aperture: f64) -> bool {
|
||||
b.expand(aperture).intersects(probe)
|
||||
}
|
||||
|
||||
/// The rotation-and-uniform-scale factor of `m`'s linear part (mirroring allowed), or `None`
|
||||
/// when it distorts shapes (non-uniform scale or shear).
|
||||
fn similarity_scale(m: &Mat3) -> Option<f64> {
|
||||
let (u, v) = (Vec2::new(m.a, m.b), Vec2::new(m.c, m.d));
|
||||
let (lu, lv) = (u.len(), v.len());
|
||||
let tol = 1e-9 * lu.max(lv);
|
||||
((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu)
|
||||
}
|
||||
|
||||
/// `p` placed by the affine transform `m`, exactly: circles and arcs under a non-uniform scale
|
||||
/// become ellipses and elliptical arcs.
|
||||
fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
|
||||
let mirror = m.is_mirroring();
|
||||
match p {
|
||||
Prim::Seg(Segment::Line(l)) => Prim::Seg(Segment::Line(cadcraft_geom::Line::new(m.apply(l.a), m.apply(l.b)))),
|
||||
Prim::Seg(Segment::Arc { arc, ccw }) => match similarity_scale(m) {
|
||||
Prim::Seg(Segment::Arc { arc, ccw }) => match m.similarity_scale() {
|
||||
Some(s) => {
|
||||
let c = m.apply(arc.center);
|
||||
let (a0, a1) = (c.angle_to(m.apply(arc.start_point())), c.angle_to(m.apply(arc.end_point())));
|
||||
@@ -837,14 +828,14 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
|
||||
}
|
||||
None => {
|
||||
let e = Ellipse { center: arc.center, major: Vec2::X * arc.radius, ratio: 1.0, start: arc.start, end: arc.end };
|
||||
Prim::Ellipse(xform_ellipse(&e, m))
|
||||
Prim::Ellipse(e.transformed(m))
|
||||
}
|
||||
},
|
||||
Prim::Circle(c) => match similarity_scale(m) {
|
||||
Prim::Circle(c) => match m.similarity_scale() {
|
||||
Some(s) => Prim::Circle(Circle::new(m.apply(c.center), c.radius * s)),
|
||||
None => Prim::Ellipse(xform_ellipse(&Ellipse::full(c.center, Vec2::X * c.radius, 1.0), m)),
|
||||
None => Prim::Ellipse(Ellipse::full(c.center, Vec2::X * c.radius, 1.0).transformed(m)),
|
||||
},
|
||||
Prim::Ellipse(e) => Prim::Ellipse(xform_ellipse(e, m)),
|
||||
Prim::Ellipse(e) => Prim::Ellipse(e.transformed(m)),
|
||||
Prim::Spline(s) => {
|
||||
let mut s = s.clone();
|
||||
s.control.iter_mut().chain(s.fit.iter_mut()).for_each(|q| *q = m.apply(*q));
|
||||
@@ -856,24 +847,6 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
|
||||
}
|
||||
}
|
||||
|
||||
/// The image of an ellipse (or elliptical arc) under an affine transform: the conjugate
|
||||
/// semi-diameters `A·major`, `A·minor` give the new principal axes; parameters shift with them
|
||||
/// (and reverse under a mirroring transform).
|
||||
fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse {
|
||||
let center = m.apply(e.center);
|
||||
let (am, an) = (m.apply_vec(e.major), m.apply_vec(e.minor()));
|
||||
// |am·cos t + an·sin t| is largest at t0: the new major axis.
|
||||
let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an));
|
||||
let (s0, c0) = t0.sin_cos();
|
||||
let (u, v) = (am * c0 + an * s0, an * c0 - am * s0);
|
||||
let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 };
|
||||
if e.is_full() {
|
||||
return Ellipse::full(center, u, ratio);
|
||||
}
|
||||
let (start, end) = if u.cross(v) >= 0.0 { (e.start - t0, e.end - t0) } else { (t0 - e.end, t0 - e.start) };
|
||||
Ellipse { center, major: u, ratio, start: cadcraft_geom::norm_angle(start), end: cadcraft_geom::norm_angle(end) }
|
||||
}
|
||||
|
||||
/// The nearest deferred tangent/perpendicular within `aperture` of `cursor`.
|
||||
fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip: Option<Bounds2>) -> Option<SnapHit> {
|
||||
let mut best: Option<(f64, SnapHit)> = None;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds};
|
||||
use cadcraft_doc::{Block, BlockBoundsCache, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds_cached};
|
||||
use cadcraft_geom::Bounds2;
|
||||
use rstar::primitives::{GeomWithData, Rectangle};
|
||||
use rstar::{AABB, RTree};
|
||||
@@ -44,6 +44,8 @@ struct ChunkIx {
|
||||
impl ChunkIx {
|
||||
fn build(d: &Drawing, key: usize, items: &[Arc<Entity>]) -> ChunkIx {
|
||||
let mut infinite = Vec::new();
|
||||
// One block-bounds cache for the chunk: references to the same block measure it once.
|
||||
let mut cache = BlockBoundsCache::default();
|
||||
let bounds = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -51,7 +53,7 @@ impl ChunkIx {
|
||||
if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) {
|
||||
infinite.push(u32::try_from(i).unwrap_or(u32::MAX));
|
||||
}
|
||||
entity_bounds(d, e, 0)
|
||||
entity_bounds_cached(d, e, 0, &mut cache)
|
||||
})
|
||||
.collect();
|
||||
let serial = NEXT_SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -200,7 +202,7 @@ impl SpatialIndex {
|
||||
let (c, l) = self.locate(i)?;
|
||||
c.ents.get(l)
|
||||
}
|
||||
/// The bounds of the entity at draw index `i` (as [`entity_bounds`] computes them).
|
||||
/// The bounds of the entity at draw index `i` (as [`cadcraft_doc::entity_bounds`] computes them).
|
||||
pub fn bounds(&self, i: u32) -> Bounds2 {
|
||||
self.locate(i).and_then(|(c, l)| c.bounds.get(l).copied()).unwrap_or(Bounds2::EMPTY)
|
||||
}
|
||||
|
||||
@@ -323,6 +323,26 @@ fn an_emergency_save_finishes_before_it_returns_even_with_savetime_off() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_autosave_folder_keeps_the_old_folder_locked() {
|
||||
hooks();
|
||||
let _turn = folder_turn();
|
||||
let (old, new) = (temp_dir("moved-old"), temp_dir("moved-new"));
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
let mut a = Autosaver::new("44-e");
|
||||
autosave_once(&mut a, &s, &old);
|
||||
autosave::set_savefilepath(&new.to_string_lossy());
|
||||
draw(&mut s);
|
||||
assert_eq!(a.emergency_save(&s), 1);
|
||||
assert!(new.join("44-e.lock").exists());
|
||||
assert!(autosave::recoverable(&old, "9-f").is_empty(), "this app is still running");
|
||||
a.shutdown();
|
||||
assert!(!old.join("44-e.lock").exists() && !new.join("44-e.lock").exists());
|
||||
let _ = std::fs::remove_dir_all(&old);
|
||||
let _ = std::fs::remove_dir_all(&new);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_exit_removes_the_lock_file() {
|
||||
hooks();
|
||||
@@ -366,7 +386,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(rearmed, "a failed autosave must be tried again");
|
||||
assert!(a.failure().is_some_and(|m| m.contains("out")), "{:?}", a.failure());
|
||||
assert!(a.failures().next().is_some_and(|(_, why)| why.contains("out")));
|
||||
|
||||
std::fs::remove_file(&out).unwrap();
|
||||
let jobs = a.plan(&s, 180.0, 1, &dir);
|
||||
@@ -387,7 +407,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(a.next_due(1e4, 1), None);
|
||||
assert_eq!(a.failure(), None, "a write that worked clears the failure");
|
||||
assert_eq!(a.failures().next(), None, "a write that worked clears the failure");
|
||||
a.shutdown();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -407,16 +427,16 @@ fn a_failure_ends_when_the_drawing_no_longer_needs_a_copy() {
|
||||
a.perform(jobs);
|
||||
for _ in 0..500 {
|
||||
a.reap();
|
||||
if a.failure().is_some() {
|
||||
if a.failures().next().is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(a.failure().is_some());
|
||||
assert!(a.failures().next().is_some());
|
||||
let i = s.active;
|
||||
s.execute("close", &json!({ "index": i })).unwrap();
|
||||
a.plan(&s, 120.0, 1, &dir);
|
||||
assert_eq!(a.failure(), None);
|
||||
assert_eq!(a.failures().next(), None);
|
||||
a.shutdown();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@ fn blocks_without_dynamic_data_and_bad_names() {
|
||||
s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "Window" })).unwrap(), json!(null));
|
||||
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "*Model_Space" })).unwrap(), json!(null));
|
||||
let list = s.execute("blocks.list", &json!({})).unwrap();
|
||||
let flags: Vec<_> = list["blocks"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap().to_string(), b["dynamic"].clone())).collect();
|
||||
assert!(flags.contains(&("Window".into(), json!(false))), "{flags:?}");
|
||||
assert!(flags.contains(&("Door".into(), json!(true))), "{flags:?}");
|
||||
for p in [json!({}), json!({ "name": 3 }), json!({ "name": "Nope" }), json!(null)] {
|
||||
assert!(s.execute("blocks.dynamic", &p).is_err(), "{p}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! EXPLODE of a block reference whose X and Y scales differ: its circles, arcs, ellipses and
|
||||
//! polyline arcs come out as the ellipses and elliptical arcs the reference draws, not as circles
|
||||
//! scaled by the mean of the two scales.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Entity, EntityKind};
|
||||
use serde_json::json;
|
||||
|
||||
const AT: [f64; 2] = [100.0, 50.0];
|
||||
const ROTATION: f64 = 30.0;
|
||||
|
||||
/// Block S (base 0,0): a circle (r 5 at 0,0), an arc (r 4 at 20,0, 0°..90°), an elliptical arc
|
||||
/// (centre 40,0, major 6 along X, ratio 0.5, 0°..180°), a slot polyline (two lines, two
|
||||
/// semicircles of r 2 at 60,0 and 70,0) and a line. One reference to it at `AT`, rotated by
|
||||
/// `ROTATION`, scaled by `scale`, exploded.
|
||||
fn exploded(scale: [f64; 2]) -> Vec<Entity> {
|
||||
let mut s = Session::new();
|
||||
let mut hs = Vec::new();
|
||||
for (id, p) in [
|
||||
("circle", json!({"center": [0, 0], "radius": 5})),
|
||||
("arc", json!({"center": [20, 0], "radius": 4, "start": 0, "end": 90})),
|
||||
("ellipse", json!({"center": [40, 0], "major": [6, 0], "ratio": 0.5, "start": 0, "end": 180})),
|
||||
("pline", json!({"vertices": [[60, -2], {"p": [70, -2], "bulge": 1}, [70, 2], {"p": [60, 2], "bulge": 1}], "closed": true})),
|
||||
("line", json!({"points": [[0, 10], [10, 10]]})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap();
|
||||
hs.push(s.doc().unwrap().model.iter().last().unwrap().handle.hex());
|
||||
}
|
||||
s.execute("block", &json!({"name": "S", "base": [0, 0], "keep": "delete", "handles": hs})).unwrap();
|
||||
let r = s.execute("insert", &json!({"name": "S", "at": AT, "scale": scale, "rotation": ROTATION})).unwrap();
|
||||
s.execute("explode", &json!({"handles": [r["handle"]]})).unwrap();
|
||||
s.doc().unwrap().model.iter().map(|e| (**e).clone()).collect()
|
||||
}
|
||||
|
||||
/// A drawing point back in block coordinates.
|
||||
fn to_block(p: [f64; 2], scale: [f64; 2]) -> [f64; 2] {
|
||||
let (s, c) = (-ROTATION.to_radians()).sin_cos();
|
||||
let (dx, dy) = (p[0] - AT[0], p[1] - AT[1]);
|
||||
[(dx * c - dy * s) / scale[0], (dx * s + dy * c) / scale[1]]
|
||||
}
|
||||
|
||||
/// Points along an ELLIPSE from its start to its end parameter.
|
||||
fn samples(e: &cadcraft_engine::doc::Ellipse) -> Vec<[f64; 2]> {
|
||||
let mut sweep = e.end - e.start;
|
||||
if sweep <= 0.0 {
|
||||
sweep += std::f64::consts::TAU;
|
||||
}
|
||||
let (mx, my) = (e.major.x, e.major.y);
|
||||
let (nx, ny) = (-my * e.ratio, mx * e.ratio);
|
||||
(0..=16)
|
||||
.map(|i| {
|
||||
let t = e.start + sweep * f64::from(i) / 16.0;
|
||||
[e.center.x + mx * t.cos() + nx * t.sin(), e.center.y + my * t.cos() + ny * t.sin()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ellipses(v: &[Entity]) -> Vec<cadcraft_engine::doc::Ellipse> {
|
||||
v.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Ellipse(el) => Some(el.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
|
||||
(a[0] - b[0]).hypot(a[1] - b[1]) < 1e-9
|
||||
}
|
||||
|
||||
/// Every point of `el`, taken back into the block, lies on the circle of radius `r` about `c`
|
||||
/// in block coordinates, and its ends are `ends` (the whole circle when `None`).
|
||||
fn on_circle(el: &cadcraft_engine::doc::Ellipse, scale: [f64; 2], c: [f64; 2], r: f64, ends: Option<([f64; 2], [f64; 2])>) {
|
||||
let pts: Vec<[f64; 2]> = samples(el).into_iter().map(|p| to_block(p, scale)).collect();
|
||||
for q in &pts {
|
||||
let d = (q[0] - c[0]).hypot(q[1] - c[1]);
|
||||
assert!((d - r).abs() < 1e-9, "{q:?} is {d} from {c:?}, not {r}: {el:?}");
|
||||
}
|
||||
if let Some((a, b)) = ends {
|
||||
let (first, last) = (pts[0], pts[pts.len() - 1]);
|
||||
assert!(near(first, a) && near(last, b) || near(first, b) && near(last, a), "ends {first:?} {last:?}, expected {a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explode_of_a_stretched_reference_draws_circles_and_arcs_as_ellipses() {
|
||||
for scale in [[2.0, 0.5], [-2.0, 0.5], [1.0, 3.0]] {
|
||||
let v = exploded(scale);
|
||||
assert!(!v.iter().any(|e| matches!(e.kind, EntityKind::Circle(_) | EntityKind::Arc(_) | EntityKind::LwPolyline(_))), "{scale:?}: {v:?}");
|
||||
let els = ellipses(&v);
|
||||
// Circle, arc, elliptical arc and the slot's two semicircles.
|
||||
assert_eq!(els.len(), 5, "{scale:?}: {els:?}");
|
||||
let at = |c: [f64; 2]| els.iter().filter(move |e| near(to_block([e.center.x, e.center.y], scale), c));
|
||||
let circle = at([0.0, 0.0]).next().unwrap();
|
||||
on_circle(circle, scale, [0.0, 0.0], 5.0, None);
|
||||
// Its axes are the scaled radii: 10 and 2.5 for 2 x 0.5.
|
||||
let major = circle.major.x.hypot(circle.major.y);
|
||||
let (big, small) = (5.0 * scale[0].abs().max(scale[1].abs()), 5.0 * scale[0].abs().min(scale[1].abs()));
|
||||
assert!((major - big).abs() < 1e-9 && (major * circle.ratio - small).abs() < 1e-9, "{circle:?}");
|
||||
on_circle(at([20.0, 0.0]).next().unwrap(), scale, [20.0, 0.0], 4.0, Some(([24.0, 0.0], [20.0, 4.0])));
|
||||
on_circle(at([60.0, 0.0]).next().unwrap(), scale, [60.0, 0.0], 2.0, Some(([60.0, 2.0], [60.0, -2.0])));
|
||||
on_circle(at([70.0, 0.0]).next().unwrap(), scale, [70.0, 0.0], 2.0, Some(([70.0, -2.0], [70.0, 2.0])));
|
||||
// The elliptical arc: its points, back in the block, are on the block's ellipse
|
||||
// ((x-40)/6)^2 + (y/3)^2 = 1, from (46,0) over the top to (34,0).
|
||||
let ell = at([40.0, 0.0]).next().unwrap();
|
||||
let pts: Vec<[f64; 2]> = samples(ell).into_iter().map(|p| to_block(p, scale)).collect();
|
||||
for q in &pts {
|
||||
let f = ((q[0] - 40.0) / 6.0).powi(2) + (q[1] / 3.0).powi(2);
|
||||
assert!((f - 1.0).abs() < 1e-9 && q[1] > -1e-9, "{q:?}: {ell:?}");
|
||||
}
|
||||
let (first, last) = (pts[0], pts[pts.len() - 1]);
|
||||
assert!(near(first, [46.0, 0.0]) && near(last, [34.0, 0.0]) || near(first, [34.0, 0.0]) && near(last, [46.0, 0.0]), "{pts:?}");
|
||||
// The slot's straight sides and the line are lines, where the reference draws them.
|
||||
let lines: Vec<[[f64; 2]; 2]> = v
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => Some([to_block([l.a.x, l.a.y], scale), to_block([l.b.x, l.b.y], scale)]),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
for [a, b] in [[[60.0, -2.0], [70.0, -2.0]], [[70.0, 2.0], [60.0, 2.0]], [[0.0, 10.0], [10.0, 10.0]]] {
|
||||
assert!(lines.iter().any(|l| near(l[0], a) && near(l[1], b)), "{a:?}-{b:?} not in {lines:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explode_of_an_evenly_scaled_or_mirrored_reference_keeps_circles_arcs_and_polylines() {
|
||||
for scale in [[2.0, 2.0], [-1.0, 1.0]] {
|
||||
let v = exploded(scale);
|
||||
let count = |f: fn(&EntityKind) -> bool| v.iter().filter(|e| f(&e.kind)).count();
|
||||
assert_eq!(count(|k| matches!(k, EntityKind::Circle(_))), 1, "{scale:?}");
|
||||
assert_eq!(count(|k| matches!(k, EntityKind::Arc(_))), 1, "{scale:?}");
|
||||
assert_eq!(count(|k| matches!(k, EntityKind::LwPolyline(_))), 1, "{scale:?}");
|
||||
assert_eq!(count(|k| matches!(k, EntityKind::Ellipse(_))), 1, "{scale:?}");
|
||||
let r = v.iter().find_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None }).unwrap();
|
||||
assert!((r - 5.0 * scale[0].abs()).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! EXTEND on an open polyline whose end segment is an arc: the arc runs on round its own circle
|
||||
//! to the boundary, as EXTEND does with an ARC, instead of the end vertex sliding along the chord
|
||||
//! with the old bulge (which changed the arc's radius and centre, or missed the boundary). The
|
||||
//! polyline keeps its width, elevation and linetype generation.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Common, Entity, EntityKind, Handle, LwPolyline};
|
||||
use cadcraft_engine::geom::{PolyVertex, Vec2};
|
||||
use serde_json::json;
|
||||
|
||||
const TOL: f64 = 1e-9;
|
||||
|
||||
fn add(s: &mut Session, kind: EntityKind) -> String {
|
||||
let space = s.space();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let handle = d.new_handle();
|
||||
d.space_mut(&space).unwrap().push(Entity { handle, common: Common::default(), kind });
|
||||
handle.hex()
|
||||
}
|
||||
|
||||
fn v(x: f64, y: f64, bulge: f64) -> PolyVertex {
|
||||
PolyVertex { p: Vec2::new(x, y), bulge, ..Default::default() }
|
||||
}
|
||||
|
||||
fn pline(vertices: Vec<PolyVertex>) -> LwPolyline {
|
||||
LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false }
|
||||
}
|
||||
|
||||
fn edge(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
|
||||
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
|
||||
r["handles"][0].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn polyline(s: &Session, h: &str) -> LwPolyline {
|
||||
match &s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => p.clone(),
|
||||
k => panic!("not a polyline: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn near(a: Vec2, x: f64, y: f64) -> bool {
|
||||
(a.x - x).abs() < 1e-6 && (a.y - y).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// tan(22.5°): the bulge of a quarter circle.
|
||||
fn quarter() -> f64 {
|
||||
(std::f64::consts::PI / 8.0).tan()
|
||||
}
|
||||
|
||||
/// The bulge of an arc turning through `deg` degrees (positive counterclockwise).
|
||||
fn bulge(deg: f64) -> f64 {
|
||||
(deg.to_radians() / 4.0).tan()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_counterclockwise_arc_at_the_end_runs_on_round_its_circle() {
|
||||
let mut s = Session::new();
|
||||
let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]);
|
||||
// A line in from (20,0), then a quarter circle about the origin from (10,0) to (0,10).
|
||||
let mut pl = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]);
|
||||
pl.const_width = 0.5;
|
||||
pl.elevation = 3.0;
|
||||
pl.plinegen = true;
|
||||
let h = add(&mut s, EntityKind::LwPolyline(pl));
|
||||
s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap();
|
||||
let p = polyline(&s, &h);
|
||||
assert_eq!(p.vertices.len(), 3);
|
||||
// Round the circle (radius 10) through (-10,0) to y = -5: a 210° turn in all.
|
||||
let end = p.vertices[2].p;
|
||||
assert!(near(end, -(75.0f64.sqrt()), -5.0), "end {end:?}");
|
||||
assert!((p.vertices[1].bulge - bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge);
|
||||
assert!(near(p.vertices[0].p, 20.0, 0.0) && near(p.vertices[1].p, 10.0, 0.0));
|
||||
assert_eq!((p.const_width, p.elevation, p.plinegen), (0.5, 3.0, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_arc_at_the_start_runs_on_backwards_round_its_circle() {
|
||||
let mut s = Session::new();
|
||||
let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]);
|
||||
// The same path drawn the other way: clockwise from (0,10) to (10,0), then out to (20,0).
|
||||
let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(0.0, 10.0, -quarter()), v(10.0, 0.0, 0.0), v(20.0, 0.0, 0.0)])));
|
||||
s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap();
|
||||
let p = polyline(&s, &h);
|
||||
assert!(near(p.vertices[0].p, -(75.0f64.sqrt()), -5.0), "start {:?}", p.vertices[0].p);
|
||||
assert!((p.vertices[0].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[0].bulge);
|
||||
assert!(near(p.vertices[1].p, 10.0, 0.0) && near(p.vertices[2].p, 20.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clockwise_arc_at_the_end_runs_on_clockwise() {
|
||||
let mut s = Session::new();
|
||||
let e = edge(&mut s, [-50.0, 5.0], [50.0, 5.0]);
|
||||
// Clockwise about the origin from (10,0) to (0,-10); on round through (-10,0) to y = 5.
|
||||
let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, -quarter()), v(0.0, -10.0, 0.0)])));
|
||||
s.execute("extend", &json!({ "handle": h, "pick": [0.5, -9.9], "edges": [e] })).unwrap();
|
||||
let p = polyline(&s, &h);
|
||||
assert!(near(p.vertices[2].p, -(75.0f64.sqrt()), 5.0), "end {:?}", p.vertices[2].p);
|
||||
assert!((p.vertices[1].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_nearest_crossing_ahead_on_the_circle_wins() {
|
||||
let mut s = Session::new();
|
||||
// A boundary crossing the circle twice ahead of the end: x = -6 at about 127° and 233°.
|
||||
let e = edge(&mut s, [-6.0, -50.0], [-6.0, 50.0]);
|
||||
let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)])));
|
||||
s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap();
|
||||
let p = polyline(&s, &h);
|
||||
assert!(near(p.vertices[2].p, -6.0, 8.0), "end {:?}", p.vertices[2].p);
|
||||
let turn = 8.0f64.atan2(-6.0).to_degrees();
|
||||
assert!((p.vertices[1].bulge - bulge(turn)).abs() < TOL, "bulge {}", p.vertices[1].bulge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_arc_end_whose_circle_misses_every_edge_is_left_alone() {
|
||||
let mut s = Session::new();
|
||||
// The chord (10,0)→(0,10) runs on to this edge, but the circle never reaches it.
|
||||
let e = edge(&mut s, [-50.0, 30.0], [50.0, 30.0]);
|
||||
let before = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]);
|
||||
let h = add(&mut s, EntityKind::LwPolyline(before.clone()));
|
||||
assert!(s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).is_err());
|
||||
assert_eq!(polyline(&s, &h), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straight_end_keeps_the_polyline_width_and_elevation() {
|
||||
let mut s = Session::new();
|
||||
let e = edge(&mut s, [50.0, -50.0], [50.0, 50.0]);
|
||||
let mut pl = pline(vec![v(0.0, 0.0, 0.0), v(10.0, 0.0, 0.0)]);
|
||||
pl.const_width = 0.5;
|
||||
pl.elevation = 3.0;
|
||||
let h = add(&mut s, EntityKind::LwPolyline(pl));
|
||||
s.execute("extend", &json!({ "handle": h, "pick": [9.0, 0.0], "edges": [e] })).unwrap();
|
||||
let p = polyline(&s, &h);
|
||||
assert!(near(p.vertices[1].p, 50.0, 0.0), "end {:?}", p.vertices[1].p);
|
||||
assert_eq!((p.const_width, p.elevation), (0.5, 3.0));
|
||||
}
|
||||
@@ -46,6 +46,8 @@ mod draworder;
|
||||
mod dyn_input;
|
||||
mod dynamic_block_data;
|
||||
mod dynamic_grips;
|
||||
mod explode_stretched;
|
||||
mod extend_polyline_arc_end;
|
||||
mod fillet_chamfer;
|
||||
mod fix_holds_grips;
|
||||
mod followups_626;
|
||||
|
||||
+43
-3
@@ -5,6 +5,11 @@
|
||||
//! (`Noto Sans CJK TC`) among installed files with a similar name. TrueType collections
|
||||
//! (`.ttc` / `.otc`) are scanned too; a stem lookup takes their first face.
|
||||
//!
|
||||
//! System fonts are searched in the platform's own directories: `/System/Library/Fonts` and
|
||||
//! friends on macOS, `C:\Windows\Fonts` on Windows, `/system/fonts` and the product/vendor
|
||||
//! partitions on Android and HarmonyOS, and the XDG directories elsewhere. Web builds have no
|
||||
//! system fonts.
|
||||
//!
|
||||
//! Two process-wide settings (like AutoCAD's profile variables) control substitution:
|
||||
//! - [`font_alt`] (`FONTALT`): the font used for a text style font that can't be found (an SHX
|
||||
//! file we don't read, a missing TTF). Empty = the built-in stroke font.
|
||||
@@ -270,15 +275,20 @@ fn lock() -> MutexGuard<'static, Db> {
|
||||
g
|
||||
}
|
||||
|
||||
/// The platform's system font directories, in search order.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan() -> HashMap<String, PathBuf> {
|
||||
let mut out = HashMap::new();
|
||||
fn font_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = std::env::var_os("HOME") {
|
||||
dirs.push(PathBuf::from(h).join("Library/Fonts"));
|
||||
}
|
||||
} else if cfg!(any(target_os = "android", target_env = "ohos")) {
|
||||
// Android and HarmonyOS keep system fonts under /system/fonts; Android devices also use
|
||||
// the product and vendor partitions for faces of their own. An app may read all of them,
|
||||
// and directories a device lacks are skipped.
|
||||
dirs.extend(["/system/fonts", "/product/fonts", "/system_ext/fonts", "/vendor/fonts"].map(Into::into));
|
||||
} else if cfg!(windows) {
|
||||
dirs.push("C:\\Windows\\Fonts".into());
|
||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
@@ -291,7 +301,14 @@ fn scan() -> HashMap<String, PathBuf> {
|
||||
dirs.push(PathBuf::from(h).join(".local/share/fonts"));
|
||||
}
|
||||
}
|
||||
let mut stack = dirs;
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Every font file under [`font_dirs`], by lower-case file stem.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan() -> HashMap<String, PathBuf> {
|
||||
let mut out = HashMap::new();
|
||||
let mut stack = font_dirs();
|
||||
let mut seen = 0usize;
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
@@ -1080,3 +1097,26 @@ mod geometry_budget_tests {
|
||||
assert!(shaped.text.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod scan_tests {
|
||||
use super::*;
|
||||
|
||||
/// Each platform searches its own system font directories. A branch that is missing or
|
||||
/// misnamed leaves a device with no fonts at all, so the platform's main location is checked
|
||||
/// here rather than discovered by a drawing full of missing-glyph boxes.
|
||||
#[test]
|
||||
fn the_platforms_system_font_directory_is_searched() {
|
||||
let dirs = font_dirs();
|
||||
#[cfg(target_os = "macos")]
|
||||
assert!(dirs.iter().any(|d| d == std::path::Path::new("/System/Library/Fonts")), "{dirs:?}");
|
||||
#[cfg(target_os = "android")]
|
||||
assert!(dirs.iter().any(|d| d == std::path::Path::new("/system/fonts")), "{dirs:?}");
|
||||
#[cfg(target_env = "ohos")]
|
||||
assert!(dirs.iter().any(|d| d == std::path::Path::new("/system/fonts")), "{dirs:?}");
|
||||
#[cfg(windows)]
|
||||
assert!(dirs.iter().any(|d| d == std::path::Path::new("C:\\Windows\\Fonts")), "{dirs:?}");
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_env = "ohos"))))]
|
||||
assert!(dirs.iter().any(|d| d == std::path::Path::new("/usr/share/fonts")), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Bounds2, EPS, PI, TAU, Vec2, angle_in_sweep, arc_segments, ccw_sweep, norm_angle};
|
||||
use crate::{Bounds2, EPS, Mat3, PI, TAU, Vec2, angle_in_sweep, arc_segments, ccw_sweep, norm_angle};
|
||||
|
||||
/// A straight segment.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -247,6 +247,23 @@ impl Ellipse {
|
||||
pub fn sweep(&self) -> f64 {
|
||||
ccw_sweep(self.start, self.end)
|
||||
}
|
||||
/// The image under the affine transform `m`, exactly: the conjugate semi-diameters
|
||||
/// `A·major`, `A·minor` give the new principal axes; parameters shift with them (and
|
||||
/// reverse under a mirroring transform).
|
||||
pub fn transformed(&self, m: &Mat3) -> Ellipse {
|
||||
let center = m.apply(self.center);
|
||||
let (am, an) = (m.apply_vec(self.major), m.apply_vec(self.minor()));
|
||||
// |am·cos t + an·sin t| is largest at t0: the new major axis.
|
||||
let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an));
|
||||
let (s0, c0) = t0.sin_cos();
|
||||
let (u, v) = (am * c0 + an * s0, an * c0 - am * s0);
|
||||
let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 };
|
||||
if self.is_full() {
|
||||
return Ellipse::full(center, u, ratio);
|
||||
}
|
||||
let (start, end) = if u.cross(v) >= 0.0 { (self.start - t0, self.end - t0) } else { (t0 - self.end, t0 - self.start) };
|
||||
Ellipse { center, major: u, ratio, start: norm_angle(start), end: norm_angle(end) }
|
||||
}
|
||||
pub fn tessellate(&self, tol: f64, out: &mut Vec<Vec2>) {
|
||||
let r = self.major.len();
|
||||
let sweep = self.sweep();
|
||||
|
||||
@@ -98,6 +98,14 @@ impl Mat3 {
|
||||
pub fn is_mirroring(&self) -> bool {
|
||||
self.det() < 0.0
|
||||
}
|
||||
/// The rotation-and-uniform-scale factor of the linear part (mirroring allowed), or `None`
|
||||
/// when it distorts shapes (non-uniform scale or shear).
|
||||
pub fn similarity_scale(&self) -> Option<f64> {
|
||||
let (u, v) = (Vec2::new(self.a, self.b), Vec2::new(self.c, self.d));
|
||||
let (lu, lv) = (u.len(), v.len());
|
||||
let tol = 1e-9 * lu.max(lv);
|
||||
((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 4x4 transform, row-major, for 3D views and block transforms with elevation.
|
||||
|
||||
@@ -600,6 +600,9 @@ fn write_annotations_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, pap
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a HATCH: its pattern name, solid and associative flags, its boundary loops, and the
|
||||
/// pattern angle, scale and double flag (written for solid hatches too). Pattern lines are written
|
||||
/// only for non-solid hatches.
|
||||
fn write_hatch_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, _cx: &Ctx) {
|
||||
if let EntityKind::Hatch(h) = &e.kind {
|
||||
w.s(0, "HATCH");
|
||||
@@ -634,10 +637,12 @@ fn write_hatch_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bo
|
||||
}
|
||||
w.i(75, i64::from(h.style));
|
||||
w.i(76, 1);
|
||||
// Angle, scale and double flag are written for solid fills too, as read (AutoCAD and
|
||||
// other writers keep them on SOLID hatches; readers ignore them there).
|
||||
w.f(52, h.angle.to_degrees());
|
||||
w.f(41, h.scale);
|
||||
w.i(77, 0);
|
||||
if !h.solid {
|
||||
w.f(52, h.angle.to_degrees());
|
||||
w.f(41, h.scale);
|
||||
w.i(77, 0);
|
||||
// The lines read from the file, else the library's pattern of that name.
|
||||
let lines = if h.pattern_lines.is_empty() {
|
||||
cadcraft_doc::library::pattern_for(d, &h.pattern).map(|p| p.lines).unwrap_or_default()
|
||||
|
||||
+30
-8
@@ -14,6 +14,8 @@ mod dxf_write;
|
||||
mod dxf_xref;
|
||||
pub mod pdf;
|
||||
pub mod plotter;
|
||||
/// Open → save → reopen comparison and the real-file corpus harness (gaps FF1, FF5).
|
||||
pub mod roundtrip;
|
||||
pub mod svg;
|
||||
mod svg_read;
|
||||
|
||||
@@ -69,19 +71,25 @@ fn ext(name: impl AsRef<Path>) -> String {
|
||||
/// Read a drawing; the format comes from the content (DXF ASCII/binary, DWG) or the name
|
||||
/// (SVG and gzip-compressed SVGZ).
|
||||
pub fn read(bytes: &[u8], name: impl AsRef<Path>) -> Result<Drawing> {
|
||||
let name = name.as_ref();
|
||||
read_source(bytes, name.as_ref()).map(|(d, _)| d)
|
||||
}
|
||||
|
||||
/// [`read`], also returning the DXF rendition a DWG was read through (`None` for other files),
|
||||
/// which the round-trip harness counts.
|
||||
pub(crate) fn read_source(bytes: &[u8], name: &Path) -> Result<(Drawing, Option<Vec<u8>>)> {
|
||||
if cadcraft_dwg::is_dwg(bytes) {
|
||||
let (dxf, notes) = cadcraft_dwg::decode(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
|
||||
let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?;
|
||||
d.read_notes = notes;
|
||||
return Ok(d);
|
||||
return Ok((d, Some(dxf)));
|
||||
}
|
||||
match (Format::from_path(name), ext(name).as_str()) {
|
||||
let d = match (Format::from_path(name), ext(name).as_str()) {
|
||||
(Some(Format::Svg | Format::Svgz), _) => svg_read::read(bytes),
|
||||
(Some(Format::Dxf), _) => dxf_read::read(bytes),
|
||||
(_, e) if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())),
|
||||
(_, e) => Err(IoError::Unsupported(e.to_string())),
|
||||
}
|
||||
}?;
|
||||
Ok((d, None))
|
||||
}
|
||||
|
||||
/// Write a drawing in the format chosen by the name's extension. Images (PNG, SVG, PDF) show
|
||||
@@ -111,15 +119,29 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
|
||||
// The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without
|
||||
// one, at least 2004: R2000 DWG has no true colours, transparency, table styles or
|
||||
// gradients, so a drawing from an R2000 DXF doesn't lose them by default.
|
||||
Some(Format::Dwg) if !opts.binary => {
|
||||
let version = opts.version.unwrap_or_else(|| version.max(Version::R2004));
|
||||
cadcraft_dwg::encode_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
|
||||
}
|
||||
Some(Format::Dwg) if !opts.binary => write_dwg(d, opts.version.unwrap_or_else(|| version.max(Version::R2004))).map(|(dwg, _)| dwg),
|
||||
_ if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{}`", ext(name)))),
|
||||
_ => write_framed(d, name, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`write_with`], also returning what the file leaves out of the drawing: for a DWG, how many
|
||||
/// records of each DXF type the DWG writer can't write (other programs' custom entities and
|
||||
/// objects); for other formats, nothing.
|
||||
pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
|
||||
let name = name.as_ref();
|
||||
if matches!(Format::from_path(name), Some(Format::Dwg)) && !opts.binary {
|
||||
return write_dwg(d, opts.version.unwrap_or_else(|| save_version(d).max(Version::R2004)));
|
||||
}
|
||||
write_with(d, name, opts).map(|b| (b, cadcraft_dwg::Unwritten::new()))
|
||||
}
|
||||
|
||||
/// The DWG bridge reads our R2000 DXF and writes the DWG at `version`, in the DWG worker when
|
||||
/// there is one.
|
||||
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
|
||||
cadcraft_dwg::encode_noted(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
|
||||
}
|
||||
|
||||
/// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in
|
||||
/// drawing units, centred and scaled to fit the image without distortion) instead of the
|
||||
/// extents. Formats that are not images (DXF, DWG) ignore the window. The window is checked with
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
//! What a DXF file holds, counted from its groups, and the same counts of a drawing read from it:
|
||||
//! together they show what opening a file leaves out (the DXF rendition of a DWG for DWG files).
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{Category, Change, Comparison, Options, Out};
|
||||
|
||||
/// Counts of what a file (or a drawing) holds.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Census {
|
||||
/// Entities of model space and every layout, per DXF type.
|
||||
pub entities: BTreeMap<String, usize>,
|
||||
/// Entities of block definitions (other than the model and paper space blocks), per type.
|
||||
pub block_entities: BTreeMap<String, usize>,
|
||||
/// Block definitions other than the model and paper space blocks and the generated ones.
|
||||
pub blocks: usize,
|
||||
/// Blocks CADCraft generates and so doesn't read (tables' blocks, standard arrowheads
|
||||
/// nothing inserts); their entities are not counted.
|
||||
pub generated_blocks: usize,
|
||||
/// Table records per table type (LAYER, LTYPE, STYLE, DIMSTYLE, VIEW, UCS…).
|
||||
pub tables: BTreeMap<String, usize>,
|
||||
/// Non-graphical objects per type. For a drawing: only those kept without being modelled.
|
||||
pub objects: BTreeMap<String, usize>,
|
||||
/// CLASS records. For a drawing: only those kept for unmodelled objects.
|
||||
pub classes: usize,
|
||||
}
|
||||
|
||||
/// Records that belong to the record before them (or delimit blocks), not entities of their own.
|
||||
const SUB_RECORDS: &[&str] = &["VERTEX", "SEQEND", "ATTRIB", "BLOCK", "ENDBLK"];
|
||||
|
||||
fn bump(m: &mut BTreeMap<String, usize>, k: &str) {
|
||||
let n = m.entry(k.to_string()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
|
||||
/// A layout's own paper-space viewport (VIEWPORT with ID 1, group 69): part of the layout, not
|
||||
/// an entity of its own for CADCraft.
|
||||
fn is_overall_viewport(t: &str, g: &[cadcraft_dxf::Tag]) -> bool {
|
||||
t == "VIEWPORT" && g.iter().any(|x| x.code == 69 && x.i64() == 1)
|
||||
}
|
||||
|
||||
fn is_space_block(name: &str) -> bool {
|
||||
let up = name.trim().to_ascii_uppercase();
|
||||
up.starts_with("*MODEL_SPACE") || up.starts_with("*PAPER_SPACE")
|
||||
}
|
||||
|
||||
/// Count what a DXF file (ASCII or binary) holds; `None` when it can't be parsed.
|
||||
pub fn census(dxf: &[u8]) -> Option<Census> {
|
||||
let tags = cadcraft_dxf::parse(dxf).ok()?;
|
||||
let mut c = Census::default();
|
||||
// Blocks CADCraft generates (tables' blocks, standard arrowheads nothing inserts) are left out
|
||||
// on read and rebuilt on save: counted apart.
|
||||
let mut table_blocks: HashSet<String> = HashSet::new();
|
||||
let mut inserted: HashSet<String> = HashSet::new();
|
||||
let mut blocks: Vec<(String, BTreeMap<String, usize>)> = Vec::new();
|
||||
let mut note_refs = |t: &str, g: &[cadcraft_dxf::Tag]| {
|
||||
let name = || g.iter().find(|x| x.code == 2).map(|x| x.str().trim().to_ascii_uppercase());
|
||||
match t {
|
||||
"ACAD_TABLE" => table_blocks.extend(name()),
|
||||
"INSERT" => inserted.extend(name()),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
for s in cadcraft_dxf::sections(&tags) {
|
||||
let records = cadcraft_dxf::records(&s.tags);
|
||||
match s.name.trim().to_ascii_uppercase().as_str() {
|
||||
"ENTITIES" => {
|
||||
for (t, g) in &records {
|
||||
note_refs(t, g);
|
||||
if !SUB_RECORDS.contains(&t.as_str()) && !is_overall_viewport(t, g) {
|
||||
bump(&mut c.entities, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
"BLOCKS" => {
|
||||
let mut space = false;
|
||||
for (t, g) in &records {
|
||||
note_refs(t, g);
|
||||
match t.as_str() {
|
||||
"BLOCK" => {
|
||||
let name = g.iter().find(|x| x.code == 2).map(cadcraft_dxf::Tag::str).unwrap_or_default();
|
||||
space = is_space_block(&name);
|
||||
if !space {
|
||||
blocks.push((name.trim().to_ascii_uppercase(), BTreeMap::new()));
|
||||
}
|
||||
}
|
||||
t if SUB_RECORDS.contains(&t) || is_overall_viewport(t, g) => {}
|
||||
t if space => bump(&mut c.entities, t),
|
||||
t => {
|
||||
if let Some((_, m)) = blocks.last_mut() {
|
||||
bump(m, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"TABLES" => {
|
||||
for (t, _) in &records {
|
||||
if t != "TABLE" && t != "ENDTAB" {
|
||||
bump(&mut c.tables, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
"OBJECTS" => {
|
||||
for (t, _) in &records {
|
||||
bump(&mut c.objects, t);
|
||||
}
|
||||
}
|
||||
"CLASSES" => c.classes = c.classes.saturating_add(records.iter().filter(|(t, _)| t == "CLASS").count()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (name, m) in blocks {
|
||||
if table_blocks.contains(&name) || (crate::dxf_ext::is_standard_arrow(&name) && !inserted.contains(&name)) {
|
||||
c.generated_blocks = c.generated_blocks.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
c.blocks = c.blocks.saturating_add(1);
|
||||
for (t, n) in m {
|
||||
let slot = c.block_entities.entry(t).or_insert(0);
|
||||
*slot = slot.saturating_add(n);
|
||||
}
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// The same counts of a drawing: its entities, blocks and table records, and the objects and
|
||||
/// classes it keeps without modelling them.
|
||||
pub fn census_of(d: &Drawing) -> Census {
|
||||
let mut c = Census::default();
|
||||
for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) {
|
||||
bump(&mut c.entities, e.kind.dxf_name());
|
||||
}
|
||||
for b in d.blocks.values() {
|
||||
for e in b.entities.iter() {
|
||||
bump(&mut c.block_entities, e.kind.dxf_name());
|
||||
}
|
||||
}
|
||||
c.blocks = d.blocks.len();
|
||||
for (t, n) in [
|
||||
("LAYER", d.layers.len()),
|
||||
("LTYPE", d.linetypes.len()),
|
||||
("STYLE", d.text_styles.len()),
|
||||
("DIMSTYLE", d.dim_styles.len()),
|
||||
("VIEW", d.views.len()),
|
||||
("UCS", d.ucss.len()),
|
||||
] {
|
||||
c.tables.insert(t.to_string(), n);
|
||||
}
|
||||
for o in &d.foreign.objects {
|
||||
bump(&mut c.objects, &o.kind);
|
||||
}
|
||||
c.classes = d.foreign.classes.len();
|
||||
c
|
||||
}
|
||||
|
||||
/// Polylines may change between the old-style POLYLINE and LWPOLYLINE when read: counted
|
||||
/// together.
|
||||
fn merged(m: &BTreeMap<String, usize>) -> BTreeMap<String, usize> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (k, n) in m {
|
||||
let key = if k == "POLYLINE" || k == "LWPOLYLINE" { "POLYLINE+LWPOLYLINE" } else { k.as_str() };
|
||||
let slot = out.entry(key.to_string()).or_insert(0usize);
|
||||
*slot = slot.saturating_add(*n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn compare_counts(out: &mut Out, place: &str, file: &BTreeMap<String, usize>, read: &BTreeMap<String, usize>) {
|
||||
let (file, read) = (merged(file), merged(read));
|
||||
let keys: std::collections::BTreeSet<&String> = file.keys().chain(read.keys()).collect();
|
||||
for k in keys {
|
||||
let (f, r) = (file.get(k).copied().unwrap_or(0), read.get(k).copied().unwrap_or(0));
|
||||
if r < f {
|
||||
out.push_n(Category::Read, Change::Lost, f - r, format!("{place} {k}"), format!("{k}: {f} in the file, {r} read"));
|
||||
} else if r > f {
|
||||
out.push_n(Category::Read, Change::Added, r - f, format!("{place} {k}"), format!("{k}: {f} in the file, {r} read"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a drawing read from a file holds less (or more) than the file: entities per type in the
|
||||
/// spaces and in blocks, blocks, and the table records CADCraft models. Blocks CADCraft generates
|
||||
/// (tables' blocks, unused standard arrowheads) are left out on both sides.
|
||||
pub fn compare_census(file: &Census, d: &Drawing, opts: &Options) -> Comparison {
|
||||
let read = census_of(d);
|
||||
let mut out = Out::new(opts);
|
||||
compare_counts(&mut out, "spaces", &file.entities, &read.entities);
|
||||
compare_counts(&mut out, "blocks", &file.block_entities, &read.block_entities);
|
||||
if file.blocks != read.blocks {
|
||||
let change = if read.blocks < file.blocks { Change::Lost } else { Change::Added };
|
||||
out.push_n(
|
||||
Category::Read,
|
||||
change,
|
||||
file.blocks.abs_diff(read.blocks),
|
||||
"BLOCK definitions",
|
||||
format!("{} in the file, {} read", file.blocks, read.blocks),
|
||||
);
|
||||
}
|
||||
for t in ["LAYER", "LTYPE", "STYLE", "DIMSTYLE", "VIEW", "UCS"] {
|
||||
let (f, r) = (file.tables.get(t).copied().unwrap_or(0), read.tables.get(t).copied().unwrap_or(0));
|
||||
if f != r {
|
||||
let change = if r < f { Change::Lost } else { Change::Added };
|
||||
out.push_n(Category::Read, change, f.abs_diff(r), format!("{t} table"), format!("{f} records in the file, {r} read"));
|
||||
}
|
||||
}
|
||||
out.cmp
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
//! The corpus harness: every DXF and DWG file of a folder is opened, saved as DXF and as DWG,
|
||||
//! reopened and compared with the drawing first opened; the findings go to a Markdown and a JSON
|
||||
//! report. A file that fails, or even makes CADCraft panic, is a reported failure: each stage runs
|
||||
//! under `catch_unwind` here, as a last resort of the harness only.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{Category, Census, Change, Comparison, Options, Tally, census, census_of, clip, compare, compare_census};
|
||||
|
||||
/// A drawing file format the harness checks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Format {
|
||||
Dxf,
|
||||
Dwg,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
pub fn ext(self) -> &'static str {
|
||||
match self {
|
||||
Format::Dxf => "dxf",
|
||||
Format::Dwg => "dwg",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Format::Dxf => "DXF",
|
||||
Format::Dwg => "DWG",
|
||||
}
|
||||
}
|
||||
/// The format of a file's bytes: DWG by its magic, otherwise DXF.
|
||||
pub fn of(bytes: &[u8]) -> Format {
|
||||
if cadcraft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf }
|
||||
}
|
||||
fn other(self) -> Format {
|
||||
match self {
|
||||
Format::Dxf => Format::Dwg,
|
||||
Format::Dwg => Format::Dxf,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the harness runs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CorpusOptions {
|
||||
pub compare: Options,
|
||||
/// Also save in the other format (DWG → DXF, DXF → DWG).
|
||||
pub cross_format: bool,
|
||||
/// Count what each file holds, to report what opening it leaves out.
|
||||
pub census: bool,
|
||||
/// Keep the saved files in this folder (and reopen them from there).
|
||||
pub keep_saved: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for CorpusOptions {
|
||||
fn default() -> Self {
|
||||
CorpusOptions { compare: Options::default(), cross_format: true, census: true, keep_saved: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest file the harness opens.
|
||||
pub const MAX_FILE_BYTES: u64 = 1 << 30;
|
||||
/// Deepest folder nesting walked.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
/// Most files taken from one corpus.
|
||||
const MAX_FILES: usize = 100_000;
|
||||
|
||||
/// One save → reopen of a drawing.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteReport {
|
||||
/// `dwg → dxf`.
|
||||
pub route: String,
|
||||
pub saved_bytes: usize,
|
||||
/// Where the saved file was kept, when it was.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub saved_path: Option<String>,
|
||||
pub save_error: Option<String>,
|
||||
pub reopen_error: Option<String>,
|
||||
/// What reopening reported (records the reader couldn't place or decode).
|
||||
pub reopen_notes: Vec<String>,
|
||||
/// What the writer itself left out of the saved file: records per DXF type (upper case) it
|
||||
/// can't write in that format (for DWG: other programs' custom entities and objects). Each
|
||||
/// is lost; the reopened drawing's entity counts show the same entities missing, and a strict
|
||||
/// corpus run fails on any.
|
||||
pub not_written: BTreeMap<String, usize>,
|
||||
/// The reopened drawing against the drawing first opened.
|
||||
pub comparison: Comparison,
|
||||
pub seconds: f64,
|
||||
}
|
||||
|
||||
impl RouteReport {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.save_error.is_none() && self.reopen_error.is_none()
|
||||
}
|
||||
/// Saved and reopened with no difference at all.
|
||||
pub fn lossless(&self) -> bool {
|
||||
self.ok() && self.not_written.is_empty() && self.comparison.is_empty()
|
||||
}
|
||||
/// How many records the writer left out ([`Self::not_written`]).
|
||||
pub fn not_written_total(&self) -> usize {
|
||||
self.not_written.values().fold(0usize, |a, n| a.saturating_add(*n))
|
||||
}
|
||||
}
|
||||
|
||||
/// `2 ACME_WIDGET, 1 OLE2FRAME`.
|
||||
fn per_type(m: &BTreeMap<String, usize>) -> String {
|
||||
m.iter().map(|(t, n)| format!("{n} {t}")).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Everything found for one file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileReport {
|
||||
/// The path relative to the corpus folder.
|
||||
pub path: String,
|
||||
pub format: Option<Format>,
|
||||
/// `AC1032` for a DWG, the `$ACADVER` of a DXF.
|
||||
pub version: String,
|
||||
pub bytes: u64,
|
||||
pub open_error: Option<String>,
|
||||
/// What opening reported (a DWG's records the reader skipped, damaged records).
|
||||
pub open_notes: Vec<String>,
|
||||
pub open_seconds: f64,
|
||||
/// What the file holds (for a DWG: its DXF rendition), when counted.
|
||||
pub file_census: Option<Census>,
|
||||
/// What the drawing read from it holds.
|
||||
pub drawing_census: Option<Census>,
|
||||
/// What the drawing holds less or more than the file.
|
||||
pub read: Comparison,
|
||||
pub routes: Vec<RouteReport>,
|
||||
}
|
||||
|
||||
impl FileReport {
|
||||
pub fn opened(&self) -> bool {
|
||||
self.open_error.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
impl CorpusReport {
|
||||
/// What makes a strict corpus run fail, one line each: a file that didn't open, a round trip
|
||||
/// that didn't save or reopen, and records a writer left out ([`RouteReport::not_written`]).
|
||||
/// Other differences are reported, not failed on.
|
||||
pub fn strict_failures(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for f in &self.files {
|
||||
if !f.opened() {
|
||||
out.push(format!("{}: failed to open", f.path));
|
||||
}
|
||||
for r in &f.routes {
|
||||
if !r.ok() {
|
||||
out.push(format!("{} ({}): failed to save or reopen", f.path, r.route));
|
||||
}
|
||||
if !r.not_written.is_empty() {
|
||||
out.push(format!("{} ({}): lost on save, not written: {}", f.path, r.route, per_type(&r.not_written)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole corpus run.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CorpusReport {
|
||||
pub corpus: String,
|
||||
/// UTC, `2026-10-21 09:30`.
|
||||
pub generated: String,
|
||||
pub cadcraft_version: String,
|
||||
pub abs_tol: f64,
|
||||
pub rel_tol: f64,
|
||||
pub files: Vec<FileReport>,
|
||||
}
|
||||
|
||||
/// Run `f`, turning a panic into an error message.
|
||||
fn guard<T>(f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
std::panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| {
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "no message".into());
|
||||
format!("CRASH (panic): {msg}")
|
||||
})
|
||||
}
|
||||
|
||||
/// A drawing's notes on what reading it left out.
|
||||
fn notes(d: &Drawing) -> Vec<String> {
|
||||
let mut v = d.read_notes.clone();
|
||||
for (section, n) in &d.skipped_records {
|
||||
v.push(format!("{n} record(s) of the {section} section couldn't be placed and were skipped"));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Save `d` as `to`, reopen it and compare. `keep` is the file to keep the saved bytes in (and
|
||||
/// reopen them from).
|
||||
pub fn round_trip(d: &Drawing, from: Format, to: Format, opts: &Options, keep: Option<&Path>) -> RouteReport {
|
||||
let start = Instant::now();
|
||||
let mut r = RouteReport { route: format!("{} → {}", from.ext(), to.ext()), ..RouteReport::default() };
|
||||
let name = format!("roundtrip.{}", to.ext());
|
||||
let saved = match guard(|| crate::write_noted(d, &name, &crate::SaveOptions::default())) {
|
||||
Ok(Ok((b, not_written))) => {
|
||||
r.not_written = not_written;
|
||||
b
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
r.save_error = Some(e.to_string());
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
return r;
|
||||
}
|
||||
Err(e) => {
|
||||
r.save_error = Some(e);
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
return r;
|
||||
}
|
||||
};
|
||||
r.saved_bytes = saved.len();
|
||||
let mut bytes = saved;
|
||||
if let Some(path) = keep {
|
||||
let kept = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|_| std::fs::write(path, &bytes)).and_then(|_| std::fs::read(path));
|
||||
match kept {
|
||||
Ok(b) => {
|
||||
bytes = b;
|
||||
r.saved_path = Some(path.display().to_string());
|
||||
}
|
||||
Err(e) => r.reopen_notes.push(format!("couldn't keep the saved file at {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
let reopened = match guard(|| crate::read(&bytes, &name)) {
|
||||
Ok(Ok(d2)) => d2,
|
||||
Ok(Err(e)) => {
|
||||
r.reopen_error = Some(e.to_string());
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
return r;
|
||||
}
|
||||
Err(e) => {
|
||||
r.reopen_error = Some(e);
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
return r;
|
||||
}
|
||||
};
|
||||
r.reopen_notes.extend(notes(&reopened));
|
||||
match guard(|| compare(d, &reopened, opts)) {
|
||||
Ok(c) => r.comparison = c,
|
||||
Err(e) => r.reopen_error = Some(format!("comparing: {e}")),
|
||||
}
|
||||
attribute_unwritten(&mut r, to);
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
r
|
||||
}
|
||||
|
||||
/// Say on the lost entity rows of a type the writer couldn't write that this is why they are
|
||||
/// lost, so a writer's gap reads as one and isn't taken for a reader's or a comparison's.
|
||||
fn attribute_unwritten(r: &mut RouteReport, to: Format) {
|
||||
if r.not_written.is_empty() {
|
||||
return;
|
||||
}
|
||||
for d in r.comparison.differences.iter_mut().filter(|d| d.category == Category::Count && d.change == Change::Lost) {
|
||||
if let Some(t) = r.not_written.keys().find(|t| d.detail.starts_with(&format!("{t}: "))) {
|
||||
d.detail.push_str(&format!(" (not written: the {} writer can't write {t})", to.label()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kept saved file of `rel` saved as `to`, in `dir`.
|
||||
fn kept_path(dir: &Path, rel: &str, to: Format) -> PathBuf {
|
||||
let flat: String = rel.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '_' } else { c }).collect();
|
||||
dir.join(format!("{flat}.roundtrip.{}", to.ext()))
|
||||
}
|
||||
|
||||
/// Check one file's bytes: open, count, save in each format, reopen, compare. `rel` names it in
|
||||
/// the report.
|
||||
pub fn check_bytes(bytes: &[u8], rel: &str, opts: &CorpusOptions) -> FileReport {
|
||||
let format = Format::of(bytes);
|
||||
let mut f = FileReport { path: rel.to_string(), format: Some(format), bytes: bytes.len() as u64, ..FileReport::default() };
|
||||
if format == Format::Dwg {
|
||||
f.version = cadcraft_dwg::version(bytes).unwrap_or_default();
|
||||
}
|
||||
let start = Instant::now();
|
||||
let name = format!("source.{}", format.ext());
|
||||
let opened = guard(|| crate::read_source(bytes, std::path::Path::new(&name)));
|
||||
f.open_seconds = start.elapsed().as_secs_f64();
|
||||
let (d, dxf) = match opened {
|
||||
Ok(Ok(x)) => x,
|
||||
Ok(Err(e)) => {
|
||||
f.open_error = Some(e.to_string());
|
||||
return f;
|
||||
}
|
||||
Err(e) => {
|
||||
f.open_error = Some(e);
|
||||
return f;
|
||||
}
|
||||
};
|
||||
if format == Format::Dxf {
|
||||
f.version = d.header.str("ACADVER", "");
|
||||
}
|
||||
f.open_notes = notes(&d);
|
||||
f.drawing_census = guard(|| census_of(&d)).ok();
|
||||
if opts.census {
|
||||
let source = dxf.as_deref().unwrap_or(bytes);
|
||||
match guard(|| census(source)) {
|
||||
Ok(Some(c)) => {
|
||||
if let Ok(read) = guard(|| compare_census(&c, &d, &opts.compare)) {
|
||||
f.read = read;
|
||||
}
|
||||
f.file_census = Some(c);
|
||||
}
|
||||
Ok(None) => f.open_notes.push("the file's groups couldn't be counted".into()),
|
||||
Err(e) => f.open_notes.push(format!("counting the file's groups: {e}")),
|
||||
}
|
||||
}
|
||||
let mut targets = vec![format];
|
||||
if opts.cross_format {
|
||||
targets.push(format.other());
|
||||
}
|
||||
for to in targets {
|
||||
let keep = opts.keep_saved.as_deref().map(|dir| kept_path(dir, rel, to));
|
||||
f.routes.push(round_trip(&d, format, to, &opts.compare, keep.as_deref()));
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Check one file on disk; `rel` names it in the report.
|
||||
pub fn check_file(path: &Path, rel: &str, opts: &CorpusOptions) -> FileReport {
|
||||
let fail = |e: String| FileReport { path: rel.to_string(), open_error: Some(e), ..FileReport::default() };
|
||||
match std::fs::metadata(path) {
|
||||
Ok(m) if m.len() > MAX_FILE_BYTES => return fail(format!("the file is {} bytes, more than the {MAX_FILE_BYTES} the harness opens", m.len())),
|
||||
Ok(_) => {}
|
||||
Err(e) => return fail(format!("can't read the file: {e}")),
|
||||
}
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => check_bytes(&bytes, rel, opts),
|
||||
Err(e) => fail(format!("can't read the file: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a path names a DXF or DWG file (by extension).
|
||||
pub fn is_drawing_file(path: &Path) -> bool {
|
||||
path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).is_some_and(|e| e == "dxf" || e == "dwg")
|
||||
}
|
||||
|
||||
/// Every DXF and DWG file under `dir`, sorted, leaving out hidden folders and the folders in
|
||||
/// `skip` (the report folder). Symbolic links are not followed.
|
||||
pub fn corpus_files(dir: &Path, skip: &[PathBuf]) -> Vec<PathBuf> {
|
||||
walk(dir, skip).files
|
||||
}
|
||||
|
||||
/// What [`walk`] found, and what it couldn't look at, so a report never passes for a whole one.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Walk {
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Folders and entries that couldn't be read, with why.
|
||||
pub unreadable: Vec<String>,
|
||||
/// Files past [`MAX_FILES`] or folders past the depth limit were left out.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// [`corpus_files`], also saying what it had to leave out.
|
||||
pub fn walk(dir: &Path, skip: &[PathBuf]) -> Walk {
|
||||
let skip: Vec<PathBuf> = skip.iter().map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())).collect();
|
||||
let mut w = Walk::default();
|
||||
let mut stack = vec![(dir.to_path_buf(), 0usize)];
|
||||
while let Some((d, depth)) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&d) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
w.unreadable.push(format!("{}: {e}", d.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
w.unreadable.push(format!("{}: {e}", d.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
let ft = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(e) => {
|
||||
w.unreadable.push(format!("{}: {e}", path.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if ft.is_dir() {
|
||||
let hidden = entry.file_name().to_string_lossy().starts_with('.');
|
||||
let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
|
||||
if hidden || skip.contains(&canon) {
|
||||
continue;
|
||||
}
|
||||
if depth < MAX_DEPTH {
|
||||
stack.push((path, depth + 1));
|
||||
} else {
|
||||
w.truncated = true;
|
||||
}
|
||||
} else if ft.is_file() && is_drawing_file(&path) {
|
||||
if w.files.len() < MAX_FILES {
|
||||
w.files.push(path);
|
||||
} else {
|
||||
w.truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
w.files.sort();
|
||||
w.unreadable.sort();
|
||||
w
|
||||
}
|
||||
|
||||
/// `path` relative to `root`, with forward slashes.
|
||||
pub fn relative(path: &Path, root: &Path) -> String {
|
||||
let rel = path.strip_prefix(root).unwrap_or(path);
|
||||
rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/")
|
||||
}
|
||||
|
||||
/// Check `files` (paths under `root`, which names the corpus in the report), calling `progress`
|
||||
/// with (index, count, relative path) before each.
|
||||
pub fn run(root: &Path, files: &[PathBuf], opts: &CorpusOptions, progress: &mut dyn FnMut(usize, usize, &str)) -> CorpusReport {
|
||||
let mut report = CorpusReport {
|
||||
corpus: root.display().to_string(),
|
||||
generated: now_utc(),
|
||||
cadcraft_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
abs_tol: opts.compare.abs_tol,
|
||||
rel_tol: opts.compare.rel_tol,
|
||||
files: Vec::new(),
|
||||
};
|
||||
for (i, path) in files.iter().enumerate() {
|
||||
let rel = relative(path, root);
|
||||
progress(i, files.len(), &rel);
|
||||
let f = match guard(|| check_file(path, &rel, opts)) {
|
||||
Ok(f) => f,
|
||||
Err(e) => FileReport { path: rel, open_error: Some(e), ..FileReport::default() },
|
||||
};
|
||||
report.files.push(f);
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// The current time, UTC, as `2026-10-21 09:30`.
|
||||
fn now_utc() -> String {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let days = i64::try_from(secs / 86_400).unwrap_or(0);
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Days to a civil date (proleptic Gregorian), after Howard Hinnant's public-domain algorithm.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!("{year:04}-{month:02}-{day:02} {h:02}:{m:02} UTC")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Rendering
|
||||
|
||||
/// Most differences listed per round trip in the Markdown report (all are in the JSON).
|
||||
const MD_ROWS: usize = 300;
|
||||
|
||||
/// Text for a Markdown table cell.
|
||||
fn cell(s: &str) -> String {
|
||||
s.replace('|', "\\|").replace(['\r', '\n'], " ")
|
||||
}
|
||||
|
||||
fn size(n: u64) -> String {
|
||||
if n >= 1 << 20 {
|
||||
format!("{:.1} MB", n as f64 / f64::from(1u32 << 20))
|
||||
} else if n >= 1 << 10 {
|
||||
format!("{:.0} KB", n as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{n} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn status(err: &Option<String>) -> String {
|
||||
match err {
|
||||
None => "ok".into(),
|
||||
Some(e) if e.starts_with("CRASH") => "**CRASH**".into(),
|
||||
Some(_) => "**failed**".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tally_cells(t: Tally) -> String {
|
||||
format!("{} | {} | {}", t.lost, t.added, t.changed)
|
||||
}
|
||||
|
||||
impl CorpusReport {
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
|
||||
}
|
||||
|
||||
/// Totals per category over every round trip.
|
||||
pub fn totals(&self) -> std::collections::BTreeMap<Category, Tally> {
|
||||
let mut all: std::collections::BTreeMap<Category, Tally> = std::collections::BTreeMap::new();
|
||||
for r in self.files.iter().flat_map(|f| f.routes.iter()) {
|
||||
for (c, t) in &r.comparison.totals {
|
||||
let slot = all.entry(*c).or_default();
|
||||
slot.add(Change::Lost, t.lost);
|
||||
slot.add(Change::Added, t.added);
|
||||
slot.add(Change::Changed, t.changed);
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
pub fn to_markdown(&self) -> String {
|
||||
let mut s = String::new();
|
||||
let opened = self.files.iter().filter(|f| f.opened()).count();
|
||||
let routes: Vec<&RouteReport> = self.files.iter().flat_map(|f| f.routes.iter()).collect();
|
||||
let save_failed = routes.iter().filter(|r| r.save_error.is_some()).count();
|
||||
let reopen_failed = routes.iter().filter(|r| r.reopen_error.is_some()).count();
|
||||
let lossless = routes.iter().filter(|r| r.lossless()).count();
|
||||
let crashes = self.files.iter().filter(|f| f.open_error.as_deref().is_some_and(|e| e.starts_with("CRASH"))).count()
|
||||
+ routes.iter().filter(|r| [&r.save_error, &r.reopen_error].iter().any(|e| e.as_deref().is_some_and(|e| e.contains("CRASH")))).count();
|
||||
s.push_str("# CADCraft round-trip report\n\n");
|
||||
s.push_str(&format!("- Corpus: `{}`\n", self.corpus));
|
||||
s.push_str(&format!("- Generated: {} by CADCraft {}\n", self.generated, self.cadcraft_version));
|
||||
s.push_str(&format!("- Tolerance for numbers: {:e} absolute + {:e} relative\n", self.abs_tol, self.rel_tol));
|
||||
s.push_str(&format!(
|
||||
"- Files: {} ({opened} opened, {} failed to open); round trips: {} ({save_failed} failed to save, {reopen_failed} failed to reopen, {lossless} without any difference); crashes: {crashes}\n",
|
||||
self.files.len(),
|
||||
self.files.len() - opened,
|
||||
routes.len()
|
||||
));
|
||||
let mut unwritten: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for r in &routes {
|
||||
for (t, n) in &r.not_written {
|
||||
let slot = unwritten.entry(format!("{t} ({})", r.route)).or_insert(0);
|
||||
*slot = slot.saturating_add(*n);
|
||||
}
|
||||
}
|
||||
if unwritten.is_empty() {
|
||||
s.push_str("- Lost on save (records a writer can't write): none\n\n");
|
||||
} else {
|
||||
s.push_str(&format!("- **Lost on save** (records a writer can't write): {}\n\n", per_type(&unwritten)));
|
||||
}
|
||||
s.push_str(
|
||||
"Each file is opened, saved in its own format and in the other one, and the saved file is reopened and compared with \
|
||||
the drawing first opened. *Read* counts what the file holds that the drawing read from it doesn't (for a DWG: its \
|
||||
DXF rendition). Lost/added/changed count items (entities, records, values).\n\n",
|
||||
);
|
||||
s.push_str("## Summary\n\n");
|
||||
s.push_str("| File | Format | Size | Open | Read lost | Round trip | Save | Reopen | Not written | Lost | Added | Changed |\n");
|
||||
s.push_str("|---|---|---|---|---|---|---|---|---|---|---|---|\n");
|
||||
for f in &self.files {
|
||||
let fmt = f.format.map_or("?".to_string(), |x| format!("{} {}", x.label(), f.version).trim().to_string());
|
||||
let open = if f.opened() { format!("ok ({:.1} s)", f.open_seconds) } else { status(&f.open_error) };
|
||||
let lead = format!("| `{}` | {} | {} | {} | {} ", cell(&f.path), cell(&fmt), size(f.bytes), open, f.read.tally(Category::Read).lost);
|
||||
if f.routes.is_empty() {
|
||||
s.push_str(&format!("{lead}| — | — | — | — | — | — | — |\n"));
|
||||
}
|
||||
for (i, r) in f.routes.iter().enumerate() {
|
||||
let head = if i == 0 { lead.clone() } else { "| | | | | ".to_string() };
|
||||
let t = r.comparison.total();
|
||||
let reopen = if r.save_error.is_some() { "—".to_string() } else { status(&r.reopen_error) };
|
||||
let unwritten = if r.not_written.is_empty() { "0".to_string() } else { format!("**{}**", r.not_written_total()) };
|
||||
s.push_str(&format!("{head}| {} | {} | {} | {unwritten} | {} |\n", r.route, status(&r.save_error), reopen, tally_cells(t)));
|
||||
}
|
||||
}
|
||||
s.push_str("\n## By category (all round trips)\n\n| Category | Lost | Added | Changed |\n|---|---|---|---|\n");
|
||||
let totals = self.totals();
|
||||
for c in Category::ALL {
|
||||
if let Some(t) = totals.get(&c).filter(|t| t.total() > 0) {
|
||||
s.push_str(&format!("| {} | {} |\n", c.label(), tally_cells(*t)));
|
||||
}
|
||||
}
|
||||
if totals.values().all(|t| t.total() == 0) {
|
||||
s.push_str("| (no differences) | 0 | 0 | 0 |\n");
|
||||
}
|
||||
s.push_str("\n## Files\n");
|
||||
for f in &self.files {
|
||||
self.file_markdown(f, &mut s);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn file_markdown(&self, f: &FileReport, s: &mut String) {
|
||||
s.push_str(&format!("\n### `{}`\n\n", f.path));
|
||||
let fmt = f.format.map_or("unknown format".to_string(), |x| format!("{} {}", x.label(), f.version).trim().to_string());
|
||||
s.push_str(&format!("- {fmt}, {}\n", size(f.bytes)));
|
||||
match &f.open_error {
|
||||
Some(e) => {
|
||||
s.push_str(&format!("- Open: {}: {}\n", status(&f.open_error), cell(e)));
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
let what = f.drawing_census.as_ref().map_or(String::new(), |c| {
|
||||
let n: usize = c.entities.values().sum();
|
||||
let nb: usize = c.block_entities.values().sum();
|
||||
format!(
|
||||
": {n} entities in model and paper space, {} blocks holding {nb} entities, {} layers, {} unmodelled objects kept",
|
||||
c.blocks,
|
||||
c.tables.get("LAYER").copied().unwrap_or(0),
|
||||
c.objects.values().sum::<usize>()
|
||||
)
|
||||
});
|
||||
s.push_str(&format!("- Open: ok in {:.1} s{what}\n", f.open_seconds));
|
||||
}
|
||||
}
|
||||
for n in &f.open_notes {
|
||||
s.push_str(&format!("- Note on opening: {}\n", cell(n)));
|
||||
}
|
||||
if let Some(c) = &f.file_census
|
||||
&& !c.objects.is_empty()
|
||||
{
|
||||
let kept = f.drawing_census.as_ref().map(|d| &d.objects);
|
||||
let list: Vec<String> = c
|
||||
.objects
|
||||
.iter()
|
||||
.map(|(k, n)| match kept.and_then(|m| m.get(k)) {
|
||||
Some(m) => format!("{k} {n} ({m} kept as unmodelled)"),
|
||||
None => format!("{k} {n}"),
|
||||
})
|
||||
.collect();
|
||||
s.push_str(&format!("- Objects in the file: {}\n", cell(&list.join(", "))));
|
||||
}
|
||||
if !f.read.is_empty() {
|
||||
s.push_str("\n#### Read (file → drawing)\n\n");
|
||||
diff_table(&f.read, s);
|
||||
}
|
||||
for r in &f.routes {
|
||||
s.push_str(&format!("\n#### {}\n\n", r.route));
|
||||
match (&r.save_error, &r.reopen_error) {
|
||||
(Some(e), _) => s.push_str(&format!("- Save: {}: {}\n", status(&r.save_error), cell(e))),
|
||||
(None, Some(e)) => {
|
||||
s.push_str(&format!("- Save: ok ({})\n", size(r.saved_bytes as u64)));
|
||||
s.push_str(&format!("- Reopen: {}: {}\n", status(&r.reopen_error), cell(e)));
|
||||
}
|
||||
(None, None) => {
|
||||
let t = r.comparison.total();
|
||||
s.push_str(&format!(
|
||||
"- Saved {} and reopened in {:.1} s: {} lost, {} added, {} changed\n",
|
||||
size(r.saved_bytes as u64),
|
||||
r.seconds,
|
||||
t.lost,
|
||||
t.added,
|
||||
t.changed
|
||||
));
|
||||
}
|
||||
}
|
||||
if !r.not_written.is_empty() {
|
||||
s.push_str(&format!("- **Lost on save**, not written (the writer can't write these types): {}\n", per_type(&r.not_written)));
|
||||
}
|
||||
if let Some(p) = &r.saved_path {
|
||||
s.push_str(&format!("- Saved file: `{p}`\n"));
|
||||
}
|
||||
for n in &r.reopen_notes {
|
||||
s.push_str(&format!("- Note on reopening: {}\n", cell(n)));
|
||||
}
|
||||
if !r.comparison.is_empty() {
|
||||
s.push('\n');
|
||||
diff_table(&r.comparison, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `report.md` and `report.json` into `dir` (created if needed); returns their paths.
|
||||
pub fn write(&self, dir: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let (md, json) = (dir.join("report.md"), dir.join("report.json"));
|
||||
std::fs::write(&md, self.to_markdown())?;
|
||||
std::fs::write(&json, self.to_json())?;
|
||||
Ok((md, json))
|
||||
}
|
||||
}
|
||||
|
||||
/// A comparison's differences as a Markdown table (the first [`MD_ROWS`]).
|
||||
fn diff_table(c: &Comparison, s: &mut String) {
|
||||
let tallies: Vec<String> = Category::ALL
|
||||
.iter()
|
||||
.filter_map(|cat| {
|
||||
let t = c.tally(*cat);
|
||||
(t.total() > 0).then(|| format!("{} {}/{}/{}", cat.label(), t.lost, t.added, t.changed))
|
||||
})
|
||||
.collect();
|
||||
s.push_str(&format!("By category (lost/added/changed): {}\n\n", tallies.join(" · ")));
|
||||
s.push_str("| Category | Change | Where | Detail |\n|---|---|---|---|\n");
|
||||
for d in c.differences.iter().take(MD_ROWS) {
|
||||
s.push_str(&format!("| {} | {} | {} | {} |\n", d.category.label(), d.change.label(), cell(&clip(&d.place, 80)), cell(&clip(&d.detail, 400))));
|
||||
}
|
||||
let more = c.differences.len().saturating_sub(MD_ROWS).saturating_add(c.omitted);
|
||||
if more > 0 {
|
||||
s.push_str(&format!("\n… and {more} more differences (see report.json; differences beyond the listing limit are only counted).\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::foreign::{ForeignExtra, ForeignObject};
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tag(code: i32, value: &str) -> RawTag {
|
||||
RawTag { code, value: value.into() }
|
||||
}
|
||||
|
||||
fn line(a: (f64, f64), b: (f64, f64)) -> EntityKind {
|
||||
EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })
|
||||
}
|
||||
|
||||
/// A small drawing touching every part the comparator looks at.
|
||||
fn drawing() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() });
|
||||
let walls = Common { layer: "Walls".into(), ..Common::default() };
|
||||
d.add(&Space::Model, walls.clone(), line((0.0, 0.0), (10.0, 0.0))).unwrap();
|
||||
d.add(&Space::Model, walls, line((0.0, 5.0), (10.0, 5.0))).unwrap();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(3.0, 4.0, 0.0), radius: 2.5 })).unwrap();
|
||||
let tagged = Common { xdata: vec![tag(1001, "ACME"), tag(1000, "door"), tag(1040, "2.5")], ..Common::default() };
|
||||
d.add(
|
||||
&Space::Model,
|
||||
tagged,
|
||||
EntityKind::LwPolyline(LwPolyline {
|
||||
vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::with_bulge(Vec2::new(4.0, 0.0), 0.5), PolyVertex::new(Vec2::new(4.0, 3.0))],
|
||||
closed: true,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
d.add(
|
||||
&Space::Model,
|
||||
Common::default(),
|
||||
EntityKind::Unknown(Unknown { dxf_type: "ACME_WIDGET".into(), tags: vec![tag(100, "AcDbEntity"), tag(10, "1.0"), tag(20, "2.0")] }),
|
||||
)
|
||||
.unwrap();
|
||||
d.add(&Space::Paper("Layout1".into()), Common::default(), line((1.0, 1.0), (2.0, 2.0))).unwrap();
|
||||
let mut b = Block::new("DOOR");
|
||||
let h = d.new_handle();
|
||||
b.entities.push(Entity::new(h, line((0.0, 0.0), (0.0, 1.0))));
|
||||
d.blocks.insert("DOOR".into(), Arc::new(b));
|
||||
let f = Arc::make_mut(&mut d.foreign);
|
||||
f.objects.push(ForeignObject { handle: Handle(0x500), kind: "XRECORD".into(), tags: vec![tag(100, "AcDbXrecord"), tag(1, "kept")] });
|
||||
f.dictionary.push(("ACME_DATA".into(), Handle(0x500)));
|
||||
f.classes.push(vec![tag(1, "ACME_WIDGET"), tag(2, "AcmeWidget"), tag(3, "Acme")]);
|
||||
f.records.insert(("LAYER".into(), "WALLS".into()), ForeignExtra { xdata: vec![tag(1001, "ACME"), tag(1070, "3")], ..ForeignExtra::default() });
|
||||
d
|
||||
}
|
||||
|
||||
fn cmp(a: &Drawing, b: &Drawing) -> Comparison {
|
||||
compare(a, b, &Options::default())
|
||||
}
|
||||
|
||||
fn only(c: &Comparison, cat: Category, change: Change) -> Vec<&Difference> {
|
||||
c.differences.iter().filter(|d| d.category == cat && d.change == change).collect()
|
||||
}
|
||||
|
||||
fn first_handle(d: &Drawing, kind: &str) -> Handle {
|
||||
d.model.iter().find(|e| e.kind.dxf_name() == kind).map(|e| e.handle).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_drawings_have_no_differences() {
|
||||
let d = drawing();
|
||||
let c = cmp(&d, &d.clone());
|
||||
assert!(c.is_empty(), "{:#?}", c.differences);
|
||||
assert_eq!(c.total(), Tally::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_is_compared_by_handle_within_the_tolerance() {
|
||||
let a = drawing();
|
||||
let h = first_handle(&a, "LINE");
|
||||
let mut b = a.clone();
|
||||
b.modify_entity(h, |e| {
|
||||
if let EntityKind::Line(l) = &mut e.kind {
|
||||
l.b.x += 1e-9;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert!(cmp(&a, &b).is_empty(), "a change within the tolerance is no difference");
|
||||
b.modify_entity(h, |e| {
|
||||
if let EntityKind::Line(l) = &mut e.kind {
|
||||
l.b.x = 11.0;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let c = cmp(&a, &b);
|
||||
let g = only(&c, Category::Geometry, Change::Changed);
|
||||
assert_eq!(g.len(), 1, "{:#?}", c.differences);
|
||||
assert!(g[0].place.contains(&h.hex()) && g[0].place.starts_with("Model LINE"), "{:?}", g[0]);
|
||||
assert!(g[0].detail.contains("b.x: 10") && g[0].detail.contains("11"), "{:?}", g[0]);
|
||||
assert_eq!(c.tally(Category::Geometry).changed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angles_a_whole_turn_apart_are_the_same() {
|
||||
let mut a = drawing();
|
||||
let h = a
|
||||
.add(&Space::Model, Common::default(), EntityKind::Arc(cadcraft_doc::Arc { center: Vec3::ZERO, radius: 2.0, start: -0.5, end: 1.0 }))
|
||||
.unwrap();
|
||||
let mut b = a.clone();
|
||||
b.modify_entity(h, |e| {
|
||||
if let EntityKind::Arc(x) = &mut e.kind {
|
||||
x.start += std::f64::consts::TAU;
|
||||
x.end -= std::f64::consts::TAU;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert!(cmp(&a, &b).is_empty(), "{:#?}", cmp(&a, &b).differences);
|
||||
// A radius is no angle.
|
||||
b.modify_entity(h, |e| {
|
||||
if let EntityKind::Arc(x) = &mut e.kind {
|
||||
x.radius += std::f64::consts::TAU;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(cmp(&a, &b).tally(Category::Geometry).changed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_and_type_changes_are_reported() {
|
||||
let a = drawing();
|
||||
let h = first_handle(&a, "CIRCLE");
|
||||
let mut b = a.clone();
|
||||
b.modify_entity(h, |e| e.common.color = Color::Index(3)).unwrap();
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(only(&c, Category::Properties, Change::Changed).len(), 1, "{:#?}", c.differences);
|
||||
b.modify_entity(h, |e| e.kind = line((0.0, 0.0), (1.0, 1.0))).unwrap();
|
||||
let c = cmp(&a, &b);
|
||||
let g = only(&c, Category::Geometry, Change::Changed);
|
||||
assert!(g.iter().any(|d| d.detail == "type CIRCLE → LINE"), "{:#?}", c.differences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_and_added_entities_are_counted_per_type_and_space() {
|
||||
let a = drawing();
|
||||
let h = first_handle(&a, "CIRCLE");
|
||||
let mut b = a.clone();
|
||||
b.remove_entity(h);
|
||||
let mut paper = b.clone();
|
||||
paper.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 })).unwrap();
|
||||
let c = cmp(&a, &paper);
|
||||
let lost = only(&c, Category::Count, Change::Lost);
|
||||
assert_eq!(lost.len(), 1, "{:#?}", c.differences);
|
||||
assert_eq!(lost[0].place, "Model CIRCLE");
|
||||
assert!(lost[0].detail.contains("1 → 0") && lost[0].detail.contains(&h.hex()), "{:?}", lost[0]);
|
||||
let added = only(&c, Category::Count, Change::Added);
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(added[0].place, "Layout Layout1 POINT");
|
||||
assert_eq!(c.tally(Category::Count), Tally { lost: 1, added: 1, changed: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renumbered_entities_are_matched_by_content() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
// Every line gets a new handle, in reverse order, and one of them moves.
|
||||
let lines: Vec<Arc<Entity>> = b.model.iter().filter(|e| e.kind.dxf_name() == "LINE").cloned().collect();
|
||||
for (i, e) in lines.iter().rev().enumerate() {
|
||||
b.model.remove(e.handle);
|
||||
let mut e = Entity::clone(e);
|
||||
e.handle = Handle(0x9000 + i as u64);
|
||||
b.model.push(e);
|
||||
}
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(c.tally(Category::Count), Tally::default(), "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Geometry), Tally::default(), "pairs by content, not by order: {:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Handle).changed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_moving_between_spaces_are_reported() {
|
||||
let a = drawing();
|
||||
let h = first_handle(&a, "CIRCLE");
|
||||
let mut b = a.clone();
|
||||
let e = b.remove_entity(h).unwrap();
|
||||
if let Some(l) = b.layouts.first_mut() {
|
||||
l.entities.push(Entity::clone(&e));
|
||||
}
|
||||
let c = cmp(&a, &b);
|
||||
let moved = only(&c, Category::Handle, Change::Changed);
|
||||
assert_eq!(moved.len(), 1, "{:#?}", c.differences);
|
||||
assert_eq!(moved[0].detail, format!("1 entities moved from Model to Layout Layout1 ({})", h.hex()));
|
||||
assert_eq!(c.tally(Category::Count), Tally::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreferenced_anonymous_blocks_dropped_on_save_are_one_row() {
|
||||
let mut a = drawing();
|
||||
for name in ["*D5", "*D6"] {
|
||||
let mut blk = Block::new(name);
|
||||
for _ in 0..2 {
|
||||
let h = a.new_handle();
|
||||
blk.entities.push(Entity::new(h, line((0.0, 0.0), (1.0, 0.0))));
|
||||
}
|
||||
a.blocks.insert(name.into(), Arc::new(blk));
|
||||
}
|
||||
let mut b = a.clone();
|
||||
b.blocks.remove("*D5");
|
||||
b.blocks.remove("*D6");
|
||||
let c = cmp(&a, &b);
|
||||
let lost = only(&c, Category::Count, Change::Lost);
|
||||
assert_eq!(lost.len(), 1, "{:#?}", c.differences);
|
||||
assert_eq!(lost[0].detail, "4 entities of 2 anonymous blocks nothing refers to (e.g. *D5) were not saved");
|
||||
assert_eq!(c.tally(Category::Count).lost, 4);
|
||||
assert_eq!(c.tally(Category::Block), Tally { lost: 2, added: 0, changed: 0 }, "{:#?}", c.differences);
|
||||
assert_eq!(only(&c, Category::Block, Change::Lost).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_anonymous_blocks_are_matched_by_their_entities() {
|
||||
let mut a = drawing();
|
||||
let mut blk = Block::new("*D1");
|
||||
let h = a.new_handle();
|
||||
blk.entities.push(Entity::new(h, line((0.0, 0.0), (5.0, 0.0))));
|
||||
a.blocks.insert("*D1".into(), Arc::new(blk.clone()));
|
||||
let key = ("BLOCK_RECORD".to_string(), "*D1".to_string());
|
||||
Arc::make_mut(&mut a.foreign).records.insert(key, ForeignExtra { xdata: vec![tag(1001, "ACME"), tag(1070, "1")], ..ForeignExtra::default() });
|
||||
let mut b = a.clone();
|
||||
b.blocks.remove("*D1");
|
||||
blk.name = "*D7".into();
|
||||
b.blocks.insert("*D7".into(), Arc::new(blk));
|
||||
let f = Arc::make_mut(&mut b.foreign);
|
||||
let extra = f.records.remove(&("BLOCK_RECORD".to_string(), "*D1".to_string())).unwrap();
|
||||
f.records.insert(("BLOCK_RECORD".into(), "*D7".into()), extra);
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(c.tally(Category::Count), Tally::default(), "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Xdata), Tally::default(), "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Block), Tally { lost: 0, added: 0, changed: 1 }, "{:#?}", c.differences);
|
||||
assert!(c.differences.iter().any(|d| d.detail == "1 anonymous blocks saved under new names (e.g. *D1 → *D7)"), "{:#?}", c.differences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdata_extension_dictionaries_and_opaque_entities_are_compared() {
|
||||
let a = drawing();
|
||||
let pl = first_handle(&a, "LWPOLYLINE");
|
||||
let unknown = first_handle(&a, "ACME_WIDGET");
|
||||
let mut b = a.clone();
|
||||
b.modify_entity(pl, |e| e.common.xdata.clear()).unwrap();
|
||||
b.modify_entity(unknown, |e| {
|
||||
if let EntityKind::Unknown(u) = &mut e.kind {
|
||||
u.tags.pop();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
Arc::make_mut(&mut b.foreign).records.clear();
|
||||
let mut a2 = a.clone();
|
||||
Arc::make_mut(&mut a2.foreign)
|
||||
.entities
|
||||
.insert(pl, ForeignExtra { xdict: Some(Handle(0x600)), reactors: vec![Handle(0x601)], ..ForeignExtra::default() });
|
||||
let c = cmp(&a2, &b);
|
||||
let x = only(&c, Category::Xdata, Change::Lost);
|
||||
assert_eq!(x.len(), 2, "the entity's and the layer's: {:#?}", c.differences);
|
||||
assert!(x.iter().any(|d| d.place == "LAYER WALLS" && d.detail.starts_with("application ACME")), "{x:#?}");
|
||||
assert!(x.iter().any(|d| d.place.contains("LWPOLYLINE")), "{x:#?}");
|
||||
assert_eq!(c.tally(Category::ExtDict).lost, 2, "the dictionary and a reactor: {:#?}", c.differences);
|
||||
let o = only(&c, Category::Opaque, Change::Changed);
|
||||
assert_eq!(o.len(), 1, "{:#?}", c.differences);
|
||||
assert!(o[0].detail.contains("3 groups → 2"), "{:?}", o[0]);
|
||||
// Numbers in kept groups compare within the tolerance.
|
||||
let mut b2 = a.clone();
|
||||
b2.modify_entity(unknown, |e| {
|
||||
if let EntityKind::Unknown(u) = &mut e.kind {
|
||||
u.tags[1].value = "1.0000000000001".into();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert!(cmp(&a, &b2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_blocks_and_layouts_are_compared_by_name() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
b.layers.retain(|l| l.name != "Walls");
|
||||
b.linetypes.push(Linetype { name: "Dashed".into(), description: String::new(), pattern: Vec::new() });
|
||||
if let Some(s) = b.text_styles.first_mut() {
|
||||
s.height = 7.0;
|
||||
}
|
||||
b.blocks.remove("DOOR");
|
||||
b.layouts.retain(|l| l.name != "Layout2");
|
||||
let c = cmp(&a, &b);
|
||||
let t = |cat, ch, place: &str| c.differences.iter().any(|d| d.category == cat && d.change == ch && d.place == place);
|
||||
assert!(t(Category::Table, Change::Lost, "LAYER Walls"), "{:#?}", c.differences);
|
||||
assert!(t(Category::Table, Change::Added, "LTYPE Dashed"));
|
||||
assert!(t(Category::Table, Change::Changed, "STYLE Standard"));
|
||||
assert!(t(Category::Block, Change::Lost, "BLOCK DOOR"));
|
||||
assert!(t(Category::Layout, Change::Lost, "LAYOUT Layout2"));
|
||||
// The block's entity is lost with it, counted where it was.
|
||||
assert!(t(Category::Count, Change::Lost, "Block DOOR LINE"), "{:#?}", c.differences);
|
||||
// Names match without case.
|
||||
let mut upper = a.clone();
|
||||
if let Some(l) = upper.layers.iter_mut().find(|l| l.name == "Walls") {
|
||||
l.name = "WALLS".into();
|
||||
}
|
||||
let c = cmp(&a, &upper);
|
||||
assert_eq!(c.tally(Category::Table).lost, 0, "{:#?}", c.differences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmodelled_objects_dictionary_entries_and_classes_are_compared() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
let f = Arc::make_mut(&mut b.foreign);
|
||||
f.objects.clear();
|
||||
f.dictionary.clear();
|
||||
f.classes.clear();
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(c.tally(Category::Object).lost, 1, "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Dictionary).lost, 1);
|
||||
assert_eq!(c.tally(Category::Class).lost, 1);
|
||||
// A renumbered object is matched by its groups.
|
||||
let mut b = a.clone();
|
||||
Arc::make_mut(&mut b.foreign).objects[0].handle = Handle(0x700);
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(c.tally(Category::Object), Tally::default(), "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Handle).changed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_variables_are_compared_without_volatile_ones() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
b.header.set_f64("LTSCALE", 2.0);
|
||||
b.header.vars.remove("TEXTSIZE");
|
||||
b.header.set_str("HANDSEED", "FFFF");
|
||||
b.header.set_str("TDUPDATE", "2461000.5");
|
||||
// 1 and 1.0 are the same value.
|
||||
b.header.set_i64("DIMSCALE", 1);
|
||||
let c = cmp(&a, &b);
|
||||
assert!(only(&c, Category::Header, Change::Changed).iter().any(|d| d.place == "$LTSCALE"), "{:#?}", c.differences);
|
||||
assert!(only(&c, Category::Header, Change::Lost).iter().any(|d| d.place == "$TEXTSIZE"));
|
||||
assert_eq!(c.tally(Category::Header).total(), 2, "{:#?}", c.differences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listing_limit_keeps_counting() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
for i in 0..20 {
|
||||
b.header.set_i64(&format!("USERI{i}"), i);
|
||||
}
|
||||
let c = compare(&a, &b, &Options { max_differences: 5, ..Options::default() });
|
||||
assert_eq!(c.differences.len(), 5);
|
||||
assert_eq!(c.omitted, 15);
|
||||
assert_eq!(c.tally(Category::Header).added, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn census_counts_what_a_dxf_holds() {
|
||||
let dxf = " 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nLAYER\n 0\nLAYER\n 2\n0\n 0\nLAYER\n 2\nA\n 0\nENDTAB\n 0\nENDSEC\n\
|
||||
0\nSECTION\n 2\nBLOCKS\n 0\nBLOCK\n 2\n*Model_Space\n 0\nENDBLK\n 0\nBLOCK\n 2\n*Paper_Space\n 0\nLINE\n 0\nENDBLK\n\
|
||||
0\nBLOCK\n 2\nB1\n 0\nCIRCLE\n 0\nPOLYLINE\n 0\nVERTEX\n 0\nSEQEND\n 0\nENDBLK\n 0\nENDSEC\n\
|
||||
0\nSECTION\n 2\nENTITIES\n 0\nLINE\n 0\nINSERT\n 0\nATTRIB\n 0\nSEQEND\n 0\nENDSEC\n\
|
||||
0\nSECTION\n 2\nOBJECTS\n 0\nDICTIONARY\n 0\nXRECORD\n 0\nXRECORD\n 0\nENDSEC\n 0\nEOF\n";
|
||||
let c = census(dxf.as_bytes()).unwrap();
|
||||
assert_eq!(c.entities.get("LINE"), Some(&2));
|
||||
assert_eq!(c.entities.get("INSERT"), Some(&1));
|
||||
assert!(!c.entities.contains_key("ATTRIB") && !c.entities.contains_key("SEQEND"));
|
||||
assert_eq!(c.block_entities.get("CIRCLE"), Some(&1));
|
||||
assert_eq!(c.block_entities.get("POLYLINE"), Some(&1));
|
||||
assert_eq!(c.blocks, 1);
|
||||
assert_eq!(c.tables.get("LAYER"), Some(&2));
|
||||
assert_eq!(c.objects.get("XRECORD"), Some(&2));
|
||||
let _ = census(b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_census_reports_entities_the_reader_left_out() {
|
||||
let d = drawing();
|
||||
let mut file = census_of(&d);
|
||||
*file.entities.entry("LINE".into()).or_default() += 2;
|
||||
*file.entities.entry("WEIRD".into()).or_default() += 1;
|
||||
let c = compare_census(&file, &d, &Options::default());
|
||||
let lost = only(&c, Category::Read, Change::Lost);
|
||||
assert_eq!(c.tally(Category::Read).lost, 3, "{:#?}", c.differences);
|
||||
assert!(lost.iter().any(|x| x.place == "spaces LINE" && x.detail == "LINE: 5 in the file, 3 read"), "{lost:#?}");
|
||||
assert!(compare_census(&census_of(&d), &d, &Options::default()).is_empty());
|
||||
}
|
||||
|
||||
/// The round trips of a synthetic drawing through CADCraft's own DXF and DWG writers: whatever
|
||||
/// they lose shows in the comparison. Plain geometry, layers, xdata and unmodelled objects must
|
||||
/// come back from DXF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn dxf_round_trip_of_plain_geometry_is_lossless() {
|
||||
use corpus::{Format, round_trip};
|
||||
let mut d = drawing();
|
||||
// What the comparator should see in a round trip: the drawing as read from its own DXF.
|
||||
d = crate::read(crate::write(&d, "a.dxf").unwrap().as_slice(), "a.dxf").unwrap();
|
||||
let r = round_trip(&d, Format::Dxf, Format::Dxf, &Options::default(), None);
|
||||
assert!(r.ok(), "{r:#?}");
|
||||
let core = [Category::Count, Category::Geometry, Category::Table, Category::Xdata, Category::Object, Category::Opaque];
|
||||
for cat in core {
|
||||
assert_eq!(r.comparison.tally(cat), Tally::default(), "{cat:?}: {:#?}", r.comparison.differences);
|
||||
}
|
||||
let dwg = round_trip(&d, Format::Dxf, Format::Dwg, &Options::default(), None);
|
||||
assert!(dwg.save_error.is_none() && dwg.reopen_error.is_none(), "{dwg:#?}");
|
||||
assert!(dwg.saved_bytes > 0);
|
||||
// Through DWG, only the custom-class entity is lost (gap FF1: acadrust drops other programs'
|
||||
// entities without DWG data of their own) — and the comparator says so.
|
||||
let lost: Vec<&str> = dwg.comparison.differences.iter().filter(|d| d.category == Category::Count).map(|d| d.place.as_str()).collect();
|
||||
assert_eq!(lost, ["Model ACME_WIDGET"], "{:#?}", dwg.comparison.differences);
|
||||
assert_eq!(dwg.comparison.tally(Category::Geometry), Tally::default(), "{:#?}", dwg.comparison.differences);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn hostile_files_are_reported_not_crashed() {
|
||||
use corpus::{CorpusOptions, check_bytes};
|
||||
let opts = CorpusOptions::default();
|
||||
for (bytes, name) in [
|
||||
(&b""[..], "empty.dxf"),
|
||||
(b"AC1032 not really a dwg file at all", "fake.dwg"),
|
||||
(b" 0\nSECTION\n 2\nENTITIES\n 0\nLINE\n 10\nnan\n 20\n1e400\n 0\nENDSEC\n 0\nEOF\n", "odd.dxf"),
|
||||
(&[0xFFu8; 64][..], "noise.dxf"),
|
||||
] {
|
||||
let f = check_bytes(bytes, name, &opts);
|
||||
assert_eq!(f.path, name);
|
||||
if f.opened() {
|
||||
assert!(!f.routes.is_empty());
|
||||
} else {
|
||||
assert!(f.routes.is_empty() && f.open_error.as_deref().is_some_and(|e| !e.is_empty()), "{f:#?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn report_renders_as_markdown_and_json() {
|
||||
use corpus::{CorpusOptions, CorpusReport, check_bytes};
|
||||
let d = drawing();
|
||||
let dxf = crate::write(&d, "a.dxf").unwrap();
|
||||
let opts = CorpusOptions::default();
|
||||
let report = CorpusReport {
|
||||
corpus: "corpus".into(),
|
||||
generated: "2026-10-21 09:30 UTC".into(),
|
||||
cadcraft_version: "0".into(),
|
||||
abs_tol: 1e-6,
|
||||
rel_tol: 1e-9,
|
||||
files: vec![check_bytes(&dxf, "synthetic/a.dxf", &opts), check_bytes(b"AC1032 broken", "dwg/broken.dwg", &opts)],
|
||||
};
|
||||
let md = report.to_markdown();
|
||||
assert!(md.starts_with("# CADCraft round-trip report"), "{md}");
|
||||
assert!(md.contains("| `synthetic/a.dxf` | DXF AC1032 |"), "{md}");
|
||||
assert!(md.contains("dxf → dxf") && md.contains("dxf → dwg"), "{md}");
|
||||
assert!(md.contains("| `dwg/broken.dwg` | DWG AC1032 |") && md.contains("**failed**"), "{md}");
|
||||
assert!(md.contains("## By category"));
|
||||
let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
|
||||
assert_eq!(json["files"][0]["path"], "synthetic/a.dxf");
|
||||
assert_eq!(json["files"][0]["routes"][0]["route"], "dxf → dxf");
|
||||
assert!(json["files"][1]["openError"].as_str().is_some_and(|e| !e.is_empty()), "{json}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_folder_it_cannot_read_is_reported_not_skipped() {
|
||||
let w = corpus::walk(std::path::Path::new("no/such/corpus/folder"), &[]);
|
||||
assert!(w.files.is_empty());
|
||||
assert_eq!(w.unreadable.len(), 1, "{:?}", w.unreadable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_files_walks_folders_and_skips_the_report() {
|
||||
use corpus::corpus_files;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-corpus-walk-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for p in ["dxf/a.DXF", "dwg/b.dwg", "dwg/sub/c.dwg", "notes.txt", "report/a.roundtrip.dxf", ".git/x.dxf"] {
|
||||
let path = dir.join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"x").unwrap();
|
||||
}
|
||||
let files = corpus_files(&dir, &[dir.join("report")]);
|
||||
let rel: Vec<String> = files.iter().map(|f| corpus::relative(f, &dir)).collect();
|
||||
assert_eq!(rel, ["dwg/b.dwg", "dwg/sub/c.dwg", "dxf/a.DXF"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The real-file corpus: set `CADCRAFT_CORPUS_DIR` to a folder of DXF/DWG files (a checkout of
|
||||
/// `storytold/cadcraft-corpus`) to open, save and reopen each one and write `report.md` and
|
||||
/// `report.json` to `CADCRAFT_CORPUS_REPORT` (default: the corpus folder's `report`). Skipped
|
||||
/// when the variable is unset, so CI passes without the corpus. It reports, it doesn't fail on
|
||||
/// differences; set `CADCRAFT_CORPUS_STRICT=1` to fail when a file doesn't open, save or reopen,
|
||||
/// or when a writer leaves records out of a saved file ([`corpus::CorpusReport::strict_failures`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn corpus_round_trip_report() {
|
||||
let Some(dir) = std::env::var_os("CADCRAFT_CORPUS_DIR").map(std::path::PathBuf::from) else {
|
||||
eprintln!("CADCRAFT_CORPUS_DIR is not set: corpus round trip skipped");
|
||||
return;
|
||||
};
|
||||
let out = std::env::var_os("CADCRAFT_CORPUS_REPORT").map_or_else(|| dir.join("report"), std::path::PathBuf::from);
|
||||
let files = corpus::corpus_files(&dir, std::slice::from_ref(&out));
|
||||
let report = corpus::run(&dir, &files, &corpus::CorpusOptions::default(), &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
|
||||
let (md, json) = report.write(&out).unwrap();
|
||||
eprintln!("corpus report: {} and {}", md.display(), json.display());
|
||||
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
|
||||
let failed = report.strict_failures();
|
||||
assert!(failed.is_empty(), "corpus failures:\n{}", failed.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A type the DWG writer can't write is reported as lost on save, per type: in the route's
|
||||
/// `not_written`, on the lost entity rows, in the Markdown and as a strict failure. A type it
|
||||
/// writes (OLE2FRAME, kept by CADCraft without modelling it) is none of these.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn types_the_dwg_writer_cannot_write_are_reported_lost() {
|
||||
use corpus::{CorpusReport, FileReport, Format, round_trip};
|
||||
let unknown = |t: &str, tags: Vec<RawTag>| EntityKind::Unknown(Unknown { dxf_type: t.into(), tags });
|
||||
let mut d = Drawing::new_metric();
|
||||
let widget = vec![tag(100, "AcDbEntity"), tag(100, "AcmeWidget"), tag(10, "1.0"), tag(20, "2.0"), tag(30, "0.0")];
|
||||
d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET", widget.clone())).unwrap();
|
||||
d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET", widget)).unwrap();
|
||||
// Its data has no DWG wrapper, which is where a DWG keeps the corners: the reader's defaults.
|
||||
let ole = ["100", "AcDbEntity", "8", "0", "100", "AcDbOle2Frame", "70", "2", "10", "1.0", "20", "1.0", "30", "0.0", "11", "0.0", "21", "0.0"]
|
||||
.chunks(2)
|
||||
.map(|g| tag(g[0].parse().unwrap(), g[1]))
|
||||
.chain(
|
||||
["31", "0.0", "71", "2", "72", "0", "73", "0", "90", "4", "310", "C0FFEE00", "1", "OLE"]
|
||||
.chunks(2)
|
||||
.map(|g| tag(g[0].parse().unwrap(), g[1])),
|
||||
)
|
||||
.collect();
|
||||
d.add(&Space::Model, Common::default(), unknown("OLE2FRAME", ole)).unwrap();
|
||||
let d = crate::read(&crate::write(&d, "a.dxf").unwrap(), "a.dxf").unwrap();
|
||||
|
||||
let r = round_trip(&d, Format::Dxf, Format::Dwg, &Options::default(), None);
|
||||
assert!(r.ok(), "{r:#?}");
|
||||
assert_eq!(r.not_written, [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
let lost: Vec<&Difference> = r.comparison.differences.iter().filter(|x| x.category == Category::Count && x.change == Change::Lost).collect();
|
||||
assert_eq!(lost.len(), 1, "{lost:#?}");
|
||||
assert!(lost[0].place.ends_with("ACME_WIDGET") && lost[0].detail.contains("not written: the DWG writer can't write ACME_WIDGET"), "{lost:#?}");
|
||||
// The OLE2FRAME is written (only the CLASS record AutoCAD's files have is added).
|
||||
assert!(
|
||||
!r.comparison.differences.iter().any(|x| x.category != Category::Class && x.place.contains("OLE2FRAME")),
|
||||
"{:#?}",
|
||||
r.comparison.differences
|
||||
);
|
||||
assert!(!r.lossless());
|
||||
|
||||
let report =
|
||||
CorpusReport { files: vec![FileReport { path: "a.dxf".into(), routes: vec![r], ..FileReport::default() }], ..CorpusReport::default() };
|
||||
assert_eq!(report.strict_failures(), ["a.dxf (dxf → dwg): lost on save, not written: 2 ACME_WIDGET"]);
|
||||
let md = report.to_markdown();
|
||||
assert!(md.contains("- **Lost on save** (records a writer can't write): 2 ACME_WIDGET (dxf → dwg)"), "{md}");
|
||||
assert!(md.contains("| dxf → dwg | ok | ok | **2** |"), "{md}");
|
||||
assert!(md.contains("- **Lost on save**, not written (the writer can't write these types): 2 ACME_WIDGET"), "{md}");
|
||||
|
||||
// DXF keeps everything: nothing lost on save, nothing to fail on.
|
||||
let r = round_trip(&d, Format::Dxf, Format::Dxf, &Options::default(), None);
|
||||
assert!(r.not_written.is_empty());
|
||||
let report =
|
||||
CorpusReport { files: vec![FileReport { path: "a.dxf".into(), routes: vec![r], ..FileReport::default() }], ..CorpusReport::default() };
|
||||
assert!(report.strict_failures().is_empty());
|
||||
assert!(report.to_markdown().contains("- Lost on save (records a writer can't write): none"));
|
||||
}
|
||||
+17
-1
@@ -1039,7 +1039,7 @@ fn oversized_dwg_and_dxf_are_rejected_early() {
|
||||
// Lines that aren't even group codes: the size is reported before anything is parsed.
|
||||
let text = "not a group code\nx\n".repeat(60);
|
||||
let e = cadcraft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
|
||||
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads");
|
||||
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)");
|
||||
}
|
||||
|
||||
/// Issue #800: one unreadable object record doesn't fail the whole DWG. The reader skips it, the
|
||||
@@ -2896,3 +2896,19 @@ fn non_unicode_names_pick_the_format_by_extension() {
|
||||
assert!(write(&d, name("png")).unwrap().starts_with(b"\x89PNG"));
|
||||
assert!(write(&d, name("dwt")).is_ok(), "a template is written as DWG");
|
||||
}
|
||||
|
||||
/// A SOLID hatch keeps its angle (52), scale (41) and double flag (77) through a save: the writer
|
||||
/// wrote them for pattern fills only.
|
||||
#[test]
|
||||
fn solid_hatch_keeps_angle_scale_and_double_flag() {
|
||||
let src = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n5\n40\n8\n0\n100\nAcDbHatch\n10\n0\n20\n0\n30\n0\n2\nSOLID\n70\n1\n71\n0\n\
|
||||
91\n1\n92\n2\n72\n0\n73\n1\n93\n4\n10\n0\n20\n0\n10\n40\n20\n0\n10\n40\n20\n40\n10\n0\n20\n40\n97\n0\n\
|
||||
75\n0\n76\n1\n52\n30\n41\n2\n77\n0\n98\n0\n0\nENDSEC\n0\nEOF\n";
|
||||
let out = write_dxf(&read_dxf(src.as_bytes()).unwrap());
|
||||
let pairs = dxf_pairs(&out);
|
||||
let h = dxf_records(&pairs, "HATCH")[0];
|
||||
let f = |code: &str| group(h, code).and_then(|v| v.parse::<f64>().ok());
|
||||
assert!(f("52").is_some_and(|a| (a - 30.0).abs() < 1e-9), "{h:?}");
|
||||
assert!(f("41").is_some_and(|s| (s - 2.0).abs() < 1e-12), "{h:?}");
|
||||
assert_eq!(group(h, "77"), Some("0"), "{h:?}");
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ pub fn load_prefs(json: &str) {
|
||||
/// What the autosave failure notice says (see [`failure_notice`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AutosaveNotice {
|
||||
/// The drawings it has told the user about. Another drawing's failure is news, even after
|
||||
/// the notice was dismissed.
|
||||
pub reported: std::collections::BTreeSet<u64>,
|
||||
pub message: String,
|
||||
/// The user closed it. The failure stays recorded so the notice doesn't come back on every
|
||||
/// retry; it does for the next failure after a write has worked.
|
||||
@@ -100,23 +103,39 @@ impl CadApp {
|
||||
_ctx.request_repaint_after(wait);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.note_autosave_failure(_ctx);
|
||||
{
|
||||
// A write that fails while the app is idle must still reach the notice.
|
||||
if self.autosave.as_ref().is_some_and(cadcraft_engine::autosave::Autosaver::results_pending) {
|
||||
_ctx.request_repaint_after(std::time::Duration::from_secs(1));
|
||||
}
|
||||
self.note_autosave_failure(_ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep [`Self::autosave_notice`] in step with autosave: a new failure is said once at the
|
||||
/// command line and shown until it is dismissed or a write works.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn note_autosave_failure(&mut self, ctx: &egui::Context) {
|
||||
let failure = self.autosave.as_ref().and_then(|s| s.failure()).map(str::to_string);
|
||||
match (failure, &mut self.autosave_notice) {
|
||||
(None, notice) => *notice = None,
|
||||
(Some(message), Some(n)) => n.message = message,
|
||||
(Some(message), notice) => {
|
||||
let failures: Vec<(u64, String)> = self.autosave.iter().flat_map(|s| s.failures()).map(|(uid, why)| (uid, why.to_string())).collect();
|
||||
if failures.is_empty() {
|
||||
self.autosave_notice = None;
|
||||
return;
|
||||
}
|
||||
let notice =
|
||||
self.autosave_notice.get_or_insert_with(|| AutosaveNotice { reported: Default::default(), message: String::new(), dismissed: false });
|
||||
notice.reported.retain(|uid| failures.iter().any(|(failed, _)| failed == uid));
|
||||
for (uid, message) in &failures {
|
||||
if notice.reported.insert(*uid) {
|
||||
self.session.echo(format!("Autosave failed: {message}"));
|
||||
*notice = Some(AutosaveNotice { message, dismissed: false });
|
||||
notice.message.clone_from(message);
|
||||
notice.dismissed = false;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
// The drawing it names recovered while another still fails.
|
||||
if let Some((_, message)) = failures.first().filter(|_| !failures.iter().any(|(_, m)| *m == notice.message)) {
|
||||
notice.message.clone_from(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every frame: after a GPU error or device loss, autosave unsaved drawings at once (the
|
||||
|
||||
@@ -23,6 +23,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
"units" => units(app, ctx, &mut open),
|
||||
"hatch" => crate::hatchdlg::dialog(app, ctx, &mut open),
|
||||
"aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open),
|
||||
"find" => crate::finddlg::dialog(app, ctx, &mut open),
|
||||
"about" => about(ctx, &mut open),
|
||||
"preview" => crate::exchange::dialog(app, ctx, &mut open),
|
||||
"commands" => commands(app, ctx, &mut open),
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Find and Replace: the window behind the FIND command.
|
||||
//!
|
||||
//! FIND used to be a command line that asked for its parameters and then printed a count. The
|
||||
//! parameters are still what the engine takes; this window is the surface where a user can see
|
||||
//! *what* matched, jump to it and replace one object at a time. It stays open while the drawing
|
||||
//! is edited — find-and-replace in a drawing is a loop (look, locate, edit, look again), so a
|
||||
//! window that closed on the first result would be closed and reopened all afternoon.
|
||||
//!
|
||||
//! Everything the engine cannot answer is left out rather than faked: the search covers the
|
||||
//! current space, and the note under the switches says so in as many words.
|
||||
|
||||
use egui::{Color32, RichText, Sense, TextFormat};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// One object the search hit, as the engine reports it.
|
||||
#[derive(Clone, Default)]
|
||||
struct Row {
|
||||
handle: String,
|
||||
kind: String,
|
||||
count: u64,
|
||||
/// The text around the first hit, and where the hit sits inside it (characters, not bytes).
|
||||
snippet: String,
|
||||
hit: usize,
|
||||
hit_len: usize,
|
||||
}
|
||||
|
||||
/// A `u64` from the reply as a `usize`, or 0 when it does not fit: an offset past the end of a
|
||||
/// snippet is a bad reply, not a reason to fail the search that produced it.
|
||||
fn as_index(v: Option<&Value>) -> usize {
|
||||
v.and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// Read one `matches` entry; anything missing keeps its default so a partially understood
|
||||
/// reply still lists something.
|
||||
fn from_json(v: &Value) -> Self {
|
||||
Self {
|
||||
handle: v.get("handle").and_then(Value::as_str).unwrap_or_default().to_string(),
|
||||
kind: v.get("type").and_then(Value::as_str).unwrap_or_default().to_string(),
|
||||
count: v.get("count").and_then(Value::as_u64).unwrap_or(0),
|
||||
snippet: v.get("snippet").and_then(Value::as_str).unwrap_or_default().to_string(),
|
||||
hit: as_index(v.get("hit")),
|
||||
hit_len: as_index(v.get("hitLen")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The snippet in three pieces: before the hit, the hit, after it. Every slice is by
|
||||
/// character and every range is clamped, so a reply with a nonsensical offset still draws.
|
||||
fn pieces(&self) -> (String, String, String) {
|
||||
let n = self.snippet.chars().count();
|
||||
let at = self.hit.min(n);
|
||||
let end = at.saturating_add(self.hit_len).min(n);
|
||||
let before: String = self.snippet.chars().take(at).collect();
|
||||
let hit: String = self.snippet.chars().skip(at).take(end.saturating_sub(at)).collect();
|
||||
let after: String = self.snippet.chars().skip(end).collect();
|
||||
(before, hit, after)
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's state between frames: kept in egui's temporary store, like the other dialogs,
|
||||
/// so it lives exactly as long as the window does and never reaches the preferences file.
|
||||
#[derive(Default, Clone)]
|
||||
struct State {
|
||||
find: String,
|
||||
replace: String,
|
||||
match_case: bool,
|
||||
whole_word: bool,
|
||||
/// The `doc_epoch` and space the rows were found in. A row's handle is only meaningful in
|
||||
/// the drawing and the space it was found in, so when either moves the rows must go.
|
||||
epoch: u64,
|
||||
space: cadcraft_doc::Space,
|
||||
rows: Vec<Row>,
|
||||
/// The row Enter and Replace act on.
|
||||
current: Option<usize>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Run FIND and keep the parsed matches, or the engine's complaint in `message`.
|
||||
fn search(app: &mut CadApp, st: &mut State) {
|
||||
if st.find.trim().is_empty() {
|
||||
st.rows.clear();
|
||||
st.current = None;
|
||||
st.message = crate::i18n::t("Type something to find").to_string();
|
||||
return;
|
||||
}
|
||||
let params = json!({ "find": st.find, "matchCase": st.match_case, "wholeWord": st.whole_word });
|
||||
match app.run("find", params) {
|
||||
Ok(v) => {
|
||||
st.rows = v.get("matches").and_then(Value::as_array).map(|a| a.iter().map(Row::from_json).collect()).unwrap_or_default();
|
||||
st.current = if st.rows.is_empty() { None } else { Some(0) };
|
||||
let total = v.get("total").and_then(Value::as_u64).unwrap_or(0);
|
||||
let objects = v.get("matches").and_then(Value::as_array).map_or(0, Vec::len);
|
||||
st.message = format!("{total} match(es) in {objects} object(s)");
|
||||
}
|
||||
Err(e) => {
|
||||
st.rows.clear();
|
||||
st.current = None;
|
||||
st.message = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select `row` in the drawing and zoom to it: the result list's rows navigate, they do not just
|
||||
/// report.
|
||||
fn reveal(app: &mut CadApp, st: &State, index: usize) {
|
||||
let Some(row) = st.rows.get(index) else { return };
|
||||
let Some(h) = cadcraft_doc::Handle::parse_hex(&row.handle) else { return };
|
||||
app.session.set_selection(vec![h]);
|
||||
let _ = app.run("zoom", json!({ "mode": "object" }));
|
||||
}
|
||||
|
||||
/// Replace inside the objects the result list already holds. `only` limits it to one row, so
|
||||
/// Replace touches the object the user is looking at and Replace All touches the whole list.
|
||||
fn replace(app: &mut CadApp, st: &mut State, only: Option<usize>) {
|
||||
if st.find.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let handles: Vec<String> = match only {
|
||||
Some(i) => st.rows.get(i).map(|r| r.handle.clone()).into_iter().collect(),
|
||||
None => st.rows.iter().map(|r| r.handle.clone()).collect(),
|
||||
};
|
||||
if handles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let params = json!({
|
||||
"find": st.find,
|
||||
"replace": st.replace,
|
||||
"matchCase": st.match_case,
|
||||
"wholeWord": st.whole_word,
|
||||
"handles": handles,
|
||||
});
|
||||
match app.run("find", params) {
|
||||
Ok(v) => {
|
||||
// The engine's reply reports what the replacement did; the re-search below would
|
||||
// overwrite that message with a match count, so the replacement report is what
|
||||
// the user ends up reading.
|
||||
let replaced = v.get("message").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
search(app, st);
|
||||
if !replaced.is_empty() {
|
||||
st.message = replaced;
|
||||
}
|
||||
}
|
||||
Err(e) => st.message = e,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Find and Replace window.
|
||||
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let t = Tokens::get();
|
||||
let sid = egui::Id::unique("find_state");
|
||||
let mut st: State = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default();
|
||||
// A handle from an earlier drawing or space now names whatever object took its place; if
|
||||
// Replace All ran on such a row it would edit an object the search never matched. Clearing
|
||||
// the rows on an epoch or space change costs a re-click on Find All, which is the cheap
|
||||
// side of that trade.
|
||||
if *open && (st.epoch != app.session.doc_epoch || st.space != app.session.space()) {
|
||||
st.epoch = app.session.doc_epoch;
|
||||
st.space = app.session.space();
|
||||
st.rows.clear();
|
||||
st.current = None;
|
||||
st.message.clear();
|
||||
}
|
||||
// The rows answer one question — this text, with these switches, in this drawing. Editing
|
||||
// any of them makes the rows stale: Replace All would rewrite with the old switches while
|
||||
// the user is looking at the new ones, so the rows go and a fresh search is owed.
|
||||
let criteria = (st.find.clone(), st.match_case, st.whole_word);
|
||||
let mut do_search = false;
|
||||
let mut do_replace: Option<Option<usize>> = None;
|
||||
let mut do_select_all = false;
|
||||
let mut pick: Option<usize> = None;
|
||||
egui::Window::new(crate::tl!("Find and Replace"))
|
||||
.id(egui::Id::unique("Find and Replace"))
|
||||
.open(open)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_width(480.0)
|
||||
.default_height(400.0)
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("find_fields").num_columns(2).spacing(egui::vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(crate::tl!("Find what:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut st.find).desired_width(f32::INFINITY).hint_text(crate::tl!("Text to find")));
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
do_search = true;
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label(crate::tl!("Replace with:"));
|
||||
ui.add(egui::TextEdit::singleline(&mut st.replace).desired_width(f32::INFINITY).hint_text(crate::tl!("Replacement text")));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(&mut st.match_case, crate::tl!("Match case"));
|
||||
ui.checkbox(&mut st.whole_word, crate::tl!("Whole words"));
|
||||
});
|
||||
ui.label(RichText::new(crate::tl!("Searches the current space.")).color(t.text_faint).small());
|
||||
ui.add_space(2.0);
|
||||
ui.separator();
|
||||
let found = if st.rows.is_empty() { crate::tl!("Nothing found").to_string() } else { st.rows.len().to_string() };
|
||||
ui.label(RichText::new(format!("{} · {found}", crate::tl!("Results"))).color(t.text_dim).small());
|
||||
let body_h = (ui.available_height() - 76.0).clamp(80.0, 280.0);
|
||||
egui::Frame::NONE.fill(t.field).corner_radius(3).inner_margin(egui::Margin::same(2)).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
for (i, row) in st.rows.iter().enumerate() {
|
||||
let bg = if st.current == Some(i) { t.select_bg } else { Color32::TRANSPARENT };
|
||||
let inner = egui::Frame::NONE.fill(bg).corner_radius(2).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(crate::i18n::t(&row.kind)).color(t.text_dim).small());
|
||||
ui.label(RichText::new(row.handle.as_str()).color(t.text_faint).small().monospace());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new(format!("×{}", row.count)).color(t.text_faint).small());
|
||||
});
|
||||
});
|
||||
let (before, hit, after) = row.pieces();
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let plain = TextFormat { font_id: theme::small(), color: t.text, ..Default::default() };
|
||||
let marked = TextFormat { font_id: theme::mono(), color: accent(), ..Default::default() };
|
||||
job.append(&before, 0.0, plain.clone());
|
||||
job.append(&hit, 0.0, marked);
|
||||
job.append(&after, 0.0, plain);
|
||||
ui.add(egui::Label::new(job).wrap());
|
||||
});
|
||||
let click = ui.interact(inner.response.rect, ui.scope_id().with(("find_row", i)), Sense::click());
|
||||
if click.clicked() {
|
||||
st.current = Some(i);
|
||||
pick = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(crate::tl!("Find All")).clicked() {
|
||||
do_search = true;
|
||||
}
|
||||
let has = !st.rows.is_empty();
|
||||
if ui.add_enabled(has, egui::Button::new(crate::tl!("Select All"))).clicked() {
|
||||
do_select_all = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// An empty replacement is deleting the matched text, which is a legitimate
|
||||
// replacement, so the buttons care only about having something to act on.
|
||||
if ui.add_enabled(has, egui::Button::new(crate::tl!("Replace All"))).clicked() {
|
||||
do_replace = Some(None);
|
||||
}
|
||||
if ui.add_enabled(st.current.is_some(), egui::Button::new(crate::tl!("Replace"))).clicked() {
|
||||
do_replace = Some(st.current);
|
||||
}
|
||||
});
|
||||
});
|
||||
if !st.message.is_empty() {
|
||||
ui.label(RichText::new(st.message.as_str()).color(t.text_faint).small());
|
||||
}
|
||||
});
|
||||
if let Some(i) = pick {
|
||||
reveal(app, &st, i);
|
||||
}
|
||||
if criteria != (st.find.clone(), st.match_case, st.whole_word) {
|
||||
st.rows.clear();
|
||||
st.current = None;
|
||||
if !do_search {
|
||||
st.message.clear();
|
||||
}
|
||||
}
|
||||
if do_search {
|
||||
search(app, &mut st);
|
||||
}
|
||||
if do_select_all {
|
||||
let hs: Vec<cadcraft_doc::Handle> = st.rows.iter().filter_map(|r| cadcraft_doc::Handle::parse_hex(&r.handle)).collect();
|
||||
if !hs.is_empty() {
|
||||
app.session.set_selection(hs);
|
||||
st.message = format!("{} object(s) selected", st.rows.len());
|
||||
}
|
||||
}
|
||||
if let Some(only) = do_replace {
|
||||
replace(app, &mut st, only);
|
||||
}
|
||||
// The state lives exactly as long as the window does: a closed window leaves nothing behind,
|
||||
// so reopening it reads the drawing instead of a stale result list.
|
||||
if *open {
|
||||
ctx.data_mut(|d| d.insert_temp(sid, st));
|
||||
} else {
|
||||
ctx.data_mut(|d| d.remove_temp::<State>(sid));
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour a hit is drawn in: the theme's point colour, the same orange the drawing uses for
|
||||
/// points and grips, so the highlight belongs to the drawing rather than to the interface.
|
||||
fn accent() -> Color32 {
|
||||
Tokens::get().icon_point
|
||||
}
|
||||
@@ -1368,3 +1368,15 @@ menu View عرض
|
||||
overrides built-in يتجاوز المدمج
|
||||
{alias} is already defined; select it and use Edit. {alias} معرَّف مسبقاً؛ حدده واستخدم «تحرير».
|
||||
{command} is an unknown command. {command} أمر غير معروف.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1335 and column 24.
|
@@ -1444,3 +1444,15 @@ menu Modify Izmijeni
|
||||
Save changes to block "{name}"? Sačuvati promjene u bloku „{name}”?
|
||||
Layouts Rasporedi
|
||||
Associate Poveži
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1444 and column 24.
|
@@ -1224,3 +1224,15 @@ menu Modify Modifikace
|
||||
Check for Updates… Zkontrolovat aktualizace…
|
||||
CADCraft is up to date. CADCraft je aktuální.
|
||||
Could not check for updates. Aktualizace se nepodařilo zkontrolovat.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
@@ -1041,3 +1041,15 @@
|
||||
Check for Updates… Nach Updates suchen…
|
||||
CADCraft is up to date. CADCraft ist auf dem neuesten Stand.
|
||||
Could not check for updates. Die Suche nach Updates ist fehlgeschlagen.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
@@ -1335,3 +1335,15 @@ menu View Προβολή
|
||||
Check for Updates… Έλεγχος για ενημερώσεις…
|
||||
CADCraft is up to date. Το CADCraft είναι ενημερωμένο.
|
||||
Could not check for updates. Δεν ήταν δυνατός ο έλεγχος για ενημερώσεις.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
@@ -1255,3 +1255,15 @@
|
||||
Check for Updates… Buscar actualizaciones…
|
||||
CADCraft is up to date. CADCraft está actualizado.
|
||||
Could not check for updates. No se pudo buscar actualizaciones.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
@@ -1014,3 +1014,15 @@
|
||||
Check for Updates… Rechercher des mises à jour…
|
||||
CADCraft is up to date. CADCraft est à jour.
|
||||
Could not check for updates. Impossible de rechercher les mises à jour.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
@@ -1346,3 +1346,15 @@ menu View Nézet
|
||||
Check for Updates… Frissítések keresése…
|
||||
CADCraft is up to date. A CADCraft naprakész.
|
||||
Could not check for updates. A frissítések keresése nem sikerült.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 16 and column 72.
|
@@ -1364,3 +1364,15 @@ menu Modify Edita
|
||||
Check for Updates… Verifica aggiornamenti…
|
||||
CADCraft is up to date. CADCraft è aggiornato.
|
||||
Could not check for updates. Impossibile verificare gli aggiornamenti.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
@@ -1244,3 +1244,15 @@ menu Modify 수정
|
||||
Check for Updates… 업데이트 확인…
|
||||
CADCraft is up to date. CADCraft가 최신 버전입니다.
|
||||
Could not check for updates. 업데이트를 확인하지 못했습니다.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
@@ -1253,6 +1253,18 @@
|
||||
Check for Updates… Verificar atualizações…
|
||||
CADCraft is up to date. O CADCraft está atualizado.
|
||||
Could not check for updates. Não foi possível verificar atualizações.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
# Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31).
|
||||
Add: Pick points Adicionar: indicar pontos
|
||||
Add: Select objects Adicionar: selecionar objetos
|
||||
|
||||
|
@@ -1015,3 +1015,15 @@
|
||||
Check for Updates… Проверить обновления…
|
||||
CADCraft is up to date. У вас последняя версия CADCraft.
|
||||
Could not check for updates. Не удалось проверить обновления.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
@@ -1448,3 +1448,15 @@ menu Modify Ändra
|
||||
Check for Updates… Sök efter uppdateringar…
|
||||
CADCraft is up to date. CADCraft är uppdaterat.
|
||||
Could not check for updates. Det gick inte att söka efter uppdateringar.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
@@ -113,6 +113,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
|
||||
"quick",
|
||||
"vpscale",
|
||||
"options/dialog",
|
||||
"finddlg",
|
||||
];
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = Vec::new();
|
||||
|
||||
@@ -1349,3 +1349,15 @@ menu View Görünüm
|
||||
Check for Updates… Güncellemeleri denetle…
|
||||
CADCraft is up to date. CADCraft güncel.
|
||||
Could not check for updates. Güncellemeler denetlenemedi.
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 13.
|
@@ -1373,3 +1373,15 @@
|
||||
CADCraft is up to date. Встановлено найновішу версію CADCraft.
|
||||
Could not check for updates. Не вдалося перевірити оновлення.
|
||||
Save changes to block "{name}"? Зберегти зміни в блоці «{name}»?
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1375 and column 24.
|
@@ -1448,3 +1448,15 @@ menu Modify Hiệu chỉnh
|
||||
Change Properties Đổi thuộc tính
|
||||
Font Phông chữ
|
||||
Save Layout as Template Lưu layout thành mẫu
|
||||
Find All Find All
|
||||
Find what: Find what:
|
||||
Match case Match case
|
||||
Replace Replace
|
||||
Replace All Replace All
|
||||
Replace with: Replace with:
|
||||
Replacement text Replacement text
|
||||
Results Results
|
||||
Searches the current space. Searches the current space.
|
||||
Text to find Text to find
|
||||
Whole words Whole words
|
||||
Nothing found Nothing found
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1443 and column 24.
|
@@ -1377,6 +1377,18 @@
|
||||
Save Layout as Template 将布局另存为样板
|
||||
Set Dynamic Property 设置动态特性
|
||||
Units 单位
|
||||
Find All 查找全部
|
||||
Find what: 查找内容:
|
||||
Match case 区分大小写
|
||||
Replace 替换
|
||||
Replace All 全部替换
|
||||
Replace with: 替换为:
|
||||
Replacement text 替换文本
|
||||
Results 结果
|
||||
Searches the current space. 仅搜索当前空间。
|
||||
Text to find 要查找的文字
|
||||
Whole words 全字匹配
|
||||
Nothing found 未找到
|
||||
|
||||
# The strings the report listed as missing for zh.
|
||||
Add… 添加…
|
||||
|
||||
|
@@ -33,6 +33,8 @@ pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod docking;
|
||||
pub mod fault;
|
||||
// The Find and Replace window behind the FIND command.
|
||||
pub mod finddlg;
|
||||
pub mod hatchdlg;
|
||||
mod mtext_editor;
|
||||
mod panel_state;
|
||||
|
||||
@@ -73,6 +73,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("ui.cmdline.lines", "Command Line History Lines", &[], None),
|
||||
("ui.cmdline.clear", "Clear Command History", &[], None),
|
||||
("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None),
|
||||
("ui.dialog.find", "Find and Replace", &[], Some("Cmd+F")),
|
||||
("ui.language", "Language", &[], None),
|
||||
("ui.noop", "", &[], None),
|
||||
("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")),
|
||||
@@ -332,6 +333,12 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
if let Some(result) = run_close_command(app, id, params) {
|
||||
return Some(result);
|
||||
}
|
||||
// FIND typed, or chosen from Edit ▸ Find..., opens the window; a JSON call carries `find`
|
||||
// and falls through to the engine, so scripts, LISP and the MCP server keep their answer.
|
||||
if id == "find" && params.is_null() {
|
||||
app.ui.dialog = Some("find".to_string());
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let r = match id {
|
||||
"ui.open" | "open" if no_path => {
|
||||
let picked = app.services.pick_open.as_ref().and_then(|f| f());
|
||||
@@ -456,7 +463,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
| "ui.dialog.about"
|
||||
| "ui.dialog.commands"
|
||||
| "ui.dialog.qselect"
|
||||
| "ui.dialog.parameters" => {
|
||||
| "ui.dialog.parameters"
|
||||
| "ui.dialog.find" => {
|
||||
app.ui.dialog = Some(id.trim_start_matches("ui.dialog.").to_string());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -816,6 +824,7 @@ pub(crate) fn builtin_shortcuts() -> Vec<(egui::KeyboardShortcut, &'static str)>
|
||||
(sc(cmd, Key::S), "qsave"),
|
||||
(sc(cmd, Key::P), "plot"),
|
||||
(sc(cmd, Key::A), "selectall"),
|
||||
(sc(cmd, Key::F), "ui.dialog.find"),
|
||||
(sc(cmd_shift, Key::C), "copybase"),
|
||||
(sc(cmd, Key::C), "copyclip"),
|
||||
(sc(cmd, Key::X), "cutclip"),
|
||||
|
||||
@@ -67,7 +67,12 @@ fn a_failed_autosave_is_shown_until_a_write_works() {
|
||||
app.autosave_tick(&ctx);
|
||||
assert!(app.autosave_notice.as_ref().is_some_and(|n| n.dismissed));
|
||||
assert!(painted_text(&mut app, &ctx).is_empty());
|
||||
assert_eq!(said(&app), 1);
|
||||
|
||||
// Another drawing's failure is news, even after the first was dismissed.
|
||||
app.autosave_notice.as_mut().unwrap().reported.clear();
|
||||
app.autosave_tick(&ctx);
|
||||
assert!(app.autosave_notice.as_ref().is_some_and(|n| !n.dismissed));
|
||||
assert_eq!(said(&app), 2);
|
||||
|
||||
// The folder comes back: the next write clears it.
|
||||
std::fs::remove_file(&dir).unwrap();
|
||||
|
||||
+48
-6
@@ -23,8 +23,10 @@ commands:
|
||||
test cargo nextest run (cargo test without nextest) plus the doctests
|
||||
i18n print the strings each interface language shows in English (never fails)
|
||||
ci fmt --check, clippy -D warnings, test, i18n, assets, layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
corpus [DIR] [--out DIR] [--keep-saved] [--same-format]
|
||||
open -> save -> reopen every DXF/DWG of a real-file corpus (DIR, default
|
||||
$CADCRAFT_CORPUS_DIR or ../cadcraft-corpus) and write report.md + report.json
|
||||
(default DIR/report); runs `cadcraft-cli roundtrip` in release mode
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity recompute the AutoCAD menu-breadth checklist in docs/parity-checklist.md
|
||||
ico <out.ico> <in.png>...
|
||||
@@ -41,7 +43,7 @@ fn main() -> ExitCode {
|
||||
Some("test") => cmd_test(),
|
||||
Some("i18n") => cmd_i18n(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("corpus") => cmd_corpus(&rest),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("parity") => parity::run(&root()),
|
||||
Some("ico") => ico::run(&rest),
|
||||
@@ -226,7 +228,47 @@ fn cmd_ci() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_corpus(_download: bool) -> Result<(), String> {
|
||||
println!("Test corpora live in storytold/cadcraft-corpus (planned); never commit large binaries here.");
|
||||
Ok(())
|
||||
/// `corpus [DIR] [options]`: the round-trip report over a real-file corpus (gaps FF1, FF5). The
|
||||
/// corpus lives outside this repo (storytold/cadcraft-corpus); its files are never committed here.
|
||||
fn cmd_corpus(args: &[&str]) -> Result<(), String> {
|
||||
// `cadcraft-cli` runs from the repository root: relative paths are made absolute against the
|
||||
// folder `cargo xtask` was run from first, so they mean what the user typed.
|
||||
let here = std::env::current_dir().map_err(|e| format!("current folder: {e}"))?;
|
||||
let mut pass: Vec<String> = Vec::new();
|
||||
let mut dir: Option<String> = None;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match *a {
|
||||
"--out" => {
|
||||
let v = it.next().ok_or_else(|| format!("{a} needs a value"))?;
|
||||
pass.extend([a.to_string(), here.join(v).display().to_string()]);
|
||||
}
|
||||
"--tolerance" => {
|
||||
let v = it.next().ok_or_else(|| format!("{a} needs a value"))?;
|
||||
pass.extend([a.to_string(), v.to_string()]);
|
||||
}
|
||||
s if s.starts_with("--out=") => {
|
||||
let v = s.trim_start_matches("--out=");
|
||||
pass.extend(["--out".to_string(), here.join(v).display().to_string()]);
|
||||
}
|
||||
s if s.starts_with("--") => pass.push(s.to_string()),
|
||||
s if dir.is_none() => dir = Some(s.to_string()),
|
||||
_ => return Err("corpus takes one DIR".into()),
|
||||
}
|
||||
}
|
||||
let dir = dir
|
||||
.or_else(|| std::env::var("CADCRAFT_CORPUS_DIR").ok())
|
||||
.unwrap_or_else(|| root().parent().map(|p| p.join("cadcraft-corpus").display().to_string()).unwrap_or_else(|| "../cadcraft-corpus".into()));
|
||||
let dir = here.join(&dir);
|
||||
if !dir.is_dir() {
|
||||
return Err(format!(
|
||||
"no corpus at `{}`: clone storytold/cadcraft-corpus next to this repo, set CADCRAFT_CORPUS_DIR or pass DIR \
|
||||
(never commit corpus files to this repo)",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
let dir = dir.display().to_string();
|
||||
let mut c = cargo();
|
||||
c.args(["run", "--release", "-q", "-p", "cadcraft-cli", "--", "roundtrip", &dir]).args(&pass);
|
||||
run(c, &format!("cadcraft-cli roundtrip {dir} {}", pass.join(" ")))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user