mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 17:18:01 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dad7d1c138 | ||
|
|
b1b3754ffa | ||
|
|
b639b18f69 | ||
|
|
578da3ba9c | ||
|
|
f2afcd4f02 | ||
|
|
1619e24381 | ||
|
|
ffc2c553e8 | ||
|
|
7bb6c2f06f | ||
|
|
e10f394b28 | ||
|
|
9a112830c5 | ||
|
|
b04273e897 | ||
|
|
5ed192555c | ||
|
|
4c00f8b9e3 | ||
|
|
7b6be7835f | ||
|
|
455b2a87cb | ||
|
|
1b7c705fac | ||
|
|
77142aa79e | ||
|
|
31e6e16bc0 | ||
|
|
a4ff30d11e | ||
|
|
9d83f21910 | ||
|
|
7caf369555 | ||
|
|
f691d0b349 | ||
|
|
e24c4553d2 | ||
|
|
c602a402c3 | ||
|
|
d180b28ef6 | ||
|
|
2e9761249a | ||
|
|
c956baa349 | ||
|
|
997f152cea | ||
|
|
0d3b3d21c0 | ||
|
|
4482316306 | ||
|
|
f6d84e28f3 | ||
|
|
806654aedf | ||
|
|
6c4984f5ad | ||
|
|
fdfe4d95a8 | ||
|
|
9107479ade | ||
|
|
a13870f376 | ||
|
|
a6ba747329 |
Generated
+1
@@ -691,6 +691,7 @@ name = "cadcraft-dwg"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report it privately through [GitHub's private reporting](https://github.com/storytold/cadcraft/security/advisories/new), not in a public issue or pull request. Say which version you ran, on which platform, and how to reproduce it. A small sample file helps, as long as it holds nothing private.
|
||||
|
||||
Maintainers read reports as they arrive, but there is no fixed response time yet.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Only the latest release gets security fixes. CADCraft is pre-alpha and moves quickly, so please check that a problem still exists there before reporting it.
|
||||
|
||||
## What counts
|
||||
|
||||
CADCraft treats these as untrusted, and a way past these defences is a vulnerability:
|
||||
|
||||
- **Drawing files.** Opening a DXF, DWG or other file must not run code, write outside the paths the user chose, or crash the app. Panics are bugs too, but memory exhaustion or a decoder abort on a crafted file is the more serious case.
|
||||
- **AutoLISP in a drawing's folder.** A drawing must not run code that the user didn't choose to load.
|
||||
- **The control channel.** It listens on loopback only and needs the session token from an owner-only file on every request. Another local account reaching it without that token is a vulnerability.
|
||||
- **Temporary and autosave files.** Autosave files are unsaved drawings: another account must not be able to read them or redirect a write through them.
|
||||
- **Updates.** A release must not be replaceable by someone other than the project.
|
||||
|
||||
## Known limits
|
||||
|
||||
These are known and being worked on, so they don't need a report:
|
||||
|
||||
- Agents connected through MCP or the control channel can read and write any path the user can. There are no workspace roots yet.
|
||||
- A local process can make the control channel buffer up to 512 MiB before its token is checked. The total is capped, so this can slow the app but not exhaust memory without limit.
|
||||
- The DWG worker process has no memory limit of its own yet, so a crafted file can use a lot of memory before it stops. It ends that one open or save, not the app.
|
||||
- The web build has no autosave yet.
|
||||
|
||||
## What stays on your machine
|
||||
|
||||
Crash reports are written to the `crash-reports` folder in the settings folder and are never uploaded. They hold the version, the platform, the panic message and a stack trace, and a message can include a file path, so read a report before you attach it anywhere.
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
fn main() {
|
||||
// A copy of this program started to read or write a DWG file does only that.
|
||||
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
cadcraft_io::dwg_worker::use_own_worker();
|
||||
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be
|
||||
// reported as an error, not panic before anything is printed.
|
||||
let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! The DWG reader and writer run in a child process, so a decoder that dies ends one open or save,
|
||||
//! not the app. The worker setting is process-wide, so everything runs in one test.
|
||||
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use cadcraft_io::dwg_worker;
|
||||
|
||||
#[test]
|
||||
fn a_real_worker_reads_and_writes_and_its_death_is_an_error_here() {
|
||||
let drawing = cadcraft_engine::sample::bracket();
|
||||
let in_process = cadcraft_io::write(&drawing, "a.dwg").unwrap();
|
||||
|
||||
dwg_worker::use_worker(env!("CARGO_BIN_EXE_cadcraft-cli").into(), vec![dwg_worker::WORKER_ARG.into()]);
|
||||
let written = cadcraft_io::write(&drawing, "a.dwg").unwrap();
|
||||
assert_eq!(written.get(..6), in_process.get(..6), "the same version of file");
|
||||
let read = cadcraft_io::read(&written, "a.dwg").unwrap();
|
||||
assert_eq!(read.model.len(), drawing.model.len());
|
||||
|
||||
// A decode error comes back as that error, not as the worker stopping.
|
||||
let mut bad = in_process.clone();
|
||||
bad.truncate(64);
|
||||
let e = cadcraft_io::read(&bad, "a.dwg").unwrap_err().to_string();
|
||||
assert!(!e.contains("worker"), "{e}");
|
||||
|
||||
// The worker aborts as the third-party code can. This process goes on.
|
||||
let e = dwg_worker::crash_worker_for_test().unwrap_err();
|
||||
assert!(e.contains("worker stopped"), "{e}");
|
||||
assert!(cadcraft_io::read(&written, "a.dwg").is_ok(), "the next file gets a new worker");
|
||||
|
||||
// No worker to start: the work happens here, as without one.
|
||||
dwg_worker::use_worker("no-such-program-here".into(), Vec::new());
|
||||
assert!(cadcraft_io::read(&written, "a.dwg").is_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")));
|
||||
|
||||
@@ -86,7 +86,7 @@ impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
|
||||
self.cad.logic(ctx);
|
||||
self.cad.guarded_logic(ctx);
|
||||
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
|
||||
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
|
||||
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
|
||||
@@ -116,7 +116,7 @@ impl eframe::App for App {
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.cad.ui(ui);
|
||||
self.cad.guarded_ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
|
||||
}
|
||||
@@ -258,6 +258,11 @@ fn version_string() -> String {
|
||||
|
||||
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
|
||||
fn main() -> eframe::Result {
|
||||
// A copy of this program started to read or write a DWG file does only that.
|
||||
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
cadcraft_io::dwg_worker::use_own_worker();
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
// The update helper relaunches this binary after a self-update; it runs and exits here.
|
||||
@@ -289,6 +294,9 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
install_io();
|
||||
cadcraft_engine::settings_dir::set(vars.config_dir.clone());
|
||||
if let Some(dir) = &vars.config_dir {
|
||||
cadcraft_ui_egui::fault::install_report_hook(dir.join(cadcraft_ui_egui::fault::REPORT_DIR));
|
||||
}
|
||||
if let Some(name) = &vars.font_alt {
|
||||
cadcraft_engine::fonts::ttf::set_font_alt(name);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,9 +362,13 @@ impl EntityKind {
|
||||
i.scale.y *= s;
|
||||
i.scale.z *= s;
|
||||
i.rotation = cadcraft_geom::norm_angle(i.rotation + rot);
|
||||
// Mirrored as AutoCAD 2024 mirrors a block reference: the X scale factor
|
||||
// negated, the rotation 180 degrees plus twice the mirror line's angle less the
|
||||
// old one, and a MINSERT grid's columns running the other way.
|
||||
if mirror {
|
||||
i.scale.y = -i.scale.y;
|
||||
i.rotation = cadcraft_geom::norm_angle(rot - (i.rotation - rot));
|
||||
i.scale.x = -i.scale.x;
|
||||
i.rotation = cadcraft_geom::norm_angle(std::f64::consts::PI + rot - (i.rotation - rot));
|
||||
i.col_spacing = -i.col_spacing;
|
||||
}
|
||||
for a in &mut i.attribs {
|
||||
transform_text(&mut a.text, m, s, rot, false);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ license.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
|
||||
# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64,
|
||||
# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname.
|
||||
# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800).
|
||||
|
||||
+108
-15
@@ -19,10 +19,8 @@
|
||||
//! crashing.
|
||||
//!
|
||||
//! These bound the input- and output-size variants. A small file that declares a huge internal
|
||||
//! table or section can still make the dependency allocate before any size is known; fully fixing
|
||||
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
|
||||
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
|
||||
//! keep the blast radius bounded in the meantime.
|
||||
//! table or section can still make the dependency allocate before any size is known, so the app
|
||||
//! runs both directions in a child process ([`worker`]): what that kills is the child.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -64,6 +62,11 @@ fn mb(n: u64) -> String {
|
||||
if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") }
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub mod worker;
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_noted, serve_if_asked, use_own_worker, use_worker};
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod native {
|
||||
use std::io::{Cursor, Write};
|
||||
@@ -541,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,
|
||||
@@ -562,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,
|
||||
@@ -573,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
|
||||
@@ -641,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,
|
||||
@@ -704,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
|
||||
@@ -729,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
|
||||
@@ -900,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.
|
||||
@@ -930,6 +954,28 @@ fn unsupported(version: &str) -> String {
|
||||
#[cfg(no_dwg_bridge)]
|
||||
const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" };
|
||||
|
||||
/// There's no worker without a bridge.
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub const WORKER_ARG: &str = "--dwg-worker";
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
None
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn use_own_worker() {}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
dwg_to_dxf_noted(bytes, limits)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
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> {
|
||||
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
@@ -950,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 {
|
||||
@@ -1281,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! The DWG reader and writer in a child process.
|
||||
//!
|
||||
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
|
||||
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
|
||||
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
|
||||
//! that reads one request from standard input and writes one reply to standard output. A file that
|
||||
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
|
||||
//!
|
||||
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
|
||||
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
|
||||
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
|
||||
//!
|
||||
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
|
||||
//! untrusted input.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc::{RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::Limits;
|
||||
|
||||
/// The argument that makes the app a worker.
|
||||
pub const WORKER_ARG: &str = "--dwg-worker";
|
||||
/// Bumped when the messages change: an app updated while running may start a newer copy.
|
||||
const PROTOCOL: u8 = 1;
|
||||
const DECODE: u8 = 1;
|
||||
const ENCODE: u8 = 2;
|
||||
const CRASH: u8 = 0xff;
|
||||
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
|
||||
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
/// Most bytes accepted for what a reply carries besides the drawing.
|
||||
const MAX_NOTES_BYTES: u64 = 64 << 20;
|
||||
/// Most bytes accepted as a written DWG.
|
||||
const MAX_DWG_REPLY: u64 = 2 << 30;
|
||||
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
|
||||
const MAX_REQUEST: u64 = (2 << 30) + 256;
|
||||
|
||||
struct Worker {
|
||||
program: PathBuf,
|
||||
args: Vec<OsString>,
|
||||
}
|
||||
|
||||
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
|
||||
|
||||
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
|
||||
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
|
||||
if let Ok(mut w) = WORKER.lock() {
|
||||
*w = Some(Worker { program, args });
|
||||
}
|
||||
}
|
||||
|
||||
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
|
||||
/// found, the reader and writer stay in this process.
|
||||
pub fn use_own_worker() {
|
||||
match std::env::current_exe() {
|
||||
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
|
||||
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one request and return the exit code if this process was started as a worker.
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
|
||||
}
|
||||
|
||||
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
// Before starting a process, and so the same limit holds with or without one.
|
||||
if bytes.len() > limits.max_dwg_bytes {
|
||||
return super::dwg_to_dxf_noted(bytes, limits);
|
||||
}
|
||||
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
|
||||
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
|
||||
Some(reply) => reply?,
|
||||
None => super::dwg_to_dxf_noted(bytes, limits),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
encode_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
|
||||
/// back as notes of `TYPE<tab>count`.
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
if u8::try_from(version.len()).is_err() {
|
||||
return Err(super::unsupported(version));
|
||||
}
|
||||
match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") {
|
||||
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))),
|
||||
None => super::dxf_to_dwg_noted(dxf, version),
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
|
||||
}
|
||||
|
||||
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
|
||||
let mut r = vec![PROTOCOL, op];
|
||||
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
|
||||
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
|
||||
r.push(u8::try_from(version.len()).unwrap_or(0));
|
||||
r.extend(version.as_bytes());
|
||||
r
|
||||
}
|
||||
|
||||
type Reply = Result<(Vec<u8>, Vec<String>), String>;
|
||||
|
||||
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
|
||||
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
|
||||
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
|
||||
let mut command = {
|
||||
let worker = WORKER.lock().ok()?;
|
||||
let worker = worker.as_ref()?;
|
||||
let mut c = Command::new(&worker.program);
|
||||
c.args(&worker.args);
|
||||
c
|
||||
};
|
||||
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let mut child = match command.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("DWG worker: can't start it: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
};
|
||||
let received = std::thread::scope(|scope| {
|
||||
// Written beside the read: a worker that replies before it has read everything, or dies,
|
||||
// must block neither side. A failed write shows up as the reply going wrong.
|
||||
scope.spawn(move || {
|
||||
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
scope.spawn(move || {
|
||||
let mut reply = Vec::new();
|
||||
let read = from_child.take(reply_limit).read_to_end(&mut reply);
|
||||
let _ = tx.send(read.map(|_| reply));
|
||||
});
|
||||
let received = rx.recv_timeout(TIMEOUT);
|
||||
if matches!(received, Err(RecvTimeoutError::Timeout)) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
received
|
||||
});
|
||||
let status = child.wait();
|
||||
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
|
||||
Some(match (received, status) {
|
||||
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
|
||||
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
|
||||
_ => Err(stopped()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Take `n` bytes off the front of `b`.
|
||||
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
|
||||
let (head, tail) = b.split_at_checked(n)?;
|
||||
*b = tail;
|
||||
Some(head)
|
||||
}
|
||||
|
||||
fn take_u32(b: &mut &[u8]) -> Option<usize> {
|
||||
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
|
||||
usize::try_from(u32::from_le_bytes(bytes)).ok()
|
||||
}
|
||||
|
||||
fn take_u64(b: &mut &[u8]) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
|
||||
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
|
||||
/// of the memory the app may use: they are kept, not copied.
|
||||
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
|
||||
let total = reply.len();
|
||||
let mut b = &reply[..];
|
||||
match *take(&mut b, 1)?.first()? {
|
||||
0 => {
|
||||
let count = take_u32(&mut b)?;
|
||||
let mut notes = Vec::new();
|
||||
for _ in 0..count {
|
||||
let n = take_u32(&mut b)?;
|
||||
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
|
||||
}
|
||||
let n = usize::try_from(take_u64(&mut b)?).ok()?;
|
||||
if b.len() != n {
|
||||
return None;
|
||||
}
|
||||
reply.drain(..total - n);
|
||||
Some(Ok((reply, notes)))
|
||||
}
|
||||
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
|
||||
match reply {
|
||||
Ok((bytes, notes)) => {
|
||||
out.write_all(&[0])?;
|
||||
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
|
||||
for note in ¬es {
|
||||
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
|
||||
if u32::try_from(note.len()).is_ok() {
|
||||
out.write_all(note.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
|
||||
out.write_all(&bytes)?;
|
||||
}
|
||||
Err(e) => {
|
||||
out.write_all(&[1])?;
|
||||
out.write_all(e.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
|
||||
/// What a request asks for, or why it can't be served.
|
||||
fn handle(mut b: &[u8]) -> Reply {
|
||||
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
|
||||
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
|
||||
return Err(bad());
|
||||
}
|
||||
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
match op {
|
||||
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
|
||||
ENCODE => super::dxf_to_dwg_noted(b, version).map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())),
|
||||
CRASH => std::process::abort(),
|
||||
_ => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
|
||||
fn serve(input: impl Read, mut output: impl Write) -> i32 {
|
||||
let mut request = Vec::new();
|
||||
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
|
||||
Ok(_) => handle(&request),
|
||||
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
|
||||
};
|
||||
i32::from(write_reply(&mut output, reply).is_err())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wire(reply: Reply) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
write_reply(&mut out, reply).unwrap();
|
||||
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()]));
|
||||
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
|
||||
let failed: Reply = Err("DWG: no".into());
|
||||
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_cut_short_or_made_up_is_refused() {
|
||||
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
|
||||
for n in 0..whole.len() {
|
||||
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
|
||||
}
|
||||
assert_eq!(parse_reply(vec![7, 0, 0]), None);
|
||||
// A note count far beyond what follows ends at the first missing note.
|
||||
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
|
||||
for request in [
|
||||
&[][..],
|
||||
&[PROTOCOL + 1, DECODE],
|
||||
&[PROTOCOL],
|
||||
&[PROTOCOL, DECODE, 1, 2, 3],
|
||||
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
] {
|
||||
assert!(handle(request).is_err(), "{request:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serving_reports_a_decode_failure_as_a_reply() {
|
||||
let mut request = self::request(DECODE, Limits::DEFAULT, "");
|
||||
request.extend(b"AC1032 not really a drawing");
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(serve(&request[..], &mut out), 0);
|
||||
assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}");
|
||||
}
|
||||
}
|
||||
+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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,9 +234,10 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
missing("table style", "Standard", out);
|
||||
d.table_styles.insert(0, TableStyle::default());
|
||||
}
|
||||
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
|
||||
// Name sets built once: both tables come from the drawing, so a scan per reference is quadratic.
|
||||
let linetypes: HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
|
||||
for l in &mut d.layers {
|
||||
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
|
||||
if !linetypes.contains(&l.linetype.to_ascii_lowercase()) {
|
||||
out.push(issue(
|
||||
"missingLinetype",
|
||||
"tables",
|
||||
@@ -247,8 +248,8 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
l.linetype = "Continuous".into();
|
||||
}
|
||||
}
|
||||
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
|
||||
let styles: HashSet<String> = d.text_styles.iter().map(|s| s.name.to_ascii_lowercase()).collect();
|
||||
let known = |n: &str| styles.contains(&n.to_ascii_lowercase());
|
||||
for s in &mut d.dim_styles {
|
||||
if !known(&s.text_style) {
|
||||
out.push(issue(
|
||||
|
||||
+194
-23
@@ -4,15 +4,18 @@
|
||||
//! autosave is written as DXF to `<SAVEFILEPATH>/<drawing>_<instance>_<uid>.sv$`. The file goes
|
||||
//! away when the drawing is saved or closed and when the app exits normally, so a file left behind
|
||||
//! means the app (or the machine) went down; the next start offers it for recovery. A running app
|
||||
//! keeps a lock file (`<instance>.lock`) fresh, so another copy running at the same time doesn't
|
||||
//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from
|
||||
//! an instance id ([`is_instance_id`]) are ever read as ours or deleted.
|
||||
//! holds an operating-system lock on its lock file (`<instance>.lock`) and keeps the file fresh, so
|
||||
//! another copy running at the same time doesn't offer its files. The system drops the lock the
|
||||
//! moment a process ends, however it ended, so a relaunch after a crash can offer recovery at once.
|
||||
//! Where locks aren't available, and for lock files of older versions, a file not refreshed for
|
||||
//! [`STALE_LOCK_SECS`] counts as stopped. SAVEFILEPATH may be a folder other programs use too, so
|
||||
//! only names built from an instance id ([`is_instance_id`]) are ever read as ours or deleted.
|
||||
//!
|
||||
//! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop
|
||||
//! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH
|
||||
//! are profile settings: process-wide, not saved in the drawing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
@@ -29,6 +32,10 @@ pub const DEFAULT_SAVETIME: u32 = 10;
|
||||
pub const MAX_SAVETIME: u32 = 600;
|
||||
/// A lock file not refreshed for this long belongs to an app that is no longer running.
|
||||
pub const STALE_LOCK_SECS: u64 = 5 * 60;
|
||||
/// How a lock file ends when its app holds the system lock on it, so a free lock proves the app
|
||||
/// stopped. Older versions write only the process id and never hold one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const LOCK_HELD: &str = " held";
|
||||
/// How often a running app refreshes its lock file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const HEARTBEAT_SECS: f64 = 60.0;
|
||||
@@ -149,13 +156,21 @@ pub struct Autosaver {
|
||||
docs: HashMap<u64, Tracked>,
|
||||
/// Drawings whose autosave is being written right now.
|
||||
busy: HashSet<u64>,
|
||||
/// Writes in progress; each tells whether its file was written.
|
||||
/// Why the last autosave of each drawing failed, until one succeeds or the drawing no longer
|
||||
/// needs it. Ordered, so [`Autosaver::failure`] doesn't change between frames.
|
||||
failed: BTreeMap<u64, String>,
|
||||
/// Writes in progress; each ends with why its file wasn't written, if it wasn't.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
|
||||
running: Vec<(u64, std::thread::JoinHandle<std::result::Result<(), String>>)>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: Option<f64>,
|
||||
/// 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"))]
|
||||
locks: Vec<(std::path::PathBuf, Option<std::fs::File>)>,
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -164,12 +179,15 @@ impl Autosaver {
|
||||
instance: instance.into(),
|
||||
docs: HashMap::new(),
|
||||
busy: HashSet::new(),
|
||||
failed: BTreeMap::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant::now(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
locks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +205,11 @@ impl Autosaver {
|
||||
self.docs.values().filter_map(|t| t.file.clone()).collect()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
|
||||
/// files of drawings that were saved (no unsaved changes left) or closed.
|
||||
@@ -220,6 +243,7 @@ impl Autosaver {
|
||||
jobs.push(Job::Delete { path });
|
||||
}
|
||||
*t = Tracked::default();
|
||||
self.failed.remove(&st.uid);
|
||||
continue;
|
||||
}
|
||||
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
|
||||
@@ -248,6 +272,8 @@ impl Autosaver {
|
||||
t.since = None;
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc });
|
||||
}
|
||||
let docs = &self.docs;
|
||||
self.failed.retain(|uid, _| docs.contains_key(uid));
|
||||
jobs
|
||||
}
|
||||
|
||||
@@ -277,12 +303,14 @@ pub use native::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::{
|
||||
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name,
|
||||
savefilepath, savetime,
|
||||
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, LOCK_HELD, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name,
|
||||
parse_lock_name, savefilepath, savetime,
|
||||
};
|
||||
|
||||
/// A new instance id: the process id and the start time.
|
||||
@@ -307,6 +335,28 @@ mod native {
|
||||
Some(SystemTime::now().duration_since(m).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Whether the app instance named by a lock file in `dir` may still be running: its system
|
||||
/// lock answers at once, and the file's age decides when it can't (no lock support, a lock
|
||||
/// file from an older version).
|
||||
fn owner_alive(dir: &Path, instance: &str) -> bool {
|
||||
let by_age = || instance_alive(lock_age(dir, instance));
|
||||
let path = lock_path(dir, instance);
|
||||
// Only a plain file is opened: opening a FIFO planted in a shared folder would block.
|
||||
if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_file()) {
|
||||
return by_age();
|
||||
}
|
||||
let Ok(file) = File::open(&path) else { return by_age() };
|
||||
match file.try_lock() {
|
||||
Err(TryLockError::WouldBlock) => true,
|
||||
Err(TryLockError::Error(_)) => by_age(),
|
||||
Ok(()) => {
|
||||
let mut text = String::new();
|
||||
let held = file.take(64).read_to_string(&mut text).is_ok() && text.ends_with(LOCK_HELD);
|
||||
!held && by_age()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The autosave files in `dir` left behind by apps that are no longer running (never those of
|
||||
/// `own`, this app's instance), oldest first.
|
||||
pub fn recoverable(dir: &Path, own: &str) -> Vec<Recoverable> {
|
||||
@@ -316,7 +366,7 @@ mod native {
|
||||
for e in entries.flatten().take(10_000) {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let Some((title, instance, _)) = parse_file_name(&name) else { continue };
|
||||
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) {
|
||||
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| owner_alive(dir, instance)) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = e.metadata() else { continue };
|
||||
@@ -355,10 +405,11 @@ mod native {
|
||||
pub fn prune_locks(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let names: Vec<String> = entries.flatten().take(10_000).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
// The instances that own autosave files, parsed once rather than once per lock file.
|
||||
let with_files: std::collections::HashSet<&str> = names.iter().filter_map(|f| parse_file_name(f)).map(|(_, i, _)| i).collect();
|
||||
for n in &names {
|
||||
let Some(instance) = parse_lock_name(n) else { continue };
|
||||
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
|
||||
if !has_files && !instance_alive(lock_age(dir, instance)) {
|
||||
if !with_files.contains(instance) && !owner_alive(dir, instance) {
|
||||
let _ = std::fs::remove_file(dir.join(n));
|
||||
}
|
||||
}
|
||||
@@ -385,7 +436,22 @@ mod native {
|
||||
}
|
||||
// Create a fresh temporary file exclusively and rename it into place, so a planted
|
||||
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
|
||||
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
|
||||
// The file holds unsaved work: owner-only from its creation, whatever the umask.
|
||||
crate::atomic_write::write_atomic_with(Path::new(path), private_mode(), |f| f.write_all(&bytes)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Owner read/write only (`None` where file modes don't exist).
|
||||
fn private_mode() -> Option<std::fs::Permissions> {
|
||||
#[cfg(unix)]
|
||||
return Some(std::os::unix::fs::PermissionsExt::from_mode(0o600));
|
||||
#[cfg(not(unix))]
|
||||
None
|
||||
}
|
||||
|
||||
/// Write this app's lock file at `path` without following a link planted there: the file is
|
||||
/// replaced (exclusive temporary file, then rename), never written through (CWE-59).
|
||||
fn write_lock(path: &Path, id: u32) -> std::io::Result<()> {
|
||||
crate::atomic_write::write_atomic_with(path, private_mode(), |f| f.write_all(id.to_string().as_bytes()))
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -423,24 +489,60 @@ 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.
|
||||
/// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`]
|
||||
/// says why until a write succeeds.
|
||||
pub fn reap(&mut self) {
|
||||
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
|
||||
self.running = running;
|
||||
for (uid, h) in done {
|
||||
self.busy.remove(&uid);
|
||||
if !h.join().unwrap_or(false) {
|
||||
self.unwritten(uid);
|
||||
}
|
||||
self.settle(uid, h);
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten(&mut self, uid: u64) {
|
||||
fn settle(&mut self, uid: u64, h: std::thread::JoinHandle<std::result::Result<(), String>>) {
|
||||
self.busy.remove(&uid);
|
||||
match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) {
|
||||
Ok(()) => {
|
||||
self.failed.remove(&uid);
|
||||
}
|
||||
Err(e) => self.unwritten(uid, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write every drawing with changes not yet autosaved and wait until it is done, because
|
||||
/// the app has gone wrong and may not get another frame. Unlike [`Autosaver::save_now`]
|
||||
/// this also runs when SAVETIME is 0: unsaved work comes before that preference. Returns
|
||||
/// how many drawings were written.
|
||||
pub fn emergency_save(&mut self, s: &Session) -> usize {
|
||||
for (uid, h) in std::mem::take(&mut self.running) {
|
||||
self.settle(uid, h);
|
||||
}
|
||||
let now = self.start.elapsed().as_secs_f64();
|
||||
let dir = savefilepath();
|
||||
let jobs = self.plan_with(s, now, savetime().max(1), &dir, true);
|
||||
let uids: Vec<u64> = jobs.iter().filter_map(|j| if let Job::Write { uid, .. } = j { Some(*uid) } else { None }).collect();
|
||||
self.perform(jobs);
|
||||
self.last_beat = None;
|
||||
self.heartbeat(&dir, now);
|
||||
for (uid, h) in std::mem::take(&mut self.running) {
|
||||
self.settle(uid, h);
|
||||
}
|
||||
uids.iter().filter(|uid| !self.failed.contains_key(uid)).count()
|
||||
}
|
||||
|
||||
fn unwritten(&mut self, uid: u64, why: String) {
|
||||
if let Some(t) = self.docs.get_mut(&uid) {
|
||||
t.written = None;
|
||||
}
|
||||
self.failed.insert(uid, why);
|
||||
}
|
||||
|
||||
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
|
||||
@@ -451,16 +553,17 @@ mod native {
|
||||
Job::Write { uid, path, doc } => {
|
||||
let Some(write) = crate::cmd::file::io().map(|h| h.write) else {
|
||||
log::warn!("autosave: file formats are not available in this build");
|
||||
self.unwritten(uid, "file formats are not available in this build".to_string());
|
||||
continue;
|
||||
};
|
||||
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
|
||||
Ok(()) => {
|
||||
log::info!("autosaved {path}");
|
||||
true
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("autosave {path}: {e}");
|
||||
false
|
||||
Err(format!("{path}: {e}"))
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
@@ -470,7 +573,7 @@ mod native {
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("autosave: {e}");
|
||||
self.unwritten(uid);
|
||||
self.unwritten(uid, e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,12 +588,52 @@ mod native {
|
||||
}
|
||||
self.last_beat = Some(now);
|
||||
let dir = Path::new(dir);
|
||||
let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
|
||||
if let Err(e) = r {
|
||||
if let Err(e) = create_folder(dir).and_then(|()| self.refresh_lock(&lock_path(dir, &self.instance))) {
|
||||
log::warn!("autosave: lock file in {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
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 => write_lock(held, id),
|
||||
};
|
||||
if held == path {
|
||||
current = Some(refreshed);
|
||||
}
|
||||
}
|
||||
if let Some(refreshed) = current {
|
||||
return refreshed;
|
||||
}
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create(true).truncate(false);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
// A symlink planted at the lock path must not be opened (and truncated) through,
|
||||
// nor a FIFO wait for a reader.
|
||||
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK).mode(0o600);
|
||||
}
|
||||
let mut file = options.open(path)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the lock file is not a plain file"));
|
||||
}
|
||||
if file.try_lock().is_err() {
|
||||
self.locks.push((path.to_path_buf(), None));
|
||||
return write_lock(path, id);
|
||||
}
|
||||
file.set_len(0)?;
|
||||
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
|
||||
self.locks.push((path.to_path_buf(), Some(file)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app is closing normally: finish writes in progress, then delete this app's autosave
|
||||
/// files and its lock file.
|
||||
pub fn shutdown(&mut self) {
|
||||
@@ -502,12 +645,40 @@ mod native {
|
||||
discard(&path);
|
||||
}
|
||||
self.docs.clear();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A symlink planted at the lock path is neither opened through nor written through.
|
||||
#[test]
|
||||
fn lock_writes_do_not_follow_a_planted_link() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lock-link-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let victim = dir.join("victim.txt");
|
||||
std::fs::write(&victim, b"precious").unwrap();
|
||||
let link = dir.join("1-a.lock");
|
||||
std::os::unix::fs::symlink(&victim, &link).unwrap();
|
||||
let mut a = Autosaver::new("1-a");
|
||||
assert!(a.refresh_lock(&link).is_err());
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
|
||||
write_lock(&link, 42).unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
|
||||
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(std::fs::read(&link).unwrap(), b"42");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -22,6 +22,7 @@ mod inquiry;
|
||||
mod layer;
|
||||
mod layout;
|
||||
mod lisp;
|
||||
mod merge;
|
||||
mod mleader_content;
|
||||
mod mleader_edit;
|
||||
mod modify;
|
||||
|
||||
+175
-16
@@ -43,6 +43,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["i", "-insert", "ddinsert"])
|
||||
.params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}")
|
||||
.interactive(|_| Ok(Box::new(InsertM::default()))),
|
||||
CommandSpec::new("minsert", "Multiple Insert", run_minsert)
|
||||
.params("{name, file?: DXF/DWG path (as INSERT), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y], attribs?: {TAG: value}, rows?: 1..32767 (default 1), columns?: 1..32767 (default 1), rowSpacing?, columnSpacing? (along the reference's own axes, not scaled with it)}")
|
||||
.interactive(|_| Ok(Box::new(InsertM { minsert: true, ..InsertM::default() }))),
|
||||
CommandSpec::new("attdef", "Define Attributes...", run_attdef)
|
||||
.menu(&["Draw", "Block", "Define Attributes..."])
|
||||
.alias(&["att", "-attdef"])
|
||||
@@ -233,6 +236,33 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option
|
||||
ins
|
||||
}
|
||||
|
||||
/// MINSERT's rectangular array of a block reference: rows and columns, and the distances between
|
||||
/// them along the reference's rotated axes (not scaled with it). One row and one column is a plain
|
||||
/// reference.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Grid {
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
row_spacing: f64,
|
||||
col_spacing: f64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
const ONE: Grid = Grid { rows: 1, cols: 1, row_spacing: 0.0, col_spacing: 0.0 };
|
||||
/// The most rows or columns MINSERT takes, as AutoCAD 2024 asks for them.
|
||||
const MAX: u32 = 32767;
|
||||
|
||||
/// A distance is kept only along an axis with more than one copy: AutoCAD 2024 asks for no
|
||||
/// other and stores 0 for it (one row of three columns: 44 = 20, 45 = 0).
|
||||
fn new(rows: u32, cols: u32, row_spacing: f64, col_spacing: f64) -> Grid {
|
||||
Grid { rows, cols, row_spacing: if rows > 1 { row_spacing } else { 0.0 }, col_spacing: if cols > 1 { col_spacing } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn apply(self, ins: &mut Insert) {
|
||||
(ins.rows, ins.cols, ins.row_spacing, ins.col_spacing) = (self.rows, self.cols, self.row_spacing, self.col_spacing);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
|
||||
/// [`insert_entity`]).
|
||||
pub(crate) fn insert_with(
|
||||
@@ -244,10 +274,12 @@ pub(crate) fn insert_with(
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values, Grid::ONE)
|
||||
}
|
||||
|
||||
/// [`insert_with`] with a Z scale factor of its own.
|
||||
/// [`insert_with`] with a Z scale factor of its own, and MINSERT's grid. A grid has the one set
|
||||
/// of attributes (AutoCAD 2024 asks for their values once).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_scaled(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
@@ -256,9 +288,11 @@ fn insert_scaled(
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
grid: Grid,
|
||||
) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut ins = insert_entity(&blk, at, scale, rotation, base);
|
||||
grid.apply(&mut ins);
|
||||
let m = ins.transform(blk.base.xy());
|
||||
for e in blk.entities.iter() {
|
||||
if let EntityKind::AttDef(ad) = &e.kind {
|
||||
@@ -310,24 +344,52 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
insert_json(s, p, "insert", Grid::ONE)
|
||||
}
|
||||
|
||||
/// MINSERT's JSON form: INSERT's parameters and the grid.
|
||||
fn run_minsert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let count = |key: &str| -> Result<u32> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(1),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.fract() == 0.0 && *n >= 1.0 && *n <= f64::from(Grid::MAX))
|
||||
.map(|n| n as u32)
|
||||
.ok_or_else(|| bad("minsert", format!("`{key}` must be an integer between 1 and {}", Grid::MAX))),
|
||||
}
|
||||
};
|
||||
// A distance given must be a number: `"15"` is refused, not taken as 0.
|
||||
let spacing = |key: &str| -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(0.0),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("minsert", format!("`{key}` must be a number"))),
|
||||
}
|
||||
};
|
||||
let grid = Grid::new(count("rows")?, count("columns")?, spacing("rowSpacing")?, spacing("columnSpacing")?);
|
||||
insert_json(s, p, "minsert", grid)
|
||||
}
|
||||
|
||||
/// INSERT's and MINSERT's JSON form.
|
||||
fn insert_json(s: &mut Session, p: &Value, cmd: &str, grid: Grid) -> Result<Value> {
|
||||
let p = &insert_file::json_params(s, p)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
|
||||
let at = point_req("insert", p, "at")?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
|
||||
let at = point_req(cmd, p, "at")?;
|
||||
// `scale`: one factor, or [x, y] for different X and Y scale factors.
|
||||
let scale = match p.get("scale") {
|
||||
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?,
|
||||
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad(cmd, "`scale` must be a number or [x, y]"))?,
|
||||
_ => {
|
||||
let f = f64_or(p, "scale", 1.0);
|
||||
Vec2::new(f, f)
|
||||
}
|
||||
};
|
||||
if scale.x == 0.0 || scale.y == 0.0 {
|
||||
return Err(bad("insert", "scale cannot be 0"));
|
||||
return Err(bad(cmd, "scale cannot be 0"));
|
||||
}
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?;
|
||||
if bool_or(p, "explode", false) {
|
||||
let h = insert_scaled(s, &name, at, Vec3::new(scale.x, scale.y, scale.x), rot, point_param(p, "basePoint"), &vals, grid)?;
|
||||
if cmd == "insert" && bool_or(p, "explode", false) {
|
||||
// The EXPLODE command body, not `execute`: INSERT records the one undo step.
|
||||
let explode = find_command("explode").ok_or_else(|| EngineError::UnknownCommand("explode".into()))?;
|
||||
return (explode.run)(s, &json!({ "handles": [h.hex()] }));
|
||||
@@ -379,8 +441,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<_>>() }))
|
||||
@@ -557,6 +622,15 @@ enum InsStage {
|
||||
/// XYZ: the Z scale factor, after X and Y.
|
||||
ZScale,
|
||||
Rotation,
|
||||
/// MINSERT: the number of rows, then of columns.
|
||||
Rows,
|
||||
Cols,
|
||||
/// MINSERT: the distance between rows, or a unit cell's first corner.
|
||||
RowSpacing,
|
||||
/// MINSERT: the unit cell's opposite corner (its width between columns, its height between
|
||||
/// rows).
|
||||
Cell(Vec2),
|
||||
ColSpacing,
|
||||
Attribs,
|
||||
/// A Verify attribute asked again.
|
||||
Verify,
|
||||
@@ -589,6 +663,12 @@ struct InsertM {
|
||||
explode: bool,
|
||||
/// The file waiting for the Redefine answer.
|
||||
file: Option<insert_file::FileRef>,
|
||||
/// MINSERT: after the rotation, the grid's rows and columns and the distances between them.
|
||||
minsert: bool,
|
||||
rows: Option<u32>,
|
||||
cols: Option<u32>,
|
||||
row_spacing: Option<f64>,
|
||||
col_spacing: Option<f64>,
|
||||
}
|
||||
|
||||
impl InsertM {
|
||||
@@ -598,6 +678,8 @@ impl InsertM {
|
||||
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
|
||||
} else if self.rotation.is_none() {
|
||||
InsStage::Rotation
|
||||
} else if let Some(g) = self.grid_stage() {
|
||||
g
|
||||
} else {
|
||||
InsStage::Attribs
|
||||
};
|
||||
@@ -616,7 +698,7 @@ impl InsertM {
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
{
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values, self.grid())?;
|
||||
if self.explode {
|
||||
insert_file::explode_insert(s, h)?;
|
||||
}
|
||||
@@ -625,6 +707,30 @@ impl InsertM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// MINSERT's next grid prompt, if any is left (as AutoCAD 2024 asks them): the rows, the
|
||||
/// columns, then the distance between rows when there is more than one row and between
|
||||
/// columns when there is more than one column. A unit cell gives both distances.
|
||||
fn grid_stage(&self) -> Option<InsStage> {
|
||||
if !self.minsert {
|
||||
return None;
|
||||
}
|
||||
let (Some(rows), Some(cols)) = (self.rows, self.cols) else {
|
||||
return Some(if self.rows.is_none() { InsStage::Rows } else { InsStage::Cols });
|
||||
};
|
||||
if rows > 1 && self.row_spacing.is_none() {
|
||||
Some(InsStage::RowSpacing)
|
||||
} else if cols > 1 && self.col_spacing.is_none() {
|
||||
Some(InsStage::ColSpacing)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid answered so far (one copy for INSERT).
|
||||
fn grid(&self) -> Grid {
|
||||
Grid::new(self.rows.unwrap_or(1), self.cols.unwrap_or(1), self.row_spacing.unwrap_or(0.0), self.col_spacing.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// The Verify attributes asked for.
|
||||
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
|
||||
self.attdefs.iter().filter(|a| a.3).collect()
|
||||
@@ -695,13 +801,18 @@ impl InsertM {
|
||||
}
|
||||
}
|
||||
|
||||
/// A distance typed at MINSERT's grid prompts: any finite number, negative or 0 too.
|
||||
fn distance(t: &str) -> Result<f64> {
|
||||
number(t).filter(|v| v.is_finite()).ok_or_else(|| EngineError::Other("Requires a distance.".into()))
|
||||
}
|
||||
|
||||
fn nonzero(t: &str) -> Result<f64> {
|
||||
number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))
|
||||
}
|
||||
|
||||
impl Interactive for InsertM {
|
||||
fn name(&self) -> &'static str {
|
||||
"INSERT"
|
||||
if self.minsert { "MINSERT" } else { "INSERT" }
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let at = self.at.unwrap_or_default();
|
||||
@@ -728,9 +839,16 @@ impl Interactive for InsertM {
|
||||
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
|
||||
InsStage::Rows => Prompt::new("Enter number of rows (---)", Accept::NUMBER).default("1"),
|
||||
InsStage::Cols => Prompt::new("Enter number of columns (|||)", Accept::NUMBER).default("1"),
|
||||
InsStage::RowSpacing => Prompt::new("Enter distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
|
||||
InsStage::Cell(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
InsStage::ColSpacing => Prompt::new("Specify distance between columns (|||)", Accept::NUMBER),
|
||||
InsStage::Attribs => {
|
||||
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
|
||||
let p = Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT);
|
||||
// No default shown when there is none ("Value:", AutoCAD 2024), not "<>".
|
||||
if default.is_empty() { p } else { p.default(default) }
|
||||
}
|
||||
InsStage::Verify => {
|
||||
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
|
||||
@@ -752,9 +870,9 @@ impl Interactive for InsertM {
|
||||
s.echo(format!("Defined blocks: {}", names.join(", ")));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// `*name`: insert exploded.
|
||||
self.explode = n.starts_with('*');
|
||||
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
|
||||
// `*name`: insert exploded (INSERT only: a grid has no exploded form).
|
||||
self.explode = !self.minsert && n.starts_with('*');
|
||||
let n = if self.explode { n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string() } else { n };
|
||||
match insert_file::file_ref(s, &n) {
|
||||
Some(f) if f.ask_redefine(s) => {
|
||||
self.file = Some(f);
|
||||
@@ -894,6 +1012,45 @@ impl Interactive for InsertM {
|
||||
});
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::Rows | InsStage::Cols => {
|
||||
let n = match i {
|
||||
Input::Text(t) => t
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= f64::from(Grid::MAX))
|
||||
.map(|v| v as u32)
|
||||
.ok_or_else(|| EngineError::Other(format!("Requires an integer between 1 and {}.", Grid::MAX)))?,
|
||||
Input::Enter => 1,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if self.stage == InsStage::Rows {
|
||||
self.rows = Some(n);
|
||||
} else {
|
||||
self.cols = Some(n);
|
||||
}
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::RowSpacing => match i {
|
||||
Input::Point(a) => self.stage = InsStage::Cell(a),
|
||||
Input::Text(t) => {
|
||||
self.row_spacing = Some(distance(&t)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
InsStage::Cell(a) => {
|
||||
if let Input::Point(b) = i {
|
||||
(self.col_spacing, self.row_spacing) = (Some(b.x - a.x), Some(b.y - a.y));
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
InsStage::ColSpacing => {
|
||||
if let Input::Text(t) = i {
|
||||
self.col_spacing = Some(distance(&t)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
InsStage::Attribs => {
|
||||
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let v = match i {
|
||||
@@ -934,7 +1091,9 @@ impl Interactive for InsertM {
|
||||
_ => Vec2::new(1.0, 1.0),
|
||||
});
|
||||
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
|
||||
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
|
||||
let mut ins = insert_entity(b, at, self.scale3(scale), rot, self.base);
|
||||
self.grid().apply(&mut ins);
|
||||
vec![EntityKind::Insert(ins)]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (names this drawing already has keep this drawing's definitions). The file is read through the
|
||||
//! host's io hooks like OPEN, so the engine stays I/O-agnostic.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle};
|
||||
@@ -102,7 +102,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
// Redefining keeps the existing definition's name (block names are case-insensitive).
|
||||
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
merge_tables(&mut d, src);
|
||||
let renames = block_names(&d, src, &name);
|
||||
let renames = crate::cmd::merge::block_renames(&d, src, Some(&name));
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -136,13 +136,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition).
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
merge(&mut d.linetypes, &src.linetypes, |l| &l.name);
|
||||
merge(&mut d.text_styles, &src.text_styles, |t| &t.name);
|
||||
@@ -152,35 +146,6 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
merge(&mut d.mline_styles, &src.mline_styles, |t| &t.name);
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
||||
/// already has (or the block being defined) keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.insert(defining.to_ascii_uppercase());
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
|
||||
//! block empty with a status saying why; nothing here stops the host drawing from opening.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -339,14 +340,15 @@ fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &
|
||||
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
|
||||
Ok((x, file)) => match import(d, &key, &x, cx) {
|
||||
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
|
||||
Err(e) => (XrefStatus::Unresolved, e),
|
||||
Err(e) => {
|
||||
// Whatever made it in before the failure goes again. A fetch that failed imported
|
||||
// nothing, so it doesn't pay for a second scan of the block table.
|
||||
clear(d, &key);
|
||||
(XrefStatus::Unresolved, e)
|
||||
}
|
||||
},
|
||||
Err(e) => e,
|
||||
};
|
||||
if status != XrefStatus::Loaded {
|
||||
// Whatever made it in before the failure goes again.
|
||||
clear(d, &key);
|
||||
}
|
||||
set_status(d, &key, status);
|
||||
(status, detail)
|
||||
}
|
||||
@@ -390,9 +392,15 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
return Err(format!("more than {MAX_XREF_OBJECTS} objects"));
|
||||
}
|
||||
let dep = |sym: Sym, n: &str| (!shared(sym, n)).then(|| format!("{key}|{n}"));
|
||||
// The host's names per table, looked up in a set built once: both drawings are hostile input,
|
||||
// so a scan per imported record would be quadratic.
|
||||
fn names<T>(all: &[T], name: fn(&T) -> &str) -> HashSet<String> {
|
||||
all.iter().map(|t| name(t).to_ascii_uppercase()).collect()
|
||||
}
|
||||
let mut have = names(&d.layers, |l| &l.name);
|
||||
for l in &x.layers {
|
||||
if let Some(n) = dep(Sym::Layer, &l.name)
|
||||
&& d.layer(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
let mut l = l.clone();
|
||||
l.name = n;
|
||||
@@ -400,39 +408,44 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.linetypes, |t| &t.name);
|
||||
for t in &x.linetypes {
|
||||
if let Some(n) = dep(Sym::Linetype, &t.name)
|
||||
&& d.linetype(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
d.linetypes.push(cadcraft_doc::Linetype { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.text_styles, |t| &t.name);
|
||||
for t in &x.text_styles {
|
||||
if let Some(n) = dep(Sym::Style, &t.name)
|
||||
&& d.text_style(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
d.text_styles.push(cadcraft_doc::TextStyle { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.dim_styles, |t| &t.name);
|
||||
for t in &x.dim_styles {
|
||||
if let Some(n) = dep(Sym::DimStyle, &t.name)
|
||||
&& d.dim_style(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.dim_styles.push(cadcraft_doc::DimStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.mleader_styles, |t| &t.name);
|
||||
for t in &x.mleader_styles {
|
||||
if let Some(n) = dep(Sym::MLeaderStyle, &t.name)
|
||||
&& !d.mleader_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.mleader_styles.push(cadcraft_doc::MLeaderStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.table_styles, |t| &t.name);
|
||||
for t in &x.table_styles {
|
||||
if let Some(n) = dep(Sym::TableStyle, &t.name)
|
||||
&& !d.table_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
d.table_styles.push(cadcraft_doc::TableStyle { name: n, ..t.clone() });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
|
||||
//! end can put it on the system clipboard and paste it into another CADCraft window.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
|
||||
@@ -279,20 +279,14 @@ fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
|
||||
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
|
||||
/// their name in `defs` to their name in `d`.
|
||||
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::cmd::merge::merge_named as add;
|
||||
add(&mut d.layers, &defs.layers, |l| &l.name);
|
||||
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
|
||||
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
|
||||
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
|
||||
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
|
||||
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
|
||||
let renames = block_names(d, defs);
|
||||
let renames = crate::cmd::merge::block_renames(d, defs, None);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = defs.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -308,34 +302,6 @@ fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
renames
|
||||
}
|
||||
|
||||
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
|
||||
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
|
||||
/// already has keeps `d`'s definition (not copied).
|
||||
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
|
||||
for (name, b) in &defs.blocks {
|
||||
let upper = up(name);
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(up(&to));
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
|
||||
/// objects of the source drawing (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! stays I/O-agnostic; the JSON forms never open dialogs (the UI asks for files when these
|
||||
//! commands come from a menu, the tool bar or the command line).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Rgb;
|
||||
@@ -166,7 +166,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let (layers, styles) = merge_tables(d, src);
|
||||
let renames = block_names(d, src);
|
||||
let renames = crate::cmd::merge::block_renames(d, src, None);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -213,16 +213,7 @@ fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition). Returns how many layers and how many other records were added.
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
||||
let mut n = 0;
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
let layers = merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
let others = merge(&mut d.linetypes, &src.linetypes, |l| &l.name)
|
||||
+ merge(&mut d.text_styles, &src.text_styles, |t| &t.name)
|
||||
@@ -232,35 +223,6 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
||||
(layers, others)
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
||||
/// already has keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let anonymous = b.anonymous || name.starts_with('*');
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if anonymous {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, Layer, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad};
|
||||
@@ -197,6 +197,7 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
d.skipped_records.clear();
|
||||
d.handseed = d.handseed.max(other.handseed);
|
||||
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
merge(&mut d.layers, &other.layers, |t| &t.name);
|
||||
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
|
||||
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
|
||||
@@ -235,22 +236,17 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
|
||||
}
|
||||
|
||||
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
|
||||
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
|
||||
for t in from {
|
||||
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
|
||||
to.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add copies of `objs` to model space with new handles, their block references renamed by
|
||||
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
|
||||
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
|
||||
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
|
||||
let mut members = Vec::new();
|
||||
// The layers this drawing has, looked up once per object rather than scanned.
|
||||
let mut layers: HashSet<String> = d.layers.iter().map(|l| l.name.to_ascii_uppercase()).collect();
|
||||
for (e, color) in objs {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
if layers.insert(e.common.layer.to_ascii_uppercase()) {
|
||||
d.layers.push(Layer::new(&e.common.layer));
|
||||
}
|
||||
let h = d.new_handle();
|
||||
let mut copy = Entity { handle: h, ..(*e).clone() };
|
||||
detach(&mut copy.kind);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -408,12 +408,24 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("layerstate.restore", "`name` is required"))?;
|
||||
let d = s.doc_mut()?;
|
||||
let st = d.layer_states.iter().find(|st| st.name == name).cloned().ok_or_else(|| bad("layerstate.restore", "no such layer state"))?;
|
||||
for saved in st.layers {
|
||||
if let Some(l) = d.layer_mut(&saved.name) {
|
||||
*l = saved;
|
||||
restore_layers(d, st.layers);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Put the saved layers back over the drawing's layers of the same name (without regard to case);
|
||||
/// saved layers the drawing no longer has are skipped. Both lists can come from a file, so the
|
||||
/// drawing's layers are looked up in an index rather than scanned per saved layer.
|
||||
pub(crate) fn restore_layers(d: &mut cadcraft_doc::Drawing, saved: Vec<Layer>) {
|
||||
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::with_capacity(d.layers.len());
|
||||
for (i, l) in d.layers.iter().enumerate() {
|
||||
// The first of names that differ only in case, as `Drawing::layer_mut` finds it.
|
||||
index.entry(l.name.to_ascii_lowercase()).or_insert(i);
|
||||
}
|
||||
for layer in saved {
|
||||
if let Some(l) = index.get(&layer.name.to_ascii_lowercase()).and_then(|&i| d.layers.get_mut(i)) {
|
||||
*l = layer;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn run_state_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -461,6 +473,22 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Restoring many saved layers, most of them missing, takes one pass and matches without case.
|
||||
#[test]
|
||||
fn restore_layers_matches_by_name_in_linear_time() {
|
||||
let mut d = cadcraft_doc::Drawing::new_imperial();
|
||||
d.layers = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("L{i}"))).collect();
|
||||
let mut saved: Vec<cadcraft_doc::Layer> = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("missing{i}"))).collect();
|
||||
let mut back = cadcraft_doc::Layer::new("l7");
|
||||
back.frozen = true;
|
||||
saved.push(back);
|
||||
let started = std::time::Instant::now();
|
||||
super::restore_layers(&mut d, saved);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert!(d.layer("L7").is_some_and(|l| l.frozen));
|
||||
assert_eq!(d.layers.len(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_states_save_list_restore_delete() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Bringing another drawing's symbol tables and blocks into this one (INSERT of a file, IMPORT,
|
||||
//! paste, COMPARE export).
|
||||
//!
|
||||
//! Both drawings are hostile input, so the work is linear in their sizes: names are looked up in a
|
||||
//! set built once per table, and a generated block name continues from the last one allocated for
|
||||
//! its prefix instead of probing every taken number again (CWE-407).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
|
||||
/// Add the entries of `theirs` whose names `ours` lacks (compared without letter case; a name that
|
||||
/// exists keeps `ours`' definition). Returns how many were added.
|
||||
pub(crate) fn merge_named<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
||||
let mut have: HashSet<String> = ours.iter().map(|o| name(o).to_ascii_uppercase()).collect();
|
||||
let mut added = 0;
|
||||
for t in theirs {
|
||||
if have.insert(name(t).to_ascii_uppercase()) {
|
||||
ours.push(t.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Which blocks of `src` to copy into `d`, and under which name: a new name keeps its own, a
|
||||
/// generated (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named
|
||||
/// block `d` already has (or `defining`, the block being defined from `src`) keeps `d`'s
|
||||
/// definition (not copied).
|
||||
pub(crate) fn block_renames(d: &Drawing, src: &Drawing, defining: Option<&str>) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.extend(defining.map(str::to_ascii_uppercase));
|
||||
// The next number to try per generated prefix: numbers below it are taken.
|
||||
let mut next: HashMap<String, usize> = HashMap::new();
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
let cursor = next.entry(prefix.to_ascii_uppercase()).or_insert(1);
|
||||
match (*cursor..=1_000_000).map(|i| (i, format!("{prefix}{i}"))).find(|(_, n)| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some((i, n)) => {
|
||||
*cursor = i + 1;
|
||||
n
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Layer};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_keeps_ours_and_adds_the_rest_without_regard_to_case() {
|
||||
let mut ours = vec![Layer::new("Walls")];
|
||||
let theirs = vec![Layer::new("WALLS"), Layer::new("Doors"), Layer::new("doors")];
|
||||
assert_eq!(merge_named(&mut ours, &theirs, |l| &l.name), 1);
|
||||
assert_eq!(ours.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Walls", "Doors"]);
|
||||
}
|
||||
|
||||
/// Many colliding anonymous blocks get consecutive names in one pass, not a rescan each.
|
||||
#[test]
|
||||
fn anonymous_collisions_take_consecutive_numbers() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut src = Drawing::new_imperial();
|
||||
for i in 1..=5000 {
|
||||
let mut b = Block::new(&format!("*U{i}"));
|
||||
b.anonymous = true;
|
||||
d.blocks.insert(b.name.clone(), Arc::new(b.clone()));
|
||||
src.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let renames = block_renames(&d, &src, None);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert_eq!(renames.len(), 5000);
|
||||
assert_eq!(renames.get("*U1").map(String::as_str), Some("*U5001"));
|
||||
let distinct: HashSet<&String> = renames.values().collect();
|
||||
assert_eq!(distinct.len(), 5000);
|
||||
// Every new name is the next free number: nothing below 5001 is free, nothing above 10000 is needed.
|
||||
assert!(renames.values().all(|n| n.strip_prefix("*U").and_then(|i| i.parse::<u32>().ok()).is_some_and(|i| (5001..=10000).contains(&i))));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,78 @@
|
||||
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);
|
||||
}
|
||||
// A MINSERT grid stays whole, as in AutoCAD 2024 (see `explode`).
|
||||
if minserted(ins) {
|
||||
return None;
|
||||
}
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let stretched = m.similarity_scale().is_none();
|
||||
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();
|
||||
}
|
||||
@@ -168,9 +227,24 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
}
|
||||
|
||||
pub(super) fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
|
||||
// AutoCAD 2024 refuses a MINSERT grid: "The object is minserted." (Locked ones are left
|
||||
// without a word, as `explode_with` leaves them.)
|
||||
let d = s.doc()?;
|
||||
let grids = hs
|
||||
.iter()
|
||||
.filter(|h| !crate::cmd::curves::is_locked(s, **h) && d.entity(**h).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if minserted(i))))
|
||||
.count();
|
||||
for _ in 0..grids {
|
||||
s.echo("The object is minserted.");
|
||||
}
|
||||
explode_with(s, hs, explode_kind)
|
||||
}
|
||||
|
||||
/// A block reference with more than one row or column (MINSERT).
|
||||
fn minserted(i: &cadcraft_doc::Insert) -> bool {
|
||||
i.rows > 1 || i.cols > 1
|
||||
}
|
||||
|
||||
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
|
||||
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
|
||||
pub(crate) fn explode_with(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d<a]`), arithmetic and vector
|
||||
//! arithmetic, numeric and geometric functions; unit conversion; values picked from the drawing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -67,6 +69,9 @@ struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
vars: &'a Map<String, Value>,
|
||||
/// `vars` by ASCII-lower-cased name (the first of names that differ only in case), so a
|
||||
/// case-insensitive lookup is not a scan of the map per reference.
|
||||
folded: HashMap<String, &'a Value>,
|
||||
depth: usize,
|
||||
unary_depth: usize,
|
||||
}
|
||||
@@ -230,7 +235,7 @@ impl Parser<'_> {
|
||||
match name.as_str() {
|
||||
"pi" => Ok(Val::Num(std::f64::consts::PI)),
|
||||
"e" => Ok(Val::Num(std::f64::consts::E)),
|
||||
_ => var(self.vars, &name).ok_or_else(|| format!("unknown name `{name}`")),
|
||||
_ => var(self.vars, &self.folded, &name).ok_or_else(|| format!("unknown name `{name}`")),
|
||||
}
|
||||
}
|
||||
_ => Err("expected a number".into()),
|
||||
@@ -239,8 +244,8 @@ impl Parser<'_> {
|
||||
}
|
||||
|
||||
/// A variable: a number, or a point as `[x, y]`. Names are case-insensitive.
|
||||
fn var(vars: &Map<String, Value>, name: &str) -> Option<Val> {
|
||||
let v = vars.get(name).or_else(|| vars.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?;
|
||||
fn var(vars: &Map<String, Value>, folded: &HashMap<String, &Value>, name: &str) -> Option<Val> {
|
||||
let v = vars.get(name).or_else(|| folded.get(&name.to_ascii_lowercase()).copied())?;
|
||||
if let Some(n) = v.as_f64() {
|
||||
return Some(Val::Num(n));
|
||||
}
|
||||
@@ -411,7 +416,11 @@ pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
|
||||
if expr.len() > MAX_LEN {
|
||||
return Err("expression too long".into());
|
||||
}
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0, unary_depth: 0 };
|
||||
let mut folded = HashMap::with_capacity(vars.len());
|
||||
for (k, v) in vars {
|
||||
folded.entry(k.to_ascii_lowercase()).or_insert(v);
|
||||
}
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, folded, depth: 0, unary_depth: 0 };
|
||||
let v = p.expr()?;
|
||||
if p.peek().is_some() {
|
||||
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
|
||||
@@ -587,6 +596,21 @@ mod tests {
|
||||
eval_val(s, &Map::new())
|
||||
}
|
||||
|
||||
/// Variables are found without regard to case; an exact match wins over a case variant.
|
||||
/// (Names are read lower-cased, so `X` finds `x` too.)
|
||||
#[test]
|
||||
fn variables_ignore_case_and_prefer_exact_names() {
|
||||
let mut vars = Map::new();
|
||||
for i in 0..10_000 {
|
||||
vars.insert(format!("v{i}"), json!(i));
|
||||
}
|
||||
vars.insert("X".into(), json!(1));
|
||||
vars.insert("x".into(), json!(2));
|
||||
assert!(matches!(eval_val("V9999 + 1", &vars), Ok(Val::Num(n)) if n == 10_000.0));
|
||||
assert!(matches!(eval_val("x", &vars), Ok(Val::Num(n)) if n == 2.0));
|
||||
assert!(eval_val("nope", &vars).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_and_vectors() {
|
||||
assert_eq!(ev("[1,2]+[3,4]*2"), Ok(Val::Pt(Vec2::new(7.0, 10.0))));
|
||||
|
||||
@@ -185,13 +185,7 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
|
||||
if let Some(state) = &v.layer_state {
|
||||
let d = s.doc_mut()?;
|
||||
match d.layer_states.iter().find(|l| l.name.eq_ignore_ascii_case(state)).cloned() {
|
||||
Some(saved) => {
|
||||
for layer in saved.layers {
|
||||
if let Some(l) = d.layer_mut(&layer.name) {
|
||||
*l = layer;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(saved) => super::super::layer::restore_layers(d, saved.layers),
|
||||
None => msg.push_str(&format!(" Its layer snapshot \"{state}\" is missing.")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ fn recovery_offers_files_of_apps_that_stopped() {
|
||||
|
||||
#[test]
|
||||
fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
|
||||
let _turn = folder_turn();
|
||||
hooks();
|
||||
let dir = temp_dir("write");
|
||||
let mut s = Session::new();
|
||||
@@ -255,6 +256,108 @@ fn a_new_autosave_folder_is_owner_only() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// SAVEFILEPATH is process-wide: the tests that set it take turns.
|
||||
static FOLDER: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn folder_turn() -> std::sync::MutexGuard<'static, ()> {
|
||||
FOLDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Run autosave once in `dir`, so `a` writes a file and takes its lock, and wait for the file.
|
||||
fn autosave_once(a: &mut Autosaver, s: &Session, dir: &std::path::Path) -> String {
|
||||
autosave::set_savefilepath(&dir.to_string_lossy());
|
||||
assert_eq!(a.save_now(s), 1);
|
||||
for _ in 0..500 {
|
||||
a.reap();
|
||||
if !a.writing() && !a.files().is_empty() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
a.files().pop().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crashed_app_is_offered_at_once_and_a_running_one_never() {
|
||||
hooks();
|
||||
let _turn = folder_turn();
|
||||
let dir = temp_dir("instant");
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
let mut a = Autosaver::new("41-e");
|
||||
let file = autosave_once(&mut a, &s, &dir);
|
||||
let lock = dir.join("41-e.lock");
|
||||
assert!(std::fs::metadata(&lock).is_ok());
|
||||
|
||||
// Running: not offered, even once its lock file looks old (a suspended laptop).
|
||||
std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now() - Duration::from_secs(3600)).ok();
|
||||
assert!(autosave::recoverable(&dir, "9-f").is_empty());
|
||||
autosave::prune_locks(&dir);
|
||||
assert!(lock.exists(), "a running app's lock stays");
|
||||
|
||||
// Gone without closing, as in a crash: the file is fresh, and it is offered right away.
|
||||
std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now()).ok();
|
||||
drop(a);
|
||||
let found = autosave::recoverable(&dir, "9-f");
|
||||
assert_eq!(found.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(), vec![file.as_str()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emergency_save_finishes_before_it_returns_even_with_savetime_off() {
|
||||
hooks();
|
||||
let _turn = folder_turn();
|
||||
let dir = temp_dir("emergency");
|
||||
autosave::set_savefilepath(&dir.to_string_lossy());
|
||||
let before = autosave::savetime();
|
||||
autosave::set_savetime(0);
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
let mut a = Autosaver::new("43-e");
|
||||
assert_eq!(a.save_now(&s), 0, "an ordinary save honours SAVETIME 0");
|
||||
assert_eq!(a.emergency_save(&s), 1);
|
||||
autosave::set_savetime(before);
|
||||
let file = a.files().pop().unwrap();
|
||||
assert!(std::fs::read(&file).unwrap().ends_with(b"EOF\n"), "the file is whole on return");
|
||||
assert_eq!(a.emergency_save(&s), 0, "nothing changed since");
|
||||
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();
|
||||
let _turn = folder_turn();
|
||||
let dir = temp_dir("exit");
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
let mut a = Autosaver::new("42-e");
|
||||
autosave_once(&mut a, &s, &dir);
|
||||
assert!(dir.join("42-e.lock").exists());
|
||||
a.shutdown();
|
||||
assert!(!dir.join("42-e.lock").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_autosave_is_tried_again_without_another_change() {
|
||||
hooks();
|
||||
@@ -283,6 +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.failures().next().is_some_and(|(_, why)| why.contains("out")));
|
||||
|
||||
std::fs::remove_file(&out).unwrap();
|
||||
let jobs = a.plan(&s, 180.0, 1, &dir);
|
||||
@@ -303,6 +407,36 @@ 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.failures().next(), None, "a write that worked clears the failure");
|
||||
a.shutdown();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_ends_when_the_drawing_no_longer_needs_a_copy() {
|
||||
hooks();
|
||||
let root = temp_dir("failed-close");
|
||||
let out = root.join("out");
|
||||
std::fs::write(&out, b"not a folder").unwrap();
|
||||
let dir = out.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
let mut a = Autosaver::new("32-e");
|
||||
a.plan(&s, 0.0, 1, &dir);
|
||||
let jobs = a.plan(&s, 60.0, 1, &dir);
|
||||
a.perform(jobs);
|
||||
for _ in 0..500 {
|
||||
a.reap();
|
||||
if a.failures().next().is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
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.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;
|
||||
@@ -79,6 +81,7 @@ mod malformed_handles;
|
||||
mod matchprop;
|
||||
mod measure_distance_snapping;
|
||||
mod metric_linetypes;
|
||||
mod minsert;
|
||||
mod mirror_degenerate_line;
|
||||
mod mleader_content;
|
||||
mod mleader_remember;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! MINSERT, one block reference repeated in rows and columns, as AutoCAD 2024 makes and edits it
|
||||
//! (accoreconsole: the prompts, and `entget` of the reference after MINSERT, EXPLODE, MIRROR,
|
||||
//! SCALE and ROTATE).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Insert};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn handle(v: &Value) -> String {
|
||||
v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_owned()
|
||||
}
|
||||
|
||||
/// A session with block B (a line from (0, 0) to (10, 0) and a circle of radius 3 round (5, 5),
|
||||
/// base point (0, 0)), after the AutoLISP `code`.
|
||||
fn run(code: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
let a = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
let c = handle(&s.execute("circle", &json!({"center": [5, 5], "radius": 3})).unwrap());
|
||||
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [a, c]})).unwrap();
|
||||
s.cmdline(code).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
s
|
||||
}
|
||||
|
||||
fn inserts(s: &Session) -> Vec<Insert> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The grid as `entget` gives it: 70 columns, 71 rows, 44 between columns, 45 between rows (a
|
||||
/// plain reference, one row and one column, as 1/1/0/0).
|
||||
fn grid(i: &Insert) -> (u32, u32, f64, f64) {
|
||||
(i.cols, i.rows, i.col_spacing, i.row_spacing)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_is_stored_as_autocad_2024_stores_it() {
|
||||
// (MINSERT's answers after the block name and insertion point, the grid AutoCAD stored.)
|
||||
let cases = [
|
||||
// One row: no distance between rows asked, and 0 stored for it.
|
||||
("1 \"\" 0 1 3 20.0", (3, 1, 20.0, 0.0)),
|
||||
("1 \"\" 0 3 1 15.0", (1, 3, 0.0, 15.0)),
|
||||
// One of each: a plain reference.
|
||||
("1 \"\" 0 1 1", (1, 1, 0.0, 0.0)),
|
||||
// A unit cell: its width between columns, its height between rows, signed.
|
||||
("1 \"\" 0 2 3 '(0.0 0.0) '(25.0 12.0)", (3, 2, 25.0, 12.0)),
|
||||
("1 \"\" 0 2 3 '(25.0 12.0) '(0.0 0.0)", (3, 2, -25.0, -12.0)),
|
||||
// Its sides along the drawing's axes, the reference rotated or not.
|
||||
("1 \"\" 30 2 3 '(0.0 0.0) '(25.0 12.0)", (3, 2, 25.0, 12.0)),
|
||||
// Typed distances are kept as typed, negative or 0 too.
|
||||
("1 \"\" 0 2 2 -15.0 -20.0", (2, 2, -20.0, -15.0)),
|
||||
("1 \"\" 0 2 2 0.0 20.0", (2, 2, 20.0, 0.0)),
|
||||
// The distances are not scaled with the reference.
|
||||
("2 \"\" 30 2 2 15.0 20.0", (2, 2, 20.0, 15.0)),
|
||||
];
|
||||
for (answers, want) in cases {
|
||||
let s = run(&format!("(command \"_.MINSERT\" \"B\" '(20.0 10.0) {answers})"));
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
let got = grid(&i);
|
||||
assert!(got.0 == want.0 && got.1 == want.1 && near(got.2, want.2) && near(got.3, want.3), "{answers}: {got:?}");
|
||||
assert!(near(i.insert.x, 20.0) && near(i.insert.y, 10.0));
|
||||
}
|
||||
let s = run("(command \"_.MINSERT\" \"B\" '(20.0 10.0) 2 \"\" 30 2 2 15.0 20.0)");
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert!(near(i.scale.x, 2.0) && near(i.scale.y, 2.0) && near(i.rotation, PI / 6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompts_follow_autocad_2024() {
|
||||
let mut s = Session::new();
|
||||
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap();
|
||||
// Each prompt, and what was written to the command line on the way to it.
|
||||
let mut asked = Vec::new();
|
||||
for input in ["minsert", "B", "20,10", "1", "", "0", "0", "2", "3", "x", "15", "20"] {
|
||||
let logged = s.log.len();
|
||||
s.cmdline(input).unwrap();
|
||||
asked.extend(s.log.iter().skip(logged).filter(|l| l.starts_with("Requires")).cloned());
|
||||
if let Some(p) = s.current_prompt() {
|
||||
asked.push(p.display());
|
||||
}
|
||||
}
|
||||
// The grid's prompts and messages are AutoCAD 2024's; the ones before are INSERT's.
|
||||
assert_eq!(
|
||||
asked,
|
||||
[
|
||||
"Enter block name or [?]:",
|
||||
"Specify insertion point or [Basepoint/Scale/X/Y/Z/Rotate]:",
|
||||
"Enter X scale factor, specify opposite corner or [Corner/XYZ] <1>:",
|
||||
"Enter Y scale factor <use X scale factor>:",
|
||||
"Specify rotation angle <0>:",
|
||||
"Enter number of rows (---) <1>:",
|
||||
"Requires an integer between 1 and 32767.",
|
||||
"Enter number of rows (---) <1>:",
|
||||
"Enter number of columns (|||) <1>:",
|
||||
"Enter distance between rows or specify unit cell (---):",
|
||||
"Requires a distance.",
|
||||
"Enter distance between rows or specify unit cell (---):",
|
||||
"Specify distance between columns (|||):",
|
||||
]
|
||||
);
|
||||
assert!(s.running.is_none());
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert_eq!(grid(&i), (3, 2, 20.0, 15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_run_from_1_to_32767() {
|
||||
// AutoCAD 2024: "Requires an integer between 1 and 32767."
|
||||
let mut s = Session::new();
|
||||
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap();
|
||||
for input in ["minsert", "B", "0,0", "1", "", "0", "32768", "32767", "1", "2"] {
|
||||
s.cmdline(input).unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(s.log.iter().filter(|l| l.starts_with("Requires an integer between 1 and 32767.")).count(), 1, "{:?}", s.log);
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert_eq!(grid(&i), (1, 32767, 0.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_values_are_asked_once_after_the_grid() {
|
||||
let mut s = Session::new();
|
||||
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
let a = handle(&s.execute("attdef", &json!({"tag": "TAG", "prompt": "Value", "at": [0, 2], "height": 1})).unwrap());
|
||||
s.execute("-block", &json!({"name": "BA", "base": [0, 0], "handles": [l, a]})).unwrap();
|
||||
for input in ["minsert", "BA", "0,700", "1", "", "0", "5", "4", "15", "20"] {
|
||||
s.cmdline(input).unwrap();
|
||||
}
|
||||
// After the grid, with no "<>" for an empty default (AutoCAD 2024: "Value: hello").
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Value:");
|
||||
s.cmdline("hello").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert_eq!(grid(&i), (4, 5, 20.0, 15.0));
|
||||
assert_eq!(i.attribs.iter().map(|a| a.text.value.as_str()).collect::<Vec<_>>(), ["hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explode_leaves_a_grid_whole() {
|
||||
let s = run("(progn (command \"_.MINSERT\" \"B\" '(20.0 10.0) 1 \"\" 0 2 2 15.0 20.0) (command \"_.EXPLODE\" (entlast) \"\"))");
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert_eq!(grid(&i), (2, 2, 20.0, 15.0));
|
||||
assert_eq!(s.doc().unwrap().model.iter().count(), 1);
|
||||
assert!(s.log.iter().any(|l| l.contains("The object is minserted.")), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_carry_the_grid_as_in_autocad_2024() {
|
||||
let made = "(command \"_.MINSERT\" \"B\" '(20.0 10.0) 1 \"\" 0 2 2 15.0 20.0)";
|
||||
// (the edit, the copy it leaves last: insertion point, X and Y scale, rotation, grid)
|
||||
let cases = [
|
||||
("(command \"_.SCALE\" (entlast) \"\" '(20.0 10.0) 2)", ([20.0, 10.0], [2.0, 2.0], 0.0, (2, 2, 40.0, 30.0))),
|
||||
("(command \"_.ROTATE\" (entlast) \"\" '(20.0 10.0) 30)", ([20.0, 10.0], [1.0, 1.0], PI / 6.0, (2, 2, 20.0, 15.0))),
|
||||
("(command \"_.MOVE\" (entlast) \"\" '(20.0 10.0) '(25.0 13.0))", ([25.0, 13.0], [1.0, 1.0], 0.0, (2, 2, 20.0, 15.0))),
|
||||
// Mirrored about the vertical through the insertion point, and about the horizontal: X
|
||||
// scale -1, and the columns run the other way.
|
||||
("(command \"_.MIRROR\" (entlast) \"\" '(20.0 10.0) '(20.0 30.0) \"_N\")", ([20.0, 10.0], [-1.0, 1.0], 0.0, (2, 2, -20.0, 15.0))),
|
||||
("(command \"_.MIRROR\" (entlast) \"\" '(20.0 10.0) '(40.0 10.0) \"_N\")", ([20.0, 10.0], [-1.0, 1.0], PI, (2, 2, -20.0, 15.0))),
|
||||
];
|
||||
for (edit, (at, scale, rot, want)) in cases {
|
||||
let s = run(&format!("(progn {made} {edit})"));
|
||||
let i = inserts(&s).pop().unwrap();
|
||||
let got = grid(&i);
|
||||
assert!(
|
||||
near(i.insert.x, at[0])
|
||||
&& near(i.insert.y, at[1])
|
||||
&& near(i.scale.x, scale[0])
|
||||
&& near(i.scale.y, scale[1])
|
||||
&& near(i.rotation, rot)
|
||||
&& got.0 == want.0
|
||||
&& got.1 == want.1
|
||||
&& near(got.2, want.2)
|
||||
&& near(got.3, want.3),
|
||||
"{edit}: {i:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_form_makes_the_same_grid() {
|
||||
let mut s = Session::new();
|
||||
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap();
|
||||
s.execute("minsert", &json!({"name": "B", "at": [20, 10], "rows": 2, "columns": 3, "rowSpacing": 15, "columnSpacing": 20})).unwrap();
|
||||
// One row: its distance is not kept.
|
||||
s.execute("minsert", &json!({"name": "B", "at": [20, 100], "rows": 1, "columns": 3, "rowSpacing": 15, "columnSpacing": 20})).unwrap();
|
||||
let got: Vec<_> = inserts(&s).iter().map(grid).collect();
|
||||
assert_eq!(got, [(3, 2, 20.0, 15.0), (3, 1, 20.0, 0.0)]);
|
||||
for bad in [
|
||||
json!({"name": "B", "at": [0, 0], "rows": 0}),
|
||||
json!({"name": "B", "at": [0, 0], "columns": 32768}),
|
||||
json!({"name": "B", "at": [0, 0], "rows": 2.5}),
|
||||
// A distance that is not a number is refused, not taken as 0.
|
||||
json!({"name": "B", "at": [0, 0], "rows": 2, "rowSpacing": "15"}),
|
||||
json!({"name": "B", "at": [0, 0], "columns": 2, "columnSpacing": true}),
|
||||
] {
|
||||
assert!(s.execute("minsert", &bad).is_err(), "{bad}");
|
||||
}
|
||||
// A whole number written as 2.0 is a count; `explode` is INSERT's and leaves the grid made.
|
||||
let r = s.execute("minsert", &json!({"name": "B", "at": [0, 200], "rows": 2.0, "rowSpacing": 15, "explode": true})).unwrap();
|
||||
assert!(r["handle"].is_string(), "{r}");
|
||||
assert_eq!(inserts(&s).last().map(grid), Some((1, 2, 0.0, 15.0)));
|
||||
}
|
||||
+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.
|
||||
|
||||
@@ -178,14 +178,32 @@ pub(super) fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx:
|
||||
(paper_block_for, first_layout)
|
||||
}
|
||||
|
||||
/// The drawing's layer names (lower-cased), so adding the layer an entity names is a set lookup
|
||||
/// rather than a scan of the layer table per entity: many distinct names made that quadratic
|
||||
/// (CWE-407).
|
||||
struct LayerSet(std::collections::HashSet<String>);
|
||||
|
||||
impl LayerSet {
|
||||
fn new(d: &Drawing) -> Self {
|
||||
Self(d.layers.iter().map(|l| l.name.to_ascii_lowercase()).collect())
|
||||
}
|
||||
/// [`Drawing::ensure_layer`] through the set.
|
||||
fn ensure(&mut self, d: &mut Drawing, name: &str) {
|
||||
if self.0.insert(name.to_ascii_lowercase()) {
|
||||
d.layers.push(cadcraft_doc::Layer::new(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Place the BLOCKS entities: model and paper space blocks go to their space, the rest become
|
||||
/// block definitions.
|
||||
pub(super) fn place_blocks(d: &mut Drawing, blocks: Vec<BlockDef>, rx: &mut Rx, paper_block_for: &HashMap<String, String>, first_layout: &str) {
|
||||
let mut layers = LayerSet::new(d);
|
||||
for (name, base, description, ents, xref) in blocks {
|
||||
let up = name.to_ascii_uppercase();
|
||||
if up == "*MODEL_SPACE" {
|
||||
for (_, _, e) in ents {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
layers.ensure(d, &e.common.layer);
|
||||
d.model.push(e);
|
||||
}
|
||||
continue;
|
||||
@@ -241,8 +259,9 @@ pub(super) fn place_entities(
|
||||
paper_block_for: &HashMap<String, String>,
|
||||
first_layout: &str,
|
||||
) {
|
||||
let mut layers = LayerSet::new(d);
|
||||
for (owner, paper, e) in entities {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
layers.ensure(d, &e.common.layer);
|
||||
let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase());
|
||||
let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE"));
|
||||
if to_paper {
|
||||
@@ -314,7 +333,17 @@ pub(super) fn apply_sortents(
|
||||
paper_for: &HashMap<String, String>,
|
||||
first_layout: &str,
|
||||
) {
|
||||
for (owner, map) in tables {
|
||||
// Each sort rebuilds its whole space, so a space named by many tables is sorted once, by the
|
||||
// last of them (which decided its order anyway); repeated tables made import quadratic.
|
||||
let target = |owner: &String| {
|
||||
let up = brs.get(owner)?.to_ascii_uppercase();
|
||||
Some(if up.starts_with("*PAPER_SPACE") { paper_for.get(&up).cloned().unwrap_or_else(|| first_layout.to_string()) + "\0paper" } else { up })
|
||||
};
|
||||
let last: HashMap<String, usize> = tables.iter().enumerate().filter_map(|(i, (owner, _))| Some((target(owner)?, i))).collect();
|
||||
for (i, (owner, map)) in tables.iter().enumerate() {
|
||||
if target(owner).and_then(|t| last.get(&t)) != Some(&i) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = brs.get(owner) else { continue };
|
||||
let up = name.to_ascii_uppercase();
|
||||
// A sort handle of 0 draws last (ezdxf `reorder`, which notes AutoCAD defines it, undocumented).
|
||||
|
||||
@@ -299,7 +299,9 @@ pub(super) fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table {
|
||||
// Large declared sizes must be backed by cell records (no allocation from a bare count).
|
||||
let n_cells = tail.iter().filter(|x| x.code == 171).count();
|
||||
let declared = rows.saturating_mul(cols);
|
||||
if declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) {
|
||||
// A table with no rows or no columns has no cells: keep neither dimension, so its row heights
|
||||
// and column widths (allocated per dimension, outside the cell budget) are not allocated.
|
||||
if declared == 0 || declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) {
|
||||
rows = 0;
|
||||
cols = 0;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
// appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer
|
||||
// names make import quadratic (CWE-407).
|
||||
let mut layer_index: HashMap<String, usize> = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect();
|
||||
// The same for linetypes (first definition wins) and text styles (last definition wins).
|
||||
let mut linetype_names: std::collections::HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
|
||||
let mut style_index: HashMap<String, usize> = d.text_styles.iter().enumerate().map(|(i, s)| (s.name.to_ascii_lowercase(), i)).collect();
|
||||
for (kind, tg) in recs {
|
||||
let t = T(&tg);
|
||||
match kind.as_str() {
|
||||
@@ -159,9 +162,10 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
}
|
||||
"LTYPE" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS {
|
||||
if name.is_empty() || linetype_names.contains(&name.to_ascii_lowercase()) || d.linetypes.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
linetype_names.insert(name.to_ascii_lowercase());
|
||||
let pattern = ltype_pattern(&tg, <ype_styles);
|
||||
d.linetypes.push(Linetype { name, description: t.s(3).unwrap_or_default(), pattern });
|
||||
}
|
||||
@@ -184,9 +188,12 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
vertical: t.i(70).unwrap_or(0) & 4 != 0,
|
||||
annotative: annotative(&tg),
|
||||
};
|
||||
match d.text_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) {
|
||||
match style_index.get(&name.to_ascii_lowercase()).and_then(|&i| d.text_styles.get_mut(i)) {
|
||||
Some(x) => *x = st,
|
||||
None => d.text_styles.push(st),
|
||||
None => {
|
||||
style_index.insert(name.to_ascii_lowercase(), d.text_styles.len());
|
||||
d.text_styles.push(st);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The first *ACTIVE viewport holds the current snap/grid settings.
|
||||
|
||||
@@ -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()
|
||||
|
||||
+38
-9
@@ -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;
|
||||
|
||||
@@ -55,6 +57,13 @@ pub enum IoError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, IoError>;
|
||||
|
||||
/// How a program runs the DWG reader and writer in a child process (`cadcraft_dwg::worker`).
|
||||
pub mod dwg_worker {
|
||||
pub use cadcraft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker};
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
pub use cadcraft_dwg::{crash_worker_for_test, use_worker};
|
||||
}
|
||||
|
||||
fn ext(name: impl AsRef<Path>) -> String {
|
||||
name.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
@@ -62,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::dwg_to_dxf_noted(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
|
||||
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
|
||||
@@ -104,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::dxf_to_dwg_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"));
|
||||
}
|
||||
+75
-1
@@ -337,6 +337,64 @@ fn entity_transparency_roundtrips_as_group_440() {
|
||||
assert_eq!(d.model.iter().map(|e| e.common.transparency).collect::<Vec<_>>(), [Transparency::ByLayer, Transparency::Percent(90)]);
|
||||
}
|
||||
|
||||
/// Several SORTENTSTABLEs for one space sort it once, in the last table's order.
|
||||
#[test]
|
||||
fn repeated_sortents_tables_use_the_last() {
|
||||
let table =
|
||||
|a: u32, b: u32| format!("0\nSORTENTSTABLE\n5\n{:X}\n100\nAcDbSortentsTable\n330\n1F\n331\nA\n5\n{a:X}\n331\nB\n5\n{b:X}\n", 0x100 + a);
|
||||
let mut t = String::from(
|
||||
"0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n0\nBLOCK_RECORD\n5\n1F\n2\n*Model_Space\n0\nENDTAB\n0\nENDSEC\n\
|
||||
0\nSECTION\n2\nENTITIES\n0\nLINE\n5\nA\n330\n1F\n10\n0\n20\n0\n11\n1\n21\n1\n0\nLINE\n5\nB\n330\n1F\n10\n0\n20\n0\n11\n2\n21\n2\n0\nENDSEC\n\
|
||||
0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n",
|
||||
);
|
||||
for _ in 0..1000 {
|
||||
t.push_str(&table(2, 1));
|
||||
}
|
||||
t.push_str(&table(1, 2));
|
||||
t.push_str("0\nENDSEC\n0\nEOF\n");
|
||||
let d = read(t.as_bytes(), "s.dxf").unwrap();
|
||||
assert_eq!(d.model.iter().map(|e| e.handle.0).collect::<Vec<_>>(), [0xA, 0xB]);
|
||||
let reversed = t.replacen(&table(1, 2), &table(2, 1), 1);
|
||||
let d = read(reversed.as_bytes(), "s.dxf").unwrap();
|
||||
assert_eq!(d.model.iter().map(|e| e.handle.0).collect::<Vec<_>>(), [0xB, 0xA]);
|
||||
}
|
||||
|
||||
/// A table declaring 10 000 rows and no columns (or the reverse) is read as an empty table.
|
||||
#[test]
|
||||
fn table_with_an_empty_dimension_allocates_nothing() {
|
||||
for (rows, cols) in [(10_000, 0), (0, 10_000)] {
|
||||
let t = format!("0\nSECTION\n2\nENTITIES\n0\nACAD_TABLE\n100\nAcDbTable\n91\n{rows}\n92\n{cols}\n0\nENDSEC\n0\nEOF\n");
|
||||
let d = read(t.as_bytes(), "t.dxf").unwrap();
|
||||
let tables: Vec<_> = d.model.iter().filter_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t) } else { None }).collect();
|
||||
assert_eq!(tables.len(), 1, "the table is read");
|
||||
for t in tables {
|
||||
assert!(t.row_heights.is_empty() && t.col_widths.is_empty() && t.cells.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Many distinct layer, linetype and style names (in the tables and on entities) read in linear
|
||||
/// time, with the duplicate rules kept: first LTYPE wins, last STYLE wins, layers case-insensitive.
|
||||
#[test]
|
||||
fn many_distinct_symbol_names_read_quickly() {
|
||||
const N: usize = 40_000;
|
||||
let mut t = String::from("0\nSECTION\n2\nTABLES\n");
|
||||
for i in 0..N {
|
||||
t.push_str(&format!("0\nLTYPE\n2\nLT{i}\n3\nfirst\n0\nSTYLE\n2\nST{i}\n40\n1\n"));
|
||||
}
|
||||
t.push_str("0\nLTYPE\n2\nlt0\n3\nsecond\n0\nSTYLE\n2\nst0\n40\n2\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n");
|
||||
for i in 0..N {
|
||||
t.push_str(&format!("0\nLINE\n8\nL{i}\n10\n0\n20\n0\n11\n1\n21\n1\n"));
|
||||
}
|
||||
t.push_str("0\nLINE\n8\nl0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n");
|
||||
let started = std::time::Instant::now();
|
||||
let d = read(t.as_bytes(), "many.dxf").unwrap();
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20), "took {:?}", started.elapsed());
|
||||
assert!(d.layers.len() > N && d.layers.len() < N + 3, "{}", d.layers.len());
|
||||
assert_eq!(d.linetype("LT0").map(|l| l.description.as_str()), Some("first"));
|
||||
assert_eq!(d.text_style("ST0").map(|s| s.height), Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_dxf_does_not_panic() {
|
||||
for t in [
|
||||
@@ -1039,7 +1097,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 +2954,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:?}");
|
||||
}
|
||||
|
||||
@@ -56,13 +56,15 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc:
|
||||
let m = Mat3::translate(off).then_before(ins.transform(blk.base.xy()));
|
||||
let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, m) };
|
||||
for be in blk.entities.iter() {
|
||||
// Every visit is charged, drawn or not, so a block of definitions can't make
|
||||
// the copies loop free.
|
||||
if !b.expand() {
|
||||
break 'copies;
|
||||
}
|
||||
// Constant/visible attribute definitions inside blocks are not drawn; attribs are.
|
||||
if matches!(be.kind, EntityKind::AttDef(_)) {
|
||||
continue;
|
||||
}
|
||||
if !b.expand() {
|
||||
break 'copies;
|
||||
}
|
||||
entity(b, &sub, be);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +75,17 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc:
|
||||
let attmode = ctx.d.header.i64("ATTMODE", 1);
|
||||
// The reference's transparency; each attribute draws with its own.
|
||||
let reference = b.transparency;
|
||||
// The block's first definition per tag (without case), built once for all the attributes.
|
||||
let mut defs: std::collections::HashMap<String, &Entity> = std::collections::HashMap::new();
|
||||
if !ins.attribs.is_empty()
|
||||
&& let Some(blk) = ctx.d.block(&ins.block)
|
||||
{
|
||||
for be in blk.entities.iter() {
|
||||
if let EntityKind::AttDef(d) = &be.kind {
|
||||
defs.entry(d.tag.to_ascii_lowercase()).or_insert(be.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
if attmode == 0 || (a.invisible && attmode != 2) {
|
||||
continue;
|
||||
@@ -83,7 +96,7 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc:
|
||||
// Properties of the attribute's definition (as AutoCAD copies them to the attribute
|
||||
// on insertion), except those set on the attribute itself: ByBlock = the reference's,
|
||||
// layer 0 = the reference's layer.
|
||||
let def = ctx.d.block(&ins.block).and_then(|blk| blk.entities.iter().find(|be| attdef_tag(be, &a.tag)));
|
||||
let def = defs.get(&a.tag.to_ascii_lowercase()).copied();
|
||||
let (ink, lw, shown, t) = match (def, a.props.is_empty()) {
|
||||
(None, true) => (rgb, resolve(ctx, e, b.plotting).1, true, reference),
|
||||
(def, _) => {
|
||||
@@ -117,8 +130,3 @@ fn by_block() -> cadcraft_doc::Common {
|
||||
..cadcraft_doc::Common::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The attribute definition with tag `tag` (any case).
|
||||
fn attdef_tag(e: &Entity, tag: &str) -> bool {
|
||||
matches!(&e.kind, EntityKind::AttDef(d) if d.tag.eq_ignore_ascii_case(tag))
|
||||
}
|
||||
|
||||
@@ -106,10 +106,12 @@ fn chain(mut segs: Vec<(Vec2, Vec2)>) -> Vec<Vec<Vec2>> {
|
||||
}
|
||||
}
|
||||
segs.sort_unstable_by(|(a, b), (c, d)| cmp(*a, *c).then_with(|| cmp(*b, *d)));
|
||||
let mut at: HashMap<Key, Vec<usize>> = HashMap::new();
|
||||
// Segments at each vertex with a cursor past the used ones, so many segments meeting at one
|
||||
// vertex are each examined once rather than from the start on every visit.
|
||||
let mut at: HashMap<Key, (Vec<usize>, usize)> = HashMap::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
at.entry(key(*a)).or_default().push(i);
|
||||
at.entry(key(*b)).or_default().push(i);
|
||||
at.entry(key(*a)).or_default().0.push(i);
|
||||
at.entry(key(*b)).or_default().0.push(i);
|
||||
}
|
||||
let mut used = vec![false; segs.len()];
|
||||
let mut out = Vec::new();
|
||||
@@ -125,7 +127,15 @@ fn chain(mut segs: Vec<(Vec2, Vec2)>) -> Vec<Vec<Vec2>> {
|
||||
let mut end = b;
|
||||
// Each step uses one segment, so the walk ends within `segs.len()` steps.
|
||||
loop {
|
||||
let next = at.get(&key(end)).and_then(|ids| ids.iter().copied().find(|&i| !used.get(i).copied().unwrap_or(true)));
|
||||
let next = at.get_mut(&key(end)).and_then(|(ids, cursor)| {
|
||||
while let Some(&i) = ids.get(*cursor) {
|
||||
if !used.get(i).copied().unwrap_or(true) {
|
||||
return Some(i);
|
||||
}
|
||||
*cursor += 1;
|
||||
}
|
||||
None
|
||||
});
|
||||
let Some(i) = next else { break };
|
||||
let Some(&(p, q)) = segs.get(i) else { break };
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
@@ -147,6 +157,20 @@ mod tests {
|
||||
lines.iter().flat_map(|l| l.windows(2).map(|w| w[0].dist(w[1]))).sum()
|
||||
}
|
||||
|
||||
/// Thousands of lobes meeting at one vertex chain in one pass over that vertex's segments.
|
||||
#[test]
|
||||
fn many_lobes_at_one_vertex_chain_quickly() {
|
||||
let mut tris = Vec::new();
|
||||
for i in 0..20_000 {
|
||||
let x = f64::from(i + 1);
|
||||
tris.extend_from_slice(&[Vec2::ZERO, Vec2::new(x, 1.0), Vec2::new(x + 0.5, 2.0)]);
|
||||
}
|
||||
// No wall-clock bound (flaky on loaded runners): a quadratic scan would make this test crawl
|
||||
// rather than fail, so check the output is complete instead.
|
||||
let lines = fill_outlines(&tris);
|
||||
assert_eq!(lines.iter().map(|l| l.len() - 1).sum::<usize>(), 60_000, "every triangle edge is emitted once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_square_of_two_triangles_is_one_loop() {
|
||||
let (a, b, c, d) = (Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0), Vec2::new(2.0, 1.0), Vec2::new(0.0, 1.0));
|
||||
|
||||
@@ -95,7 +95,9 @@ pub fn dashed(p: &LwPolyline, tol: f64, lt: &Linetype, scale: f64, min_pattern:
|
||||
continue;
|
||||
}
|
||||
let (Some(first), Some(last)) = (at(run, a), at(run, b)) else { continue };
|
||||
let inner = run.iter().filter(|x| x.s > a && x.s < b).copied();
|
||||
// The samples strictly between a and b: `run` is ordered by `s`, so a slice, not a scan.
|
||||
let (lo, hi) = (run.partition_point(|x| x.s <= a), run.partition_point(|x| x.s < b));
|
||||
let inner = run.get(lo..hi.max(lo)).unwrap_or_default().iter().copied();
|
||||
let pts: Vec<Sample> = std::iter::once(first).chain(inner).chain(std::iter::once(last)).collect();
|
||||
for w in pts.windows(2) {
|
||||
if let [x, y] = w {
|
||||
|
||||
@@ -32,6 +32,18 @@ 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.
|
||||
pub dismissed: bool,
|
||||
}
|
||||
|
||||
/// What the GPU error notice says (see [`gpu_notice`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GpuNotice {
|
||||
@@ -90,6 +102,40 @@ impl CadApp {
|
||||
if let Some(wait) = self.autosave.as_mut().and_then(|s| s.tick(&self.session)) {
|
||||
_ctx.request_repaint_after(wait);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// 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 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.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
|
||||
@@ -107,8 +153,9 @@ impl CadApp {
|
||||
|
||||
/// The app closes normally: this app's autosave files are not needed any more.
|
||||
pub fn shutdown_autosave(&mut self) {
|
||||
// After the interface gave up, the recovery copies are all that's left of the work.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(s) = &mut self.autosave {
|
||||
if let Some(s) = self.autosave.as_mut().filter(|_| !self.fault.broken) {
|
||||
s.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -116,7 +163,7 @@ impl CadApp {
|
||||
|
||||
/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). Past chrono's year 262143 range the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch.
|
||||
fn utc(secs: u64) -> String {
|
||||
pub(crate) fn utc(secs: u64) -> String {
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
let (y, mo, d) = civil_from_days(secs / 86_400);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
@@ -242,6 +289,37 @@ pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The notice that autosave isn't working, so unsaved changes have no recovery copy. It doesn't
|
||||
/// block the drawing: the user needs to be able to save.
|
||||
pub fn failure_notice(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let Some(n) = app.autosave_notice.as_ref().filter(|n| !n.dismissed) else { return };
|
||||
let t = Tokens::get();
|
||||
let mut dismiss = false;
|
||||
egui::Window::new("Autosave failed")
|
||||
.id(egui::Id::unique("cc_autosave_notice"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(440.0);
|
||||
ui.label("CADCraft could not write a recovery copy, so recent changes would be lost if the app stopped now.");
|
||||
ui.label(RichText::new("Save your work. Autosave tries again after the SAVETIME interval; its folder is in Options.").strong());
|
||||
ui.add_space(4.0);
|
||||
ui.collapsing("Details", |ui| {
|
||||
ui.label(RichText::new(&n.message).color(t.text_dim).monospace());
|
||||
});
|
||||
// A row just as tall as the button (a plain right-to-left layout takes the rest).
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Dismiss").clicked() {
|
||||
dismiss = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if dismiss && let Some(n) = &mut app.autosave_notice {
|
||||
n.dismissed = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -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,235 @@
|
||||
//! What happens when the interface panics. Commands already run under their own guard
|
||||
//! (`cadcraft_engine::guard`); this one covers the rest of the frame, so a panic in a panel keeps
|
||||
//! the user's work: every drawing with unsaved changes is autosaved at once and the app says so.
|
||||
//! A panic that keeps coming back leaves a plain window that only closes, with the recovery copies
|
||||
//! kept for the next start. A panic hook writes a report for each, which stays on this machine.
|
||||
//! None of this helps on the web, where a panic aborts.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{CadApp, now_ms};
|
||||
|
||||
/// Where crash reports go, under the settings folder.
|
||||
pub const REPORT_DIR: &str = "crash-reports";
|
||||
/// This many panics, each within [`QUIET_MS`] of the last, and the interface is given up on.
|
||||
const MAX_STRIKES: u32 = 3;
|
||||
/// A stretch this long without a panic means the app has recovered.
|
||||
const QUIET_MS: f64 = 5_000.0;
|
||||
|
||||
/// Panics caught around the frame.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct UiFault {
|
||||
/// The last panic's message, until the user dismisses the notice.
|
||||
pub message: Option<String>,
|
||||
/// Drawings autosaved because of panics so far.
|
||||
pub autosaved: usize,
|
||||
/// Panics in the current run of them.
|
||||
pub strikes: u32,
|
||||
/// When the last one happened.
|
||||
pub last_ms: f64,
|
||||
/// The interface keeps panicking: it no longer runs, and recovery copies are kept on exit.
|
||||
pub broken: bool,
|
||||
}
|
||||
|
||||
impl CadApp {
|
||||
/// [`CadApp::logic`] under the panic guard.
|
||||
pub fn guarded_logic(&mut self, ctx: &egui::Context) {
|
||||
self.guarded(ctx, |app| app.logic(ctx));
|
||||
}
|
||||
|
||||
/// [`CadApp::ui`] under the panic guard, then the notice about any panic. Once the interface
|
||||
/// has been given up on, only the window that closes the app is drawn.
|
||||
pub fn guarded_ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.guarded(&ctx, |app| app.ui(ui));
|
||||
self.show_fault(&ctx);
|
||||
}
|
||||
|
||||
/// The notice about a caught panic, or the window that closes the app.
|
||||
pub fn show_fault(&mut self, ctx: &egui::Context) {
|
||||
if self.fault.broken {
|
||||
broken_window(self, ctx);
|
||||
} else {
|
||||
notice(self, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `frame`; a panic in it autosaves unsaved drawings instead of ending the app.
|
||||
pub fn guarded(&mut self, ctx: &egui::Context, frame: impl FnOnce(&mut CadApp)) {
|
||||
if self.fault.broken {
|
||||
return;
|
||||
}
|
||||
if let Err(p) = catch_unwind(AssertUnwindSafe(|| frame(self))) {
|
||||
let message = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned());
|
||||
self.on_panic(ctx, message.unwrap_or_else(|| "panic".into()));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_panic(&mut self, ctx: &egui::Context, message: String) {
|
||||
let now = now_ms();
|
||||
let fault = &mut self.fault;
|
||||
fault.strikes = if now - fault.last_ms < QUIET_MS { fault.strikes + 1 } else { 1 };
|
||||
fault.last_ms = now;
|
||||
fault.broken = fault.strikes >= MAX_STRIKES;
|
||||
log::error!("the interface panicked: {message}");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(saver) = &mut self.autosave {
|
||||
let session = &self.session;
|
||||
// The save is the point of being here: a panic inside it must not end the app either.
|
||||
self.fault.autosaved += catch_unwind(AssertUnwindSafe(|| saver.emergency_save(session))).unwrap_or(0);
|
||||
}
|
||||
self.fault.message = Some(message);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The notice after a panic. It doesn't block the drawing: the user needs to be able to save.
|
||||
fn notice(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let Some(message) = app.fault.message.clone() else { return };
|
||||
let t = Tokens::get();
|
||||
let mut dismiss = false;
|
||||
egui::Window::new("Internal error")
|
||||
.id(egui::Id::unique("cc_fault_notice"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(440.0);
|
||||
ui.label("Part of CADCraft failed, and the app kept running.");
|
||||
ui.label(saved_text(app.fault.autosaved));
|
||||
ui.label(RichText::new("Save your work and restart CADCraft.").strong());
|
||||
ui.add_space(4.0);
|
||||
details(ui, t, &message);
|
||||
// A row just as tall as the button (a plain right-to-left layout takes the rest).
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Dismiss").clicked() {
|
||||
dismiss = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if dismiss {
|
||||
app.fault.message = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// All that's left once the interface keeps panicking.
|
||||
fn broken_window(app: &CadApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get();
|
||||
egui::Window::new("CADCraft has to close")
|
||||
.id(egui::Id::unique("cc_fault_broken"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(440.0);
|
||||
ui.label("Part of CADCraft keeps failing, so it can't continue.");
|
||||
ui.label(saved_text(app.fault.autosaved));
|
||||
ui.label(RichText::new("Drawing Recovery offers them at the next start.").strong());
|
||||
ui.add_space(4.0);
|
||||
if let Some(message) = &app.fault.message {
|
||||
details(ui, t, message);
|
||||
}
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Close CADCraft").clicked() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn saved_text(autosaved: usize) -> String {
|
||||
match autosaved {
|
||||
0 => "No drawing needed a new recovery copy.".to_string(),
|
||||
1 => "A recovery copy of the drawing with unsaved changes was autosaved.".to_string(),
|
||||
k => format!("Recovery copies of {k} drawings with unsaved changes were autosaved."),
|
||||
}
|
||||
}
|
||||
|
||||
fn details(ui: &mut egui::Ui, t: Tokens, message: &str) {
|
||||
ui.collapsing("Details", |ui| {
|
||||
ui.label(RichText::new(message).color(t.text_dim).monospace());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = report::last() {
|
||||
ui.label(RichText::new(format!("Report: {}", path.display())).color(t.text_dim).monospace());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use report::install_report_hook;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod report {
|
||||
use std::fmt::Write as _;
|
||||
use std::panic::PanicHookInfo;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Reports one run may write: a panic every frame must not fill the disk.
|
||||
const MAX_PER_RUN: u32 = 5;
|
||||
/// Reports kept in the folder.
|
||||
const KEEP: usize = 20;
|
||||
|
||||
static WRITTEN: AtomicU32 = AtomicU32::new(0);
|
||||
static LAST: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
|
||||
/// The report of the latest panic, once one was written.
|
||||
pub(super) fn last() -> Option<PathBuf> {
|
||||
LAST.lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
/// Write a report into `dir` for every panic, on any thread, before the hook already in
|
||||
/// place runs. Panics that a guard then catches get one too: they are still bugs. A panic that
|
||||
/// can't be reported goes on as it would have: a report never makes things worse.
|
||||
pub fn install_report_hook(dir: PathBuf) {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if WRITTEN.fetch_add(1, Ordering::Relaxed) < MAX_PER_RUN
|
||||
&& let Ok(path) = write_report(&dir, info)
|
||||
&& let Ok(mut last) = LAST.lock()
|
||||
{
|
||||
*last = Some(path);
|
||||
}
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
|
||||
fn write_report(dir: &Path, info: &PanicHookInfo<'_>) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||
let mut text = String::new();
|
||||
let _ = writeln!(text, "CADCraft {} on {} {}", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
let _ = writeln!(text, "Time: {} UTC", crate::autosave::utc(now.as_secs()));
|
||||
let _ = writeln!(text, "Thread: {}", std::thread::current().name().unwrap_or("unnamed"));
|
||||
if let Some(l) = info.location() {
|
||||
let _ = writeln!(text, "Location: {}:{}:{}", l.file(), l.line(), l.column());
|
||||
}
|
||||
let _ = writeln!(text, "Message: {}", info.payload_as_str().unwrap_or("(not text)"));
|
||||
let _ = writeln!(text, "\n{}", std::backtrace::Backtrace::force_capture());
|
||||
let path = dir.join(format!("crash-{:012x}-{}.txt", now.as_millis(), std::process::id()));
|
||||
cadcraft_engine::atomic_write::write_atomic(&path, text.as_bytes())?;
|
||||
prune(dir);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Keep the newest [`KEEP`] reports. Names start with the time, so they sort by age.
|
||||
fn prune(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut reports: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.take(10_000)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("crash-") && n.ends_with(".txt")))
|
||||
.collect();
|
||||
reports.sort();
|
||||
let extra = reports.len().saturating_sub(KEEP);
|
||||
for old in reports.drain(..extra) {
|
||||
let _ = std::fs::remove_file(old);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,35 @@ 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
|
||||
Layouts Layouter
|
||||
Associate Associera
|
||||
Change Ändra
|
||||
Change Properties Ändra egenskaper
|
||||
Save Layout as Template Spara layout som mall
|
||||
Units Enheter
|
||||
Backup copy Säkerhetskopia
|
||||
Create backup copy with each save Skapa en säkerhetskopia vid varje sparande
|
||||
Font Typsnitt
|
||||
Save changes to block "{name}"? Spara ändringar i blocket "{name}"?
|
||||
Backup copies are off, so the current file will be replaced. Säkerhetskopior är avstängda, så den aktuella filen kommer att ersättas.
|
||||
DXF 2010 and earlier keep them. Use Save As to keep them in the drawing. DXF 2010 och tidigare behåller dem. Använd Spara som för att behålla dem i ritningen.
|
||||
DXF keeps them. Use Save As to keep them in the drawing. DXF behåller dem. Använd Spara som för att behålla dem i ritningen.
|
||||
Save Anyway Spara ändå
|
||||
Save cancelled: {path} was not changed. Sparandet avbröts: {path} ändrades inte.
|
||||
Saving {name} will remove content Om du sparar {name} tas innehåll bort
|
||||
The current file doesn't look whole, so {name} stays as it is and the current file will be replaced. Den aktuella filen verkar inte vara hel, så {name} lämnas som den är och den aktuella filen kommer att ersättas.
|
||||
The current file is kept as {name} until the next save. Den aktuella filen sparas som {name} till nästa sparande.
|
||||
This format can't keep these objects yet: {objects}. Det här formatet kan ännu inte behålla följande objekt: {objects}.
|
||||
{count} more types {count} typer till
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1472 and column 24.
|
@@ -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.
|
@@ -533,6 +533,7 @@
|
||||
Multiline Text Chữ nhiều dòng
|
||||
Multiline Text... Chữ nhiều dòng...
|
||||
Multiple Nhiều
|
||||
Multiple Insert Chèn nhiều
|
||||
Multiple Point Nhiều điểm
|
||||
My Của tôi
|
||||
My properties: not available yet Thuộc tính của tôi: chưa khả dụng
|
||||
@@ -1448,3 +1449,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 1444 and column 24.
|
@@ -448,6 +448,7 @@
|
||||
Multiline Text 多行文字
|
||||
Multiline Text... 多行文字...
|
||||
Multiple 多个
|
||||
Multiple Insert 多重插入
|
||||
Multiple Point 多点
|
||||
My 我的
|
||||
My properties: not available yet 我的属性:暂不可用
|
||||
@@ -1377,6 +1378,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… 添加…
|
||||
|
||||
|
@@ -32,6 +32,9 @@ pub mod control;
|
||||
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;
|
||||
@@ -305,6 +308,10 @@ pub struct CadApp {
|
||||
pub gpu_faults: std::sync::Arc<gpu::GpuFaults>,
|
||||
/// The notice shown after one (see [`autosave::gpu_notice`]).
|
||||
pub gpu_notice: Option<autosave::GpuNotice>,
|
||||
/// Panics caught around the frame ([`fault`]).
|
||||
pub fault: fault::UiFault,
|
||||
/// The notice shown while autosave fails (see [`autosave::failure_notice`]).
|
||||
pub autosave_notice: Option<autosave::AutosaveNotice>,
|
||||
/// The Command Aliases dialog while open ([`aliasdlg`]).
|
||||
pub alias_dlg: Option<aliasdlg::AliasDlgState>,
|
||||
/// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]).
|
||||
@@ -364,6 +371,8 @@ impl CadApp {
|
||||
recovery: autosave::Recovery::default(),
|
||||
gpu_faults: std::sync::Arc::default(),
|
||||
gpu_notice: None,
|
||||
fault: fault::UiFault::default(),
|
||||
autosave_notice: None,
|
||||
alias_dlg: None,
|
||||
hatch_dlg: None,
|
||||
bedit: bedit::State::default(),
|
||||
@@ -561,7 +570,13 @@ impl CadApp {
|
||||
/// Add the files whose background read has finished; report the ones that failed as a failed
|
||||
/// OPEN is reported. Called every frame.
|
||||
pub fn finish_opening(&mut self) {
|
||||
for (_, outcome) in self.opening.poll() {
|
||||
for (path, outcome) in self.opening.poll() {
|
||||
// A recent file that is gone can never be opened again: drop it from the list. A path
|
||||
// whose existence can't be checked (a folder that denies access) stays.
|
||||
if outcome.is_err() && matches!(path.try_exists(), Ok(false)) {
|
||||
let path = path.to_string_lossy();
|
||||
self.ui.recent_files.retain(|p| *p != path);
|
||||
}
|
||||
let r = match outcome {
|
||||
Ok(f) => self.session.finish_open(f).map_err(|e| e.to_string()),
|
||||
Err(e) => Err(e),
|
||||
@@ -903,6 +918,7 @@ impl CadApp {
|
||||
save_loss::prompt(self, ui.ctx());
|
||||
autosave::show(self, ui.ctx());
|
||||
autosave::gpu_notice(self, ui.ctx());
|
||||
autosave::failure_notice(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A failed autosave reaches the user: a notice and one command-line message, gone once a write
|
||||
//! works. SAVEFILEPATH is process-wide, so everything runs in one test.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::Drawing;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
|
||||
fn wait_for(app: &mut CadApp, ctx: &egui::Context, done: impl Fn(&CadApp) -> bool) {
|
||||
for _ in 0..500 {
|
||||
app.autosave_tick(ctx);
|
||||
if done(app) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// The text the notice paints (the second frame: a new window sizes itself in the first).
|
||||
fn painted_text(app: &mut CadApp, ctx: &egui::Context) -> String {
|
||||
fn collect(shape: &egui::Shape, text: &mut String) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => text.push_str(t.galley.text()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, text)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| cadcraft_ui_egui::autosave::failure_notice(app, ui.ctx()));
|
||||
out.textures_delta.clear(); // no renderer here
|
||||
text.clear();
|
||||
out.shapes.iter().for_each(|s| collect(&s.shape, &mut text));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_autosave_is_shown_until_a_write_works() {
|
||||
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
|
||||
let root = std::env::temp_dir().join(format!("cadcraft-autosave-failure-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// The folder can't be created while a file holds its name.
|
||||
let dir = root.join("out");
|
||||
std::fs::write(&dir, b"not a folder").unwrap();
|
||||
cadcraft_engine::autosave::set_savefilepath(&dir.to_string_lossy());
|
||||
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.enable_autosave(&dir);
|
||||
let ctx = egui::Context::default();
|
||||
app.autosave_tick(&ctx);
|
||||
assert!(app.autosave_notice.is_none(), "nothing failed yet");
|
||||
|
||||
app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
app.gpu_faults.error("forces an autosave now".into());
|
||||
app.gpu_fault_tick(&ctx);
|
||||
wait_for(&mut app, &ctx, |a| a.autosave_notice.is_some());
|
||||
let n = app.autosave_notice.clone().expect("the failure reaches the notice");
|
||||
assert!(!n.dismissed && n.message.contains("out"), "{n:?}");
|
||||
let said = |app: &CadApp| app.session.log.iter().filter(|l| l.starts_with("Autosave failed:")).count();
|
||||
assert_eq!(said(&app), 1);
|
||||
assert!(painted_text(&mut app, &ctx).contains("could not write a recovery copy"));
|
||||
|
||||
// Dismissed, it stays away through retries and isn't repeated at the command line.
|
||||
app.autosave_notice.as_mut().unwrap().dismissed = true;
|
||||
app.autosave_tick(&ctx);
|
||||
assert!(app.autosave_notice.as_ref().is_some_and(|n| n.dismissed));
|
||||
assert!(painted_text(&mut app, &ctx).is_empty());
|
||||
|
||||
// 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();
|
||||
app.run("circle", serde_json::json!({"center": [5, 0], "radius": 1})).unwrap();
|
||||
app.gpu_faults.error("forces an autosave now".into());
|
||||
app.gpu_fault_tick(&ctx);
|
||||
wait_for(&mut app, &ctx, |a| a.autosave_notice.is_none());
|
||||
assert!(app.autosave_notice.is_none(), "a write that worked ends the notice");
|
||||
|
||||
app.shutdown_autosave();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -135,3 +135,15 @@ fn start_page_lists_recent_files_and_opens_one_on_click() {
|
||||
assert_eq!(app.session.state().ok().and_then(|s| s.path.clone()), Some(plan.clone()));
|
||||
assert_eq!(app.ui.recent_files, [plan, section]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recent_file_that_no_longer_exists_is_dropped_when_opening_it_fails() {
|
||||
let (_dir, plan, _section) = drawings("missing");
|
||||
let mut app = CadApp::new(Session::empty(), Services::default());
|
||||
let gone = "/nonexistent/dir/gone.dxf".to_string();
|
||||
app.ui.recent_files = vec![gone.clone(), plan.clone()];
|
||||
let id = open_recent(&app).into_iter().find(|(l, _)| l == "gone.dxf").map(|(_, id)| id).unwrap_or_default();
|
||||
menus::activate(&mut app, &id);
|
||||
settle(&mut app);
|
||||
assert_eq!(app.ui.recent_files, [plan], "the stale entry is removed, the others stay");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! A panic in the interface keeps the user's work: unsaved drawings are autosaved at once, the
|
||||
//! app says so and writes a report, and a panic that keeps coming back leaves only a way to close
|
||||
//! with the recovery copies kept. The panic hook is process-wide, so everything runs in one test.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::Drawing;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
|
||||
fn panic_in_a_nested_ui(app: &mut CadApp, ctx: &egui::Context, text: &'static str) -> String {
|
||||
let mut painted = String::new();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.guarded(&ctx, |_| {
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label("before");
|
||||
std::panic::panic_any(text);
|
||||
});
|
||||
});
|
||||
});
|
||||
app.show_fault(ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear(); // no renderer here
|
||||
out.shapes.iter().for_each(|s| collect(&s.shape, &mut painted));
|
||||
painted
|
||||
}
|
||||
|
||||
fn painted_text(app: &mut CadApp, ctx: &egui::Context) -> String {
|
||||
let mut painted = String::new();
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.show_fault(ui.ctx()));
|
||||
out.textures_delta.clear(); // no renderer here
|
||||
painted.clear();
|
||||
out.shapes.iter().for_each(|s| collect(&s.shape, &mut painted));
|
||||
}
|
||||
painted
|
||||
}
|
||||
|
||||
fn collect(shape: &egui::Shape, text: &mut String) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => {
|
||||
text.push_str(t.galley.text());
|
||||
text.push('\n');
|
||||
}
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, text)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_copies(dir: &std::path::Path) -> usize {
|
||||
std::fs::read_dir(dir).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "sv$")).count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_saves_the_work_and_reports_and_repeated_ones_close_safely() {
|
||||
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
|
||||
let root = std::env::temp_dir().join(format!("cadcraft-ui-faults-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (auto, reports) = (root.join("autosave"), root.join("crash-reports"));
|
||||
cadcraft_ui_egui::fault::install_report_hook(reports.clone());
|
||||
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.enable_autosave(&auto);
|
||||
app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
// One panic: the drawing has a whole recovery copy by the time the frame goes on.
|
||||
panic_in_a_nested_ui(&mut app, &ctx, "a bug in a panel");
|
||||
assert_eq!(app.fault.message.as_deref(), Some("a bug in a panel"));
|
||||
assert_eq!((app.fault.autosaved, app.fault.broken), (1, false));
|
||||
assert_eq!(recovery_copies(&auto), 1);
|
||||
let text = painted_text(&mut app, &ctx);
|
||||
assert!(text.contains("kept running") && text.contains("recovery copy"), "{text}");
|
||||
|
||||
let report = std::fs::read_dir(&reports).unwrap().flatten().next().expect("a report").path();
|
||||
let report = std::fs::read_to_string(report).unwrap();
|
||||
assert!(report.contains("Message: a bug in a panel") && report.contains("ui_faults.rs"), "{report}");
|
||||
assert!(report.starts_with("CADCraft "), "{report}");
|
||||
|
||||
// The app still works after it.
|
||||
app.run("circle", serde_json::json!({"center": [5, 0], "radius": 1})).unwrap();
|
||||
|
||||
// Panics that keep coming: the interface stops running and only offers to close.
|
||||
panic_in_a_nested_ui(&mut app, &ctx, "again");
|
||||
panic_in_a_nested_ui(&mut app, &ctx, "and again");
|
||||
assert!(app.fault.broken);
|
||||
let mut ran = false;
|
||||
app.guarded(&ctx, |_| ran = true);
|
||||
assert!(!ran, "a broken interface isn't run again");
|
||||
assert!(painted_text(&mut app, &ctx).contains("Drawing Recovery offers them"));
|
||||
|
||||
// Closing now keeps the copies for the next start.
|
||||
app.shutdown_autosave();
|
||||
assert_eq!(recovery_copies(&auto), 1);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
+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