diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10312a342..c2cf475e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,6 @@ jobs: release: needs: git-sources - environment: release permissions: # The reusable release workflow tags and uploads the draft release. contents: write @@ -86,3 +85,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} diff --git a/Cargo.lock b/Cargo.lock index 0001b5378..ba9e5357e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -691,6 +691,7 @@ name = "cadcraft-dwg" version = "0.17.1" dependencies = [ "acadrust", + "log", ] [[package]] @@ -728,6 +729,7 @@ name = "cadcraft-fonts" version = "0.17.1" dependencies = [ "cadcraft-geom", + "encoding_rs", "harfrust 0.14.0", "icu_segmenter", "skrifa 0.48.0", @@ -754,6 +756,7 @@ dependencies = [ "cadcraft-geom", "cadcraft-render", "craft-pdf", + "image", "miniz_oxide 0.9.1", "quick-xml 0.42.0", "serde", @@ -820,6 +823,7 @@ name = "cadcraft-web" version = "0.17.1" dependencies = [ "cadcraft-engine", + "cadcraft-fonts", "cadcraft-io", "cadcraft-ui-egui", "eframe", diff --git a/Cargo.toml b/Cargo.toml index 60aae159a..d5ce730d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ unicode-bidi = "0.3.18" # Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between # words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries). icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] } -image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } encoding_rs = "0.8.35" # XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones) quick-xml = "0.42" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..fbad5435b --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/apps/cadcraft-cli/src/args.rs b/apps/cadcraft-cli/src/args.rs index 16b57eeb7..f3b70fdc8 100644 --- a/apps/cadcraft-cli/src/args.rs +++ b/apps/cadcraft-cli/src/args.rs @@ -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, + /// Where the report goes (default: `report` beside the files) + #[arg(long, value_name = "DIR")] + pub(crate) out: Option, + /// 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 { + match v.parse::() { + 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). diff --git a/apps/cadcraft-cli/src/error.rs b/apps/cadcraft-cli/src/error.rs index 51b816890..359c6775c 100644 --- a/apps/cadcraft-cli/src/error.rs +++ b/apps/cadcraft-cli/src/error.rs @@ -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), diff --git a/apps/cadcraft-cli/src/lib.rs b/apps/cadcraft-cli/src/lib.rs index c858417e2..46f8cf61d 100644 --- a/apps/cadcraft-cli/src/lib.rs +++ b/apps/cadcraft-cli/src/lib.rs @@ -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}; @@ -43,6 +45,7 @@ fn install_io() { write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), }); + cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string())); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) }); @@ -99,7 +102,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 +113,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 { diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 7561b96b5..866923c9a 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -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()); diff --git a/apps/cadcraft-cli/src/roundtrip.rs b/apps/cadcraft-cli/src/roundtrip.rs new file mode 100644 index 000000000..dba9c8d04 --- /dev/null +++ b/apps/cadcraft-cli/src/roundtrip.rs @@ -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), +} + +/// 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 { + let mut out: Vec = 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 = 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(()) +} diff --git a/apps/cadcraft-cli/tests/dwg_worker.rs b/apps/cadcraft-cli/tests/dwg_worker.rs new file mode 100644 index 000000000..8656b84ff --- /dev/null +++ b/apps/cadcraft-cli/tests/dwg_worker.rs @@ -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()); +} diff --git a/apps/cadcraft-cli/tests/man_and_completions.rs b/apps/cadcraft-cli/tests/man_and_completions.rs index df6dea90c..9e3e8c65e 100644 --- a/apps/cadcraft-cli/tests/man_and_completions.rs +++ b/apps/cadcraft-cli/tests/man_and_completions.rs @@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() { let mut written: Vec = 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 = completions.map(String::from).into(); expected.push("cadcraft-cli.1".into()); expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1"))); diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index e5e86a0e3..cb9ddb81b 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" cadcraft-engine = { workspace = true } cadcraft-ui-egui = { workspace = true } cadcraft-io = { workspace = true } +cadcraft-fonts = { workspace = true } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index a25782d6e..30d9c55f5 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -119,6 +119,7 @@ mod web { write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), }); + cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string())); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) }); diff --git a/apps/cadcraft-web/src/upload.rs b/apps/cadcraft-web/src/upload.rs index 0b567f8b7..333c4906b 100644 --- a/apps/cadcraft-web/src/upload.rs +++ b/apps/cadcraft-web/src/upload.rs @@ -2,6 +2,11 @@ //! a path from, so the picker is a hidden ``; the chosen file (or one dropped on //! the page) is read in the background and handed to the app on its next frame, which opens it //! with OPEN `{data, name}` ([`CadApp::open_bytes`]). +//! +//! SHX fonts (text fonts, big fonts, shape files) chosen or dropped with a drawing are the web +//! build's font folder: they are registered with the font table +//! ([`cadcraft_fonts::shx::register`]) under their file name, so the styles and linetypes that +//! name them draw with them, and the drawings are regenerated. use std::cell::Cell; use std::rc::Rc; @@ -12,8 +17,14 @@ use web_sys::js_sys; /// The hidden file input (one at a time; a cancelled pick leaves it until the next one). const INPUT_ID: &str = "cadcraft_open_input"; -/// What the picker offers: the formats OPEN reads. -const ACCEPT: &str = ".dxf,.dwg"; +/// What the picker offers: the formats OPEN reads, and the SHX fonts drawings name. +const ACCEPT: &str = ".dxf,.dwg,.shx"; + +/// Most files taken from one pick. +const MAX_PICKED: u32 = 64; +/// Most bytes being read at once, all files together: a large pick must not exhaust the page's +/// memory (each file is copied once more into the app). +const MAX_READING_BYTES: u64 = 512 << 20; /// A file read in the browser (name, contents), or why it couldn't be read. type Upload = Result<(String, Vec), String>; @@ -22,12 +33,14 @@ type Upload = Result<(String, Vec), String>; #[derive(Clone)] pub struct Inbox { files: Rc>>, + /// Bytes of the files being read now. + reading: Rc>, ctx: egui::Context, } impl Inbox { pub fn new(ctx: &egui::Context) -> Self { - Inbox { files: Rc::default(), ctx: ctx.clone() } + Inbox { files: Rc::default(), reading: Rc::default(), ctx: ctx.clone() } } /// Show the browser's file picker and return at once. A chosen file arrives through @@ -51,13 +64,23 @@ impl Inbox { .map_err(|_| "could not create a file input".to_string())?; input.set_type("file"); input.set_accept(ACCEPT); + // A drawing and the fonts it uses can be chosen together. + input.set_multiple(true); input.set_id(INPUT_ID); input.set_attribute("style", "display:none").map_err(js_error)?; let (inbox, chosen) = (self.clone(), input.clone()); - // Called once, when a file was chosen (browsers don't fire `change` on cancel). + // Called once, when files were chosen (browsers don't fire `change` on cancel). let on_change = wasm_bindgen::closure::Closure::once_into_js(move || { - if let Some(file) = chosen.files().and_then(|f| f.get(0)) { - inbox.read(file); + if let Some(files) = chosen.files() { + for i in 0..files.length().min(MAX_PICKED) { + if let Some(file) = files.get(i) { + inbox.read(file); + } + } + let skipped: Vec = (MAX_PICKED..files.length()).filter_map(|i| files.get(i)).map(|f| f.name()).collect(); + if !skipped.is_empty() { + inbox.push(Err(format!("Open: at most {MAX_PICKED} files at a time; not opened: {}", skipped.join(", ")))); + } } chosen.remove(); }); @@ -87,12 +110,18 @@ impl Inbox { if let Some(e) = CadApp::open_size_error(&name, len) { return self.push(Err(e)); } + let reading = self.reading.get(); + if reading.saturating_add(len) > MAX_READING_BYTES { + return self.push(Err(format!("Open: {name} was not read: the files being read together are too large; open it on its own"))); + } + self.reading.set(reading.saturating_add(len)); let inbox = self.clone(); wasm_bindgen_futures::spawn_local(async move { let r = match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { Ok(buf) => Ok((name, js_sys::Uint8Array::new(&buf).to_vec())), Err(e) => Err(format!("Open: could not read {name}: {}", js_error(e))), }; + inbox.reading.set(inbox.reading.get().saturating_sub(len)); inbox.push(r); }); } @@ -118,9 +147,21 @@ impl Inbox { self.ctx.request_repaint(); } - /// Open what arrived since the last frame (or report why it couldn't be read). + /// Open what arrived since the last frame (or report why it couldn't be read). Fonts are + /// registered first, so drawings opened with them draw with them. pub fn drain(&self, app: &mut CadApp) { - for upload in self.files.take() { + let (fonts, files): (Vec, Vec) = self.files.take().into_iter().partition(|u| u.as_ref().is_ok_and(|(n, _)| is_shx(n))); + let mut registered = false; + for (name, bytes) in fonts.into_iter().flatten() { + match cadcraft_fonts::shx::register(&name, &bytes) { + Ok(()) => registered = true, + Err(e) => app.open_failed(format!("{name}: {e}")), + } + } + if registered { + let _ = app.run("regenall", serde_json::json!({})); + } + for upload in files { match upload { Ok((name, bytes)) => app.open_bytes(&name, &bytes), Err(e) => app.open_failed(e), @@ -129,6 +170,11 @@ impl Inbox { } } +/// Whether `name` is an SHX font or shape file. +fn is_shx(name: &str) -> bool { + name.to_ascii_lowercase().ends_with(".shx") +} + fn js_error(e: wasm_bindgen::JsValue) -> String { e.as_string().unwrap_or_else(|| format!("{e:?}")) } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 3454dc35f..f5eef8932 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -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); } @@ -146,6 +146,7 @@ pub fn install_io() { write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), }); + cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string())); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) }); @@ -258,6 +259,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 +295,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); } diff --git a/contributors/people.toml b/contributors/people.toml index 994c68bb1..8bb73b9ea 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/constraints/src/dof.rs b/crates/constraints/src/dof.rs index 559452223..e6128665c 100644 --- a/crates/constraints/src/dof.rs +++ b/crates/constraints/src/dof.rs @@ -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 { 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 { 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()); } } diff --git a/crates/constraints/src/expr.rs b/crates/constraints/src/expr.rs index c71ed3486..057ef3e1c 100644 --- a/crates/constraints/src/expr.rs +++ b/crates/constraints/src/expr.rs @@ -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)"); diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 89c6276cd..5b01fc022 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -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 = 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::().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 { 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) -> f64 { + let rows = self.rows(skip) as f64; + let evals = 2.0 * free.len() as f64 + TRIAL_EVALS; + let mut splines: Vec = 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) -> Result { 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| 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, 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, 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 = 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 = 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 Vec { let mut gone = Vec::new(); - let keep: Vec = 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 = 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 MAX_EVAL_WORK { + return Err(SolveError::TooLarge); + } let after = comp.free(None); let locked: Vec = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect(); let rows_old = comp.rows(Some(pos)); diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index d18646c32..bf363c728 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -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, Rc)>; + #[derive(Clone, Debug, Default)] pub(crate) struct Model { pub slots: Vec, @@ -79,20 +84,75 @@ pub(crate) struct Model { splines: HashMap, /// Block reference rigid points relative to the insertion point at rotation 0. rigid: HashMap>, + /// 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>, +} + +/// 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>>, + /// Lower-cased block name to the stored name it resolves to, built on the first exact miss. + folded: Option>, + rebuilt: Rc>, +} + +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> { + 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 { + 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 { - 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 { - 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 { + fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option { 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 = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); + let pts: Vec = 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 { + /// 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> { 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::>>()?; - 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,80 @@ 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()); + clip: None, + } + } + + 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 = (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); } } diff --git a/crates/constraints/src/tests.rs b/crates/constraints/src/tests.rs index 35b73fea2..eadc7a3c2 100644 --- a/crates/constraints/src/tests.rs +++ b/crates/constraints/src/tests.rs @@ -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"); +} diff --git a/crates/constraints/tests/dof.rs b/crates/constraints/tests/dof.rs index 02fd935c0..59dede1d5 100644 --- a/crates/constraints/tests/dof.rs +++ b/crates/constraints/tests/dof.rs @@ -302,6 +302,7 @@ fn block_reference_moves_rigidly() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ); assert_eq!(obj_dof(&d, ins), Some(3)); @@ -343,6 +344,7 @@ fn hostile_new_geometry_never_panics() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ); let l = line(&mut d, (0.0, 0.0), (0.0, 0.0)); diff --git a/crates/doc/src/array.rs b/crates/doc/src/array.rs index b663785ed..1514bc055 100644 --- a/crates/doc/src/array.rs +++ b/crates/doc/src/array.rs @@ -228,6 +228,7 @@ impl ArrayParams { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; out.push(Entity { handle: d.new_handle(), common: common.clone(), kind: EntityKind::Insert(ins) }); } diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index 392861388..409d403dd 100644 --- a/crates/doc/src/dynblock.rs +++ b/crates/doc/src/dynblock.rs @@ -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, xdict: Handle) -> Option> { +/// `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, 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, 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, xdict: Handle) -> Option> { + 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, 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); + +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 { + 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 { - let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?; - let objects: HashMap = 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 { + 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); diff --git a/crates/doc/src/dynblock/apply.rs b/crates/doc/src/dynblock/apply.rs index ecca36257..c22a6df86 100644 --- a/crates/doc/src/dynblock/apply.rs +++ b/crates/doc/src/dynblock/apply.rs @@ -54,7 +54,11 @@ fn record_handle(d: &Drawing, name: &str) -> Option { /// The object entry `key` of dictionary `dict` refers to. fn entry(d: &Drawing, dict: Handle, key: &str) -> Option { - 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 { 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 { /// 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> { let objects: HashMap = 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> = HashMap::new(); + let mut taken: HashSet = 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 = (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)); + } } diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index a516137fa..535506806 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -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); diff --git a/crates/doc/src/entity/block.rs b/crates/doc/src/entity/block.rs index 3d6665e0b..a3f2e76b4 100644 --- a/crates/doc/src/entity/block.rs +++ b/crates/doc/src/entity/block.rs @@ -161,6 +161,25 @@ pub struct Insert { pub col_spacing: f64, #[serde(default)] pub row_spacing: f64, + /// XCLIP boundary of this reference (other references to the block are not clipped). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clip: Option, +} +impl Default for Insert { + fn default() -> Self { + Insert { + block: String::new(), + insert: Vec3::ZERO, + scale: unit_scale(), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + clip: None, + } + } } fn unit_scale() -> Vec3 { diff --git a/crates/doc/src/entity/layout.rs b/crates/doc/src/entity/layout.rs index be1b9584d..9155add9e 100644 --- a/crates/doc/src/entity/layout.rs +++ b/crates/doc/src/entity/layout.rs @@ -1,3 +1,5 @@ +use cadcraft_geom::Mat3; + use super::common::Handle; use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes}; @@ -25,6 +27,39 @@ pub struct Viewport { /// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space. #[serde(default = "yes")] pub on: bool, + /// The view twist (DVIEW TWist inside the viewport; DXF group 51): radians counterclockwise, + /// the model view seen through the viewport is turned on the sheet by it. 0 is untwisted. + #[serde(default, skip_serializing_if = "is_zero_twist")] + pub twist: f64, +} +fn is_zero_twist(v: &f64) -> bool { + *v == 0.0 +} +impl Viewport { + /// The view twist, usable: 0 when it isn't finite. + pub fn view_twist(&self) -> f64 { + if self.twist.is_finite() { self.twist } else { 0.0 } + } + /// Paper units per model unit. + pub fn scale(&self) -> f64 { + self.height / self.view_height + } + /// Where model point `m` is on the sheet: `center + scale · R(twist)(m − view_center)`. + pub fn model_to_paper(&self, m: Vec2) -> Vec2 { + self.center.xy() + (m - self.view_center).rotate(self.view_twist()) * self.scale() + } + /// The model point at sheet point `p` (the inverse of [`Viewport::model_to_paper`]). + pub fn paper_to_model(&self, p: Vec2) -> Vec2 { + self.view_center + ((p - self.center.xy()) / self.scale()).rotate(-self.view_twist()) + } + /// [`Viewport::model_to_paper`] as a matrix. + pub fn model_to_paper_matrix(&self) -> Mat3 { + let s = self.scale(); + Mat3::translate(self.center.xy()) + .then_before(Mat3::scale(s, s)) + .then_before(Mat3::rotate(self.view_twist())) + .then_before(Mat3::translate(-self.view_center)) + } } impl Default for Viewport { @@ -41,6 +76,7 @@ impl Default for Viewport { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, } } } @@ -59,8 +95,9 @@ pub struct Image { /// The image definition's name (its key in the drawing's image dictionary). #[serde(default, skip_serializing_if = "String::is_empty")] pub name: String, - /// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down): - /// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image. + /// Clip boundary in pixel coordinates (y down, counted from pixel centres: the centre of the + /// top-left pixel is the origin, so the whole image is -0.5 to size - 0.5): two opposite + /// corners of a rectangle, or a polygon's vertices. Empty: the whole image. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub clip: Vec, /// The clip boundary is applied. diff --git a/crates/doc/src/entity/mline.rs b/crates/doc/src/entity/mline.rs index 62ab1d8f3..f6ceef519 100644 --- a/crates/doc/src/entity/mline.rs +++ b/crates/doc/src/entity/mline.rs @@ -207,6 +207,41 @@ pub struct MLineGeometry { pub fill: Vec>, } +/// 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, Vec) { + 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, Vec) { + 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()); + } +} diff --git a/crates/doc/src/entity/region.rs b/crates/doc/src/entity/region.rs index 0d2594ea8..8c63b43bf 100644 --- a/crates/doc/src/entity/region.rs +++ b/crates/doc/src/entity/region.rs @@ -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 = 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> = 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 { (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 = + (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::>(), [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"); + } +} diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index d87cdc9a2..4f40a148c 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -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, @@ -156,6 +179,13 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap< } }; b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) }; + // XCLIP: the reference app takes the boundary's box, also where it reaches past the + // block or misses it (an inverted clip can show anything, so it keeps the block's). + if let Some(c) = ins.clip.as_ref().filter(|c| c.active() && !c.inverted) + && !b.is_empty() + { + b = Bounds2::from_points(c.polygon().into_iter().map(|p| m.apply(p))); + } if ins.cols > 1 || ins.rows > 1 { let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1))) @@ -442,4 +472,54 @@ 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, + clip: None, + }), + ) + }; + 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 = (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()); + } + } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8f2a1e2e4..2b11a5bd3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,9 +16,11 @@ mod header; pub mod library; mod plotstyle; mod plotter; +mod raster; mod refedit; mod store; mod tables; +mod xclip; use std::collections::{BTreeMap, BTreeSet}; @@ -27,15 +29,17 @@ 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}; pub use plotstyle::*; pub use plotter::*; +pub use raster::{Raster, Rasters}; pub use refedit::{RefEdit, WorkItem}; pub use store::EntityStore; pub use tables::*; +pub use xclip::{ClipBoundary, MAX_CLIP_VERTICES}; /// File data CADCraft doesn't model, kept for saving (#528). pub mod dynblock; @@ -136,6 +140,8 @@ pub struct Drawing { /// for the Linux fonts of text styles, and each style keeps its own in CADCraft xdata, so the /// drawing reopens with it. Never set on an open drawing. pub portable_fonts: bool, + /// The pixels of the raster images that are loaded (IMAGE). Never written to a file. + pub rasters: Rasters, } impl Default for Drawing { @@ -196,6 +202,7 @@ impl Drawing { refedit: None, skipped_records: BTreeMap::new(), portable_fonts: false, + rasters: Rasters::default(), } } @@ -383,10 +390,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)); } } } diff --git a/crates/doc/src/raster.rs b/crates/doc/src/raster.rs new file mode 100644 index 000000000..1eb721997 --- /dev/null +++ b/crates/doc/src/raster.rs @@ -0,0 +1,93 @@ +//! Decoded raster image pixels (IMAGE entities). The drawing keeps them for the session only: +//! they are loaded from the image files after a drawing opens and are never written to a file. + +use std::collections::BTreeMap; +use std::sync::Arc; + +/// The pixels of a raster image: 8-bit RGBA, not premultiplied, rows from the top. +#[derive(Clone, PartialEq)] +pub struct Raster { + width: u32, + height: u32, + rgba: Vec, + opaque: bool, +} + +impl Raster { + /// `None` unless both sides are at least one pixel and `rgba` holds exactly four bytes per pixel. + pub fn new(width: u32, height: u32, rgba: Vec) -> Option { + let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?; + if pixels == 0 || rgba.len() != pixels.checked_mul(4)? { + return None; + } + let opaque = rgba.as_chunks::<4>().0.iter().all(|[_, _, _, alpha]| *alpha == u8::MAX); + Some(Raster { width, height, rgba, opaque }) + } + pub fn width(&self) -> u32 { + self.width + } + pub fn height(&self) -> u32 { + self.height + } + /// Four bytes per pixel (red, green, blue, alpha), `width * height` pixels. + pub fn rgba(&self) -> &[u8] { + &self.rgba + } + /// No pixel is transparent. + pub fn opaque(&self) -> bool { + self.opaque + } + /// The pixel in column `x` of row `y` (from the top): red, green, blue, alpha. + pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> { + if x >= self.width || y >= self.height { + return None; + } + let (x, y, width) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?, usize::try_from(self.width).ok()?); + let pixels = self.rgba.as_chunks::<4>().0; + pixels.get(y.checked_mul(width)?.checked_add(x)?).copied() + } +} + +impl std::fmt::Debug for Raster { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Raster({}x{})", self.width, self.height) + } +} + +/// The loaded pixels of a drawing's raster images, by image file path ([`crate::Image::path`]). +/// Session data: two drawings are equal whatever images they have loaded, and copying a drawing +/// (undo) shares the pixels. +#[derive(Clone, Default)] +pub struct Rasters(Arc>>); + +impl Rasters { + pub fn get(&self, path: &str) -> Option<&Arc> { + self.0.get(path) + } + pub fn insert(&mut self, path: &str, raster: Arc) { + Arc::make_mut(&mut self.0).insert(path.to_string(), raster); + } + pub fn remove(&mut self, path: &str) { + if self.0.contains_key(path) { + Arc::make_mut(&mut self.0).remove(path); + } + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl PartialEq for Rasters { + fn eq(&self, _: &Rasters) -> bool { + true + } +} + +impl std::fmt::Debug for Rasters { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Rasters({})", self.0.len()) + } +} diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index a5f02454f..9ad951f6e 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -863,7 +863,7 @@ pub struct NamedView { /// The layer state restored with the view (its layer snapshot). #[serde(default)] pub layer_state: Option, - /// View twist (radians, counterclockwise); kept for files, not applied to the 2D display. + /// View twist (radians, counterclockwise); VIEW Restore applies it to the model space view. #[serde(default)] pub twist: f64, /// The layout a paper space view belongs to (`None`: a model space view; an empty name: a @@ -884,6 +884,9 @@ pub struct VportTile { /// Width / height of the tile on screen when it was saved (0: unknown). #[serde(default)] pub aspect: f64, + /// The tile's view twist (radians, counterclockwise; DXF VPORT group 51 in degrees). + #[serde(default)] + pub twist: f64, } /// A model space viewport configuration (VPORTS): `*Active` is the one shown, other names are diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 29d55e30f..ec3f38ebb 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -189,6 +189,7 @@ fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) }; d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty"))); @@ -242,6 +243,7 @@ fn visible_attributes_of_a_missing_block_still_count() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) }; @@ -285,6 +287,7 @@ fn insert_bounds_with_cyclic_block_terminate() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), )); b.entities.push(Entity::new(Handle(2), line((0.0, 0.0), (1.0, 1.0)))); @@ -303,6 +306,7 @@ fn insert_bounds_with_cyclic_block_terminate() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) .unwrap(); @@ -420,6 +424,7 @@ fn plain_insert(block: &str) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/doc/src/xclip.rs b/crates/doc/src/xclip.rs new file mode 100644 index 000000000..b73be0ca0 --- /dev/null +++ b/crates/doc/src/xclip.rs @@ -0,0 +1,85 @@ +//! XCLIP: the clipping boundary of one block or xref reference. +//! +//! The boundary is kept in the referenced block's coordinates, so it moves, rotates and scales +//! with its reference; the block definition and other references to it are untouched. In a file +//! it is the reference's `ACAD_FILTER` / `SPATIAL` extension-dictionary entry (a SPATIAL_FILTER +//! object, DXF Reference), read and written by the io crate. + +use cadcraft_geom::Vec2; +use serde::{Deserialize, Serialize}; + +/// Most boundary vertices a clip keeps (hostile files and parameters are capped to this). +pub const MAX_CLIP_VERTICES: usize = 4096; + +/// A reference's clipping boundary. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClipBoundary { + /// Vertices in the block's coordinates: two opposite corners for a rectangle, three or more + /// for a closed polygon (not repeating the first vertex). + pub points: Vec, + /// Whether clipping is on (XCLIP ON/OFF); an off boundary is kept but nothing is clipped. + #[serde(default = "on")] + pub enabled: bool, + /// Inverted clip: what is inside the boundary is hidden, what is outside shows. + #[serde(default)] + pub inverted: bool, +} + +fn on() -> bool { + true +} + +impl ClipBoundary { + /// A boundary from `points` (block coordinates), on and not inverted. + pub fn new(points: Vec) -> Self { + ClipBoundary { points, enabled: true, inverted: false } + } + + /// The closed outline in block coordinates: a rectangle's four corners or the polygon's + /// vertices. Empty when the boundary has fewer than two vertices, more than + /// [`MAX_CLIP_VERTICES`] or a non-finite one. + pub fn polygon(&self) -> Vec { + if self.points.len() > MAX_CLIP_VERTICES || !self.points.iter().all(|p| p.is_finite()) { + return Vec::new(); + } + match self.points.as_slice() { + [a, b] => vec![ + Vec2::new(a.x.min(b.x), a.y.min(b.y)), + Vec2::new(a.x.max(b.x), a.y.min(b.y)), + Vec2::new(a.x.max(b.x), a.y.max(b.y)), + Vec2::new(a.x.min(b.x), a.y.max(b.y)), + ], + [_, _, _, ..] => self.points.clone(), + _ => Vec::new(), + } + } + + /// Whether the boundary is a rectangle given by two corners. + pub fn is_rectangle(&self) -> bool { + self.points.len() == 2 + } + + /// Whether drawing the reference is clipped by it now (on, with a usable outline). + pub fn active(&self) -> bool { + self.enabled && self.polygon().len() >= 3 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rectangle_and_polygon_outlines() { + let r = ClipBoundary::new(vec![Vec2::new(2.0, 3.0), Vec2::new(0.0, 0.0)]); + assert_eq!(r.polygon(), vec![Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0), Vec2::new(2.0, 3.0), Vec2::new(0.0, 3.0)]); + assert!(r.active() && r.is_rectangle()); + let bad = ClipBoundary::new(vec![Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 1.0)]); + assert!(bad.polygon().is_empty() && !bad.active()); + let one = ClipBoundary::new(vec![Vec2::ZERO]); + assert!(!one.active()); + let off = ClipBoundary { enabled: false, ..r }; + assert!(!off.active()); + } +} diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml index acb8d0aa7..23d1c82d2 100644 --- a/crates/dwg/Cargo.toml +++ b/crates/dwg/Cargo.toml @@ -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). diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index e5463aaec..f143b7be8 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -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 = 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, 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, 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, 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) -> Result, String> { + fn convert(dxf: &[u8], version: Option) -> Result<(Vec, 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; // 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 { + None +} +#[cfg(no_dwg_bridge)] +pub fn use_own_worker() {} +#[cfg(no_dwg_bridge)] +pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + dwg_to_dxf_noted(bytes, limits) +} +#[cfg(no_dwg_bridge)] +pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + dxf_to_dwg_as(dxf, version) +} +#[cfg(no_dwg_bridge)] +pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec, Unwritten), String> { + dxf_to_dwg_noted(dxf, version) +} + #[cfg(no_dwg_bridge)] pub fn dwg_to_dxf(_bytes: &[u8]) -> Result, 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, String> { pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result, 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, 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::(), "{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> = 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}"); + } + } } diff --git a/crates/dwg/src/worker.rs b/crates/dwg/src/worker.rs new file mode 100644 index 000000000..f55c7d170 --- /dev/null +++ b/crates/dwg/src/worker.rs @@ -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, +} + +static WORKER: Mutex> = 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) { + 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 { + (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, Vec), 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, 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 `TYPEcount`. +pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec, 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::().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 { + 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, Vec), 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> { + 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 { + 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 { + 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) -> Option { + 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 { + 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:?}"); + } +} diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 953043cb9..c31defc21 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -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 = std::result::Result; @@ -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::().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> { - 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> { 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> { - 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> { +fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result> { 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> { - 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> { @@ -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}"); + } +} diff --git a/crates/engine/src/audit.rs b/crates/engine/src/audit.rs index c055bff25..5940a96c1 100644 --- a/crates/engine/src/audit.rs +++ b/crates/engine/src/audit.rs @@ -234,9 +234,10 @@ fn tables(d: &mut Drawing, out: &mut Vec) { missing("table style", "Standard", out); d.table_styles.insert(0, TableStyle::default()); } - let linetypes: Vec = 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 = 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) { l.linetype = "Continuous".into(); } } - let styles: Vec = 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 = 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( diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 9ff7eab0b..f23d3159f 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -4,15 +4,18 @@ //! autosave is written as DXF to `/__.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 (`.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 (`.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, /// Drawings whose autosave is being written right now. busy: HashSet, - /// 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, + /// 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)>, + running: Vec<(u64, std::thread::JoinHandle>)>, #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant, #[cfg(not(target_arch = "wasm32"))] last_beat: Option, + /// 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)>, } 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 { + 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>, d: &Arc| 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 { @@ -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 = 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 // `.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 { + #[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::, _>(|(_, 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>) { + 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 = 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)] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index bc93b9f55..f16a16fe7 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -18,10 +18,12 @@ mod gripcmds; mod griphot; pub(crate) mod groups; pub(crate) mod hatch; +mod image; mod inquiry; mod layer; mod layout; mod lisp; +mod merge; mod mleader_content; mod mleader_edit; mod modify; @@ -199,6 +201,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(layer::specs()); v.extend(layout::specs()); v.extend(exchange::specs()); + v.extend(image::specs()); v.extend(props::specs()); v.extend(inquiry::specs()); v.extend(settings::specs()); diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index da96ccfed..452cf176f 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -159,6 +159,7 @@ pub(crate) fn make_assoc(s: &mut Session, objs: &[Handle], base: Vec2, mut p: Ar rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, })) } diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 258594ff0..efea47962 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -25,6 +25,7 @@ mod dyngrip; mod extract; pub(crate) mod refedit; mod wblock; +mod xclip; pub fn specs() -> Vec { vec![ @@ -43,6 +44,9 @@ pub fn specs() -> Vec { .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"]) @@ -89,6 +93,7 @@ pub fn specs() -> Vec { .chain(attribs::attdisp_menu_specs()) .chain(xref::specs()) .chain(refedit::specs()) + .chain(xclip::specs()) .chain(bedit::specs()) .collect() } @@ -225,6 +230,7 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; if let Some(b) = base { let landed = ins.transform(blk.base.xy()).apply(b); @@ -233,6 +239,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 +277,12 @@ pub(crate) fn insert_with( base: Option, values: &serde_json::Map, ) -> Result { - 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 +291,11 @@ fn insert_scaled( rotation: f64, base: Option, values: &serde_json::Map, + grid: Grid, ) -> Result { 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 +347,52 @@ fn run_block(s: &mut Session, p: &Value) -> Result { } fn run_insert(s: &mut Session, p: &Value) -> Result { + 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 { + let count = |key: &str| -> Result { + 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 { + 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 { 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 +444,11 @@ fn run_base(s: &mut Session, p: &Value) -> Result { fn run_list(s: &mut Session, _p: &Value) -> Result { 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::>() })) @@ -557,6 +625,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 +666,12 @@ struct InsertM { explode: bool, /// The file waiting for the Redefine answer. file: Option, + /// MINSERT: after the rotation, the grid's rows and columns and the distances between them. + minsert: bool, + rows: Option, + cols: Option, + row_spacing: Option, + col_spacing: Option, } impl InsertM { @@ -598,6 +681,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 +701,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 +710,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 { + 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 +804,18 @@ impl InsertM { } } +/// A distance typed at MINSERT's grid prompts: any finite number, negative or 0 too. +fn distance(t: &str) -> Result { + number(t).filter(|v| v.is_finite()).ok_or_else(|| EngineError::Other("Requires a distance.".into())) +} + fn nonzero(t: &str) -> Result { 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 +842,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 +873,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 +1015,45 @@ impl Interactive for InsertM { }); return self.advance(s); } + InsStage::Rows | InsStage::Cols => { + let n = match i { + Input::Text(t) => t + .trim() + .parse::() + .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 +1094,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)] } } diff --git a/crates/engine/src/cmd/blocks/insert_file.rs b/crates/engine/src/cmd/blocks/insert_file.rs index a00dbc64c..7a0492fad 100644 --- a/crates/engine/src/cmd/blocks/insert_file.rs +++ b/crates/engine/src/cmd/blocks/insert_file.rs @@ -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 { // 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 { /// 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(ours: &mut Vec, 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 { - let mut out = BTreeMap::new(); - let mut taken: BTreeSet = 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) -> Entity { diff --git a/crates/engine/src/cmd/blocks/xclip.rs b/crates/engine/src/cmd/blocks/xclip.rs new file mode 100644 index 000000000..067932127 --- /dev/null +++ b/crates/engine/src/cmd/blocks/xclip.rs @@ -0,0 +1,484 @@ +//! XCLIP and XCLIPFRAME: clip block and xref references to a boundary. +//! +//! A boundary belongs to one reference ([`cadcraft_doc::Insert::clip`]), in the block's own +//! coordinates, so it moves, rotates and scales with the reference and other references of the +//! block stay whole. It is a rectangle, a polygon or the outline of a closed polyline, can hide +//! what is inside instead of what is outside (Invert clip), and can be turned off, deleted or +//! turned into a polyline. Clip depth (front and back clipping planes) has no meaning in a 2D +//! drawing and is ignored. XCLIPFRAME says whether the boundary is drawn and plotted. + +use cadcraft_doc::{ClipBoundary, EntityKind, Handle, MAX_CLIP_VERTICES}; +use cadcraft_geom::{PolyVertex, Vec2}; +use serde_json::{Value, json}; + +use super::super::machines::{SelOutcome, SelectPhase}; +use crate::cmd::{CommandSpec, bad, bool_or, has_doc, point_value, str_param, targets}; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +pub(super) fn specs() -> Vec { + vec![ + CommandSpec::new("xclip", "Clip Xref", run) + .menu(&["Modify", "Clip", "Xref"]) + .alias(&["xc"]) + .params("{handles? | handle? (block or xref references, default the selection), option?: new|on|off|delete|polyline|clipdepth|query (default new with a boundary, else query), rectangle?: [[x,y],[x,y]] | points?: [[x,y],…] (3 to 4096, drawing coordinates) | polyline?: handle of a closed polyline, invert?: bool (hide what is inside)} → {handles, option} (query: {clips: [{handle, boundary, enabled, inverted}]}; polyline: {polylines})") + .enabled(has_doc) + .interactive(|_| Ok(Box::new(XclipM::default()))), + CommandSpec::new("xclipframe", "Xref Clip Frame", run_frame) + .params("{value?: 0 (frames hidden) | 1 (shown and plotted) | 2 (shown, not plotted; the default)} → {XCLIPFRAME}") + .enabled(has_doc) + .interactive(|_| Ok(Box::new(FrameM))), + ] +} + +/// The block references among `hs` (other objects are ignored, as XCLIP's selection does). +fn references(s: &Session, hs: &[Handle]) -> Vec { + let Ok(d) = s.doc() else { return Vec::new() }; + hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).collect() +} + +/// A rectangle's two corners as a closed outline. +fn rectangle(a: Vec2, b: Vec2) -> Vec { + vec![ + Vec2::new(a.x.min(b.x), a.y.min(b.y)), + Vec2::new(a.x.max(b.x), a.y.min(b.y)), + Vec2::new(a.x.max(b.x), a.y.max(b.y)), + Vec2::new(a.x.min(b.x), a.y.max(b.y)), + ] +} + +/// The boundary a JSON call gives, in drawing coordinates, and whether it is a rectangle. +fn world_boundary(s: &Session, p: &Value) -> Result<(Vec, bool)> { + if let Some(r) = p.get("rectangle") { + let pts: Vec = r.as_array().map(|a| a.iter().filter_map(point_value).collect()).unwrap_or_default(); + let [a, b] = pts.as_slice() else { return Err(bad("xclip", "`rectangle` needs two finite corners [[x,y],[x,y]]")) }; + if (a.x - b.x).abs() <= 1e-12 || (a.y - b.y).abs() <= 1e-12 { + return Err(bad("xclip", "the clipping rectangle must have a width and a height")); + } + return Ok((vec![*a, *b], true)); + } + if let Some(v) = p.get("polyline") { + let h = v + .as_str() + .and_then(Handle::parse_hex) + .or_else(|| v.as_u64().map(Handle)) + .ok_or_else(|| bad("xclip", "`polyline` must be an object handle"))?; + let d = s.doc()?; + let e = d.entity(h).ok_or_else(|| bad("xclip", format!("no object {}", h.hex())))?; + let closed = matches!(&e.kind, EntityKind::LwPolyline(pl) if pl.closed) || matches!(&e.kind, EntityKind::Polyline3d(pl) if pl.closed); + if !closed { + return Err(bad("xclip", "select a closed polyline")); + } + let pts = cadcraft_render::clip::boundary_polygon(&e.kind).ok_or_else(|| bad("xclip", "the polyline does not enclose an area"))?; + return Ok((pts, false)); + } + let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("xclip", "a boundary is required: `rectangle`, `points` or `polyline`"))?; + if !(3..=MAX_CLIP_VERTICES).contains(&pts.len()) { + return Err(bad("xclip", format!("a polygonal boundary needs 3 to {MAX_CLIP_VERTICES} points"))); + } + let pts: Vec = + pts.iter().map(|v| point_value(v).ok_or_else(|| bad("xclip", "every boundary point must be a finite [x, y]"))).collect::>()?; + Ok((pts, false)) +} + +fn run(s: &mut Session, p: &Value) -> Result { + let hs = references(s, &targets(s, p)?); + if hs.is_empty() { + return Err(bad("xclip", "select block or xref references")); + } + let has_boundary = ["rectangle", "points", "polyline"].iter().any(|k| p.get(*k).is_some()); + let option = str_param(p, "option").map(str::to_ascii_lowercase).unwrap_or_else(|| if has_boundary { "new".into() } else { "query".into() }); + match option.as_str() { + "new" | "n" => { + let (world, rect) = world_boundary(s, p)?; + let outline = if rect { world.first().zip(world.get(1)).map(|(a, b)| rectangle(*a, *b)).unwrap_or_default() } else { world.clone() }; + if cadcraft_render::clip::PolyClip::new(&outline).is_none() { + return Err(bad("xclip", "the clipping boundary encloses no area")); + } + let invert = bool_or(p, "invert", false); + let mut clips = Vec::with_capacity(hs.len()); + { + let d = s.doc()?; + for h in &hs { + let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue }; + let base = d.block(&ins.block).map(|b| b.base.xy()).ok_or_else(|| bad("xclip", format!("block \"{}\" is missing", ins.block)))?; + let inv = ins.transform(base).inverse().ok_or_else(|| bad("xclip", "a reference with a zero scale can't be clipped"))?; + let points: Vec = world.iter().map(|q| inv.apply(*q)).collect(); + // A rotated or skewed reference sees the rectangle as a polygon. + let r = ins.rotation.rem_euclid(std::f64::consts::FRAC_PI_2); + let square = r < 1e-9 || std::f64::consts::FRAC_PI_2 - r < 1e-9; + let points = if rect && !square { outline.iter().map(|q| inv.apply(*q)).collect() } else { points }; + clips.push((*h, ClipBoundary { points, enabled: true, inverted: invert })); + } + } + for (h, c) in clips { + set_clip(s, h, Some(c))?; + } + } + "on" | "off" => { + let on = option == "on"; + // Every reference is checked before any changes: an error leaves the drawing as it was. + let d = s.doc()?; + if !hs.iter().all(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Insert(i)) if i.clip.is_some())) { + return Err(bad("xclip", "no clipping boundary on the selected reference")); + } + // Only the flag changes, once per reference (a selection may name one many times). + let mut seen = std::collections::HashSet::new(); + for h in hs.iter().filter(|h| seen.insert(**h)) { + s.doc_mut()? + .modify_entity(*h, |e| { + if let EntityKind::Insert(i) = &mut e.kind + && let Some(c) = &mut i.clip + { + c.enabled = on; + } + }) + .map_err(|e| bad("xclip", e.to_string()))?; + } + } + "delete" | "d" => { + for h in &hs { + set_clip(s, *h, None)?; + } + } + "polyline" | "p" | "generate" => { + let mut made = Vec::new(); + for h in &hs { + let Some(world) = world_outline(s, *h) else { continue }; + let vertices = world.into_iter().map(|p| PolyVertex { p, bulge: 0.0, start_width: 0.0, end_width: 0.0 }).collect(); + let pl = cadcraft_doc::LwPolyline { vertices, closed: true, const_width: 0.0, elevation: 0.0, plinegen: false }; + made.push(s.add_entity(EntityKind::LwPolyline(pl))?.hex()); + } + if made.is_empty() { + return Err(bad("xclip", "no clipping boundary on the selected references")); + } + return Ok(json!({ "handles": hexes(&hs), "option": "polyline", "polylines": made })); + } + // Front and back clipping planes have no meaning in 2D: nothing changes. + "clipdepth" | "c" => return Ok(json!({ "handles": hexes(&hs), "option": "clipdepth", "ignored": true })), + "query" | "?" => { + let clips: Vec = hs + .iter() + .map(|h| { + let c = clip_of(s, *h); + json!({ + "handle": h.hex(), + "boundary": world_outline(s, *h).map(|w| w.iter().map(|p| [p.x, p.y]).collect::>()), + "enabled": c.as_ref().is_some_and(|c| c.enabled), + "inverted": c.as_ref().is_some_and(|c| c.inverted), + }) + }) + .collect(); + return Ok(json!({ "clips": clips })); + } + o => return Err(bad("xclip", format!("unknown option \"{o}\" (new, on, off, delete, polyline, clipdepth or query)"))), + } + Ok(json!({ "handles": hexes(&hs), "option": option })) +} + +fn hexes(hs: &[Handle]) -> Vec { + hs.iter().map(|h| h.hex()).collect() +} + +fn clip_of(s: &Session, h: Handle) -> Option { + match &s.doc().ok()?.entity(h)?.kind { + EntityKind::Insert(i) => i.clip.clone(), + _ => None, + } +} + +/// Reference `h`'s boundary outline in drawing coordinates. +fn world_outline(s: &Session, h: Handle) -> Option> { + let d = s.doc().ok()?; + let EntityKind::Insert(i) = &d.entity(h)?.kind else { return None }; + let c = i.clip.as_ref()?; + let m = i.transform(d.block(&i.block)?.base.xy()); + let poly = c.polygon(); + (poly.len() >= 3).then(|| poly.into_iter().map(|p| m.apply(p)).collect()) +} + +fn set_clip(s: &mut Session, h: Handle, c: Option) -> Result<()> { + s.doc_mut()? + .modify_entity(h, |e| { + if let EntityKind::Insert(i) = &mut e.kind { + i.clip = c; + } + }) + .map_err(|e| bad("xclip", e.to_string())) +} + +// ---------------- XCLIPFRAME ---------------- + +fn run_frame(s: &mut Session, p: &Value) -> Result { + let v = match p.get("value").or_else(|| p.get("XCLIPFRAME")) { + None | Some(Value::Null) => return Ok(json!({ "XCLIPFRAME": s.doc()?.header.i64("XCLIPFRAME", 2) })), + Some(v) => v.as_i64().or_else(|| v.as_str().and_then(|t| t.trim().parse().ok())), + }; + let v = v.filter(|v| (0..=2).contains(v)).ok_or_else(|| bad("xclipframe", "`value` must be 0, 1 or 2"))?; + s.doc_mut()?.header.set_i64("XCLIPFRAME", v); + Ok(json!({ "XCLIPFRAME": v })) +} + +struct FrameM; + +impl Interactive for FrameM { + fn name(&self) -> &'static str { + "XCLIPFRAME" + } + fn prompt(&self, s: &Session) -> Prompt { + let cur = s.doc().map(|d| d.header.i64("XCLIPFRAME", 2)).unwrap_or(2); + Prompt::new("Enter new value for XCLIPFRAME", Accept::TEXT).kw(&["0", "1", "2"]).default(cur.to_string()) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let t = match i { + Input::Cancel => return Ok(Step::Cancel), + Input::Enter => return Ok(Step::Done), + Input::Text(t) | Input::Keyword(t) => t, + _ => return Ok(Step::Continue), + }; + match run_frame(s, &json!({ "value": t.trim() })) { + Ok(_) => Ok(Step::Done), + Err(_) => { + s.echo("Requires an integer between 0 and 2."); + Ok(Step::Continue) + } + } + } +} + +// ---------------- interactive XCLIP ---------------- + +#[derive(Default, Clone, Copy, PartialEq, Eq)] +enum Stage { + #[default] + Select, + Option, + DeleteOld, + Boundary, + Corner1, + Corner2, + Polygon, + Polyline, +} + +#[derive(Default)] +struct XclipM { + stage: Stage, + sel: SelectPhase, + refs: Vec, + invert: bool, + pts: Vec, +} + +impl XclipM { + fn selected(&mut self, s: &mut Session, hs: &[Handle]) -> Result { + self.refs = references(s, hs); + if self.refs.is_empty() { + s.echo("No block or xref references selected."); + return Ok(Step::Done); + } + self.stage = Stage::Option; + Ok(Step::Continue) + } + + fn apply(&mut self, s: &mut Session, mut p: Value) -> Result { + p["handles"] = json!(hexes(&self.refs)); + let r = run(s, &p); + s.set_selection(Vec::new()); + match r { + Ok(_) => Ok(Step::Done), + Err(e) => { + s.echo(e.to_string()); + Ok(Step::Done) + } + } + } + + /// Delete: as the reference app, with nothing to delete it says so and asks again. + fn delete(&mut self, s: &mut Session) -> Result { + if !self.any_clipped(s) { + s.echo("No clip boundary found."); + return Ok(Step::Continue); + } + self.apply(s, json!({ "option": "delete" })) + } + + fn any_clipped(&self, s: &Session) -> bool { + self.refs.iter().any(|h| clip_of(s, *h).is_some()) + } +} + +fn kw(i: &Input) -> Option { + match i { + Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()), + _ => None, + } +} + +impl Interactive for XclipM { + fn name(&self) -> &'static str { + "XCLIP" + } + + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + let hs = self.sel.picked.clone(); + return self.selected(s, &hs); + } + Ok(Step::Continue) + } + + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::Select => self.sel.prompt(), + Stage::Option => Prompt::new("Enter clipping option", Accept::TEXT) + .kw(&["ON", "OFF", "Clipdepth", "Delete", "generate Polyline", "New boundary"]) + .default("New"), + Stage::DeleteOld => Prompt::new("Delete old boundary(s)?", Accept::TEXT).kw(&["Yes", "No"]).default("Yes"), + Stage::Boundary => Prompt::new("Specify clipping boundary or select invert option", Accept::POINT) + .kw(&["Select polyline", "Polygonal", "Rectangular", "Invert clip"]) + .default("Rectangular"), + Stage::Corner1 => Prompt::new("Specify first corner", Accept::POINT), + Stage::Corner2 => { + let p = Prompt::new("Specify opposite corner", Accept::POINT); + match self.pts.first() { + Some(a) => p.corner(*a), + None => p, + } + } + Stage::Polygon => { + let msg = if self.pts.is_empty() { "Specify first point" } else { "Specify next point" }; + let p = Prompt::new(msg, Accept::POINT).base_opt(self.pts.last().copied()); + if self.pts.is_empty() { p } else { p.kw(&["Undo"]) } + } + Stage::Polyline => Prompt::new("Select polyline", Accept::SELECT), + } + } + + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if matches!(i, Input::Cancel) { + return Ok(Step::Cancel); + } + match self.stage { + Stage::Select => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => self.selected(s, &hs), + }, + Stage::Option => { + let k = if matches!(i, Input::Enter) { Some("new".to_string()) } else { kw(&i) }; + match k.as_deref() { + Some("on") => self.apply(s, json!({ "option": "on" })), + Some("off") => self.apply(s, json!({ "option": "off" })), + Some(k) if k.starts_with('c') => { + s.echo("Clip depth does not apply to a 2D drawing: front and back clipping planes are ignored."); + Ok(Step::Done) + } + Some(k) if k.starts_with('d') => self.delete(s), + Some(k) if k.starts_with('p') || k.starts_with('g') => self.apply(s, json!({ "option": "polyline" })), + Some(k) if k.starts_with('n') => { + self.stage = if self.any_clipped(s) { Stage::DeleteOld } else { Stage::Boundary }; + Ok(Step::Continue) + } + _ => { + s.echo("Invalid option keyword."); + Ok(Step::Continue) + } + } + } + Stage::DeleteOld => match kw(&i).as_deref() { + None if matches!(i, Input::Enter) => { + self.stage = Stage::Boundary; + Ok(Step::Continue) + } + Some(k) if k.starts_with('y') => { + self.stage = Stage::Boundary; + Ok(Step::Continue) + } + Some(k) if k.starts_with('n') => Ok(Step::Done), + _ => Ok(Step::Continue), + }, + Stage::Boundary => match (&i, kw(&i).as_deref()) { + (Input::Enter, _) => { + self.stage = Stage::Corner1; + Ok(Step::Continue) + } + (Input::Point(p), _) => { + // A point starts the default rectangle. + self.pts = vec![*p]; + self.stage = Stage::Corner2; + Ok(Step::Continue) + } + (_, Some(k)) if k.starts_with('i') => { + self.invert = !self.invert; + s.echo(if self.invert { + "Inside mode - Objects inside boundary will be hidden." + } else { + "Outside mode - Objects outside boundary will be hidden." + }); + Ok(Step::Continue) + } + (_, Some(k)) if k.starts_with('s') => { + self.stage = Stage::Polyline; + Ok(Step::Continue) + } + (_, Some(k)) if k.starts_with('p') => { + self.pts.clear(); + self.stage = Stage::Polygon; + Ok(Step::Continue) + } + (_, Some(k)) if k.starts_with('r') => { + self.stage = Stage::Corner1; + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + }, + Stage::Corner1 => match i { + Input::Point(p) => { + self.pts = vec![p]; + self.stage = Stage::Corner2; + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + }, + Stage::Corner2 => match (i, self.pts.first().copied()) { + (Input::Point(b), Some(a)) => self.apply(s, json!({ "option": "new", "rectangle": [[a.x, a.y], [b.x, b.y]], "invert": self.invert })), + _ => Ok(Step::Continue), + }, + Stage::Polygon => match i { + Input::Point(p) if self.pts.len() < MAX_CLIP_VERTICES => { + self.pts.push(p); + Ok(Step::Continue) + } + Input::Point(_) => { + s.echo(format!("A boundary has at most {MAX_CLIP_VERTICES} points.")); + Ok(Step::Continue) + } + Input::Keyword(_) | Input::Text(_) => { + self.pts.pop(); + Ok(Step::Continue) + } + Input::Enter if self.pts.len() >= 3 => { + let pts: Vec<[f64; 2]> = self.pts.iter().map(|p| [p.x, p.y]).collect(); + self.apply(s, json!({ "option": "new", "points": pts, "invert": self.invert })) + } + Input::Enter => { + s.echo("A polygonal boundary needs at least three points."); + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + }, + Stage::Polyline => match i { + Input::Pick(hs) => match hs.first() { + Some(h) => { + let h = *h; + self.apply(s, json!({ "option": "new", "polyline": h.hex(), "invert": self.invert })) + } + None => Ok(Step::Continue), + }, + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + }, + } + } +} diff --git a/crates/engine/src/cmd/blocks/xref.rs b/crates/engine/src/cmd/blocks/xref.rs index b87e73795..18da7fa0f 100644 --- a/crates/engine/src/cmd/blocks/xref.rs +++ b/crates/engine/src/cmd/blocks/xref.rs @@ -62,11 +62,20 @@ pub(crate) fn after_open(s: &mut Session, i: usize) { st.saved = st.doc.clone(); let ext = st.doc.extents(&cadcraft_doc::Space::Model); if !ext.is_empty() { - st.set_view_quiet(crate::View { center: ext.center(), height: (ext.height().max(ext.width() * px.1 / px.0.max(1.0)) * 1.1).max(1e-6) }); + // Fit in the frame of the saved twist, which open_drawing has restored. + let twist = st.view().twist; + let (center, w, h) = crate::View::twisted_box(ext, twist); + st.set_view_quiet(crate::View { center, height: (h.max(w * px.1 / px.0.max(1.0)) * 1.1).max(1e-6), twist }); } echo_all(s, &out); } +/// The file a reference saved as `path` in a drawing at `host` is read from, if there is one +/// (xrefs and raster images look in the same places). +pub(crate) fn find_file(path: &str, host: Option<&str>) -> Option { + load::find(path, host.map(std::path::Path::new), false).ok() +} + /// The open drawing's path (xref paths are relative to its folder). fn host(s: &Session) -> Option { s.state().ok().and_then(|st| st.path.clone()) diff --git a/crates/engine/src/cmd/blocks/xref/load.rs b/crates/engine/src/cmd/blocks/xref/load.rs index b47aa00e0..981df918d 100644 --- a/crates/engine/src/cmd/blocks/xref/load.rs +++ b/crates/engine/src/cmd/blocks/xref/load.rs @@ -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(all: &[T], name: fn(&T) -> &str) -> HashSet { + 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() }); } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 49a7e7f33..3ddf8cf5b 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -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 { - fn add(ours: &mut Vec, 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 { 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 { - let mut out = BTreeMap::new(); - let mut taken: BTreeSet = 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) -> Entity { diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index 2a6a01f67..6148a0446 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -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; @@ -55,11 +55,11 @@ pub fn specs() -> Vec { .alias(&["printpdf", "pdfb"]) .params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match") .noundo(), - CommandSpec::new("attach", "Attach", |_, _| { - Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into())) + CommandSpec::new("attach", "Attach", |s, p| match str_param(p, "path").map(extension) { + Some(e) if super::image::is_image_ext(&e) => super::image::run_imageattach(s, p), + _ => Err(EngineError::Other("ATTACH is not available yet for this file: only raster images (PNG, JPEG, BMP, TIFF; see IMAGEATTACH) can be attached, PDF/DWF/DGN underlays can't be shown yet".into())), }) - .params("not available yet") - .noundo(), + .params("{path: image file, …} as IMAGEATTACH; other file types are not available yet"), CommandSpec::new("share", "Share", |_, _| { Err(EngineError::Other("SHARE is not available yet: there is no online service to share through; EXPORT or PUBLISH the drawing and send the file".into())) }) @@ -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> { /// 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(ours: &mut Vec, 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 { - let mut out = BTreeMap::new(); - let mut taken: BTreeSet = 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) -> Entity { @@ -680,6 +642,7 @@ mod tests { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 76a53da46..606ad4e28 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -70,6 +70,19 @@ pub fn entity_codec() -> Option<&'static EntityCodec> { /// COMPARE (drawing comparison). pub mod compare; +/// Decode a raster image file (PNG, JPEG, BMP, TIFF) to pixels: installed by the app with the +/// image reader, a lower layer. +pub type ImageHook = fn(&[u8]) -> std::result::Result; + +static IMAGE: OnceLock = OnceLock::new(); + +pub fn set_image_decoder(h: ImageHook) { + let _ = IMAGE.set(h); +} +pub fn image_decoder() -> Option { + IMAGE.get().copied() +} + /// Hand finished file bytes to the user where there is no file system (the browser: a download). /// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started. pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>; @@ -311,6 +324,7 @@ pub fn finish_open(s: &mut Session, f: ReadFile) -> Result { let ReadFile { drawing, binary_dxf, name, path } = f; let i = add_read(s, drawing, binary_dxf, &name, path); super::blocks::xref::after_open(s, i); + super::image::after_open(s, i); Ok(opened(s, i, true)) } @@ -320,6 +334,12 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option) -> Result Result { crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?; let i = open_bytes(s, &bytes, path, Some(path.to_string()))?; super::blocks::xref::after_open(s, i); + super::image::after_open(s, i); return Ok(opened(s, i, true)); } #[cfg(target_arch = "wasm32")] @@ -373,6 +394,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").unwrap_or("Drawing.dxf"); let i = open_bytes(s, &bytes, name, None)?; super::blocks::xref::after_open(s, i); + super::image::after_open(s, i); Ok(opened(s, i, false)) } diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index f3801049d..667a19a36 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -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(to: &mut Vec, 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 = 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); diff --git a/crates/engine/src/cmd/file/template.rs b/crates/engine/src/cmd/file/template.rs index 49ce172d0..11d6f2926 100644 --- a/crates/engine/src/cmd/file/template.rs +++ b/crates/engine/src/cmd/file/template.rs @@ -59,7 +59,8 @@ pub(super) fn run_new(s: &mut Session, p: &Value) -> Result> { && hi.y > lo.y { let center = cadcraft_geom::Vec2::new((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0); - st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6) }); + let twist = st.view().twist; + st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6), twist }); } Ok(Some(json!({ "index": i, "title": title, "template": path }))) } diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs new file mode 100644 index 000000000..daf46557b --- /dev/null +++ b/crates/engine/src/cmd/image.rs @@ -0,0 +1,353 @@ +//! Raster images (IMAGEATTACH, IMAGE). An IMAGE entity names its file; the pixels are decoded +//! when the drawing opens or the image is attached and kept with the open drawing +//! ([`Drawing::rasters`]), never in the file. An image whose file can't be found or read stays in +//! the drawing as a crossed-out frame. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + +use cadcraft_doc::{Drawing, EntityKind, Image, Raster}; +use cadcraft_geom::Vec2; +use serde_json::{Value, json}; + +use super::file::base64_decode; +use super::{CommandSpec, bad, f64_or, point_param, str_param}; +use crate::{Result, Session}; + +/// Most image files loaded per drawing. +const MAX_IMAGE_FILES: usize = 1000; +/// Largest image file read (bytes). +#[cfg(not(target_arch = "wasm32"))] +const MAX_IMAGE_BYTES: u64 = 256 << 20; +/// Most decoded pixel bytes kept per drawing; images past it stay unloaded. +const MAX_LOADED_BYTES: u64 = 2 << 30; +/// Most image files decoded at the same time when a drawing opens. +const MAX_LOADERS: usize = 4; +/// Longest image path taken from a caller. +const MAX_PATH: usize = 4096; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("imageattach", "Raster Image Reference...", run_imageattach).alias(&["iat"]).params( + "{path: PNG/JPEG/BMP/TIFF file (saved as given; a relative path is relative to this drawing's folder), data?: base64 file contents \ + (instead of reading `path`), at?: [x,y] lower-left corner (default 0,0), scale?: number (default 1: the image is one drawing unit \ + wide), width?: width in drawing units (instead of `scale`), rotation? (degrees), name? (default: the file name)} → {name, handle, \ + path, pixels: [w, h], size: [w, h]}", + ), + CommandSpec::new("image", "Raster Images", run_image) + .alias(&["im"]) + .params("{} → {images: [{name, path, foundAt, status: Loaded|Not Found|Not loaded, references, pixels?: [w, h]}]}") + .noundo(), + ] +} + +/// Whether `ext` (lower case, no dot) is an image file type IMAGEATTACH reads. +pub(crate) fn is_image_ext(ext: &str) -> bool { + matches!(ext, "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff") +} + +/// Every IMAGE entity of `d`: in model space, on the layouts and in the blocks. +fn images(d: &Drawing) -> impl Iterator { + let model = std::iter::once(&d.model); + let layouts = d.layouts.iter().map(|l| &l.entities); + let blocks = d.blocks.values().map(|b| &b.entities); + model.chain(layouts).chain(blocks).flat_map(|store| store.iter()).filter_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i), + _ => None, + }) +} + +/// The image files `d` names, each once, in the order they are first used. +fn paths(d: &Drawing) -> Vec { + let mut seen = BTreeSet::new(); + let mut paths = Vec::new(); + for i in images(d) { + if !i.path.trim().is_empty() && seen.insert(i.path.as_str()) { + paths.push(i.path.clone()); + } + } + paths +} + +/// The contents of the image file saved as `path` in a drawing at `host`. +fn read_file(path: &str, host: Option<&str>) -> std::result::Result, String> { + #[cfg(not(target_arch = "wasm32"))] + { + let file = super::blocks::xref::find_file(path, host).ok_or("file not found")?; + let len = std::fs::metadata(&file).map_err(|e| e.to_string())?.len(); + if len > MAX_IMAGE_BYTES { + return Err(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20)); + } + // Read no more than the limit: the file can grow or change after the check above. + let limit = usize::try_from(MAX_IMAGE_BYTES).unwrap_or(usize::MAX); + crate::capped_read::read_capped(&file, limit).map_err(|e| { + if e.kind() == std::io::ErrorKind::InvalidData { format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20) } else { e.to_string() } + }) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (path, host); + Err("files are not available on the web".into()) + } +} + +/// Decode an image file. A decoder that panics on a damaged file fails this image only: the +/// drawing it belongs to must still open. +fn decode(bytes: &[u8]) -> std::result::Result { + let hook = super::file::image_decoder().ok_or("image files can't be read in this build")?; + match std::panic::catch_unwind(|| hook(bytes)) { + Ok(r) => r, + Err(_) => Err("the file is damaged and can't be read".into()), + } +} + +fn bytes_of(r: &Raster) -> u64 { + u64::try_from(r.rgba().len()).unwrap_or(u64::MAX) +} + +/// Load the images of drawing `i`, just opened. Those that can't be loaded are reported and +/// stay crossed-out frames; opening never fails over an image. +pub(crate) fn after_open(s: &mut Session, i: usize) { + let Some(st) = s.docs.get_mut(i) else { return }; + let paths = paths(&st.doc); + if paths.is_empty() { + return; + } + let mut rasters = st.doc.rasters.clone(); + let notes = load_missing(&paths, st.path.as_deref(), &mut rasters); + if !rasters.is_empty() { + // Loading pixels is not a change to the drawing. A drawing counts as unchanged while + // it is the very one that was saved, so the saved one follows. + let unchanged = Arc::ptr_eq(&st.doc, &st.saved); + Arc::make_mut(&mut st.doc).rasters = rasters; + if unchanged { + st.saved = st.doc.clone(); + } + } + for n in notes { + s.echo(n); + } +} + +/// Read and decode into `rasters` the files of `paths` that are not in it yet, for a drawing at +/// `host`. Returns one message per file that could not be loaded. +fn load_missing(paths: &[String], host: Option<&str>, rasters: &mut cadcraft_doc::Rasters) -> Vec { + let mut notes = Vec::new(); + let mut missing: Vec<&str> = paths.iter().map(String::as_str).filter(|p| rasters.get(p).is_none()).collect(); + if missing.len() > MAX_IMAGE_FILES { + missing.truncate(MAX_IMAGE_FILES); + notes.push(format!("Images: only the first {MAX_IMAGE_FILES} image files are loaded")); + } + let decoded = decode_files(&missing, host); + // Kept in the order the drawing uses them, up to the memory limit. + let mut kept_bytes = 0u64; + for (path, result) in missing.iter().zip(decoded) { + match result { + Ok(r) => { + kept_bytes = kept_bytes.saturating_add(bytes_of(&r)); + if kept_bytes > MAX_LOADED_BYTES { + notes.push(over_limit(path)); + break; + } + rasters.insert(path, Arc::new(r)); + } + Err(e) => notes.push(format!("Image {path}: {e}")), + } + } + notes +} + +fn over_limit(path: &str) -> String { + format!("Image {path}: not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20) +} + +/// Read and decode the image files `paths` of a drawing at `host`, a few at a time (decoding is +/// what opening a drawing with large images waits for). The results are in the order of `paths`. +fn decode_files(paths: &[&str], host: Option<&str>) -> Vec> { + // Decoded bytes so far, shared by the loaders: past the limit no further file is started. + let decoded_bytes = AtomicU64::new(0); + let load = |path: &str| -> std::result::Result { + if decoded_bytes.load(Ordering::Relaxed) > MAX_LOADED_BYTES { + return Err(format!("not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20)); + } + let r = read_file(path, host).and_then(|bytes| decode(&bytes))?; + decoded_bytes.fetch_add(bytes_of(&r), Ordering::Relaxed); + Ok(r) + }; + let mut results: Vec>> = paths.iter().map(|_| None).collect(); + let loaders = loader_count(paths.len()); + if loaders > 1 { + // Each loader takes the next file nobody has started. + let next = AtomicUsize::new(0); + let work = || { + let mut done = Vec::new(); + loop { + let index = next.fetch_add(1, Ordering::Relaxed); + let Some(path) = paths.get(index) else { break }; + done.push((index, load(path))); + } + done + }; + let finished: Vec<_> = std::thread::scope(|scope| { + let started: Vec<_> = (0..loaders).filter_map(|_| std::thread::Builder::new().spawn_scoped(scope, work).ok()).collect(); + started.into_iter().filter_map(|loader| loader.join().ok()).flatten().collect() + }); + for (index, result) in finished { + if let Some(slot) = results.get_mut(index) { + *slot = Some(result); + } + } + } + // One loader, or files a loader that could not start or finish left behind. + results.into_iter().zip(paths).map(|(result, path)| result.unwrap_or_else(|| load(path))).collect() +} + +/// How many files are decoded at once: one per processor, at most [`MAX_LOADERS`] (each large +/// image needs several hundred megabytes while it is decoded) and no more than there are files. +fn loader_count(files: usize) -> usize { + #[cfg(not(target_arch = "wasm32"))] + let processors = std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(1); + #[cfg(target_arch = "wasm32")] + let processors = 1; + processors.min(MAX_LOADERS).min(files) +} + +/// The file name without folders and extension (either separator). +fn stem(path: &str) -> String { + let file = path.rsplit(['/', '\\']).next().unwrap_or(path); + match file.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem.to_string(), + _ => file.to_string(), + } +} + +/// The definition name for a new image of `path`: the name that file already has in `d`, else +/// `wanted`, numbered (`wanted_2`, `wanted_3`, …) when another file has taken it. +fn def_name(d: &Drawing, path: &str, wanted: &str) -> String { + if let Some(i) = images(d).find(|i| i.path == path && !i.name.is_empty()) { + return i.name.clone(); + } + // The names other files have, compared without regard to case. + let taken: BTreeSet = images(d).filter(|i| i.path != path).map(|i| i.name.to_ascii_lowercase()).collect(); + let free = |name: &str| !taken.contains(&name.to_ascii_lowercase()); + if free(wanted) { + return wanted.to_string(); + } + // One of these is free: there are more candidates than names taken. + let candidates = 2..taken.len().saturating_add(3); + candidates.map(|n| format!("{wanted}_{n}")).find(|name| free(name)).unwrap_or_else(|| wanted.to_string()) +} + +const ID: &str = "imageattach"; + +pub(crate) fn run_imageattach(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(ID, "`path` is required"))?.to_string(); + if path.len() > MAX_PATH { + return Err(bad(ID, "`path` is too long")); + } + let place = placement(p)?; + let raster = pixels(s, p, &path)?; + let (w, h) = (f64::from(raster.width()), f64::from(raster.height())); + // One pixel in drawing units, along the image's bottom edge (`u`) and its left edge (`v`). + let pixel = place.width / w; + let u = Vec2::from_angle(place.rotation) * pixel; + let v = u.perp(); + if !(pixel.is_normal() && (pixel * h).is_normal() && u.x.is_finite() && u.y.is_finite()) { + return Err(bad(ID, "the image would be too small or too large to draw")); + } + let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| stem(&path)); + let name = def_name(s.doc()?, &path, &wanted); + let image = Image { + insert: place.at.to3(0.0), + u: u.to3(0.0), + v: v.to3(0.0), + size: Vec2::new(w, h), + path: path.clone(), + name: name.clone(), + ..Image::default() + }; + s.doc_mut()?.rasters.insert(&path, raster); + let handle = s.add_entity(EntityKind::Image(image))?; + Ok(json!({ "name": name, "handle": handle.hex(), "path": path, "pixels": [w, h], "size": [place.width, pixel * h] })) +} + +/// Where and how large IMAGEATTACH puts the image. +struct Placement { + /// The lower-left corner. + at: Vec2, + /// Radians, counter-clockwise. + rotation: f64, + /// The image's width in drawing units. + width: f64, +} + +/// `at`, `rotation` and the width (`width`, else `scale`: an image is one unit wide at scale 1). +fn placement(p: &Value) -> Result { + let at = point_param(p, "at").unwrap_or(Vec2::ZERO); + if !(at.x.is_finite() && at.y.is_finite()) { + return Err(bad(ID, "`at` must be a point")); + } + let width = match p.get("width") { + Some(v) => v.as_f64().filter(|w| w.is_finite()).ok_or_else(|| bad(ID, "`width` must be a number"))?, + None => f64_or(p, "scale", 1.0), + }; + if !(width.is_finite() && width > 0.0) { + return Err(bad(ID, "`scale` and `width` must be greater than zero")); + } + Ok(Placement { at, rotation: f64_or(p, "rotation", 0.0).to_radians(), width }) +} + +/// The pixels of the image at `path`: decoded from `data` when the caller hands the file over, +/// else the ones already loaded for that path, else read from the file. +fn pixels(s: &Session, p: &Value, path: &str) -> Result> { + let bytes = match p.get("data") { + Some(data) => { + let text = data.as_str().ok_or_else(|| bad(ID, "`data` must be base64 text"))?; + base64_decode(text).ok_or_else(|| bad(ID, "`data` is not valid base64"))? + } + None => { + if let Some(loaded) = s.doc()?.rasters.get(path) { + return Ok(loaded.clone()); + } + let host = s.state()?.path.clone(); + read_file(path, host.as_deref()).map_err(|e| bad(ID, format!("{path}: {e}")))? + } + }; + Ok(Arc::new(decode(&bytes).map_err(|e| bad(ID, format!("{path}: {e}")))?)) +} + +fn run_image(s: &mut Session, _p: &Value) -> Result { + let host = s.state()?.path.clone(); + let d = s.doc()?; + // Per file: its definition name and how many images show it. + let mut uses: BTreeMap<&str, (&str, usize)> = BTreeMap::new(); + for i in images(d) { + uses.entry(i.path.as_str()).or_insert((i.name.as_str(), 0)).1 += 1; + } + let rows: Vec = paths(d) + .iter() + .map(|path| { + let (name, references) = uses.get(path.as_str()).copied().unwrap_or_default(); + let mut row = describe(d, path, host.as_deref()); + row["name"] = json!(name); + row["references"] = json!(references); + row + }) + .collect(); + Ok(json!({ "images": rows })) +} + +/// The state of image file `path` of drawing `d` (saved at `host`): where it is and whether its +/// pixels are loaded. +fn describe(d: &Drawing, path: &str, host: Option<&str>) -> Value { + let loaded = d.rasters.get(path); + let found_at = super::blocks::xref::find_file(path, host).map(|f| f.to_string_lossy().into_owned()); + let status = match (loaded, &found_at) { + (Some(_), _) => "Loaded", + (None, None) => "Not Found", + // The file is there but could not be read as an image. + (None, Some(_)) => "Not loaded", + }; + json!({ "path": path, "foundAt": found_at, "status": status, "pixels": loaded.map(|r| json!([r.width(), r.height()])) }) +} diff --git a/crates/engine/src/cmd/inquiry/regions.rs b/crates/engine/src/cmd/inquiry/regions.rs index 9e6b11a49..033d6e0c2 100644 --- a/crates/engine/src/cmd/inquiry/regions.rs +++ b/crates/engine/src/cmd/inquiry/regions.rs @@ -128,7 +128,9 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result> { } 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)) } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index c5fa89403..41c58febe 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -408,12 +408,24 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result { 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) { + let mut index: std::collections::HashMap = 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 { @@ -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 = (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(); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index ba5e711e4..f6b519a3c 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -524,6 +524,7 @@ fn viewport_json(h: Handle, v: &Viewport) -> Value { "height": v.height, "viewCenter": [v.view_center.x, v.view_center.y], "viewHeight": v.view_height, + "twist": v.view_twist().to_degrees(), "scale": if v.view_height > 0.0 { v.height / v.view_height } else { 0.0 }, "locked": v.locked, "on": v.on, diff --git a/crates/engine/src/cmd/layout/tests.rs b/crates/engine/src/cmd/layout/tests.rs index 4a631ccb0..e8733786f 100644 --- a/crates/engine/src/cmd/layout/tests.rs +++ b/crates/engine/src/cmd/layout/tests.rs @@ -283,7 +283,7 @@ fn mspace_edits_model_through_viewport() { s.cmdline("circle 10,10 3").unwrap(); assert_eq!(s.doc().unwrap().model.len(), before + 1); // Zooming changes the viewport's model view, not the sheet. - s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 }); + s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0, twist: v.twist }); let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1; assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9); assert_eq!(s.state().unwrap().paper_view(), pv); diff --git a/crates/engine/src/cmd/layout/viewports.rs b/crates/engine/src/cmd/layout/viewports.rs index e82d881fc..bc89d20fc 100644 --- a/crates/engine/src/cmd/layout/viewports.rs +++ b/crates/engine/src/cmd/layout/viewports.rs @@ -329,7 +329,7 @@ pub(super) fn run_vpclip(s: &mut Session, p: &Value) -> Result { let k = cur.view_height / cur.height.max(1e-12); let mut v = cur; let c = b.center(); - let view_center = v.view_center + (c - v.center.xy()) * k; + let view_center = v.paper_to_model(c); let view_height = b.height() * k; if view_center.is_finite() && view_height.is_finite() && view_height > 0.0 { v.view_center = view_center; diff --git a/crates/engine/src/cmd/layout/vplayer.rs b/crates/engine/src/cmd/layout/vplayer.rs index 0d2e7d9eb..eab260ce9 100644 --- a/crates/engine/src/cmd/layout/vplayer.rs +++ b/crates/engine/src/cmd/layout/vplayer.rs @@ -109,7 +109,7 @@ fn parse_color(t: &str) -> Result { fn viewport_at(s: &Session, p: Vec2) -> Option { let st = s.state().ok()?; let at = match st.active_viewport() { - Some((_, vp)) => vp.center.xy() + (p - vp.view_center) * (vp.height / vp.view_height), + Some((_, vp)) => vp.model_to_paper(p), None => p, }; super::viewports::viewport_at(s, at) diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs new file mode 100644 index 000000000..119f678b7 --- /dev/null +++ b/crates/engine/src/cmd/merge.rs @@ -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(ours: &mut Vec, theirs: &[T], name: fn(&T) -> &str) -> usize { + let mut have: HashSet = 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 { + let mut out = BTreeMap::new(); + let mut taken: BTreeSet = 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 = 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::>(), ["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::().ok()).is_some_and(|i| (5001..=10000).contains(&i)))); + } +} diff --git a/crates/engine/src/cmd/mleader_content.rs b/crates/engine/src/cmd/mleader_content.rs index 3d96c9baa..3b413724e 100644 --- a/crates/engine/src/cmd/mleader_content.rs +++ b/crates/engine/src/cmd/mleader_content.rs @@ -54,6 +54,7 @@ pub(super) fn apply(s: &Session, k: EntityKind, c: &Content) -> Result Option { + 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> { + 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::>>()? + } + _ => 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> { 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 Result> { + // 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( diff --git a/crates/engine/src/cmd/modify/trim.rs b/crates/engine/src/cmd/modify/trim.rs index 66eab0be8..9f8e856dd 100644 --- a/crates/engine/src/cmd/modify/trim.rs +++ b/crates/engine/src/cmd/modify/trim.rs @@ -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)?, }; diff --git a/crates/engine/src/cmd/modify2/selop.rs b/crates/engine/src/cmd/modify2/selop.rs index 53939d4b6..607edd0f1 100644 --- a/crates/engine/src/cmd/modify2/selop.rs +++ b/crates/engine/src/cmd/modify2/selop.rs @@ -182,8 +182,7 @@ fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option) }; let m = match viewport_of(s, &layout, vp) { Some(v) => { - let k = v.view_height / v.height; - let to_paper = Mat3::translate(v.center.xy()).then_before(Mat3::scale(1.0 / k, 1.0 / k)).then_before(Mat3::translate(-v.view_center)); + let to_paper = v.model_to_paper_matrix(); if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper } } None => Mat3::IDENTITY, diff --git a/crates/engine/src/cmd/modify2/tests.rs b/crates/engine/src/cmd/modify2/tests.rs index 98b2f40d9..5f65026a9 100644 --- a/crates/engine/src/cmd/modify2/tests.rs +++ b/crates/engine/src/cmd/modify2/tests.rs @@ -411,6 +411,7 @@ fn chspace_json_and_command_line() { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }; s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Default::default(), EntityKind::Viewport(vp)).unwrap(); let r = s.execute("chspace", &json!({"handles": [l]})).unwrap(); diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs index 97678ae24..8faab4f9c 100644 --- a/crates/engine/src/cmd/utility.rs +++ b/crates/engine/src/cmd/utility.rs @@ -26,7 +26,10 @@ pub fn specs() -> Vec { .params("{mode: point|distance|angle|intersection, points: [[x,y],...]} → {value} | {point}") .noundo() .interactive(|_| Ok(Box::::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 { // ---------------- 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 = 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 { 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> = + 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 = 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 = 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 = 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 { } 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); + } } diff --git a/crates/engine/src/cmd/utility/calc.rs b/crates/engine/src/cmd/utility/calc.rs index 0c0d29b32..bcba8052a 100644 --- a/crates/engine/src/cmd/utility/calc.rs +++ b/crates/engine/src/cmd/utility/calc.rs @@ -1,6 +1,8 @@ //! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d { s: &'a [u8], i: usize, vars: &'a Map, + /// `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, 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, name: &str) -> Option { - let v = vars.get(name).or_else(|| vars.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?; +fn var(vars: &Map, folded: &HashMap, name: &str) -> Option { + 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) -> Result { 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)))); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 4586cd988..7161308df 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -1,5 +1,5 @@ -//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), layout -//! switching. +//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), the view +//! twist (DVIEW TWist), layout switching. use cadcraft_doc::{Space, entity_bounds}; use cadcraft_geom::{Bounds2, Vec2}; @@ -10,6 +10,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, f64_req, ok, point_param, point_r use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View}; +mod dview; mod named_views; mod vports; @@ -95,10 +96,13 @@ pub fn specs() -> Vec { .alias(&["r"]) .noundo() .transparent(), - CommandSpec::new("view.set", "Set View", run_view_set).params("{center: [x,y], height}").noundo(), + dview::spec(), + CommandSpec::new("view.set", "Set View", run_view_set) + .params("{center: [x,y], height, twist?: degrees (default: keep the current twist; in a layout viewport it is the viewport's twist, an undoable edit; not in Paper space)}") + .noundo(), CommandSpec::new("view.get", "Get View", |s, _| { let v = s.state()?.view(); - Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "viewportPx": [s.viewport_px.0, s.viewport_px.1]})) + Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "twist": v.twist.to_degrees(), "viewportPx": [s.viewport_px.0, s.viewport_px.1]})) }) .noundo(), CommandSpec::new("layout.set", "Switch Layout", run_layout_set).params("{name: \"Model\" | layout name}").noundo(), @@ -120,8 +124,11 @@ fn fit(s: &mut Session, b: Bounds2) -> Result<()> { let (w, h) = s.viewport_px; let st = s.state_mut()?; let aspect = st.zoom_aspect(w / h.max(1.0)); - let height = b.height().max(b.width() / aspect.max(1e-6)).max(1e-9); - st.set_zoom_frame(View { center: b.center(), height }); + // A twisted view fits the box the bounds make on screen. + let twist = st.zoom_frame().twist; + let (center, bw, bh) = View::twisted_box(b, twist); + let height = bh.max(bw / aspect.max(1e-6)).max(1e-9); + st.set_zoom_frame(View { center, height, twist }); Ok(()) } @@ -150,18 +157,18 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result { let f = if mode == "in" { 2.0 } else { 0.5 }; let st = s.state_mut()?; let v = st.zoom_frame(); - st.set_zoom_frame(View { center: v.center, height: v.height / f }); + st.set_zoom_frame(View { height: v.height / f, ..v }); } "center" | "c" | "dynamic" | "d" => { let c = point_req("zoom", p, "center")?; - let cur = s.state()?.zoom_frame().height; - let h = if p.get("factor").is_some() { factor_height(s, p, cur)? } else { f64_or(p, "height", cur) }; - s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9) }); + let cur = s.state()?.zoom_frame(); + let h = if p.get("factor").is_some() { factor_height(s, p, cur.height)? } else { f64_or(p, "height", cur.height) }; + s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9), ..cur }); } "scale" | "s" => { let v = s.state()?.zoom_frame(); let height = factor_height(s, p, v.height)?; - s.state_mut()?.set_zoom_frame(View { center: v.center, height }); + s.state_mut()?.set_zoom_frame(View { height, ..v }); } "realtime" => { let v = s.state()?.zoom_frame(); @@ -169,7 +176,7 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result { if f <= 0.0 { return Err(bad("zoom", "factor must be positive")); } - s.state_mut()?.set_zoom_frame(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) }); + s.state_mut()?.set_zoom_frame(View { height: (v.height / f).clamp(1e-9, 1e12), ..v }); } "object" | "o" => { let hs = targets(s, p)?; @@ -242,7 +249,8 @@ fn pan_frac(s: &mut Session, f: Vec2) -> Result { let st = s.state_mut()?; let v = st.view(); let ww = v.height * w / h.max(1.0); - st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height), height: v.height }); + // Screen directions: in a twisted view the drawing is turned by the twist. + st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height).rotate(-v.twist), ..v }); ok() } @@ -258,7 +266,7 @@ fn run_pan(s: &mut Session, p: &Value) -> Result { let b = point_req("pan", p, "to")?; v.center - (b - a) }; - st.set_view(View { center: c, height: v.height }); + st.set_view(View { center: c, ..v }); ok() } @@ -268,9 +276,33 @@ fn run_view_set(s: &mut Session, p: &Value) -> Result { if h <= 0.0 { return Err(bad("view.set", "height must be positive")); } - if !s.state_mut()?.set_view(View { center: c, height: h }) { + let twist = match p.get("twist") { + Some(t) => t.as_f64().filter(|t| t.is_finite()).ok_or_else(|| bad("view.set", "twist must be a number of degrees"))?.to_radians(), + None => s.state()?.view().twist, + }; + // As DVIEW TWist: the Model tab's view or the active layout viewport turns; a layout sheet + // (paper space) is never twisted. + let target = match dview::target(s) { + Ok(t) => Some(t), + Err(_) if twist == 0.0 => None, + Err(e) => return Err(e), + }; + let before = (s.state()?.doc.clone(), s.state()?.selection.clone()); + let twist = match target { + Some(t @ dview::Target::Viewport(_)) => { + dview::set_twist(s, t, twist)?; + s.state()?.view().twist + } + Some(dview::Target::Model) => cadcraft_geom::norm_angle(twist), + None => 0.0, + }; + if !s.state_mut()?.set_view(View { center: c, height: h, twist }) { + s.state_mut()?.doc = before.0; return Err(bad("view.set", "the view is too large to show. Nothing was changed")); } + if let Some(dview::Target::Viewport(h)) = target { + dview::record_viewport_edit(s, before, h)?; + } ok() } @@ -294,7 +326,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result { let page = st.doc.layout(n).map(|l| l.page.clone()).unwrap_or_default(); let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) }; let unit = if st.doc.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 1.0 / 25.4 }; - st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15 }); + st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15, twist: 0.0 }); } s.touch(); ok() @@ -441,7 +473,7 @@ impl Interactive for ZoomM { if st.view_history.last() == Some(&b.screen) { st.view_history.pop(); } - st.set_zoom_frame(View { center: b.center, height: b.height }); + st.set_zoom_frame(View { center: b.center, height: b.height, twist: b.frame.twist }); return Ok(Step::Done); } (ZoomStage::Dynamic(mut b), Input::Point(p)) => { @@ -565,7 +597,7 @@ impl Session { let f = 2f64.powf((-dy / half).clamp(-16.0, 16.0)); let st = self.state_mut()?; let v = st.view(); - st.set_view_quiet(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) }); + st.set_view_quiet(View { height: (v.height / f).clamp(1e-9, 1e12), ..v }); self.touch(); Ok(()) } diff --git a/crates/engine/src/cmd/view/dview.rs b/crates/engine/src/cmd/view/dview.rs new file mode 100644 index 000000000..3133fd6fa --- /dev/null +++ b/crates/engine/src/cmd/view/dview.rs @@ -0,0 +1,441 @@ +//! DVIEW: the dynamic view, on CADCraft's 2D plan views. +//! +//! It starts with "Select objects or :": the objects +//! picked are the preview set, and Enter with nothing picked previews the DVIEWBLOCK block (the +//! drawing's own block of that name, else a built-in sample house). While DVIEW runs only the +//! preview set is drawn ([`Interactive::display_only`]); the whole drawing is regenerated when it +//! ends. Then it repeats "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/ +//! Off/Undo]:" until Enter. TWist, PAn, Zoom, Off and Undo work; the options that need a 3D +//! camera say they are not available yet and stay at the option prompt. +//! +//! DVIEW works on the Model tab's view, or on the active (double-clicked) layout viewport; on a +//! layout with no active viewport it is not available. A Model tab twist is a view change like +//! ZOOM (view history, not undo). A layout viewport's twist belongs to the viewport entity, so a +//! DVIEW that changed it is one undo step. + +use std::sync::Arc; + +use cadcraft_doc::{Entity, EntityKind, Handle, Insert, Line, Space}; +use cadcraft_geom::{Vec2, Vec3}; +use serde_json::{Value, json}; + +use super::super::machines::{SelOutcome, SelectPhase, number}; +use super::super::{CommandSpec, bad, f64_req}; +use crate::{Accept, DisplayOnly, EngineError, Input, Interactive, Prompt, Result, Session, Snapshot, Step, View}; + +/// What DVIEW (and a view twist asked for anywhere) says on a layout with no active viewport. +pub(super) const PAPER_SPACE: &str = "This command is not available in Paper space."; + +/// The option prompt's keywords, in prompt order. +const OPTIONS: &[&str] = &["CAmera", "TArget", "Distance", "POints", "PAn", "Zoom", "TWist", "CLip", "Hide", "Off", "Undo"]; + +/// The block DVIEW previews when nothing is selected. +const DVIEWBLOCK: &str = "DVIEWBLOCK"; + +pub(super) fn spec() -> CommandSpec { + CommandSpec::new("dview", "DView", run_dview) + .alias(&["dv"]) + .params("{twist: degrees counterclockwise} (DVIEW TWist: the Model tab's view twist, or the active layout viewport's, an undoable edit; not in Paper space)") + .noundo() + .interactive(|s| { + // Refused as it starts on a layout with no active viewport. + target(s)?; + Ok(Box::new(DviewM::default())) + }) +} + +/// The view DVIEW changes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Target { + /// The Model tab's view. + Model, + /// The active (MSPACE) layout viewport: its own view twist. + Viewport(Handle), +} + +/// The view DVIEW changes: the active layout viewport, else the Model tab's view. A layout with +/// no active viewport has none. +pub(super) fn target(s: &Session) -> Result { + let st = s.state()?; + if let Some((h, _)) = st.active_viewport() { + return Ok(Target::Viewport(h)); + } + if st.space == Space::Model { + return Ok(Target::Model); + } + Err(EngineError::Other(PAPER_SPACE.into())) +} + +/// A twist kept as files hold it: [0, 2π). +fn normal(twist: f64) -> f64 { + cadcraft_geom::norm_angle(twist) +} + +/// The active viewport's twist, if it is `h`. +fn viewport_twist(s: &Session, h: Handle) -> Option { + s.state().ok()?.active_viewport().filter(|(vh, _)| *vh == h).map(|(_, vp)| vp.view_twist()) +} + +/// Turn `target`'s view to `twist` radians. The Model tab's twist is a view change (view +/// history, like ZOOM); a viewport's is a change to the viewport entity, which the caller +/// records as an undo step ([`record_viewport_edit`]). +pub(super) fn set_twist(s: &mut Session, target: Target, twist: f64) -> Result<()> { + if !twist.is_finite() { + return Err(bad("dview", "the twist must be a finite angle")); + } + let twist = normal(twist); + match target { + Target::Model => { + let st = s.state_mut()?; + let v = st.view(); + st.set_view(View { twist, ..v }); + } + Target::Viewport(h) => { + if viewport_twist(s, h) == Some(twist) { + return Ok(()); + } + // A locked viewport keeps its view, the twist with it. + if s.state()?.active_viewport().is_some_and(|(vh, vp)| vh == h && vp.locked) { + return Err(EngineError::Other("The viewport is locked.".into())); + } + let space = s.state()?.space.clone(); + let d = s.doc_mut()?; + if let Some(store) = d.space_mut(&space) { + store.modify(h, |e| { + if let EntityKind::Viewport(vp) = &mut e.kind { + vp.twist = twist; + } + }); + } + } + } + Ok(()) +} + +/// Record a layout viewport's twist change as one undo step, `before` being the drawing (and +/// selection) before it. Nothing when the twist did not change, and nothing inside a LISP routine +/// (its own single step holds it). +pub(super) fn record_viewport_edit(s: &mut Session, before: (Arc, Vec), h: Handle) -> Result<()> { + let old = before.0.entity(h).and_then(|e| match &e.kind { + EntityKind::Viewport(vp) => Some(vp.view_twist()), + _ => None, + }); + if old == viewport_twist(s, h) || s.lisp_groups_undo() { + return Ok(()); + } + let st = s.state_mut()?; + if !Arc::ptr_eq(&before.0, &st.doc) { + st.push_undo(Snapshot { label: "DView".into(), doc: before.0, selection: before.1 }); + st.revision += 1; + } + Ok(()) +} + +/// The drawing and selection now, for [`record_viewport_edit`]. +fn snapshot(s: &Session) -> Result<(Arc, Vec)> { + let st = s.state()?; + Ok((st.doc.clone(), st.selection.clone())) +} + +fn run_dview(s: &mut Session, p: &Value) -> Result { + let t = target(s)?; + let deg = f64_req("dview", p, "twist")?; + let before = snapshot(s)?; + set_twist(s, t, deg.to_radians())?; + if let Target::Viewport(h) = t { + record_viewport_edit(s, before, h)?; + } + let v = s.state()?.view(); + Ok(json!({ "twist": v.twist.to_degrees() })) +} + +/// The built-in DVIEWBLOCK: a house front in the unit square, origin at the lower-left corner +/// (the size and origin a DVIEWBLOCK of one's own is expected to have). CADCraft's own drawing. +fn sample_house() -> Vec<(Vec2, Vec2)> { + let p = Vec2::new; + let mut lines = Vec::new(); + let mut poly = |pts: &[Vec2], closed: bool| { + for w in pts.windows(2) { + lines.push((w[0], w[1])); + } + if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) { + lines.push((*a, *b)); + } + }; + // Walls. + poly(&[p(0.1, 0.0), p(0.9, 0.0), p(0.9, 0.6), p(0.1, 0.6)], true); + // Roof with eaves, and a chimney on its right slope. + poly(&[p(0.0, 0.55), p(0.5, 1.0), p(1.0, 0.55)], false); + poly(&[p(0.7, 0.82), p(0.7, 0.95), p(0.8, 0.95), p(0.8, 0.73)], false); + // Door. + poly(&[p(0.42, 0.0), p(0.42, 0.35), p(0.58, 0.35), p(0.58, 0.0)], false); + // Two windows with their bars. + for x in [0.17, 0.67] { + poly(&[p(x, 0.25), p(x + 0.16, 0.25), p(x + 0.16, 0.45), p(x, 0.45)], true); + poly(&[p(x + 0.08, 0.25), p(x + 0.08, 0.45)], false); + poly(&[p(x, 0.35), p(x + 0.16, 0.35)], false); + } + lines +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Stage { + #[default] + Select, + Option, + Twist, + PanBase, + PanSecond(Vec2), + Zoom, +} + +/// DVIEW, interactive. +#[derive(Default)] +struct DviewM { + stage: Stage, + sel: SelectPhase, + /// The preview set; empty with `sample` set: the DVIEWBLOCK. + preview: Vec, + /// Where the DVIEWBLOCK preview stands: its lower-left corner and size (drawing units), fixed + /// as the options start so that PAn and Zoom move the view, not the house. + sample: Option<(Vec2, f64)>, + /// The views before each DVIEW action, for Undo. + undo: Vec, + /// What DVIEW works on, and the drawing as it started (a viewport's twist change is one + /// undo step). + target: Option, + before: Option<(Arc, Vec)>, +} + +impl DviewM { + fn target(&self, s: &Session) -> Result { + match self.target { + Some(t) => Ok(t), + None => target(s), + } + } + /// Start the options with this preview set (empty: the DVIEWBLOCK). + fn options(&mut self, s: &mut Session, picked: Vec) -> Result { + // Objects picked at the prompt don't stay selected. + s.set_selection(Vec::new()); + if picked.is_empty() { + let v = s.state()?.view(); + let size = v.height * 0.5; + self.sample = Some((v.center - Vec2::new(size, size) * 0.5, size)); + } + self.preview = picked; + self.stage = Stage::Option; + Ok(Step::Continue) + } + /// Show view `v` (centre, height and twist) on the target. + fn show(&self, s: &mut Session, v: View) -> Result<()> { + let t = self.target(s)?; + let before = s.state()?.doc.clone(); + let twist = normal(v.twist); + if let Target::Viewport(_) = t { + // The twist is the viewport's; the view inside it then moves as asked. + set_twist(s, t, twist)?; + } + if !s.state_mut()?.set_view(View { twist, ..v }) { + s.state_mut()?.doc = before; + return Err(EngineError::Other("The view is too large to show.".into())); + } + Ok(()) + } + /// Apply a view as one DVIEW action (Undo takes it back). + fn change(&mut self, s: &mut Session, f: impl FnOnce(View) -> View) -> Result<()> { + let v = s.state()?.view(); + self.show(s, f(v))?; + self.undo.push(v); + Ok(()) + } + /// The command ends: a viewport twist change becomes one undo step, and the whole drawing is + /// regenerated. + fn end(&mut self, s: &mut Session) -> Result<()> { + if let (Some(Target::Viewport(h)), Some(before)) = (self.target, self.before.take()) { + record_viewport_edit(s, before, h)?; + } + s.touch(); + Ok(()) + } +} + +impl Interactive for DviewM { + fn name(&self) -> &'static str { + "DVIEW" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.target = Some(target(s)?); + self.before = Some(snapshot(s)?); + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match self.stage { + Stage::Select if self.sel.removing => self.sel.prompt(), + Stage::Select => Prompt::new("Select objects or", Accept::SELECT).default("use DVIEWBLOCK"), + Stage::Option => Prompt::new("Enter option", Accept::TEXT).kw(OPTIONS), + Stage::Twist => { + let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); + let cur = s.state().map(|st| st.view().twist).unwrap_or(0.0); + // Offered as it would be typed: clockwise is positive when ANGDIR is 1. + let cur = if s.angle_settings().clockwise { -cur } else { cur }; + let center = s.state().map(|st| st.view().center).ok(); + Prompt::new("Specify view twist angle", Accept::POINT_OR_NUMBER).default(crate::units::format_angle(cur, au, ap)).base_opt(center) + } + Stage::PanBase => Prompt::new("Specify displacement base point", Accept::POINT), + Stage::PanSecond(b) => Prompt::new("Specify second point", Accept::POINT).base(b), + Stage::Zoom => Prompt::new("Specify zoom scale factor", Accept::NUMBER).default("1"), + } + } + fn cancelled(&mut self, s: &mut Session) { + if let Err(e) = self.end(s) { + s.echo(e.to_string()); + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.stage { + Stage::Select => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => self.options(s, Vec::new()), + SelOutcome::Done(hs) => self.options(s, hs), + }, + Stage::Option => { + let word = match &i { + Input::Enter => { + self.end(s)?; + return Ok(Step::Done); + } + Input::Keyword(t) | Input::Text(t) => self.prompt(s).match_keyword(t), + _ => None, + }; + match word.as_deref() { + Some("TWist") => self.stage = Stage::Twist, + Some("PAn") => self.stage = Stage::PanBase, + Some("Zoom") => self.stage = Stage::Zoom, + // Perspective is never on in a 2D view: there is nothing to turn off. + Some("Off") => {} + Some("Undo") => { + if let Some(v) = self.undo.pop() { + self.show(s, v)?; + } + } + // CAmera, TArget, Distance, POints, CLip and Hide need a 3D view. + Some(other) => s.echo(format!("DVIEW {other} is not available yet.")), + None => return Err(EngineError::Other("Invalid option keyword.".into())), + } + Ok(Step::Continue) + } + Stage::Twist => { + let twist = match &i { + Input::Enter => None, + Input::Point(p) => { + let c = s.state()?.view().center; + let d = *p - c; + if d.len() < 1e-12 { + return Err(EngineError::Other("Requires a valid angle.".into())); + } + Some(d.y.atan2(d.x)) + } + Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None, + Input::Text(t) | Input::Keyword(t) => { + Some(s.angle_settings().rotation(t).ok_or_else(|| EngineError::Other("Requires a valid angle.".into()))?) + } + _ => return Ok(Step::Continue), + }; + if let Some(a) = twist { + self.change(s, |v| View { twist: normal(a), ..v })?; + } + self.stage = Stage::Option; + Ok(Step::Continue) + } + Stage::PanBase => match i { + Input::Point(p) => { + self.stage = Stage::PanSecond(p); + Ok(Step::Continue) + } + Input::Enter => { + self.stage = Stage::Option; + Ok(Step::Continue) + } + _ => Err(EngineError::Other("Invalid point.".into())), + }, + Stage::PanSecond(b) => match i { + Input::Point(p) => { + self.change(s, |v| View { center: v.center - (p - b), ..v })?; + self.stage = Stage::Option; + Ok(Step::Continue) + } + Input::Enter => { + self.stage = Stage::Option; + Ok(Step::Continue) + } + _ => Err(EngineError::Other("Invalid point.".into())), + }, + Stage::Zoom => { + let f = match &i { + Input::Enter => None, + Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None, + Input::Text(t) | Input::Keyword(t) => match number(t) { + Some(f) if f.is_finite() && f > 0.0 => Some(f), + _ => return Err(EngineError::Other("Requires a positive number.".into())), + }, + _ => return Ok(Step::Continue), + }; + if let Some(f) = f.filter(|f| *f != 1.0) { + self.change(s, |v| View { height: v.height / f, ..v })?; + } + self.stage = Stage::Option; + Ok(Step::Continue) + } + } + } + fn display_only(&self, s: &Session) -> Option { + if self.stage == Stage::Select { + return None; + } + let mut extra = Vec::new(); + if let Some((corner, size)) = self.sample { + let common = s.current_common(); + let d = s.doc().ok()?; + if d.block(DVIEWBLOCK).is_some() { + // The drawing's own DVIEWBLOCK, defined in a 1 by 1 unit area. + let ins = Insert { + block: DVIEWBLOCK.into(), + insert: corner.to3(0.0), + scale: Vec3::new(size, size, size), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + clip: None, + }; + extra.push(Entity { handle: Handle(0), common, kind: EntityKind::Insert(ins) }); + } else { + extra.extend(sample_house().into_iter().map(|(a, b)| Entity { + handle: Handle(0), + common: common.clone(), + kind: EntityKind::Line(Line { a: (corner + a * size).to3(0.0), b: (corner + b * size).to3(0.0) }), + })); + } + } + Some(DisplayOnly { handles: self.preview.clone(), extra }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_sample_house_fits_the_unit_square() { + let lines = sample_house(); + assert!(lines.len() > 10); + for (a, b) in lines { + for q in [a, b] { + assert!((0.0..=1.0).contains(&q.x) && (0.0..=1.0).contains(&q.y), "{q:?}"); + } + } + } +} diff --git a/crates/engine/src/cmd/view/named_views.rs b/crates/engine/src/cmd/view/named_views.rs index f88d240db..b7f7a90d0 100644 --- a/crates/engine/src/cmd/view/named_views.rs +++ b/crates/engine/src/cmd/view/named_views.rs @@ -110,15 +110,25 @@ fn save(s: &mut Session, name: &str, window: Option, snapshot: bool) -> (Space::Paper(l), false) => Some(l.clone()), _ => None, }; + // The view this saves: the viewport's model view inside an MSPACE viewport, else the screen. + let frame = if in_viewport { st.zoom_frame() } else { st.view() }; + // The view twist goes with the view (paper space has none). + let twist = if layout.is_some() { 0.0 } else { frame.twist }; let (center, height, width) = match window { Some(b) => { if !(b.width() > 1e-12 && b.height() > 1e-12 && b.min.is_finite() && b.max.is_finite()) { return Err(EngineError::Other("The window has no area.".into())); } - (b.center(), b.height(), b.width()) + // In a twisted view the window's box on screen is larger than its world box. + let (center, width, height) = crate::View::twisted_box(b, twist); + // Turning corners near the number limit can overflow: save no view that can't be restored. + if !(center.is_finite() && width.is_finite() && height.is_finite() && width > 1e-12 && height > 1e-12) { + return Err(EngineError::Other("The window is too large for a twisted view.".into())); + } + (center, height, width) } None => { - let f = if in_viewport { st.zoom_frame() } else { st.view() }; + let f = frame; let aspect = if in_viewport { st.zoom_aspect(w / h.max(1.0)) } else { w / h.max(1.0) }; (f.center, f.height, f.height * aspect) } @@ -139,7 +149,7 @@ fn save(s: &mut Session, name: &str, window: Option, snapshot: bool) -> } d.views.retain(|v| !v.name.eq_ignore_ascii_case(old)); } - d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist: 0.0, layout }); + d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist, layout }); let msg = if replaced.is_some() { format!("View \"{name}\" replaced.") } else { format!("View \"{name}\" saved.") }; Ok(json!({ "name": name, "center": [center.x, center.y], "height": height, "width": width, "message": msg })) } @@ -168,7 +178,7 @@ fn restore(s: &mut Session, name: &str) -> Result { let st = s.state_mut()?; st.mspace = None; let height = v.height.max(v.width / (w / h.max(1.0)).max(1e-9)); - st.set_view(View { center: v.center, height }); + st.set_view(View { center: v.center, height, twist: 0.0 }); } None => { // A model space view: into the active MSPACE viewport, else on the Model tab. @@ -177,21 +187,18 @@ fn restore(s: &mut Session, name: &str) -> Result { } let (w, h) = s.viewport_px; let st = s.state_mut()?; + // In an unlocked MSPACE viewport the saved twist turns the viewport (an edit of the + // viewport, recorded as the command's undo step). let aspect = st.zoom_aspect(w / h.max(1.0)).max(1e-9); - st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect) }); + let twist = cadcraft_geom::norm_angle(v.twist); + st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect), twist }); } } let mut msg = format!("View \"{}\" restored.", v.name); 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.")), } } diff --git a/crates/engine/src/cmd/view/vports.rs b/crates/engine/src/cmd/view/vports.rs index f040133fc..412ca08de 100644 --- a/crates/engine/src/cmd/view/vports.rs +++ b/crates/engine/src/cmd/view/vports.rs @@ -104,7 +104,7 @@ fn rect(t: &VportTile) -> Rect { } fn view_of(t: &VportTile) -> View { - View { center: t.center, height: t.height } + View { center: t.center, height: t.height, twist: if t.twist.is_finite() { t.twist } else { 0.0 } } } fn active_config(d: &Drawing) -> Option<&VportConfig> { @@ -117,7 +117,7 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec, usize) { let now = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v).unwrap_or_default(); let Some(cfg) = active_config(&st.doc) else { let aspect = px.0 / px.1.max(1.0); - return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect }], 0); + return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect, twist: now.twist }], 0); }; let mut tiles = cfg.tiles.clone(); let synced = @@ -125,11 +125,11 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec, usize) { let active = if synced { st.tiles.active.min(tiles.len() - 1) } else { 0 }; if synced { for (t, v) in tiles.iter_mut().zip(&st.tiles.views) { - (t.center, t.height) = (v.center, v.height); + (t.center, t.height, t.twist) = (v.center, v.height, v.twist); } } if let Some(t) = tiles.get_mut(active) { - (t.center, t.height) = (now.center, now.height); + (t.center, t.height, t.twist) = (now.center, now.height, now.twist); } // Screen aspects from the current tile's size in pixels. if let Some(cur) = tiles.get(active).copied() { @@ -171,7 +171,7 @@ fn summary(s: &Session) -> Result { let rows: Vec = tiles .iter() .enumerate() - .map(|(i, t)| json!({ "index": i + 1, "min": [t.min.x, t.min.y], "max": [t.max.x, t.max.y], "center": [t.center.x, t.center.y], "height": t.height, "current": i == active })) + .map(|(i, t)| json!({ "index": i + 1, "min": [t.min.x, t.min.y], "max": [t.max.x, t.max.y], "center": [t.center.x, t.center.y], "height": t.height, "twist": t.twist.to_degrees(), "current": i == active })) .collect(); Ok(json!({ "tiles": rows })) } diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 31d23a94e..f3862f90a 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,5 +1,5 @@ use super::view::{ModelTiles, View, usable_view}; -use super::{Arc, Drawing, Handle, Space, blockedit, undoctl}; +use super::{Arc, Drawing, Handle, Space, Vec2, blockedit, undoctl}; #[derive(Clone, Debug)] pub struct Snapshot { @@ -80,6 +80,7 @@ impl DocState { pub(crate) fn swap_in(&mut self, snap: Snapshot) -> Snapshot { let cur = Snapshot { label: snap.label, doc: std::mem::replace(&mut self.doc, snap.doc), selection: std::mem::take(&mut self.selection) }; self.follow_layout(&cur.doc); + self.reconcile_untiled_twist(); self.revision += 1; cur } @@ -135,7 +136,8 @@ impl DocState { match self.active_viewport() { Some((_, vp)) => { let k = vp.view_height / vp.height; - View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k } + // The viewport's twist turns the model view inside it. + View { center: vp.paper_to_model(pv.center), height: pv.height * k, twist: vp.view_twist() } } None => pv, } @@ -150,16 +152,18 @@ impl DocState { if vp.locked { // A locked viewport keeps its scale: pan/zoom the sheet instead. let k0 = vp.view_height / vp.height; - let pc = vp.center.xy() + (v.center - vp.view_center) / k0; - let paper = View { center: pc, height: v.height / k0 }; + let pc = vp.model_to_paper(v.center); + let paper = View { center: pc, height: v.height / k0, twist: pv.twist }; if !usable_view(&paper) { return false; } self.put_paper_view(paper); } else { - let center = v.center - (pv.center - vp.center.xy()) * k; + // `v`'s twist is the one the viewport shows (a non-finite one keeps its own). + let twist = if v.twist.is_finite() { v.twist } else { vp.view_twist() }; + let center = v.center - ((pv.center - vp.center.xy()) * k).rotate(-twist); let height = vp.height * k; - if !usable_view(&View { center, height }) { + if !usable_view(&View { center, height, twist }) { return false; } let doc = Arc::make_mut(&mut self.doc); @@ -168,6 +172,7 @@ impl DocState { if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind { x.view_center = center; x.view_height = height; + x.twist = twist; } }); } @@ -182,6 +187,36 @@ impl DocState { Some((_, slot)) => *slot = v, None => self.views.push((self.space.clone(), v)), } + if self.space == Space::Model { + self.keep_untiled_twist(v); + } + } + /// An untiled Model tab keeps its view twist in the drawing as a lone `*Active` VPORT tile + /// (written as that record's group 51 and read back), so it survives save and reopen. Only a + /// change of twist touches the drawing; an untwisted view keeps no record. + /// After undo or redo has put back another drawing: its untiled-twist record follows the + /// Model tab's live view again (views are not part of the drawing's undo). + pub(crate) fn reconcile_untiled_twist(&mut self) { + if let Some(v) = self.views.iter().find(|(s, _)| *s == Space::Model).map(|(_, v)| *v) { + self.keep_untiled_twist(v); + } + } + fn keep_untiled_twist(&mut self, v: View) { + use cadcraft_doc::{VportConfig, VportTile}; + let twist = cadcraft_geom::norm_angle(v.twist); + let active = self.doc.vports.iter().find(|c| c.is_active()); + if active.is_some_and(|c| c.tiles.len() > 1) { + return; + } + if active.and_then(|c| c.tiles.first()).map_or(0.0, |t| t.twist) == twist { + return; + } + let d = Arc::make_mut(&mut self.doc); + d.vports.retain(|c| !c.is_active()); + if twist != 0.0 { + let tile = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: v.center, height: v.height, aspect: 1.0, twist }; + d.vports.insert(0, VportConfig { name: VportConfig::ACTIVE.into(), tiles: vec![tile] }); + } } /// Set the view, recording view history. Returns false, changing nothing, when the view (or, /// in MSPACE, the viewport view it maps to) isn't finite with a positive height. @@ -210,7 +245,7 @@ impl DocState { /// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`]. pub fn zoom_frame(&self) -> View { match self.zoom_viewport() { - Some(v) => View { center: v.view_center, height: v.view_height }, + Some(v) => View { center: v.view_center, height: v.view_height, twist: v.view_twist() }, None => self.view(), } } @@ -225,7 +260,9 @@ impl DocState { Some(vp) => { let pv = self.paper_view(); let k = f.height / vp.height; - View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k } + // The frame's twist turns the viewport (a non-finite one keeps the viewport's). + let twist = if f.twist.is_finite() { f.twist } else { vp.view_twist() }; + View { center: f.center + ((pv.center - vp.center.xy()) * k).rotate(-twist), height: pv.height * k, twist } } None => f, }; @@ -233,7 +270,7 @@ impl DocState { } /// Set without recording view history (realtime pan/zoom frames). pub fn set_view_quiet(&mut self, v: View) { - if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) { + if !usable_view(&v) { return; } self.store_view(v); diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 799b7046d..e77960958 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -66,6 +66,14 @@ impl Interactive for Guarded { fn begin(&mut self, s: &mut Session) -> Result { catch_unwind(AssertUnwindSafe(|| self.inner.begin(s))).unwrap_or_else(|p| Err(self.internal(&*p))) } + fn cancelled(&mut self, s: &mut Session) { + if self.broken.get().is_none() + && let Err(p) = catch_unwind(AssertUnwindSafe(|| self.inner.cancelled(s))) + { + let e = self.internal(&*p); + s.echo(e.to_string()); + } + } fn preview(&self, s: &Session, cursor: Vec2) -> Vec { if self.broken.get().is_some() { return Vec::new(); @@ -102,6 +110,15 @@ impl Interactive for Guarded { None }) } + fn display_only(&self, s: &Session) -> Option { + if self.broken.get().is_some() { + return None; + } + catch_unwind(AssertUnwindSafe(|| self.inner.display_only(s))).unwrap_or_else(|_| { + self.broken.set(Some("panic in preview")); + None + }) + } } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d1fa2b03d..ccb93ef4f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -49,7 +49,7 @@ pub use cadcraft_geom as geom; pub use cadcraft_render as render; pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; -pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step}; +pub use prompt::{Accept, DisplayOnly, Drag, Input, Interactive, Picker, Prompt, Step}; mod doc_state; mod error; diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index e3498dcf0..8bf4b28d8 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -31,7 +31,7 @@ impl Accept { /// [Truecolor/COlorbook] :". A prompt asking for something else besides its keywords has /// it: "Enter block name or [?]:", "Specify insertion base point or [Basepoint/…]:". Read from /// AutoCAD's command line over measured runs, where no prompt was written both ways. -const NO_OR: [&str; 27] = [ +const NO_OR: [&str; 28] = [ "Bring to front", "Calculate rectangle dimensions based on", "Choose mode for selected object conversion", @@ -47,6 +47,7 @@ const NO_OR: [&str; 27] = [ "Enter leader format option", "Enter line spacing type", "Enter object ordering option", + "Enter option", "Enter polyline linetype generation option", "Enter property to change", "Enter the type of array", @@ -266,6 +267,9 @@ pub trait Interactive: Send { fn begin(&mut self, _s: &mut Session) -> Result { Ok(Step::Continue) } + /// Called when the command is cancelled from outside (Esc, or another command starting), + /// before it finishes: what it records on every exit path. + fn cancelled(&mut self, _s: &mut Session) {} /// Rubber-band geometry for the cursor position. fn preview(&self, _s: &Session, _cursor: Vec2) -> Vec { Vec::new() @@ -287,6 +291,22 @@ pub trait Interactive: Send { fn editing_mtext(&self) -> Option { None } + /// What the drawing area shows in place of the drawing while the command runs (DVIEW draws + /// only its preview set); `None` shows the drawing. + fn display_only(&self, _s: &Session) -> Option { + None + } +} + +/// See [`Interactive::display_only`]: the only things drawn in the current view while a command +/// runs. The whole drawing is drawn again when it ends. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DisplayOnly { + /// Objects of the drawing, drawn as they are. + pub handles: Vec, + /// Geometry that is not in the drawing (DVIEW's sample house, or an insert of the drawing's + /// DVIEWBLOCK block), with its own properties. + pub extra: Vec, } /// See [`Interactive::drag`]. diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 6721cdad8..b92c6b14f 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -527,6 +527,7 @@ mod tests { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap(); (d, h) diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 95d439f8c..6704b2674 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -6,7 +6,7 @@ impl Session { self.untitled_counter += 1; let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() }; let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None); - st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() }); + st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0, twist: 0.0 } } else { View::default() }); self.end_running(); self.docs.push(st); self.active = self.docs.len() - 1; @@ -18,11 +18,13 @@ impl Session { pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option) -> usize { let mut st = DocState::new(d, title, path); let ext = st.doc.extents(&Space::Model); - if !ext.is_empty() { - st.set_view_quiet(View { - center: ext.center(), - height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6), - }); + // The current tile (the first), or the lone untiled view, keeps its saved twist. + let twist = cadcraft_geom::norm_angle(st.doc.vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map_or(0.0, |t| t.twist)); + if ext.is_empty() { + st.set_view_quiet(View { twist, ..st.view() }); + } else { + let (center, w, h) = View::twisted_box(ext, twist); + st.set_view_quiet(View { center, height: (h.max(w * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6), twist }); } self.end_running(); self.docs.push(st); diff --git a/crates/engine/src/session/interactive.rs b/crates/engine/src/session/interactive.rs index aa66bd4c5..273ce0890 100644 --- a/crates/engine/src/session/interactive.rs +++ b/crates/engine/src/session/interactive.rs @@ -1,3 +1,5 @@ +use crate::DisplayOnly; + use super::{ Arc, Bounds2, Drag, Drawing, EngineError, Entity, Handle, Input, PendingWindow, Prompt, Result, Running, Session, Snapshot, Step, Value, Vec2, assoc, cmd, find_command, guard, pointmod, select, snap, sysvars, @@ -199,7 +201,7 @@ impl Session { Input::Point(p) => { let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5; if let Some(pw) = self.pending_window.take() { - let crossing = p.x < pw.corner.x; + let crossing = self.window_is_crossing(pw.corner, p); let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing); return Ok(Some(Input::Pick(cmd::groups::expand(self, hs)))); } @@ -279,7 +281,10 @@ impl Session { let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None }; let Some(mut run) = self.running.take() else { return Ok(()) }; let step = match input { - Some(Input::Cancel) => Ok(Step::Cancel), + Some(Input::Cancel) => { + run.machine.cancelled(self); + Ok(Step::Cancel) + } Some(i) => { if let Input::Point(p) = &i { self.last_point = *p; @@ -438,7 +443,8 @@ impl Session { } if self.running.is_some() { if self.suspended.is_some() { - if let Some(nested) = self.running.take() { + if let Some(mut nested) = self.running.take() { + nested.machine.cancelled(self); self.finish(nested, true); } self.running = self.suspended.take(); @@ -523,6 +529,13 @@ impl Session { out } + /// What the drawing area shows in place of the drawing while a command runs (DVIEW's preview + /// set, see [`Interactive::display_only`]), also while a transparent command interrupts it. + /// `None`: the drawing. + pub fn display_only(&self) -> Option { + [&self.running, &self.suspended].into_iter().flatten().find_map(|r| r.machine.display_only(self)) + } + /// The active command's preview as a transform of existing objects (see /// [`Interactive::drag`]), with its extra rubber-band geometry as entities. pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec)> { diff --git a/crates/engine/src/session/picking.rs b/crates/engine/src/session/picking.rs index 1ab773a00..db7a55f63 100644 --- a/crates/engine/src/session/picking.rs +++ b/crates/engine/src/session/picking.rs @@ -16,6 +16,13 @@ impl Session { true } + /// Whether a selection window from `corner` to `p` is a crossing window: it was dragged + /// leftwards on screen (in a twisted view, not along the drawing's X axis). + pub fn window_is_crossing(&self, corner: Vec2, p: Vec2) -> bool { + let twist = self.state().map_or(0.0, |st| st.view().twist); + (p - corner).rotate(twist).x < 0.0 + } + /// A click with no command running: pick/toggle objects or start a selection window. /// /// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick @@ -24,7 +31,7 @@ impl Session { let space = self.space(); let pickadd = self.settings.pickadd; if let Some(pw) = self.pending_window.take() { - let crossing = p.x < pw.corner.x; + let crossing = self.window_is_crossing(pw.corner, p); let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing); let hs = cmd::groups::expand(self, hs); let mut sel = if pickadd || shift { self.selection() } else { Vec::new() }; diff --git a/crates/engine/src/session/zoom.rs b/crates/engine/src/session/zoom.rs index 3936f3d14..e23e15cb7 100644 --- a/crates/engine/src/session/zoom.rs +++ b/crates/engine/src/session/zoom.rs @@ -23,8 +23,10 @@ impl Session { let ext = if ext.is_empty() { self.zoom_limits()? } else { ext }; let st = self.state_mut()?; let aspect = st.zoom_aspect(w / h.max(1.0)); - let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05; - st.set_zoom_frame(View { center: ext.center(), height }); + let twist = st.zoom_frame().twist; + let (center, bw, bh) = View::twisted_box(ext, twist); + let height = bh.max(bw / aspect.max(1e-6)).max(1e-6) * 1.05; + st.set_zoom_frame(View { center, height, twist }); Ok(()) } @@ -38,7 +40,7 @@ impl Session { let h = (v.height / factor).clamp(1e-9, 1e12); let k = h / v.height; let c = about + (v.center - about) * k; - st.set_view_quiet(View { center: c, height: h }); + st.set_view_quiet(View { center: c, height: h, twist: v.twist }); Ok(()) } } diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 8def404d2..061b0772b 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -714,6 +714,7 @@ impl Sink<'_> { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, frozen, &mut |k, m| self.object(k, Some(m))); } @@ -813,22 +814,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 { - 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 +829,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 +848,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) -> Option { let mut best: Option<(f64, SnapHit)> = None; diff --git a/crates/engine/src/snap/viewport.rs b/crates/engine/src/snap/viewport.rs index 6480deadf..c6ef9963c 100644 --- a/crates/engine/src/snap/viewport.rs +++ b/crates/engine/src/snap/viewport.rs @@ -40,7 +40,8 @@ impl<'a> Projection<'a> { } let half = Vec2::new(viewport.width / 2.0, viewport.height / 2.0); let paper_clip = Bounds2::new(center - half, center + half); - let model_clip = Bounds2::new(viewport.view_center - half / scale, viewport.view_center + half / scale); + // The model box the viewport shows (turned, with a view twist). + let model_clip = Bounds2::from_points(paper_clip.corners().map(|p| viewport.paper_to_model(p))); if ![paper_clip.min, paper_clip.max, model_clip.min, model_clip.max].into_iter().all(Vec2::is_finite) { return None; } @@ -48,11 +49,11 @@ impl<'a> Projection<'a> { } pub fn to_model(&self, point: Vec2) -> Vec2 { - self.viewport.view_center + (point - self.viewport.center.xy()) / self.scale + self.viewport.paper_to_model(point) } fn to_paper(&self, point: Vec2) -> Vec2 { - self.viewport.center.xy() + (point - self.viewport.view_center) * self.scale + self.viewport.model_to_paper(point) } pub fn hit(&self, mut hit: SnapHit) -> Option { @@ -76,7 +77,9 @@ impl<'a> Projection<'a> { if !(center.is_finite() && radius.is_finite() && radius > 0.0) { return None; } - SnapCurve::Arc { center, radius, start, end } + // The arc turns on the sheet with the viewport's twist. + let twist = self.viewport.view_twist(); + SnapCurve::Arc { center, radius, start: start + twist, end: end + twist } } SnapCurve::Line { a, b } => { let (a, b) = (self.to_paper(a), self.to_paper(b)); diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index dd2e0d593..6740707c0 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -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]) -> 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) } @@ -362,6 +364,7 @@ mod tests { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), 8 if i % 40 == 8 => EntityKind::XLine(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(r.r(-1.0, 1.0), 1.0) }), 8 if i % 40 == 18 => EntityKind::Ray(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(1.0, r.r(-1.0, 1.0)) }), @@ -544,6 +547,7 @@ mod tests { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap(); let small = Bounds2::new(Vec2::new(-2.0, -2.0), Vec2::new(2.0, 2.0)); diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index fcecbd2cf..e98836446 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -184,6 +184,8 @@ pub fn get(s: &Session, name: &str) -> Option { json!([v.center.x, v.center.y, 0.0]) } "VIEWSIZE" => json!(s.state().ok()?.view().height), + // The view twist (DVIEW TWist), in degrees counterclockwise. + "VIEWTWIST" => json!(s.state().ok()?.view().twist.to_degrees()), "EXTMIN" | "EXTMAX" => { let d = s.doc().ok()?; let e = d.extents(&s.space()); @@ -260,6 +262,7 @@ const READ_ONLY: &[&str] = &[ "DISTANCE", "VIEWCTR", "VIEWSIZE", + "VIEWTWIST", "EXTMIN", "EXTMAX", "UNDOCTL", diff --git a/crates/engine/src/sysvars/table.rs b/crates/engine/src/sysvars/table.rs index 910068f5c..46f8b4cc1 100644 --- a/crates/engine/src/sysvars/table.rs +++ b/crates/engine/src/sysvars/table.rs @@ -74,6 +74,8 @@ const HEADER: &[(&str, Kind, Def)] = &[ ("TRIMEXTENDMODE", Kind::Choice(&[0, 1]), Def::Int(1)), ("TRIMMODE", Kind::Choice(&[0, 1]), Def::Int(1)), ("WIPEOUTFRAME", Kind::Choice(&[0, 1, 2]), Def::Int(1)), + // XCLIP boundaries: 0 hidden, 1 shown and plotted, 2 shown but not plotted. + ("XCLIPFRAME", Kind::Choice(&[0, 1, 2]), Def::Int(2)), ]; /// The standard lineweights in hundredths of a millimetre. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fec3fbb18..35573df6a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -185,7 +185,7 @@ fn move_with_pickfirst_and_window_selection() { s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap(); s.execute("line", &json!({"points": [[5, 5], [6, 5]]})).unwrap(); s.viewport_px = (1000.0, 1000.0); - s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0 }); + s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0, twist: 0.0 }); // Window selection from left to right picks only the first line. s.cmdline("move").unwrap(); s.input(Input::Point(Vec2::new(-1.0, -1.0))).unwrap(); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 9f08b4fc5..bbfc4f972 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -1,20 +1,38 @@ +use cadcraft_geom::Bounds2; + use super::{Deserialize, Serialize, Vec2}; -/// A 2D view of a space: world centre and visible height in drawing units. +/// A 2D view of a space: world centre and visible height in drawing units, and the view twist +/// (VIEWTWIST, DVIEW TWist): how far the view is turned on screen, in radians counterclockwise +/// (the drawing appears turned by `twist`; 0 is the plain plan view). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct View { pub center: Vec2, pub height: f64, + #[serde(default)] + pub twist: f64, } impl Default for View { fn default() -> Self { - View { center: Vec2::new(6.0, 4.5), height: 10.0 } + View { center: Vec2::new(6.0, 4.5), height: 10.0, twist: 0.0 } + } +} + +impl View { + /// Box `b` (world) as a view turned by `twist` shows it: the world centre of the box it + /// makes on screen, and that box's width and height (`b`'s own when there is no twist). + pub fn twisted_box(b: Bounds2, twist: f64) -> (Vec2, f64, f64) { + if twist == 0.0 || !twist.is_finite() { + return (b.center(), b.width(), b.height()); + } + let r = Bounds2::from_points(b.corners().map(|p| p.rotate(twist))); + (r.center().rotate(-twist), r.width(), r.height()) } } pub(crate) fn usable_view(v: &View) -> bool { - v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 + v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 && v.twist.is_finite() } /// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration. diff --git a/crates/engine/tests/audit.rs b/crates/engine/tests/audit.rs index 8c1ce9c4a..4084a7b1b 100644 --- a/crates/engine/tests/audit.rs +++ b/crates/engine/tests/audit.rs @@ -24,6 +24,7 @@ fn insert(block: &str) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs index f44be5231..378cf0291 100644 --- a/crates/engine/tests/autosave.rs +++ b/crates/engine/tests/autosave.rs @@ -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![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); } @@ -353,6 +487,7 @@ fn a_block_editor_that_cannot_be_composed_is_not_autosaved_as_the_old_host() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); d.model.push(Entity::new(h, kind)); assert!(s.state().unwrap().recovery_drawing().is_err()); diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index 650d63092..b9eeff317 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -28,6 +28,7 @@ fn insert(d: &mut Drawing, block: &str, at: (f64, f64)) -> Entity { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); Entity { handle: d.new_handle(), common: Common::default(), kind } } diff --git a/crates/engine/tests/compare.rs b/crates/engine/tests/compare.rs index a346fbba2..f7b50cd60 100644 --- a/crates/engine/tests/compare.rs +++ b/crates/engine/tests/compare.rs @@ -124,6 +124,7 @@ fn hooks() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).map_err(|e| e.to_string())?; return Ok(d); @@ -175,6 +176,7 @@ fn hostile_inputs_are_errors() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; b.entities.push(Entity::new(cadcraft_engine::doc::Handle(0x900), EntityKind::Insert(ins.clone()))); let d = s.doc_mut().unwrap(); diff --git a/crates/engine/tests/dview.rs b/crates/engine/tests/dview.rs new file mode 100644 index 000000000..266421b2f --- /dev/null +++ b/crates/engine/tests/dview.rs @@ -0,0 +1,381 @@ +//! DVIEW starts with "Select objects or :", then the option prompt; +//! TWist, PAn, Zoom, Off and Undo on a 2D view; the 3D options not available yet. It works on the +//! Model tab and inside an active layout viewport (whose twist is an undoable edit of the +//! viewport), and not on a layout sheet. + +use cadcraft_doc::{EntityKind, Handle, Viewport}; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{Input, Session, View}; +use serde_json::json; + +const SELECT: &str = "Select objects or :"; +const OPTION: &str = "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/Off/Undo]:"; +const PAPER: &str = "This command is not available in Paper space."; + +fn session() -> Session { + let mut s = Session::new(); + s.viewport_px = (1200.0, 800.0); + s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap(); + s.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap(); + s +} + +fn prompt(s: &Session) -> String { + s.current_prompt().map(|p| p.display()).unwrap_or_default() +} + +fn view(s: &Session) -> View { + s.state().unwrap().view() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +fn twist_deg(s: &Session) -> f64 { + view(s).twist.to_degrees() +} + +fn logged(s: &Session, m: &str) -> bool { + s.log.iter().any(|l| l == m) +} + +#[test] +fn every_prompt_step_on_the_model_tab() { + let mut s = session(); + let undo = s.state().unwrap().undo.len(); + s.cmdline("dview").unwrap(); + assert_eq!(prompt(&s), SELECT); + // The whole drawing shows while objects are picked. + assert!(s.display_only().is_none()); + // Enter with nothing selected: the DVIEWBLOCK sample (a house of lines) is the preview set. + s.cmdline("").unwrap(); + assert_eq!(prompt(&s), OPTION); + let only = s.display_only().unwrap(); + assert!(only.handles.is_empty()); + assert!(only.extra.len() > 10 && only.extra.iter().all(|e| matches!(e.kind, EntityKind::Line(_)))); + twist_pan_and_zoom(&mut s); + // The sample house stays where it was put: the view moves over it. + assert_eq!(s.display_only().unwrap(), only); + off_and_unavailable_options(&mut s); + undo_steps(&mut s); + + // An unknown option is refused and asked again. + assert!(s.cmdline("bogus").is_ok()); + assert_eq!(prompt(&s), OPTION); + + // Enter ends DVIEW; the whole drawing shows again, and a Model tab twist is no undo step. + s.cmdline("tw").unwrap(); + s.cmdline("45").unwrap(); + s.cmdline("").unwrap(); + assert!(s.current_prompt().is_none()); + assert!(s.display_only().is_none()); + assert!(near(twist_deg(&s), 45.0)); + assert_eq!(s.state().unwrap().undo.len(), undo); +} + +/// DVIEW's TWist, PAn and Zoom steps from the option prompt of a fresh Model tab session. +fn twist_pan_and_zoom(s: &mut Session) { + // TWist: the current twist is the default; a typed angle turns the view. + s.cmdline("tw").unwrap(); + assert_eq!(prompt(s), "Specify view twist angle <0>:"); + s.cmdline("30").unwrap(); + assert!(near(twist_deg(s), 30.0)); + assert_eq!(prompt(s), OPTION); + s.cmdline("TWist").unwrap(); + assert_eq!(prompt(s), "Specify view twist angle <30>:"); + // Enter keeps it. + s.cmdline("").unwrap(); + assert!(near(twist_deg(s), 30.0)); + + // PAn: base point, second point. + s.cmdline("pa").unwrap(); + assert_eq!(prompt(s), "Specify displacement base point:"); + s.cmdline("0,0").unwrap(); + assert_eq!(prompt(s), "Specify second point:"); + s.cmdline("4,0").unwrap(); + assert_eq!(view(s).center, Vec2::new(6.0, 10.0)); + assert_eq!(prompt(s), OPTION); + + // Zoom: a scale factor of the current view. + s.cmdline("z").unwrap(); + assert_eq!(prompt(s), "Specify zoom scale factor <1>:"); + s.cmdline("2").unwrap(); + assert!(near(view(s).height, 10.0)); +} + +/// DVIEW's Off option and the options that need a 3D camera. +fn off_and_unavailable_options(s: &mut Session) { + // Off: perspective is never on in a 2D view, so nothing changes. + let before = view(s); + s.cmdline("o").unwrap(); + assert_eq!(prompt(s), OPTION); + assert_eq!(view(s), before); + + // The options that need a 3D camera are not available yet and stay at the option prompt. + for (typed, name) in [("ca", "CAmera"), ("ta", "TArget"), ("d", "Distance"), ("po", "POints"), ("cl", "CLip"), ("h", "Hide")] { + s.cmdline(typed).unwrap(); + assert!(logged(s, &format!("DVIEW {name} is not available yet.")), "{name}: {:?}", s.log); + assert_eq!(prompt(s), OPTION); + } + assert!(!s.log.iter().any(|l| l.contains("Only the view twist"))); +} + +/// DVIEW's Undo after [`twist_pan_and_zoom`]. +fn undo_steps(s: &mut Session) { + // Undo takes back one DVIEW action at a time: zoom, pan, twist. + s.cmdline("u").unwrap(); + assert!(near(view(s).height, 20.0)); + s.cmdline("u").unwrap(); + assert_eq!(view(s).center, Vec2::new(10.0, 10.0)); + assert!(near(twist_deg(s), 30.0)); + s.cmdline("u").unwrap(); + assert_eq!(view(s).twist, 0.0); + // Nothing left to undo: still at the option prompt. + s.cmdline("u").unwrap(); + assert_eq!(prompt(s), OPTION); +} + +#[test] +fn selected_objects_are_the_preview_set() { + let mut s = session(); + let line = s.doc().unwrap().model.iter().next().map(|e| e.handle).unwrap(); + s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap(); + s.cmdline("dview").unwrap(); + s.input(Input::Pick(vec![line])).unwrap(); + assert_eq!(prompt(&s), SELECT); + s.cmdline("").unwrap(); + assert_eq!(prompt(&s), OPTION); + let only = s.display_only().unwrap(); + assert_eq!(only.handles, vec![line]); + assert!(only.extra.is_empty(), "no sample house with a selection"); + // Picked at the prompt, the objects don't stay selected. + assert!(s.selection().is_empty()); + // Escape ends it too, and the drawing shows again. + s.input(Input::Cancel).unwrap(); + assert!(s.current_prompt().is_none()); + assert!(s.display_only().is_none()); +} + +#[test] +fn a_dviewblock_of_the_drawings_own_is_previewed() { + let mut s = session(); + s.execute("circle", &json!({ "center": [0.5, 0.5], "radius": 0.5 })).unwrap(); + let c = s.doc().unwrap().model.iter().last().map(|e| e.handle).unwrap(); + s.execute("block", &json!({ "name": "DVIEWBLOCK", "base": [0, 0], "handles": [c.hex()], "keep": "delete" })).unwrap(); + s.cmdline("dview").unwrap(); + s.cmdline("").unwrap(); + let only = s.display_only().unwrap(); + assert_eq!(only.extra.len(), 1); + match &only.extra[0].kind { + EntityKind::Insert(i) => { + assert_eq!(i.block, "DVIEWBLOCK"); + // Scaled from its 1 by 1 unit area to half the view's height. + assert!(near(i.scale.x, 10.0) && near(i.scale.y, 10.0)); + } + k => panic!("{k:?}"), + } + s.cmdline("").unwrap(); +} + +/// A layout with one viewport, `(session, viewport handle)`. +fn layout_session() -> (Session, Handle) { + let mut s = session(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let r = s.execute("mview", &json!({ "p1": [20, 20], "p2": [220, 120] })).unwrap(); + let h = Handle::parse_hex(r["viewports"][0].as_str().unwrap()).unwrap(); + (s, h) +} + +fn vp(s: &Session, h: Handle) -> Viewport { + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Viewport(v) => v.clone(), + _ => panic!("not a viewport"), + } +} + +#[test] +fn on_a_layout_sheet_it_is_not_available() { + let (mut s, _) = layout_session(); + assert_eq!(s.execute("dview", &json!({ "twist": 10 })).unwrap_err().to_string(), PAPER); + assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER); + assert!(s.current_prompt().is_none()); + // view.set can't turn the sheet either; an untwisted view is fine. + assert_eq!(s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap_err().to_string(), PAPER); + s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap(); +} + +#[test] +fn inside_an_active_viewport_it_twists_that_viewport_as_one_undo_step() { + let (mut s, h) = layout_session(); + s.execute("mspace", &json!({ "handle": h.hex() })).unwrap(); + let undo = s.state().unwrap().undo.len(); + let pv = s.state().unwrap().paper_view(); + s.cmdline("dview").unwrap(); + assert_eq!(prompt(&s), SELECT); + s.cmdline("").unwrap(); + s.cmdline("tw").unwrap(); + s.cmdline("30").unwrap(); + // The viewport entity carries the twist, and the model view inside it is turned by it. + assert!(near(vp(&s, h).twist.to_degrees(), 30.0)); + assert!(near(twist_deg(&s), 30.0)); + // The sheet itself is not turned. + assert_eq!(s.state().unwrap().paper_view(), pv); + s.cmdline("tw").unwrap(); + s.cmdline("50").unwrap(); + s.cmdline("").unwrap(); + assert!(s.current_prompt().is_none()); + assert!(near(vp(&s, h).twist.to_degrees(), 50.0)); + // One undo step for the whole DVIEW, which turns the viewport back. + assert_eq!(s.state().unwrap().undo.len(), undo + 1); + s.execute("undo", &json!({})).unwrap(); + assert_eq!(vp(&s, h).twist, 0.0); + + // The JSON form and view.set twist the viewport the same way, each one undo step. + s.execute("dview", &json!({ "twist": -90 })).unwrap(); + assert!(near(vp(&s, h).twist.to_degrees(), 270.0)); + assert_eq!(s.state().unwrap().undo.len(), undo + 1); + let v = view(&s); + s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 15 })).unwrap(); + assert!(near(vp(&s, h).twist.to_degrees(), 15.0)); + assert_eq!(s.state().unwrap().undo.len(), undo + 2); + // Setting a view without a twist keeps the viewport's. + s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap(); + assert!(near(vp(&s, h).twist.to_degrees(), 15.0)); + assert_eq!(s.state().unwrap().undo.len(), undo + 2); + + // Back on the sheet, DVIEW is not available again. + s.execute("pspace", &json!({})).unwrap(); + assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER); +} + +#[test] +fn a_locked_viewport_keeps_its_twist() { + let (mut s, h) = layout_session(); + s.doc_mut() + .unwrap() + .modify_entity(h, |e| { + if let EntityKind::Viewport(v) = &mut e.kind { + v.locked = true; + } + }) + .unwrap(); + s.execute("mspace", &json!({ "handle": h.hex() })).unwrap(); + let undo = s.state().unwrap().undo.len(); + assert!(s.execute("dview", &json!({ "twist": 30 })).is_err()); + let v = view(&s); + assert!(s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 30 })).is_err()); + assert_eq!(vp(&s, h).twist, 0.0); + assert_eq!(s.state().unwrap().undo.len(), undo); + // A view with the twist it already has is fine. + s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap(); +} + +#[test] +fn escape_inside_an_active_viewport_keeps_the_twist_as_one_undo_step() { + let (mut s, h) = layout_session(); + s.execute("mspace", &json!({ "handle": h.hex() })).unwrap(); + let undo = s.state().unwrap().undo.len(); + for l in ["dview", "", "tw", "30"] { + s.cmdline(l).unwrap(); + } + s.input(Input::Cancel).unwrap(); + assert!(s.current_prompt().is_none()); + assert!(near(vp(&s, h).twist.to_degrees(), 30.0)); + assert_eq!(s.state().unwrap().undo.len(), undo + 1); + s.execute("undo", &json!({})).unwrap(); + assert_eq!(vp(&s, h).twist, 0.0); +} + +#[test] +fn the_view_inside_a_twisted_viewport_maps_the_screen_like_the_sheet() { + let (mut s, h) = layout_session(); + s.execute("mspace", &json!({ "handle": h.hex() })).unwrap(); + s.execute("dview", &json!({ "twist": 30 })).unwrap(); + let st = s.state().unwrap(); + let (vpe, pv, v) = (vp(&s, h), st.paper_view(), st.view()); + // The model view's centre is the model point at the sheet's view centre. + assert!((vpe.model_to_paper(v.center) - pv.center).len() < 1e-9); + assert!(near(v.twist, vpe.twist)); + // Panning the view inside the viewport moves its model view, keeping the twist. + let moved = View { center: v.center + Vec2::new(5.0, -2.0), ..v }; + s.state_mut().unwrap().set_view(moved); + let after = view(&s); + assert!((after.center - moved.center).len() < 1e-9, "{after:?}"); + assert!(near(after.twist, vpe.twist)); + assert_eq!(s.state().unwrap().paper_view(), pv); + // ZOOM Extents fits the turned box of the drawing in the viewport. + s.execute("zoom", &json!({ "mode": "extents" })).unwrap(); + assert!(near(view(&s).twist, vpe.twist)); +} + +#[test] +fn a_viewport_twist_survives_a_dxf_round_trip() { + let (mut s, h) = layout_session(); + s.execute("mspace", &json!({ "handle": h.hex() })).unwrap(); + s.execute("dview", &json!({ "twist": 30 })).unwrap(); + let dxf = cadcraft_io::write_dxf(s.doc().unwrap()); + let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap(); + let twists: Vec = back + .layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) if v.id != 1 => Some(v.twist.to_degrees()), + _ => None, + }) + .collect(); + assert_eq!(twists.len(), 1); + assert!((twists[0] - 30.0).abs() < 1e-9, "{twists:?}"); +} + +#[test] +fn a_view_saved_in_a_twisted_viewport_restores_its_twist_as_one_undo_step() { + let (mut s, h) = layout_session(); + s.execute("mspace", &json!({ "handle": h.hex() })).unwrap(); + s.execute("dview", &json!({ "twist": 30 })).unwrap(); + s.execute("view", &json!({ "option": "save", "name": "Turned" })).unwrap(); + let saved = s.doc().unwrap().views.iter().find(|v| v.name == "Turned").map(|v| v.twist.to_degrees()).unwrap(); + assert!(near(saved, 30.0), "saved twist {saved}"); + let center = vp(&s, h).view_center; + s.execute("dview", &json!({ "twist": 0 })).unwrap(); + s.execute("view.set", &json!({ "center": [80, 40], "height": 3 })).unwrap(); + let undo = s.state().unwrap().undo.len(); + s.execute("view", &json!({ "option": "restore", "name": "Turned" })).unwrap(); + assert!(near(vp(&s, h).twist.to_degrees(), 30.0), "viewport twist {}", vp(&s, h).twist.to_degrees()); + assert!(near(twist_deg(&s), 30.0)); + assert!((vp(&s, h).view_center - center).len() < 1e-6, "{:?} vs {center:?}", vp(&s, h).view_center); + // The viewport's twist changed: one undo step, which turns it back. + assert_eq!(s.state().unwrap().undo.len(), undo + 1); + s.execute("undo", &json!({})).unwrap(); + assert_eq!(vp(&s, h).twist, 0.0); +} + +#[test] +fn undoing_a_drawing_change_keeps_the_untiled_twist_saved() { + let mut s = session(); + s.execute("line", &json!({ "points": [[0, 10], [5, 10]] })).unwrap(); + // A Model tab twist is a view change, not an undo step. + s.execute("dview", &json!({ "twist": 30 })).unwrap(); + s.execute("undo", &json!({})).unwrap(); + assert!(near(twist_deg(&s), 30.0)); + let saved = |s: &Session| s.doc().unwrap().vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map(|t| t.twist.to_degrees()); + assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s)); + s.execute("redo", &json!({})).unwrap(); + assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s)); +} + +#[test] +fn a_leftward_drag_on_a_turned_screen_is_a_crossing_window() { + let mut s = session(); + let r = s.execute("line", &json!({ "points": [[200, 0], [260, 0]] })).unwrap(); + let line = Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap(); + s.execute("dview", &json!({ "twist": 180 })).unwrap(); + // Turned half way round, a drag towards +X in the drawing runs leftwards on screen. + assert!(s.window_is_crossing(Vec2::new(210.0, -5.0), Vec2::new(230.0, 5.0))); + assert!(!s.window_is_crossing(Vec2::new(230.0, -5.0), Vec2::new(210.0, 5.0))); + // A crossing window takes the line it only partly covers. + s.idle_click(Vec2::new(210.0, -5.0), false).unwrap(); + s.idle_click(Vec2::new(230.0, 5.0), false).unwrap(); + assert_eq!(s.selection(), vec![line]); +} diff --git a/crates/engine/tests/image_attach.rs b/crates/engine/tests/image_attach.rs new file mode 100644 index 000000000..864d968a5 --- /dev/null +++ b/crates/engine/tests/image_attach.rs @@ -0,0 +1,376 @@ +//! Raster images (#956): IMAGEATTACH from a file and from handed-over bytes, the pixels loaded +//! again when the drawing is reopened, a missing file left as a frame, and bad input refused. + +use std::path::{Path, PathBuf}; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io}; +use cadcraft_engine::doc::{Drawing, EntityKind, Image, Raster}; +use serde_json::{Value, json}; + +fn hooks() { + set_io(IoHooks { + read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), + write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), + plot: None, + }); + set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string())); +} + +/// A fresh folder for one test. +fn folder(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cadcraft-image-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A PNG file of 4 x 2 pixels. +fn png() -> Vec { + let px: Vec = (0..8u8).flat_map(|i| [i * 30, 255 - i * 30, 7, 255]).collect(); + cadcraft_io::raster::encode_png(&Raster::new(4, 2, px).unwrap()).unwrap() +} + +fn images(s: &Session) -> Vec { + let d = s.doc().unwrap(); + d.model.iter().filter_map(|e| if let EntityKind::Image(i) = &e.kind { Some(i.clone()) } else { None }).collect() +} + +fn open(s: &mut Session, path: &Path) { + s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap(); +} + +fn listed(s: &mut Session) -> Value { + s.execute("image", &json!({})).unwrap()["images"].clone() +} + +#[test] +fn attach_from_bytes_places_the_image() { + hooks(); + let mut s = Session::new(); + let r = s.execute("imageattach", &json!({ "path": "maps/site.png", "data": base64_encode(&png()), "at": [10, 20], "width": 100 })).unwrap(); + assert_eq!((r["name"].as_str(), r["pixels"].clone(), r["size"].clone()), (Some("site"), json!([4.0, 2.0]), json!([100.0, 50.0]))); + let i = images(&s).pop().unwrap(); + assert_eq!((i.insert.x, i.insert.y, i.u.x, i.u.y, i.v.x, i.v.y), (10.0, 20.0, 25.0, 0.0, 0.0, 25.0)); + assert_eq!((i.size.x, i.size.y, i.path.as_str(), i.name.as_str()), (4.0, 2.0, "maps/site.png", "site")); + assert_eq!(s.doc().unwrap().rasters.get("maps/site.png").map(|r| (r.width(), r.height())), Some((4, 2))); + let l = listed(&mut s); + assert_eq!((l[0]["status"].as_str(), l[0]["references"].as_u64(), l[0]["pixels"].clone()), (Some("Loaded"), Some(1), json!([4, 2]))); + + // A second reference to the same file shares the definition and the pixels; another file + // with the same file name gets its own name. + s.execute("imageattach", &json!({ "path": "maps/site.png", "at": [200, 0], "rotation": 90, "scale": 8 })).unwrap(); + let r = s.execute("attach", &json!({ "path": "other/site.png", "data": base64_encode(&png()) })).unwrap(); + assert_eq!(r["name"], "site_2"); + let all = images(&s); + assert_eq!(all.len(), 3); + assert_eq!(all[1].name, "site"); + assert!((all[1].u.x).abs() < 1e-12 && (all[1].u.y - 2.0).abs() < 1e-12 && (all[1].v.x + 2.0).abs() < 1e-12, "{:?}", all[1]); + assert_eq!(listed(&mut s)[0]["references"], 2); + + // Undo takes the reference away; the drawing draws with its pixels. + s.execute("undo", &json!({})).unwrap(); + assert_eq!(images(&s).len(), 2); + let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default()); + assert_eq!(list.images.len(), 2); +} + +#[test] +fn a_file_is_found_next_to_the_drawing_and_loaded_again_on_open() { + hooks(); + let dir = folder("file"); + std::fs::write(dir.join("map.png"), png()).unwrap(); + let host = dir.join("host.dxf"); + std::fs::write(&host, cadcraft_io::write(&Drawing::new_metric(), "host.dxf").unwrap()).unwrap(); + let mut s = Session::new(); + open(&mut s, &host); + let r = s.execute("imageattach", &json!({ "path": "map.png" })).unwrap(); + assert_eq!(r["size"], json!([1.0, 0.5]), "one drawing unit wide by default"); + s.execute("qsave", &json!({})).unwrap(); + + // Reopened: the pixels come back from the file. + let mut s = Session::new(); + open(&mut s, &host); + assert_eq!(images(&s).len(), 1); + assert!(s.doc().unwrap().rasters.get("map.png").is_some()); + assert!(!s.state().unwrap().is_dirty(), "loading images is not a change"); + let l = listed(&mut s); + assert_eq!(l[0]["status"], "Loaded"); + assert!(l[0]["foundAt"].as_str().is_some_and(|f| f.ends_with("map.png"))); + + // The file gone: the drawing still opens, says so, and keeps the reference. + std::fs::remove_file(dir.join("map.png")).unwrap(); + let mut s = Session::new(); + open(&mut s, &host); + assert_eq!(images(&s).len(), 1); + assert!(s.doc().unwrap().rasters.get("map.png").is_none()); + assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("file not found")), "{:?}", s.log); + assert_eq!(listed(&mut s)[0]["status"], "Not Found"); + + // A file that is not an image is reported the same way. + std::fs::write(dir.join("map.png"), b"not an image").unwrap(); + let mut s = Session::new(); + open(&mut s, &host); + assert!(s.doc().unwrap().rasters.get("map.png").is_none()); + assert!(s.log.iter().any(|l| l.contains("map.png")), "{:?}", s.log); + assert_eq!(listed(&mut s)[0]["status"], "Not loaded"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bad_input_is_refused_and_changes_nothing() { + hooks(); + let mut s = Session::new(); + let data = base64_encode(&png()); + for p in [ + json!({}), + json!({ "path": " " }), + json!({ "path": "nowhere/missing.png" }), + json!({ "path": "a.png", "data": "***" }), + json!({ "path": "a.png", "data": 5 }), + json!({ "path": "a.png", "data": base64_encode(b"plain text") }), + json!({ "path": "a.png", "data": data, "scale": 0 }), + json!({ "path": "a.png", "data": data, "width": -3 }), + json!({ "path": "a.png", "data": data, "width": "wide" }), + json!({ "path": "a.png", "data": data, "width": 1e-320 }), + json!({ "path": "x".repeat(5000), "data": data }), + ] { + assert!(s.execute("imageattach", &p).is_err(), "{p}"); + } + assert!(images(&s).is_empty()); + assert!(s.execute("attach", &json!({ "path": "plan.pdf" })).unwrap_err().to_string().contains("not available yet")); + assert_eq!(listed(&mut s), json!([])); +} + +#[test] +fn an_image_among_many_objects_keeps_its_pixels() { + // More objects than one build task takes: the display list is built in parts and joined, + // and the image must still point at its own pixels. + hooks(); + let mut s = Session::new(); + for i in 0..700 { + s.execute("line", &json!({ "points": [[i, 0], [i, 1]] })).unwrap(); + } + s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [0, 5], "width": 4 })).unwrap(); + for i in 0..700 { + s.execute("line", &json!({ "points": [[i, 2], [i, 3]] })).unwrap(); + } + s.execute("imageattach", &json!({ "path": "b.png", "data": base64_encode(&png()), "at": [10, 5], "width": 8 })).unwrap(); + let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default()); + let widths: Vec = list + .prims + .iter() + .filter_map(|p| match p.kind { + cadcraft_engine::render::Kind::Image { index } => list.images.get(index as usize).map(|i| i.right.x * 4.0), + _ => None, + }) + .collect(); + assert_eq!(widths, [4.0, 8.0]); +} + +fn drawn_images(s: &Session) -> usize { + cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default()).images.len() +} + +#[test] +fn editing_commands_work_on_images_and_undo_keeps_the_pixels() { + hooks(); + let mut s = Session::new(); + let r = s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [10, 0], "width": 4 })).unwrap(); + let h = r["handle"].as_str().unwrap().to_string(); + + // Move, then a quarter turn about the origin: the corner and both axes follow. + s.execute("move", &json!({ "handles": [h], "delta": [0, 5] })).unwrap(); + s.execute("rotate", &json!({ "handles": [h], "base": [0, 0], "angle": 90 })).unwrap(); + let i = images(&s).pop().unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + assert!(close(i.insert.x, -5.0) && close(i.insert.y, 10.0), "{i:?}"); + assert!(close(i.u.x, 0.0) && close(i.u.y, 1.0) && close(i.v.x, -1.0) && close(i.v.y, 0.0), "{i:?}"); + assert_eq!(drawn_images(&s), 1); + + // A copy is a second reference to the same file. + s.execute("copy", &json!({ "handles": [h], "delta": [20, 0] })).unwrap(); + assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2)); + assert_eq!(listed(&mut s)[0]["references"], 2); + assert_eq!(listed(&mut s).as_array().map(Vec::len), Some(1)); + + // Scaled about its corner: twice the size, still the same pixels. + s.execute("scale", &json!({ "handles": [h], "base": [-5, 10], "factor": 2 })).unwrap(); + let i = images(&s).into_iter().find(|i| close(i.insert.x, -5.0)).unwrap(); + assert!(close(i.u.y, 2.0) && close(i.size.x, 4.0), "{i:?}"); + + // Erased, then brought back by undo, with its picture; redo erases again. + s.execute("erase", &json!({ "handles": [h] })).unwrap(); + assert_eq!((images(&s).len(), drawn_images(&s)), (1, 1)); + s.execute("undo", &json!({})).unwrap(); + assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2)); + s.execute("redo", &json!({})).unwrap(); + assert_eq!(images(&s).len(), 1); + + // Everything undone: no image, and nothing left to draw. + for _ in 0..10 { + let _ = s.execute("undo", &json!({})); + } + assert!(images(&s).is_empty()); + assert_eq!(drawn_images(&s), 0); + assert_eq!(listed(&mut s), json!([])); +} + +#[test] +fn the_alias_and_the_command_line_reach_the_command() { + hooks(); + assert_eq!(cadcraft_engine::cmd::resolve_alias("IAT"), "imageattach"); + assert_eq!(cadcraft_engine::cmd::resolve_alias("im"), "image"); + let spec = |id: &str| cadcraft_engine::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); + assert!(!spec("imageattach").params.is_empty() && !spec("image").params.is_empty()); + // Typed without a file it has nothing to attach: an error, not a crash or a stuck prompt. + let mut s = Session::new(); + let _ = s.cmdline("imageattach"); + let _ = s.cmdline(""); + s.cancel(); + assert!(s.running.is_none() && images(&s).is_empty()); +} + +#[test] +fn images_in_blocks_and_layouts_are_loaded_on_open() { + use cadcraft_engine::doc::geom::{Vec2, Vec3}; + use cadcraft_engine::doc::{Block, Common, Entity, Space}; + hooks(); + let dir = folder("spaces"); + std::fs::create_dir_all(dir.join("maps")).unwrap(); + std::fs::write(dir.join("maps").join("a.png"), png()).unwrap(); + std::fs::write(dir.join("b.png"), png()).unwrap(); + let image = |path: &str, name: &str| { + EntityKind::Image(Image { + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(4.0, 2.0), + path: path.into(), + name: name.into(), + ..Image::default() + }) + }; + let mut d = Drawing::new_metric(); + // The same file in model space, in a block and on a layout; another only on the layout, + // saved with a folder that no longer exists (found by its name next to the drawing); and + // one that is nowhere. + d.add(&Space::Model, Common::default(), image("maps/a.png", "a")).unwrap(); + let mut blk = Block::new("MAP"); + let bh = d.new_handle(); + blk.entities.push(Entity::new(bh, image("maps/a.png", "a"))); + d.blocks.insert("MAP".into(), std::sync::Arc::new(blk)); + let layout = Space::Paper("Layout1".into()); + d.add(&layout, Common::default(), image("maps/a.png", "a")).unwrap(); + d.add(&layout, Common::default(), image("C:\\old\\place\\b.png", "b")).unwrap(); + d.add(&layout, Common::default(), image("gone.png", "gone")).unwrap(); + let host = dir.join("host.dxf"); + std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap(); + + let mut s = Session::new(); + open(&mut s, &host); + let doc = s.doc().unwrap(); + assert_eq!(doc.rasters.len(), 2, "each file once"); + assert!(doc.rasters.get("maps/a.png").is_some() && doc.rasters.get("C:\\old\\place\\b.png").is_some()); + assert!(!s.state().unwrap().is_dirty()); + assert_eq!(s.log.iter().filter(|l| l.starts_with("Image ")).count(), 1, "{:?}", s.log); + let l = listed(&mut s); + let row = |name: &str| l.as_array().unwrap().iter().find(|r| r["name"] == name).cloned().unwrap(); + assert_eq!((row("a")["status"].clone(), row("a")["references"].clone()), (json!("Loaded"), json!(3))); + assert_eq!(row("b")["status"], "Loaded"); + assert_eq!(row("gone")["status"], "Not Found"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_drawing_opened_from_bytes_gets_its_pixels_handed_over() { + // The web app's way: no file system, the drawing arrives as bytes and so do the images. + use cadcraft_engine::doc::geom::{Vec2, Vec3}; + use cadcraft_engine::doc::{Common, Space}; + hooks(); + let mut d = Drawing::new_metric(); + let i = Image { + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(4.0, 2.0), + path: "no-such-folder/web.png".into(), + name: "web".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + let dxf = cadcraft_io::write(&d, "plan.dxf").unwrap(); + + let mut s = Session::new(); + s.execute("open", &json!({ "data": base64_encode(&dxf), "name": "plan.dxf" })).unwrap(); + assert_eq!(images(&s).len(), 1, "opened with its image reference"); + assert!(s.doc().unwrap().rasters.is_empty()); + assert!(s.log.iter().any(|l| l.contains("web.png")), "{:?}", s.log); + assert_eq!(listed(&mut s)[0]["status"], "Not Found"); + assert_eq!(drawn_images(&s), 0); + + // The same file handed over as bytes: the reference that was there draws too. + s.execute("imageattach", &json!({ "path": "no-such-folder/web.png", "data": base64_encode(&png()), "at": [10, 0] })).unwrap(); + assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2)); + let l = listed(&mut s); + assert_eq!((l[0]["status"].clone(), l[0]["references"].clone(), l[0]["name"].clone()), (json!("Loaded"), json!(2), json!("web"))); + + // Saved and read back: the references are kept, the pixels are not in the file. + let bytes = cadcraft_io::write(s.doc().unwrap(), "plan.dxf").unwrap(); + let back = cadcraft_io::read(&bytes, "plan.dxf").unwrap(); + assert!(back.rasters.is_empty()); + assert_eq!(back.model.iter().filter(|e| matches!(e.kind, EntityKind::Image(_))).count(), 2); +} + +#[test] +fn many_files_are_loaded_together_and_reported_in_order() { + // More files than loaders, with missing and unreadable ones among them: each good file is + // loaded, each bad one reported once, in the order the drawing uses them. + use cadcraft_engine::doc::geom::{Vec2, Vec3}; + use cadcraft_engine::doc::{Common, Space}; + hooks(); + let dir = folder("many"); + let mut d = Drawing::new_metric(); + let mut expected_notes = Vec::new(); + for n in 0..60u32 { + let name = format!("tile{n:02}.png"); + match n % 10 { + 3 => expected_notes.push(format!("Image {name}: file not found")), + 7 => { + std::fs::write(dir.join(&name), b"not an image").unwrap(); + expected_notes.push(format!("Image {name}: ")); + } + _ => { + // A different width per file, so a mix-up would show. + let px = vec![200u8; (n as usize + 1) * 4]; + std::fs::write(dir.join(&name), cadcraft_io::raster::encode_png(&Raster::new(n + 1, 1, px).unwrap()).unwrap()).unwrap(); + } + } + let i = Image { + insert: Vec3::new(f64::from(n) * 3.0, 0.0, 0.0), + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(f64::from(n + 1), 1.0), + path: name.clone(), + name: format!("tile{n:02}"), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + } + let host = dir.join("host.dxf"); + std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap(); + + for _ in 0..3 { + let mut s = Session::new(); + open(&mut s, &host); + let doc = s.doc().unwrap(); + assert_eq!(doc.rasters.len(), 48); + for n in 0..60u32 { + let loaded = doc.rasters.get(&format!("tile{n:02}.png")).map(|r| r.width()); + assert_eq!(loaded, if matches!(n % 10, 3 | 7) { None } else { Some(n + 1) }, "tile {n}"); + } + let notes: Vec<&String> = s.log.iter().filter(|l| l.starts_with("Image ")).collect(); + assert_eq!(notes.len(), expected_notes.len(), "{notes:?}"); + assert!(notes.iter().zip(&expected_notes).all(|(got, want)| got.starts_with(want.as_str())), "{notes:?}"); + assert!(!s.state().unwrap().is_dirty()); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/image_decoder_panic.rs b/crates/engine/tests/image_decoder_panic.rs new file mode 100644 index 000000000..42e74253e --- /dev/null +++ b/crates/engine/tests/image_decoder_panic.rs @@ -0,0 +1,55 @@ +//! A raster image decoder that panics on a damaged file fails that image only (#956): the +//! drawing it belongs to still opens, and IMAGEATTACH reports an error. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io}; +use cadcraft_engine::doc::geom::{Vec2, Vec3}; +use cadcraft_engine::doc::{Common, Drawing, EntityKind, Image, Line, Space}; +use serde_json::json; + +fn hooks() { + set_io(IoHooks { + read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), + write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), + plot: None, + }); + set_image_decoder(|_| panic!("decoder bug")); +} + +#[test] +fn a_panicking_decoder_fails_the_image_not_the_drawing() { + hooks(); + let dir = std::env::temp_dir().join(format!("cadcraft-image-panic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("map.png"), b"anything").unwrap(); + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 5.0, 0.0) })).unwrap(); + let i = Image { + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(4.0, 2.0), + path: "map.png".into(), + name: "map".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + let host = dir.join("host.dxf"); + std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap(); + + let mut s = Session::new(); + s.execute("open", &json!({ "path": host.to_string_lossy() })).unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(doc.model.len(), 2, "the drawing opened with everything in it"); + assert!(doc.rasters.is_empty()); + assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("damaged")), "{:?}", s.log); + + let before = s.doc().unwrap().model.len(); + let e = s.execute("imageattach", &json!({ "path": "other.png", "data": base64_encode(b"bytes") })).unwrap_err().to_string(); + assert!(e.contains("damaged"), "{e}"); + assert_eq!(s.doc().unwrap().model.len(), before); + // The session still works. + s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap(); + assert_eq!(s.doc().unwrap().model.len(), before + 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/insert_file.rs b/crates/engine/tests/insert_file.rs index 41f7a92a7..23e5d5581 100644 --- a/crates/engine/tests/insert_file.rs +++ b/crates/engine/tests/insert_file.rs @@ -40,6 +40,7 @@ fn read(bytes: &[u8], _name: &str) -> Result { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.model.push(Entity::new(Handle(0x61), EntityKind::Insert(ins))); Ok(d) diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs index 86b8c42e3..51e7b9654 100644 --- a/crates/engine/tests/it/blockreplace.rs +++ b/crates/engine/tests/it/blockreplace.rs @@ -40,6 +40,7 @@ fn drawing() -> (Session, Handle) { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap(); (s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()) diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index 740133c2b..d887d67d6 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -21,6 +21,7 @@ fn insert(block: &str) -> Insert { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, } } diff --git a/crates/engine/tests/it/count.rs b/crates/engine/tests/it/count.rs index c654814aa..d28d28ecf 100644 --- a/crates/engine/tests/it/count.rs +++ b/crates/engine/tests/it/count.rs @@ -20,6 +20,7 @@ fn insert(block: &str, x: f64, cols: u32, rows: u32) -> EntityKind { rows, col_spacing: 5.0, row_spacing: 5.0, + clip: None, }) } diff --git a/crates/engine/tests/it/dynamic_block_data.rs b/crates/engine/tests/it/dynamic_block_data.rs index 5b4cb11e3..2e3f0baaa 100644 --- a/crates/engine/tests/it/dynamic_block_data.rs +++ b/crates/engine/tests/it/dynamic_block_data.rs @@ -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}"); } diff --git a/crates/engine/tests/it/explode_stretched.rs b/crates/engine/tests/it/explode_stretched.rs new file mode 100644 index 000000000..d7a7ac843 --- /dev/null +++ b/crates/engine/tests/it/explode_stretched.rs @@ -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 { + 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 { + 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); + } +} diff --git a/crates/engine/tests/it/extend_polyline_arc_end.rs b/crates/engine/tests/it/extend_polyline_arc_end.rs new file mode 100644 index 000000000..496187767 --- /dev/null +++ b/crates/engine/tests/it/extend_polyline_arc_end.rs @@ -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) -> 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)); +} diff --git a/crates/engine/tests/it/followups_626.rs b/crates/engine/tests/it/followups_626.rs index 7032a4c31..f19ad61bb 100644 --- a/crates/engine/tests/it/followups_626.rs +++ b/crates/engine/tests/it/followups_626.rs @@ -135,9 +135,9 @@ fn mview_restore_puts_a_saved_configuration_on_a_layout() { fn mview_restore_ignores_unusable_tiles_from_files() { let (mut s, _) = two_tiles(); let bad = [ - VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 }, - VportTile { min: v(0.5, 0.5), max: v(0.5, 0.9), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 }, - VportTile { min: v(-3.0, -3.0), max: v(9.0, 9.0), center: v(f64::INFINITY, 0.0), height: -1.0, aspect: f64::NAN }, + VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0, twist: 0.0 }, + VportTile { min: v(0.5, 0.5), max: v(0.5, 0.9), center: v(0.0, 0.0), height: 1.0, aspect: 1.0, twist: 0.0 }, + VportTile { min: v(-3.0, -3.0), max: v(9.0, 9.0), center: v(f64::INFINITY, 0.0), height: -1.0, aspect: f64::NAN, twist: 0.0 }, ]; if let Some(c) = s.doc_mut().unwrap().vports.iter_mut().find(|c| c.name == "Two") { c.tiles = bad.to_vec(); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 38fb4d005..3a1537bee 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -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; @@ -162,6 +165,7 @@ mod viewport_clip; mod vplayer_prompts; mod vports; mod wipeout_grips; +mod xclip; mod xdata_follows_entity; mod zoom_keywords; mod zoom_viewport; diff --git a/crates/engine/tests/it/measure_distance_snapping.rs b/crates/engine/tests/it/measure_distance_snapping.rs index 9842b65e0..30c313ad3 100644 --- a/crates/engine/tests/it/measure_distance_snapping.rs +++ b/crates/engine/tests/it/measure_distance_snapping.rs @@ -27,6 +27,7 @@ fn drawing() -> (Drawing, Space) { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }), ) .unwrap(); @@ -310,6 +311,7 @@ fn viewport_frozen_layers_apply_inside_nested_block_references() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) }; let mut outer = Block::new("Outer"); diff --git a/crates/engine/tests/it/minsert.rs b/crates/engine/tests/it/minsert.rs new file mode 100644 index 000000000..e123876df --- /dev/null +++ b/crates/engine/tests/it/minsert.rs @@ -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 { + 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 :", + "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::>(), ["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))); +} diff --git a/crates/engine/tests/it/named_views.rs b/crates/engine/tests/it/named_views.rs index f33a02cd2..bccc253b1 100644 --- a/crates/engine/tests/it/named_views.rs +++ b/crates/engine/tests/it/named_views.rs @@ -46,7 +46,7 @@ fn typed_save_restore_list_delete() { // Saving is an undo step; a view change is not. set_view(&mut s, 0.0, 0.0, 500.0); typed(&mut s, &["view", "r", "front"]); - assert_eq!(view(&s), View { center: Vec2::new(10.0, 20.0), height: 30.0 }); + assert_eq!(view(&s), View { center: Vec2::new(10.0, 20.0), height: 30.0, twist: 0.0 }); // Zoom Previous goes back to the view before the restore. s.execute("zoom.previous", &json!({})).unwrap(); assert_eq!(view(&s).height, 500.0); @@ -84,6 +84,20 @@ fn window_option_and_json_forms() { assert!(s.execute("view", &json!({ "option": "orthographic" })).is_err()); } +#[test] +fn a_window_saved_in_a_twisted_view_restores_whole() { + let mut s = session(); + s.execute("view.set", &json!({ "center": [0, 0], "height": 10, "twist": 45 })).unwrap(); + s.execute("view", &json!({ "option": "window", "name": "Turned", "p1": [0, 0], "p2": [10, 10] })).unwrap(); + let v = s.doc().unwrap().views.first().cloned().unwrap(); + // A 10 x 10 world square seen at 45° is 10√2 across on screen. + assert!(close(v.width, 200f64.sqrt()) && close(v.height, 200f64.sqrt()), "{} x {}", v.width, v.height); + // Corners near the number limit overflow when turned: an error, and the saved view stays. + let huge = json!({ "option": "window", "name": "Turned", "p1": [1.4e308, 1.4e308], "p2": [1.5e308, 1.5e308] }); + assert!(s.execute("view", &huge).is_err()); + assert!(close(s.doc().unwrap().views.first().unwrap().width, 200f64.sqrt())); +} + #[test] fn layer_snapshot_restores_layers_and_the_setting_turns_it_off() { let mut s = session(); @@ -112,7 +126,7 @@ fn paper_space_view_switches_to_its_layout() { s.execute("layout.set", &json!({ "name": "Model" })).unwrap(); s.execute("view", &json!({ "option": "restore", "name": "Sheet" })).unwrap(); assert_eq!(s.layout_space(), Space::Paper(layout)); - assert_eq!(view(&s), View { center: Vec2::new(5.0, 4.0), height: 6.0 }); + assert_eq!(view(&s), View { center: Vec2::new(5.0, 4.0), height: 6.0, twist: 0.0 }); } #[test] @@ -138,7 +152,7 @@ fn zoom_realtime_drags_and_enter_ends() { assert_eq!(view(&s).height, 100.0); // The JSON form zooms by a factor about the centre. s.execute("zoom.realtime", &json!({ "factor": 4 })).unwrap(); - assert_eq!(view(&s), View { center: Vec2::ZERO, height: 25.0 }); + assert_eq!(view(&s), View { center: Vec2::ZERO, height: 25.0, twist: 0.0 }); } #[test] @@ -158,10 +172,10 @@ fn zoom_dynamic_places_and_sizes_the_view_box() { assert!(close(v.height, 40.0), "{v:?}"); // Zoom Previous skips the overview. s.execute("zoom.previous", &json!({})).unwrap(); - assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0 }); + assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.0 }); // JSON: like Center. s.execute("zoom.dynamic", &json!({ "center": [1, 2], "height": 3 })).unwrap(); - assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0 }); + assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0, twist: 0.0 }); } #[test] diff --git a/crates/engine/tests/it/purge_mlinestyles.rs b/crates/engine/tests/it/purge_mlinestyles.rs index a037096f5..a0f955c96 100644 --- a/crates/engine/tests/it/purge_mlinestyles.rs +++ b/crates/engine/tests/it/purge_mlinestyles.rs @@ -41,6 +41,7 @@ fn session() -> Session { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); s diff --git a/crates/engine/tests/it/refedit.rs b/crates/engine/tests/it/refedit.rs index df3aa5ea1..0eb63c29f 100644 --- a/crates/engine/tests/it/refedit.rs +++ b/crates/engine/tests/it/refedit.rs @@ -20,6 +20,7 @@ fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: f64, rot_deg: f64 rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() } @@ -246,6 +247,7 @@ fn a_block_that_references_itself_in_a_file_does_not_hang() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); let d = s.doc_mut().unwrap(); let hh = d.new_handle(); diff --git a/crates/engine/tests/it/snap_blocks.rs b/crates/engine/tests/it/snap_blocks.rs index 6d44893d7..6bd3fd09c 100644 --- a/crates/engine/tests/it/snap_blocks.rs +++ b/crates/engine/tests/it/snap_blocks.rs @@ -31,6 +31,7 @@ fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: (f64, f64), rotat rows: grid.1, col_spacing: grid.2, row_spacing: grid.3, + clip: None, }); s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() } diff --git a/crates/engine/tests/it/snap_tracking.rs b/crates/engine/tests/it/snap_tracking.rs index 3843002df..de62e482c 100644 --- a/crates/engine/tests/it/snap_tracking.rs +++ b/crates/engine/tests/it/snap_tracking.rs @@ -41,6 +41,7 @@ fn insert_block(s: &mut Session, name: &str, at: Vec2) -> String { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) .unwrap() diff --git a/crates/engine/tests/it/vports.rs b/crates/engine/tests/it/vports.rs index 543840220..abc69d4b1 100644 --- a/crates/engine/tests/it/vports.rs +++ b/crates/engine/tests/it/vports.rs @@ -46,15 +46,15 @@ fn split_activate_and_each_tile_keeps_its_view() { s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap(); assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]); // Every new tile starts with the view that was split. - assert!(tiles(&s).iter().all(|t| t.view == View { center: Vec2::new(10.0, 10.0), height: 20.0 })); + assert!(tiles(&s).iter().all(|t| t.view == View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.0 })); let first = current(&s); let other = 1 - first; s.activate_tile(other).unwrap(); assert_eq!(current(&s), other); s.execute("view.set", &json!({ "center": [50, 25], "height": 5 })).unwrap(); s.execute("vports.activate", &json!({ "index": first + 1 })).unwrap(); - assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0 }); - assert_eq!(tiles(&s)[other].view, View { center: Vec2::new(50.0, 25.0), height: 5.0 }); + assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.0 }); + assert_eq!(tiles(&s)[other].view, View { center: Vec2::new(50.0, 25.0), height: 5.0, twist: 0.0 }); // Switching tiles changes no drawing data; arranging them is an undo step. assert_eq!(s.state().unwrap().undo.len(), 2, "line + VPORTS"); s.undo().unwrap(); @@ -82,7 +82,7 @@ fn typed_three_right_then_join_and_single() { s.click_tile(bottom).unwrap(); assert!(s.running.is_none(), "{}", s.prompt_text()); assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]); - assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0 }); + assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0, twist: 0.0 }); // Tiles that don't make a rectangle can't be joined. s.execute("-vports", &json!({ "option": "3", "arrangement": "above" })).unwrap(); let t = tiles(&s); @@ -93,7 +93,7 @@ fn typed_three_right_then_join_and_single() { s.execute("view.set", &json!({ "center": [7, 7], "height": 7 })).unwrap(); typed(&mut s, &["-vports", "si"]); assert!(tiles(&s).is_empty()); - assert_eq!(view(&s), View { center: Vec2::new(7.0, 7.0), height: 7.0 }); + assert_eq!(view(&s), View { center: Vec2::new(7.0, 7.0), height: 7.0, twist: 0.0 }); } #[test] diff --git a/crates/engine/tests/it/xclip.rs b/crates/engine/tests/it/xclip.rs new file mode 100644 index 000000000..10b551716 --- /dev/null +++ b/crates/engine/tests/it/xclip.rs @@ -0,0 +1,273 @@ +//! XCLIP and XCLIPFRAME: clipping block and xref references (gap F2). + +use cadcraft_engine::doc::{ClipBoundary, Common, EntityKind, Handle, Insert, Space}; +use cadcraft_engine::geom::{Vec2, Vec3}; +use cadcraft_engine::render::{self, Options}; +use cadcraft_engine::{Input, Session}; +use serde_json::{Value, json}; + +fn h(v: &Value) -> String { + v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_string() +} + +fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: f64, rot_deg: f64) -> String { + let kind = EntityKind::Insert(Insert { + block: block.into(), + insert: Vec3::new(at.0, at.1, 0.0), + scale: Vec3::new(scale, scale, scale), + rotation: rot_deg.to_radians(), + ..Insert::default() + }); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() +} + +/// Block B: a horizontal line (0,0)-(100,0) and a vertical one (50,-50)-(50,50); two references. +fn setup() -> (Session, String, String) { + let mut s = Session::new(); + let a = h(&s.execute("line", &json!({ "points": [[0, 0], [100, 0]] })).unwrap()); + let b = h(&s.execute("line", &json!({ "points": [[50, -50], [50, 50]] })).unwrap()); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [a, b], "keep": "delete" })).unwrap(); + let r1 = insert(&mut s, "B", (0.0, 0.0), 1.0, 0.0); + let r2 = insert(&mut s, "B", (1000.0, 0.0), 2.0, 90.0); + (s, r1, r2) +} + +fn clip(s: &Session, hex: &str) -> Option { + match &s.doc().unwrap().entity(Handle::parse_hex(hex).unwrap()).unwrap().kind { + EntityKind::Insert(i) => i.clip.clone(), + _ => None, + } +} + +/// The x extent of what the drawing shows around reference 1 (x < 500). +fn shown_x(s: &Session) -> (f64, f64) { + let list = render::build(s.doc().unwrap(), &Space::Model, &Options::default()); + let xs: Vec = list.verts.iter().filter(|v| v.x < 500.0).map(|v| v.x).collect(); + (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)) +} + +#[test] +fn rectangular_clip_cuts_only_that_reference() { + let (mut s, r1, r2) = setup(); + assert_eq!(shown_x(&s), (0.0, 100.0)); + s.execute("setvar", &json!({ "name": "XCLIPFRAME", "value": 0 })).unwrap(); + let out = s.execute("xclip", &json!({ "handle": r1, "rectangle": [[20, -10], [70, 10]] })).unwrap(); + assert_eq!(out["option"], "new"); + let c = clip(&s, &r1).unwrap(); + assert!(c.enabled && !c.inverted && c.points.len() == 2); + let (lo, hi) = shown_x(&s); + assert!((lo - 20.0).abs() < 1e-9 && (hi - 70.0).abs() < 1e-9, "{lo} {hi}"); + assert!(clip(&s, &r2).is_none(), "the other reference stays whole"); + // The boundary follows its reference. + s.execute("move", &json!({ "handles": [r1], "delta": [5, 0] })).unwrap(); + let (lo, hi) = shown_x(&s); + assert!((lo - 25.0).abs() < 1e-9 && (hi - 75.0).abs() < 1e-9, "{lo} {hi}"); + // Its extents are the clipped part (ZOOM Extents). + let d = s.doc().unwrap(); + let b = cadcraft_engine::doc::entity_bounds(d, d.entity(Handle::parse_hex(&r1).unwrap()).unwrap(), 0); + assert!((b.min.x - 25.0).abs() < 1e-9 && (b.max.x - 75.0).abs() < 1e-9 && b.max.y <= 10.0 + 1e-9, "{b:?}"); + // Query reports it in drawing coordinates. + let q = s.execute("xclip", &json!({ "handle": r1, "option": "query" })).unwrap(); + assert_eq!(q["clips"][0]["boundary"][0], json!([25.0, -10.0])); + // OFF shows everything, ON clips again, Delete removes it; undo brings it back. + s.execute("xclip", &json!({ "handle": r1, "option": "off" })).unwrap(); + assert_eq!(shown_x(&s), (5.0, 105.0)); + s.execute("xclip", &json!({ "handle": r1, "option": "on" })).unwrap(); + assert!((shown_x(&s).0 - 25.0).abs() < 1e-9); + s.execute("xclip", &json!({ "handle": r1, "option": "delete" })).unwrap(); + assert!(clip(&s, &r1).is_none()); + s.execute("undo", &json!({})).unwrap(); + assert!(clip(&s, &r1).is_some()); +} + +#[test] +fn off_on_a_selection_with_an_unclipped_reference_changes_nothing() { + let (mut s, r1, r2) = setup(); + s.execute("xclip", &json!({ "handle": r1, "rectangle": [[20, -10], [70, 10]] })).unwrap(); + // r2 has no boundary: the command fails, and r1 keeps its clip on. + assert!(s.execute("xclip", &json!({ "handles": [r1, r2], "option": "off" })).is_err()); + assert!(clip(&s, &r1).unwrap().enabled); + assert!(clip(&s, &r2).is_none()); + // A reference named many times is turned off once, its boundary kept. + s.execute("xclip", &json!({ "handles": vec![r1.clone(); 1000], "option": "off" })).unwrap(); + let c = clip(&s, &r1).unwrap(); + assert!(!c.enabled && c.points.len() == 2); +} + +#[test] +fn a_clipped_reference_plots_its_contents_on_their_own_layers() { + let mut s = Session::new(); + s.doc_mut().unwrap().layers.push(cadcraft_engine::doc::Layer { name: "Walls".into(), ..Default::default() }); + let a = h(&s.execute("line", &json!({ "points": [[0, 0], [100, 0]] })).unwrap()); + let b = h(&s.execute("line", &json!({ "points": [[50, -50], [50, 50]] })).unwrap()); + s.doc_mut().unwrap().modify_entity(Handle::parse_hex(&a).unwrap(), |e| e.common.layer = "Walls".into()).unwrap(); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [a, b], "keep": "delete" })).unwrap(); + let r = insert(&mut s, "B", (0.0, 0.0), 1.0, 0.0); + s.execute("setvar", &json!({ "name": "XCLIPFRAME", "value": 0 })).unwrap(); + s.execute("xclip", &json!({ "handle": r, "rectangle": [[20, -10], [70, 10]] })).unwrap(); + let d = s.doc().unwrap(); + let index = |name: &str| d.layers.iter().position(|l| l.name == name).map(|i| i as u32); + let list = render::build_plot(d, &Space::Model, &Options::default()); + assert_eq!(list.prim_layers.len(), list.prims.len()); + // The horizontal line is on Walls, the vertical one (layer 0) takes the reference's layer. + assert!(list.prim_layers.contains(&index("Walls").unwrap()), "{:?}", list.prim_layers); + assert!(list.prim_layers.contains(&index("0").unwrap()), "{:?}", list.prim_layers); +} + +#[test] +fn extents_are_the_boundary_box_as_in_the_reference_app() { + let (mut s, r1, _) = setup(); + let bounds = |s: &Session| { + let d = s.doc().unwrap(); + cadcraft_engine::doc::entity_bounds(d, d.entity(Handle::parse_hex(&r1).unwrap()).unwrap(), 0) + }; + // A boundary reaching past the block (0..100 x -50..50): its own box, not the overlap. + s.execute("xclip", &json!({ "handle": r1, "rectangle": [[50, -20], [150, 30]] })).unwrap(); + let b = bounds(&s); + assert!((b.min - Vec2::new(50.0, -20.0)).len() < 1e-9 && (b.max - Vec2::new(150.0, 30.0)).len() < 1e-9, "{b:?}"); + // One that misses the block entirely: still its box. + s.execute("xclip", &json!({ "handle": r1, "rectangle": [[200, 200], [300, 300]] })).unwrap(); + let b = bounds(&s); + assert!((b.min - Vec2::new(200.0, 200.0)).len() < 1e-9 && (b.max - Vec2::new(300.0, 300.0)).len() < 1e-9, "{b:?}"); +} + +#[test] +fn rotated_and_scaled_reference_gets_a_polygon_in_block_coordinates() { + let (mut s, _, r2) = setup(); + // r2: scale 2, rotated 90 degrees at (1000, 0): the block's x axis points up. + s.execute("xclip", &json!({ "handle": r2, "rectangle": [[990, 10], [1010, 50]] })).unwrap(); + let c = clip(&s, &r2).unwrap(); + // A 90 degree rotation keeps the rectangle a rectangle: (5..25, -5..5) in the block. + let poly = c.polygon(); + let xs: Vec = poly.iter().map(|p| p.x).collect(); + assert!(xs.iter().any(|x| (x - 5.0).abs() < 1e-9) && xs.iter().any(|x| (x - 25.0).abs() < 1e-9), "{poly:?}"); + // 30 degrees: kept as the four corners. + let r3 = insert(&mut s, "B", (2000.0, 0.0), 1.0, 30.0); + s.execute("xclip", &json!({ "handle": r3, "rectangle": [[1990, -10], [2010, 10]] })).unwrap(); + assert_eq!(clip(&s, &r3).unwrap().points.len(), 4); +} + +#[test] +fn inverted_polygonal_and_polyline_boundaries() { + let (mut s, r1, _) = setup(); + s.execute("setvar", &json!({ "name": "XCLIPFRAME", "value": 0 })).unwrap(); + s.execute("xclip", &json!({ "handle": r1, "points": [[40, -10], [60, -10], [60, 10], [40, 10]], "invert": true })).unwrap(); + assert!(clip(&s, &r1).unwrap().inverted); + let list = render::build(s.doc().unwrap(), &Space::Model, &Options::default()); + // The middle of the horizontal line is hidden, both ends show. + assert!(!list.verts.iter().any(|v| v.x > 40.5 && v.x < 59.5 && v.y.abs() < 1e-9)); + assert!(list.verts.iter().any(|v| v.x == 0.0) && list.verts.iter().any(|v| v.x == 100.0)); + // A closed polyline as the boundary. + let pl = h(&s.execute("pline", &json!({ "vertices": [[10, -5], [30, -5], [30, 5], [10, 5]], "closed": true })).unwrap()); + s.execute("xclip", &json!({ "handle": r1, "polyline": pl })).unwrap(); + let c = clip(&s, &r1).unwrap(); + assert!(!c.inverted && c.points.len() == 4); + // An open polyline is refused, a bad boundary changes nothing. + let open = h(&s.execute("pline", &json!({ "vertices": [[0, 0], [5, 5], [9, 0]] })).unwrap()); + assert!(s.execute("xclip", &json!({ "handle": r1, "polyline": open })).is_err()); + assert!(s.execute("xclip", &json!({ "handle": r1, "points": [[0, 0], [1, 1]] })).is_err()); + assert!(s.execute("xclip", &json!({ "handle": r1, "rectangle": [[0, 0], [0, 10]] })).is_err()); + assert_eq!(clip(&s, &r1), Some(c)); + // Generate polyline makes the boundary an object. + let n = s.doc().unwrap().model.len(); + let out = s.execute("xclip", &json!({ "handle": r1, "option": "polyline" })).unwrap(); + assert_eq!(out["polylines"].as_array().unwrap().len(), 1); + assert_eq!(s.doc().unwrap().model.len(), n + 1); + // Clip depth has no effect in 2D. + assert_eq!(s.execute("xclip", &json!({ "handle": r1, "option": "clipdepth" })).unwrap()["ignored"], true); +} + +#[test] +fn hostile_params_never_panic() { + let (mut s, r1, _) = setup(); + for p in [ + json!({ "handle": r1, "points": "x" }), + json!({ "handle": r1, "points": [[1e308, 0], [-1e308, 0], [0, 1e308]] }), + json!({ "handle": r1, "rectangle": [[f64::MAX, 0]] }), + json!({ "handle": r1, "rectangle": 5 }), + json!({ "handle": r1, "polyline": "ZZZZ" }), + json!({ "handle": r1, "option": "bogus" }), + json!({ "handle": "nothex" }), + json!({ "handles": [] }), + json!({ "handle": r1, "points": vec![[0.0, 0.0]; 5000] }), + ] { + let _ = s.execute("xclip", &p); + } + assert!(s.execute("xclipframe", &json!({ "value": 7 })).is_err()); + assert!(s.execute("xclip", &json!({ "handle": r1, "option": "on" })).is_err(), "no boundary to turn on"); +} + +#[test] +fn typed_xclip_follows_the_prompts() { + let (mut s, r1, r2) = setup(); + let pick = |hex: &str| Input::Pick(vec![Handle::parse_hex(hex).unwrap()]); + s.start("xclip").unwrap(); + s.input(pick(&r1)).unwrap(); + s.input(Input::Enter).unwrap(); + assert!(s.prompt_text().contains("Enter clipping option"), "{}", s.prompt_text()); + s.input(Input::Enter).unwrap(); // New + assert!(s.prompt_text().contains("Invert clip"), "{}", s.prompt_text()); + s.input(Input::Keyword("Invert clip".into())).unwrap(); + assert!(s.log.iter().any(|l| l.contains("Inside mode"))); + s.input(Input::Keyword("Rectangular".into())).unwrap(); + s.input(Input::Point(Vec2::new(10.0, -10.0))).unwrap(); + s.input(Input::Point(Vec2::new(30.0, 10.0))).unwrap(); + let c = clip(&s, &r1).unwrap(); + assert!(c.inverted && c.points.len() == 2); + // Again on the clipped reference: "Delete old boundary(s)?", then a polygon. + s.cmdline("xclip").unwrap(); + s.input(pick(&r1)).unwrap(); + s.input(Input::Enter).unwrap(); + s.cmdline("n").unwrap(); + assert!(s.prompt_text().contains("Delete old boundary"), "{}", s.prompt_text()); + s.cmdline("y").unwrap(); + s.cmdline("p").unwrap(); + for p in ["0,-5", "20,-5", "20,5"] { + s.cmdline(p).unwrap(); + } + s.input(Input::Enter).unwrap(); + let c = clip(&s, &r1).unwrap(); + assert!(!c.inverted && c.points.len() == 3); + // OFF, then Delete on two references at once. + s.start("xclip").unwrap(); + s.input(pick(&r1)).unwrap(); + s.input(Input::Enter).unwrap(); + s.cmdline("off").unwrap(); + assert!(!clip(&s, &r1).unwrap().enabled); + s.execute("xclip", &json!({ "handle": r2, "rectangle": [[0, 0], [1, 1]] })).unwrap(); + s.start("xclip").unwrap(); + s.input(Input::Pick(vec![Handle::parse_hex(&r1).unwrap(), Handle::parse_hex(&r2).unwrap()])).unwrap(); + s.input(Input::Enter).unwrap(); + s.cmdline("d").unwrap(); + assert!(clip(&s, &r1).is_none() && clip(&s, &r2).is_none()); + // Delete with no boundary left says so and asks again. + s.start("xclip").unwrap(); + s.input(pick(&r1)).unwrap(); + s.input(Input::Enter).unwrap(); + s.cmdline("d").unwrap(); + assert!(s.log.iter().any(|l| l.contains("No clip boundary found.")), "{:?}", s.log); + assert!(s.prompt_text().contains("Enter clipping option"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + // XCLIPFRAME typed. + s.cmdline("xclipframe").unwrap(); + s.cmdline("1").unwrap(); + assert_eq!(s.execute("getvar", &json!({ "name": "XCLIPFRAME" })).unwrap()["value"], 1); +} + +#[test] +fn the_frame_shows_and_plots_as_xclipframe_says() { + let (mut s, r1, _) = setup(); + s.execute("xclip", &json!({ "handle": r1, "rectangle": [[20, -10], [70, 10]] })).unwrap(); + let frame_at = |s: &Session, plot: bool| { + let d = s.doc().unwrap(); + let list = + if plot { render::build_plot(d, &Space::Model, &Options::default()) } else { render::build(d, &Space::Model, &Options::default()) }; + list.verts.iter().any(|v| (v.y - 10.0).abs() < 1e-9 && (v.x - 20.0).abs() < 1e-9) + }; + // Default 2: shown, not plotted. + assert!(frame_at(&s, false) && !frame_at(&s, true)); + s.execute("xclipframe", &json!({ "value": 1 })).unwrap(); + assert!(frame_at(&s, false) && frame_at(&s, true)); + s.execute("xclipframe", &json!({ "value": 0 })).unwrap(); + assert!(!frame_at(&s, false) && !frame_at(&s, true)); +} diff --git a/crates/engine/tests/shx_beside_drawing.rs b/crates/engine/tests/shx_beside_drawing.rs new file mode 100644 index 000000000..e31d0c0cf --- /dev/null +++ b/crates/engine/tests/shx_beside_drawing.rs @@ -0,0 +1,34 @@ +//! FF8: the SHX fonts a drawing's text styles name are found beside the drawing it opens. The +//! font is compiled here from shape source written for the test. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::{Drawing, TextStyle}; +use serde_json::json; + +#[test] +fn opening_a_drawing_finds_the_shx_fonts_beside_it() { + set_io(IoHooks { + read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), + write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), + plot: None, + }); + let dir = std::env::temp_dir().join(format!("cadcraft-shx-beside-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let font = cadcraft_fonts::shx::compile("*0,4,beside\n6,2,0,0\n*65,7,UCA\n1,064,040,06C,2,020,0\n").unwrap(); + std::fs::write(dir.join("cadcraft-beside-engine.shx"), font).unwrap(); + let mut d = Drawing::new_imperial(); + d.text_styles.push(TextStyle { name: "Beside".into(), font: "cadcraft-beside-engine.shx".into(), ..TextStyle::default() }); + let path = dir.join("host.dxf"); + std::fs::write(&path, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap(); + + assert!(!cadcraft_fonts::TextFont::resolve("cadcraft-beside-engine.shx").is_shx()); + let mut s = Session::new(); + s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap(); + let style = s.doc().unwrap().text_style("Beside").cloned().unwrap(); + let font = cadcraft_fonts::TextFont::resolve_style(&style.font, &style.big_font, style.vertical); + assert!(font.is_shx()); + assert!((cadcraft_fonts::text_width(&font, "AA", 3.0, 1.0) - 6.0).abs() < 1e-9); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/usage.rs b/crates/engine/tests/usage.rs index 9754ce766..e70d9d638 100644 --- a/crates/engine/tests/usage.rs +++ b/crates/engine/tests/usage.rs @@ -50,6 +50,7 @@ fn insert(handle: u64, block: &str, layer: &str) -> Entity { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ); e.common = Common { layer: layer.into(), ..Common::default() }; diff --git a/crates/engine/tests/view_twist.rs b/crates/engine/tests/view_twist.rs new file mode 100644 index 000000000..02eb8d7fe --- /dev/null +++ b/crates/engine/tests/view_twist.rs @@ -0,0 +1,193 @@ +//! The view twist (VIEWTWIST): DVIEW TWist turns the Model tab's view, ZOOM Previous turns it +//! back, each VPORTS tile keeps its own (DXF VPORT group 51), and VIEW Save/Restore keep it. + +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{Session, View, sysvars}; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.viewport_px = (1200.0, 800.0); + s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap(); + s.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap(); + s +} + +fn typed(s: &mut Session, lines: &[&str]) { + for l in lines { + s.cmdline(l).unwrap(); + } +} + +fn view(s: &Session) -> View { + s.state().unwrap().view() +} + +fn twist_deg(s: &Session) -> f64 { + view(s).twist.to_degrees() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +#[test] +fn dview_twist_sets_viewtwist_and_zoom_previous_turns_it_back() { + let mut s = session(); + assert_eq!(view(&s).twist, 0.0); + typed(&mut s, &["dview", "", "tw", "30", ""]); + assert!(near(twist_deg(&s), 30.0), "twist {}", twist_deg(&s)); + let vt = sysvars::get(&s, "VIEWTWIST").and_then(|v| v.as_f64()).unwrap(); + assert!(near(vt, 30.0), "VIEWTWIST {vt}"); + // Only the twist changed; the view is not an undo step. + assert_eq!((view(&s).center, view(&s).height), (Vec2::new(10.0, 10.0), 20.0)); + assert_eq!(s.state().unwrap().undo.len(), 1, "just the line"); + // Zooming keeps the twist; ZOOM Previous steps back through it. + s.execute("zoom", &json!({ "mode": "in" })).unwrap(); + assert!(near(twist_deg(&s), 30.0)); + s.execute("zoom", &json!({ "mode": "previous" })).unwrap(); + s.execute("zoom", &json!({ "mode": "previous" })).unwrap(); + assert_eq!(view(&s).twist, 0.0); + assert!(sysvars::is_read_only("VIEWTWIST")); + // JSON form; negative angles wrap into 0..360. + s.execute("dview", &json!({ "twist": -90 })).unwrap(); + assert!(near(twist_deg(&s), 270.0)); + // The 3D options are not available yet; the command carries on. + typed(&mut s, &["dview", "", "ca", ""]); + assert!(s.current_prompt().is_none()); + // Not in a layout. + let layout = s.doc().unwrap().layouts.first().map(|l| l.name.clone()).unwrap(); + s.execute("layout.set", &json!({ "name": layout })).unwrap(); + assert!(s.execute("dview", &json!({ "twist": 10 })).is_err()); +} + +const PAPER: &str = "This command is not available in Paper space."; + +#[test] +fn dview_in_paper_space_says_it_is_not_available() { + let mut s = session(); + let layout = s.doc().unwrap().layouts.first().map(|l| l.name.clone()).unwrap(); + s.execute("layout.set", &json!({ "name": layout })).unwrap(); + let e = s.execute("dview", &json!({ "twist": 10 })).unwrap_err(); + assert_eq!(e.to_string(), PAPER); + // Typed, it is refused as it starts: no prompt is left open. + let e = s.cmdline("dview").unwrap_err(); + assert_eq!(e.to_string(), PAPER); + assert!(s.current_prompt().is_none()); + // view.set can't turn a layout's view either (an untwisted one is fine). + let e = s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap_err(); + assert_eq!(e.to_string(), PAPER); + s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap(); + assert_eq!(view(&s).twist, 0.0); +} + +#[test] +fn a_single_untiled_view_keeps_its_twist_through_a_dxf_round_trip() { + let mut s = session(); + s.execute("dview", &json!({ "twist": 30 })).unwrap(); + assert!(s.model_tiles().len() <= 1, "untiled"); + let dxf = cadcraft_io::write_dxf(s.doc().unwrap()); + let lines: Vec<&str> = dxf.lines().map(str::trim).collect(); + let written = lines.windows(2).any(|w| w[0] == "51" && w[1].parse::().is_ok_and(|a| near(a, 30.0))); + assert!(written, "the lone *ACTIVE VPORT carries group 51"); + let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap(); + let mut o = Session::new(); + o.viewport_px = (1200.0, 800.0); + o.open_drawing(back.clone(), "single", None); + assert!(near(twist_deg(&o), 30.0), "twist {}", twist_deg(&o)); + assert!(o.model_tiles().len() <= 1, "still untiled"); + // And again: saved from the reopened session, it is still turned. + let again = cadcraft_io::read_dxf(cadcraft_io::write_dxf(o.doc().unwrap()).as_bytes()).unwrap(); + let mut o2 = Session::new(); + o2.viewport_px = (1200.0, 800.0); + o2.open_drawing(again, "again", None); + assert!(near(twist_deg(&o2), 30.0)); + // Turned back (ZOOM Previous here), the drawing keeps no twist. + s.execute("zoom", &json!({ "mode": "previous" })).unwrap(); + assert_eq!(view(&s).twist, 0.0); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let mut o3 = Session::new(); + o3.viewport_px = (1200.0, 800.0); + o3.open_drawing(back, "plain", None); + assert_eq!(view(&o3).twist, 0.0); +} + +#[test] +fn zoom_extents_fits_in_the_twisted_frame() { + let mut s = session(); + s.execute("dview", &json!({ "twist": 90 })).unwrap(); + s.execute("zoom", &json!({ "mode": "extents" })).unwrap(); + let v = view(&s); + // Turned a quarter, the 100 x 50 line's box stands 100 high on screen. + assert!(near(v.twist.to_degrees(), 90.0)); + assert!((v.center - Vec2::new(50.0, 25.0)).len() < 1e-6, "{:?}", v.center); + assert!(near(v.height, 105.0), "height {}", v.height); +} + +#[test] +fn each_tile_keeps_its_twist_through_a_dxf_round_trip() { + let mut s = session(); + s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap(); + s.execute("dview", &json!({ "twist": 30 })).unwrap(); + let first = s.model_tiles().iter().position(|t| t.active).unwrap(); + s.activate_tile(1 - first).unwrap(); + assert_eq!(view(&s).twist, 0.0, "the other tile has its own twist"); + s.execute("dview", &json!({ "twist": 60 })).unwrap(); + let mut tw: Vec = s.model_tiles().iter().map(|t| t.view.twist.to_degrees()).collect(); + tw.sort_by(f64::total_cmp); + assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}"); + // Switching back shows the first tile's twist again. + s.activate_tile(first).unwrap(); + assert!(near(twist_deg(&s), 30.0)); + // A saved configuration keeps both twists through DXF (VPORT group 51). + s.execute("-vports", &json!({ "option": "save", "name": "Pair" })).unwrap(); + let dxf = cadcraft_io::write_dxf(s.doc().unwrap()); + let lines: Vec<&str> = dxf.lines().map(str::trim).collect(); + let written = lines.windows(2).any(|w| w[0] == "51" && w[1].parse::().is_ok_and(|a| near(a, 30.0))); + assert!(written, "group 51 written"); + let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap(); + let pair = back.vports.iter().find(|c| c.name == "Pair").unwrap(); + let mut tw: Vec = pair.tiles.iter().map(|t| t.twist.to_degrees()).collect(); + tw.sort_by(f64::total_cmp); + assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}"); + // Opened, restoring the configuration shows each tile turned as saved. + let mut o = Session::new(); + o.viewport_px = (1200.0, 800.0); + o.open_drawing(back, "pair", None); + o.execute("-vports", &json!({ "option": "restore", "name": "Pair" })).unwrap(); + let mut tw: Vec = o.model_tiles().iter().map(|t| t.view.twist.to_degrees()).collect(); + tw.sort_by(f64::total_cmp); + assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}"); +} + +#[test] +fn opening_a_tiled_drawing_shows_the_current_tile_twisted() { + let mut s = session(); + s.execute("dview", &json!({ "twist": 45 })).unwrap(); + s.execute("-vports", &json!({ "option": "2" })).unwrap(); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let mut o = Session::new(); + o.viewport_px = (1200.0, 800.0); + o.open_drawing(back, "tiled", None); + assert!(near(twist_deg(&o), 45.0), "twist {}", twist_deg(&o)); +} + +#[test] +fn view_save_and_restore_keep_the_twist() { + let mut s = session(); + s.execute("dview", &json!({ "twist": 30 })).unwrap(); + s.execute("view", &json!({ "option": "save", "name": "Turned" })).unwrap(); + let saved = s.doc().unwrap().views.iter().find(|v| v.name == "Turned").map(|v| v.twist.to_degrees()).unwrap(); + assert!(near(saved, 30.0)); + s.execute("dview", &json!({ "twist": 0 })).unwrap(); + s.execute("view.set", &json!({ "center": [80, 40], "height": 3 })).unwrap(); + assert_eq!(view(&s).twist, 0.0); + s.execute("view", &json!({ "option": "restore", "name": "Turned" })).unwrap(); + assert!(near(twist_deg(&s), 30.0)); + assert!((view(&s).center - Vec2::new(10.0, 10.0)).len() < 1e-9); + // view.set keeps the twist unless given one. + s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap(); + assert!(near(twist_deg(&s), 30.0)); + s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap(); + assert!(near(twist_deg(&s), 15.0)); +} diff --git a/crates/engine/tests/xref.rs b/crates/engine/tests/xref.rs index 27fa505c4..87c645a81 100644 --- a/crates/engine/tests/xref.rs +++ b/crates/engine/tests/xref.rs @@ -52,6 +52,7 @@ fn insert(block: &str, x: f64) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 0fc676c7b..59c03b0d8 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -14,3 +14,4 @@ skrifa = { workspace = true } harfrust = { workspace = true } unicode-bidi = { workspace = true } icu_segmenter = { workspace = true } +encoding_rs = { workspace = true } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 983110b4d..456769caa 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -1,7 +1,8 @@ //! CADCraft text layout. //! //! Turns TEXT and MTEXT into stroke polylines with the built-in single-stroke font -//! ("CADCraft Stroke") or glyph outlines of installed TrueType fonts ([`ttf`]), with a +//! ("CADCraft Stroke"), the user's SHX shape fonts and big fonts ([`shx`]) or glyph outlines +//! of installed TrueType fonts ([`ttf`]), with a //! per-character fallback to installed fonts for characters the chosen font lacks. Handles the //! `%%` control codes, alignment modes, width factor, obliquing, MTEXT inline formatting //! (paragraphs, stacking, height changes), word wrap and attachment points. @@ -18,6 +19,7 @@ pub mod bidi; mod complex; mod linebreak; mod mtext; +pub mod shx; mod stroke; pub mod subset; pub mod ttf; @@ -42,8 +44,8 @@ pub struct Run { } /// Replace `%%` control codes: `%%d` degree, `%%p` plus/minus, `%%c` diameter, `%%%` percent, -/// `%%nnn` character code. Underline/overline toggles (`%%u`, `%%o`) are returned as flags -/// per character. +/// `%%nnn` character code, and `\U+XXXX` Unicode escapes. Underline/overline toggles (`%%u`, +/// `%%o`) are returned as flags per character. pub fn decode_controls(s: &str) -> Vec<(char, bool, bool)> { let mut out = Vec::with_capacity(s.len()); let mut under = false; @@ -52,6 +54,14 @@ pub fn decode_controls(s: &str) -> Vec<(char, bool, bool)> { let mut i = 0; while i < chars.len() { let c = chars.get(i).copied().unwrap_or(' '); + // `\U+XXXX`: a Unicode character by its code. + if c == '\\' + && let Some(u) = unicode_escape(&chars, i) + { + out.push((u, under, over)); + i += 7; + continue; + } if c == '%' && chars.get(i + 1) == Some(&'%') { let code = chars.get(i + 2).copied().unwrap_or(' '); match code.to_ascii_lowercase() { @@ -83,6 +93,18 @@ pub fn decode_controls(s: &str) -> Vec<(char, bool, bool)> { out } +/// The character of a `\U+XXXX` escape (four hex digits) starting at `chars[i]`. +pub(crate) fn unicode_escape(chars: &[char], i: usize) -> Option { + if chars.get(i) != Some(&'\\') || !matches!(chars.get(i + 1), Some('U' | 'u')) || chars.get(i + 2) != Some(&'+') { + return None; + } + let hex: String = chars.get(i + 3..i + 7)?.iter().collect(); + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + char::from_u32(u32::from_str_radix(&hex, 16).ok()?) +} + /// Advance width of one character at height 1 and width factor 1. pub fn char_advance(c: char) -> f64 { match stroke::glyph(c) { @@ -279,35 +301,65 @@ pub fn space_width(font: &TextFont, height: f64, width_factor: f64) -> f64 { { return w; } + if let TextFont::Shx(t) = font { + return shx::width(t, " ", height, width_factor); + } stroke_advance(' ', height, width_factor) + stroke::GAP / stroke::CAP * height * width_factor } -/// The font a piece of text is set in: the built-in stroke font or an installed TrueType / -/// OpenType face (outlines). Either way, characters the font lacks come from a fallback font. +/// The font a piece of text is set in: the built-in stroke font, an installed TrueType / +/// OpenType face (outlines) or the user's SHX fonts (a main font and a big font). Whichever, +/// characters the font lacks come from a fallback font. #[derive(Clone, Debug, Default)] pub enum TextFont { #[default] Stroke, Outline(ttf::Face), + Shx(std::sync::Arc), } impl TextFont { - /// Resolve a text style font name: an installed TTF/OTF/TTC when found; else, for a font we - /// can't find (an SHX file, a missing TTF), the FONTALT font ([`ttf::font_alt`]) when set; - /// else the stroke font. + /// Resolve a text style font name (no big font, horizontal): see [`TextFont::resolve_style`]. pub fn resolve(name: &str) -> TextFont { - if let Some(f) = ttf::find(name) { + TextFont::resolve_style(name, "", false) + } + + /// Resolve a text style's fonts: an installed TTF/OTF/TTC when `font` names one; else the + /// SHX font and big font when found ([`shx::find`]), a missing one replaced by the FONTALT + /// font ([`ttf::font_alt`], which may name both: `simplex.shx,bigfont.shx`); else FONTALT + /// as a TrueType font; else the stroke font (which also draws the characters a missing big + /// font would have). `vertical` is the style's vertical flag, honoured by fonts made for it. + pub fn resolve_style(font: &str, big_font: &str, vertical: bool) -> TextFont { + if let Some(f) = ttf::find(font) { return TextFont::Outline(f); } - let n = name.trim(); - if n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT) { + let n = font.trim(); + let builtin = n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT); + let alt = ttf::font_alt(); + let (alt_main, alt_big) = alt.split_once(',').map_or((alt.as_str(), ""), |(a, b)| (a.trim(), b.trim())); + let main = if builtin { None } else { shx::find(n).or_else(|| shx::find(alt_main)) }; + let (big, big_name) = match big_font.trim() { + "" => (None, ""), + b => match shx::find(b) { + Some(f) => (Some(f), b), + None => (shx::find(alt_big), alt_big), + }, + }; + if main.is_some() || big.is_some() { + return TextFont::Shx(std::sync::Arc::new(shx::ShxText::new(main, big, big_name, vertical))); + } + if builtin { return TextFont::Stroke; } - ttf::find(&ttf::font_alt()).map(TextFont::Outline).unwrap_or(TextFont::Stroke) + ttf::find(alt_main).map(TextFont::Outline).unwrap_or(TextFont::Stroke) } pub fn is_outline(&self) -> bool { matches!(self, TextFont::Outline(_)) } + /// Whether the text is drawn with SHX fonts. + pub fn is_shx(&self) -> bool { + matches!(self, TextFont::Shx(_)) + } } /// Shaped text in local or world coordinates: open strokes (stroke-font glyphs, underlines, @@ -436,6 +488,9 @@ pub fn shape_line_dir(font: &TextFont, s: &str, height: f64, width_factor: f64, { return sh; } + if let TextFont::Shx(t) = font { + return shx::shape(t, s, height, width_factor, oblique); + } shape_stroke_dir(s, height, width_factor, oblique, base_rtl) } @@ -450,6 +505,9 @@ pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f { return w; } + if let TextFont::Shx(t) = font { + return shx::width(t, s, height, width_factor); + } line_width(s, height, width_factor) } @@ -529,7 +587,8 @@ pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 { fn bottom_depth(font: &TextFont, height: f64) -> f64 { match font { TextFont::Outline(face) => ttf::descent_ratio(face).map_or(height / 3.0, |r| r * height), - TextFont::Stroke => height / 3.0, + // AutoCAD places it a third of the height down with SHX fonts. + TextFont::Stroke | TextFont::Shx(_) => height / 3.0, } } diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index a8d49fcbd..e3bc973a5 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -55,6 +55,13 @@ pub fn plain_mtext(s: &str) -> String { i = j + 1; } 'L' | 'l' | 'O' | 'o' | 'K' | 'k' => i += 2, + // `\U+XXXX`: a character by its Unicode code, as the layout reads it. + 'U' if crate::unicode_escape(&chars, i).is_some() => { + if let Some(u) = crate::unicode_escape(&chars, i) { + out.push(u); + } + i += 7; + } // Codes with an argument terminated by ';' (\f, \F, \H, \W, \Q, \T, \A, \C, \c, \p). _ => { let mut j = i + 2; @@ -287,6 +294,13 @@ fn parse(contents: &str, base: Fmt) -> (Vec, Vec) { 'O' => derive(&mut fmts, &mut cur, &|f| f.over = true), 'o' => derive(&mut fmts, &mut cur, &|f| f.over = false), 'K' | 'k' => {} + // `\U+XXXX`: a character by its Unicode code. + 'U' if i >= 2 && crate::unicode_escape(&chars, i - 2).is_some() => { + if let Some(u) = crate::unicode_escape(&chars, i - 2) { + push_char(&mut word, u, cur); + } + i += 5; + } 'S' => { let mut j = i; let (mut top, mut bottom, mut sep) = (String::new(), String::new(), None); @@ -785,6 +799,8 @@ mod tests { assert_eq!(plain_mtext("a\\~b\\\\c"), "a b\\c"); assert_eq!(plain_mtext("\\H2.5x;Big"), "Big"); assert_eq!(plain_mtext("unterminated \\H2"), "unterminated "); + assert_eq!(plain_mtext("\\U+0041B \\U+00E8"), "AB è"); + assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument"); } #[test] diff --git a/crates/fonts/src/shx/compile.rs b/crates/fonts/src/shx/compile.rs new file mode 100644 index 000000000..2e8cb3c25 --- /dev/null +++ b/crates/fonts/src/shx/compile.rs @@ -0,0 +1,313 @@ +//! Shape source (`.shp`) to a compiled SHX file, as COMPILE does. +//! +//! A source is a list of definitions: a header line `*number,defbytes,NAME` followed by its +//! specification bytes (on as many lines as needed, ending with 0). Numbers are decimal, or +//! hexadecimal after a leading `0` (`012`, `08cfb`); parentheses only group; `;` starts a +//! comment. A big font starts with `*BIGFONT nchars,nranges,first,last,…` and a Unicode font +//! with `*UNIFONT,6,name` followed by `above,below,modes,encoding,type,0`. Shape 0 is a text +//! font's record (`above,below,modes,0`). +//! +//! Operands are encoded by what they are: a subshape number takes two bytes in a Unicode font +//! (and after `7,0` in a big font), displacements and bulges are two's complement, and an arc's +//! direction byte (`-043`) keeps its sign in the high bit. `defbytes` is not checked: the +//! program's own length is written. + +use super::ShxError; +use super::parse::{BIGFONT, SHAPES_10, UNIFONT}; + +/// Largest source compiled. +const MAX_SOURCE: usize = 8 << 20; + +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Shapes, + Big, + Uni, +} + +struct Shape { + number: u16, + name: String, + program: Vec, +} + +/// A number token: value (sign included). +fn number(tok: &str, line: usize) -> Result { + let t: String = tok.chars().filter(|c| !matches!(c, '(' | ')') && !c.is_whitespace()).collect(); + let (neg, digits) = match t.strip_prefix('-') { + Some(d) => (true, d), + None => (false, t.strip_prefix('+').unwrap_or(&t)), + }; + let err = || ShxError::Source(line, format!("not a number: {tok:?}")); + let v = if digits.len() > 1 && digits.starts_with('0') { + i64::from_str_radix(digits.get(1..).unwrap_or_default(), 16).map_err(|_| err())? + } else { + digits.parse::().map_err(|_| err())? + }; + if v > 0xFFFF { + return Err(err()); + } + Ok(if neg { -v } else { v }) +} + +/// The comma-separated numbers of a line (comment removed, blank fields skipped). +fn numbers(text: &str, line: usize) -> Result, ShxError> { + text.split(',').map(str::trim).filter(|t| !t.is_empty() && *t != "(" && *t != ")").map(|t| number(t, line)).collect() +} + +/// The spec-byte encoder: walks the tokens as the reader walks the bytes. +struct Enc<'a> { + toks: &'a [i64], + i: usize, + out: Vec, + kind: Kind, + line: usize, +} + +impl Enc<'_> { + fn next(&mut self) -> Result { + let v = self.toks.get(self.i).copied().ok_or_else(|| ShxError::Source(self.line, "the shape ends inside a command".into()))?; + self.i += 1; + Ok(v) + } + fn byte(&mut self) -> Result<(), ShxError> { + let v = self.next()?; + let b = u8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a byte")))?; + self.out.push(b); + Ok(()) + } + /// Two's complement −128..127. + fn signed(&mut self) -> Result { + let v = self.next()?; + let b = i8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not −128..127")))?; + self.out.extend(b.to_le_bytes()); + Ok(v) + } + /// Sign in the high bit (an arc's direction byte). + fn sign_magnitude(&mut self) -> Result<(), ShxError> { + let v = self.next()?; + let m = u8::try_from(v.unsigned_abs()) + .ok() + .filter(|m| *m < 0x80) + .ok_or_else(|| ShxError::Source(self.line, format!("{v} is not an octant byte")))?; + self.out.push(if v < 0 { m | 0x80 } else { m }); + Ok(()) + } + fn word(&mut self) -> Result<(), ShxError> { + let v = self.next()?; + let w = u16::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a shape number")))?; + self.out.extend(w.to_be_bytes()); + Ok(()) + } + + fn program(mut self) -> Result, ShxError> { + while self.i < self.toks.len() { + let code = self.next()?; + let c = u8::try_from(code).map_err(|_| ShxError::Source(self.line, format!("{code} is not a code")))?; + self.out.push(c); + match c { + 0 => break, + 3 | 4 => self.byte()?, + 7 => match self.kind { + Kind::Uni => self.word()?, + Kind::Big => { + let first = self.toks.get(self.i).copied().unwrap_or(0); + if first == 0 { + self.byte()?; + self.word()?; + for _ in 0..4 { + self.byte()?; + } + } else { + self.byte()?; + } + } + Kind::Shapes => self.byte()?, + }, + 8 => { + self.signed()?; + self.signed()?; + } + 9 => loop { + let (x, y) = (self.signed()?, self.signed()?); + if x == 0 && y == 0 { + break; + } + }, + 10 => { + self.byte()?; + self.sign_magnitude()?; + } + 11 => { + for _ in 0..4 { + self.byte()?; + } + self.sign_magnitude()?; + } + 12 => { + for _ in 0..3 { + self.signed()?; + } + } + 13 => loop { + let (x, y) = (self.signed()?, self.signed()?); + if x == 0 && y == 0 { + break; + } + self.signed()?; + }, + _ => {} + } + } + if self.out.last() != Some(&0) { + self.out.push(0); + } + Ok(self.out) + } +} + +/// Compile shape source into an SHX file (a shape file or text font, a big font, or a Unicode +/// font, as its first definition says). +pub fn compile(source: &str) -> Result, ShxError> { + if source.len() > MAX_SOURCE { + return Err(ShxError::TooLarge); + } + let mut kind = Kind::Shapes; + let mut ranges: Vec<(u16, u16)> = Vec::new(); + let mut uni_info: Option<(String, Vec)> = None; + // Definitions: header (number, name, line) and the numbers that follow it. + let mut defs: Vec<(i64, String, usize, Vec)> = Vec::new(); + let mut uni_header_pending = false; + for (no, raw) in source.lines().enumerate() { + let line = no + 1; + let text = raw.split(';').next().unwrap_or_default().trim(); + if text.is_empty() { + continue; + } + if let Some(head) = text.strip_prefix('*') { + let upper = head.trim_start().to_ascii_uppercase(); + if let Some(rest) = upper.strip_prefix("BIGFONT") { + kind = Kind::Big; + let v = numbers(rest, line)?; + let n = usize::try_from(v.get(1).copied().unwrap_or(0)).unwrap_or(0); + for k in 0..n { + let lo = v.get(2 + 2 * k).copied().unwrap_or(0); + let hi = v.get(3 + 2 * k).copied().unwrap_or(lo); + let to = |x: i64| u16::try_from(x).map_err(|_| ShxError::Source(line, "bad escape range".into())); + ranges.push((to(lo)?, to(hi)?)); + } + continue; + } + if upper.starts_with("UNIFONT") { + kind = Kind::Uni; + let name = head.splitn(3, ',').nth(2).unwrap_or_default().trim().to_string(); + uni_info = Some((name, Vec::new())); + uni_header_pending = true; + continue; + } + uni_header_pending = false; + let mut parts = head.splitn(3, ','); + let num = number(parts.next().unwrap_or_default().trim(), line)?; + let _defbytes = parts.next(); + let name = parts.next().unwrap_or_default().trim().to_string(); + defs.push((num, name, line, Vec::new())); + continue; + } + let v = numbers(text, line)?; + if uni_header_pending { + if let Some((_, info)) = uni_info.as_mut() { + for x in v { + info.push(u8::try_from(x).map_err(|_| ShxError::Source(line, "bad font record".into()))?); + } + } + continue; + } + match defs.last_mut() { + Some(d) => d.3.extend(v), + None => return Err(ShxError::Source(line, "specification bytes before any *number,defbytes,NAME line".into())), + } + } + let mut shapes: Vec = Vec::new(); + for (num, name, line, toks) in defs { + let number = u16::try_from(num).map_err(|_| ShxError::Source(line, format!("shape number {num} out of range")))?; + let program = if number == 0 { + // The font record: plain bytes. + toks.iter().map(|x| u8::try_from(*x).map_err(|_| ShxError::Source(line, "bad font record".into()))).collect::, _>>()? + } else { + Enc { toks: &toks, i: 0, out: Vec::new(), kind, line }.program()? + }; + if shapes.iter().any(|s| s.number == number) { + return Err(ShxError::Source(line, format!("shape {number} is defined twice"))); + } + shapes.push(Shape { number, name, program }); + } + if shapes.is_empty() { + return Err(ShxError::Empty); + } + shapes.sort_by_key(|s| s.number); + let record = |s: &Shape| -> Result, ShxError> { + let mut r: Vec = s.name.bytes().filter(|b| *b != 0).collect(); + r.push(0); + r.extend(&s.program); + if r.len() > usize::from(u16::MAX) { Err(ShxError::TooLarge) } else { Ok(r) } + }; + let count = u16::try_from(shapes.len()).map_err(|_| ShxError::TooLarge)?; + let len16 = |r: &[u8]| u16::try_from(r.len()).unwrap_or(u16::MAX); + let mut out = Vec::new(); + match kind { + Kind::Shapes => { + out.extend(SHAPES_10); + out.extend(shapes.first().map_or(0, |s| s.number).to_le_bytes()); + out.extend(shapes.last().map_or(0, |s| s.number).to_le_bytes()); + out.extend(count.to_le_bytes()); + let recs: Vec> = shapes.iter().map(record).collect::>()?; + for (s, r) in shapes.iter().zip(&recs) { + out.extend(s.number.to_le_bytes()); + out.extend(len16(r).to_le_bytes()); + } + for r in &recs { + out.extend(r); + } + out.extend(b"EOF"); + } + Kind::Big => { + out.extend(BIGFONT); + let nranges = u16::try_from(ranges.len()).map_err(|_| ShxError::TooLarge)?; + out.extend(count.to_le_bytes()); + out.extend(count.to_le_bytes()); + out.extend(nranges.to_le_bytes()); + for (lo, hi) in &ranges { + out.extend(lo.to_le_bytes()); + out.extend(hi.to_le_bytes()); + } + let recs: Vec> = shapes.iter().map(record).collect::>()?; + let mut off = out.len() + 8 * recs.len(); + for (s, r) in shapes.iter().zip(&recs) { + out.extend(s.number.to_le_bytes()); + out.extend(len16(r).to_le_bytes()); + out.extend(u32::try_from(off).map_err(|_| ShxError::TooLarge)?.to_le_bytes()); + off += r.len(); + } + for r in &recs { + out.extend(r); + } + } + Kind::Uni => { + out.extend(UNIFONT); + let (name, info) = uni_info.unwrap_or_default(); + out.extend((u32::from(count) + 1).to_le_bytes()); + let mut rec: Vec = name.bytes().filter(|b| *b != 0).collect(); + rec.push(0); + rec.extend(&info); + out.extend(len16(&rec).to_le_bytes()); + out.extend(&rec); + for s in &shapes { + let r = record(s)?; + out.extend(s.number.to_le_bytes()); + out.extend(len16(&r).to_le_bytes()); + out.extend(r); + } + } + } + Ok(out) +} diff --git a/crates/fonts/src/shx/mod.rs b/crates/fonts/src/shx/mod.rs new file mode 100644 index 000000000..9ad352a2e --- /dev/null +++ b/crates/fonts/src/shx/mod.rs @@ -0,0 +1,248 @@ +//! SHX shape fonts: text fonts, big fonts (East Asian double-byte) and Unicode fonts that a +//! drawing's text styles name, read at run time from files the user has. None is bundled. +//! +//! - [`ShxFont::parse`] reads the compiled containers (`shapes 1.0/1.1`, `bigfont 1.0`, +//! `unifont 1.0`) and [`ShxFont::glyph`] runs a shape's pen program (the vector length and +//! direction codes and special codes 0–14 of the public shape description language) into +//! polylines in font units. Every count, offset and loop in a file is bounded: a hostile or +//! truncated file never panics, it reads as far as it is valid or not at all. +//! - [`compile`] turns shape source (`.shp`) into a compiled file, as COMPILE does. The tests +//! build every font they use with it: no font file is in this repository. +//! - Fonts are found by name through the font table (`table`): registered bytes +//! ([`register`], the web build's files opened alongside a drawing), an app-set +//! [`FontProvider`], the folders of opened drawings ([`add_drawing_dir`]) and the support +//! search path ([`set_search_path`], `CADCRAFT_SHXPATH`), after the font map +//! ([`set_font_map`]). CADCraft never looks inside another CAD program's installation. +//! - [`ShxText`] is a text style's pair of fonts (main + big font) and orientation, which the +//! text layout draws ([`crate::TextFont::Shx`]): advances are the pen's end positions, so +//! widths and extents follow the font's own metrics. +//! +//! Binary container layouts (little-endian): a signature line ending in CR LF 0x1A, then +//! - shapes: first and last shape number, count, an index of (number, record length), the +//! records (a NUL-terminated name, then the program) and `EOF`; +//! - big font: index size, count, escape-range count, the ranges (first, last lead byte), +//! an index of (number, record length, file offset) and the records; +//! - unifont: shape count (32 bits), the length of the font record, the font record (name, +//! above, below, modes, encoding, embedding, 0) and then each shape as (number, length, +//! record). + +mod compile; +mod parse; +mod program; +mod table; +#[cfg(test)] +mod tests; +mod text; + +use std::collections::HashMap; +use std::sync::{Arc, PoisonError, RwLock}; + +use cadcraft_geom::Vec2; + +pub use compile::compile; +pub use table::{ + FontProvider, add_drawing_dir, clear_registered, drawing_dirs, find, font_map, generation, register, search_path, set_font_map, set_provider, + set_search_path, +}; +pub use text::ShxText; +pub(crate) use text::{shape, width}; + +/// Why a font file can't be read or a shape source can't be compiled. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ShxError { + /// Not an SHX file (unknown signature). + NotShx, + /// The file ends inside its header or index. + Truncated, + /// The file is larger, or has more of something, than CADCraft reads. + TooLarge, + /// The file is well-formed but holds no shape. + Empty, + /// Shape source error: line and message. + Source(usize, String), +} + +impl std::fmt::Display for ShxError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ShxError::NotShx => f.write_str("not an SHX shape or font file"), + ShxError::Truncated => f.write_str("the SHX file is truncated"), + ShxError::TooLarge => f.write_str("the SHX file is too large"), + ShxError::Empty => f.write_str("the SHX file has no shapes"), + ShxError::Source(line, msg) => write!(f, "line {line}: {msg}"), + } + } +} + +impl std::error::Error for ShxError {} + +/// The kind of SHX file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShxKind { + /// A shape file or a text font with single-byte character codes (`shapes 1.0/1.1`). + Shapes, + /// A big font: double-byte character codes after an escape (lead) byte. + BigFont, + /// A Unicode font: shape numbers are Unicode code points. + Unifont, +} + +/// A shape's program run into polylines, in font units (pen starts at the origin). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ShxGlyph { + /// Pen-down runs (two points or more). + pub strokes: Vec>, + /// Where the pen ends: the advance to the next character. + pub advance: Vec2, +} + +/// One shape record: where its name and program are in [`ShxFont::data`]. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Record { + name: (usize, usize), + program: (usize, usize), +} + +/// Glyphs cached per font, keyed by (shape, vertical). +type GlyphCache = HashMap<(u16, bool), Option>>; + +/// Most glyphs cached per font (a big font has thousands; text uses a few hundred). +const MAX_CACHED_GLYPHS: usize = 16_384; + +/// A parsed SHX file. +pub struct ShxFont { + kind: ShxKind, + name: String, + /// Shape 0 (the font record) is present: the file is a text font, not only shapes. + header: bool, + above: f64, + below: f64, + /// Modes 2: horizontal and vertical (code 14 is honoured). + dual: bool, + /// Unifont encoding: 0 Unicode, 1 packed multibyte, 2 shape file. + encoding: u8, + /// Extended big font: the font record gives a character width (subshape cell). + char_width: f64, + /// Big font escape ranges (lead bytes). + ranges: Vec<(u16, u16)>, + data: Vec, + records: HashMap, + cache: RwLock, +} + +impl std::fmt::Debug for ShxFont { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShxFont") + .field("kind", &self.kind) + .field("name", &self.name) + .field("above", &self.above) + .field("below", &self.below) + .field("dual", &self.dual) + .field("shapes", &self.records.len()) + .finish() + } +} + +impl ShxFont { + /// Parse a compiled SHX file. + pub fn parse(bytes: &[u8]) -> Result { + parse::parse(bytes) + } + pub fn kind(&self) -> ShxKind { + self.kind + } + /// The font name in its font record (shape 0), or empty. + pub fn name(&self) -> &str { + &self.name + } + /// Whether the file has a font record (shape 0): a text font rather than a shape file. + pub fn is_font(&self) -> bool { + self.header + } + /// Vector units uppercase letters rise above the baseline (big font: the character height). + /// Text height `h` draws the font at `h / above` per unit. 0 for a shape file. + pub fn above(&self) -> f64 { + self.above + } + /// Vector units lowercase descenders drop below the baseline. + pub fn below(&self) -> f64 { + self.below + } + /// The font supports vertical text (modes 2). + pub fn is_dual(&self) -> bool { + self.dual + } + /// The units per text height: [`Self::above`], or 1 for a file without a font record. + pub fn units_per_height(&self) -> f64 { + if self.above > 0.0 { self.above } else { 1.0 } + } + /// Big font escape ranges: `(first, last)` lead bytes. + pub fn escape_ranges(&self) -> &[(u16, u16)] { + &self.ranges + } + /// Whether `b` starts a double-byte character in this big font. + pub fn is_lead_byte(&self, b: u8) -> bool { + self.ranges.iter().any(|(lo, hi)| (*lo..=*hi).contains(&u16::from(b))) + } + /// Unifont encoding (0 Unicode, 1 packed multibyte, 2 shape file); 0 for other kinds. + pub fn encoding(&self) -> u8 { + self.encoding + } + pub fn shape_count(&self) -> usize { + self.records.len() + } + pub fn has_shape(&self, number: u16) -> bool { + number != 0 && self.records.contains_key(&number) + } + /// The shape numbers in the file, sorted (not shape 0). + pub fn shape_numbers(&self) -> Vec { + let mut v: Vec = self.records.keys().copied().filter(|n| *n != 0).collect(); + v.sort_unstable(); + v + } + /// The name of shape `number` as the file stores it. + pub fn shape_name(&self, number: u16) -> Option { + let r = self.records.get(&number)?; + self.data.get(r.name.0..r.name.1).map(|b| b.iter().map(|c| char::from(*c)).collect()) + } + /// The number of the shape called `name` (case-insensitive), as a linetype's + /// `[SHAPE,file.shx]` element names it. + pub fn shape_number(&self, name: &str) -> Option { + let want = name.trim(); + if want.is_empty() { + return None; + } + let mut found: Vec = self + .records + .iter() + .filter(|(n, r)| **n != 0 && self.data.get(r.name.0..r.name.1).is_some_and(|b| b.eq_ignore_ascii_case(want.as_bytes()))) + .map(|(n, _)| *n) + .collect(); + found.sort_unstable(); + found.first().copied() + } + /// The program bytes of shape `number`. + pub(crate) fn program(&self, number: u16) -> Option<&[u8]> { + let r = self.records.get(&number)?; + self.data.get(r.program.0..r.program.1) + } + /// Shape `number` drawn horizontally or vertically, in font units; `None` when the font + /// has no such shape or its program is broken. Cached. + pub fn glyph(&self, number: u16, vertical: bool) -> Option> { + if !self.has_shape(number) { + return None; + } + let vertical = vertical && self.dual; + let key = (number, vertical); + if let Some(g) = self.cache.read().unwrap_or_else(PoisonError::into_inner).get(&key) { + return g.clone(); + } + let g = program::outline(self, number, vertical).ok().map(Arc::new); + let mut c = self.cache.write().unwrap_or_else(PoisonError::into_inner); + if c.len() >= MAX_CACHED_GLYPHS { + c.clear(); + } + c.insert(key, g.clone()); + g + } +} diff --git a/crates/fonts/src/shx/parse.rs b/crates/fonts/src/shx/parse.rs new file mode 100644 index 000000000..b10e8142e --- /dev/null +++ b/crates/fonts/src/shx/parse.rs @@ -0,0 +1,224 @@ +//! Reading the compiled SHX containers (layouts in the module documentation). Every count and +//! offset comes from the file, so each is checked against its length; records that run past +//! the end are dropped and the rest kept. + +use std::collections::HashMap; +use std::sync::RwLock; + +use super::{Record, ShxError, ShxFont, ShxKind}; + +pub(super) const SHAPES_10: &[u8] = b"AutoCAD-86 shapes 1.0\r\n\x1a"; +pub(super) const SHAPES_11: &[u8] = b"AutoCAD-86 shapes 1.1\r\n\x1a"; +pub(super) const BIGFONT: &[u8] = b"AutoCAD-86 bigfont 1.0\r\n\x1a"; +pub(super) const UNIFONT: &[u8] = b"AutoCAD-86 unifont 1.0\r\n\x1a"; + +/// Largest file read (the largest big fonts are a few megabytes). +pub(super) const MAX_FILE_BYTES: usize = 32 << 20; +/// Most escape ranges in a big font (lead bytes: at most 256). +const MAX_RANGES: usize = 256; + +/// A checked little-endian reader. +struct Rd<'a> { + b: &'a [u8], + pos: usize, +} + +impl<'a> Rd<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8], ShxError> { + let end = self.pos.checked_add(n).ok_or(ShxError::Truncated)?; + let s = self.b.get(self.pos..end).ok_or(ShxError::Truncated)?; + self.pos = end; + Ok(s) + } + fn u16(&mut self) -> Result { + match self.take(2)? { + [a, b] => Ok(u16::from_le_bytes([*a, *b])), + _ => Err(ShxError::Truncated), + } + } + fn u32(&mut self) -> Result { + match self.take(4)? { + [a, b, c, d] => Ok(u32::from_le_bytes([*a, *b, *c, *d])), + _ => Err(ShxError::Truncated), + } + } +} + +/// A font being read. +struct Font { + kind: ShxKind, + name: String, + header: bool, + above: f64, + below: f64, + dual: bool, + encoding: u8, + char_width: f64, + ranges: Vec<(u16, u16)>, + records: HashMap, +} + +impl Font { + fn new(kind: ShxKind) -> Font { + Font { + kind, + name: String::new(), + header: false, + above: 0.0, + below: 0.0, + dual: false, + encoding: 0, + char_width: 0.0, + ranges: Vec::new(), + records: HashMap::new(), + } + } + + /// Add the record of shape `number` at `start..end` of `data`: a NUL-terminated name, then + /// the program. Shape 0 is the font record. A record without a name terminator is dropped; + /// the first record of a number wins. + fn record(&mut self, data: &[u8], number: u16, start: usize, end: usize) { + let Some(bytes) = data.get(start..end) else { return }; + let Some(nul) = bytes.iter().position(|b| *b == 0) else { return }; + let name = (start, start + nul); + let program = (start + nul + 1, end); + if number == 0 { + if !self.header { + self.font_record(data.get(program.0..program.1).unwrap_or_default(), data.get(name.0..name.1).unwrap_or_default()); + } + return; + } + self.records.entry(number).or_insert(Record { name, program }); + } + + /// The font record: above, below, modes (and for a unifont encoding and embedding); an + /// extended big font's is character height, 0, modes, character width. + fn font_record(&mut self, info: &[u8], name: &[u8]) { + self.header = true; + self.name = name.iter().take(255).map(|c| char::from(*c)).collect(); + let byte = |i: usize| info.get(i).copied().unwrap_or(0); + self.above = f64::from(byte(0)); + self.below = f64::from(byte(1)); + self.dual = byte(2) == 2; + if self.kind == ShxKind::Unifont { + self.encoding = byte(3); + } + if self.kind == ShxKind::BigFont && info.len() >= 5 && byte(1) == 0 { + // Extended: height, 0, modes, width, 0. + self.below = 0.0; + self.char_width = f64::from(byte(3)); + } + } + + fn finish(self, data: Vec) -> Result { + if self.records.is_empty() { + return Err(ShxError::Empty); + } + Ok(ShxFont { + kind: self.kind, + name: self.name, + header: self.header, + above: self.above, + below: self.below, + dual: self.dual, + encoding: self.encoding, + char_width: if self.char_width > 0.0 { self.char_width } else { self.above }, + ranges: self.ranges, + data, + records: self.records, + cache: RwLock::default(), + }) + } +} + +pub(super) fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_FILE_BYTES { + return Err(ShxError::TooLarge); + } + if bytes.starts_with(SHAPES_10) || bytes.starts_with(SHAPES_11) { + shapes(bytes) + } else if bytes.starts_with(BIGFONT) { + bigfont(bytes) + } else if bytes.starts_with(UNIFONT) { + unifont(bytes) + } else { + Err(ShxError::NotShx) + } +} + +fn shapes(bytes: &[u8]) -> Result { + let mut r = Rd { b: bytes, pos: SHAPES_10.len() }; + let _first = r.u16()?; + let _last = r.u16()?; + let count = usize::from(r.u16()?); + // The whole index must be there before anything is read from it. + let index = r.take(count.checked_mul(4).ok_or(ShxError::TooLarge)?)?; + let mut f = Font::new(ShxKind::Shapes); + let mut pos = r.pos; + for [a, b, c, d] in index.as_chunks::<4>().0 { + let (number, len) = (u16::from_le_bytes([*a, *b]), usize::from(u16::from_le_bytes([*c, *d]))); + let Some(end) = pos.checked_add(len).filter(|e| *e <= bytes.len()) else { break }; + f.record(bytes, number, pos, end); + pos = end; + } + f.finish(bytes.to_vec()) +} + +fn bigfont(bytes: &[u8]) -> Result { + let mut r = Rd { b: bytes, pos: BIGFONT.len() }; + let index_size = usize::from(r.u16()?); + let count = usize::from(r.u16()?); + let nranges = usize::from(r.u16()?); + if nranges > MAX_RANGES { + return Err(ShxError::TooLarge); + } + let mut f = Font::new(ShxKind::BigFont); + for _ in 0..nranges { + let (lo, hi) = (r.u16()?, r.u16()?); + f.ranges.push((lo.min(hi), lo.max(hi))); + } + // The index has `count` entries of 8 bytes; a file whose count doesn't fit but whose index + // size does is read by that. + let fits = |n: usize| n.checked_mul(8).and_then(|l| r.pos.checked_add(l)).is_some_and(|e| e <= bytes.len()); + let n = if fits(count) { + count + } else if fits(index_size) { + index_size + } else { + return Err(ShxError::Truncated); + }; + let index = r.take(n.checked_mul(8).ok_or(ShxError::TooLarge)?)?; + for [a, b, c, d, o0, o1, o2, o3] in index.as_chunks::<8>().0 { + let number = u16::from_le_bytes([*a, *b]); + let len = usize::from(u16::from_le_bytes([*c, *d])); + let Ok(off) = usize::try_from(u32::from_le_bytes([*o0, *o1, *o2, *o3])) else { continue }; + if len == 0 { + continue; + } + let Some(end) = off.checked_add(len).filter(|e| *e <= bytes.len()) else { continue }; + f.record(bytes, number, off, end); + } + f.finish(bytes.to_vec()) +} + +fn unifont(bytes: &[u8]) -> Result { + let mut r = Rd { b: bytes, pos: UNIFONT.len() }; + let count = r.u32()?; + let info_len = usize::from(r.u16()?); + let info = r.take(info_len)?; + let mut f = Font::new(ShxKind::Unifont); + let nul = info.iter().position(|b| *b == 0).unwrap_or(info.len()); + f.font_record(info.get(nul.saturating_add(1)..).unwrap_or_default(), info.get(..nul).unwrap_or_default()); + // `count` includes the font record; never trusted beyond the data that is there. + let mut left = count.saturating_sub(1); + while left > 0 { + left -= 1; + let Ok(number) = r.u16() else { break }; + let Ok(len) = r.u16().map(usize::from) else { break }; + let start = r.pos; + let Some(end) = start.checked_add(len).filter(|e| *e <= bytes.len()) else { break }; + r.pos = end; + f.record(bytes, number, start, end); + } + f.finish(bytes.to_vec()) +} diff --git a/crates/fonts/src/shx/program.rs b/crates/fonts/src/shx/program.rs new file mode 100644 index 000000000..ecfab3e98 --- /dev/null +++ b/crates/fonts/src/shx/program.rs @@ -0,0 +1,344 @@ +//! The shape pen program: vector length/direction bytes and special codes 0–14, run into +//! polylines. Bounded everywhere: subshape depth, location stack, steps, points, coordinates. +//! +//! - A byte with a non-zero high nibble is a vector: length (high nibble) in one of 16 +//! directions (low nibble, counter-clockwise from east in 22.5° steps; the off-axis ones +//! are half-unit or unit steps on the minor axis). +//! - 0 end, 1 pen down, 2 pen up, 3/4 divide/multiply vector lengths by the next byte, +//! 5/6 push/pop the location, 7 subshape (one byte; two bytes high first in a Unicode font; +//! `0`, a two-byte number, base point, width and height in an extended big font), +//! 8 one X-Y displacement, 9 displacements up to (0,0), 10 octant arc (radius, +//! ±0SC: sign = clockwise, S start octant, C octants, 0 = 8), 11 fractional arc (start +//! offset, end offset, high and low radius, ±0SC; offsets in 1/256 of 45° from the octant +//! boundaries), 12 bulge arc (dx, dy, bulge: ±127 = semicircle), 13 bulge arcs up to (0,0), +//! 14 the next command is for vertical text only (dual-orientation fonts). +//! - Displacements and bulges are two's complement bytes; the arc direction byte keeps its +//! sign in the high bit. + +use std::f64::consts::{FRAC_PI_4, TAU}; + +use cadcraft_geom::Vec2; + +use super::{ShxFont, ShxGlyph, ShxKind}; + +/// Subshape nesting (a cycle ends here). +const MAX_DEPTH: usize = 16; +/// Location stack (the format promises four). +const MAX_STACK: usize = 64; +/// Bytes run per glyph, subshapes included. +const MAX_STEPS: usize = 200_000; +/// Points per glyph. +const MAX_POINTS: usize = 50_000; +/// Coordinates in font units. +const MAX_COORD: f64 = 1.0e7; +/// Arc segments per full turn. +const ARC_STEPS: f64 = 64.0; + +/// Why a program stops early: the glyph is then not drawn. +#[derive(Debug)] +pub(super) struct Broken; + +type Res = Result<(), Broken>; + +struct Pen<'a> { + font: &'a ShxFont, + vertical: bool, + pos: Vec2, + down: bool, + /// Vector length factor (codes 3 and 4). + scale: f64, + /// Per-axis factor of an extended big font subshape. + axes: Vec2, + stack: Vec, + strokes: Vec>, + active: Vec, + steps: usize, + points: usize, +} + +pub(super) fn outline(font: &ShxFont, number: u16, vertical: bool) -> Result { + let mut pen = Pen { + font, + vertical, + pos: Vec2::ZERO, + down: true, + scale: 1.0, + axes: Vec2::new(1.0, 1.0), + stack: Vec::new(), + strokes: Vec::new(), + active: Vec::new(), + steps: 0, + points: 0, + }; + pen.run(number, 0)?; + pen.flush(); + Ok(ShxGlyph { strokes: pen.strokes, advance: pen.pos }) +} + +/// The 16 vector directions (unit length on the major axis). +fn direction(d: u8) -> Vec2 { + let (x, y) = match d & 15 { + 0 => (1.0, 0.0), + 1 => (1.0, 0.5), + 2 => (1.0, 1.0), + 3 => (0.5, 1.0), + 4 => (0.0, 1.0), + 5 => (-0.5, 1.0), + 6 => (-1.0, 1.0), + 7 => (-1.0, 0.5), + 8 => (-1.0, 0.0), + 9 => (-1.0, -0.5), + 10 => (-1.0, -1.0), + 11 => (-0.5, -1.0), + 12 => (0.0, -1.0), + 13 => (0.5, -1.0), + 14 => (1.0, -1.0), + _ => (1.0, -0.5), + }; + Vec2::new(x, y) +} + +/// A two's complement operand byte. +fn signed(b: u8) -> f64 { + f64::from(i8::from_le_bytes([b])) +} + +/// A program being read: its bytes and position. +struct Cur<'p> { + b: &'p [u8], + pos: usize, +} + +impl Cur<'_> { + fn byte(&mut self) -> Result { + let v = self.b.get(self.pos).copied().ok_or(Broken)?; + self.pos += 1; + Ok(v) + } +} + +impl Pen<'_> { + fn step(&mut self) -> Res { + self.steps += 1; + if self.steps > MAX_STEPS { Err(Broken) } else { Ok(()) } + } + + fn flush(&mut self) { + if self.active.len() >= 2 { + self.strokes.push(std::mem::take(&mut self.active)); + } else { + self.active.clear(); + } + } + + /// Move the pen by `d` font units (already scaled), drawing when the pen is down. + fn to(&mut self, target: Vec2) -> Res { + if !target.is_finite() || target.x.abs().max(target.y.abs()) > MAX_COORD { + return Err(Broken); + } + if self.down { + if self.active.is_empty() { + self.active.push(self.pos); + self.points += 1; + } + self.active.push(target); + self.points += 1; + if self.points > MAX_POINTS { + return Err(Broken); + } + } else { + self.flush(); + } + self.pos = target; + Ok(()) + } + + /// A displacement in vector units (times the length factor and the subshape axes). + fn by(&mut self, d: Vec2) -> Res { + let k = d * self.scale; + self.to(self.pos + Vec2::new(k.x * self.axes.x, k.y * self.axes.y)) + } + + /// An arc of `radius` vector units from the pen, starting at angle `start` on its circle + /// and turning `sweep` radians (positive counter-clockwise). + fn arc(&mut self, radius: f64, start: f64, sweep: f64) -> Res { + let r = radius * self.scale; + if !(r.is_finite() && r > 0.0 && sweep.is_finite()) { + return Ok(()); + } + let n = if self.down { (sweep.abs() / TAU * ARC_STEPS).ceil().clamp(1.0, ARC_STEPS) as usize } else { 1 }; + let from = self.pos; + let s0 = Vec2::new(start.cos(), start.sin()); + for i in 1..=n { + let a = start + sweep * i as f64 / n as f64; + let d = (Vec2::new(a.cos(), a.sin()) - s0) * r; + self.to(from + Vec2::new(d.x * self.axes.x, d.y * self.axes.y))?; + } + Ok(()) + } + + /// An arc to the displacement `d` with `bulge` (−127..127, ±127 a semicircle). + fn bulge(&mut self, d: Vec2, bulge: f64) -> Res { + let b = bulge.clamp(-127.0, 127.0) / 127.0; + if b == 0.0 || !self.down || d.len2() == 0.0 { + return self.by(d); + } + // Centre from the chord midpoint along its left normal; sweep 4·atan(b). + let centre = d * 0.5 + Vec2::new(-d.y, d.x) * ((1.0 - b * b) / (4.0 * b)); + let rel = -centre; + self.arc(rel.len(), rel.y.atan2(rel.x), 4.0 * b.atan()) + } + + /// The octant byte `±0SC`: (clockwise, start octant, octants with 0 = 8). + fn octants(spec: u8) -> (f64, f64, f64) { + let sign = if spec & 0x80 != 0 { -1.0 } else { 1.0 }; + let s = f64::from((spec >> 4) & 7); + let c = match spec & 7 { + 0 => 8.0, + c => f64::from(c), + }; + (sign, s, c) + } + + /// The vector unit size of an extended big font's subshape cell. + fn cell(&self) -> Vec2 { + let h = if self.font.above > 0.0 { self.font.above } else { 1.0 }; + let w = if self.font.char_width > 0.0 { self.font.char_width } else { h }; + Vec2::new(w, h) + } + + fn run(&mut self, number: u16, depth: usize) -> Res { + if depth >= MAX_DEPTH { + return Err(Broken); + } + let program = self.font.program(number).ok_or(Broken)?; + let mut r = Cur { b: program, pos: 0 }; + let mut skip = false; + while r.pos < r.b.len() { + self.step()?; + let code = r.byte()?; + // Code 14: in a dual-orientation font the next command is for vertical text only. + let exec = !skip; + skip = false; + match code { + 0 => return Ok(()), + 1 if exec => self.down = true, + 2 if exec => { + self.down = false; + self.flush(); + } + 3 | 4 => { + let k = f64::from(r.byte()?); + if exec && k > 0.0 { + let s = if code == 3 { self.scale / k } else { self.scale * k }; + if !(1e-9..=MAX_COORD).contains(&s) { + return Err(Broken); + } + self.scale = s; + } + } + 5 if exec => { + if self.stack.len() >= MAX_STACK { + return Err(Broken); + } + self.stack.push(self.pos); + } + 6 if exec => { + self.flush(); + if let Some(p) = self.stack.pop() { + self.pos = p; + } + } + 7 => self.subshape(&mut r, exec, depth)?, + 8 | 9 | 12 | 13 => loop { + self.step()?; + let d = Vec2::new(signed(r.byte()?), signed(r.byte()?)); + if matches!(code, 9 | 13) && d == Vec2::ZERO { + break; + } + let bulge = if matches!(code, 12 | 13) { signed(r.byte()?) } else { 0.0 }; + if exec { + self.bulge(d, bulge)?; + } + if matches!(code, 8 | 12) { + break; + } + }, + 10 => { + let radius = f64::from(r.byte()?); + let (sign, s, c) = Self::octants(r.byte()?); + if exec { + self.arc(radius, s * FRAC_PI_4, sign * c * FRAC_PI_4)?; + } + } + 11 => { + let start = f64::from(r.byte()?) / 256.0; + let end = match r.byte()? { + 0 => 1.0, + e => f64::from(e) / 256.0, + }; + let radius = f64::from(u16::from_be_bytes([r.byte()?, r.byte()?])); + let (sign, s, c) = Self::octants(r.byte()?); + if exec { + // From `start` past the first octant boundary to `end` past the last + // octant's. + self.arc(radius, (s + sign * start) * FRAC_PI_4, sign * (c - 1.0 + end - start) * FRAC_PI_4)?; + } + } + 14 => skip = self.font.dual && !self.vertical && exec, + 1..=15 => {} + v => { + if exec { + self.by(direction(v) * f64::from(v >> 4))?; + } + } + } + } + Ok(()) + } + + /// Code 7 and its operands. + fn subshape(&mut self, r: &mut Cur, exec: bool, depth: usize) -> Res { + match self.font.kind { + ShxKind::Unifont => { + let n = u16::from_be_bytes([r.byte()?, r.byte()?]); + if exec { + self.run(n, depth + 1)?; + } + } + ShxKind::BigFont => { + let first = r.byte()?; + if first != 0 { + if exec { + self.run(u16::from(first), depth + 1)?; + } + return Ok(()); + } + // Extended: the primitive drawn in the box (x, y, width, height) of the cell. + let n = u16::from_be_bytes([r.byte()?, r.byte()?]); + let (x, y, w, h) = (f64::from(r.byte()?), f64::from(r.byte()?), f64::from(r.byte()?), f64::from(r.byte()?)); + if !exec { + return Ok(()); + } + let (pos, axes, down) = (self.pos, self.axes, self.down); + let cell = self.cell(); + self.flush(); + self.pos = pos + Vec2::new(x * axes.x, y * axes.y) * self.scale; + self.axes = Vec2::new(axes.x * w / cell.x, axes.y * h / cell.y); + let res = self.run(n, depth + 1); + self.flush(); + self.axes = axes; + self.pos = pos; + self.down = down; + res?; + } + ShxKind::Shapes => { + let n = u16::from(r.byte()?); + if exec { + self.run(n, depth + 1)?; + } + } + } + Ok(()) + } +} diff --git a/crates/fonts/src/shx/table.rs b/crates/fonts/src/shx/table.rs new file mode 100644 index 000000000..4b44927d7 --- /dev/null +++ b/crates/fonts/src/shx/table.rs @@ -0,0 +1,267 @@ +//! Finding SHX files by the name a text style or linetype gives (`romans.shx`, `GBCBIG`, +//! `C:\fonts\simplex.shx`): only the file name counts, so a path stored in a drawing never +//! reaches another folder or computer. +//! +//! Order: the font map ([`set_font_map`]) renames the file; then fonts registered by the app +//! ([`register`]: the web build's files opened alongside a drawing, tests); then the app's +//! [`FontProvider`]; then, natively, the folders of opened drawings ([`add_drawing_dir`], most +//! recent first) and the support search path ([`set_search_path`], from `CADCRAFT_SHXPATH` at +//! start). Results, found or not, are cached until one of these changes ([`generation`] +//! counts the changes, so a viewer knows to regenerate). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; + +use super::parse::MAX_FILE_BYTES; +use super::{ShxError, ShxFont}; + +/// Where an app finds font files CADCraft can't reach itself (a web page's file store, an +/// app's own font folder). Called with the normalised file name (`romans.shx`); returns the +/// file's bytes. Called without the font table's lock held. +pub trait FontProvider: Send + Sync { + fn load(&self, file_name: &str) -> Option>; +} + +/// Most fonts kept loaded, and their total bytes. +const MAX_FONTS: usize = 128; +const MAX_LOADED_BYTES: usize = 256 << 20; +/// Most drawing folders and search path entries remembered. +const MAX_DIRS: usize = 32; +/// Most font map entries. +const MAX_MAP: usize = 1024; + +#[derive(Default)] +struct Table { + registered: HashMap>, + resolved: HashMap>>, + loaded_bytes: usize, + provider: Option>, + drawing_dirs: Vec, + search_path: Vec, + map: Vec<(String, String)>, + generation: u64, +} + +impl Table { + /// Forget cached results: something that changes them changed. + fn changed(&mut self) { + self.resolved.clear(); + self.loaded_bytes = self.registered.values().map(|f| f.data.len()).sum(); + self.generation = self.generation.wrapping_add(1); + } +} + +fn table() -> MutexGuard<'static, Table> { + static T: OnceLock> = OnceLock::new(); + T.get_or_init(|| { + #[cfg(not(target_arch = "wasm32"))] + let search_path = std::env::var_os("CADCRAFT_SHXPATH") + .map(|p| std::env::split_paths(&p).filter(|d| !d.as_os_str().is_empty()).take(MAX_DIRS).collect()) + .unwrap_or_default(); + #[cfg(target_arch = "wasm32")] + let search_path = Vec::new(); + Mutex::new(Table { search_path, ..Table::default() }) + }) + .lock() + .unwrap_or_else(PoisonError::into_inner) +} + +/// The lookup key of a font name: its lower-case file name with `.shx` (`romans.shx`), or +/// `None` for a name that isn't an SHX file (a TrueType font, empty, the built-in font). +pub(crate) fn key(name: &str) -> Option { + let base = name.trim().rsplit(['/', '\\']).next()?.trim(); + if base.is_empty() || base.len() > 255 || base.contains(':') || base.chars().any(char::is_control) { + return None; + } + let lower = base.to_ascii_lowercase(); + if lower.ends_with(".shx") { + return (lower.len() > 4).then_some(lower); + } + let ext = Path::new(&lower).extension().map(|e| e.to_string_lossy().to_string()); + match ext.as_deref() { + // `txt` (no extension) is a classic style font name; other extensions are other kinds. + None => (lower != crate::BUILTIN_FONT.to_ascii_lowercase() && !lower.contains(' ')).then(|| format!("{lower}.shx")), + Some(_) => None, + } +} + +/// Register font bytes under a file name (the web build's files opened alongside a drawing; +/// tests). Replaces a font registered under the same name. +pub fn register(name: &str, bytes: &[u8]) -> Result<(), ShxError> { + let k = key(name).ok_or(ShxError::NotShx)?; + let font = Arc::new(ShxFont::parse(bytes)?); + let mut t = table(); + if !t.registered.contains_key(&k) && t.registered.len() >= MAX_FONTS { + return Err(ShxError::TooLarge); + } + t.registered.insert(k, font); + t.changed(); + Ok(()) +} + +/// Forget every registered font (a web session closing its drawings). +pub fn clear_registered() { + let mut t = table(); + t.registered.clear(); + t.changed(); +} + +/// Set (or clear) the app's font provider. +pub fn set_provider(provider: Option>) { + let mut t = table(); + t.provider = provider; + t.changed(); +} + +/// Search the folder of a drawing being opened for the fonts its styles name (most recent +/// first). +pub fn add_drawing_dir(dir: &Path) { + if dir.as_os_str().is_empty() { + return; + } + let mut t = table(); + if t.drawing_dirs.first().is_some_and(|d| d == dir) { + return; + } + t.drawing_dirs.retain(|d| d != dir); + t.drawing_dirs.insert(0, dir.to_path_buf()); + t.drawing_dirs.truncate(MAX_DIRS); + t.changed(); +} + +/// The drawing folders searched, most recent first. +pub fn drawing_dirs() -> Vec { + table().drawing_dirs.clone() +} + +/// Set the support search path for SHX files: folders separated by `;` (or the platform's +/// path separator). Empty clears it. +pub fn set_search_path(list: &str) { + let dirs: Vec = list + .split(';') + .flat_map(|p| if cfg!(any(windows, target_arch = "wasm32")) { vec![PathBuf::from(p)] } else { std::env::split_paths(p).collect() }) + .filter(|d| !d.as_os_str().is_empty() && !d.to_string_lossy().trim().is_empty()) + .take(MAX_DIRS) + .collect(); + let mut t = table(); + t.search_path = dirs; + t.changed(); +} + +/// The support search path, `;`-separated. +pub fn search_path() -> String { + table().search_path.iter().map(|p| p.to_string_lossy().to_string()).collect::>().join(";") +} + +/// Set the font map: one `font;replacement` per line (or separated by `|`), as a font mapping +/// file lists them. Lines that aren't a pair are ignored; empty clears it. +pub fn set_font_map(text: &str) { + let map: Vec<(String, String)> = text + .split(['\n', '|']) + .filter_map(|l| { + let (a, b) = l.split_once(';')?; + Some((key(a)?, b.trim().to_string())) + }) + .filter(|(_, b)| !b.is_empty()) + .take(MAX_MAP) + .collect(); + let mut t = table(); + t.map = map; + t.changed(); +} + +/// The font map, one `font;replacement` per line. +pub fn font_map() -> String { + table().map.iter().map(|(a, b)| format!("{a};{b}")).collect::>().join("\n") +} + +/// Counts changes to what fonts are found (registration, provider, folders, map). +pub fn generation() -> u64 { + table().generation +} + +/// The SHX font file a style or linetype names, if CADCraft can find and read it. +pub fn find(name: &str) -> Option> { + let mut k = key(name)?; + let (provider, dirs, generation) = { + let t = table(); + // The font map renames once (a map can't loop). + if let Some(to) = t.map.iter().find(|(from, _)| *from == k).and_then(|(_, to)| key(to)) { + k = to; + } + if let Some(f) = t.registered.get(&k) { + return Some(f.clone()); + } + if let Some(r) = t.resolved.get(&k) { + return r.clone(); + } + let dirs: Vec = t.drawing_dirs.iter().chain(t.search_path.iter()).cloned().collect(); + (t.provider.clone(), dirs, t.generation) + }; + let font = provider + .and_then(|p| p.load(&k)) + .or_else(|| read_from(&dirs, &k)) + .filter(|b| b.len() <= MAX_FILE_BYTES) + .and_then(|b| ShxFont::parse(&b).ok()) + .map(Arc::new); + let mut t = table(); + // Something changed while the file was read: keep the result out of the new cache. + if t.generation != generation { + return font; + } + if t.resolved.len() >= MAX_FONTS * 4 { + t.resolved.clear(); + t.loaded_bytes = t.registered.values().map(|f| f.data.len()).sum(); + } + let size = font.as_ref().map_or(0, |f| f.data.len()); + if t.loaded_bytes.saturating_add(size) > MAX_LOADED_BYTES { + // Over the memory bound: used, not kept. + return font; + } + t.loaded_bytes += size; + t.resolved.insert(k, font.clone()); + font +} + +/// Most directory entries looked at for a font file whose name differs only in case. +#[cfg(not(target_arch = "wasm32"))] +const MAX_DIR_ENTRIES: usize = 10_000; + +/// The file `name` (a lower-case key) in the first of `dirs` that has it, whatever the case of +/// its name: lower-case, upper-case, else any other case found in the directory (`Simplex.shx` +/// on a case-sensitive file system). +#[cfg(not(target_arch = "wasm32"))] +fn read_from(dirs: &[PathBuf], name: &str) -> Option> { + use std::io::Read as _; + let read = |file: &Path| -> Option> { + let meta = std::fs::metadata(file).ok()?; + if !meta.is_file() || meta.len() > MAX_FILE_BYTES as u64 { + return None; + } + let mut bytes = Vec::new(); + let limit = u64::try_from(MAX_FILE_BYTES).unwrap_or(u64::MAX).saturating_add(1); + std::fs::File::open(file).and_then(|f| f.take(limit).read_to_end(&mut bytes)).ok()?; + Some(bytes) + }; + for d in dirs { + if let Some(b) = [d.join(name), d.join(name.to_ascii_uppercase())].iter().find_map(|f| read(f)) { + return Some(b); + } + let Ok(entries) = std::fs::read_dir(d) else { continue }; + let found = entries + .take(MAX_DIR_ENTRIES) + .filter_map(Result::ok) + .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(name))) + .and_then(|e| read(&e.path())); + if found.is_some() { + return found; + } + } + None +} + +#[cfg(target_arch = "wasm32")] +fn read_from(_dirs: &[PathBuf], _name: &str) -> Option> { + None +} diff --git a/crates/fonts/src/shx/tests.rs b/crates/fonts/src/shx/tests.rs new file mode 100644 index 000000000..a6ab40c99 --- /dev/null +++ b/crates/fonts/src/shx/tests.rs @@ -0,0 +1,450 @@ +//! SHX tests. Every font here is compiled from shape source written for these tests: no font +//! file is read from disk or committed. + +use std::f64::consts::PI; + +use cadcraft_geom::Vec2; + +use super::*; +use crate::{TextFont, TextParams}; + +/// A small dual-orientation text font: 6 units above, 2 below. +pub(crate) const TEXT_FONT: &str = "\ +;; CADCraft test font (written for the tests) +*0,4,cadcraft test +6,2,2,0 +*32,3,SPACE +2,060,0 +; A: up 6, right 4, down 6, then a gap of 2 +*65,7,UCA +1,064,040,06C,2,020,0 +; B: a displacement out and back, then 5 on +*66,10,UCB +2,010,1,8,(3,4),8,(-3,-4),2,050,0 +; C: vertical-only moves (code 14) +*67,12,UCC +2,14,8,(0,-8),1,064,2,14,8,(0,-6),040,0 +; the degree sign: a full octant circle +*256,12,DEGREE +2,8,(1,4),1,10,(1,0),2,8,(2,-4),0 +*257,6,PLUSMINUS +1,040,2,020,0 +*258,6,DIAMETER +1,044,2,020,0 +"; + +/// A Unicode font with the same letters, a subshape and the diameter sign at U+2205. +const UNI_FONT: &str = "\ +*UNIFONT,6,cadcraft uni +6,2,0,0,0,0 +*00041,7,uca +1,064,040,06C,2,020,0 +*000C5,9,ring a +7,00041,2,8,(-4,7),1,040,2,8,(2,-7),0 +*02205,6,diameter +1,044,2,020,0 +"; + +/// A big font with Shift-JIS lead bytes: one square character (U+3042, 0x82A0). +const BIG_FONT: &str = "\ +*BIGFONT 2,1,081,09F +*0,4,cadcraft big +8,0,0,0 +*082A0,7,square +1,080,084,088,08C,2,080,0 +"; + +/// An extended big font: two primitives of a 16 x 16 cell drawn into one character. +const EXT_BIG_FONT: &str = "\ +*BIGFONT 2,1,081,09F +*0,5,cadcraft ext +16,0,0,16,0 +*08F01,10,box +4,2,1,080,084,088,08C,3,2,0 +*082A2,20,two boxes +7,0,08F01,0,0,8,16,7,0,08F01,8,0,8,16,2,4,2,080,3,2,0 +"; + +fn font(src: &str) -> ShxFont { + ShxFont::parse(&compile(src).unwrap()).unwrap() +} + +/// A shape file (no font record) with one shape from `spec`. +fn one(spec: &str) -> ShxFont { + font(&format!("*1,1,T\n{spec}\n")) +} + +fn adv(spec: &str) -> Vec2 { + one(spec).glyph(1, false).unwrap().advance +} + +fn close(a: Vec2, b: Vec2) -> bool { + (a - b).len() < 1e-6 +} + +fn dir(deg: f64) -> Vec2 { + let r = deg.to_radians(); + Vec2::new(r.cos(), r.sin()) +} + +#[test] +fn every_vector_direction() { + let want = [ + (1.0, 0.0), + (1.0, 0.5), + (1.0, 1.0), + (0.5, 1.0), + (0.0, 1.0), + (-0.5, 1.0), + (-1.0, 1.0), + (-1.0, 0.5), + (-1.0, 0.0), + (-1.0, -0.5), + (-1.0, -1.0), + (-0.5, -1.0), + (0.0, -1.0), + (0.5, -1.0), + (1.0, -1.0), + (1.0, -0.5), + ]; + for (d, (x, y)) in want.iter().enumerate() { + let a = adv(&format!("02{d:X},0")); + assert!(close(a, Vec2::new(2.0 * x, 2.0 * y)), "direction {d}: {a:?}"); + } + // Length 15, the longest vector. + assert!(close(adv("0F0,0"), Vec2::new(15.0, 0.0))); +} + +#[test] +fn pen_up_down_scale_push_pop() { + let f = one("2,010,1,020,2,010,1,010,0"); + let g = f.glyph(1, false).unwrap(); + assert_eq!(g.strokes, vec![vec![Vec2::new(1.0, 0.0), Vec2::new(3.0, 0.0)], vec![Vec2::new(4.0, 0.0), Vec2::new(5.0, 0.0)]]); + // 4 multiplies, 3 divides: 3 + 1. + assert!(close(adv("4,3,010,3,3,010,0"), Vec2::new(4.0, 0.0))); + // Push, draw, pop: the next run starts where the push was. + let g = one("5,040,6,014,0").glyph(1, false).unwrap(); + assert_eq!(g.strokes.len(), 2); + assert!(close(g.strokes[1][0], Vec2::ZERO) && close(g.advance, Vec2::new(0.0, 1.0))); + // An unmatched pop is ignored. + assert!(close(adv("6,010,0"), Vec2::new(1.0, 0.0))); +} + +#[test] +fn displacements() { + assert!(close(adv("8,(-3,5),0"), Vec2::new(-3.0, 5.0))); + assert!(close(adv("9,(1,1),(2,-1),(-127,0),(0,0),0"), Vec2::new(-124.0, 0.0))); +} + +#[test] +fn octant_and_fractional_arcs() { + // A full circle comes back to where it started. + let g = one("10,(2,0),0").glyph(1, false).unwrap(); + assert!(close(g.advance, Vec2::ZERO)); + let maxx = g.strokes.iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max); + assert!((maxx - 0.0).abs() < 1e-9 && g.strokes.iter().flatten().any(|p| (p.x + 4.0).abs() < 1e-6), "circle left of the pen"); + // Clockwise from octant 4 (180°), three octants: ends at 45°. + assert!(close(adv("10,(1,-043),0"), dir(45.0) - dir(180.0))); + // Counter-clockwise quarter from 0°. + assert!(close(adv("10,(3,002),0"), (dir(90.0) - dir(0.0)) * 3.0)); + // The documented fractional example: 55° to 95°, radius 3 (start octant 1, two octants). + let a0 = 45.0 + 56.0 / 256.0 * 45.0; + let a1 = 90.0 + 28.0 / 256.0 * 45.0; + assert!(close(adv("11,(56,28,0,3,012),0"), (dir(a1) - dir(a0)) * 3.0)); + // High radius byte: 256 + 4. + assert!(close(adv("11,(0,0,1,4,001),0"), (dir(45.0) - dir(0.0)) * 260.0)); +} + +#[test] +fn bulge_arcs() { + // A counter-clockwise semicircle up the chord bulges to the right... of the chord's left + // normal: from (0,0) to (0,4) about (0,2), through (2,2). + let g = one("12,(0,4,127),0").glyph(1, false).unwrap(); + assert!(close(g.advance, Vec2::new(0.0, 4.0))); + assert!(g.strokes.iter().flatten().any(|p| close(*p, Vec2::new(2.0, 2.0))), "{:?}", g.strokes); + // Bulge 0 is a straight line. + assert_eq!(one("12,(3,0,0),0").glyph(1, false).unwrap().strokes, vec![vec![Vec2::ZERO, Vec2::new(3.0, 0.0)]]); + // Polyarc: two arcs, then the (0,0) end without a bulge. + let g = one("13,(4,0,127),(0,4,-127),(0,0),010,0").glyph(1, false).unwrap(); + assert!(close(g.advance, Vec2::new(5.0, 4.0))); + assert!(g.strokes.iter().flatten().any(|p| (p.y + 2.0).abs() < 1e-6), "first arc dips below"); +} + +#[test] +fn vertical_only_commands_and_text_font_record() { + let f = font(TEXT_FONT); + assert_eq!((f.kind(), f.name(), f.above(), f.below(), f.is_dual(), f.is_font()), (ShxKind::Shapes, "cadcraft test", 6.0, 2.0, true, true)); + let h = f.glyph(67, false).unwrap(); + assert_eq!(h.strokes, vec![vec![Vec2::ZERO, Vec2::new(0.0, 6.0)]]); + let v = f.glyph(67, true).unwrap(); + assert_eq!(v.strokes, vec![vec![Vec2::new(0.0, -8.0), Vec2::new(0.0, -2.0)]]); + assert!(close(v.advance, Vec2::new(4.0, -8.0))); + // A font that isn't dual runs code 14's command in horizontal text too. + let g = one("2,14,8,(0,-8),0").glyph(1, false).unwrap(); + assert!(close(g.advance, Vec2::new(0.0, -8.0))); + assert_eq!(f.shape_number("ucb"), Some(66)); + assert_eq!(f.shape_name(65).as_deref(), Some("UCA")); + assert_eq!(f.shape_numbers().first(), Some(&32)); +} + +#[test] +fn subshapes_shapes_unicode_and_extended_big_font() { + let s = font("*1,4,BAR\n1,030,0\n*2,6,TWO\n7,1,7,1,0\n"); + assert!(close(s.glyph(2, false).unwrap().advance, Vec2::new(6.0, 0.0))); + let u = font(UNI_FONT); + assert_eq!((u.kind(), u.name(), u.above(), u.encoding()), (ShxKind::Unifont, "cadcraft uni", 6.0, 0)); + let ring = u.glyph(0xC5, false).unwrap(); + assert_eq!(ring.strokes.len(), 2, "the A and the ring's bar"); + assert!(close(ring.advance, Vec2::new(8.0, 0.0))); + let e = font(EXT_BIG_FONT); + assert_eq!((e.kind(), e.above()), (ShxKind::BigFont, 16.0)); + let g = e.glyph(0x82A2, false).unwrap(); + assert_eq!(g.strokes.len(), 2); + let xs: Vec<(f64, f64)> = g.strokes.iter().map(|s| s.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)))).collect(); + assert_eq!(xs, vec![(0.0, 8.0), (8.0, 16.0)], "each primitive squeezed into half the cell"); + let ys = g.strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y))); + assert_eq!(ys, (0.0, 16.0)); + assert!(close(g.advance, Vec2::new(16.0, 0.0))); +} + +#[test] +fn big_font_header_and_escape_ranges() { + let b = font(BIG_FONT); + assert_eq!(b.kind(), ShxKind::BigFont); + assert_eq!(b.escape_ranges(), &[(0x81, 0x9F)]); + assert!(b.is_lead_byte(0x82) && !b.is_lead_byte(0x41) && !b.is_lead_byte(0xA0)); + assert_eq!(b.above(), 8.0); + assert!(b.has_shape(0x82A0)); +} + +#[test] +fn cyclic_and_deep_subshapes_are_bounded() { + let f = font("*1,3,SELF\n7,1,0\n*2,3,A\n7,3,0\n*3,3,B\n7,2,0\n*4,3,MISSING\n7,9,0\n"); + for n in 1..=4 { + assert!(f.glyph(n, false).is_none(), "shape {n}"); + } + // A chain deeper than the bound fails; a shallow one draws. + let mut src = String::new(); + for n in 1..=40u16 { + if n == 40 { + src.push_str(&format!("*{n},2,L\n010,0\n")); + } else { + src.push_str(&format!("*{n},3,L\n7,{},0\n", n + 1)); + } + } + let f = font(&src); + assert!(f.glyph(1, false).is_none() && f.glyph(30, false).is_some()); + // Doubling with subshapes: 2^n steps are cut off by the step bound. + let mut src = String::from("*1,2,L\n010,0\n"); + for n in 2..=10u16 { + src.push_str(&format!("*{n},9,D\n7,{0},7,{0},7,{0},7,{0},0\n", n - 1)); + } + let f = font(&src); + assert!(f.glyph(10, false).is_none() && f.glyph(3, false).is_some()); + // Pushes without pops, and runaway scales. + let pushes = format!("{}0", "5,".repeat(100)); + assert!(one(&pushes).glyph(1, false).is_none()); + let scales = format!("{}010,0", "4,255,".repeat(10)); + assert!(one(&scales).glyph(1, false).is_none()); + // A zero radius or a zero factor draws nothing and doesn't fail. + assert!(one("10,(0,001),4,0,010,0").glyph(1, false).is_some()); +} + +/// Parse every prefix and many mutations of `bytes`; draw every shape of whatever parses. +fn hammer(bytes: &[u8]) { + let draw = |b: &[u8]| { + if let Ok(f) = ShxFont::parse(b) { + for n in f.shape_numbers() { + let _ = f.glyph(n, false); + let _ = f.glyph(n, true); + } + let _ = f.shape_number("X"); + } + }; + for n in 0..=bytes.len() { + draw(&bytes[..n]); + } + let mut seed: u64 = 0x5EED_1234; + for _ in 0..2000 { + let mut b = bytes.to_vec(); + for _ in 0..3 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let i = (seed >> 33) as usize % b.len().max(1); + if let Some(x) = b.get_mut(i) { + *x = (seed >> 24) as u8; + } + } + draw(&b); + } +} + +#[test] +fn hostile_files_never_panic() { + for src in [TEXT_FONT, UNI_FONT, BIG_FONT, EXT_BIG_FONT] { + hammer(&compile(src).unwrap()); + } + // Counts far beyond the data. + let mut shapes = parse::SHAPES_10.to_vec(); + shapes.extend([1, 0, 0xFF, 0xFF, 0xFF, 0xFF]); + assert_eq!(ShxFont::parse(&shapes).err(), Some(ShxError::Truncated)); + let mut big = parse::BIGFONT.to_vec(); + big.extend([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); + assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::TooLarge)); + let mut big = parse::BIGFONT.to_vec(); + big.extend([0xFF, 0xFF, 0xFF, 0xFF, 0, 0]); + assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::Truncated)); + // A big font index pointing anywhere. + let mut big = parse::BIGFONT.to_vec(); + big.extend([1, 0, 1, 0, 0, 0, 0x41, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F]); + assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::Empty)); + let mut uni = parse::UNIFONT.to_vec(); + uni.extend([0xFF, 0xFF, 0xFF, 0xFF, 2, 0, b'x', 0]); + assert_eq!(ShxFont::parse(&uni).err(), Some(ShxError::Empty)); + assert_eq!(ShxFont::parse(b"not a font").err(), Some(ShxError::NotShx)); + assert_eq!(ShxFont::parse(&[]).err(), Some(ShxError::NotShx)); + // Hostile shape source. + for src in ["*1,2,X\n7\n", "*1,2,X\n9,(1,1)\n", "*99999,1,X\n0\n", "*1,1,X\n300,0\n", "010,0\n", "*1,1,X\n1,0zz,0\n", "*1,1,X\n0\n*1,1,Y\n0\n"] { + assert!(compile(src).is_err(), "{src:?}"); + } + assert!(compile("").is_err()); +} + +/// Register a compiled test font under `name` (each test its own names: the table is shared). +fn reg(name: &str, src: &str) { + register(name, &compile(src).unwrap()).unwrap(); +} + +#[test] +fn text_metrics_follow_the_font() { + reg("cadcraft-test-metrics.shx", TEXT_FONT); + let f = TextFont::resolve_style("CADCRAFT-TEST-METRICS", "", false); + assert!(f.is_shx() && !f.is_outline()); + let h = 3.0; + // Advances are the pen's end positions: A 6, B 6, at h / above = 0.5. + assert!((crate::text_width(&f, "AB", h, 1.0) - 6.0).abs() < 1e-9); + assert!((crate::text_width(&f, "AB", h, 0.5) - 3.0).abs() < 1e-9); + let sh = crate::shape_line(&f, "AB", h, 1.0, 0.0); + assert!((sh.width - 6.0).abs() < 1e-9); + let ys = sh.strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y))); + assert_eq!(ys, (0.0, 3.0), "cap height = text height"); + // %%d %%p %%c come from shapes 256-258; \U+0041 is A. + assert!((crate::text_width(&f, "%%d", h, 1.0) - 1.5).abs() < 1e-9); + assert!((crate::text_width(&f, "%%p%%c", h, 1.0) - 4.0).abs() < 1e-9); + assert!((crate::text_width(&f, "\\U+0041", h, 1.0) - 3.0).abs() < 1e-9); + assert_eq!(crate::shape_line(&f, "\\U+0041", h, 1.0, 0.0), crate::shape_line(&f, "A", h, 1.0, 0.0)); + // A character the font lacks comes from the stroke font, with its advance. + let z = crate::text_width(&f, "Z", h, 1.0); + assert!((z - crate::stroke_advance('Z', h, 1.0)).abs() < 1e-9); + assert!(!crate::shape_line(&f, "Z", h, 1.0, 0.0).strokes.is_empty()); + // Widths and shapes agree, with obliquing, underline and mixed fallbacks. + for s in ["AB AZ", "%%uAB%%u", "A\u{3042}B"] { + let sh = crate::shape_line(&f, s, h, 0.8, 0.2); + assert!((sh.width - crate::text_width(&f, s, h, 0.8)).abs() < 1e-9, "{s}"); + } + // Placement: centred text sits on the alignment point by the font's own width. + let p = TextParams { h: crate::Align::Center, align_pt: Some(Vec2::new(10.0, 0.0)), ..TextParams::new(Vec2::ZERO, h) }; + let (_, bb) = crate::place(&f, "AB", &p); + assert!((bb.center().x - 10.0).abs() < 1e-9 && (bb.width() - 6.0).abs() < 1e-9); + // MTEXT with the style's SHX font, and its \U+ escape. + let mp = crate::MTextParams { font: f.clone(), ..crate::MTextParams::new(Vec2::ZERO, h) }; + let m = crate::layout_mtext_with("\\U+0041B", &mp); + assert_eq!(m.lines, vec!["AB".to_string()]); + assert_eq!(crate::space_width(&f, h, 1.0), 3.0); +} + +#[test] +fn unifont_text_and_specials() { + reg("cadcraft-test-uni.shx", UNI_FONT); + let f = TextFont::resolve("cadcraft-test-uni.shx"); + assert!((crate::text_width(&f, "A\u{C5}", 6.0, 1.0) - 14.0).abs() < 1e-9); + // %%c is the diameter sign, at U+2205 in this font. + assert!((crate::text_width(&f, "%%c", 6.0, 1.0) - 2.0).abs() < 1e-9); +} + +#[test] +fn big_font_draws_double_byte_characters() { + reg("cadcraft-test-main.shx", TEXT_FONT); + reg("cadcraft-test-big.shx", BIG_FONT); + let f = TextFont::resolve_style("cadcraft-test-main.shx", "cadcraft-test-big.shx", false); + let TextFont::Shx(t) = &f else { panic!("SHX") }; + assert_eq!(t.big_font_encoding(), "Shift_JIS"); + let h = 2.5; + // あ (Shift-JIS 82A0) fills a square cell of the text height. + let w = crate::text_width(&f, "\u{3042}", h, 1.0); + assert!((w - h).abs() < 1e-9, "{w}"); + let sh = crate::shape_line(&f, "A\u{3042}", h, 1.0, 0.0); + let big: Vec = sh.strokes.iter().flatten().copied().filter(|p| p.x > 2.5 - 1e-9).collect(); + let ys = big.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y))); + assert_eq!(ys, (0.0, h)); + // Only the big font found: Latin letters from the stroke font, the big font's own. + let f = TextFont::resolve_style("cadcraft-missing-main-font.shx", "cadcraft-test-big.shx", false); + assert!(f.is_shx()); + assert!((crate::text_width(&f, "\u{3042}", h, 1.0) - h).abs() < 1e-9); + assert!((crate::text_width(&f, "A", h, 1.0) - crate::stroke_advance('A', h, 1.0)).abs() < 1e-9); + // Neither found: the stroke font as before. + assert!(!TextFont::resolve_style("cadcraft-missing-a.shx", "cadcraft-missing-b.shx", false).is_shx()); +} + +#[test] +fn big_font_code_pages() { + for (name, ranges, want) in [ + ("gbcbig.shx", vec![(0xA1, 0xFE)], "GBK"), + ("chineset.shx", vec![(0xA1, 0xFE)], "Big5"), + ("whgtxt.shx", vec![(0xA1, 0xFE)], "EUC-KR"), + ("extfont2.shx", vec![(0x81, 0x9F)], "Shift_JIS"), + ("custom.shx", vec![(0x81, 0x9F), (0xE0, 0xFC)], "Shift_JIS"), + ("custom.shx", vec![(0xA1, 0xFE)], "GBK"), + ] { + let src = format!( + "*BIGFONT 1,{},{}\n*0,4,x\n8,0,0,0\n*0FFFF,2,x\n010,0\n", + ranges.len(), + ranges.iter().map(|(a, b)| format!("0{a:X},0{b:X}")).collect::>().join(",") + ); + let b = std::sync::Arc::new(font(&src)); + assert_eq!(ShxText::new(None, Some(b), name, false).big_font_encoding(), want, "{name}"); + } +} + +#[test] +fn vertical_text_runs_down() { + reg("cadcraft-test-vertical.shx", TEXT_FONT); + let v = TextFont::resolve_style("cadcraft-test-vertical.shx", "", true); + let sh = crate::shape_line(&v, "CC", 1.0, 1.0, 0.0); + let miny = sh.strokes.iter().flatten().map(|p| p.y).fold(f64::MAX, f64::min); + // Two characters, each 8 units (8/6 of the height) down. + assert!((miny + 16.0 / 6.0).abs() < 1e-9, "{miny}"); + assert!(sh.strokes.iter().flatten().all(|p| p.y <= 1e-9), "below the insertion point"); + // A font without modes 2 stays horizontal. + reg("cadcraft-test-horizontal.shx", "*0,4,h\n6,2,0,0\n*67,3,C\n1,064,0\n"); + let h = TextFont::resolve_style("cadcraft-test-horizontal.shx", "", true); + assert!(crate::shape_line(&h, "CC", 1.0, 1.0, 0.0).strokes.iter().flatten().all(|p| p.y >= -1e-9)); +} + +#[test] +fn names_and_lookup() { + assert_eq!(table::key("ROMANS.SHX").as_deref(), Some("romans.shx")); + assert_eq!(table::key("txt").as_deref(), Some("txt.shx")); + assert_eq!(table::key(r"C:\Fonts\Simplex.shx").as_deref(), Some("simplex.shx")); + assert_eq!(table::key("../x/gbcbig.shx").as_deref(), Some("gbcbig.shx")); + for n in ["", "arial.ttf", "CADCraft Stroke", "Times New Roman", ".shx", "a\u{0}.shx"] { + assert_eq!(table::key(n), None, "{n:?}"); + } + assert!(register("not-a-font.ttf", &compile(TEXT_FONT).unwrap()).is_err()); + assert!(register("broken.shx", b"AutoCAD-86 shapes 1.0\r\n\x1a").is_err()); + // A path stored in a drawing only names the file. + reg("cadcraft-test-path.shx", TEXT_FONT); + assert!(find(r"\\server\share\CADCRAFT-TEST-PATH.SHX").is_some()); + assert!(find("cadcraft-never-registered.shx").is_none()); +} + +#[test] +fn arcs_follow_the_circle() { + // Every point of an octant arc is on its circle. + let g = one("10,(5,034),0").glyph(1, false).unwrap(); + let c = -dir(135.0) * 5.0; + for p in g.strokes.iter().flatten() { + assert!(((*p - c).len() - 5.0).abs() < 1e-9); + } + assert!(close(g.advance, (dir(135.0 + 180.0) - dir(135.0)) * 5.0)); + let _ = PI; +} diff --git a/crates/fonts/src/shx/text.rs b/crates/fonts/src/shx/text.rs new file mode 100644 index 000000000..0e7e6513e --- /dev/null +++ b/crates/fonts/src/shx/text.rs @@ -0,0 +1,202 @@ +//! Text set in SHX fonts: a text style's main font and big font, horizontal or vertical. +//! +//! Each character is drawn by the font that has it: a double-byte character of the big font's +//! code page (its lead byte in an escape range) from the big font, others from the main font +//! (character code, or Unicode in a Unicode font; `%%d` `%%p` `%%c` from shapes 256–258 or their +//! older or Unicode places). A font draws at text height / its `above` value, and its pen's end +//! position is the advance: there is no added letter gap. Characters neither font has come from +//! the built-in stroke font (and its fallback fonts), so nothing goes missing. +//! +//! Vertical text (a style flag, for fonts with modes 2) runs each shape's vertical-only +//! commands (code 14) and follows the pen downward. + +use std::sync::Arc; + +use cadcraft_geom::Vec2; +use encoding_rs::Encoding; + +use super::{ShxFont, ShxKind}; +use crate::{Shaped, bidi}; + +/// A text style's SHX fonts: the main font, the big font and the orientation. Either font may +/// be missing (not found): its characters then come from the stroke font. +#[derive(Clone, Debug)] +pub struct ShxText { + pub main: Option>, + pub big: Option>, + /// The style is vertical and the main font supports it. + pub vertical: bool, + /// The big font's code page (from its name and escape ranges). + big_encoding: &'static Encoding, +} + +impl ShxText { + /// The fonts of a style: `big_name` is the big font's file name (it picks the code page). + pub fn new(main: Option>, big: Option>, big_name: &str, vertical: bool) -> ShxText { + let big_encoding = big.as_deref().map_or(encoding_rs::GBK, |b| big_font_encoding(big_name, b.escape_ranges())); + let vertical = vertical && main.as_ref().is_some_and(|m| m.is_dual()); + ShxText { main, big, vertical, big_encoding } + } + /// The code page of the big font's double-byte codes. + pub fn big_font_encoding(&self) -> &'static str { + self.big_encoding.name() + } +} + +/// The code page a big font's double-byte codes are in: known Japanese, Chinese and Korean +/// big font names, else its escape ranges (Shift-JIS lead bytes from 0x81; the others start at +/// 0xA1 or above, read as GBK). +fn big_font_encoding(name: &str, ranges: &[(u16, u16)]) -> &'static Encoding { + let stem = name.trim().rsplit(['/', '\\']).next().unwrap_or_default().to_ascii_lowercase(); + let stem = stem.trim_end_matches(".shx").trim_start_matches('@'); + if stem.starts_with("whg") || stem.starts_with("wht") || stem.contains("kor") || stem.contains("ksc") { + return encoding_rs::EUC_KR; + } + if stem.starts_with("chineset") || stem.contains("big5") || stem.starts_with("cht") { + return encoding_rs::BIG5; + } + if stem.starts_with("gb") || stem.starts_with("hz") || stem.contains("chs") { + return encoding_rs::GBK; + } + if stem.starts_with("bigfont") || stem.starts_with("extfont") || stem.contains("jis") || stem.contains("jpn") { + return encoding_rs::SHIFT_JIS; + } + if ranges.iter().any(|(lo, _)| (0x81..0xA1).contains(lo)) { encoding_rs::SHIFT_JIS } else { encoding_rs::GBK } +} + +/// The double-byte code of `c` in `enc`, if it has one. +fn double_byte(c: char, enc: &'static Encoding) -> Option { + let mut buf = [0u8; 4]; + let (bytes, _, errors) = enc.encode(c.encode_utf8(&mut buf)); + match (errors, bytes.as_ref()) { + (false, [a, b]) => Some(u16::from_be_bytes([*a, *b])), + _ => None, + } +} + +/// The shape numbers that may draw `c` in `font`, best first. +fn candidates(c: char, font: &ShxFont) -> [Option; 4] { + let u = u32::from(c); + let unicode = font.kind() == ShxKind::Unifont && font.encoding() == 0; + let code = |x: u32| u16::try_from(x).ok(); + // The special characters of `%%d` `%%p` `%%c`: shapes 256–258, the Latin-1 or Unicode code, + // and the oldest fonts' 127–129. + let special = match c { + '°' => Some((256, 0xB0, 127)), + '±' => Some((257, 0xB1, 128)), + '⌀' | '∅' => Some((258, u, 129)), + _ => None, + }; + if let Some((s, latin, old)) = special { + return if unicode { + [code(u), code(if c == '⌀' { 0x2205 } else { latin }), Some(s), Some(old)] + } else { + [Some(s), code(latin), Some(old), None] + }; + } + if unicode || u < 0x80 { + return [code(u), None, None, None]; + } + // A single-byte font: the Windows Latin-1 code of the character (not the old special + // codes 127–129). + let mut buf = [0u8; 4]; + let (bytes, _, errors) = encoding_rs::WINDOWS_1252.encode(c.encode_utf8(&mut buf)); + match (errors, bytes.as_ref()) { + (false, [b]) if !(127..=129).contains(b) => [Some(u16::from(*b)), None, None, None], + _ => [None, None, None, None], + } +} + +/// The font and shape that draw `c`. +fn pick(t: &ShxText, c: char) -> Option<(&ShxFont, u16)> { + let from_main = || { + let m = t.main.as_deref()?; + candidates(c, m).into_iter().flatten().find(|n| m.has_shape(*n)).map(|n| (m, n)) + }; + let from_big = || { + let b = t.big.as_deref()?; + let code = double_byte(c, t.big_encoding)?; + let [lead, _] = code.to_be_bytes(); + (b.is_lead_byte(lead) && b.has_shape(code)).then_some((b, code)) + }; + if u32::from(c) < 0x100 || matches!(c, '⌀' | '∅') { from_main().or_else(from_big) } else { from_big().or_else(from_main) } +} + +fn finite_or(v: f64, d: f64) -> f64 { + if v.is_finite() { v } else { d } +} + +/// Shape one line (baseline at y = 0, x from 0). +pub(crate) fn shape(t: &ShxText, s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped { + let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; + let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; + let shear = finite_or(oblique, 0.0).tan().clamp(-10.0, 10.0); + let chars = crate::decode_controls(s); + let logical: Vec = chars.iter().map(|v| v.0).collect(); + let mut out = Shaped::default(); + let mut spans: Vec<(f64, f64, bool, bool)> = Vec::new(); + let mut pen = Vec2::ZERO; + let mut max_x: f64 = 0.0; + let runs = if t.vertical { vec![(0..logical.len(), false)] } else { bidi::visual_runs(&logical, None) }; + for (run, rtl) in runs { + let order: Box> = if rtl { Box::new(run.rev()) } else { Box::new(run) }; + for i in order { + let Some(&(c, under, over)) = chars.get(i) else { continue }; + if bidi::is_format_control(c) { + continue; + } + let c = if rtl { bidi::mirror(c) } else { c }; + let x0 = pen.x; + match pick(t, c).and_then(|(f, n)| Some((f, f.glyph(n, t.vertical)?))) { + Some((f, g)) => { + let k = h / f.units_per_height(); + let place = |p: Vec2| Vec2::new(pen.x + p.x * k * wf + p.y * k * shear, pen.y + p.y * k); + for st in &g.strokes { + let pts: Vec = st.iter().map(|p| place(*p)).collect(); + max_x = pts.iter().fold(max_x, |m, p| m.max(p.x)); + out.strokes.push(pts); + } + let adv = Vec2::new(g.advance.x * k * wf, g.advance.y * k); + pen = if t.vertical { pen + adv } else { Vec2::new(pen.x + adv.x, pen.y) }; + } + None => { + // The stroke font draws it, at the pen. + let mut fb = crate::shape_stroke(&c.to_string(), h, wf, oblique); + let at = pen; + fb.map(|p| p + at); + max_x = max_x.max(at.x + fb.width); + out.extend(fb); + if t.vertical { + pen.y -= h * 1.5; + } else { + pen.x += crate::stroke_advance(c, h, wf); + } + } + } + if !t.vertical { + spans.push((x0, pen.x, under, over)); + } + } + } + out.strokes.extend(crate::decorations(&spans, h)); + out.width = if t.vertical { max_x.max(0.0) } else { pen.x.max(0.0) }; + out +} + +/// The width of one line: the sum of the advances. +pub(crate) fn width(t: &ShxText, s: &str, height: f64, width_factor: f64) -> f64 { + if t.vertical || bidi::has_rtl(s) { + return shape(t, s, height, width_factor, 0.0).width; + } + let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; + let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; + crate::decode_controls(s) + .into_iter() + .filter(|(c, _, _)| !bidi::is_format_control(*c)) + .map(|(c, _, _)| match pick(t, c).and_then(|(f, n)| Some((f, f.glyph(n, false)?))) { + Some((f, g)) => g.advance.x * h / f.units_per_height() * wf, + None => crate::stroke_advance(c, h, wf), + }) + .sum::() + .max(0.0) +} diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index fff4ecde7..bcf610e0a 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -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 { - let mut out = HashMap::new(); +fn font_dirs() -> Vec { let mut dirs: Vec = 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 { 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 { + 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:?}"); + } +} diff --git a/crates/fonts/tests/shx_lookup.rs b/crates/fonts/tests/shx_lookup.rs new file mode 100644 index 000000000..b5feb5a70 --- /dev/null +++ b/crates/fonts/tests/shx_lookup.rs @@ -0,0 +1,76 @@ +//! How SHX fonts are found: drawing folders, the search path, the font map, an app's font +//! provider and FONTALT. Process-wide settings, so one test in its own binary. The fonts are +//! compiled here from shape source written for the test; nothing is read from a CAD install. + +use std::sync::Arc; + +use cadcraft_fonts::shx::{self, FontProvider}; +use cadcraft_fonts::{TextFont, ttf}; + +const FONT: &str = "*0,4,lookup test\n6,2,0,0\n*65,4,UCA\n1,064,0\n"; + +struct Provider; + +impl FontProvider for Provider { + fn load(&self, file_name: &str) -> Option> { + (file_name == "cadcraft-provided.shx").then(|| shx::compile(FONT).ok()).flatten() + } +} + +#[test] +fn fonts_are_found_where_the_drawing_and_the_app_keep_them() { + let bytes = shx::compile(FONT).unwrap(); + let dir = std::env::temp_dir().join(format!("cadcraft-shx-lookup-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("cadcraft-beside.shx"), &bytes).unwrap(); + + // Beside the drawing: found once the drawing's folder is known; a later change of folders + // starts a new generation (viewers regenerate). + assert!(shx::find("cadcraft-beside.shx").is_none()); + let g = shx::generation(); + shx::add_drawing_dir(&dir); + assert!(shx::generation() != g); + assert_eq!(shx::drawing_dirs().first(), Some(&dir)); + assert!(shx::find("CADCRAFT-BESIDE").is_some_and(|f| f.has_shape(65))); + assert!(TextFont::resolve("cadcraft-beside.shx").is_shx()); + // A file whose name has another case than the drawing's (found on case-sensitive systems + // too, by looking through the folder). + std::fs::write(dir.join("CadCraft-Mixed.SHX"), &bytes).unwrap(); + assert!(shx::find("cadcraft-mixed.shx").is_some()); + + // The search path, and the font map renaming a font to one that is there. + let other = dir.join("lib"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(other.join("cadcraft-onpath.shx"), &bytes).unwrap(); + assert!(shx::find("cadcraft-onpath.shx").is_none()); + shx::set_search_path(&other.to_string_lossy()); + assert!(shx::find("cadcraft-onpath.shx").is_some()); + shx::set_font_map("cadcraft-old.shx;cadcraft-onpath.shx\nnonsense"); + assert_eq!(shx::font_map(), "cadcraft-old.shx;cadcraft-onpath.shx"); + assert!(shx::find("cadcraft-old").is_some()); + shx::set_font_map(""); + + // An app's provider (the web build's file store). + assert!(shx::find("cadcraft-provided.shx").is_none()); + shx::set_provider(Some(Arc::new(Provider))); + assert!(shx::find("cadcraft-provided.shx").is_some()); + shx::set_provider(None); + assert!(shx::find("cadcraft-provided.shx").is_none()); + + // FONTALT replaces a missing SHX font (and may name a big font after a comma). + assert!(!TextFont::resolve("cadcraft-missing.shx").is_shx()); + ttf::set_font_alt("cadcraft-beside.shx"); + assert!(TextFont::resolve("cadcraft-missing.shx").is_shx()); + assert!(!TextFont::resolve("").is_shx(), "the built-in font is not replaced"); + ttf::set_font_alt(""); + + // Registered bytes (files opened alongside a drawing on the web) and clearing them. + shx::register("cadcraft-registered.shx", &bytes).unwrap(); + assert!(shx::find("cadcraft-registered.shx").is_some()); + shx::clear_registered(); + assert!(shx::find("cadcraft-registered.shx").is_none()); + + shx::set_search_path(""); + assert_eq!(shx::search_path(), ""); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index 2b799e433..04f665f88 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -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) { let r = self.major.len(); let sweep = self.sweep(); diff --git a/crates/geom/src/mat.rs b/crates/geom/src/mat.rs index 5f2613540..5f28b068d 100644 --- a/crates/geom/src/mat.rs +++ b/crates/geom/src/mat.rs @@ -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 { + 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. diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 49464e06c..21ba5d5cc 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -22,6 +22,7 @@ serde = { workspace = true } miniz_oxide = "0.9" craft-pdf = { workspace = true, features = ["write"] } quick-xml = { workspace = true } +image = { workspace = true } [dev-dependencies] # Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's). diff --git a/crates/io/src/dxf_clip.rs b/crates/io/src/dxf_clip.rs new file mode 100644 index 000000000..5e239e85c --- /dev/null +++ b/crates/io/src/dxf_clip.rs @@ -0,0 +1,551 @@ +//! XCLIP in a file (DXF Reference, SPATIAL_FILTER): a clipped block reference has an extension +//! dictionary with an `ACAD_FILTER` dictionary whose `SPATIAL` entry is a SPATIAL_FILTER object. +//! The filter holds the boundary vertices in the reference's coordinates (group 70 the count, +//! 10/20 the vertices), whether clipping is on (71), the front and back clip (72/73, 40/41) and +//! two 3×4 matrices (group 40, row by row): the inverse of the reference's transform, which takes +//! the boundary into the block's coordinates, and the boundary's own transform (identity here). +//! +//! An inverted clip ("Invert clip") is marked as the reference app writes it in a file: the +//! filter has an extension dictionary whose `ACAD_XREC_ROUNDTRIP` entry is an XRECORD holding +//! `102 ACAD_INVERTEDCLIP_ROUNDTRIP` then the boundary (10/20/30), and +//! `102 ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE` then the filter's own vertices. While the filter +//! still holds those vertices the clip is inverted, with the first list as its boundary. The +//! reference app shows the flag as group 290 on the object but never writes 290 in a DXF file, +//! and discards a file whose filter has one; 290 is read when another program wrote it, never +//! written. The record goes through DWG as it is. +//! +//! Reading takes the planar filters the model can hold out of the kept foreign data and puts them +//! on the references ([`read`]); others (front/back clipping, a skewed boundary transform) stay +//! foreign and are written back untouched. Writing adds the dictionaries and the filter of every +//! clipped reference to a copy of the drawing's foreign data ([`prepare`]), which the raw-data +//! writer (#528) then places on the reference. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use cadcraft_doc::foreign::{Foreign, ForeignObject}; +use cadcraft_doc::{ClipBoundary, Drawing, EntityKind, Handle, MAX_CLIP_VERTICES, RawTag}; +use cadcraft_geom::{Mat3, Vec2}; + +/// The filter's extension-dictionary entry of the reference app's inverted-clip record. +const ROUNDTRIP_KEY: &str = "ACAD_XREC_ROUNDTRIP"; +const ROUNDTRIP: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP"; +const COMPARE: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE"; +/// Handles above this are not handed out (the DXF handle space is 64-bit, keep a margin). +const MAX_HANDLE: u64 = 1 << 48; + +fn raw(code: i32, value: impl ToString) -> RawTag { + RawTag { code, value: value.to_string() } +} + +/// The handle of dictionary entry `name` (group 3 followed by 350/360). +fn entry(tags: &[RawTag], name: &str) -> Option { + tags.windows(2) + .find(|w| matches!(w, [k, v] if k.code == 3 && k.value.eq_ignore_ascii_case(name) && matches!(v.code, 350 | 360))) + .and_then(|w| w.get(1)) + .and_then(|v| Handle::parse_hex(&v.value)) +} + +/// Remove dictionary entry `name`; returns the handle it named. +fn remove_entry(tags: &mut Vec, name: &str) -> Option { + let mut old = None; + let mut i = 0; + while let (Some(k), Some(v)) = (tags.get(i), tags.get(i + 1)) { + if k.code == 3 && k.value.eq_ignore_ascii_case(name) && matches!(v.code, 350 | 360) { + old = old.or_else(|| Handle::parse_hex(&v.value)); + tags.drain(i..i + 2); + } else { + i += 1; + } + } + old +} + +fn number(tags: &[RawTag], code: i32) -> Option { + tags.iter().find(|t| t.code == code)?.value.trim().parse::().ok().filter(|n| n.is_finite()) +} + +/// Where each handle's object is in `Foreign::objects` (the first, as a scan would find it), so +/// a file with many references and objects isn't searched once per reference. +type Index = HashMap; + +fn index(f: &Foreign) -> Index { + let mut idx = Index::with_capacity(f.objects.len()); + for (i, o) in f.objects.iter().enumerate() { + idx.entry(o.handle).or_insert(i); + } + idx +} + +fn object<'a>(f: &'a Foreign, idx: &Index, h: Handle) -> Option<&'a ForeignObject> { + f.objects.get(*idx.get(&h)?) +} + +/// The extension dictionary, ACAD_FILTER dictionary and SPATIAL_FILTER of entity `h`. +fn chain<'a>(f: &'a Foreign, idx: &Index, h: Handle) -> Option<(Handle, Handle, &'a ForeignObject)> { + let xd = f.entities.get(&h)?.xdict?; + let fd = entry(&object(f, idx, xd)?.tags, "ACAD_FILTER")?; + let sf = entry(&object(f, idx, fd)?.tags, "SPATIAL")?; + Some((xd, fd, object(f, idx, sf).filter(|o| o.kind.eq_ignore_ascii_case("SPATIAL_FILTER"))?)) +} + +/// A 3×4 matrix (12 values row by row) as a 2D transform, when it is one (no Z mixing). +fn matrix_2d(m: &[f64]) -> Option { + let [a, c, z1, e, b, d, z2, f, z3, z4, one, z5] = <[f64; 12]>::try_from(m).ok()?; + let flat = [z1, z2, z3, z4, z5].iter().all(|v| v.abs() <= 1e-9) && (one - 1.0).abs() <= 1e-9; + (flat && [a, b, c, d, e, f].iter().all(|v| v.is_finite())).then_some(Mat3 { a, b, c, d, e, f }) +} + +fn matrix_values(m: &Mat3) -> [f64; 12] { + [m.a, m.c, 0.0, m.e, m.b, m.d, 0.0, m.f, 0.0, 0.0, 1.0, 0.0] +} + +/// The `count` vertices (10/20) a SPATIAL_FILTER holds, as written. +fn vertices(tags: &[RawTag], count: usize) -> Option> { + // The count is the file's: bound it before allocating. + if count > MAX_CLIP_VERTICES { + return None; + } + let mut points = Vec::with_capacity(count); + for w in tags.windows(2) { + let [x, y] = w else { continue }; + if x.code != 10 { + continue; + } + if y.code != 20 || points.len() >= count { + return None; + } + points.push(Vec2::new(x.value.trim().parse().ok()?, y.value.trim().parse().ok()?)); + } + (points.len() == count).then_some(points) +} + +/// The handle of an object's own extension dictionary (`102 {ACAD_XDICTIONARY`, 360). +fn own_xdict(tags: &[RawTag]) -> Option { + tags.windows(2) + .find(|w| matches!(w, [k, v] if k.code == 102 && k.value.trim().eq_ignore_ascii_case("{ACAD_XDICTIONARY") && v.code == 360)) + .and_then(|w| w.get(1)) + .and_then(|v| Handle::parse_hex(&v.value)) +} + +/// The boundary of an inverted clip as the reference app marks it on `filter` (see above), when +/// the filter still holds the vertices the mark was made for. +fn roundtrip(f: &Foreign, idx: &Index, filter: &ForeignObject) -> Option> { + let rec = object(f, idx, entry(&object(f, idx, own_xdict(&filter.tags)?)?.tags, ROUNDTRIP_KEY)?)?; + if !rec.kind.eq_ignore_ascii_case("XRECORD") { + return None; + } + let (mut boundary, mut compare) = (Vec::new(), Vec::new()); + // 0 before either list, 1 in the boundary, 2 in the filter's vertices. + let mut list = 0; + for w in rec.tags.windows(2) { + let [x, y] = w else { continue }; + match x.code { + 102 => { + list = match x.value.trim() { + v if v.eq_ignore_ascii_case(ROUNDTRIP) => 1, + v if v.eq_ignore_ascii_case(COMPARE) => 2, + _ => 0, + } + } + 10 if list != 0 && y.code == 20 => { + let into = if list == 1 { &mut boundary } else { &mut compare }; + if into.len() >= MAX_CLIP_VERTICES { + return None; + } + into.push(Vec2::new(x.value.trim().parse().ok()?, y.value.trim().parse().ok()?)); + } + _ => {} + } + } + let own = vertices(&filter.tags, usize::try_from(number(&filter.tags, 70)? as i64).ok()?)?; + let near = |a: f64, b: f64| (a - b).abs() <= 1e-9 * (1.0 + a.abs().max(b.abs())); + let same = own.len() == compare.len() && own.iter().zip(&compare).all(|(p, q)| near(p.x, q.x) && near(p.y, q.y)); + (same && boundary.len() >= 2).then_some(boundary) +} + +/// The boundary a planar SPATIAL_FILTER holds, in the block's coordinates; with `boundary`, the +/// clip is inverted and that is its outline (in the filter's coordinates). `None` for anything +/// the model can't hold as it is (it then stays foreign). +fn parse_filter(o: &ForeignObject, boundary: Option<&[Vec2]>) -> Option { + let count = usize::try_from(number(&o.tags, 70)? as i64).ok()?; + if !(2..=MAX_CLIP_VERTICES).contains(&count) { + return None; + } + let normal_up = number(&o.tags, 210).unwrap_or(0.0).abs() <= 1e-9 + && number(&o.tags, 220).unwrap_or(0.0).abs() <= 1e-9 + && (number(&o.tags, 230).unwrap_or(1.0) - 1.0).abs() <= 1e-9; + let no_depth = number(&o.tags, 72).unwrap_or(0.0) == 0.0 && number(&o.tags, 73).unwrap_or(0.0) == 0.0; + if !normal_up || !no_depth { + return None; + } + let m: Vec = o.tags.iter().filter(|t| t.code == 40).map(|t| t.value.trim().parse::().unwrap_or(f64::NAN)).collect(); + let (inverse, own) = match m.len() { + 24 => (matrix_2d(m.get(..12)?)?, matrix_2d(m.get(12..)?)?), + // Without matrices the vertices are taken as the block's own. + 0 => (Mat3::IDENTITY, Mat3::IDENTITY), + _ => return None, + }; + if (own.a - 1.0).abs() > 1e-9 + || own.b.abs() > 1e-9 + || own.c.abs() > 1e-9 + || (own.d - 1.0).abs() > 1e-9 + || own.e.abs() > 1e-9 + || own.f.abs() > 1e-9 + { + return None; + } + let own = vertices(&o.tags, count)?; + let outline = boundary.unwrap_or(&own); + if !(2..=MAX_CLIP_VERTICES).contains(&outline.len()) { + return None; + } + let points: Vec = outline.iter().map(|p| inverse.apply(*p)).collect(); + if !points.iter().all(|p| p.is_finite()) { + return None; + } + let inverted = boundary.is_some() || number(&o.tags, 290).is_some_and(|v| v != 0.0); + let clip = ClipBoundary { points, enabled: number(&o.tags, 71).unwrap_or(1.0) != 0.0, inverted }; + cadcraft_render::clip::PolyClip::new(&clip.polygon()).is_some().then_some(clip) +} + +/// Put the planar filters of the file read into `d` on their references, taking them out of +/// the kept foreign data (the model writes them from now on). +pub(crate) fn read(d: &mut Drawing) { + if d.foreign.objects.is_empty() { + return; + } + let idx = index(&d.foreign); + let found: Vec<(Handle, ClipBoundary)> = d + .foreign + .entities + .keys() + .filter_map(|h| { + let (_, _, o) = chain(&d.foreign, &idx, *h)?; + let boundary = roundtrip(&d.foreign, &idx, o); + Some((*h, parse_filter(o, boundary.as_deref())?)) + }) + .collect(); + if found.is_empty() { + return; + } + let mut taken = Vec::new(); + for (h, clip) in found { + let set = |e: &mut cadcraft_doc::Entity| { + if let EntityKind::Insert(i) = &mut e.kind { + i.clip = Some(clip.clone()); + } + }; + let is_insert = |e: &cadcraft_doc::Entity| matches!(e.kind, EntityKind::Insert(_)); + let done = if d.entity(h).is_some_and(|e| is_insert(e)) { + d.modify_entity(h, set).is_ok() + } else { + let mut done = false; + for b in d.blocks.values_mut() { + if b.entities.get(h).is_some_and(|e| is_insert(e)) { + done = Arc::make_mut(b).entities.modify(h, set); + break; + } + } + done + }; + if done { + taken.push(h); + } + } + let f = Arc::make_mut(&mut d.foreign); + let mut gone = HashSet::new(); + for h in taken { + forget(f, &idx, h, &mut gone); + } + f.objects.retain(|o| !gone.contains(&o.handle)); +} + +/// Take the clip of entity `h` out of the foreign data: the ACAD_FILTER entry and what it holds, +/// and the extension dictionary when nothing else is left in it. The +/// objects to remove are added to `gone`; the caller removes them all at once. +fn forget(f: &mut Foreign, idx: &Index, h: Handle, all_gone: &mut HashSet) { + let Some(xd) = f.entities.get(&h).and_then(|x| x.xdict) else { return }; + let mut gone: Vec = Vec::new(); + let mut empty = false; + if let Some(o) = idx.get(&xd).copied().and_then(|i| f.objects.get_mut(i)) { + gone.extend(remove_entry(&mut o.tags, "ACAD_FILTER")); + empty = !o.tags.iter().any(|t| t.code == 3); + } + // The filter dictionary's entries go with it, and the filter's own extension dictionary. + let mut i = 0; + while let Some(g) = gone.get(i).copied() { + if let Some(o) = object(f, idx, g) { + if o.kind.eq_ignore_ascii_case("DICTIONARY") { + gone.extend(o.tags.windows(2).filter_map(|w| match w { + [k, v] if k.code == 3 && matches!(v.code, 350 | 360) => Handle::parse_hex(&v.value), + _ => None, + })); + } + gone.extend(own_xdict(&o.tags)); + } + i += 1; + if i > MAX_CLIP_VERTICES { + break; + } + } + if empty { + gone.push(xd); + if let Some(x) = f.entities.get_mut(&h) { + x.xdict = None; + } + if f.entities.get(&h).is_some_and(|x| x.is_empty()) { + f.entities.remove(&h); + } + } + all_gone.extend(gone); +} + +fn dictionary(h: Handle, owner: Handle) -> ForeignObject { + ForeignObject { + handle: h, + kind: "DICTIONARY".into(), + tags: vec![ + raw(5, h.hex()), + raw(102, "{ACAD_REACTORS"), + raw(330, owner.hex()), + raw(102, "}"), + raw(330, owner.hex()), + raw(100, "AcDbDictionary"), + raw(280, 1), + raw(281, 1), + ], + } +} + +/// The SPATIAL_FILTER of `clip`; `xdict` is its extension dictionary (an inverted clip's). +/// No group 290: the reference app discards a file whose filter has one. +fn spatial_filter(h: Handle, owner: Handle, clip: &ClipBoundary, to_world: &Mat3, inverse: &Mat3, xdict: Option) -> ForeignObject { + let mut tags = vec![raw(5, h.hex()), raw(102, "{ACAD_REACTORS"), raw(330, owner.hex()), raw(102, "}")]; + if let Some(x) = xdict { + tags.extend([raw(102, "{ACAD_XDICTIONARY"), raw(360, x.hex()), raw(102, "}")]); + } + tags.extend([raw(330, owner.hex()), raw(100, "AcDbFilter"), raw(100, "AcDbSpatialFilter"), raw(70, clip.points.len())]); + for p in &clip.points { + let q = to_world.apply(*p); + tags.push(raw(10, q.x)); + tags.push(raw(20, q.y)); + } + tags.extend([raw(210, 0.0), raw(220, 0.0), raw(230, 1.0), raw(11, 0.0), raw(21, 0.0), raw(31, 0.0)]); + tags.extend([raw(71, i32::from(clip.enabled)), raw(72, 0), raw(73, 0)]); + for v in matrix_values(inverse).into_iter().chain(matrix_values(&Mat3::IDENTITY)) { + tags.push(raw(40, v)); + } + ForeignObject { handle: h, kind: "SPATIAL_FILTER".into(), tags } +} + +/// The reference app's inverted-clip record for a filter whose vertices are `points` (drawing +/// coordinates): the boundary and the filter's vertices are the same list. +fn roundtrip_record(h: Handle, owner: Handle, points: &[Vec2]) -> ForeignObject { + let mut tags = vec![raw(5, h.hex()), raw(102, "{ACAD_REACTORS"), raw(330, owner.hex()), raw(102, "}")]; + tags.extend([raw(330, owner.hex()), raw(100, "AcDbXrecord"), raw(280, 1)]); + for name in [ROUNDTRIP, COMPARE] { + tags.push(raw(102, name)); + for p in points { + tags.extend([raw(10, p.x), raw(20, p.y), raw(30, 0.0)]); + } + } + ForeignObject { handle: h, kind: "XRECORD".into(), tags } +} + +/// A copy of `d` whose foreign data holds the SPATIAL_FILTER of every clipped reference (any +/// filter kept from the file for it is replaced). `None` when no reference is clipped. +pub(crate) fn prepare(d: &Drawing) -> Option { + let mut clips: Vec<(Handle, ClipBoundary, Mat3)> = Vec::new(); + let mut take = |e: &cadcraft_doc::Entity| { + if let EntityKind::Insert(i) = &e.kind + && let Some(c) = &i.clip + && c.polygon().len() >= 3 + && let Some(base) = d.block(&i.block).map(|b| b.base.xy()) + { + clips.push((e.handle, c.clone(), i.transform(base))); + } + }; + d.model.iter().for_each(|e| take(e)); + d.layouts.iter().flat_map(|l| l.entities.iter()).for_each(|e| take(e)); + d.blocks.values().filter(|b| b.xref_path.is_none()).flat_map(|b| b.entities.iter()).for_each(|e| take(e)); + if clips.is_empty() { + return None; + } + let mut out = d.clone(); + let f = Arc::make_mut(&mut out.foreign); + let mut next = d.handseed.max(f.objects.iter().map(|o| o.handle.0).max().unwrap_or(0)).saturating_add(1); + fn fresh(next: &mut u64) -> Handle { + let h = Handle(*next); + *next = next.saturating_add(1); + h + } + let mut idx = index(f); + let mut gone = HashSet::new(); + for (insert, clip, to_world) in clips { + let Some(inverse) = to_world.inverse() else { continue }; + if next.saturating_add(6) >= MAX_HANDLE { + break; + } + // A filter kept from the file for this reference is replaced. + forget(f, &idx, insert, &mut gone); + let xd = match f.entities.get(&insert).and_then(|x| x.xdict).filter(|h| idx.contains_key(h) && !gone.contains(h)) { + Some(h) => h, + None => { + let h = fresh(&mut next); + idx.insert(h, f.objects.len()); + f.objects.push(dictionary(h, insert)); + f.entities.entry(insert).or_default().xdict = Some(h); + h + } + }; + let (fd, sf) = (fresh(&mut next), fresh(&mut next)); + // An inverted clip's filter has an extension dictionary and the record in it. + let marks = clip.inverted.then(|| (fresh(&mut next), fresh(&mut next))); + if let Some(o) = idx.get(&xd).copied().and_then(|i| f.objects.get_mut(i)) { + o.tags.extend([raw(3, "ACAD_FILTER"), raw(360, fd.hex())]); + } + let mut filters = dictionary(fd, xd); + filters.tags.extend([raw(3, "SPATIAL"), raw(360, sf.hex())]); + let mut add = |o: ForeignObject| { + idx.insert(o.handle, f.objects.len()); + f.objects.push(o); + }; + add(filters); + add(spatial_filter(sf, fd, &clip, &to_world, &inverse, marks.map(|(x, _)| x))); + if let Some((x, r)) = marks { + let mut own = dictionary(x, sf); + own.tags.extend([raw(3, ROUNDTRIP_KEY), raw(360, r.hex())]); + add(own); + let world: Vec = clip.points.iter().map(|p| to_world.apply(*p)).collect(); + add(roundtrip_record(r, x, &world)); + } + } + f.objects.retain(|o| !gone.contains(&o.handle)); + // The writer hands out handles of its own from the seed on: start it past these. + out.handseed = out.handseed.max(next); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matrices_round_trip_row_by_row() { + let m = Mat3::translate(Vec2::new(10.0, 20.0)).then_before(Mat3::rotate(0.5)).then_before(Mat3::scale(2.0, 2.0)); + let back = matrix_2d(&matrix_values(&m)).unwrap(); + assert!((back.a - m.a).abs() < 1e-12 && (back.c - m.c).abs() < 1e-12 && (back.f - m.f).abs() < 1e-12); + // Translation is the fourth value of each row. + let t = matrix_values(&Mat3::translate(Vec2::new(-5.0, 7.0))); + assert_eq!((t[3], t[7]), (-5.0, 7.0)); + assert!(matrix_2d(&[1.0; 11]).is_none()); + } + + #[test] + fn hostile_filters_stay_foreign() { + let o = |tags: Vec| ForeignObject { handle: Handle(1), kind: "SPATIAL_FILTER".into(), tags }; + assert!(parse_filter(&o(vec![raw(70, 1_000_000_000)]), None).is_none()); + assert!(parse_filter(&o(vec![raw(70, 3), raw(10, 0), raw(20, 0)]), None).is_none()); + assert!(parse_filter(&o(vec![raw(70, 2), raw(10, "nan"), raw(20, 0), raw(10, 1), raw(20, 1)]), None).is_none()); + assert!(parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1), raw(72, 1)]), None).is_none()); + let ok = parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1)]), None).unwrap(); + assert_eq!((ok.points.len(), ok.inverted), (2, false)); + } + + #[test] + fn the_writer_starts_its_handles_past_the_clip_objects() { + let mut d = Drawing::new_metric(); + d.blocks.insert("B".into(), Arc::new(cadcraft_doc::Block::new("B"))); + for i in 0..3 { + let clip = ClipBoundary::new(vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)]); + let ins = cadcraft_doc::Insert { block: "B".into(), clip: Some(ClipBoundary { inverted: i == 0, ..clip }), ..Default::default() }; + d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), EntityKind::Insert(ins)).unwrap(); + } + let out = prepare(&d).unwrap(); + let highest = out.foreign.objects.iter().map(|o| o.handle.0).max().unwrap(); + assert!(out.handseed > highest, "seed {} at or below {highest}", out.handseed); + } + + #[test] + fn group_290_is_read_but_never_written() { + let tags = |invert: &str| vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1), raw(290, invert)]; + let o = |invert| ForeignObject { handle: Handle(1), kind: "SPATIAL_FILTER".into(), tags: tags(invert) }; + assert!(parse_filter(&o("1"), None).unwrap().inverted); + assert!(!parse_filter(&o("0"), None).unwrap().inverted); + assert!(!parse_filter(&o("junk"), None).unwrap().inverted); + // The reference app discards a DXF whose filter has 290. + let clip = ClipBoundary { inverted: true, ..ClipBoundary::new(vec![Vec2::ZERO, Vec2::new(1.0, 1.0)]) }; + let written = spatial_filter(Handle(2), Handle(1), &clip, &Mat3::IDENTITY, &Mat3::IDENTITY, Some(Handle(3))); + assert!(!written.tags.iter().any(|t| t.code == 290)); + assert_eq!(own_xdict(&written.tags), Some(Handle(3))); + } + + /// The filter, its extension dictionary and the reference app's record, as one foreign set. + fn marked(filter_points: &[(f64, f64)], boundary: &[(f64, f64)], compare: &[(f64, f64)]) -> (Foreign, ForeignObject) { + let mut tags = vec![raw(5, "10"), raw(102, "{ACAD_XDICTIONARY"), raw(360, "11"), raw(102, "}"), raw(70, filter_points.len())]; + for (x, y) in filter_points { + tags.extend([raw(10, x), raw(20, y)]); + } + let filter = ForeignObject { handle: Handle(0x10), kind: "SPATIAL_FILTER".into(), tags }; + let mut f = Foreign::default(); + let mut dict = dictionary(Handle(0x11), Handle(0x10)); + dict.tags.extend([raw(3, ROUNDTRIP_KEY), raw(360, "12")]); + f.objects.push(dict); + let mut rec = vec![raw(100, "AcDbXrecord"), raw(280, 1)]; + for (name, list) in [(ROUNDTRIP, boundary), (COMPARE, compare)] { + rec.push(raw(102, name)); + for (x, y) in list { + rec.extend([raw(10, x), raw(20, y), raw(30, 0)]); + } + } + f.objects.push(ForeignObject { handle: Handle(0x12), kind: "XRECORD".into(), tags: rec }); + f.objects.push(filter.clone()); + (f, filter) + } + + #[test] + fn the_reference_apps_record_marks_an_inverted_clip() { + // As the reference app writes an inverted rectangle: a keyhole filter, the rectangle kept. + let keyhole = [ + (0.0, 0.0), + (100.0, 0.0), + (100.0, 50.0), + (0.0, 50.0), + (0.0, 10.0), + (20.0, 10.0), + (20.0, 40.0), + (80.0, 40.0), + (80.0, 10.0), + (20.0, 10.0), + (0.0, 10.0), + ]; + let (f, filter) = marked(&keyhole, &[(20.0, 10.0), (80.0, 40.0)], &keyhole); + let boundary = roundtrip(&f, &index(&f), &filter).unwrap(); + let clip = parse_filter(&filter, Some(&boundary)).unwrap(); + assert!(clip.inverted); + assert_eq!(clip.points, vec![Vec2::new(20.0, 10.0), Vec2::new(80.0, 40.0)]); + // The simpler form: the filter keeps the boundary itself. + let rect = [(20.0, 10.0), (80.0, 40.0)]; + let (f, filter) = marked(&rect, &rect, &rect); + assert!(roundtrip(&f, &index(&f), &filter).is_some()); + // A filter changed since the record was made (the clip edited elsewhere) isn't inverted. + let (f, filter) = marked(&[(0.0, 0.0), (5.0, 5.0)], &rect, &rect); + assert!(roundtrip(&f, &index(&f), &filter).is_none()); + } + + #[test] + fn a_hostile_vertex_count_beside_the_record_does_not_allocate() { + let rect = [(20.0, 10.0), (80.0, 40.0)]; + for count in ["9223372036854775807", "1000000000", "1e300"] { + let (mut f, mut filter) = marked(&rect, &rect, &rect); + for t in filter.tags.iter_mut().filter(|t| t.code == 70) { + t.value = count.into(); + } + f.objects.retain(|o| o.handle != filter.handle); + f.objects.push(filter.clone()); + assert!(roundtrip(&f, &index(&f), &filter).is_none(), "{count}"); + assert!(parse_filter(&filter, None).is_none(), "{count}"); + } + } +} diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 392ee1400..0b733f47c 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -881,6 +881,23 @@ pub(crate) fn parse_constraints(text: &str) -> Option<(Vec, Parametr Some((p.constraints, p.parametric)) } +/// A view centre as a file stores it (VIEWPORT and VPORT group 12, VIEW group 10), as the +/// drawing point at the middle of the view. The stored centre is in display coordinates: their +/// origin is the view's `target` (VIEWPORT and VPORT group 17, VIEW group 12) and their axes are +/// turned by the view's `twist` (radians). Turning back keeps a twisted view on its part of the +/// drawing however far that is from the origin; a non-finite result keeps the stored centre. +pub(crate) fn dcs_center_to_model(c: cadcraft_geom::Vec2, twist: f64, target: cadcraft_geom::Vec2) -> cadcraft_geom::Vec2 { + let m = c.rotate(-twist) + if target.is_finite() { target } else { cadcraft_geom::Vec2::ZERO }; + if m.is_finite() { m } else { c } +} + +/// The display-coordinate view centre a file stores for the drawing point `m` at the middle of +/// a view with `twist`, its target at the origin (the inverse of [`dcs_center_to_model`]). +pub(crate) fn model_center_to_dcs(m: cadcraft_geom::Vec2, twist: f64) -> cadcraft_geom::Vec2 { + let c = m.rotate(twist); + if c.is_finite() { c } else { m } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/io/src/dxf_mleader.rs b/crates/io/src/dxf_mleader.rs index 04ce90a01..f6c88d41b 100644 --- a/crates/io/src/dxf_mleader.rs +++ b/crates/io/src/dxf_mleader.rs @@ -325,6 +325,7 @@ pub(crate) fn read(tags: &[Tag]) -> MLeader { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, } }); MLeader { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 2dcb14fc0..40366a7c7 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -139,6 +139,7 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { d.layers.insert(0, Layer::default()); } crate::dxf_raw::read(&secs, &mut d); + crate::dxf_clip::read(&mut d); d.skipped_records = skipped; Ok(d) } diff --git a/crates/io/src/dxf_read/common.rs b/crates/io/src/dxf_read/common.rs index 1dbabf6e9..907427718 100644 --- a/crates/io/src/dxf_read/common.rs +++ b/crates/io/src/dxf_read/common.rs @@ -23,10 +23,8 @@ impl T<'_> { /// origin is the view target (`target`), so the point in the middle of a plan view is their /// sum: a file whose view centre is 0,0 keeps the location in the target (#2492). A sum that /// overflows leaves the stored centre. - pub(super) fn view_center(&self, center: i32, target: i32) -> Vec2 { - let c = self.p(center).xy(); - let sum = c + self.p(target).xy(); - if sum.is_finite() { sum } else { c } + pub(super) fn view_center(&self, center: i32, target: i32, twist: f64) -> Vec2 { + crate::dxf_ext::dcs_center_to_model(self.p(center).xy(), twist, self.p(target).xy()) } pub(super) fn all_f(&self, code: i32) -> Vec { self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect() diff --git a/crates/io/src/dxf_read/document.rs b/crates/io/src/dxf_read/document.rs index 6f61e3415..7d22e95d1 100644 --- a/crates/io/src/dxf_read/document.rs +++ b/crates/io/src/dxf_read/document.rs @@ -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); + +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, rx: &mut Rx, paper_block_for: &HashMap, 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, 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, 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 = 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). diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index 6ca0f30d1..c28dfd7bb 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -167,6 +167,7 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, col_spacing, row_spacing: t.fd(45, 0.0), + clip: None, }) } "DIMENSION" => { @@ -243,20 +244,25 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef k } "ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)), - "VIEWPORT" => EntityKind::Viewport(Viewport { - center: t.p(10), - width: t.fd(40, 1.0), - height: t.fd(41, 1.0), - // 12/22 is in display coordinates, whose origin is the view target 17/27/37. - view_center: t.view_center(12, 17), - view_height: t.fd(45, 1.0), - id: t.i(69).unwrap_or(0) as u32, - locked: t.i(90).unwrap_or(0) & 16384 != 0, - frozen_layers: crate::dxf_ext::read_frozen(tags), - layer_colors: crate::dxf_ext::read_layer_colors(tags), - // Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off. - clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0), - on: t.i(90).unwrap_or(0) & 131072 == 0, + "VIEWPORT" => EntityKind::Viewport({ + // View twist (group 51, degrees). + let twist = t.f(51).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians())); + Viewport { + center: t.p(10), + width: t.fd(40, 1.0), + height: t.fd(41, 1.0), + // 12/22 is in display coordinates, whose origin is the view target 17/27/37. + view_center: t.view_center(12, 17, twist), + view_height: t.fd(45, 1.0), + id: t.i(69).unwrap_or(0) as u32, + locked: t.i(90).unwrap_or(0) & 16384 != 0, + frozen_layers: crate::dxf_ext::read_frozen(tags), + layer_colors: crate::dxf_ext::read_layer_colors(tags), + // Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off. + clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0), + on: t.i(90).unwrap_or(0) & 131072 == 0, + twist, + } }), "WIPEOUT" => { let o = t.p(10); @@ -299,7 +305,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; } diff --git a/crates/io/src/dxf_read/tables.rs b/crates/io/src/dxf_read/tables.rs index c914ea9f5..518df8d3f 100644 --- a/crates/io/src/dxf_read/tables.rs +++ b/crates/io/src/dxf_read/tables.rs @@ -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 = 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 = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect(); + let mut style_index: HashMap = 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. @@ -217,8 +224,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { continue; } + let twist = t.f(50).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians())); // The view centre 10/20 is in display coordinates, whose origin is the target 12/22/32. - let center = t.view_center(10, 12); + let center = t.view_center(10, 12, twist); if !(center.x.is_finite() && center.y.is_finite()) { continue; } @@ -228,7 +236,6 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { }; // 70 bit 1: a paper space view; its layout is CADCraft xdata. let layout = (t.i(70).unwrap_or(0) & 1 != 0).then(|| field("LAYOUT").unwrap_or_default()); - let twist = t.f(50).filter(|a| a.is_finite()).map_or(0.0, |a| a.rem_euclid(360.0).to_radians()); let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state: field("LAYERSTATE"), twist, layout }; match d.views.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { Some(x) => *x = v, @@ -279,8 +286,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { _ => {} } } - // One *Active viewport is no tiling: the view is the editor's (it zooms to the extents). - d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active())); + // One *Active viewport is no tiling: the view is the editor's (it zooms to the extents), + // kept only for its view twist (see `cadcraft_engine::DocState`). + d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active()) || c.tiles.first().is_some_and(|t| t.twist != 0.0)); } fn index_table_handles(recs: &[(String, Vec)], rx: &mut Rx) { @@ -317,8 +325,10 @@ const MAX_VPORT_TILES: usize = 64; /// are fractions of the drawing area; unusable tiles are skipped. fn vport_record(d: &mut Drawing, t: &T) { let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default(); + // View twist (group 51, degrees). + let twist = t.f(51).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians())); // The view centre 12/22 is in display coordinates, whose origin is the view target 17/27/37. - let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17)); + let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17, twist)); let height = t.fd(40, 0.0); let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) @@ -332,7 +342,7 @@ fn vport_record(d: &mut Drawing, t: &T) { } let aspect = t.f(41).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(0.0); let clamp = |p: Vec2| Vec2::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)); - let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect }; + let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect, twist }; let name = if name.eq_ignore_ascii_case(VportConfig::ACTIVE) { VportConfig::ACTIVE.to_string() } else { name }; let room = d.vports.len() < MAX_OBJECTS; match d.vports.iter_mut().find(|c| c.name.eq_ignore_ascii_case(&name)) { diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index ee9c18d33..5d759e223 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -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() @@ -679,8 +684,15 @@ fn write_viewport_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: // Status 68: a positive value is "on" (the stacking order; 1 is the active viewport), 0 off. w.i(68, if v.on { i64::from(v.id.clamp(1, 32767)) } else { 0 }); w.i(69, i64::from(v.id)); - w.p2(12, v.view_center); + // Within one turn, so a hostile twist (from JSON or MCP) writes a sane angle. + let twist = cadcraft_geom::norm_angle(v.view_twist()); + // The view centre in display coordinates (turned by the twist). + w.p2(12, dxf_ext::model_center_to_dcs(v.view_center, twist)); w.f(45, v.view_height); + // View twist (degrees), when the viewport has one. + if twist != 0.0 { + w.f(51, twist.to_degrees()); + } // Status flags: 16384 = display locked, 32768 = always set, 65536 = clipped by the // 340 entity, 131072 = off. let clip = v.clip.filter(|h| *h != e.handle && d.entity(*h).is_some()); diff --git a/crates/io/src/dxf_write/layout.rs b/crates/io/src/dxf_write/layout.rs index 364648daf..1341f8ec2 100644 --- a/crates/io/src/dxf_write/layout.rs +++ b/crates/io/src/dxf_write/layout.rs @@ -72,13 +72,24 @@ pub(super) fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &C /// The VPORT table: the `*Active` model space tiles (one viewport on the extents when there is no /// tiled configuration) and the saved configurations, each tile a record, the current one first. pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) { - let single = VportTile { - min: Vec2::ZERO, - max: Vec2::new(1.0, 1.0), - center: if ext.is_empty() { Vec2::new(6.0, 4.5) } else { ext.center() }, - height: if ext.is_empty() { 9.0 } else { (ext.height() * 1.1).max(1e-6) }, - aspect: 1.6, + // An untiled view's twist: the lone `*Active` tile the editor keeps for it. + let twist = d + .vports + .iter() + .find(|c| c.is_active() && c.tiles.len() == 1) + .and_then(|c| c.tiles.first()) + .map_or(0.0, |t| cadcraft_geom::norm_angle(t.twist)); + // The extents fitted as the turned view sees them. + let (center, height) = if ext.is_empty() { + (Vec2::new(6.0, 4.5), 9.0) + } else if twist == 0.0 { + (ext.center(), (ext.height() * 1.1).max(1e-6)) + } else { + let r = Bounds2::from_points(ext.corners().map(|p| p.rotate(twist))); + let height = r.height() * 1.1; + (r.center().rotate(-twist), if height.is_finite() { height.max(1e-6) } else { 9.0 }) }; + let single = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center, height, aspect: 1.6, twist }; let tiled = d.vports.iter().find(|c| c.is_active() && c.tiles.len() > 1); let mut records: Vec<(&str, &VportTile)> = match tiled { Some(c) => c.tiles.iter().map(|t| (VportConfig::ACTIVE, t)).collect(), @@ -93,7 +104,8 @@ pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) { w.i(70, 0); w.p2(10, finite(t.min, Vec2::ZERO)); w.p2(11, finite(t.max, Vec2::new(1.0, 1.0))); - w.p2(12, finite(t.center, Vec2::ZERO)); + let twist = cadcraft_geom::norm_angle(t.twist); + w.p2(12, dxf_ext::model_center_to_dcs(finite(t.center, Vec2::ZERO), twist)); // Snap grid origin and rotation (SNAPBASE, SNAPANG in degrees): R13+ files keep them here. w.p2(13, d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO)); w.p2(14, d.header.snap_unit()); @@ -104,6 +116,7 @@ pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) { w.f(41, if t.aspect.is_finite() && t.aspect > 0.0 { t.aspect } else { 1.0 }); w.f(42, 50.0); w.f(50, d.header.f64("SNAPANG", 0.0)); + w.f(51, twist.to_degrees()); } w.s(0, "ENDTAB"); } diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index 68cf61b21..99d6a25e1 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -136,14 +136,15 @@ fn write_tables(w: &mut W, plan: TableWritePlan<'_>) -> HashMap w.s(2, &v.name); w.i(70, i64::from(v.layout.is_some())); w.f(40, v.height); - w.p2(10, v.center); + let twist = cadcraft_geom::norm_angle(v.twist); + w.p2(10, dxf_ext::model_center_to_dcs(v.center, twist)); w.f(41, v.width); w.p(11, Vec3::Z); w.p(12, Vec3::ZERO); w.f(42, 50.0); w.f(43, 0.0); w.f(44, 0.0); - w.f(50, if v.twist.is_finite() { v.twist.to_degrees() } else { 0.0 }); + w.f(50, twist.to_degrees()); w.i(71, 0); w.i(281, 0); w.i(72, 0); @@ -908,6 +909,9 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version, frozen_34 // An in-place block edit in progress is not written: the drawing as before it. let committed = d.committed(); let d: &Drawing = &committed; + // XCLIP boundaries go into the foreign data the raw writer places on the references. + let clipped = crate::dxf_clip::prepare(d); + let d: &Drawing = clipped.as_ref().unwrap_or(d); // Structural handles start above every entity handle. let mut w = W { t: Vec::new(), diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index ccfba201e..e82c3e35d 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -5,6 +5,8 @@ /// What a DXF file holds that CADCraft doesn't model, kept and written back (#528). mod dxf_raw; +/// XCLIP boundaries as SPATIAL_FILTER objects. +mod dxf_clip; mod dxf_ext; mod dxf_image; mod dxf_mleader; @@ -14,6 +16,10 @@ mod dxf_write; mod dxf_xref; pub mod pdf; pub mod plotter; +/// Raster image files (PNG, JPEG, BMP, TIFF) decoded to pixels. +pub mod raster; +/// Open → save → reopen comparison and the real-file corpus harness (gaps FF1, FF5). +pub mod roundtrip; pub mod svg; mod svg_read; @@ -55,6 +61,13 @@ pub enum IoError { pub type Result = std::result::Result; +/// 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) -> String { name.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() } @@ -62,19 +75,25 @@ fn ext(name: impl AsRef) -> 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) -> Result { - 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>)> { 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 +123,29 @@ pub fn write_with(d: &Drawing, name: impl AsRef, 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, opts: &SaveOptions) -> Result<(Vec, 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, 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 diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index ba9d65867..6d9a69bf1 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -873,6 +873,8 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts: } } Kind::Point => point(s, &mut batch, &p.map, raw, m), + // Raster images are not written to PDF yet: their frame is. + Kind::Image { .. } => {} } } flush(s, &mut batch); diff --git a/crates/io/src/raster.rs b/crates/io/src/raster.rs new file mode 100644 index 000000000..3e09ef9c9 --- /dev/null +++ b/crates/io/src/raster.rs @@ -0,0 +1,79 @@ +//! Raster image files decoded to pixels (IMAGEATTACH): PNG, JPEG, BMP and TIFF, the format taken +//! from the content. Files are hostile input: the size is read from the header and checked +//! before any pixel is decoded. + +use std::io::Cursor; + +use cadcraft_doc::Raster; + +use crate::{IoError, Result}; + +/// Largest image file read (bytes). +pub const MAX_IMAGE_BYTES: usize = 256 << 20; +/// Most pixels decoded per image (four bytes each in memory). +pub const MAX_IMAGE_PIXELS: u64 = 128_000_000; + +fn bad(e: impl std::fmt::Display) -> IoError { + IoError::Format(format!("image: {e}")) +} + +/// A reader over an image file, once it is small enough and of a format we read. +fn reader(bytes: &[u8]) -> Result>> { + if bytes.len() > MAX_IMAGE_BYTES { + return Err(bad(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20))); + } + // The format comes from the file's first bytes, not from its name. + let file = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(bad)?; + if file.format().is_none() { + return Err(bad("not a PNG, JPEG, BMP or TIFF file")); + } + Ok(file) +} + +/// The width and height in pixels of the image in `bytes`, read from its header. +pub fn probe(bytes: &[u8]) -> Result<(u32, u32)> { + let (width, height) = reader(bytes)?.into_dimensions().map_err(bad)?; + if width == 0 || height == 0 { + return Err(bad("the image is empty")); + } + if u64::from(width).saturating_mul(u64::from(height)) > MAX_IMAGE_PIXELS { + return Err(bad(format!("{width} x {height} pixels is more than {} megapixels", MAX_IMAGE_PIXELS / 1_000_000))); + } + Ok((width, height)) +} + +/// Decode the image in `bytes`. +pub fn decode(bytes: &[u8]) -> Result { + // The size is checked first, from the header alone. + let (width, height) = probe(bytes)?; + let mut file = reader(bytes)?; + file.limits(decode_limits(width, height)); + let pixels = file.decode().map_err(bad)?.into_rgba8(); + Raster::new(pixels.width(), pixels.height(), pixels.into_raw()).ok_or_else(|| bad("the image is empty")) +} + +/// What the decoder may allocate for an image of this size: the pixels themselves (up to 16 +/// bytes each before they become 8-bit RGBA) and its own working buffers. +fn decode_limits(width: u32, height: u32) -> image::Limits { + let pixels = u64::from(width).saturating_mul(u64::from(height)); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(width); + limits.max_image_height = Some(height); + limits.max_alloc = Some(pixels.saturating_mul(16).saturating_add(64 << 20)); + limits +} + +/// `raster` as a PNG file. +pub fn encode_png(raster: &Raster) -> Result> { + let mut out = Cursor::new(Vec::new()); + image::write_buffer_with_format( + &mut out, + raster.rgba(), + raster.width(), + raster.height(), + image::ExtendedColorType::Rgba8, + image::ImageFormat::Png, + ) + .map_err(bad)?; + Ok(out.into_inner()) +} diff --git a/crates/io/src/roundtrip.rs b/crates/io/src/roundtrip.rs new file mode 100644 index 000000000..e801e663d --- /dev/null +++ b/crates/io/src/roundtrip.rs @@ -0,0 +1,1148 @@ +//! Round-trip checks (gaps FF1 and FF5): open a drawing, save it, open the saved file again and +//! list everything that differs between the two drawings. +//! +//! [`compare`] works on CADCraft's own model ([`Drawing`]), so it measures what saving and +//! reopening lose or change: entities per type in every space and block (matched by handle, +//! geometry and properties within a tolerance), table records, blocks, layouts, the data CADCraft +//! keeps without modelling it (other programs' objects and entities, dictionaries, CLASS records, +//! extension dictionaries, reactors, xdata) and header variables. [`census`] counts what the +//! file itself holds, so opening a file can be checked against it too. +//! +//! The corpus harness ([`corpus`], native only) runs both over a folder of real files and writes +//! a Markdown and JSON report; `cadcraft-cli roundtrip` and `cargo xtask corpus` run it. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use cadcraft_doc::foreign::{Foreign, ForeignExtra}; +use cadcraft_doc::{Drawing, Entity, EntityKind, EntityStore, Handle, RawTag}; +use serde::Serialize; +use serde_json::Value; + +mod census; +#[cfg(not(target_arch = "wasm32"))] +pub mod corpus; + +pub use census::{Census, census, census_of, compare_census}; + +/// What a difference is about. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Category { + /// Opening the file: what the file holds that the drawing read from it doesn't. + Read, + /// Entities of a type in a space or block. + Count, + /// Entity, object or dictionary entry handles. + Handle, + /// An entity's geometry and type-specific data. + Geometry, + /// An entity's common properties (layer, colour, linetype, lineweight, transparency…). + Properties, + /// Entities CADCraft doesn't model, kept as their DXF groups. + Opaque, + /// Other applications' extended data. + Xdata, + /// Extension dictionaries and persistent reactors. + ExtDict, + /// Table records: layers, linetypes, text, dimension, multileader, table and multiline styles, + /// views, viewport configurations, UCSs, layer states, groups, scales, plot style tables. + Table, + /// Block definitions (their own properties; their entities are under the other categories). + Block, + /// Layouts and their page setups. + Layout, + /// Non-graphical objects CADCraft doesn't model, kept as their DXF groups; constraints. + Object, + /// Entries of the named object dictionary. + Dictionary, + /// CLASS records. + Class, + /// Header variables. + Header, +} + +impl Category { + pub const ALL: [Category; 15] = [ + Category::Read, + Category::Count, + Category::Handle, + Category::Geometry, + Category::Properties, + Category::Opaque, + Category::Xdata, + Category::ExtDict, + Category::Table, + Category::Block, + Category::Layout, + Category::Object, + Category::Dictionary, + Category::Class, + Category::Header, + ]; + + /// A short name for reports. + pub fn label(self) -> &'static str { + match self { + Category::Read => "read", + Category::Count => "entity count", + Category::Handle => "handles", + Category::Geometry => "geometry", + Category::Properties => "properties", + Category::Opaque => "unknown entities", + Category::Xdata => "xdata", + Category::ExtDict => "ext. dictionaries/reactors", + Category::Table => "table records", + Category::Block => "blocks", + Category::Layout => "layouts", + Category::Object => "objects", + Category::Dictionary => "dictionary entries", + Category::Class => "classes", + Category::Header => "header", + } + } +} + +/// Whether something went missing, appeared, or is there with other values. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Change { + Lost, + Added, + Changed, +} + +impl Change { + pub fn label(self) -> &'static str { + match self { + Change::Lost => "lost", + Change::Added => "added", + Change::Changed => "changed", + } + } +} + +/// One difference between two drawings. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Difference { + pub category: Category, + pub change: Change, + /// Where: `Model LINE 1A`, `Block DOOR`, `LAYER Walls`, `$LTSCALE`… + pub place: String, + /// What: the values before and after, or the items lost. + pub detail: String, + /// How many items this difference stands for (several entities lost of one type: one row). + pub count: usize, +} + +/// How many items were lost, added and changed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Tally { + pub lost: usize, + pub added: usize, + pub changed: usize, +} + +impl Tally { + pub fn total(&self) -> usize { + self.lost.saturating_add(self.added).saturating_add(self.changed) + } + fn add(&mut self, change: Change, n: usize) { + let slot = match change { + Change::Lost => &mut self.lost, + Change::Added => &mut self.added, + Change::Changed => &mut self.changed, + }; + *slot = slot.saturating_add(n); + } +} + +/// How two drawings are compared. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Options { + /// Absolute tolerance for numbers (drawing units, radians…). + pub abs_tol: f64, + /// Relative tolerance for numbers, of the larger magnitude. + pub rel_tol: f64, + /// Most differences listed; the rest are only counted ([`Comparison::omitted`]). + pub max_differences: usize, + /// Most changed values named for one object. + pub max_paths: usize, +} + +impl Default for Options { + fn default() -> Self { + Options { abs_tol: 1e-6, rel_tol: 1e-9, max_differences: 5000, max_paths: 6 } + } +} + +/// Every difference found between two drawings. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct Comparison { + pub differences: Vec, + /// Differences found beyond [`Options::max_differences`] (counted in `totals`, not listed). + pub omitted: usize, + /// Items lost, added and changed per category. + pub totals: BTreeMap, +} + +impl Comparison { + pub fn is_empty(&self) -> bool { + self.totals.values().all(|t| t.total() == 0) + } + /// Items lost, added and changed in all categories. + pub fn total(&self) -> Tally { + let mut all = Tally::default(); + for t in self.totals.values() { + all.add(Change::Lost, t.lost); + all.add(Change::Added, t.added); + all.add(Change::Changed, t.changed); + } + all + } + /// The tally of one category. + pub fn tally(&self, c: Category) -> Tally { + self.totals.get(&c).copied().unwrap_or_default() + } + /// Add another comparison's differences (within this one's limit). + pub fn merge(&mut self, other: Comparison, max: usize) { + for (c, t) in other.totals { + let mine = self.totals.entry(c).or_default(); + mine.add(Change::Lost, t.lost); + mine.add(Change::Added, t.added); + mine.add(Change::Changed, t.changed); + } + for d in other.differences { + if self.differences.len() < max { + self.differences.push(d); + } else { + self.omitted = self.omitted.saturating_add(1); + } + } + self.omitted = self.omitted.saturating_add(other.omitted); + } +} + +/// Collects differences within the limit. +struct Out<'o> { + opts: &'o Options, + cmp: Comparison, + /// Anonymous blocks saved under another name (`*D344` → `*D10`), upper case: references + /// to them compare equal. + renamed: HashMap, + /// Things that only got new handles, summed up in one row each: (what) → (how many, an + /// example). + renumbered: BTreeMap, + /// Anonymous blocks of the first drawing that nothing refers to and that the second one + /// doesn't have (saving drops them, as PURGE would), upper case: reported in one row. + orphans: BTreeSet, +} + +/// What values are compared with: the tolerances and the renamed anonymous blocks. +struct Norm<'a> { + o: &'a Options, + renamed: &'a HashMap, +} + +impl Norm<'_> { + /// Equal strings, or an anonymous block's name and the name it was saved under. + fn same_str(&self, a: &str, b: &str) -> bool { + a == b || (a.starts_with('*') && self.renamed.get(&a.to_ascii_uppercase()).is_some_and(|n| n.eq_ignore_ascii_case(b))) + } +} + +impl<'o> Out<'o> { + fn new(opts: &'o Options) -> Self { + Out { opts, cmp: Comparison::default(), renamed: HashMap::new(), renumbered: BTreeMap::new(), orphans: BTreeSet::new() } + } + fn norm(&self) -> Norm<'_> { + Norm { o: self.opts, renamed: &self.renamed } + } + /// Note that one of `what` only got a new handle. + fn renumber(&mut self, what: &str, example: impl FnOnce() -> String) { + match self.renumbered.get_mut(what) { + Some(slot) => slot.0 = slot.0.saturating_add(1), + None => { + self.renumbered.insert(what.to_string(), (1, example())); + } + } + } + /// The rows of [`Out::renumber`]. + fn flush_renumbered(&mut self) { + for (what, (n, example)) in std::mem::take(&mut self.renumbered) { + self.push_n(Category::Handle, Change::Changed, n, what.clone(), format!("{n} {what} got new handles (e.g. {example})")); + } + } + fn push(&mut self, category: Category, change: Change, place: impl Into, detail: impl Into) { + self.push_n(category, change, 1, place, detail); + } + fn push_n(&mut self, category: Category, change: Change, count: usize, place: impl Into, detail: impl Into) { + self.cmp.totals.entry(category).or_default().add(change, count); + if self.cmp.differences.len() < self.opts.max_differences { + self.cmp.differences.push(Difference { category, change, place: place.into(), detail: detail.into(), count }); + } else { + self.cmp.omitted = self.cmp.omitted.saturating_add(1); + } + } +} + +/// Header variables that change on every save (time stamps, the handle seed, GUIDs): not +/// compared. +pub const VOLATILE_HEADER: &[&str] = + &["HANDSEED", "TDCREATE", "TDUCREATE", "TDUPDATE", "TDUUPDATE", "TDINDWG", "TDUSRTIMER", "VERSIONGUID", "FINGERPRINTGUID"]; + +/// Compare `before` (the drawing as first opened) with `after` (the same drawing saved and +/// reopened). Differences are reported as what `after` lost, gained or changed. +pub fn compare(before: &Drawing, after: &Drawing, opts: &Options) -> Comparison { + let mut out = Out::new(opts); + compare_entities(before, after, &mut out); + compare_tables(before, after, &mut out); + compare_foreign(&before.foreign, &after.foreign, &mut out); + compare_header(before, after, &mut out); + out.flush_renumbered(); + out.cmp +} + +// --------------------------------------------------------------------------------------------- +// Values + +fn close(a: f64, b: f64, o: &Options) -> bool { + a == b || (a - b).abs() <= o.abs_tol + o.rel_tol * a.abs().max(b.abs()) +} + +/// The changed values of one object, as `path: before → after`. +struct Paths { + list: Vec, + cap: usize, + more: usize, +} + +impl Paths { + fn new(cap: usize) -> Self { + Paths { list: Vec::new(), cap: cap.max(1), more: 0 } + } + fn add(&mut self, s: String) { + if self.list.len() < self.cap { + self.list.push(s); + } else { + self.more = self.more.saturating_add(1); + } + } + fn is_empty(&self) -> bool { + self.list.is_empty() + } + /// Far more changes than can be listed: stop looking. + fn saturated(&self) -> bool { + self.more > 1000 + } + fn text(&self) -> String { + let mut s = self.list.join("; "); + if self.more > 0 { + s.push_str(&format!("; and {} more", if self.saturated() { "many".to_string() } else { self.more.to_string() })); + } + s + } +} + +/// A value for a report: strings quoted, at most 60 characters. +fn short(v: &Value) -> String { + let s = match v { + Value::String(s) => format!("\"{s}\""), + Value::Array(a) => format!("[{} items]", a.len()), + Value::Object(_) => "{…}".to_string(), + other => other.to_string(), + }; + clip(&s, 60) +} + +/// `s` cut to `n` characters, with an ellipsis when cut. +pub(crate) fn clip(s: &str, n: usize) -> String { + if s.chars().count() <= n { + s.to_string() + } else { + let mut t: String = s.chars().take(n.saturating_sub(1)).collect(); + t.push('…'); + t + } +} + +fn show(path: &str) -> &str { + if path.is_empty() { "value" } else { path } +} + +const MAX_DEPTH: usize = 64; + +/// Whether a value's path names an angle (in radians in the model): `rotation`, `start`, +/// `end`, `angle`, `twist`, `oblique`. +fn is_angle(path: &str) -> bool { + let last = path.rsplit('.').next().unwrap_or(path); + let last = last.split('[').next().unwrap_or(last).to_ascii_lowercase(); + ["rotation", "start", "end", "angle", "twist", "oblique"].iter().any(|k| last.ends_with(k)) +} + +/// The same angle, a whole number of turns apart (-π/4 and 7π/4). +fn same_angle(a: f64, b: f64, o: &Options) -> bool { + let d = (a - b).rem_euclid(std::f64::consts::TAU); + let tol = o.abs_tol + o.rel_tol * a.abs().max(b.abs()); + d <= tol || std::f64::consts::TAU - d <= tol +} + +fn diff_values(a: &Value, b: &Value, path: &mut String, n: &Norm, p: &mut Paths, depth: usize) { + if p.saturated() { + return; + } + if depth > MAX_DEPTH { + if a != b { + p.add(format!("{}: differs", show(path))); + } + return; + } + match (a, b) { + (Value::Number(x), Value::Number(y)) => { + let same = match (x.as_i64(), y.as_i64()) { + (Some(i), Some(j)) => i == j, + _ => match (x.as_f64(), y.as_f64()) { + (Some(f), Some(g)) => close(f, g, n.o) || (is_angle(path) && same_angle(f, g, n.o)), + _ => x == y, + }, + }; + if !same { + p.add(format!("{}: {x} → {y}", show(path))); + } + } + (Value::String(x), Value::String(y)) => { + if !n.same_str(x, y) { + p.add(format!("{}: {} → {}", show(path), short(a), short(b))); + } + } + (Value::Object(x), Value::Object(y)) => { + let keys: BTreeSet<&String> = x.keys().chain(y.keys()).collect(); + for k in keys { + let len = path.len(); + if !path.is_empty() { + path.push('.'); + } + path.push_str(k); + match (x.get(k.as_str()), y.get(k.as_str())) { + (Some(u), Some(v)) => diff_values(u, v, path, n, p, depth + 1), + (Some(Value::Null), None) | (None, Some(Value::Null)) | (None, None) => {} + (Some(u), None) => p.add(format!("{}: {} → (none)", show(path), short(u))), + (None, Some(v)) => p.add(format!("{}: (none) → {}", show(path), short(v))), + } + path.truncate(len); + } + } + (Value::Array(x), Value::Array(y)) => { + if x.len() != y.len() { + p.add(format!("{}: {} items → {}", show(path), x.len(), y.len())); + } + for (i, (u, v)) in x.iter().zip(y).enumerate() { + if p.saturated() { + return; + } + let len = path.len(); + path.push_str(&format!("[{i}]")); + diff_values(u, v, path, n, p, depth + 1); + path.truncate(len); + } + } + _ if a == b => {} + _ => p.add(format!("{}: {} → {}", show(path), short(a), short(b))), + } +} + +/// The values that differ between two serializable things. +fn diff_ser(a: &T, b: &T, n: &Norm) -> Paths { + let mut p = Paths::new(n.o.max_paths); + match (serde_json::to_value(a), serde_json::to_value(b)) { + (Ok(x), Ok(y)) => diff_values(&x, &y, &mut String::new(), n, &mut p, 0), + _ if a != b => p.add("differs (not comparable field by field)".into()), + _ => {} + } + p +} + +/// A key for matching things that may have been renumbered: the value with numbers rounded. +fn signature(v: &Value, out: &mut String, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match v { + Value::Number(n) => match n.as_f64() { + Some(f) if f.is_finite() => { + let r = if f.abs() < 1e-9 { 0.0 } else { f }; + out.push_str(&format!("{r:.6e}")); + } + _ => out.push_str(&n.to_string()), + }, + Value::Array(a) => { + out.push('['); + for x in a { + signature(x, out, depth + 1); + out.push(','); + } + out.push(']'); + } + Value::Object(m) => { + out.push('{'); + for (k, x) in m { + out.push_str(k); + out.push(':'); + signature(x, out, depth + 1); + out.push(','); + } + out.push('}'); + } + other => out.push_str(&other.to_string()), + } +} + +fn sig_of(t: &T) -> String { + let mut s = String::new(); + if let Ok(v) = serde_json::to_value(t) { + signature(&v, &mut s, 0); + } + s +} + +/// Pair up the items of `a` and `b` that weren't matched by handle: equal signatures first, then +/// in order. Returns the pairs and the items left over on each side. +fn pair_up<'x, T>(a: Vec<&'x T>, b: Vec<&'x T>, sig: impl Fn(&T) -> String) -> (Vec<(&'x T, &'x T)>, Vec<&'x T>, Vec<&'x T>) { + let mut by_sig: HashMap> = HashMap::new(); + for (i, y) in b.iter().enumerate().rev() { + by_sig.entry(sig(y)).or_default().push(i); + } + let mut used = vec![false; b.len()]; + let mut pairs = Vec::new(); + let mut rest_a = Vec::new(); + for x in a { + match by_sig.get_mut(&sig(x)).and_then(Vec::pop) { + Some(i) => { + if let (Some(y), Some(u)) = (b.get(i), used.get_mut(i)) { + *u = true; + pairs.push((x, *y)); + } + } + None => rest_a.push(x), + } + } + let mut rest_b: Vec<&T> = b.iter().zip(&used).filter(|(_, u)| !**u).map(|(y, _)| *y).collect(); + let n = rest_a.len().min(rest_b.len()); + let tail_a = rest_a.split_off(n); + let tail_b = rest_b.split_off(n); + pairs.extend(rest_a.into_iter().zip(rest_b)); + (pairs, tail_a, tail_b) +} + +fn hex_list(hs: impl Iterator, n: usize) -> String { + let all: Vec = hs.map(Handle::hex).collect(); + let mut s = all.iter().take(n).cloned().collect::>().join(", "); + if all.len() > n { + s.push_str(", …"); + } + s +} + +/// Group codes that hold handles (DXF Reference: 5, 105, 320–369, 390–399, 480–481, xdata +/// 1005). A file's objects can be renumbered on save (gap FF7), so a handle compares equal to +/// any other handle; only a reference that became null (or stopped being null) counts. +fn is_handle_code(code: i32) -> bool { + matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480 | 481 | 1005) +} + +fn null_handle(v: &str) -> bool { + v.trim().trim_start_matches('0').is_empty() +} + +/// Whether two groups hold the same value: the same text, numbers within the tolerance, or two +/// (non-null) handles. +fn tag_eq(a: &RawTag, b: &RawTag, o: &Options) -> bool { + if a.code != b.code { + return false; + } + if is_handle_code(a.code) { + return null_handle(&a.value) == null_handle(&b.value); + } + a.value == b.value + || a.value.trim() == b.value.trim() + || matches!((a.value.trim().parse::(), b.value.trim().parse::()), (Ok(x), Ok(y)) if close(x, y, o)) +} + +/// How two group lists differ, or `None` when they hold the same values. +fn tags_diff(a: &[RawTag], b: &[RawTag], o: &Options) -> Option { + let first = a.iter().zip(b).position(|(x, y)| !tag_eq(x, y, o)); + if first.is_none() && a.len() == b.len() { + return None; + } + let mut s = String::new(); + if a.len() != b.len() { + s.push_str(&format!("{} groups → {}; ", a.len(), b.len())); + } + let at = first.unwrap_or(a.len().min(b.len())); + let show_tag = |t: Option<&RawTag>| t.map_or("(end)".to_string(), |t| format!("{} `{}`", t.code, clip(&t.value, 40))); + s.push_str(&format!("first difference at group #{at}: {} → {}", show_tag(a.get(at)), show_tag(b.get(at)))); + Some(s) +} + +// --------------------------------------------------------------------------------------------- +// Entities + +/// An entity collection of a drawing: model space, a layout's paper space or a block. `key` is +/// upper case (`BLOCK *D12`), `name` for reports (`Block *D12`). +struct Container<'d> { + key: String, + name: String, + store: &'d EntityStore, +} + +fn containers(d: &Drawing) -> Vec> { + let mut v = vec![Container { key: "MODEL".into(), name: "Model".into(), store: &d.model }]; + for l in &d.layouts { + v.push(Container { key: format!("LAYOUT {}", l.name.to_ascii_uppercase()), name: format!("Layout {}", l.name), store: &l.entities }); + } + for (name, b) in &d.blocks { + v.push(Container { key: format!("BLOCK {}", name.to_ascii_uppercase()), name: format!("Block {name}"), store: &b.entities }); + } + v +} + +/// Which container each entity is in. +fn locate(cs: &[Container]) -> HashMap { + cs.iter().enumerate().flat_map(|(i, c)| c.store.iter().map(move |e| (e.handle, i))).collect() +} + +/// Anonymous blocks of `a` whose entities (by handle) are, at least half of them, in another +/// anonymous block of `b`: saving renamed them (`*D344` → `*D10`). Upper-case names. +fn anonymous_renames(ca: &[Container], cb: &[Container], where_b: &HashMap) -> HashMap { + let mut renamed = HashMap::new(); + let mut taken: HashSet = HashSet::new(); + for c in ca { + let Some(name) = c.key.strip_prefix("BLOCK ").filter(|n| n.starts_with('*')) else { continue }; + if c.store.is_empty() { + continue; + } + let mut votes: BTreeMap = BTreeMap::new(); + for e in c.store.iter() { + if let Some(j) = where_b.get(&e.handle) { + *votes.entry(*j).or_default() += 1; + } + } + let best = votes.into_iter().max_by(|x, y| x.1.cmp(&y.1).then(y.0.cmp(&x.0))); + let Some((j, n)) = best else { continue }; + let Some(target) = cb.get(j).and_then(|t| t.key.strip_prefix("BLOCK ")) else { continue }; + if n.saturating_mul(2) >= c.store.len() && target != name && target.starts_with('*') && taken.insert(j) { + renamed.insert(name.to_string(), target.to_string()); + } + } + renamed +} + +struct Ctx<'d> { + a: &'d Drawing, + b: &'d Drawing, + cb: &'d [Container<'d>], + where_a: &'d HashMap, + where_b: &'d HashMap, +} + +fn compare_entities(a: &Drawing, b: &Drawing, out: &mut Out) { + let ca = containers(a); + let cb = containers(b); + let where_a = locate(&ca); + let where_b = locate(&cb); + out.renamed = anonymous_renames(&ca, &cb, &where_b); + let index_b: HashMap<&str, usize> = cb.iter().enumerate().map(|(i, c)| (c.key.as_str(), i)).collect(); + let target = |c: &Container| match c.key.strip_prefix("BLOCK ").and_then(|n| out.renamed.get(n)) { + Some(n) => index_b.get(format!("BLOCK {n}").as_str()).copied(), + None => index_b.get(c.key.as_str()).copied(), + }; + let targets: Vec> = ca.iter().map(target).collect(); + // A block renamed onto the name another block had: that one has no counterpart by name. + let renamed_onto: HashSet = ca + .iter() + .zip(&targets) + .filter(|(c, _)| c.key.strip_prefix("BLOCK ").is_some_and(|n| out.renamed.contains_key(n))) + .filter_map(|(_, t)| *t) + .collect(); + let ctx = Ctx { a, b, cb: &cb, where_a: &where_a, where_b: &where_b }; + let empty = EntityStore::new(); + let mut matched: HashSet = HashSet::new(); + let mut moves: BTreeMap<(String, String), Vec> = BTreeMap::new(); + let referenced = referenced_blocks(&ca); + let mut orphan_entities = 0usize; + for (c, t) in ca.iter().zip(&targets) { + let renamed = c.key.strip_prefix("BLOCK ").is_some_and(|n| out.renamed.contains_key(n)); + let j = t.filter(|j| renamed || !renamed_onto.contains(j)).filter(|j| matched.insert(*j)); + if j.is_none() + && let Some(name) = c.key.strip_prefix("BLOCK ").filter(|n| n.starts_with('*') && !referenced.contains(*n)) + && c.store.iter().all(|e| !where_b.contains_key(&e.handle)) + { + out.orphans.insert(name.to_string()); + orphan_entities = orphan_entities.saturating_add(c.store.len()); + continue; + } + let sb = j.and_then(|j| cb.get(j)).map_or(&empty, |x| x.store); + compare_store(&c.name, c.store, sb, &ctx, &mut moves, out); + } + if let Some(first) = out.orphans.first().cloned() { + let n = out.orphans.len(); + let detail = format!("{orphan_entities} entities of {n} anonymous blocks nothing refers to (e.g. {first}) were not saved"); + out.push_n(Category::Count, Change::Lost, orphan_entities, "unreferenced anonymous blocks", detail); + } + for (j, c) in cb.iter().enumerate() { + if !matched.contains(&j) { + compare_store(&c.name, &empty, c.store, &ctx, &mut moves, out); + } + } + for ((from, to), hs) in moves { + let detail = format!("{} entities moved from {from} to {to} ({})", hs.len(), hex_list(hs.iter().copied(), 6)); + out.push_n(Category::Handle, Change::Changed, hs.len(), from, detail); + } +} + +/// The blocks a drawing's entities refer to (block references, dimensions, multileaders), upper +/// case. +fn referenced_blocks(cs: &[Container]) -> HashSet { + let mut out = HashSet::new(); + for e in cs.iter().flat_map(|c| c.store.iter()) { + match &e.kind { + EntityKind::Insert(i) => { + out.insert(i.block.to_ascii_uppercase()); + } + EntityKind::Dimension(d) => out.extend(d.block.as_ref().map(|b| b.to_ascii_uppercase())), + EntityKind::MLeader(m) => out.extend(m.block.as_ref().map(|b| b.block.to_ascii_uppercase())), + _ => {} + } + } + out +} + +fn kind_sig(e: &Entity) -> String { + sig_of(&e.kind) +} + +fn compare_store(name: &str, sa: &EntityStore, sb: &EntityStore, ctx: &Ctx, moves: &mut BTreeMap<(String, String), Vec>, out: &mut Out) { + let ib: HashMap = sb.iter().map(|e| (e.handle, e.as_ref())).collect(); + let ia: HashSet = sa.iter().map(|e| e.handle).collect(); + let mut count_a: BTreeMap = BTreeMap::new(); + let mut count_b: BTreeMap = BTreeMap::new(); + let mut un_a: BTreeMap> = BTreeMap::new(); + let mut un_b: BTreeMap> = BTreeMap::new(); + for e in sa.iter() { + let t = e.kind.dxf_name().to_string(); + *count_a.entry(t.clone()).or_default() += 1; + if let Some(f) = ib.get(&e.handle) { + compare_entity(name, e, f, ctx, out); + } else if let Some(other) = ctx.where_b.get(&e.handle).and_then(|j| ctx.cb.get(*j)) { + moves.entry((name.to_string(), other.name.clone())).or_default().push(e.handle); + if let Some(f) = other.store.get(e.handle) { + compare_entity(name, e, f, ctx, out); + } + } else { + un_a.entry(t).or_default().push(e); + } + } + for e in sb.iter() { + let t = e.kind.dxf_name().to_string(); + *count_b.entry(t.clone()).or_default() += 1; + if !ia.contains(&e.handle) && !ctx.where_a.contains_key(&e.handle) { + un_b.entry(t).or_default().push(e); + } + } + let types: BTreeSet = un_a.keys().chain(un_b.keys()).cloned().collect(); + for t in types { + let xs = un_a.remove(&t).unwrap_or_default(); + let ys = un_b.remove(&t).unwrap_or_default(); + let (pairs, lost, added) = pair_up(xs, ys, kind_sig); + for (x, y) in &pairs { + out.renumber(&format!("{t} entities"), || format!("{} → {} in {name}", x.handle.hex(), y.handle.hex())); + compare_entity(name, x, y, ctx, out); + } + let (na, nb) = (count_a.get(&t).copied().unwrap_or(0), count_b.get(&t).copied().unwrap_or(0)); + if !lost.is_empty() { + let detail = format!("{t}: {na} → {nb} ({} lost: {})", lost.len(), hex_list(lost.iter().map(|e| e.handle), 8)); + out.push_n(Category::Count, Change::Lost, lost.len(), format!("{name} {t}"), detail); + } + if !added.is_empty() { + let detail = format!("{t}: {na} → {nb} ({} added: {})", added.len(), hex_list(added.iter().map(|e| e.handle), 8)); + out.push_n(Category::Count, Change::Added, added.len(), format!("{name} {t}"), detail); + } + } +} + +fn compare_entity(container: &str, x: &Entity, y: &Entity, ctx: &Ctx, out: &mut Out) { + let handles = if x.handle == y.handle { x.handle.hex() } else { format!("{}→{}", x.handle.hex(), y.handle.hex()) }; + let place = format!("{container} {} {handles}", x.kind.dxf_name()); + if std::mem::discriminant(&x.kind) != std::mem::discriminant(&y.kind) || x.kind.dxf_name() != y.kind.dxf_name() { + out.push(Category::Geometry, Change::Changed, place, format!("type {} → {}", x.kind.dxf_name(), y.kind.dxf_name())); + return; + } + match (&x.kind, &y.kind) { + (EntityKind::Unknown(u), EntityKind::Unknown(v)) => { + if let Some(d) = tags_diff(&u.tags, &v.tags, out.opts) { + out.push(Category::Opaque, Change::Changed, place.clone(), d); + } + } + _ => { + let p = diff_ser(&x.kind, &y.kind, &out.norm()); + if !p.is_empty() { + out.push(Category::Geometry, Change::Changed, place.clone(), p.text()); + } + } + } + let common = |e: &Entity| { + let mut v = serde_json::to_value(&e.common).unwrap_or(Value::Null); + if let Value::Object(m) = &mut v { + m.remove("xdata"); + } + v + }; + let mut p = Paths::new(out.opts.max_paths); + diff_values(&common(x), &common(y), &mut String::new(), &out.norm(), &mut p, 0); + if !p.is_empty() { + out.push(Category::Properties, Change::Changed, place.clone(), p.text()); + } + compare_xdata(&place, &x.common.xdata, &y.common.xdata, out); + compare_extra(&place, ctx.a.foreign.entities.get(&x.handle), ctx.b.foreign.entities.get(&y.handle), out); +} + +/// Extended data split per application (from each 1001 group). +fn xdata_apps(tags: &[RawTag]) -> Vec<(String, &[RawTag])> { + let starts: Vec = tags.iter().enumerate().filter(|(_, t)| t.code == 1001).map(|(i, _)| i).collect(); + let mut v = Vec::new(); + for (k, &s) in starts.iter().enumerate() { + let end = starts.get(k + 1).copied().unwrap_or(tags.len()); + let name = tags.get(s).map(|t| t.value.trim().to_ascii_uppercase()).unwrap_or_default(); + v.push((name, tags.get(s..end).unwrap_or(&[]))); + } + if starts.first().is_some_and(|&s| s > 0) || (starts.is_empty() && !tags.is_empty()) { + let end = starts.first().copied().unwrap_or(tags.len()); + v.insert(0, (String::new(), tags.get(..end).unwrap_or(&[]))); + } + v +} + +fn compare_xdata(place: &str, a: &[RawTag], b: &[RawTag], out: &mut Out) { + if a.is_empty() && b.is_empty() { + return; + } + let (xa, xb) = (xdata_apps(a), xdata_apps(b)); + let mb: HashMap<&str, &[RawTag]> = xb.iter().map(|(n, t)| (n.as_str(), *t)).collect(); + let ma: HashSet<&str> = xa.iter().map(|(n, _)| n.as_str()).collect(); + for (name, ta) in &xa { + let label = if name.is_empty() { "(no application)" } else { name }; + match mb.get(name.as_str()) { + None => out.push(Category::Xdata, Change::Lost, place, format!("application {label} ({} groups)", ta.len())), + Some(tb) => { + if let Some(d) = tags_diff(ta, tb, out.opts) { + out.push(Category::Xdata, Change::Changed, place, format!("application {label}: {d}")); + } + } + } + } + for (name, tb) in &xb { + if !ma.contains(name.as_str()) { + let label = if name.is_empty() { "(no application)" } else { name }; + out.push(Category::Xdata, Change::Added, place, format!("application {label} ({} groups)", tb.len())); + } + } +} + +fn compare_extra(place: &str, a: Option<&ForeignExtra>, b: Option<&ForeignExtra>, out: &mut Out) { + let none = ForeignExtra::default(); + let (a, b) = (a.unwrap_or(&none), b.unwrap_or(&none)); + if a == b { + return; + } + match (a.xdict, b.xdict) { + (Some(h), None) => out.push(Category::ExtDict, Change::Lost, place, format!("extension dictionary {}", h.hex())), + (None, Some(h)) => out.push(Category::ExtDict, Change::Added, place, format!("extension dictionary {}", h.hex())), + (Some(h), Some(k)) if h != k => out.renumber("extension dictionaries", || format!("{} → {} of {place}", h.hex(), k.hex())), + _ => {} + } + if a.reactors.len() > b.reactors.len() { + let n = a.reactors.len() - b.reactors.len(); + out.push_n(Category::ExtDict, Change::Lost, n, place, format!("reactors: {} → {}", a.reactors.len(), b.reactors.len())); + } else if b.reactors.len() > a.reactors.len() { + let n = b.reactors.len() - a.reactors.len(); + out.push_n(Category::ExtDict, Change::Added, n, place, format!("reactors: {} → {}", a.reactors.len(), b.reactors.len())); + } + compare_xdata(place, &a.xdata, &b.xdata, out); + if a.color_book != b.color_book { + let show = |c: &Option<(String, String)>| c.as_ref().map_or("(none)".to_string(), |(book, tc)| format!("`{}` ({tc})", clip(book, 40))); + let change = match (&a.color_book, &b.color_book) { + (Some(_), None) => Change::Lost, + (None, Some(_)) => Change::Added, + _ => Change::Changed, + }; + out.push(Category::Properties, change, place, format!("colour book colour {} → {}", show(&a.color_book), show(&b.color_book))); + } + if let Some(d) = tags_diff(&a.props, &b.props, out.opts) { + out.push(Category::Properties, Change::Changed, place, format!("other property groups (material, shadows…): {d}")); + } +} + +// --------------------------------------------------------------------------------------------- +// Tables, blocks, layouts + +/// Compare two lists of named records, matched by name without case. +fn compare_named(out: &mut Out, cat: Category, kind: &str, a: &[&T], b: &[&T], name: impl Fn(&T) -> &str) { + let mut mb: HashMap = HashMap::new(); + for y in b.iter().rev() { + mb.insert(name(y).to_ascii_uppercase(), *y); + } + let mut seen: HashSet = HashSet::new(); + for x in a { + let key = name(x).to_ascii_uppercase(); + if !seen.insert(key.clone()) { + continue; + } + let place = format!("{kind} {}", name(x)); + match mb.get(&key) { + None => out.push(cat, Change::Lost, place, String::new()), + Some(y) => { + let p = diff_ser(*x, *y, &out.norm()); + if !p.is_empty() { + out.push(cat, Change::Changed, place, p.text()); + } + } + } + } + let mut seen_b: HashSet = HashSet::new(); + for y in b { + let key = name(y).to_ascii_uppercase(); + if !seen.contains(&key) && seen_b.insert(key) { + out.push(cat, Change::Added, format!("{kind} {}", name(y)), String::new()); + } + } +} + +fn refs(v: &[T]) -> Vec<&T> { + v.iter().collect() +} + +fn compare_tables(a: &Drawing, b: &Drawing, out: &mut Out) { + use Category::{Block, Layout, Table}; + compare_named(out, Table, "LAYER", &refs(&a.layers), &refs(&b.layers), |x| &x.name); + compare_named(out, Table, "LTYPE", &refs(&a.linetypes), &refs(&b.linetypes), |x| &x.name); + compare_named(out, Table, "STYLE", &refs(&a.text_styles), &refs(&b.text_styles), |x| &x.name); + compare_named(out, Table, "DIMSTYLE", &refs(&a.dim_styles), &refs(&b.dim_styles), |x| &x.name); + compare_named(out, Table, "MLEADERSTYLE", &refs(&a.mleader_styles), &refs(&b.mleader_styles), |x| &x.name); + compare_named(out, Table, "TABLESTYLE", &refs(&a.table_styles), &refs(&b.table_styles), |x| &x.name); + compare_named(out, Table, "MLINESTYLE", &refs(&a.mline_styles), &refs(&b.mline_styles), |x| &x.name); + compare_named(out, Table, "VIEW", &refs(&a.views), &refs(&b.views), |x| &x.name); + compare_named(out, Table, "VPORT", &refs(&a.vports), &refs(&b.vports), |x| &x.name); + compare_named(out, Table, "UCS", &refs(&a.ucss), &refs(&b.ucss), |x| &x.name); + compare_named(out, Table, "LAYERSTATE", &refs(&a.layer_states), &refs(&b.layer_states), |x| &x.name); + compare_named(out, Table, "GROUP", &refs(&a.groups), &refs(&b.groups), |x| &x.name); + compare_named(out, Table, "SCALE", &refs(&a.scales), &refs(&b.scales), |x| &x.name); + compare_named(out, Table, "PLOTSTYLE", &refs(&a.plot_style_tables), &refs(&b.plot_style_tables), |x| &x.name); + // Blocks: renamed anonymous blocks are compared with what they were saved as. + let renamed_to: HashSet = out.renamed.values().cloned().collect(); + let ba: Vec<&cadcraft_doc::Block> = a + .blocks + .values() + .map(|x| x.as_ref()) + .filter(|x| { + let up = x.name.to_ascii_uppercase(); + !out.renamed.contains_key(&up) && !out.orphans.contains(&up) + }) + .collect(); + if let Some(first) = out.orphans.first().cloned() { + let n = out.orphans.len(); + out.push_n( + Block, + Change::Lost, + n, + "unreferenced anonymous blocks", + format!("{n} anonymous blocks nothing refers to (e.g. {first}) were not saved"), + ); + } + let bb: Vec<&cadcraft_doc::Block> = + b.blocks.values().map(|x| x.as_ref()).filter(|x| !renamed_to.contains(&x.name.to_ascii_uppercase())).collect(); + let mut pairs: Vec<(&cadcraft_doc::Block, &cadcraft_doc::Block)> = Vec::new(); + for (from, to) in &out.renamed { + if let (Some(x), Some(y)) = (a.block(from), b.block(to)) { + pairs.push((x.as_ref(), y.as_ref())); + } + } + compare_named(out, Block, "BLOCK", &ba, &bb, |x| &x.name); + pairs.sort_by(|x, y| x.0.name.cmp(&y.0.name)); + if let Some((x, y)) = pairs.first() { + let detail = format!("{} anonymous blocks saved under new names (e.g. {} → {})", pairs.len(), x.name, y.name); + out.push_n(Block, Change::Changed, pairs.len(), "anonymous blocks", detail); + } + for (x, y) in pairs { + let p = diff_ser(x, y, &out.norm()); + if !p.is_empty() { + out.push(Block, Change::Changed, format!("BLOCK {} → {}", x.name, y.name), p.text()); + } + } + compare_named(out, Layout, "LAYOUT", &refs(&a.layouts), &refs(&b.layouts), |x| &x.name); + for (place, p) in + [("CONSTRAINTS", diff_ser(&a.constraints, &b.constraints, &out.norm())), ("PARAMETERS", diff_ser(&a.parametric, &b.parametric, &out.norm()))] + { + if !p.is_empty() { + out.push(Category::Object, Change::Changed, place, p.text()); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Data kept without modelling it + +fn class_name(c: &[RawTag]) -> String { + c.iter().find(|t| t.code == 1).map(|t| t.value.trim().to_string()).unwrap_or_default() +} + +/// A CLASS record without its instance count (group 91), which follows the objects written. +fn class_groups(c: &[RawTag]) -> Vec { + c.iter().filter(|t| t.code != 91).cloned().collect() +} + +fn compare_foreign(a: &Foreign, b: &Foreign, out: &mut Out) { + let o = out.opts; + type Obj = cadcraft_doc::foreign::ForeignObject; + // Objects, by handle, then by content. + let ib: HashMap = b.objects.iter().map(|x| (x.handle, x)).collect(); + let ia: HashSet = a.objects.iter().map(|x| x.handle).collect(); + let mut un_a: BTreeMap> = BTreeMap::new(); + let mut un_b: BTreeMap> = BTreeMap::new(); + for x in &a.objects { + match ib.get(&x.handle) { + Some(y) if y.kind != x.kind => { + out.push(Category::Object, Change::Changed, format!("{} {}", x.kind, x.handle.hex()), format!("type {} → {}", x.kind, y.kind)) + } + Some(y) => { + if let Some(d) = tags_diff(&x.tags, &y.tags, o) { + out.push(Category::Object, Change::Changed, format!("{} {}", x.kind, x.handle.hex()), d); + } + } + None => un_a.entry(x.kind.clone()).or_default().push(x), + } + } + for y in &b.objects { + if !ia.contains(&y.handle) { + un_b.entry(y.kind.clone()).or_default().push(y); + } + } + // Content without handles, so renumbered objects pair up. + let content = |x: &Obj| sig_of(&x.tags.iter().filter(|t| !is_handle_code(t.code)).map(|t| (t.code, &t.value)).collect::>()); + let kinds: BTreeSet = un_a.keys().chain(un_b.keys()).cloned().collect(); + for k in kinds { + let xs = un_a.remove(&k).unwrap_or_default(); + let ys = un_b.remove(&k).unwrap_or_default(); + let (pairs, lost, added) = pair_up(xs, ys, content); + for (x, y) in &pairs { + out.renumber(&format!("{k} objects"), || format!("{} → {}", x.handle.hex(), y.handle.hex())); + if let Some(d) = tags_diff(&x.tags, &y.tags, o) { + out.push(Category::Object, Change::Changed, format!("{k} {}→{}", x.handle.hex(), y.handle.hex()), d); + } + } + if !lost.is_empty() { + out.push_n( + Category::Object, + Change::Lost, + lost.len(), + format!("objects {k}"), + format!("{} lost: {}", lost.len(), hex_list(lost.iter().map(|x| x.handle), 8)), + ); + } + if !added.is_empty() { + out.push_n( + Category::Object, + Change::Added, + added.len(), + format!("objects {k}"), + format!("{} added: {}", added.len(), hex_list(added.iter().map(|x| x.handle), 8)), + ); + } + } + // Named object dictionary entries. + let db: HashMap = b.dictionary.iter().map(|(k, h)| (k.to_ascii_uppercase(), *h)).collect(); + let da: HashSet = a.dictionary.iter().map(|(k, _)| k.to_ascii_uppercase()).collect(); + for (k, h) in &a.dictionary { + match db.get(&k.to_ascii_uppercase()) { + None => out.push(Category::Dictionary, Change::Lost, format!("NOD {k}"), format!("entry for {}", h.hex())), + Some(g) if g != h => out.renumber("dictionary entries", || format!("{k}: {} → {}", h.hex(), g.hex())), + Some(_) => {} + } + } + for (k, h) in &b.dictionary { + if !da.contains(&k.to_ascii_uppercase()) { + out.push(Category::Dictionary, Change::Added, format!("NOD {k}"), format!("entry for {}", h.hex())); + } + } + // CLASS records. + let cb: HashMap> = b.classes.iter().map(|c| (class_name(c).to_ascii_uppercase(), c)).collect(); + let ca: HashSet = a.classes.iter().map(|c| class_name(c).to_ascii_uppercase()).collect(); + for c in &a.classes { + let n = class_name(c); + match cb.get(&n.to_ascii_uppercase()) { + None => out.push(Category::Class, Change::Lost, format!("CLASS {n}"), String::new()), + Some(d) => { + if let Some(t) = tags_diff(&class_groups(c), &class_groups(d), o) { + out.push(Category::Class, Change::Changed, format!("CLASS {n}"), t); + } + } + } + } + for c in &b.classes { + let n = class_name(c); + if !ca.contains(&n.to_ascii_uppercase()) { + out.push(Category::Class, Change::Added, format!("CLASS {n}"), String::new()); + } + } + // Extras of table records, tables and blocks (renamed anonymous blocks by their new name). + let key_of = |k: &(String, String)| -> (String, String) { + match out.renamed.get(&k.1.to_ascii_uppercase()) { + Some(n) if k.0.starts_with("BLOCK") => (k.0.clone(), n.clone()), + _ => k.clone(), + } + }; + let ra: BTreeMap<(String, String), &ForeignExtra> = a.records.iter().map(|(k, v)| (key_of(k), v)).collect(); + let rb: BTreeMap<(String, String), &ForeignExtra> = b.records.iter().map(|(k, v)| ((k.0.clone(), k.1.to_ascii_uppercase()), v)).collect(); + let ra: BTreeMap<(String, String), &ForeignExtra> = ra.into_iter().map(|((k0, k1), v)| ((k0, k1.to_ascii_uppercase()), v)).collect(); + let keys: BTreeSet<&(String, String)> = ra.keys().chain(rb.keys()).collect(); + for k in keys { + compare_extra(&format!("{} {}", k.0, k.1), ra.get(k).copied(), rb.get(k).copied(), out); + } + // The data store (ACIS data of 2013+ files). + if let Some(d) = tags_diff(&a.data_store, &b.data_store, o) { + let change = match (a.data_store.is_empty(), b.data_store.is_empty()) { + (false, true) => Change::Lost, + (true, false) => Change::Added, + _ => Change::Changed, + }; + out.push(Category::Object, change, "ACDSDATA", d); + } +} + +// --------------------------------------------------------------------------------------------- +// Header + +fn compare_header(a: &Drawing, b: &Drawing, out: &mut Out) { + let skip = |k: &str| VOLATILE_HEADER.contains(&k); + for (k, va) in &a.header.vars { + if skip(k) { + continue; + } + let place = format!("${k}"); + match b.header.vars.get(k) { + None => out.push(Category::Header, Change::Lost, place, va.display()), + Some(vb) => { + let p = diff_ser(va, vb, &out.norm()); + if !p.is_empty() { + out.push(Category::Header, Change::Changed, place, format!("{} → {}", clip(&va.display(), 60), clip(&vb.display(), 60))); + } + } + } + } + for (k, vb) in &b.header.vars { + if !skip(k) && !a.header.vars.contains_key(k) { + out.push(Category::Header, Change::Added, format!("${k}"), vb.display()); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/io/src/roundtrip/census.rs b/crates/io/src/roundtrip/census.rs new file mode 100644 index 000000000..bf0d4488e --- /dev/null +++ b/crates/io/src/roundtrip/census.rs @@ -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, + /// Entities of block definitions (other than the model and paper space blocks), per type. + pub block_entities: BTreeMap, + /// 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, + /// Non-graphical objects per type. For a drawing: only those kept without being modelled. + pub objects: BTreeMap, + /// 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, 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 { + 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 = HashSet::new(); + let mut inserted: HashSet = HashSet::new(); + let mut blocks: Vec<(String, BTreeMap)> = 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) -> BTreeMap { + 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, read: &BTreeMap) { + 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 +} diff --git a/crates/io/src/roundtrip/corpus.rs b/crates/io/src/roundtrip/corpus.rs new file mode 100644 index 000000000..fe5f2abdf --- /dev/null +++ b/crates/io/src/roundtrip/corpus.rs @@ -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, +} + +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, + pub save_error: Option, + pub reopen_error: Option, + /// What reopening reported (records the reader couldn't place or decode). + pub reopen_notes: Vec, + /// 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, + /// 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 { + m.iter().map(|(t, n)| format!("{n} {t}")).collect::>().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, + /// `AC1032` for a DWG, the `$ACADVER` of a DXF. + pub version: String, + pub bytes: u64, + pub open_error: Option, + /// What opening reported (a DWG's records the reader skipped, damaged records). + pub open_notes: Vec, + pub open_seconds: f64, + /// What the file holds (for a DWG: its DXF rendition), when counted. + pub file_census: Option, + /// What the drawing read from it holds. + pub drawing_census: Option, + /// What the drawing holds less or more than the file. + pub read: Comparison, + pub routes: Vec, +} + +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 { + 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, +} + +/// Run `f`, turning a panic into an error message. +fn guard(f: impl FnOnce() -> T) -> Result { + 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::().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 { + 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 { + 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, + /// Folders and entries that couldn't be read, with why. + pub unreadable: Vec, + /// 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 = 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::>().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 { + 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 { + let mut all: std::collections::BTreeMap = 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 = 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::() + ) + }); + 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 = 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 = 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")); + } +} diff --git a/crates/io/src/roundtrip/tests.rs b/crates/io/src/roundtrip/tests.rs new file mode 100644 index 000000000..2107fb48f --- /dev/null +++ b/crates/io/src/roundtrip/tests.rs @@ -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> = 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 = 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| 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")); +} diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index b5ace3bcf..2312c9d5f 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -96,6 +96,8 @@ pub fn export_list(list: &cadcraft_render::DisplayList, window: Option) } s.push_str(r#""/>"#); } + // Raster images are not written to SVG yet: their frame is. + Kind::Image { .. } => {} Kind::Point => { if let Some(q) = pts.first() { let _ = write!(s, r#""#, tx(q.x), ty(q.y), stroke); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 5d08817c5..f9f055675 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -141,6 +141,7 @@ fn sample() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) .unwrap(); @@ -337,6 +338,64 @@ fn entity_transparency_roundtrips_as_group_440() { assert_eq!(d.model.iter().map(|e| e.common.transparency).collect::>(), [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::>(), [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::>(), [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 [ @@ -482,6 +541,7 @@ fn pdf_layout_one_to_one_with_viewport() { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }), ) .unwrap(); @@ -588,6 +648,7 @@ fn viewport_frozen_layers_roundtrip() { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }) }; d.add(&paper, Common::default(), vp(2, vec!["Walls".into(), "A B".into()])).unwrap(); @@ -630,6 +691,7 @@ fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }); d.add(&paper, Common::default(), vp).unwrap(); let text = dxf_2018(&d); @@ -772,6 +834,7 @@ fn viewport_layer_colors_roundtrip() { layer_colors: colors, clip: None, on: true, + twist: 0.0, }) }; let colors = vec![("Walls".to_string(), Color::Index(5)), ("A B".to_string(), Color::True(cadcraft_color::Rgb(255, 128, 0)))]; @@ -1039,7 +1102,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 @@ -1533,6 +1596,7 @@ fn intl_sample() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) .unwrap(); @@ -2633,6 +2697,7 @@ fn pdf_strokes_each_transparent_primitive_inside_a_block() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common { transparency: Transparency::Percent(t), ..Common::default() }, EntityKind::Insert(ins)).unwrap(); d @@ -2896,3 +2961,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::().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:?}"); +} diff --git a/crates/io/tests/it/annotative_roundtrip.rs b/crates/io/tests/it/annotative_roundtrip.rs index b7fb62384..52cd6e762 100644 --- a/crates/io/tests/it/annotative_roundtrip.rs +++ b/crates/io/tests/it/annotative_roundtrip.rs @@ -45,6 +45,7 @@ fn drawing() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let on_paper = Common { anno_scales: vec![AnnoScale::unit()], ..Default::default() }; d.add(&Space::Paper("Layout1".into()), on_paper, EntityKind::Insert(ins)).unwrap(); diff --git a/crates/io/tests/it/assoc_array_roundtrip.rs b/crates/io/tests/it/assoc_array_roundtrip.rs index 2b869d9e9..9500671b2 100644 --- a/crates/io/tests/it/assoc_array_roundtrip.rs +++ b/crates/io/tests/it/assoc_array_roundtrip.rs @@ -56,6 +56,7 @@ fn drawing() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); d diff --git a/crates/io/tests/it/attrib_modes.rs b/crates/io/tests/it/attrib_modes.rs index 3dfa8e321..6e71e4b7e 100644 --- a/crates/io/tests/it/attrib_modes.rs +++ b/crates/io/tests/it/attrib_modes.rs @@ -49,6 +49,7 @@ fn drawing_c(constant: bool) -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).expect("add"); d diff --git a/crates/io/tests/it/attrib_props.rs b/crates/io/tests/it/attrib_props.rs index 32dac1101..3b750fa7a 100644 --- a/crates/io/tests/it/attrib_props.rs +++ b/crates/io/tests/it/attrib_props.rs @@ -66,6 +66,7 @@ fn drawing(attribs: Vec) -> (Drawing, Handle) { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let h = d.add(&Space::Model, Common { layer: "INS".into(), ..Common::default() }, EntityKind::Insert(ins)).expect("add"); (d, h) diff --git a/crates/io/tests/it/block_expansion.rs b/crates/io/tests/it/block_expansion.rs index 64fca189e..60b064391 100644 --- a/crates/io/tests/it/block_expansion.rs +++ b/crates/io/tests/it/block_expansion.rs @@ -103,6 +103,7 @@ fn an_empty_followup_viewport_does_not_mark_an_exact_budget_truncated() { rows: 1_000, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap(); let viewport = |center: Vec3, view_center: Vec2, frozen_layers| Viewport { @@ -117,6 +118,7 @@ fn an_empty_followup_viewport_does_not_mark_an_exact_budget_truncated() { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }; let paper = Space::Paper("Layout1".into()); d.add(&paper, Common::default(), EntityKind::Viewport(viewport(Vec3::new(50.0, 50.0, 0.0), Vec2::ZERO, vec!["FROZEN".into()]))).unwrap(); diff --git a/crates/io/tests/it/dim_block_resave_roundtrip.rs b/crates/io/tests/it/dim_block_resave_roundtrip.rs index 195706157..ad264b96e 100644 --- a/crates/io/tests/it/dim_block_resave_roundtrip.rs +++ b/crates/io/tests/it/dim_block_resave_roundtrip.rs @@ -42,6 +42,7 @@ fn insert(block: &str) -> Insert { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, } } diff --git a/crates/io/tests/it/dwg_roundtrip_losses.rs b/crates/io/tests/it/dwg_roundtrip_losses.rs index aa00458a6..d0577ef91 100644 --- a/crates/io/tests/it/dwg_roundtrip_losses.rs +++ b/crates/io/tests/it/dwg_roundtrip_losses.rs @@ -27,6 +27,7 @@ fn viewport(id: u32) -> EntityKind { layer_colors: vec![], clip: None, on: true, + twist: 0.0, }) } @@ -182,3 +183,67 @@ fn the_added_paper_viewport_follows_the_plot_scale() { assert!((sheet_width(49.6, true) - 420.0).abs() < 1e-9, "scaled to fit: the scale means nothing"); assert!((sheet_width(f64::NAN, false) - 420.0).abs() < 1e-9, "an unusable scale is 1"); } + +/// A layout viewport's view twist (group 51, #1316) is read and written, so DXF and DWG keep it; +/// a hostile angle reads as no twist and never panics. +#[test] +fn viewport_twist_survives_dxf_and_dwg() { + let mut d = Drawing::new_metric(); + let name = d.layouts[0].name.clone(); + let space = Space::Paper(name.clone()); + let twist = 30f64.to_radians(); + let EntityKind::Viewport(mut vp) = viewport(2) else { unreachable!() }; + vp.twist = twist; + let h = d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap(); + let twist_of = |d: &Drawing| { + let l = d.layouts.iter().find(|l| l.name == name).unwrap(); + l.entities + .iter() + .find_map(|e| { + if let EntityKind::Viewport(v) = &e.kind + && v.id == 2 + { + Some(v.twist) + } else { + None + } + }) + .unwrap() + }; + + let dxf = cadcraft_io::write(&d, "x.dxf").unwrap(); + let text = String::from_utf8_lossy(&dxf).to_string(); + let rec = records(&text, "VIEWPORT").into_iter().find(|r| group(r, "5") == Some(h.hex().as_str())).unwrap(); + assert_eq!(group(&rec, "51").and_then(|v| v.parse::().ok()).map(f64::round), Some(30.0), "{rec:?}"); + assert!((twist_of(&cadcraft_io::read(&dxf, "x.dxf").unwrap()) - twist).abs() < 1e-9); + assert!((twist_of(&through_dwg(&d)) - twist).abs() < 1e-9); + + // An untwisted viewport writes no group 51; a hostile twist (from JSON or MCP) writes a finite + // angle within one turn, or none. + for (value, want) in [(0.0, None), (f64::NAN, None), (f64::INFINITY, None), (1e300, Some(()))] { + let mut d = Drawing::new_metric(); + let EntityKind::Viewport(mut vp) = viewport(2) else { unreachable!() }; + vp.twist = value; + let h = d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap(); + let text = String::from_utf8_lossy(&cadcraft_io::write(&d, "x.dxf").unwrap()).to_string(); + let rec = records(&text, "VIEWPORT").into_iter().find(|r| group(r, "5") == Some(h.hex().as_str())).unwrap(); + let written = group(&rec, "51").map(|v| v.parse::().unwrap()); + assert_eq!(written.map(|_| ()), want, "{value}: {rec:?}"); + assert!(written.is_none_or(|a| a.is_finite() && (0.0..360.0).contains(&a)), "{value}: {written:?}"); + } + + // Hostile angles: not a number reads as 0, a huge one is brought into one turn. + for (value, want) in + [("nan", Some(0.0)), ("1e400", Some(0.0)), ("-1e-20", Some(0.0)), ("-330", Some(30f64.to_radians())), ("7200000000000000000030", None)] + { + let mut lines: Vec = text.lines().map(str::to_string).collect(); + let at = lines.windows(2).position(|w| w[0].trim() == "51" && Some(w[1].trim()) == group(&rec, "51")).unwrap(); + lines[at + 1] = value.to_string(); + let bad = lines.join("\n"); + let back = twist_of(&cadcraft_io::read(bad.as_bytes(), "x.dxf").unwrap()); + assert!(back.is_finite() && (0.0..std::f64::consts::TAU).contains(&back), "{value}: {back}"); + if let Some(w) = want { + assert!((back - w).abs() < 1e-9, "{value}: {back}"); + } + } +} diff --git a/crates/io/tests/it/dynblock_read.rs b/crates/io/tests/it/dynblock_read.rs index 189a2305b..9c4943350 100644 --- a/crates/io/tests/it/dynblock_read.rs +++ b/crates/io/tests/it/dynblock_read.rs @@ -422,6 +422,7 @@ fn parameters_in_a_stretch_selection_are_carried_along() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let r = d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap(); cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); @@ -461,6 +462,7 @@ fn door_reference(src: &str) -> (cadcraft_doc::Drawing, Handle) { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let r = d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap(); (d, r) @@ -711,6 +713,7 @@ fn door_at_100(d: &mut cadcraft_doc::Drawing) -> Handle { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap() } diff --git a/crates/io/tests/it/entity_props_roundtrip.rs b/crates/io/tests/it/entity_props_roundtrip.rs index a5467d7cc..149cbd551 100644 --- a/crates/io/tests/it/entity_props_roundtrip.rs +++ b/crates/io/tests/it/entity_props_roundtrip.rs @@ -187,6 +187,7 @@ fn xref_block_flag_and_path_roundtrip() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let h = add(&mut d, 0.0, EntityKind::Insert(ins)); diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 289e87806..2e6c894a9 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -79,4 +79,5 @@ mod user_lin_pat_roundtrip; mod view_tables_roundtrip; mod view_target; mod viewport_clip_roundtrip; +mod xclip_roundtrip; mod xref_roundtrip; diff --git a/crates/io/tests/it/mleader_content.rs b/crates/io/tests/it/mleader_content.rs index 07f4b16bc..f2fb6f1db 100644 --- a/crates/io/tests/it/mleader_content.rs +++ b/crates/io/tests/it/mleader_content.rs @@ -28,6 +28,7 @@ fn spline_leader_and_block_content_are_written() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), }; d.add(&Space::Model, Common::default(), EntityKind::MLeader(m)).unwrap(); diff --git a/crates/io/tests/it/mleader_roundtrip.rs b/crates/io/tests/it/mleader_roundtrip.rs index 4cb1bff34..f55da8925 100644 --- a/crates/io/tests/it/mleader_roundtrip.rs +++ b/crates/io/tests/it/mleader_roundtrip.rs @@ -51,6 +51,7 @@ fn insert(block: &str, at: Vec3) -> Insert { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, } } diff --git a/crates/io/tests/it/paperview_marker.rs b/crates/io/tests/it/paperview_marker.rs index 4159e14c3..283cfc8ac 100644 --- a/crates/io/tests/it/paperview_marker.rs +++ b/crates/io/tests/it/paperview_marker.rs @@ -18,6 +18,7 @@ fn viewport(id: u32, frozen_layers: Vec, layer_colors: Vec<(String, Colo layer_colors, clip: None, on: true, + twist: 0.0, }) } diff --git a/crates/io/tests/it/pdf_layers.rs b/crates/io/tests/it/pdf_layers.rs index f6da233ca..b32682f46 100644 --- a/crates/io/tests/it/pdf_layers.rs +++ b/crates/io/tests/it/pdf_layers.rs @@ -55,6 +55,7 @@ fn layered() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common { layer: "Doors".into(), ..Default::default() }, EntityKind::Insert(ins)).unwrap(); d @@ -149,6 +150,7 @@ fn viewport_contents_keep_their_layers() { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }; d.add(&paper, Common { layer: "Viewports".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap(); add_line(&mut d, &paper, "Title", (10.0, 10.0), (50.0, 10.0)); diff --git a/crates/io/tests/it/roundtrip/cases/blocks.rs b/crates/io/tests/it/roundtrip/cases/blocks.rs index a1af6218a..51a8ba0c0 100644 --- a/crates/io/tests/it/roundtrip/cases/blocks.rs +++ b/crates/io/tests/it/roundtrip/cases/blocks.rs @@ -20,6 +20,7 @@ pub(crate) fn insert(block: &str) -> Insert { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, } } diff --git a/crates/io/tests/it/roundtrip/collections.rs b/crates/io/tests/it/roundtrip/collections.rs index 9b9acf36b..81ed33641 100644 --- a/crates/io/tests/it/roundtrip/collections.rs +++ b/crates/io/tests/it/roundtrip/collections.rs @@ -145,8 +145,8 @@ pub(super) fn vports() -> Case { drawing.vports.push(VportConfig { name: "Split".into(), tiles: vec![ - VportTile { min: Vec2::ZERO, max: Vec2::new(0.5, 1.0), center: Vec2::new(2.0, 3.0), height: 20.0, aspect: 0.75 }, - VportTile { min: Vec2::new(0.5, 0.0), max: Vec2::new(1.0, 1.0), center: Vec2::new(7.0, 8.0), height: 30.0, aspect: 0.75 }, + VportTile { min: Vec2::ZERO, max: Vec2::new(0.5, 1.0), center: Vec2::new(2.0, 3.0), height: 20.0, aspect: 0.75, twist: 0.0 }, + VportTile { min: Vec2::new(0.5, 0.0), max: Vec2::new(1.0, 1.0), center: Vec2::new(7.0, 8.0), height: 30.0, aspect: 0.75, twist: 0.0 }, ], }); Case { name: "drawing/tiled-viewports".into(), drawing } diff --git a/crates/io/tests/it/roundtrip/compare.rs b/crates/io/tests/it/roundtrip/compare.rs index d4bd1541a..e263f47f8 100644 --- a/crates/io/tests/it/roundtrip/compare.rs +++ b/crates/io/tests/it/roundtrip/compare.rs @@ -263,7 +263,7 @@ fn attrib(expected: &Attrib, actual: &Attrib, context: &str) { } fn insert(expected: &Insert, actual: &Insert, context: &str) { - let Insert { block, insert, scale, rotation, attribs, cols, rows, col_spacing, row_spacing } = expected; + let Insert { block, insert, scale, rotation, attribs, cols, rows, col_spacing, row_spacing, clip } = expected; equal(block, &actual.block, context, "insert.block"); equal(insert, &actual.insert, context, "insert.insert"); equal(scale, &actual.scale, context, "insert.scale"); @@ -272,6 +272,7 @@ fn insert(expected: &Insert, actual: &Insert, context: &str) { equal(rows, &actual.rows, context, "insert.rows"); equal(col_spacing, &actual.col_spacing, context, "insert.col_spacing"); equal(row_spacing, &actual.row_spacing, context, "insert.row_spacing"); + equal(clip, &actual.clip, context, "insert.clip"); equal(&attribs.len(), &actual.attribs.len(), context, "insert.attribs.len"); for (index, (a, b)) in attribs.iter().zip(&actual.attribs).enumerate() { attrib(a, b, &format!("{context}.attrib[{index}]")); diff --git a/crates/io/tests/it/view_tables_roundtrip.rs b/crates/io/tests/it/view_tables_roundtrip.rs index b7ec71ac3..e7ae3af6d 100644 --- a/crates/io/tests/it/view_tables_roundtrip.rs +++ b/crates/io/tests/it/view_tables_roundtrip.rs @@ -29,10 +29,9 @@ fn model_and_paper_views_round_trip() { let r = round_trip(&d); assert_eq!(r.views.len(), 3); for (a, b) in d.views.iter().zip(&r.views) { - assert_eq!( - (&a.name, a.center, a.height, a.width, &a.layer_state, &a.layout), - (&b.name, b.center, b.height, b.width, &b.layer_state, &b.layout) - ); + assert_eq!((&a.name, a.height, a.width, &a.layer_state, &a.layout), (&b.name, b.height, b.width, &b.layer_state, &b.layout)); + // A twisted view's centre is stored turned by the twist, and turned back on reading. + assert!((a.center - b.center).len() < 1e-9, "{}: {:?} vs {:?}", a.name, a.center, b.center); assert!((a.twist - b.twist).abs() < 1e-12, "{}: {} vs {}", a.name, a.twist, b.twist); } } @@ -75,7 +74,7 @@ fn foreign_paper_view_and_hostile_twist() { } fn tile(min: (f64, f64), max: (f64, f64), center: (f64, f64), height: f64) -> VportTile { - VportTile { min: Vec2::new(min.0, min.1), max: Vec2::new(max.0, max.1), center: Vec2::new(center.0, center.1), height, aspect: 0.75 } + VportTile { min: Vec2::new(min.0, min.1), max: Vec2::new(max.0, max.1), center: Vec2::new(center.0, center.1), height, aspect: 0.75, twist: 0.0 } } #[test] diff --git a/crates/io/tests/it/xclip_roundtrip.rs b/crates/io/tests/it/xclip_roundtrip.rs new file mode 100644 index 000000000..a7df6c62b --- /dev/null +++ b/crates/io/tests/it/xclip_roundtrip.rs @@ -0,0 +1,173 @@ +//! XCLIP boundaries (gap F2) through DXF and DWG: SPATIAL_FILTER objects in the references' +//! ACAD_FILTER extension dictionaries. + +use std::sync::Arc; + +use cadcraft_doc::{Block, ClipBoundary, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, Space}; +use cadcraft_dxf::Version; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_io::{SaveOptions, read, write_with}; + +fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + let mut block = Block::new("Plan"); + block.entities.push(Entity::new(d.new_handle(), EntityKind::Line(Line { a: Vec3::new(-10.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) }))); + block.entities.push(Entity::new(d.new_handle(), EntityKind::Line(Line { a: Vec3::new(0.0, -10.0, 0.0), b: Vec3::new(0.0, 10.0, 0.0) }))); + d.blocks.insert(block.name.clone(), Arc::new(block)); + let clips = [ + // A rectangle on a moved, scaled, rotated reference. + (Vec3::new(10.0, 20.0, 0.0), 2.0, 0.3, ClipBoundary::new(vec![Vec2::new(-2.0, -1.0), Vec2::new(2.0, 1.0)])), + // A triangle, inverted. + ( + Vec3::new(100.0, 0.0, 0.0), + 1.0, + 0.0, + ClipBoundary { inverted: true, ..ClipBoundary::new(vec![Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0), Vec2::new(0.0, 5.0)]) }, + ), + // Turned off. + ( + Vec3::new(200.0, 0.0, 0.0), + 0.5, + 1.0, + ClipBoundary { enabled: false, ..ClipBoundary::new(vec![Vec2::new(-3.0, -3.0), Vec2::new(3.0, 3.0)]) }, + ), + ]; + for (at, scale, rotation, clip) in clips { + let ins = Insert { block: "Plan".into(), insert: at, scale: Vec3::new(scale, scale, 1.0), rotation, clip: Some(clip), ..Insert::default() }; + d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); + } + // An unclipped reference stays unclipped. + d.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { block: "Plan".into(), ..Insert::default() })).unwrap(); + d +} + +fn clips(d: &Drawing) -> Vec> { + d.model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.clip.clone()), + _ => None, + }) + .collect() +} + +fn same(a: &[Option], b: &[Option]) { + assert_eq!(a.len(), b.len()); + for (x, y) in a.iter().zip(b) { + match (x, y) { + (None, None) => {} + (Some(x), Some(y)) => { + assert_eq!((x.enabled, x.inverted, x.points.len()), (y.enabled, y.inverted, y.points.len())); + for (p, q) in x.points.iter().zip(&y.points) { + assert!(p.near(*q, 1e-9), "{p:?} {q:?}"); + } + } + _ => panic!("{x:?} != {y:?}"), + } + } +} + +fn save(d: &Drawing, name: &str, version: Version) -> Vec { + write_with(d, name, &SaveOptions { version: Some(version), ..Default::default() }).unwrap() +} + +#[test] +fn clips_round_trip_through_dxf() { + let d = drawing(); + let bytes = save(&d, "a.dxf", Version::R2018); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert_eq!(text.matches("SPATIAL_FILTER").count(), 3, "one filter per clipped reference"); + assert!(text.contains("ACAD_FILTER")); + // No filter has group 290: the reference app discards a DXF whose filter has one. The + // inverted triangle's filter has its record instead, in the filter's extension dictionary. + let filters: Vec<&str> = text.split("\r\nSPATIAL_FILTER\r\n").skip(1).map(|f| f.split("\r\n 0\r\n").next().unwrap_or("")).collect(); + assert_eq!(filters.len(), 3); + assert!(!filters.iter().any(|f| f.contains("\r\n290\r\n")), "{filters:?}"); + assert_eq!(filters.iter().filter(|f| f.contains("{ACAD_XDICTIONARY")).count(), 1, "{filters:?}"); + assert_eq!(text.matches("ACAD_XREC_ROUNDTRIP").count(), 1); + assert!(text.contains("ACAD_INVERTEDCLIP_ROUNDTRIP\r\n") && text.contains("ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE\r\n")); + let first = read(&bytes, "a.dxf").unwrap(); + same(&clips(&first), &clips(&d)); + // The model owns the filters now: nothing of them is left as foreign data. + assert!(!first.foreign.objects.iter().any(|o| o.kind == "SPATIAL_FILTER")); + for v in [Version::R2000, Version::R2004, Version::R2010, Version::R2018] { + let back = read(&save(&first, "a.dxf", v), "a.dxf").unwrap(); + same(&clips(&back), &clips(&d)); + assert_eq!(back.model.len(), first.model.len(), "{}", v.year()); + assert!(!back.foreign.objects.iter().any(|o| o.kind == "SPATIAL_FILTER"), "{}", v.year()); + let again = String::from_utf8(save(&back, "a.dxf", v)).unwrap(); + assert_eq!(again.matches("SPATIAL_FILTER").count(), 3, "no duplicate filters on resave"); + assert_eq!(again.matches("ACAD_XREC_ROUNDTRIP").count(), 1, "no duplicate records on resave"); + } +} + +#[test] +fn clips_round_trip_through_dwg() { + let d = drawing(); + let first = read(&save(&d, "a.dxf", Version::R2018), "a.dxf").unwrap(); + for v in [Version::R2004, Version::R2010, Version::R2018] { + let back = read(&save(&first, "a.dwg", v), "a.dwg").unwrap(); + same(&clips(&back), &clips(&d)); + assert_eq!(back.model.len(), first.model.len(), "{}", v.year()); + let blk = |d: &Drawing| d.block("Plan").map(|b| b.entities.len()); + assert_eq!(blk(&back), blk(&first), "{}", v.year()); + } +} + +#[test] +fn deleting_a_clip_read_from_a_file_removes_its_filter() { + let mut d = read(&save(&drawing(), "a.dxf", Version::R2018), "a.dxf").unwrap(); + let hs: Vec = d.model.iter().filter(|e| matches!(&e.kind, EntityKind::Insert(i) if i.clip.is_some())).map(|e| e.handle).collect(); + for h in &hs { + d.modify_entity(*h, |e| { + if let EntityKind::Insert(i) = &mut e.kind { + i.clip = None; + } + }) + .unwrap(); + } + let text = String::from_utf8(save(&d, "a.dxf", Version::R2018)).unwrap(); + assert!(!text.contains("SPATIAL_FILTER") && !text.contains("ACAD_XREC_ROUNDTRIP")); + assert!(clips(&read(text.as_bytes(), "a.dxf").unwrap()).iter().all(Option::is_none)); +} + +#[test] +fn filter_matrices_are_the_inverse_reference_transform() { + // A reference at (150, 0), unrotated: the inverse moves by -150; vertices in drawing units. + let mut d = Drawing::new_metric(); + let mut block = Block::new("P"); + block.entities.push(Entity::new(d.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 1.0, 0.0) }))); + d.blocks.insert("P".into(), Arc::new(block)); + let clip = ClipBoundary::new(vec![Vec2::new(20.0, 20.0), Vec2::new(80.0, 20.0), Vec2::new(80.0, 80.0), Vec2::new(20.0, 80.0)]); + let h = d + .add( + &Space::Model, + Common::default(), + EntityKind::Insert(Insert { block: "P".into(), insert: Vec3::new(150.0, 0.0, 0.0), clip: Some(clip), ..Insert::default() }), + ) + .unwrap(); + // Read back without CADCraft taking the filter: look at the DXF groups. + let text = String::from_utf8(save(&d, "a.dxf", Version::R2018)).unwrap(); + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let at = lines.windows(2).position(|w| w[0] == "0" && w[1] == "SPATIAL_FILTER").unwrap(); + let end = (at + 2..lines.len()).step_by(2).find(|i| lines[*i] == "0").unwrap(); + let rec = &lines[at..end]; + let values = |code: &str| -> Vec { rec.chunks(2).filter(|w| w[0] == code).map(|w| w[1].parse().unwrap()).collect() }; + assert_eq!(values("70"), vec![4.0]); + assert_eq!(values("10"), vec![170.0, 230.0, 230.0, 170.0]); + assert_eq!( + values("40"), + vec![1.0, 0.0, 0.0, -150.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0] + ); + let _ = h; +} + +#[test] +fn hostile_spatial_filters_do_not_panic() { + let text = String::from_utf8(save(&drawing(), "a.dxf", Version::R2018)).unwrap(); + // Vertex counts, coordinates and matrices replaced by nonsense. + for (from, to) in [(" 70\n3\n", " 70\n999999999\n"), ("\n 40\n1.0\n", "\n 40\nnan\n"), ("\n 10\n", "\n 10\n1e999\n 10\n")] { + let bad = text.replacen(from, to, 1); + let _ = read(bad.as_bytes(), "a.dxf"); + } +} diff --git a/crates/io/tests/it/xref_roundtrip.rs b/crates/io/tests/it/xref_roundtrip.rs index 815d71afa..58869c0d9 100644 --- a/crates/io/tests/it/xref_roundtrip.rs +++ b/crates/io/tests/it/xref_roundtrip.rs @@ -52,6 +52,7 @@ fn host() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).expect("add"); } diff --git a/crates/io/tests/pdf_text.rs b/crates/io/tests/pdf_text.rs index baba82f9d..a0f96dd67 100644 --- a/crates/io/tests/pdf_text.rs +++ b/crates/io/tests/pdf_text.rs @@ -335,6 +335,7 @@ fn text_seen_through_a_viewport_and_on_layers() { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }; d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap(); text(&mut d, &paper, "Real", (20.0, 20.0), "B", (0.0, 1.0, 0.0)); diff --git a/crates/io/tests/raster_decode.rs b/crates/io/tests/raster_decode.rs new file mode 100644 index 000000000..51a6130b7 --- /dev/null +++ b/crates/io/tests/raster_decode.rs @@ -0,0 +1,172 @@ +//! Raster image files decoded to pixels (IMAGEATTACH): round trip through PNG, and hostile files +//! (truncated, random, absurd sizes) refused without a panic. + +use cadcraft_doc::Raster; +use cadcraft_io::raster::{MAX_IMAGE_PIXELS, decode, encode_png, probe}; + +/// A 3 x 2 image with a different colour per pixel, one of them half transparent. +fn sample() -> Raster { + let px: [[u8; 4]; 6] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 0, 255], [0, 255, 255, 128], [10, 20, 30, 255]]; + Raster::new(3, 2, px.concat()).unwrap() +} + +#[test] +fn png_round_trips_pixel_for_pixel() { + let r = sample(); + let png = encode_png(&r).unwrap(); + assert_eq!(probe(&png).unwrap(), (3, 2)); + let back = decode(&png).unwrap(); + assert_eq!(back, r); + assert!(!back.opaque()); + assert_eq!(back.pixel(1, 1), Some([0, 255, 255, 128])); + assert_eq!(back.pixel(3, 0), None); +} + +#[test] +fn hostile_image_does_not_panic() { + let png = encode_png(&sample()).unwrap(); + // Empty, random, not an image, and every truncation of a real file. + for bytes in [&b""[..], &[0u8; 64], b"0\nSECTION\n2\nHEADER\n", &[0xff; 4096]] { + assert!(decode(bytes).is_err()); + assert!(probe(bytes).is_err()); + } + for n in 0..png.len() { + let _ = probe(&png[..n]); + let _ = decode(&png[..n]); + } + assert!(decode(&png[..png.len() / 2]).is_err()); + // Every byte flipped in turn: an error or an image, never a panic. + for i in 0..png.len() { + let mut b = png.clone(); + b[i] ^= 0xff; + let _ = decode(&b); + } +} + +#[test] +fn an_absurd_declared_size_is_refused_before_decoding() { + // A PNG declaring 60000 x 60000 pixels (3.6 gigapixels) with no pixel data. + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = b"IHDR".to_vec(); + ihdr.extend(60000u32.to_be_bytes()); + ihdr.extend(60000u32.to_be_bytes()); + ihdr.extend([8, 6, 0, 0, 0]); + for chunk in [&ihdr[..], b"IDAT", b"IEND"] { + png.extend(u32::try_from(chunk.len() - 4).unwrap().to_be_bytes()); + png.extend(chunk); + png.extend(crc32(chunk).to_be_bytes()); + } + assert!(u64::from(60000u32) * 60000 > MAX_IMAGE_PIXELS); + let e = decode(&png).unwrap_err().to_string(); + assert!(e.contains("megapixels"), "{e}"); +} + +fn crc32(data: &[u8]) -> u32 { + let mut c = !0u32; + for b in data { + c ^= u32::from(*b); + for _ in 0..8 { + c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c +} + +#[test] +fn raster_new_checks_the_buffer() { + assert!(Raster::new(0, 4, Vec::new()).is_none()); + assert!(Raster::new(2, 2, vec![0; 15]).is_none()); + assert!(Raster::new(u32::MAX, u32::MAX, vec![0; 16]).is_none()); + assert!(Raster::new(2, 2, vec![255; 16]).unwrap().opaque()); +} + +/// `r` written in `format` by the image library. +fn encoded(r: &Raster, format: image::ImageFormat, color: image::ExtendedColorType, bytes: &[u8]) -> Vec { + let mut out = std::io::Cursor::new(Vec::new()); + image::write_buffer_with_format(&mut out, bytes, r.width(), r.height(), color, format).unwrap(); + out.into_inner() +} + +#[test] +fn bmp_tiff_and_jpeg_files_are_read() { + // Opaque colours in 8 x 8 blocks (JPEG works in blocks of that size). + let (w, h) = (16u32, 8u32); + let rgb: Vec = (0..h).flat_map(|_| (0..w).flat_map(|x| if x < 8 { [200u8, 30, 30] } else { [30, 30, 200] })).collect(); + let rgba: Vec = rgb.chunks(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect(); + let r = Raster::new(w, h, rgba).unwrap(); + + for format in [image::ImageFormat::Bmp, image::ImageFormat::Tiff] { + let file = encoded(&r, format, image::ExtendedColorType::Rgb8, &rgb); + assert_eq!(probe(&file).unwrap(), (w, h), "{format:?}"); + let back = decode(&file).unwrap(); + assert_eq!(back, r, "{format:?} keeps every pixel"); + assert!(back.opaque()); + } + + // JPEG loses a little: the colours are close, the size exact. + let file = encoded(&r, image::ImageFormat::Jpeg, image::ExtendedColorType::Rgb8, &rgb); + let back = decode(&file).unwrap(); + assert_eq!((back.width(), back.height()), (w, h)); + for (x, want) in [(2, [200u8, 30, 30]), (13, [30, 30, 200])] { + let got = back.pixel(x, 4).unwrap(); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 12) && got[3] == 255, "{got:?} at {x}"); + } + + // Grey and grey-with-alpha files become RGBA. + let grey = Raster::new(2, 1, vec![0, 0, 0, 255, 255, 255, 255, 255]).unwrap(); + assert_eq!(decode(&encoded(&grey, image::ImageFormat::Png, image::ExtendedColorType::L8, &[0, 255])).unwrap(), grey); + let grey_alpha = Raster::new(2, 1, vec![9, 9, 9, 0, 255, 255, 255, 128]).unwrap(); + assert_eq!(decode(&encoded(&grey_alpha, image::ImageFormat::Png, image::ExtendedColorType::La8, &[9, 0, 255, 128])).unwrap(), grey_alpha); +} + +#[test] +fn damaged_files_of_every_format_do_not_panic() { + let (w, h) = (16u32, 8u32); + let rgb = vec![120u8; (w * h * 3) as usize]; + let r = Raster::new(w, h, vec![255; (w * h * 4) as usize]).unwrap(); + for format in [image::ImageFormat::Bmp, image::ImageFormat::Tiff, image::ImageFormat::Jpeg] { + let file = encoded(&r, format, image::ExtendedColorType::Rgb8, &rgb); + for n in 0..file.len() { + let _ = probe(&file[..n]); + let _ = decode(&file[..n]); + } + for i in 0..file.len() { + let mut b = file.clone(); + b[i] ^= 0xff; + let _ = decode(&b); + b[i] = 0; + let _ = decode(&b); + } + } +} + +#[test] +fn the_pixels_never_reach_the_drawing_file() { + use cadcraft_doc::{Common, Drawing, EntityKind, Image, Space}; + use cadcraft_geom::{Vec2, Vec3}; + let mut d = Drawing::new_metric(); + let i = Image { + insert: Vec3::new(3.0, 4.0, 0.0), + u: Vec3::new(0.5, 0.0, 0.0), + v: Vec3::new(0.0, 0.5, 0.0), + size: Vec2::new(3.0, 2.0), + path: "maps/site.png".into(), + name: "site".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i.clone())).unwrap(); + d.rasters.insert("maps/site.png", std::sync::Arc::new(sample())); + let text = cadcraft_io::write_dxf(&d); + assert!(text.contains("maps/site.png")); + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert!(back.rasters.is_empty(), "a drawing just read has no pixels"); + let got: Vec<&Image> = back.model.iter().filter_map(|e| if let EntityKind::Image(i) = &e.kind { Some(i) } else { None }).collect(); + // An unclipped image is written with the boundary of the whole image, switched off. + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(2.5, 1.5)], ..i }; + assert_eq!(got, [&i]); + // Loaded pixels don't make two drawings differ. + let mut bare = d.clone(); + bare.rasters.remove("maps/site.png"); + assert!(bare.rasters.is_empty() && d.rasters.len() == 1); + assert_eq!(bare, d); +} diff --git a/crates/io/tests/raster_foreign_files.rs b/crates/io/tests/raster_foreign_files.rs new file mode 100644 index 000000000..bf7c7ace7 --- /dev/null +++ b/crates/io/tests/raster_foreign_files.rs @@ -0,0 +1,222 @@ +//! Raster image files CADCraft did not write itself (#956): files made by another program, and +//! files laid out by hand from the formats' public descriptions, so reading is not only checked +//! against the library that also does the writing. +//! +//! The four embedded files hold our own synthetic picture (16 x 8 pixels: the left half +//! (200, 30, 30), the right half (30, 30, 200)), converted from a hand-built PNG with the macOS +//! command `sips` (`-s format jpeg|tiff|bmp|png`, colour profile removed). They contain nothing +//! but those pixels and the encoder's headers. + +use cadcraft_doc::Raster; +use cadcraft_io::raster::{decode, probe}; + +/// JPEG, baseline, quality 90, with JFIF and Exif headers. +const SIPS_JPEG: &str = "ffd8ffe000104a46494600010100004800480000ffe1004c4578696600004d4d002a00000008000187690004000000010000001a000000000003a001 \ + 000300000001ffff0000a00200040000000100000010a0030004000000010000000800000000ffed003850686f746f73686f7020332e30003842494d \ + 04040000000000003842494d0425000000000010d41d8cd98f00b204e9800998ecf8427effc00011080008001003012200021101031101ffc4001f00 \ + 00010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d0102030004110512213141 \ + 0613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758 \ + 595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8 \ + c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f01000301010101010101010100000000000001020304050607 \ + 08090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272 \ + d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a8283848586 \ + 8788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3 \ + f4f5f6f7f8f9faffdb004300020202020202030202030503030305060505050506080606060606080a0808080808080a0a0a0a0a0a0a0a0c0c0c0c0c \ + 0c0e0e0e0e0e0f0f0f0f0f0f0f0f0f0fffdb00430102030304040407040407100b090b10101010101010101010101010101010101010101010101010 \ + 10101010101010101010101010101010101010101010101010ffdd00040001ffda000c03010002110311003f00f90ebe7fafa02be7fafdc7e8f3ff00 \ + 331ffb87ff00b79fd1de3aff00cc07fdbfff00b69fffd9"; +/// TIFF, big-endian, LZW compressed. +const SIPS_TIFF_LZW: &str = "4d4d002a000000680002a00200040000000100000010a0030004000000010000000800000000803203c1e004160d0784424ac00554261d0f814121f1 \ + 38342e1b148a44631148b46e271a8f43a3b2184c82490791c9e0d2695002532d964aa5f2a98c9e672780800000100100000300000001001000000101 \ + 0003000000010008000001020003000000030000012e010300030000000100050000010600030000000100020000010a000300000001000100000111 \ + 00040000000100000026011200030000000100010000011500030000000100030000011600030000000100080000011700040000000100000041011c \ + 00030000000100010000012800030000000100020000013d000300000001000200000153000300000003000001348769000400000001000000080000 \ + 0000000800080008000100010001"; +/// TIFF, big-endian, not compressed. +const SIPS_TIFF_PLAIN: &str = "4d4d002a000001a60002a00200040000000100000010a0030004000000010000000800000000c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \ + 1e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e \ + 1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e \ + 1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec8 \ + 1e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \ + 1e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e \ + 1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e \ + 1ec8000f0100000300000001001000000101000300000001000800000102000300000003000002600103000300000001000100000106000300000001 \ + 00020000010a000300000001000100000111000400000001000000260112000300000001000100000115000300000001000300000116000300000001 \ + 00080000011700040000000100000180011c000300000001000100000128000300000001000200000153000300000003000002668769000400000001 \ + 0000000800000000000800080008000100010001"; +/// BMP, 24 bits, rows stored top-down (negative height). +const SIPS_BMP: &str = "424db601000000000000360000002800000010000000f8ffffff010018000000000080010000000000000000000000000000000000001e1ec81e1ec8 \ + 1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \ + 1e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1e \ + c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e \ + c81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec8 \ + 1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \ + 1e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1e \ + c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e"; +/// PNG as that program writes it (its own chunks and compression). +const SIPS_PNG: &str = "89504e470d0a1a0a0000000d49484452000000100000000808020000007f14e8c00000000467414d410000b18f0bfc6105000000206348524d00007a \ + 26000080840000fa00000080e8000075300000ea6000003a98000017709cba513c00000038655849664d4d002a000000080001876900040000000100 \ + 00001a000000000002a00200040000000100000010a0030004000000010000000800000000470d8f8b000001c869545874584d4c3a636f6d2e61646f \ + 62652e786d7000000000003c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f2220783a786d70746b3d22584d5020 \ + 436f726520362e302e30223e0a2020203c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30 \ + 322f32322d7264662d73796e7461782d6e7323223e0a2020202020203c7264663a4465736372697074696f6e207264663a61626f75743d22220a2020 \ + 20202020202020202020786d6c6e733a657869663d22687474703a2f2f6e732e61646f62652e636f6d2f657869662f312e302f223e0a202020202020 \ + 2020203c657869663a436f6c6f7253706163653e313c2f657869663a436f6c6f7253706163653e0a2020202020202020203c657869663a506978656c \ + 5844696d656e73696f6e3e31363c2f657869663a506978656c5844696d656e73696f6e3e0a2020202020202020203c657869663a506978656c594469 \ + 6d656e73696f6e3e383c2f657869663a506978656c5944696d656e73696f6e3e0a2020202020203c2f7264663a4465736372697074696f6e3e0a2020 \ + 203c2f7264663a5244463e0a3c2f783a786d706d6574613e0ae6ba92b400000021494441541819633c2127c7800d8431acc226ccc08455148fe0a806 \ + 3c8103970200086302148ee3f7130000000049454e44ae426082"; + +fn bytes(hex: &str) -> Vec { + let digits: Vec = hex.bytes().filter(u8::is_ascii_hexdigit).collect(); + digits.chunks(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()).collect() +} + +const LEFT: [u8; 3] = [200, 30, 30]; +const RIGHT: [u8; 3] = [30, 30, 200]; + +/// The picture is the two-colour 16 x 8 one, each colour within `tolerance`. +#[track_caller] +fn is_the_picture(r: &Raster, tolerance: u8, what: &str) { + assert_eq!((r.width(), r.height()), (16, 8), "{what}"); + assert!(r.opaque(), "{what}"); + for y in 0..8 { + for x in 0..16 { + // A lossy file blends the columns around the colour change (JPEG stores colour at + // half resolution). + if tolerance > 3 && (6..=9).contains(&x) { + continue; + } + let want = if x < 8 { LEFT } else { RIGHT }; + let got = r.pixel(x, y).unwrap(); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= tolerance), "{what}: {got:?} at {x},{y}, wanted {want:?}"); + } + } +} + +#[test] +fn files_written_by_another_program_are_read() { + for (hex, tolerance, what) in [ + (SIPS_PNG, 3, "PNG"), + (SIPS_BMP, 3, "top-down BMP"), + (SIPS_TIFF_LZW, 3, "LZW TIFF"), + (SIPS_TIFF_PLAIN, 3, "plain TIFF"), + (SIPS_JPEG, 14, "JPEG"), + ] { + let file = bytes(hex); + assert_eq!(probe(&file).unwrap(), (16, 8), "{what}"); + is_the_picture(&decode(&file).unwrap(), tolerance, what); + // Cut short or damaged, they are refused or read: never a panic. + for n in 0..file.len() { + let _ = decode(&file[..n]); + } + for i in 0..file.len() { + let mut b = file.clone(); + b[i] ^= 0x55; + let _ = decode(&b); + } + } +} + +/// An uncompressed RGB TIFF of one strip, laid out by hand: header, pixels, then the directory. +fn tiff_by_hand(big_endian: bool, w: u32, h: u32, rgb: &[u8]) -> Vec { + let u16b = |v: u16| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() }; + let u32b = |v: u32| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() }; + let mut f: Vec = if big_endian { b"MM".to_vec() } else { b"II".to_vec() }; + f.extend(u16b(42)); + let pixels_at = 8u32; + let bits_at = pixels_at + rgb.len() as u32; + let dir_at = bits_at + 6; + f.extend(u32b(dir_at)); + f.extend(rgb); + for _ in 0..3 { + f.extend(u16b(8)); + } + // (tag, type: 3 short / 4 long, count, value) + let entries: [(u16, u16, u32, u32); 10] = [ + (256, 4, 1, w), + (257, 4, 1, h), + (258, 3, 3, bits_at), + (259, 3, 1, 1), + (262, 3, 1, 2), + (273, 4, 1, pixels_at), + (277, 3, 1, 3), + (278, 4, 1, h), + (279, 4, 1, rgb.len() as u32), + (284, 3, 1, 1), + ]; + f.extend(u16b(entries.len() as u16)); + for (tag, kind, count, value) in entries { + f.extend(u16b(tag)); + f.extend(u16b(kind)); + f.extend(u32b(count)); + // A single short sits in the first two bytes of the value field. + if kind == 3 && count == 1 { + f.extend(u16b(value as u16)); + f.extend([0, 0]); + } else { + f.extend(u32b(value)); + } + } + f.extend(u32b(0)); + f +} + +/// A 24-bit BMP laid out by hand: rows bottom-up, blue-green-red, each padded to four bytes. +fn bmp_by_hand(w: u32, h: u32, rgb: &[u8]) -> Vec { + let row = (w as usize * 3).div_ceil(4) * 4; + let size = 54 + row * h as usize; + let mut f = b"BM".to_vec(); + f.extend((size as u32).to_le_bytes()); + f.extend([0; 4]); + f.extend(54u32.to_le_bytes()); + f.extend(40u32.to_le_bytes()); + f.extend((w as i32).to_le_bytes()); + f.extend((h as i32).to_le_bytes()); + f.extend(1u16.to_le_bytes()); + f.extend(24u16.to_le_bytes()); + f.extend([0; 24]); + for y in (0..h as usize).rev() { + let line = &rgb[y * w as usize * 3..(y + 1) * w as usize * 3]; + for p in line.chunks(3) { + f.extend([p[2], p[1], p[0]]); + } + f.extend(std::iter::repeat_n(0, row - w as usize * 3)); + } + f +} + +#[test] +fn files_laid_out_by_hand_are_read_pixel_for_pixel() { + // 3 x 2: an odd width, so BMP rows are padded; every pixel different. + let rgb: Vec = (0..18u8).map(|i| i * 13 + 5).collect(); + let rgba: Vec = rgb.chunks(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect(); + let want = Raster::new(3, 2, rgba).unwrap(); + for (file, what) in [ + (tiff_by_hand(false, 3, 2, &rgb), "little-endian TIFF"), + (tiff_by_hand(true, 3, 2, &rgb), "big-endian TIFF"), + (bmp_by_hand(3, 2, &rgb), "bottom-up BMP"), + ] { + assert_eq!(probe(&file).unwrap(), (3, 2), "{what}"); + assert_eq!(decode(&file).unwrap(), want, "{what}"); + } +} + +#[test] +fn hand_made_files_that_lie_about_their_size_are_refused() { + let rgb = [9u8; 18]; + // More pixels declared than the file holds. + for file in [tiff_by_hand(false, 300, 200, &rgb), tiff_by_hand(true, 3, 2000, &rgb), bmp_by_hand(3, 2, &rgb)[..60].to_vec()] { + assert!(decode(&file).is_err()); + } + // Sizes past the limit, before any pixel is read. + for file in [tiff_by_hand(false, 60_000, 60_000, &rgb), tiff_by_hand(true, u32::MAX, u32::MAX, &rgb)] { + assert!(decode(&file).is_err()); + } + let mut bmp = bmp_by_hand(3, 2, &rgb); + bmp[18..22].copy_from_slice(&60_000i32.to_le_bytes()); + bmp[22..26].copy_from_slice(&60_000i32.to_le_bytes()); + assert!(decode(&bmp).is_err()); + // A zero-sized image. + assert!(decode(&tiff_by_hand(false, 0, 0, &[])).is_err()); +} diff --git a/crates/io/tests/view_centre_twist.rs b/crates/io/tests/view_centre_twist.rs new file mode 100644 index 000000000..9f84b3a3e --- /dev/null +++ b/crates/io/tests/view_centre_twist.rs @@ -0,0 +1,168 @@ +//! A twisted view's centre is stored in display coordinates, turned by the twist: VIEWPORT and +//! VPORT group 12 and VIEW group 10. The reader turns it back to the drawing point at the middle +//! of the view and the writer turns it again, so a twisted view far from the origin (a site plan +//! on a national grid) shows its own part of the drawing. + +use cadcraft_doc::*; +use cadcraft_geom::{Vec2, Vec3}; + +/// The far site: a national-grid point. +const SITE: Vec2 = Vec2 { x: 2_600_000.0, y: 1_200_000.0 }; + +fn twist() -> f64 { + (-30.0_f64).to_radians() +} + +/// The display-coordinate centre another program stores for a view on `SITE` with `twist()`. +fn dcs() -> Vec2 { + SITE.rotate(twist()) +} + +fn close(a: Vec2, b: Vec2) -> bool { + (a - b).len() < 1e-6 +} + +/// Every `(code, value)` pair of a DXF text. +fn pairs(dxf: &str) -> Vec<(i32, String)> { + let lines: Vec<&str> = dxf.lines().collect(); + lines.chunks(2).filter_map(|p| Some((p.first()?.trim().parse().ok()?, p.get(1)?.trim().to_string()))).collect() +} + +/// Groups `x`/`x + 10` of each record of type `kind` that has group `has` (its twist). +fn centres(dxf: &str, kind: &str, x: i32, has: i32) -> Vec { + let mut out = Vec::new(); + let mut rec: Option<(Option, Option, bool)> = None; + for (c, v) in pairs(dxf).into_iter().chain(std::iter::once((0, String::new()))) { + if c == 0 { + if let Some((Some(px), Some(py), true)) = rec.take() { + out.push(Vec2::new(px, py)); + } + rec = (v == kind).then_some((None, None, false)); + } else if let Some(r) = rec.as_mut() { + if c == x { + r.0 = v.parse().ok(); + } else if c == x + 10 { + r.1 = v.parse().ok(); + } else if c == has && v.parse::().is_ok_and(|a| a != 0.0) { + r.2 = true; + } + } + } + out +} + +fn viewport(view_center: Vec2) -> EntityKind { + EntityKind::Viewport(Viewport { + center: Vec3::new(230.0, 80.0, 0.0), + width: 420.0, + height: 120.0, + view_center, + view_height: 60.0, + id: 2, + twist: twist().rem_euclid(std::f64::consts::TAU), + ..Default::default() + }) +} + +fn sheet_viewport(d: &Drawing) -> Viewport { + d.layouts + .iter() + .flat_map(|l| l.entities.iter()) + .find_map(|e| match &e.kind { + EntityKind::Viewport(v) if v.id == 2 => Some(v.clone()), + _ => None, + }) + .expect("the sheet viewport") +} + +#[test] +fn a_twisted_layout_viewport_far_from_the_origin_looks_at_its_site() { + let mut d = Drawing::new_metric(); + let sheet = Space::Paper(d.layouts[0].name.clone()); + d.add(&sheet, Common::default(), viewport(SITE)).unwrap(); + let dxf = cadcraft_io::write_dxf(&d); + // Written as other programs write it: the centre in display coordinates. + let written = centres(&dxf, "VIEWPORT", 12, 51); + assert_eq!(written.len(), 1, "{written:?}"); + assert!(written.first().is_some_and(|c| close(*c, dcs())), "{written:?} vs {:?}", dcs()); + // Read back, the site is at the middle of the viewport. + let v = sheet_viewport(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()); + assert!(close(v.view_center, SITE), "{:?}", v.view_center); + assert!(close(v.model_to_paper(SITE), Vec2::new(230.0, 80.0)), "{:?}", v.model_to_paper(SITE)); +} + +#[test] +fn twisted_model_views_and_named_views_store_display_centres() { + let mut d = Drawing::new_metric(); + let tile = |center: Vec2, twist: f64| VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center, height: 50.0, aspect: 1.5, twist }; + // A saved configuration of two tiles, one twisted. + d.vports = vec![VportConfig { name: "Site".into(), tiles: vec![tile(SITE, twist()), tile(Vec2::new(5.0, 5.0), 0.0)] }]; + d.views.push(NamedView { name: "Site".into(), center: SITE, height: 50.0, width: 75.0, twist: twist(), ..Default::default() }); + let dxf = cadcraft_io::write_dxf(&d); + let vports = centres(&dxf, "VPORT", 12, 51); + assert_eq!(vports.len(), 1, "{vports:?}"); + assert!(vports.iter().all(|c| close(*c, dcs())), "{vports:?} vs {:?}", dcs()); + let views = centres(&dxf, "VIEW", 10, 50); + assert_eq!(views.len(), 1, "{views:?}"); + assert!(views.iter().all(|c| close(*c, dcs())), "{views:?} vs {:?}", dcs()); + let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap(); + let tiles: Vec = back.vports.iter().filter(|c| c.name == "Site").flat_map(|c| c.tiles.iter().map(|t| t.center)).collect(); + assert_eq!(tiles.len(), 2, "{:?}", back.vports); + assert!(close(tiles[0], SITE) && close(tiles[1], Vec2::new(5.0, 5.0)), "{tiles:?}"); + assert!(back.views.iter().all(|v| close(v.center, SITE)), "{:?}", back.views); +} + +/// `dxf` with every record of type `kind` that has a nonzero group `has` (its twist) moved to +/// view target `target` (groups `t`/`t + 10`): its centre (groups `x`/`x + 10`) is set to the +/// display-coordinate centre of `SITE` seen from that target, as another program stores it. +fn with_target(dxf: &str, kind: &str, x: i32, t: i32, has: i32, target: Vec2) -> String { + let c = (SITE - target).rotate(twist()); + let mut records: Vec> = Vec::new(); + for (code, v) in pairs(dxf) { + match records.last_mut() { + Some(r) if code != 0 => r.push((code, v)), + _ => records.push(vec![(code, v)]), + } + } + let mut out = String::new(); + for mut r in records { + let twisted = r.first().is_some_and(|(_, v)| v == kind) && r.iter().any(|(code, v)| *code == has && v.parse::().is_ok_and(|a| a != 0.0)); + if twisted { + r.retain(|(code, _)| ![x, x + 10, t, t + 10, t + 20].contains(code)); + r.extend([ + (x, c.x.to_string()), + (x + 10, c.y.to_string()), + (t, target.x.to_string()), + (t + 10, target.y.to_string()), + (t + 20, "0".into()), + ]); + } + for (code, v) in r { + out.push_str(&format!("{code}\n{v}\n")); + } + } + out +} + +#[test] +fn display_centres_are_measured_from_the_view_target() { + let target = Vec2::new(2_599_000.0, 1_199_500.0); + // A layout viewport. + let mut d = Drawing::new_metric(); + let sheet = Space::Paper(d.layouts[0].name.clone()); + d.add(&sheet, Common::default(), viewport(SITE)).unwrap(); + let dxf = with_target(&cadcraft_io::write_dxf(&d), "VIEWPORT", 12, 17, 51, target); + let v = sheet_viewport(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()); + assert!(close(v.view_center, SITE), "{:?}", v.view_center); + // A model view tile and a named view. + let mut d = Drawing::new_metric(); + let tile = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: SITE, height: 50.0, aspect: 1.5, twist: twist() }; + d.vports = vec![VportConfig { name: "Site".into(), tiles: vec![tile, VportTile { center: Vec2::new(5.0, 5.0), twist: 0.0, ..tile }] }]; + d.views.push(NamedView { name: "Site".into(), center: SITE, height: 50.0, width: 75.0, twist: twist(), ..Default::default() }); + let dxf = with_target(&cadcraft_io::write_dxf(&d), "VPORT", 12, 17, 51, target); + let dxf = with_target(&dxf, "VIEW", 10, 12, 50, target); + let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap(); + let tiles: Vec = back.vports.iter().filter(|c| c.name == "Site").flat_map(|c| c.tiles.iter().map(|t| t.center)).collect(); + assert!(tiles.first().is_some_and(|c| close(*c, SITE)), "{tiles:?}"); + assert!(back.views.iter().all(|v| close(v.center, SITE)), "{:?}", back.views); +} diff --git a/crates/render/src/blocks.rs b/crates/render/src/blocks.rs index ad4689682..976840fbd 100644 --- a/crates/render/src/blocks.rs +++ b/crates/render/src/blocks.rs @@ -1,7 +1,7 @@ use super::draw::entity; use super::props::{resolve, resolve_common, transparency}; use super::text_entity::{mtext, place_text_entity}; -use super::{Builder, Color, Ctx, Entity, EntityKind, Ink, Lineweight, Mat3, Vec2}; +use super::{Builder, Clip, Color, Ctx, Entity, EntityKind, Ink, Lineweight, Mat3, Vec2, append_clipped_keep, clip}; pub(crate) fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> { let layer = if e.common.layer == "0" { ctx.block_layer.clone() } else { Some(e.common.layer.clone()) }; @@ -55,24 +55,88 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc: let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation); 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) }; + // XCLIP: the copy is drawn on its own, then cut to the boundary. + let clip = xclip_region(ins, &sub.xf); + let mut part = clip.as_ref().map(|_| b.part()); + let target = part.as_mut().unwrap_or(&mut *b); + let mut stop = false; 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 !target.expand() { + stop = true; + break; + } // 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); + entity(target, &sub, be); + } + if let (Some(part), Some((region, inverted))) = (part, &clip) { + b.list.expanded = part.list.expanded; + b.list.truncated |= part.list.truncated; + append_clipped_keep(&mut b.list, &part.list, &if *inverted { Clip::Outside(region) } else { Clip::Poly(region) }); + xclip_frame(b, ctx, region, rgb); + } + if stop { + break 'copies; } } } } + // Attributes are cut by the reference's clip too. + let attr_clip = ctx.d.block(&ins.block).and_then(|blk| xclip_region(ins, &ctx.xf.then_before(ins.transform(blk.base.xy())))); + let mut part = attr_clip.as_ref().map(|_| b.part()); + attributes(part.as_mut().unwrap_or(&mut *b), ctx, e, ins, rgb); + if let (Some(part), Some((region, inverted))) = (part, &attr_clip) { + append_clipped_keep(&mut b.list, &part.list, &if *inverted { Clip::Outside(region) } else { Clip::Poly(region) }); + } +} + +/// The XCLIP region of reference `ins` drawn through `xf` (block to drawing coordinates), and +/// whether it is inverted. `None` when the reference isn't clipped (no boundary, clipping off, a +/// degenerate outline). +fn xclip_region(ins: &cadcraft_doc::Insert, xf: &Mat3) -> Option<(clip::PolyClip, bool)> { + let c = ins.clip.as_ref().filter(|c| c.active())?; + let world: Vec = c.polygon().into_iter().map(|p| xf.apply(p)).collect(); + Some((clip::PolyClip::new(&world)?, c.inverted)) +} + +/// The clipping boundary's frame as XCLIPFRAME says: 0 hidden, 1 shown and plotted, 2 shown but +/// not plotted (the default). +fn xclip_frame(b: &mut Builder, ctx: &Ctx, region: &clip::PolyClip, rgb: Ink) { + let mode = ctx.d.header.i64("XCLIPFRAME", 2); + if mode == 0 || (b.plotting && mode != 1) { + return; + } + let mut frame = region.polygon().to_vec(); + if let Some(first) = frame.first().copied() { + frame.push(first); + } + // The outline is in drawing coordinates already. + let top = Ctx { xf: Mat3::IDENTITY, ..ctx.clone() }; + b.polyline(&top, rgb, 0.0, &frame); +} + +/// The attributes of block reference `e` (`ins`). +fn attributes(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rgb: Ink) { if b.opts.text { // ATTMODE (ATTDISP): 0 shows no attributes, 1 the visible ones, 2 all of them. 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 = 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 +147,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 +181,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)) -} diff --git a/crates/render/src/clip.rs b/crates/render/src/clip.rs index 73d89fdc3..a95455d3d 100644 --- a/crates/render/src/clip.rs +++ b/crates/render/src/clip.rs @@ -423,6 +423,80 @@ impl PolyClip { } out } + + /// Clip a segment the other way round (an inverted XCLIP): the pieces outside the region. + fn clip_segment_outside(&self, a: Vec2, b: Vec2, out: &mut Vec<(Vec2, Vec2)>) { + if !a.is_finite() || !b.is_finite() { + return; + } + if clip_segment(a, b, &self.bounds).is_none() { + out.push((a, b)); + return; + } + let d = b - a; + let mut prev = 0.0; + for t in self.crossings(a, b).into_iter().chain(std::iter::once(1.0)) { + if t - prev > 1e-12 && !self.contains(a + d * ((prev + t) / 2.0)) { + out.push((a + d * prev, a + d * t)); + } + prev = t; + } + } + /// Clip a polyline to outside the region (an inverted XCLIP); the visible pieces. + pub fn clip_polyline_outside(&self, pts: &[Vec2]) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + let mut pieces = Vec::new(); + for w in pts.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + pieces.clear(); + self.clip_segment_outside(*a, *b, &mut pieces); + if pieces.is_empty() { + if cur.len() >= 2 { + out.push(std::mem::take(&mut cur)); + } + cur.clear(); + } + for (p, q) in pieces.drain(..) { + if !cur.last().is_some_and(|l| l.near(p, 1e-12)) { + if cur.len() >= 2 { + out.push(std::mem::take(&mut cur)); + } + cur.clear(); + cur.push(p); + } + cur.push(q); + } + } + if cur.len() >= 2 { + out.push(cur); + } + out + } + /// Clip an infinite line (or a ray) to outside the region: the line cut where it crosses. + pub fn clip_infinite_outside(&self, base: Vec2, dir: Vec2, ray: bool) -> Vec> { + let len = dir.len(); + if !base.is_finite() || !dir.is_finite() || len < 1e-300 { + return Vec::new(); + } + let u = dir * (1.0 / len); + // Far enough past the region and the base point that the cut pieces reach the view. + let far = (self.bounds.width() + self.bounds.height() + (base - self.bounds.center()).len()) * 4.0 + 1e6; + let a = if ray { base } else { base - u * far }; + self.clip_polyline_outside(&[a, base + u * far]) + } + /// Fill triangles outside the region (an inverted XCLIP). A triangle wholly inside goes; one + /// the outline crosses stays whole (fills are not cut against the outside). + pub fn clip_triangles_outside(&self, tris: &[Vec2]) -> Vec { + let mut out = Vec::new(); + for t in tris.as_chunks::<3>().0 { + let inside = t.iter().all(|p| self.contains(*p)) && (0..3).all(|i| self.crossings(t[i], t[(i + 1) % 3]).is_empty()); + if !inside { + out.extend_from_slice(t); + } + } + out + } } /// Clip a polygon to a triangle (Sutherland–Hodgman; the triangle is convex). diff --git a/crates/render/src/dim_draw.rs b/crates/render/src/dim_draw.rs index b83a33128..011dfcc73 100644 --- a/crates/render/src/dim_draw.rs +++ b/crates/render/src/dim_draw.rs @@ -1,13 +1,13 @@ use super::display::Kind; use super::draw::stroke_with; use super::props::default_lineweight; -use super::text_entity::{layout_mtext_entity, place_text_entity, text_style}; +use super::text_entity::{layout_mtext_entity, place_text_entity, style_font, text_style}; use super::{Builder, Color, Ctx, DimGeometry, DimText, Drawing, Entity, EntityKind, LineRole, Lineweight, Vec2, dim, ink}; /// The font settings of a dimension style's text style. pub fn dim_text(d: &Drawing, st: &cadcraft_doc::DimStyle) -> DimText { let ts = text_style(d, &st.text_style); - DimText { font: cadcraft_fonts::TextFont::resolve(&ts.font), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique } + DimText { font: style_font(&ts), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique } } /// Geometry of a dimension in a drawing: its style, overrides and text font. diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs index 9731ecbf7..6d9c536f3 100644 --- a/crates/render/src/display.rs +++ b/crates/render/src/display.rs @@ -22,6 +22,51 @@ pub enum Kind { Point, /// An infinite line or ray: `verts[start]` = base, `verts[start+1]` = unit direction. Infinite { ray: bool }, + /// A raster image, [`DisplayList::images`]`[index]`; `verts[start..start+4]` are its corners + /// (top left, top right, bottom right, bottom left of the pixel grid). + Image { index: u32 }, +} + +/// A raster image placed in the world. +#[derive(Clone, Debug, PartialEq)] +pub struct DImage { + pub raster: std::sync::Arc, + /// Where the top-left corner of the top-left pixel is. + pub origin: Vec2, + /// One pixel to the right and one pixel down, in world units. + pub right: Vec2, + pub down: Vec2, + /// Closed outlines the image is cut to (all of them: its own clip boundary, then the + /// viewports it is seen through). Empty: the whole image. + pub clips: Vec>, + /// Fade towards the background, percent (0..=100). + pub fade: u8, +} + +impl DImage { + /// The world position of pixel-grid point (`x`, `y`), `y` down from the top. + pub fn at(&self, x: f64, y: f64) -> Vec2 { + self.origin + self.right * x + self.down * y + } + /// The pixel-grid point at world position `q` (the inverse of [`DImage::at`]); `None` for an + /// image squashed to a line or a point. + pub fn pixel_of(&self, q: Vec2) -> Option { + let det = self.right.x * self.down.y - self.right.y * self.down.x; + if !(det.is_finite() && det.abs() > 0.0) { + return None; + } + let d = q - self.origin; + Some(Vec2::new((d.x * self.down.y - d.y * self.down.x) / det, (self.right.x * d.y - self.right.y * d.x) / det)) + } + /// How opaque the image is drawn, 0..=1: the object's opacity `alpha` (0..=255) less the fade. + pub fn opacity(&self, alpha: u8) -> f32 { + f32::from(alpha) / 255.0 * (1.0 - f32::from(self.fade.min(100)) / 100.0) + } + /// The corners: top left, top right, bottom right, bottom left. + pub fn corners(&self) -> [Vec2; 4] { + let (w, h) = (f64::from(self.raster.width()), f64::from(self.raster.height())); + [self.at(0.0, 0.0), self.at(w, 0.0), self.at(w, h), self.at(0.0, h)] + } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -46,6 +91,8 @@ pub struct DisplayList { pub prims: Vec, pub verts: Vec, pub tris: Vec, + /// The raster images drawn by [`Kind::Image`] primitives. + pub images: Vec, /// Plots ([`build_plot`]): for each primitive, the index in [`Drawing::layers`] of the layer /// it is drawn on (block contents on layer 0: the block reference's; [`NO_LAYER`] when the /// layer isn't in the table), for PDF layers. Empty for other lists. @@ -135,14 +182,19 @@ impl DisplayList { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn append(dst: &mut DisplayList, src: DisplayList) { let (verts, tris, prims) = (dst.verts.len() as u32, dst.tris.len() as u32, dst.prims.len() as u32); + let images = dst.images.len() as u32; // Pens are parallel to the primitives, with missing entries meaning "use object". if !src.pens.is_empty() { dst.pens.resize(dst.prims.len(), Pen::default()); } dst.prims.extend(src.prims.into_iter().map(|mut p| { p.start += if matches!(p.kind, Kind::Tris | Kind::Mask) { tris } else { verts }; + if let Kind::Image { index } = &mut p.kind { + *index = index.saturating_add(images); + } p })); + dst.images.extend(src.images); dst.verts.extend(src.verts); dst.tris.extend(src.tris); dst.prim_layers.extend(src.prim_layers); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index e73659675..f779bff5b 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -89,7 +89,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { b.polyline(ctx, rgb, lw, &dash); } } - // Text embedded in the linetype (shapes need SHX files and are not drawn). + // Text and shapes embedded in the linetype (shapes when their SHX file is found). if b.opts.text { for sh in linetype_text::texts(ctx.d, pts, lt, ltscale, min) { b.shaped(ctx, rgb, lw, &sh); @@ -193,14 +193,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { } } EntityKind::Wipeout(w) => draw_wipeout(b, ctx, w, rgb, lw), - EntityKind::Image(i) => { - let o = i.insert.xy(); - let u = i.u.xy() * i.size.x; - let v = i.v.xy() * i.size.y; - b.polyline(ctx, rgb, lw, &[o, o + u, o + u + v, o + v, o]); - b.polyline(ctx, rgb, lw, &[o, o + u + v]); - b.polyline(ctx, rgb, lw, &[o + u, o + v]); - } + EntityKind::Image(i) => draw_image(b, ctx, i, rgb, lw), EntityKind::Table(t) => table(b, ctx, t, rgb, lw), EntityKind::Tolerance(t) => tolerance::draw(b, ctx, e, t, lw, tol), EntityKind::Leader(l) => { @@ -289,6 +282,23 @@ fn draw_mleader(b: &mut Builder, ctx: &Ctx, e: &Entity, m: &cadcraft_doc::MLeade } } +fn draw_image(b: &mut Builder, ctx: &Ctx, i: &cadcraft_doc::Image, rgb: Ink, lw: f32) { + let o = i.insert.xy(); + let u = i.u.xy() * i.size.x; + let v = i.v.xy() * i.size.y; + // Display flag 1: show the image. Without pixels (file not found, not loaded yet, hidden) + // the rectangle is crossed out. + let raster = if i.display & 1 != 0 { ctx.d.rasters.get(&i.path) } else { None }; + if let Some(r) = raster { + b.image(ctx, rgb, i, r); + } + b.polyline(ctx, rgb, lw, &[o, o + u, o + u + v, o + v, o]); + if raster.is_none() { + b.polyline(ctx, rgb, lw, &[o, o + u + v]); + b.polyline(ctx, rgb, lw, &[o + u, o + v]); + } +} + fn draw_wipeout(b: &mut Builder, ctx: &Ctx, w: &cadcraft_doc::Wipeout, rgb: Ink, lw: f32) { // Hide what was drawn before it (draw order), then the frame per WIPEOUTFRAME: // 0 = none, 1 = shown and plotted, 2 = shown but not plotted. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e93c9fbff..7d5da3358 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -133,7 +133,7 @@ struct Builder<'a> { /// bounds the depth); past the limit the remaining block contents are not drawn. pub const MAX_BLOCK_EXPANSION: usize = 2_000_000; -impl Builder<'_> { +impl<'a> Builder<'a> { /// `color` and `lw` as the plot style table prints them. fn styled(&self, color: Ink, lw: f32) -> (Ink, f32) { match self.styles { @@ -186,6 +186,45 @@ impl Builder<'_> { len: pts.len() as u32, }); } + /// The pixels of image `i` over its rectangle, cut to its clip boundary. + fn image(&mut self, ctx: &Ctx, color: Ink, i: &cadcraft_doc::Image, raster: &std::sync::Arc) { + // Where the entity puts its picture: the top-left corner, and one of its pixels to the + // right and down. + let top_left = ctx.xf.apply(i.insert.xy() + i.v.xy() * i.size.y); + let right = ctx.xf.apply_vec(i.u.xy()); + let down = ctx.xf.apply_vec(i.v.xy() * -1.0); + let clip = image_clip(i, |q| top_left + right * q.x + down * q.y); + // The file found can have another pixel count than the entity records: its pixels are + // stretched over the same rectangle. + let img = DImage { + raster: raster.clone(), + origin: top_left, + right: right * (i.size.x / f64::from(raster.width())), + down: down * (i.size.y / f64::from(raster.height())), + clips: clip.into_iter().collect(), + fade: i.fade.min(100), + }; + let corners = img.corners(); + if !corners.iter().all(|q| q.x.is_finite() && q.y.is_finite()) { + return; + } + let (Ok(index), Ok(start)) = (u32::try_from(self.list.images.len()), u32::try_from(self.list.verts.len())) else { return }; + for q in corners { + self.list.bounds.add(q); + self.list.verts.push(q); + } + self.list.images.push(img); + self.list.prims.push(DPrim { + handle: ctx.top, + color: color.rgb, + aci7: color.aci7, + lw: 0.0, + transparency: self.transparency, + kind: Kind::Image { index }, + start, + len: 4, + }); + } fn tris(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2]) { self.tris_at(ctx, color, tris, ctx.xf); } @@ -288,6 +327,21 @@ impl Builder<'_> { len: 2, }); } + /// An empty builder drawing like this one (for contents cut afterwards, an XCLIP), with the + /// block-expansion count carried over. + fn part(&self) -> Builder<'a> { + let mut list = DisplayList::default(); + list.expanded = self.list.expanded; + Builder { + list, + opts: self.opts, + plotting: self.plotting, + styles: self.styles, + named: self.named, + shared_budget: self.shared_budget, + transparency: self.transparency, + } + } /// Take one unit of the block-expansion budget; false once it is spent. fn expand(&mut self) -> bool { self.list.expanded = self.list.expanded.saturating_add(1); @@ -302,6 +356,23 @@ impl Builder<'_> { } } +/// The clip boundary of image `i` as a closed outline, or `None` when the whole image shows. +/// `place` gives the position of a point of the entity's pixel grid (x right, y down from the +/// top-left corner of the image). +fn image_clip(i: &cadcraft_doc::Image, place: impl Fn(Vec2) -> Vec2) -> Option> { + if !i.clipping { + return None; + } + // Boundaries count from pixel centres: the top-left corner of the image is (-0.5, -0.5). + let at = |q: Vec2| place(Vec2::new(q.x + 0.5, q.y + 0.5)); + match i.clip.as_slice() { + // Two opposite corners of a rectangle. + [a, b] => Some(vec![at(*a), at(Vec2::new(b.x, a.y)), at(*b), at(Vec2::new(a.x, b.y))]), + polygon if polygon.len() >= 3 => Some(polygon.iter().map(|q| at(*q)).collect()), + _ => None, + } +} + mod table; pub use table::{MAX_LAYOUT_CELLS, TableFill, TableGeom, TableLine, TableText, table_covered, table_geometry, table_style_of}; @@ -315,12 +386,13 @@ mod text_entity; mod viewports; pub use dim_draw::{dim_text, dimension_in}; -pub use display::{DPrim, DisplayList, Kind, MAX_TRANSPARENCY, NO_LAYER, PlotText, VIEWPORT_CONTENT, alpha}; +pub use display::{DImage, DPrim, DisplayList, Kind, MAX_TRANSPARENCY, NO_LAYER, PlotText, VIEWPORT_CONTENT, alpha}; pub use pipeline::{Options, build, build_entities, build_plot}; use props::{default_lineweight, resolve_common}; use text_entity::text_style; -pub use text_entity::{layout_mtext_entity, place_text_entity, text_align}; -pub use viewports::{viewport_clip, viewport_content}; +pub use text_entity::{layout_mtext_entity, place_text_entity, style_font, text_align}; +use viewports::{Clip, append_clipped_keep}; +pub use viewports::{clip_list, viewport_clip, viewport_content}; #[cfg(test)] mod tests; diff --git a/crates/render/src/linetype_text.rs b/crates/render/src/linetype_text.rs index a012f0df0..9f753ed11 100644 --- a/crates/render/src/linetype_text.rs +++ b/crates/render/src/linetype_text.rs @@ -1,9 +1,10 @@ -//! Text embedded in complex linetypes (a gas line's `---- GAS ---- GAS ----`). +//! Text and shapes embedded in complex linetypes (a gas line's `---- GAS ---- GAS ----`, a +//! fence's crosses). //! -//! A dash element may carry a text (DXF `74` bit 2, `9` string). As in a `.lin` definition, -//! where the bracketed text follows the dash or gap that carries it, the text is placed where -//! that element ends, at every repeat of the pattern (laid out from the start of the run like -//! [`crate::linetype::apply`]): +//! A dash element may carry a text (DXF `74` bit 2, `9` string) or a shape (`74` bit 4, `75` +//! shape number). As in a `.lin` definition, where the bracketed text or shape follows the dash +//! or gap that carries it, it is placed where that element ends, at every repeat of the pattern +//! (laid out from the start of the run like [`crate::linetype::apply`]): //! - height: the element's scale (`46`), times the text style's fixed height when it has one, //! times the linetype scale; //! - origin: the anchor shifted by the X/Y offsets (`44`/`45`, times the linetype scale) along @@ -11,17 +12,24 @@ //! - rotation (`50`): relative to the line direction, or absolute (`74` bit 1); //! - font, width factor, oblique angle and mirroring from the text style (`340`). //! -//! Shape elements (`74` bit 4) need SHX shape files, which CADCraft does not read: they draw -//! nothing and leave their gap empty. +//! A shape comes from the SHX shape file its style (`340`) names, as the font table finds it +//! ([`cadcraft_fonts::shx::find`]): an unnamed shape-file style is read as its file name, a +//! named style by its font. It is drawn at the element's scale times the linetype scale per +//! text height of the file (its font record's `above`, else one vector unit), rotated and +//! offset like text. A shape whose file isn't found draws nothing and leaves its gap empty. use cadcraft_doc::{Drawing, Linetype}; -use cadcraft_fonts::{Shaped, TextFont, TextParams}; +use cadcraft_fonts::{Shaped, TextParams}; use cadcraft_geom::Vec2; -/// Upper bound on the texts drawn along one run (hostile scales; `apply` caps the dashes). +/// Upper bound on the texts and shapes drawn along one run (hostile scales; `apply` caps the +/// dashes). const MAX_TEXTS: usize = 10_000; +/// Most points all the placed texts and shapes of one polyline hold together (a large shape +/// repeated many times). +const MAX_POINTS: usize = 2_000_000; -/// One text element of the pattern, shaped once at the origin. +/// One text or shape element of the pattern, shaped once at the origin. struct Element { /// Distance from the start of a pattern repeat to the anchor (where the element ends). at: f64, @@ -31,12 +39,12 @@ struct Element { offset: Vec2, } -/// The texts of `lt` along the polyline `pts`, in the coordinates of `pts`. Empty when the -/// linetype has no text or its scaled pattern is drawn continuous (shorter than `min_pattern`, -/// or repeating too often). +/// The texts and shapes of `lt` along the polyline `pts`, in the coordinates of `pts`. Empty +/// when the linetype has none or its scaled pattern is drawn continuous (shorter than +/// `min_pattern`, or repeating too often). pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern: f64) -> Vec { let mut out = Vec::new(); - if pts.len() < 2 || !lt.pattern.iter().any(|e| e.text.as_deref().is_some_and(|t| !t.trim().is_empty())) { + if pts.len() < 2 || !lt.pattern.iter().any(|e| e.text.as_deref().is_some_and(|t| !t.trim().is_empty()) || e.shape.is_some()) { return out; } let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; @@ -51,10 +59,12 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern: } let mut walk = Walk { pts, i: 0, start: 0.0 }; let mut base = 0.0; + let mut points = 0usize; 'repeats: while base <= len { for el in &elements { let s = base + el.at; - if s > len + 1e-9 || out.len() >= MAX_TEXTS { + points = points.saturating_add(point_count(&el.local)); + if s > len + 1e-9 || out.len() >= MAX_TEXTS || points > MAX_POINTS { break 'repeats; } let Some((p, dir)) = walk.at(s) else { break 'repeats }; @@ -69,12 +79,45 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern: out } -/// The text elements of `lt`, scaled by `scale`, in pattern order. +fn point_count(s: &Shaped) -> usize { + s.strokes.iter().map(Vec::len).chain(s.glyphs.iter().flatten().map(Vec::len)).fold(0, usize::saturating_add) +} + +/// The shape element `el` at the origin (scaled by the element's and the linetype's scale), if +/// its shape file is found and has the shape. +fn shape(d: &Drawing, el: &cadcraft_doc::DashElement, scale: f64) -> Option { + let number = el.shape.filter(|n| *n != 0)?; + let style = el.style.as_deref().map(str::trim).filter(|s| !s.is_empty())?; + let file = d.text_style(style).map_or_else(|| style.to_string(), |ts| ts.font.clone()); + let font = cadcraft_fonts::shx::find(&file)?; + let glyph = font.glyph(number, false)?; + let k = if el.scale.is_finite() && el.scale > 0.0 { el.scale } else { 1.0 } * scale / font.units_per_height(); + if !(k.is_finite() && k > 0.0) { + return None; + } + let strokes = glyph.strokes.iter().map(|s| s.iter().map(|p| *p * k).collect()).collect(); + Some(Shaped { strokes, width: glyph.advance.x * k, ..Shaped::default() }) +} + +/// The text and shape elements of `lt`, scaled by `scale`, in pattern order. fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec { let mut out = Vec::new(); let mut at = 0.0; + let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; for el in <.pattern { at += el.length.abs() * scale; + if el.text.is_none() + && let Some(local) = shape(d, el, scale) + { + out.push(Element { + at, + local, + rotation: finite(el.rotation), + absolute: el.absolute, + offset: Vec2::new(finite(el.offset.x), finite(el.offset.y)) * scale, + }); + continue; + } let Some(text) = el.text.as_deref().filter(|t| !t.trim().is_empty()) else { continue }; let ts = d.text_style(el.style.as_deref().unwrap_or("Standard")).cloned().unwrap_or_default(); let k = if el.scale.is_finite() && el.scale > 0.0 { el.scale } else { 1.0 }; @@ -83,7 +126,7 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec { if !(height.is_finite() && height > 0.0) { continue; } - let font = TextFont::resolve(&ts.font); + let font = crate::style_font(&ts); let params = TextParams { width_factor: if ts.width_factor.is_finite() && ts.width_factor > 0.0 { ts.width_factor } else { 1.0 }, oblique: if ts.oblique.is_finite() { ts.oblique } else { 0.0 }, @@ -92,7 +135,6 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec { ..TextParams::new(Vec2::ZERO, height) }; let (local, _) = cadcraft_fonts::place(&font, text, ¶ms); - let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; out.push(Element { at, local, @@ -175,6 +217,91 @@ mod tests { assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty()); } + /// A shape file written for this test: shape 130 a 2 x 2 box with its pen ending at x = 2. + fn register_shapes() { + let src = "*130,7,BOX\n1,020,024,028,02C,2,020,0\n"; + cadcraft_fonts::shx::register("cadcraft-render-lt.shx", &cadcraft_fonts::shx::compile(src).unwrap()).unwrap(); + } + + fn fence(style: &str) -> Linetype { + Linetype { + name: "FENCE".into(), + description: String::new(), + pattern: vec![ + DashElement::dash(0.5), + DashElement { shape: Some(130), style: Some(style.into()), scale: 0.1, ..DashElement::dash(-0.2) }, + DashElement::dash(-0.25), + ], + } + } + + #[test] + fn shape_elements_draw_their_shape_at_each_repeat() { + register_shapes(); + let d = Drawing::new_imperial(); + // An unnamed shape-file style reads as its file name (DXF); scale 0.1 per vector unit. + let t = texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &fence("cadcraft-render-lt.shx"), 1.0, 0.0); + assert_eq!(t.len(), 2); + let x = starts(&t); + assert!((x[0] - 0.7).abs() < 1e-9 && (x[1] - 1.65).abs() < 1e-9, "{x:?}"); + let ys = t[0].strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y))); + assert!((ys.0).abs() < 1e-9 && (ys.1 - 0.2).abs() < 1e-9, "{ys:?}"); + // Along a vertical line the shape turns with it; absolute rotation keeps it level. + let t = texts(&d, &[Vec2::ZERO, Vec2::new(0.0, 2.0)], &fence("cadcraft-render-lt.shx"), 2.0, 0.0); + assert_eq!(t.len(), 1); + let xs = t[0].strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x))); + assert!((xs.0 + 0.4).abs() < 1e-9 && xs.1.abs() < 1e-9, "{xs:?}"); + // A named style whose font is the shape file. + let mut d = Drawing::new_imperial(); + d.text_styles.push(cadcraft_doc::TextStyle { name: "SHAPES".into(), font: "cadcraft-render-lt.shx".into(), ..Default::default() }); + assert_eq!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &fence("SHAPES"), 1.0, 0.0).len(), 2); + // A shape file that isn't found, or a shape it lacks, draws nothing. + assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &fence("cadcraft-no-such-file.shx"), 1.0, 0.0).is_empty()); + let mut lt = fence("cadcraft-render-lt.shx"); + lt.pattern[1].shape = Some(131); + assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], <, 1.0, 0.0).is_empty()); + } + + #[test] + fn a_large_shape_repeated_many_times_is_bounded() { + // A shape of 1,000 one-unit vectors there and back: about 1,000 points each time. + let body: Vec<&str> = (0..500).flat_map(|_| ["010", "018"]).collect(); + let src = format!("*131,1002,ZIGZAG\n1,{},0\n", body.join(",")); + cadcraft_fonts::shx::register("cadcraft-render-big.shx", &cadcraft_fonts::shx::compile(&src).unwrap()).unwrap(); + let d = Drawing::new_imperial(); + let mut lt = fence("cadcraft-render-big.shx"); + lt.pattern[1].shape = Some(131); + // 3,000 repeats would hold about 3 million points. + let t = texts(&d, &[Vec2::ZERO, Vec2::new(2850.0, 0.0)], <, 1.0, 0.0); + let points: usize = t.iter().map(point_count).sum(); + assert!(!t.is_empty() && t.len() < 3000 && points <= MAX_POINTS, "{} shapes, {points} points", t.len()); + } + + #[test] + fn text_in_an_shx_style_uses_the_font_metrics() { + // A text font written for this test: A is 4 wide with a gap of 2, cap height 6. + let src = "*0,4,render test\n6,2,0,0\n*65,7,UCA\n1,064,040,06C,2,020,0\n"; + cadcraft_fonts::shx::register("cadcraft-render-text.shx", &cadcraft_fonts::shx::compile(src).unwrap()).unwrap(); + let mut d = Drawing::new_imperial(); + d.text_styles.push(cadcraft_doc::TextStyle { name: "SHX".into(), font: "cadcraft-render-text.shx".into(), ..Default::default() }); + let t = cadcraft_doc::Text { + insert: cadcraft_geom::Vec3::new(0.0, 0.0, 0.0), + align_pt: None, + height: 3.0, + value: "AA".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "SHX".into(), + halign: Default::default(), + valign: Default::default(), + }; + let (sh, bb) = crate::place_text_entity(&d, &t, "AA"); + assert!((sh.width - 6.0).abs() < 1e-9 && (bb.width() - 6.0).abs() < 1e-9); + let maxy = sh.strokes.iter().flatten().map(|p| p.y).fold(f64::MIN, f64::max); + assert!((maxy - 3.0).abs() < 1e-9); + } + #[test] fn hostile_values_never_panic() { let d = Drawing::new_imperial(); diff --git a/crates/render/src/outline.rs b/crates/render/src/outline.rs index 730d7fb39..96244fc01 100644 --- a/crates/render/src/outline.rs +++ b/crates/render/src/outline.rs @@ -106,10 +106,12 @@ fn chain(mut segs: Vec<(Vec2, Vec2)>) -> Vec> { } } segs.sort_unstable_by(|(a, b), (c, d)| cmp(*a, *c).then_with(|| cmp(*b, *d))); - let mut at: HashMap> = 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, 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> { 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::(), 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)); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index a32818a38..d802d2c1d 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -4,7 +4,7 @@ use cadcraft_color::Rgb; use cadcraft_geom::{Bounds2, Vec2}; use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; -use crate::{DisplayList, Kind}; +use crate::{DImage, DisplayList, Kind}; /// A 2D view: world point at the image centre and pixels per drawing unit. #[derive(Clone, Copy, Debug, PartialEq)] @@ -147,6 +147,13 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option { + if let Some(img) = list.images.get(index as usize) + && Bounds2::from_points(pts.iter().copied()).intersects(&vis) + { + image(&mut pm, img, view, p.alpha(), o.antialias); + } + } Kind::Infinite { ray } => { let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) else { continue }; let far = (vis.width() + vis.height()) * 2.0 + base.dist(vis.center()); @@ -253,6 +260,95 @@ pub fn blank_outside_pixels(pm: &mut Pixmap, top_left: (f64, f64), bottom_right: } } +/// Draw `img` with the object opacity `alpha`, cut to its clip outlines. +fn image(pm: &mut Pixmap, img: &DImage, view: &View, alpha: u8, antialias: bool) { + let r = &img.raster; + let Some(to_screen) = grid_to_screen(img, view) else { return }; + let Ok(mask) = clip_mask(pm, img, view, antialias) else { return }; + // tiny-skia wants premultiplied pixels; opaque ones already are. + let owned; + let pixels = if r.opaque() { + tiny_skia::PixmapRef::from_bytes(r.rgba(), r.width(), r.height()) + } else { + owned = premultiplied(r); + owned.as_ref().map(Pixmap::as_ref) + }; + let Some(pixels) = pixels else { return }; + let paint = Paint { + shader: tiny_skia::Pattern::new( + pixels, + tiny_skia::SpreadMode::Pad, + tiny_skia::FilterQuality::Bilinear, + img.opacity(alpha), + Transform::identity(), + ), + anti_alias: antialias, + ..Paint::default() + }; + // The picture's own rectangle in its pixel grid, carried to the screen by `to_screen`. + // Filled as a path: tiny-skia's anti-aliased `fill_rect` trips a debug assertion on narrow + // rects (see the points above), which a one-pixel image drawn at its own size would be. + if let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, r.width() as f32, r.height() as f32) { + pm.fill_path(&PathBuilder::from_rect(rect), &paint, FillRule::Winding, to_screen, mask.as_ref()); + } +} + +/// The transform from `img`'s pixel grid (x right, y down) to screen pixels; `None` when the +/// image has no area on screen or lies out of number range. +fn grid_to_screen(img: &DImage, view: &View) -> Option { + let s = view.scale; + let (x, y) = view.to_screen(img.origin); + // Screen y runs down, world y up. + let ts = Transform::from_row((img.right.x * s) as f32, (-img.right.y * s) as f32, (img.down.x * s) as f32, (-img.down.y * s) as f32, x, y); + (ts.is_finite() && ts.invert().is_some()).then_some(ts) +} + +/// The image has a clip outline without area: nothing of it shows. +struct Hidden; + +/// The part of the picture `img`'s clip outlines leave visible: `None` for all of it. +fn clip_mask(pm: &Pixmap, img: &DImage, view: &View, antialias: bool) -> Result, Hidden> { + let mut mask: Option = None; + for outline in &img.clips { + let path = outline_path(outline, view).ok_or(Hidden)?; + match &mut mask { + // The first outline starts the mask; each further one narrows it. + None => { + let mut m = tiny_skia::Mask::new(pm.width(), pm.height()).ok_or(Hidden)?; + m.fill_path(&path, FillRule::EvenOdd, antialias, Transform::identity()); + mask = Some(m); + } + Some(m) => m.intersect_path(&path, FillRule::EvenOdd, antialias, Transform::identity()), + } + } + Ok(mask) +} + +/// A closed world-space outline as a screen path. +fn outline_path(outline: &[Vec2], view: &View) -> Option { + let mut pb = PathBuilder::new(); + for (i, q) in outline.iter().enumerate() { + let (x, y) = view.to_screen(*q); + if i == 0 { + pb.move_to(x, y); + } else { + pb.line_to(x, y); + } + } + pb.close(); + pb.finish() +} + +/// The pixels of `r` with each colour multiplied by its alpha. +fn premultiplied(r: &cadcraft_doc::Raster) -> Option { + let mut data = Vec::with_capacity(r.rgba().len()); + for [red, green, blue, a] in r.rgba().as_chunks::<4>().0 { + let m = |c: &u8| ((u16::from(*c) * u16::from(*a) + 127) / 255) as u8; + data.extend_from_slice(&[m(red), m(green), m(blue), *a]); + } + Pixmap::from_vec(data, tiny_skia::IntSize::from_wh(r.width(), r.height())?) +} + /// Render and encode as PNG. pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option> { render(list, view, o)?.encode_png().ok() diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 5a571feff..741ffd410 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -375,7 +375,7 @@ pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, t: &Table, rgb: Ink, lw: f32) { } for tx in &g.texts { let ts = crate::text_style(ctx.d, &tx.style); - let font = cadcraft_fonts::TextFont::resolve(&ts.font); + let font = crate::style_font(&ts); // Cell content is MTEXT (`\P`, `{\Q15;…}`, `\F…;`): laid out as such, at the cell's // alignment point (its numbering is MTEXT's attachment). let mp = cadcraft_fonts::MTextParams { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e4b314ff5..757259583 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -137,6 +137,7 @@ fn vp(center: Vec2, w: f64, h: f64, view_center: Vec2, view_height: f64, id: u32 layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, } } @@ -633,6 +634,7 @@ fn hatch_in_a_squashed_block_keeps_lines_that_show() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); // One pixel = 1 unit: 0.125 spacing × 100 along x is far more than a pixel. @@ -677,6 +679,7 @@ fn insert_of(block: &str) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/render/src/text_entity.rs b/crates/render/src/text_entity.rs index f7989e2c8..bcff8d4d8 100644 --- a/crates/render/src/text_entity.rs +++ b/crates/render/src/text_entity.rs @@ -5,10 +5,16 @@ pub(crate) fn text_style(d: &Drawing, name: &str) -> cadcraft_doc::TextStyle { d.text_style(name).cloned().unwrap_or_default() } +/// The font a text style draws with: its font file (TrueType, or an SHX font found by name), +/// its big font and its vertical flag. +pub fn style_font(ts: &cadcraft_doc::TextStyle) -> cadcraft_fonts::TextFont { + cadcraft_fonts::TextFont::resolve_style(&ts.font, &ts.big_font, ts.vertical) +} + /// Shape a TEXT / ATTRIB / ATTDEF with its style's font and generation flags. pub fn place_text_entity(d: &Drawing, t: &cadcraft_doc::Text, value: &str) -> (cadcraft_fonts::Shaped, Bounds2) { let ts = text_style(d, &t.style); - let font = cadcraft_fonts::TextFont::resolve(&ts.font); + let font = style_font(&ts); let (h, v) = align(t.halign, t.valign); let p = cadcraft_fonts::TextParams { insert: t.insert.xy(), @@ -36,7 +42,7 @@ pub fn layout_mtext_entity(d: &Drawing, t: &cadcraft_doc::MText) -> cadcraft_fon rotation: t.rotation, line_spacing: t.line_spacing, line_spacing_exact: t.line_spacing_exact, - font: cadcraft_fonts::TextFont::resolve(&ts.font), + font: style_font(&ts), width_factor: ts.width_factor, oblique: ts.oblique, columns: t.columns.as_ref().map(|c| cadcraft_fonts::MTextColumnSpec { diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs index 67c31c694..82885b85f 100644 --- a/crates/render/src/viewports.rs +++ b/crates/render/src/viewports.rs @@ -1,11 +1,11 @@ #[cfg(not(target_arch = "wasm32"))] use super::display::append; -use super::display::{DPrim, DisplayList, Kind, PlotText, VIEWPORT_CONTENT}; +use super::display::{DImage, DPrim, DisplayList, Kind, NO_LAYER, PlotText, VIEWPORT_CONTENT}; use super::draw::entity; #[cfg(not(target_arch = "wasm32"))] use super::pipeline::PARALLEL_CHUNK; use super::pipeline::{Options, style_table, top_ctx, with_point_view}; -use super::{Bounds2, Builder, Ctx, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_EXPANSION, Mat3, Space, Vec2, clip, paper, pen, plotstyle}; +use super::{Bounds2, Builder, Ctx, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_EXPANSION, Mat3, Pen, Space, Vec2, clip, paper, pen, plotstyle}; /// Upper bound on viewports drawn per layout (hostile files). pub(crate) const MAX_VIEWPORTS: usize = 256; @@ -88,11 +88,12 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou if !ok(s) { return; } - let xf = Mat3::translate(center).then_before(Mat3::scale(s, s)).then_before(Mat3::translate(-vp.view_center)); - let mhalf = Vec2::new(area.width(), area.height()) / (2.0 * s); - let mcenter = vp.view_center + (area.center() - center) / s; - // Model window with slack: entity bounds are approximate (text, dimensions). - let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1); + // Model space scaled onto the sheet and turned by the viewport's view twist. + let xf = vp.model_to_paper_matrix(); + // The model box the area covers (turned, with a twist), with slack: entity bounds are + // approximate (text, dimensions). + let mbox = Bounds2::from_points(area.corners().map(|p| vp.paper_to_model(p))); + let win = mbox.expand(mbox.width().max(mbox.height()) * 0.05); // PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in // paper space, whatever the viewport scale. let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 }; @@ -187,17 +188,66 @@ pub fn viewport_clip(d: &Drawing, handle: Handle, vp: &cadcraft_doc::Viewport) - clip::PolyClip::new(&clip::boundary_polygon(&be.kind)?) } +/// `list` clipped to `clip` (a clipped viewport's outline in the list's own units), for a list +/// drawn inside a clipped viewport apart from its contents. +pub fn clip_list(list: &DisplayList, clip: &clip::PolyClip) -> DisplayList { + let mut out = DisplayList::default(); + append_clipped(&mut out, list, &Clip::Poly(clip)); + out +} + /// What viewport contents are clipped to. -enum Clip<'a> { +pub(crate) enum Clip<'a> { /// The viewport rectangle (the fast path). Rect(Bounds2), /// A clip boundary's outline. Poly(&'a clip::PolyClip), + /// Outside a clip boundary's outline (an inverted XCLIP). + Outside(&'a clip::PolyClip), } /// Append `src` to `dst`, clipped to `clip`. -fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { +pub(crate) fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { for p in &src.prims { + clip_prim(dst, src, p, clip); + } +} + +/// Append `src` to `dst` clipped to `clip`, keeping each primitive's plot style pen and, for a +/// glyph that the clip leaves whole, its plotted text (an XCLIP-clipped block reference). +pub(crate) fn append_clipped_keep(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { + let texts: std::collections::HashMap = + src.texts.iter().filter(|t| t.prim != PlotText::NONE && t.prim != PlotText::CLIPPED).map(|t| (t.prim as usize, t)).collect(); + for (i, p) in src.prims.iter().enumerate() { + let first = dst.prims.len(); + clip_prim(dst, src, p, clip); + // Each piece keeps its source's layer (block contents not on layer 0 keep theirs). + if let Some(layer) = src.prim_layers.get(i).copied() { + for k in first..dst.prims.len() { + dst.prim_layers.resize(k, NO_LAYER); + dst.prim_layers.push(layer); + } + } + if let Some(pen) = src.pens.get(i).copied().filter(|pen| !pen.is_object()) { + for k in first..dst.prims.len() { + dst.pens.resize(k, Pen::default()); + dst.pens.push(pen); + } + } + if dst.prims.len() == first + 1 + && let (Some(q), Some(t)) = (dst.prims.get(first), texts.get(&i)) + && q.kind == p.kind + && dst.points(q) == src.points(p) + && let Ok(prim) = u32::try_from(first) + { + dst.texts.push(PlotText { prim, ..(**t).clone() }); + } + } +} + +/// Append primitive `p` of `src` to `dst`, clipped to `clip`. +fn clip_prim(dst: &mut DisplayList, src: &DisplayList, p: &DPrim, clip: &Clip) { + { let pts = src.points(p); match (p.kind, clip) { (Kind::Polyline, Clip::Rect(rect)) => { @@ -210,6 +260,11 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { push_raw(dst, p, Kind::Polyline, &piece); } } + (Kind::Polyline, Clip::Outside(c)) => { + for piece in c.clip_polyline_outside(pts) { + push_raw(dst, p, Kind::Polyline, &piece); + } + } (Kind::Tris | Kind::Mask, Clip::Rect(rect)) => { let t = clip::clip_triangles(pts, rect); push_raw(dst, p, p.kind, &t); @@ -218,10 +273,15 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { let t = c.clip_triangles(pts); push_raw(dst, p, p.kind, &t); } + (Kind::Tris | Kind::Mask, Clip::Outside(c)) => { + let t = c.clip_triangles_outside(pts); + push_raw(dst, p, p.kind, &t); + } (Kind::Point, _) => { let inside = |q: Vec2| match clip { Clip::Rect(rect) => rect.contains(q), Clip::Poly(c) => c.contains(q), + Clip::Outside(c) => !c.contains(q), }; if let Some(q) = pts.first() && inside(*q) @@ -229,6 +289,11 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { push_raw(dst, p, Kind::Point, &[*q]); } } + (Kind::Image { index }, _) => { + if let Some(img) = src.images.get(index as usize) { + push_clipped_image(dst, p, img, pts, clip); + } + } (Kind::Infinite { ray }, Clip::Rect(rect)) => { if let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) && let Some((a, c)) = clip::clip_infinite(*base, *dir, ray, rect) @@ -243,10 +308,48 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { } } } + (Kind::Infinite { ray }, Clip::Outside(c)) => { + if let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) { + for piece in c.clip_infinite_outside(*base, *dir, ray) { + push_raw(dst, p, Kind::Polyline, &piece); + } + } + } } } } +/// Append image `img` (primitive `p`, corners `corners`) to `dst`, to be cut to `clip` when it +/// is drawn: the picture is not cut here, the outline is added to those it carries. +fn push_clipped_image(dst: &mut DisplayList, p: &DPrim, img: &DImage, corners: &[Vec2], clip: &Clip) { + let (outline, area) = match clip { + Clip::Rect(rect) => (vec![rect.min, Vec2::new(rect.max.x, rect.min.y), rect.max, Vec2::new(rect.min.x, rect.max.y)], *rect), + Clip::Poly(c) => (c.polygon().to_vec(), c.bounds()), + // An image can only be cut to the inside of an outline: under an inverted XCLIP it is + // drawn whole, which shows too much rather than losing any of it. + Clip::Outside(_) => { + let Ok(index) = u32::try_from(dst.images.len()) else { return }; + dst.images.push(img.clone()); + push_raw(dst, p, Kind::Image { index }, corners); + return; + } + }; + let own = Bounds2::from_points(corners.iter().copied()); + if !own.intersects(&area) { + return; + } + let Ok(index) = u32::try_from(dst.images.len()) else { return }; + let mut img = img.clone(); + img.clips.push(outline); + dst.images.push(img); + // The list's bounds take only the part inside the clip, as for the other primitives. + let before = dst.bounds; + push_raw(dst, p, Kind::Image { index }, corners); + dst.bounds = before; + dst.bounds.add(own.min.max(area.min)); + dst.bounds.add(own.max.min(area.max)); +} + fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { let min = match kind { Kind::Polyline => 2, diff --git a/crates/render/src/wide.rs b/crates/render/src/wide.rs index d0d874d3f..5df6f42f3 100644 --- a/crates/render/src/wide.rs +++ b/crates/render/src/wide.rs @@ -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 = std::iter::once(first).chain(inner).chain(std::iter::once(last)).collect(); for w in pts.windows(2) { if let [x, y] = w { diff --git a/crates/render/tests/block_ltscale.rs b/crates/render/tests/block_ltscale.rs index 4e5863752..3f8334e48 100644 --- a/crates/render/tests/block_ltscale.rs +++ b/crates/render/tests/block_ltscale.rs @@ -29,6 +29,7 @@ fn drawing(ltscale: f64, own: &str) -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, reference, EntityKind::Insert(ins)).unwrap(); d diff --git a/crates/render/tests/it/aci7_display.rs b/crates/render/tests/it/aci7_display.rs index 5881c6bce..943d1ba7b 100644 --- a/crates/render/tests/it/aci7_display.rs +++ b/crates/render/tests/it/aci7_display.rs @@ -27,6 +27,7 @@ fn insert(y: f64) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/render/tests/it/plot_line_styles.rs b/crates/render/tests/it/plot_line_styles.rs index 8cad7e3e5..b0abc6702 100644 --- a/crates/render/tests/it/plot_line_styles.rs +++ b/crates/render/tests/it/plot_line_styles.rs @@ -167,6 +167,7 @@ fn viewport_contents_get_their_pens() { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }; let space = Space::Paper("Layout1".into()); d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap(); diff --git a/crates/render/tests/it/plot_styles.rs b/crates/render/tests/it/plot_styles.rs index 75134911d..4e027506c 100644 --- a/crates/render/tests/it/plot_styles.rs +++ b/crates/render/tests/it/plot_styles.rs @@ -143,6 +143,7 @@ fn named_styles_by_layer_by_block_and_name() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); d.add(&Space::Model, Common { plot_style: "Thick".into(), ..Default::default() }, ins).unwrap(); let l = build_plot(&d, &Space::Model, &with_table(PlotStyleTable::builtin("default.stb").unwrap())); diff --git a/crates/render/tests/it/psltscale.rs b/crates/render/tests/it/psltscale.rs index dab134ec5..e0b47647c 100644 --- a/crates/render/tests/it/psltscale.rs +++ b/crates/render/tests/it/psltscale.rs @@ -25,6 +25,7 @@ fn drawing(psltscale: i64) -> Drawing { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }; d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap(); d diff --git a/crates/render/tests/it/render_props.rs b/crates/render/tests/it/render_props.rs index a40b1988d..b0361c21f 100644 --- a/crates/render/tests/it/render_props.rs +++ b/crates/render/tests/it/render_props.rs @@ -63,6 +63,7 @@ fn dimensions_take_the_viewport_layer_colour() { layer_colors: vec![("a".into(), Color::Index(3))], clip: None, on: true, + twist: 0.0, }; d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap(); let l = build(&d, &Space::Paper("Layout1".into()), &Options::default()); @@ -170,6 +171,7 @@ fn attributes_draw_with_their_own_properties() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let common = Common { color: Color::Index(1), lineweight: Lineweight::Mm100(35), ..on("INS") }; d.add(&Space::Model, common, EntityKind::Insert(ins)).unwrap(); diff --git a/crates/render/tests/it/viewport_frozen_layer.rs b/crates/render/tests/it/viewport_frozen_layer.rs index bce291635..876b33574 100644 --- a/crates/render/tests/it/viewport_frozen_layer.rs +++ b/crates/render/tests/it/viewport_frozen_layer.rs @@ -21,6 +21,7 @@ fn drawing() -> Drawing { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }; d.add(&Space::Paper("Layout1".into()), Common { layer: "VPORTS".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap(); d diff --git a/crates/render/tests/raster_image.rs b/crates/render/tests/raster_image.rs new file mode 100644 index 000000000..2572fcf72 --- /dev/null +++ b/crates/render/tests/raster_image.rs @@ -0,0 +1,343 @@ +//! Raster images (IMAGE): the pixels are drawn over the image's rectangle, rotated with it and +//! cut to its clip boundary; an image without loaded pixels is a crossed-out frame. + +use std::sync::Arc; + +use cadcraft_color::Rgb; +use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Image, Insert, Line, Raster, Space, Transparency, Viewport}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_render::raster::{RasterOptions, View, render}; +use cadcraft_render::{Kind, Options, build}; + +const RED: [u8; 4] = [255, 0, 0, 255]; +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const BG: Rgb = Rgb(33, 40, 48); + +/// Two by two pixels: red and green on top, blue and white below. +fn pixels() -> Arc { + Arc::new(Raster::new(2, 2, [RED, GREEN, BLUE, WHITE].concat()).unwrap()) +} + +/// A 10 x 10 unit image of `map.png` with its lower-left corner at the origin. +fn image() -> Image { + Image { + insert: Vec3::ZERO, + u: Vec3::new(5.0, 0.0, 0.0), + v: Vec3::new(0.0, 5.0, 0.0), + size: Vec2::new(2.0, 2.0), + path: "map.png".into(), + ..Image::default() + } +} + +fn drawing(i: Image, loaded: bool) -> Drawing { + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + if loaded { + d.rasters.insert("map.png", pixels()); + } + d +} + +/// The colour at the centre of each quarter of the 10 x 10 square: top left, top right, bottom +/// left, bottom right. +fn quarters(d: &Drawing) -> [[u8; 4]; 4] { + let list = build(d, &Space::Model, &Options::default()); + let view = View { center: Vec2::new(5.0, 5.0), scale: 10.0, width: 100, height: 100 }; + let pm = render(&list, &view, &RasterOptions { antialias: false, ..Default::default() }).unwrap(); + [(25, 25), (75, 25), (25, 75), (75, 75)].map(|(x, y)| { + let c = pm.pixel(x, y).unwrap(); + [c.red(), c.green(), c.blue(), c.alpha()] + }) +} + +fn bg() -> [u8; 4] { + [BG.0, BG.1, BG.2, 255] +} + +/// The quarters show `want` (the filter blends a little of the neighbouring pixel in). +#[track_caller] +fn shows(got: [[u8; 4]; 4], want: [[u8; 4]; 4]) { + let near = got.iter().flatten().zip(want.iter().flatten()).all(|(a, b)| a.abs_diff(*b) <= 6); + assert!(near, "{got:?} is not {want:?}"); +} + +#[test] +fn the_pixels_are_drawn_over_the_rectangle() { + let d = drawing(image(), true); + shows(quarters(&d), [RED, GREEN, BLUE, WHITE]); + let list = build(&d, &Space::Model, &Options::default()); + assert_eq!(list.images.len(), 1); + let p = list.prims.iter().find(|p| matches!(p.kind, Kind::Image { .. })).unwrap(); + assert_eq!(list.points(p), [Vec2::new(0.0, 10.0), Vec2::new(10.0, 10.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 0.0)]); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Polyline).count(), 1, "the frame, not crossed out"); +} + +#[test] +fn an_image_without_pixels_is_a_crossed_out_frame() { + for d in [drawing(image(), false), drawing(Image { display: 6, ..image() }, true)] { + let list = build(&d, &Space::Model, &Options::default()); + assert!(list.images.is_empty()); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Polyline).count(), 3, "frame and two diagonals"); + } +} + +#[test] +fn the_image_turns_with_its_axes() { + // Turned a quarter turn counter-clockwise about its lower-left corner, then moved back over + // the square: the image's top edge is now the square's left edge. + let i = Image { insert: Vec3::new(10.0, 0.0, 0.0), u: Vec3::new(0.0, 5.0, 0.0), v: Vec3::new(-5.0, 0.0, 0.0), ..image() }; + shows(quarters(&drawing(i, true)), [GREEN, WHITE, RED, BLUE]); +} + +#[test] +fn the_clip_boundary_cuts_the_image() { + // The left pixel column only (pixel coordinates from pixel centres, y down from the top). + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(0.5, 1.5)], clipping: true, ..image() }; + shows(quarters(&drawing(i, true)), [RED, bg(), BLUE, bg()]); + // A triangle over the top-left half. + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(1.5, -0.5), Vec2::new(-0.5, 1.5)], clipping: true, ..image() }; + let q = quarters(&drawing(i, true)); + shows([q[0], q[3], q[0], q[3]], [RED, bg(), RED, bg()]); + // The boundary is kept but switched off. + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(0.5, 1.5)], clipping: false, ..image() }; + shows(quarters(&drawing(i, true)), [RED, GREEN, BLUE, WHITE]); + // The boundary a file holds for an unclipped image is the whole image. + let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(1.5, 1.5)], clipping: true, ..image() }; + shows(quarters(&drawing(i, true)), [RED, GREEN, BLUE, WHITE]); +} + +#[test] +fn a_file_of_another_size_fills_the_same_rectangle() { + // The entity says 4 x 4 pixels of 2.5 units; the file found has 2 x 2. + let i = Image { u: Vec3::new(2.5, 0.0, 0.0), v: Vec3::new(0.0, 2.5, 0.0), size: Vec2::new(4.0, 4.0), ..image() }; + shows(quarters(&drawing(i, true)), [RED, GREEN, BLUE, WHITE]); +} + +#[test] +fn fade_and_degenerate_images_do_not_panic() { + let faded = quarters(&drawing(Image { fade: 100, ..image() }, true)); + assert_eq!(faded[0], bg(), "fully faded shows the background"); + for i in [ + Image { u: Vec3::ZERO, ..image() }, + Image { u: Vec3::new(f64::NAN, 0.0, 0.0), ..image() }, + Image { size: Vec2::new(0.0, -1.0), ..image() }, + Image { insert: Vec3::new(1e308, 1e308, 0.0), u: Vec3::new(1e308, 0.0, 0.0), ..image() }, + Image { clip: vec![Vec2::new(f64::INFINITY, 0.0), Vec2::new(1.0, f64::NAN)], clipping: true, ..image() }, + ] { + let _ = quarters(&drawing(i, true)); + } +} + +#[test] +fn a_one_pixel_image_at_its_own_size_does_not_panic() { + // One pixel drawn one screen pixel wide, at whole and at half pixel positions, with and + // without smoothing (tiny-skia asserts on narrow anti-aliased rectangles). + for x in [0.0, 0.25, 0.5] { + let mut d = Drawing::new_imperial(); + let i = Image { + insert: Vec3::new(x, x, 0.0), + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(1.0, 1.0), + path: "dot.png".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + d.rasters.insert("dot.png", Arc::new(Raster::new(1, 1, RED.to_vec()).unwrap())); + let list = build(&d, &Space::Model, &Options::default()); + for antialias in [true, false] { + let view = View { center: Vec2::new(8.0, 8.0), scale: 1.0, width: 16, height: 16 }; + assert!(render(&list, &view, &RasterOptions { antialias, ..Default::default() }).is_some()); + } + } +} + +/// The colour of one pixel of the 100 x 100 picture of the 10 x 10 square. +fn pixel_at(d: &Drawing, opts: &Options, x: u32, y: u32) -> [u8; 4] { + let list = build(d, &Space::Model, opts); + let view = View { center: Vec2::new(5.0, 5.0), scale: 10.0, width: 100, height: 100 }; + let c = render(&list, &view, &RasterOptions { antialias: false, ..Default::default() }).unwrap().pixel(x, y).unwrap(); + [c.red(), c.green(), c.blue(), c.alpha()] +} + +#[track_caller] +fn near(got: [u8; 4], want: [u8; 4]) { + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 6), "{got:?} is not {want:?}"); +} + +#[test] +fn the_image_primitive_belongs_to_its_entity_and_widens_the_bounds() { + let mut d = Drawing::new_imperial(); + let i = Image { insert: Vec3::new(100.0, 200.0, 0.0), ..image() }; + let h = d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + d.rasters.insert("map.png", pixels()); + let list = build(&d, &Space::Model, &Options::default()); + let p = list.prims.iter().find(|p| matches!(p.kind, Kind::Image { .. })).unwrap(); + assert_eq!(p.handle, h, "picking and highlighting find the entity"); + assert_eq!((list.bounds.min, list.bounds.max), (Vec2::new(100.0, 200.0), Vec2::new(110.0, 210.0))); + let img = &list.images[0]; + assert_eq!((img.origin, img.right, img.down), (Vec2::new(100.0, 210.0), Vec2::new(5.0, 0.0), Vec2::new(0.0, -5.0))); + assert_eq!(img.at(2.0, 2.0), Vec2::new(110.0, 200.0)); +} + +#[test] +fn draw_order_is_kept_in_pictures() { + // A white line across the top half (y = 7.5, picture row 25). + let line = || EntityKind::Line(Line { a: Vec3::new(0.0, 7.5, 0.0), b: Vec3::new(10.0, 7.5, 0.0) }); + let build_with = |image_last: bool| { + let mut d = Drawing::new_imperial(); + let order = if image_last { [line(), EntityKind::Image(image())] } else { [EntityKind::Image(image()), line()] }; + for k in order { + d.add(&Space::Model, Common::default(), k).unwrap(); + } + d.rasters.insert("map.png", pixels()); + d + }; + near(pixel_at(&build_with(true), &Options::default(), 25, 25), RED); + near(pixel_at(&build_with(false), &Options::default(), 25, 25), WHITE); +} + +#[test] +fn an_image_in_a_block_follows_the_block_reference() { + let mut d = Drawing::new_imperial(); + let mut blk = Block::new("MAP"); + // A 2 x 2 unit image in the block. + let small = Image { u: Vec3::new(1.0, 0.0, 0.0), v: Vec3::new(0.0, 1.0, 0.0), ..image() }; + blk.entities.push(Entity::new(Handle(900), EntityKind::Image(small))); + d.blocks.insert("MAP".into(), Arc::new(blk)); + // Inserted five times larger at the origin: the same 10 x 10 square. + let ins = Insert { + block: "MAP".into(), + insert: Vec3::ZERO, + scale: Vec3::new(5.0, 5.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + clip: None, + }; + let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins.clone())).unwrap(); + d.rasters.insert("map.png", pixels()); + shows(quarters(&d), [RED, GREEN, BLUE, WHITE]); + let list = build(&d, &Space::Model, &Options::default()); + assert_eq!(list.prims.iter().find(|p| matches!(p.kind, Kind::Image { .. })).map(|p| p.handle), Some(h), "selected with its block reference"); + + // A quarter turn about the origin, moved back over the square. + let mut d2 = d.clone(); + let turned = Insert { insert: Vec3::new(10.0, 0.0, 0.0), rotation: std::f64::consts::FRAC_PI_2, ..ins }; + d2.model = Default::default(); + d2.add(&Space::Model, Common::default(), EntityKind::Insert(turned)).unwrap(); + shows(quarters(&d2), [GREEN, WHITE, RED, BLUE]); +} + +#[test] +fn a_layout_viewport_cuts_the_image_to_its_outline() { + let mut d = Drawing::new_metric(); + d.add( + &Space::Model, + Common::default(), + EntityKind::Image(Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(1.5, 0.5)], clipping: true, ..image() }), + ) + .unwrap(); + d.rasters.insert("map.png", pixels()); + // 90 x 100 paper units at 10:1, looking at the middle of the image: model x 0.5..9.5. + let vp = Viewport { + center: Vec3::new(60.0, 60.0, 0.0), + width: 90.0, + height: 100.0, + view_center: Vec2::new(5.0, 5.0), + view_height: 10.0, + id: 2, + locked: false, + frozen_layers: Vec::new(), + layer_colors: Vec::new(), + clip: None, + on: true, + twist: 0.0, + }; + d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap(); + let list = build(&d, &Space::Paper("Layout1".into()), &Options::default()); + assert_eq!(list.images.len(), 1); + let img = &list.images[0]; + // In paper units: ten times larger, the image's top-left corner at (10, 110). + assert_eq!((img.origin, img.right, img.down), (Vec2::new(10.0, 110.0), Vec2::new(50.0, 0.0), Vec2::new(0.0, -50.0))); + assert_eq!(img.clips.len(), 2, "its own boundary, then the viewport"); + assert_eq!(img.clips[0], [Vec2::new(10.0, 110.0), Vec2::new(110.0, 110.0), Vec2::new(110.0, 60.0), Vec2::new(10.0, 60.0)], "the top pixel row"); + assert_eq!(img.clips[1], [Vec2::new(15.0, 10.0), Vec2::new(105.0, 10.0), Vec2::new(105.0, 110.0), Vec2::new(15.0, 110.0)]); + let p = list.prims.iter().find(|p| matches!(p.kind, Kind::Image { index: 0 })).unwrap(); + assert_eq!(list.points(p).len(), 4); + // The image (10..110 across) is wider than the viewport (15..105): only what shows counts + // towards the sheet's bounds, which a plot fits to. + assert!(list.bounds.min.x >= 15.0 - 1e-9 && list.bounds.max.x <= 105.0 + 1e-9, "{:?}", list.bounds); + + // Drawn: inside the viewport and the image's top row there is the picture; outside the + // viewport, or in the clipped-away bottom row, there is none. + let view = View { center: Vec2::new(60.0, 60.0), scale: 1.0, width: 120, height: 120 }; + let pm = render(&list, &view, &RasterOptions { antialias: false, ..Default::default() }).unwrap(); + let px = |x: u32, y: u32| { + let c = pm.pixel(x, y).unwrap(); + [c.red(), c.green(), c.blue(), c.alpha()] + }; + // (Sampled at pixel centres: an enlarged picture is smoothed between them.) + near(px(35, 35), RED); + near(px(85, 35), GREEN); + near(px(12, 35), bg()); + near(px(107, 35), bg()); + near(px(35, 85), bg()); + near(px(85, 85), bg()); + + // A viewport looking elsewhere shows nothing of it. + let mut away = d.clone(); + let Some(l) = away.layouts.iter_mut().find(|l| l.name == "Layout1") else { panic!("no Layout1") }; + l.entities = Default::default(); + let far = Viewport { + view_center: Vec2::new(500.0, 500.0), + ..match &d.layouts.iter().find(|l| l.name == "Layout1").unwrap().entities.iter().last().unwrap().kind { + EntityKind::Viewport(v) => v.clone(), + _ => panic!("not a viewport"), + } + }; + away.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(far)).unwrap(); + assert!(build(&away, &Space::Paper("Layout1".into()), &Options::default()).images.is_empty()); +} + +#[test] +fn object_transparency_and_pixel_alpha_blend_with_what_is_under() { + // Half transparent as an object. + let mut d = Drawing::new_imperial(); + let c = Common { transparency: Transparency::Percent(50), ..Default::default() }; + d.add(&Space::Model, c, EntityKind::Image(image())).unwrap(); + d.rasters.insert("map.png", pixels()); + let half = |c: u8, b: u8| ((u16::from(c) + u16::from(b)) / 2) as u8; + let mixed = [half(255, BG.0), half(0, BG.1), half(0, BG.2), 255]; + near(pixel_at(&d, &Options { transparency: true, ..Options::default() }, 25, 25), mixed); + near(pixel_at(&d, &Options::default(), 25, 25), RED); + + // Half transparent pixels in the file. + let mut d = drawing(image(), false); + d.rasters.insert("map.png", Arc::new(Raster::new(2, 2, [[255, 0, 0, 128], GREEN, BLUE, [0, 0, 0, 0]].concat()).unwrap())); + let q = quarters(&d); + near(q[0], mixed); + near(q[1], GREEN); + near(q[3], bg()); + + // Half faded. + let d = drawing(Image { fade: 50, ..image() }, true); + near(quarters(&d)[0], mixed); +} + +#[test] +fn two_references_share_one_set_of_pixels() { + let mut d = drawing(image(), true); + d.add(&Space::Model, Common::default(), EntityKind::Image(Image { insert: Vec3::new(20.0, 0.0, 0.0), ..image() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + assert_eq!(list.images.len(), 2); + assert!(Arc::ptr_eq(&list.images[0].raster, &list.images[1].raster)); + let kinds: Vec = list.prims.iter().map(|p| p.kind).filter(|k| matches!(k, Kind::Image { .. })).collect(); + assert_eq!(kinds, [Kind::Image { index: 0 }, Kind::Image { index: 1 }]); +} diff --git a/crates/render/tests/viewport_twist.rs b/crates/render/tests/viewport_twist.rs new file mode 100644 index 000000000..555852ccd --- /dev/null +++ b/crates/render/tests/viewport_twist.rs @@ -0,0 +1,66 @@ +//! A layout viewport with a view twist (DVIEW TWist inside it; DXF VIEWPORT group 51) shows model +//! space turned on the sheet, on screen, when plotting and in the canvas's active-viewport list. + +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Space, Viewport}; +use cadcraft_geom::{Bounds2, Vec2, Vec3}; +use cadcraft_render::{DisplayList, Options, VIEWPORT_CONTENT, build, build_plot, viewport_content}; + +fn drawing(twist_deg: f64) -> (Drawing, cadcraft_doc::Handle) { + let mut d = Drawing::new_imperial(); + // A model line along +X, 2 long, from the view centre. + d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.0, 0.0, 0.0) })).unwrap(); + let vp = Viewport { + center: Vec3::new(5.0, 4.0, 0.0), + width: 8.0, + height: 6.0, + view_center: Vec2::ZERO, + view_height: 12.0, + id: 2, + twist: twist_deg.to_radians(), + ..Viewport::default() + }; + let h = d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap(); + (d, h) +} + +/// The bounds of the viewport's contents in a list. +fn contents(l: &DisplayList) -> Bounds2 { + Bounds2::from_points(l.prims.iter().filter(|p| p.handle == VIEWPORT_CONTENT).flat_map(|p| l.points(p).to_vec())) +} + +fn near(a: Vec2, b: Vec2) -> bool { + (a - b).len() < 1e-6 +} + +#[test] +fn a_twisted_viewport_turns_its_contents_on_screen_and_in_plots() { + let space = Space::Paper("Layout1".into()); + // Untwisted: the line runs right from the viewport centre (scale 1/2). + let (d, _) = drawing(0.0); + let b = contents(&build(&d, &space, &Options::default())); + assert!(near(b.min, Vec2::new(5.0, 4.0)) && near(b.max, Vec2::new(6.0, 4.0)), "{b:?}"); + // Turned 90° counterclockwise: it runs up. + let (d, h) = drawing(90.0); + for l in [build(&d, &space, &Options::default()), build_plot(&d, &space, &Options::default())] { + let b = contents(&l); + assert!(near(b.min, Vec2::new(5.0, 4.0)) && near(b.max, Vec2::new(5.0, 5.0)), "{b:?}"); + } + // The active viewport's own list, built for an area around it, agrees. + let area = Bounds2::new(Vec2::new(0.0, 0.0), Vec2::new(10.0, 8.0)); + let l = viewport_content(&d, &space, h, area, &Options::default()); + let b = Bounds2::from_points(l.prims.iter().flat_map(|p| l.points(p).to_vec())); + assert!(near(b.min, Vec2::new(5.0, 4.0)) && near(b.max, Vec2::new(5.0, 5.0)), "{b:?}"); +} + +#[test] +fn model_and_paper_points_map_both_ways_through_a_twist() { + let (d, h) = drawing(30.0); + let Some(EntityKind::Viewport(vp)) = d.entity(h).map(|e| e.kind.clone()) else { panic!() }; + let m = Vec2::new(3.0, -1.5); + let p = vp.model_to_paper(m); + assert!(near(vp.paper_to_model(p), m)); + assert!(near(vp.model_to_paper_matrix().apply(m), p)); + // +X in model space points 30° above the sheet's +X. + let dir = vp.model_to_paper(Vec2::new(1.0, 0.0)) - vp.model_to_paper(Vec2::ZERO); + assert!((dir.y.atan2(dir.x).to_degrees() - 30.0).abs() < 1e-9); +} diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index 012231683..716c27371 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -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, + 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] diff --git a/crates/ui-egui/src/bedit.rs b/crates/ui-egui/src/bedit.rs index fa51656ac..e66430391 100644 --- a/crates/ui-egui/src/bedit.rs +++ b/crates/ui-egui/src/bedit.rs @@ -143,7 +143,7 @@ fn draw_preview(ui: &mut egui::Ui, list: Option<&DisplayList>, note: &str) { let inner = rect.shrink(12.0); let (w, h) = (b.width().max(1e-9), b.height().max(1e-9)); let scale = (f64::from(inner.width()) / w).min(f64::from(inner.height()) / h); - let xf = Xf { rect, center: Vec2::new((b.min.x + b.max.x) / 2.0, (b.min.y + b.max.y) / 2.0), scale }; + let xf = Xf { rect, center: Vec2::new((b.min.x + b.max.x) / 2.0, (b.min.y + b.max.y) / 2.0), scale, twist: 0.0 }; crate::canvas::draw_list(&p, &xf, l, Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()), false); } None => { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 39040c366..5d3bc449d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5,7 +5,7 @@ use cadcraft_color::Rgb; use cadcraft_doc::Handle; use cadcraft_engine::Input; use cadcraft_engine::snap::{self, SnapHit}; -use cadcraft_geom::{Bounds2, Vec2}; +use cadcraft_geom::{Bounds2, Mat3, Vec2}; use cadcraft_render::{DisplayList, Kind}; use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; @@ -26,12 +26,12 @@ mod widgets; use cursor::{draw_crosshair, draw_snap_marker, draw_tracking, effective_point, override_cursor}; use cycle::{CycleList, cycle_badge, cycle_click, cycle_list, over_canvas_menu, shift_space_cycle}; pub(crate) use draw::{Look, draw_highlight, draw_list}; -use draw::{dashed_rect, draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill}; +use draw::{dashed_polygon, dashed_rect, draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill}; use dyn_boxes::dynamic_input; pub(crate) use dyn_boxes::length_units; use grid::draw_grid; use grips::{click_grip, grip_at, grip_menu, grip_options, grip_rect}; -use list::{PrimIndex, ensure_list, rebuilt_preview}; +use list::{OnlyList, PrimIndex, ensure_list, rebuilt_preview}; #[cfg(test)] use list::{preview_options, prims_of}; use pick::{edit_text_at, hatch_at, wheel_zoom_points}; @@ -47,19 +47,45 @@ pub struct Xf { pub center: Vec2, /// Pixels (points) per drawing unit. pub scale: f64, + /// The view twist (radians counterclockwise, `cadcraft_engine::View::twist`): the drawing + /// appears turned by it about `center`. 0 for paper space. + pub twist: f64, } impl Xf { + /// A world offset from `center` in the screen's frame (y up): turned by the twist. + fn turn(&self, v: Vec2) -> Vec2 { + if self.twist == 0.0 { v } else { v.rotate(self.twist) } + } pub fn to_screen(&self, p: Vec2) -> Pos2 { let c = self.rect.center(); - pos2(c.x + ((p.x - self.center.x) * self.scale) as f32, c.y - ((p.y - self.center.y) * self.scale) as f32) + let q = self.turn(p - self.center); + pos2(c.x + (q.x * self.scale) as f32, c.y - (q.y * self.scale) as f32) } pub fn to_world(&self, p: Pos2) -> Vec2 { let c = self.rect.center(); - Vec2::new(self.center.x + f64::from(p.x - c.x) / self.scale, self.center.y - f64::from(p.y - c.y) / self.scale) + self.center + self.screen_delta_to_world(p - c) } + /// The world offset a screen offset `d` (points, y down) covers: a pan moves the view by it. + pub fn screen_delta_to_world(&self, d: egui::Vec2) -> Vec2 { + let q = Vec2::new(f64::from(d.x) / self.scale, -f64::from(d.y) / self.scale); + if self.twist == 0.0 { q } else { q.rotate(-self.twist) } + } + /// The world box holding everything on screen (in a twisted view, the box of the rect's + /// four corners). pub fn world_bounds(&self) -> Bounds2 { - Bounds2::new(self.to_world(self.rect.left_bottom()), self.to_world(self.rect.right_top())) + let r = self.rect; + Bounds2::from_points([r.left_bottom(), r.right_top(), r.left_top(), r.right_bottom()].map(|p| self.to_world(p))) + } + /// The GPU transform ([`crate::gpu::CanvasCallback::xform`]) that draws vertex data built at + /// `origin`, moved by `m` (a drag; [`Mat3::IDENTITY`] for the drawing), in this view: the + /// shader takes `L·p + e + (origin − center)` to the screen frame, which must be + /// `R(m(origin + p) − center)` with `R` the twist, so `L = R·m` and + /// `e = R(m(origin) − center) − (origin − center)`. + pub(crate) fn gpu_xform(&self, origin: Vec2, m: Mat3) -> [f32; 6] { + let (a, b) = (self.turn(Vec2::new(m.a, m.b)), self.turn(Vec2::new(m.c, m.d))); + let e = self.turn(m.apply(origin) - self.center) - (origin - self.center); + [a.x as f32, a.y as f32, b.x as f32, b.y as f32, e.x as f32, e.y as f32] } } @@ -109,6 +135,8 @@ pub struct CanvasState { vp_hover: VpHighlight, /// Model space seen through the active MSPACE viewport (see [`VpContent`]). vp: VpContent, + /// What a running command shows in place of the drawing (see [`OnlyList`]). + only: OnlyList, /// Indices of infinite-line primitives in `list` (drawn on the CPU in GPU mode). infinite: Vec, /// Milliseconds spent converting the display list to vertex data (last rebuild). @@ -277,8 +305,9 @@ fn handle_scroll_zoom(app: &mut CadApp, ui: &mut egui::Ui, nav: &Navigation<'_>, { let view = st.view(); st.set_view_quiet(cadcraft_engine::View { - center: view.center - Vec2::new(f64::from(p.scroll.x) / nav.xf.scale, -f64::from(p.scroll.y) / nav.xf.scale), + center: view.center - nav.xf.screen_delta_to_world(p.scroll), height: view.height, + twist: view.twist, }); } let opts = ui.ctx().options(|o| o.input_options); @@ -315,8 +344,9 @@ fn handle_button_navigation(app: &mut CadApp, nav: &Navigation<'_>, inside: bool if let Ok(st) = app.session.state_mut() { let view = st.view(); st.set_view_quiet(cadcraft_engine::View { - center: view.center - Vec2::new(f64::from(delta.x) / nav.xf.scale, -f64::from(delta.y) / nav.xf.scale), + center: view.center - nav.xf.screen_delta_to_world(delta), height: view.height, + twist: view.twist, }); } } @@ -362,16 +392,35 @@ fn paint_content_highlights(frame: &mut CanvasPaint<'_>, bg: Rgb, sel: &[Handle] // The active viewport's contents, drawn apart from the layout's list, under its own objects. // `masked`: its outline is in the stencil buffer this frame. let mut masked = false; - if let Some(f) = ensure_vp_content(app, &xf_paper, ui.ctx()) { - let gpu = app.canvas.gpu.is_some(); - let clipped = painter.with_clip_rect(f.on_screen.intersect(rect)); - draw_vp_content(&mut app.canvas.vp, gpu, &clipped, &f, &xf_paper, bg, app.session.settings.lwdisplay); - masked = gpu && f.outline.is_some(); - } - if app.canvas.gpu.is_some() { - draw_list_gpu(&mut app.canvas, painter, &xf_paper, bg, app.session.settings.lwdisplay); - } else if let Some(list) = &app.canvas.list { - draw_list(painter, &xf_paper, list, bg, app.session.settings.lwdisplay); + if let Some(only) = app.session.display_only() { + // A command that shows only some objects while it runs (DVIEW's preview set): those, + // in the view being changed (inside the active viewport, clipped to it). + let clip = active_vp.as_ref().map_or(rect, |vp| { + Rect::from_two_pos( + xf_paper.to_screen(Vec2::new(vp.center.x - vp.width / 2.0, vp.center.y + vp.height / 2.0)), + xf_paper.to_screen(Vec2::new(vp.center.x + vp.width / 2.0, vp.center.y - vp.height / 2.0)), + ) + .intersect(rect) + }); + if let Ok(st) = app.session.state() { + // A clipped viewport's outline, in the model units the preview is built in. + let outline = + active_vp.as_ref().zip(active_clip.as_ref()).map(|(vp, c)| c.polygon().iter().map(|q| vp.paper_to_model(*q)).collect::>()); + app.canvas.only.update(st, &only, scale, outline.as_deref()); + } + draw_list(&painter.with_clip_rect(clip), &xf, &app.canvas.only.list, bg, app.session.settings.lwdisplay); + } else { + if let Some(f) = ensure_vp_content(app, &xf_paper, ui.ctx()) { + let gpu = app.canvas.gpu.is_some(); + let clipped = painter.with_clip_rect(f.on_screen.intersect(rect)); + draw_vp_content(&mut app.canvas.vp, gpu, &clipped, &f, &xf_paper, bg, app.session.settings.lwdisplay); + masked = gpu && f.outline.is_some(); + } + if app.canvas.gpu.is_some() { + draw_list_gpu(&mut app.canvas, painter, &xf_paper, bg, app.session.settings.lwdisplay); + } else if let Some(list) = &app.canvas.list { + draw_list(painter, &xf_paper, list, bg, app.session.settings.lwdisplay); + } } if let Some(vp) = &active_vp { // The active viewport: a heavy border, highlights in model units clipped to it. @@ -744,7 +793,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let Ok(st) = app.session.state() else { return }; let view = st.view(); let scale = f64::from(rect.height()) / view.height.max(1e-12); - let xf = Xf { rect, center: view.center, scale }; + let xf = Xf { rect, center: view.center, scale, twist: view.twist }; app.canvas.xf = Some(xf); // ---------- input ---------- @@ -764,13 +813,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // Re-read the view after navigation. let view = app.session.state().map(|s| s.view()).unwrap_or(view); let scale = f64::from(rect.height()) / view.height.max(1e-12); - let xf = Xf { rect, center: view.center, scale }; + let xf = Xf { rect, center: view.center, scale, twist: view.twist }; app.canvas.xf = Some(xf); // The paper (display) transform; equal to `xf` except inside an MSPACE viewport. let (xf_paper, active_vp) = match app.session.state() { Ok(st) => { let pv = st.paper_view(); - let xp = Xf { rect, center: pv.center, scale: f64::from(rect.height()) / pv.height.max(1e-12) }; + // Paper space is never twisted. + let xp = Xf { rect, center: pv.center, scale: f64::from(rect.height()) / pv.height.max(1e-12), twist: 0.0 }; (if st.mspace.is_some() { xp } else { xf }, st.active_viewport().map(|(_, v)| v)) } Err(_) => (xf, None), @@ -1102,18 +1152,19 @@ fn paint_canvas(frame: CanvasPaint<'_>) { } // Pending selection window. if let (Some(pw), Some(c)) = (app.session.pending_window, raw_world) { - let a = xf.to_screen(pw.corner); - let b = xf.to_screen(c); - let r = Rect::from_two_pos(a, b); - let crossing = c.x < pw.corner.x; + // The world box it selects (turned in a twisted view). + let corners = [pw.corner, Vec2::new(c.x, pw.corner.y), c, Vec2::new(pw.corner.x, c.y)].map(|q| xf.to_screen(q)); + let crossing = app.session.window_is_crossing(pw.corner, c); if let Some(fill) = selection_area_fill(&app.session.settings, crossing) { - painter.rect_filled(r, 0.0, fill); + painter.add(Shape::convex_polygon(corners.to_vec(), fill, Stroke::NONE)); } let st = Stroke::new(1.0, if crossing { t.crossing_stroke } else { t.window_stroke }); if crossing { - painter.extend(dashed_rect(painter.clip_rect(), r, st, 5.0, 3.0)); + painter.extend(dashed_polygon(painter.clip_rect(), &corners, st, 5.0, 3.0)); } else { - painter.rect_stroke(r, 0.0, st, egui::StrokeKind::Middle); + let mut pts = corners.to_vec(); + pts.push(corners[0]); + painter.add(Shape::line(pts, st)); } } paint_pointer_overlay( @@ -1152,4 +1203,5 @@ mod blockedit; mod compare; /// Background rebuilds of the display list for a new view. +mod images; mod rebuild; diff --git a/crates/ui-egui/src/canvas/draw.rs b/crates/ui-egui/src/canvas/draw.rs index 146466b24..34316374a 100644 --- a/crates/ui-egui/src/canvas/draw.rs +++ b/crates/ui-egui/src/canvas/draw.rs @@ -1,5 +1,5 @@ use super::list::preview_options; -use super::{Bounds2, CanvasState, Color32, DisplayList, Handle, Kind, ListKey, Pos2, Rect, Rgb, Shape, Stroke, Vec2, Xf, vec2}; +use super::{Bounds2, CanvasState, Color32, DisplayList, Handle, Kind, ListKey, Mat3, Pos2, Rect, Rgb, Shape, Stroke, Vec2, Xf, vec2}; fn color32(c: Rgb) -> Color32 { Color32::from_rgb(c.0, c.1, c.2) @@ -62,12 +62,17 @@ fn dashes(run: &[Pos2], along: f64, stroke: Stroke, dash: f32, gap: f32, out: &m /// show (#2299). Each edge keeps its direction and its dashes start where they would on the whole /// edge, so they don't crawl as the rectangle or the view moves. pub(super) fn dashed_rect(clip: Rect, r: Rect, stroke: Stroke, dash: f32, gap: f32) -> Vec { + dashed_polygon(clip, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], stroke, dash, gap) +} + +/// [`dashed_rect`] for any closed outline (a selection window turned in a twisted view): each +/// edge from one corner to the next, the last back to the first. +pub(super) fn dashed_polygon(clip: Rect, corners: &[Pos2], stroke: Stroke, dash: f32, gap: f32) -> Vec { let v = |p: Pos2| Vec2::new(f64::from(p.x), f64::from(p.y)); let c = clip.expand(CLIP_MARGIN); let c = Bounds2::new(v(c.min), v(c.max)); let mut out = Vec::new(); - let edges = - [(r.left_top(), r.right_top()), (r.right_top(), r.right_bottom()), (r.right_bottom(), r.left_bottom()), (r.left_bottom(), r.left_top())]; + let edges = corners.iter().copied().zip(corners.iter().copied().cycle().skip(1)); for (a, b) in edges { if let Some((p, q)) = cadcraft_render::clip::clip_segment(v(a), v(b), &c) { let pos = |w: Vec2| Pos2::new(w.x as f32, w.y as f32); @@ -80,6 +85,7 @@ pub(super) fn dashed_rect(clip: Rect, r: Rect, stroke: Stroke, dash: f32, gap: f /// Draw a display list. pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: bool) { let vis = xf.world_bounds(); + super::images::draw(p, xf, list); let mut shapes: Vec = Vec::with_capacity(list.prims.len()); let px = 1.0 / xf.scale; for prim in &list.prims { @@ -137,6 +143,8 @@ pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, shapes.push(Shape::line_segment(seg, Stroke::new(1.0, col))); } } + // Drawn first, under the lines (`images::draw`). + Kind::Image { .. } => {} } } p.extend(shapes); @@ -188,7 +196,7 @@ pub(super) fn draw_highlight_gpu( scale: xf.scale as f32, center: [center.x, center.y], layer: crate::gpu::LAYER_HIGHLIGHT, - xform: crate::gpu::IDENTITY_XFORM, + xform: xf.gpu_xform(*hl_origin, Mat3::IDENTITY), dash: if look.dashed { 10.0 } else { 0.0 }, stencil, }, @@ -240,10 +248,8 @@ pub(super) fn draw_drag_gpu( c.drag_key = Some(key); c.drag_origin = origin; } - // Positions are relative to the origin o: M(o + p) = L·p + (M(o) − o). - let m = drag.matrix; + // Positions are relative to the origin o: M(o + p) = L·p + (M(o) − o), turned with the view. let o = c.drag_origin; - let t = m.apply(o) - o; let center = xf.rect.center(); p.add(egui_wgpu::Callback::new_paint_callback( xf.rect, @@ -254,7 +260,7 @@ pub(super) fn draw_drag_gpu( scale: xf.scale as f32, center: [center.x, center.y], layer: crate::gpu::LAYER_DRAG, - xform: [m.a as f32, m.b as f32, m.c as f32, m.d as f32, t.x as f32, t.y as f32], + xform: xf.gpu_xform(o, drag.matrix), dash: 0.0, stencil: crate::gpu::Stencil::Off, }, @@ -286,6 +292,7 @@ pub(super) fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: c.mesh_origin = origin; c.mesh_ms = crate::now_ms() - t0; } + super::images::draw(p, xf, list); let center = xf.rect.center(); p.add(egui_wgpu::Callback::new_paint_callback( xf.rect, @@ -296,7 +303,7 @@ pub(super) fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: scale: xf.scale as f32, center: [center.x, center.y], layer: crate::gpu::LAYER_DRAWING, - xform: crate::gpu::IDENTITY_XFORM, + xform: xf.gpu_xform(c.mesh_origin, Mat3::IDENTITY), dash: 0.0, stencil: crate::gpu::Stencil::Off, }, @@ -381,6 +388,7 @@ pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, pri line(&outline, &mut shapes); } } + Kind::Image { .. } => line(&pts.iter().chain(pts.first()).copied().collect::>(), &mut shapes), } } if !fill.is_empty() { @@ -407,7 +415,7 @@ mod highlight_stability_tests { let rect = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0)); let input = egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(pointer)], ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { - draw_highlight(ui.painter(), &Xf { rect, center: Vec2::ZERO, scale: 11.0 }, list, &[0], Color32::LIGHT_BLUE, look); + draw_highlight(ui.painter(), &Xf { rect, center: Vec2::ZERO, scale: 11.0, twist: 0.0 }, list, &[0], Color32::LIGHT_BLUE, look); }); let shapes = std::mem::take(&mut output.shapes); output.textures_delta.clear(); @@ -464,7 +472,7 @@ mod highlight_stability_tests { let dir = std::path::PathBuf::from(dir); std::fs::create_dir_all(&dir).unwrap(); let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 400.0)).build_ui(|ui| { - let xf = Xf { rect: ui.max_rect(), center: Vec2::new(3.5, 1.5), scale: 31.0 }; + let xf = Xf { rect: ui.max_rect(), center: Vec2::new(3.5, 1.5), scale: 31.0, twist: 0.0 }; draw_highlight(ui.painter(), &xf, &list, &[0], Color32::LIGHT_BLUE, Look { width: 2.0, dashed: true }); }); h.run_steps(3); @@ -534,7 +542,7 @@ mod zoomed_highlight_tests { let list = line(Vec2::new(-1000.0, 0.0), Vec2::new(1000.0, 0.0)); // The smaller zoom first, so a regression fails here instead of filling memory at 1e9. for scale in [1e3, 1e9] { - let n = dashes_of(&list, &Xf { rect: SCREEN, center: Vec2::new(0.3, 0.0), scale }).len(); + let n = dashes_of(&list, &Xf { rect: SCREEN, center: Vec2::new(0.3, 0.0), scale, twist: 0.0 }).len(); assert!((50..=70).contains(&n), "scale {scale}: {n} dashes for 600 points of line"); } } @@ -545,7 +553,7 @@ mod zoomed_highlight_tests { fn clipped_dashes_stay_where_the_whole_line_puts_them() { let list = line(Vec2::new(0.0, 0.0), Vec2::new(1000.0, 0.0)); for x in [500.3, 512.9, 731.0] { - let xf = Xf { rect: SCREEN, center: Vec2::new(x, 0.0), scale: 1.0 }; + let xf = Xf { rect: SCREEN, center: Vec2::new(x, 0.0), scale: 1.0, twist: 0.0 }; let whole = Shape::dashed_line( &[xf.to_screen(Vec2::ZERO), xf.to_screen(Vec2::new(1000.0, 0.0))], Stroke::new(1.0, Color32::LIGHT_BLUE), diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index a07f4e092..7664b9ffb 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -1,4 +1,4 @@ -use super::{Bounds2, CadApp, Shape, Stroke, Tokens, Vec2, Xf, pos2}; +use super::{Bounds2, CadApp, Shape, Stroke, Tokens, Vec2, Xf}; /// Grid spacing adapted to the zoom: multiply the unit until lines are at least `min_px` apart. fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 { @@ -36,21 +36,20 @@ pub(super) fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { let major = minor * major_n; let mut lines = Vec::new(); if let Some(((x0, x1), (y0, y1))) = grid_index_ranges(&wb, minor) { + // World-aligned lines across the visible box (the painter clips them to the rect). for i in x0..=x1 { let x = i as f64 * minor; let is_major = (x / major).round() * major - x == 0.0 || ((x / major) - (x / major).round()).abs() < 1e-6; - let sx = xf.to_screen(Vec2::new(x, 0.0)).x; lines.push(Shape::line_segment( - [pos2(sx, xf.rect.top()), pos2(sx, xf.rect.bottom())], + [xf.to_screen(Vec2::new(x, wb.min.y)), xf.to_screen(Vec2::new(x, wb.max.y))], Stroke::new(1.0, if is_major { t.grid_major } else { t.grid_minor }), )); } for j in y0..=y1 { let y = j as f64 * minor; let is_major = ((y / major) - (y / major).round()).abs() < 1e-6; - let sy = xf.to_screen(Vec2::new(0.0, y)).y; lines.push(Shape::line_segment( - [pos2(xf.rect.left(), sy), pos2(xf.rect.right(), sy)], + [xf.to_screen(Vec2::new(wb.min.x, y)), xf.to_screen(Vec2::new(wb.max.x, y))], Stroke::new(1.0, if is_major { t.grid_major } else { t.grid_minor }), )); } @@ -58,12 +57,11 @@ pub(super) fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { p.extend(lines); } // Axes through the origin. - let o = xf.to_screen(Vec2::ZERO); - if xf.rect.y_range().contains(o.y) { - p.line_segment([pos2(xf.rect.left(), o.y), pos2(xf.rect.right(), o.y)], Stroke::new(1.0, t.axis_x)); + if (wb.min.y..=wb.max.y).contains(&0.0) { + p.line_segment([xf.to_screen(Vec2::new(wb.min.x, 0.0)), xf.to_screen(Vec2::new(wb.max.x, 0.0))], Stroke::new(1.0, t.axis_x)); } - if xf.rect.x_range().contains(o.x) { - p.line_segment([pos2(o.x, xf.rect.top()), pos2(o.x, xf.rect.bottom())], Stroke::new(1.0, t.axis_y)); + if (wb.min.x..=wb.max.x).contains(&0.0) { + p.line_segment([xf.to_screen(Vec2::new(0.0, wb.min.y)), xf.to_screen(Vec2::new(0.0, wb.max.y))], Stroke::new(1.0, t.axis_y)); } } diff --git a/crates/ui-egui/src/canvas/images.rs b/crates/ui-egui/src/canvas/images.rs new file mode 100644 index 000000000..9cc61e981 --- /dev/null +++ b/crates/ui-egui/src/canvas/images.rs @@ -0,0 +1,565 @@ +//! Raster images on the canvas. Each image is a pyramid of levels, each half the size of the one +//! before, cut into tiles that are uploaded as egui textures when first seen. The level drawn is +//! the smallest that still has a pixel for every screen pixel, so a large scan stays sharp zoomed +//! in, doesn't shimmer zoomed out, and only what is on screen takes texture memory. Images are +//! drawn as meshes under the drawing's lines (the drawing itself goes through the GPU callback, +//! so images are not yet interleaved with it in draw order). + +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::sync::{Arc, Mutex, Weak}; + +use cadcraft_doc::Raster; +use cadcraft_geom::{Bounds2, Vec2}; +use cadcraft_render::{DImage, DisplayList, Kind}; +use egui::{Color32, Mesh, Rect, Shape, TextureHandle, TextureOptions, epaint::Vertex, pos2}; + +use super::Xf; + +/// The longest tile side uploaded, whatever the device allows. +const MAX_TILE_SIDE: usize = 2048; +/// Above this many uploaded texels per image, tiles not drawn this frame are let go. +const MAX_UPLOADED_TEXELS: usize = 64_000_000; +/// The pyramid stops once a level's longer side is at most this. +const SMALLEST_LEVEL: usize = 256; + +/// One level of an image's pyramid. +struct Level { + width: usize, + height: usize, + /// Its pixels; empty for the first level, which is the raster's own. + rgba: Vec, + /// Tiles uploaded so far, by column and row. + tiles: HashMap<(usize, usize), TextureHandle>, +} + +/// The pyramid of one image; it goes when its pixels do. +struct Pyramid { + pixels: Weak, + levels: Vec, +} + +/// Pyramids by the address of the pixels they were made from, kept between frames. Behind a +/// mutex of its own: uploading a texture takes the context's lock, which `Context::data_mut` holds. +#[derive(Clone, Default)] +struct Textures(Arc>>); + +/// `rgba` (`width` x `height`) at half the size, each pixel the average of up to four. +fn halved(rgba: &[u8], width: usize, height: usize) -> Option<(Vec, usize, usize)> { + let (w, h) = (width.div_ceil(2), height.div_ceil(2)); + let mut out = Vec::with_capacity(w.checked_mul(h)?.checked_mul(4)?); + let pixel = |x: usize, y: usize| rgba.get(y.checked_mul(width)?.checked_add(x)?.checked_mul(4)?..)?.first_chunk::<4>(); + for y in 0..h { + let (y0, y1) = (y * 2, (y * 2 + 1).min(height - 1)); + for x in 0..w { + let (x0, x1) = (x * 2, (x * 2 + 1).min(width - 1)); + let (a, b, c, d) = (pixel(x0, y0)?, pixel(x1, y0)?, pixel(x0, y1)?, pixel(x1, y1)?); + for i in 0..4 { + let sum = u16::from(a[i]) + u16::from(b[i]) + u16::from(c[i]) + u16::from(d[i]); + out.push(((sum + 2) / 4) as u8); + } + } + } + Some((out, w, h)) +} + +/// The level to draw at `px_per_unit` device pixels per world unit: the smallest with at least one +/// pixel per device pixel. Level `n` is `2^n` raster pixels to its pixel. +fn level_for(img: &DImage, px_per_unit: f64) -> usize { + let per_device_pixel = 1.0 / (img.right.len().min(img.down.len()) * px_per_unit); + if !(per_device_pixel.is_finite() && per_device_pixel > 1.0) { + return 0; + } + per_device_pixel.log2().floor() as usize +} + +impl Pyramid { + fn new(raster: &Arc) -> Option { + let (width, height) = (usize::try_from(raster.width()).ok()?, usize::try_from(raster.height()).ok()?); + Some(Pyramid { pixels: Arc::downgrade(raster), levels: vec![Level { width, height, rgba: Vec::new(), tiles: HashMap::new() }] }) + } + + /// Make the levels up to `wanted` (fewer for a small image); the index of the last one to use. + fn reach(&mut self, raster: &Raster, wanted: usize) -> usize { + while self.levels.len() <= wanted { + let Some(last) = self.levels.last() else { return 0 }; + if last.width.max(last.height) <= SMALLEST_LEVEL { + break; + } + let rgba = if last.rgba.is_empty() { raster.rgba() } else { &last.rgba }; + let Some((rgba, width, height)) = halved(rgba, last.width, last.height) else { break }; + self.levels.push(Level { width, height, rgba, tiles: HashMap::new() }); + } + wanted.min(self.levels.len().saturating_sub(1)) + } + + fn texels(&self) -> usize { + let tile = |t: &TextureHandle| t.size()[0].saturating_mul(t.size()[1]); + self.levels.iter().flat_map(|l| l.tiles.values()).map(tile).sum() + } +} + +/// The texture of tile (`col`, `row`), `side` pixels on a side at most, of level `l`. +fn tile(ctx: &egui::Context, l: &mut Level, raster: &Raster, side: usize, col: usize, row: usize) -> Option { + if let Some(t) = l.tiles.get(&(col, row)) { + return Some(t.clone()); + } + let rgba = if l.rgba.is_empty() { raster.rgba() } else { &l.rgba }; + let (x, y) = (col.checked_mul(side)?, row.checked_mul(side)?); + let (w, h) = (side.min(l.width.checked_sub(x)?), side.min(l.height.checked_sub(y)?)); + let mut bytes = Vec::with_capacity(w.checked_mul(h)?.checked_mul(4)?); + for r in y..y + h { + let start = r.checked_mul(l.width)?.checked_add(x)?.checked_mul(4)?; + bytes.extend_from_slice(rgba.get(start..start.checked_add(w.checked_mul(4)?)?)?); + } + let name = format!("raster-{:x}-{}x{}-{col}-{row}", std::ptr::from_ref(raster) as usize, l.width, l.height); + let image = egui::ColorImage::from_rgba_unmultiplied([w, h], &bytes); + let texture = ctx.load_texture(name, image, TextureOptions::LINEAR); + l.tiles.insert((col, row), texture.clone()); + Some(texture) +} + +/// `poly` cut to the rectangle `min`..`max` (Sutherland-Hodgman; keeps the order of its points). +fn clip_to_rect(poly: &[Vec2], min: Vec2, max: Vec2) -> Vec { + let mut out = poly.to_vec(); + // Inside is `v * sign >= limit`, for each side in turn. + for (x_axis, sign, limit) in [(true, 1.0, min.x), (true, -1.0, -max.x), (false, 1.0, min.y), (false, -1.0, -max.y)] { + let dist = |q: Vec2| (if x_axis { q.x } else { q.y }) * sign - limit; + let input = std::mem::take(&mut out); + let mut prev = input.last().copied(); + for cur in input { + if let Some(p) = prev { + let (dp, dc) = (dist(p), dist(cur)); + if (dp < 0.0) != (dc < 0.0) { + out.push(p + (cur - p) * (dp / (dp - dc))); + } + } + if dist(cur) >= 0.0 { + out.push(cur); + } + prev = Some(cur); + } + } + out +} + +/// `outline` as a rectangle when it is one with sides along the axes. +fn axis_rect(outline: &[Vec2]) -> Option { + if outline.len() != 4 { + return None; + } + let bounds = Bounds2::from_points(outline.iter().copied()); + // Four points are such a rectangle when each lies on a corner of their bounding box. + let close = (bounds.width() + bounds.height()) * 1e-9; + let on_either = |v: f64, low: f64, high: f64| (v - low).abs() <= close || (v - high).abs() <= close; + let on_a_corner = |q: &Vec2| on_either(q.x, bounds.min.x, bounds.max.x) && on_either(q.y, bounds.min.y, bounds.max.y); + outline.iter().all(on_a_corner).then_some(bounds) +} + +/// Draw the images of `list`. +pub(super) fn draw(p: &egui::Painter, xf: &Xf, list: &DisplayList) { + // Pyramids whose pixels are gone (drawing closed, image undone) go, also when the list has + // no images left to draw. + if let Ok(mut pyramids) = textures(p.ctx()).0.lock() { + pyramids.retain(|_, pyramid| pyramid.pixels.strong_count() > 0); + } + if list.images.is_empty() { + return; + } + let vis = xf.world_bounds(); + for prim in &list.prims { + if let Kind::Image { index } = prim.kind + && let Some(img) = list.images.get(index as usize) + && Bounds2::from_points(img.corners()).intersects(&vis) + { + draw_one(p, xf, img, prim.alpha()); + } + } +} + +/// The image tiles uploaded so far, kept between frames. +fn textures(ctx: &egui::Context) -> Textures { + ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("cadcraft-raster-textures")).clone()) +} + +/// Draw one image as textured meshes, one per tile on screen: its rectangle, or the outline it is +/// clipped to. +fn draw_one(p: &egui::Painter, xf: &Xf, img: &DImage, alpha: u8) { + let ctx = p.ctx(); + let raster = &img.raster; + let cut = cut(img, xf, p.clip_rect()); + if !cut.rect.is_positive() { + return; + } + // Where in the raster the triangles are. + let Some(tris) = triangles(img, cut.outline).iter().map(|q| img.pixel_of(*q)).collect::>>() else { return }; + let color = Color32::from_white_alpha((img.opacity(alpha) * 255.0) as u8); + let textures = textures(ctx); + let Ok(mut pyramids) = textures.0.lock() else { return }; + let key = Arc::as_ptr(raster) as usize; + let pyramid = match pyramids.entry(key) { + Entry::Occupied(e) => e.into_mut(), + Entry::Vacant(e) => { + let Some(new) = Pyramid::new(raster) else { return }; + e.insert(new) + } + }; + let index = pyramid.reach(raster, level_for(img, xf.scale * f64::from(ctx.pixels_per_point()))); + let Some(level) = pyramid.levels.get_mut(index) else { return }; + let side = ctx.input(|i| i.max_texture_side).clamp(1, MAX_TILE_SIDE); + // Raster pixels per pixel of this level (not quite 2^index for an odd size). + let (kx, ky) = (f64::from(raster.width()) / level.width as f64, f64::from(raster.height()) / level.height as f64); + let mut drawn = Vec::new(); + for row in 0..level.height.div_ceil(side) { + for col in 0..level.width.div_ceil(side) { + let (tw, th) = (side.min(level.width - col * side), side.min(level.height - row * side)); + let min = Vec2::new((col * side) as f64 * kx, (row * side) as f64 * ky); + let max = Vec2::new(((col * side + tw) as f64) * kx, ((row * side + th) as f64) * ky); + let corners = [img.at(min.x, min.y), img.at(max.x, min.y), img.at(max.x, max.y), img.at(min.x, max.y)]; + let on_screen = Rect::from_points(&corners.map(|q| xf.to_screen(q))); + if !on_screen.intersects(cut.rect) { + continue; + } + let mut mesh = Mesh::default(); + for t in tris.as_chunks::<3>().0 { + let piece = clip_to_rect(t, min, max); + // A fan over the piece, each triangle laid out in turn. + for pair in piece.get(1..).unwrap_or(&[]).windows(2) { + for q in [piece.first(), pair.first(), pair.last()].into_iter().flatten() { + let at = img.at(q.x, q.y); + let uv = + pos2(((q.x - min.x) / (max.x - min.x)).clamp(0.0, 1.0) as f32, ((q.y - min.y) / (max.y - min.y)).clamp(0.0, 1.0) as f32); + mesh.indices.push(mesh.vertices.len() as u32); + mesh.vertices.push(Vertex { pos: xf.to_screen(at), uv, color }); + } + } + } + if mesh.indices.is_empty() { + continue; + } + let Some(texture) = tile(ctx, level, raster, side, col, row) else { continue }; + mesh.texture_id = texture.id(); + p.with_clip_rect(cut.rect).add(Shape::mesh(mesh)); + drawn.push((col, row)); + } + } + if pyramid.texels() > MAX_UPLOADED_TEXELS { + for (i, l) in pyramid.levels.iter_mut().enumerate() { + l.tiles.retain(|at, _| i == index && drawn.contains(at)); + } + } +} + +/// How an image's clip outlines are applied on the canvas, which can clip a painter to a +/// rectangle but has no general clip. +struct Cut<'a> { + /// The screen rectangle the painter is clipped to: the canvas, narrowed by every outline + /// that is a rectangle along the axes (viewports, an upright image's own boundary). + rect: Rect, + /// The first outline of another shape, which the mesh itself is cut to. Further ones are + /// not applied on screen. + outline: Option<&'a [Vec2]>, +} + +fn cut<'a>(img: &'a DImage, xf: &Xf, canvas: Rect) -> Cut<'a> { + let mut cut = Cut { rect: canvas, outline: None }; + for outline in &img.clips { + // In a twisted view a rectangle along the drawing's axes is turned on screen. + match axis_rect(outline).filter(|_| xf.twist == 0.0) { + Some(b) => cut.rect = cut.rect.intersect(Rect::from_two_pos(xf.to_screen(b.min), xf.to_screen(b.max))), + None if cut.outline.is_none() => cut.outline = Some(outline), + None => {} + } + } + cut +} + +/// World points, three per triangle, covering what is drawn of `img`: the area inside +/// `outline`, or the whole rectangle. +fn triangles(img: &DImage, outline: Option<&[Vec2]>) -> Vec { + match outline { + Some(o) => { + let mut t = cadcraft_render::triangulate_evenodd(&[o.to_vec()]); + t.truncate(t.len() - t.len() % 3); + t + } + None => { + let [a, b, c, d] = img.corners(); + vec![a, b, c, a, c, d] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_doc::{Common, Drawing, EntityKind, Image, Space}; + use cadcraft_geom::Vec3; + use cadcraft_render::{Options, build}; + use egui::epaint::ClippedShape; + + fn v(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) + } + + /// `w` x `h` pixels, each with its column in red and its row in green. + fn raster(w: u32, h: u32) -> Arc { + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [x as u8, y as u8, 0, 255])).collect(); + Arc::new(Raster::new(w, h, px).unwrap()) + } + + /// A drawing with a 10 x 10 unit image of 2 x 2 pixels at the origin, changed by `f`. + fn list(f: impl FnOnce(&mut Image)) -> DisplayList { + let mut i = Image { u: Vec3::new(5.0, 0.0, 0.0), v: Vec3::new(0.0, 5.0, 0.0), size: v(2.0, 2.0), path: "a.png".into(), ..Image::default() }; + f(&mut i); + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + d.rasters.insert("a.png", raster(2, 2)); + build(&d, &Space::Model, &Options::default()) + } + + /// A 100 x 100 point canvas showing the 10 x 10 square, and what `draw` painted on it. + fn painted(list: &DisplayList) -> (egui::Context, Vec) { + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(100.0, 100.0)); + let xf = Xf { rect, center: v(5.0, 5.0), scale: 10.0, twist: 0.0 }; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + draw(&painter, &xf, list); + }); + // No renderer takes the uploads in a test. + out.textures_delta.clear(); + let shapes = std::mem::take(&mut out.shapes); + (ctx, shapes) + } + + fn meshes(shapes: &[ClippedShape]) -> Vec<(Rect, &Mesh)> { + shapes.iter().filter_map(|s| if let Shape::Mesh(m) = &s.shape { Some((s.clip_rect, &**m)) } else { None }).collect() + } + + #[test] + fn a_rectangle_along_the_axes_is_recognised() { + assert!(axis_rect(&[v(0.0, 0.0), v(4.0, 0.0), v(4.0, 2.0), v(0.0, 2.0)]).is_some()); + assert!(axis_rect(&[v(4.0, 2.0), v(0.0, 2.0), v(0.0, 0.0), v(4.0, 0.0)]).is_some()); + assert!(axis_rect(&[v(0.0, 0.0), v(4.0, 1.0), v(4.0, 2.0), v(0.0, 2.0)]).is_none(), "a slanted side"); + assert!(axis_rect(&[v(0.0, 0.0), v(4.0, 0.0), v(0.0, 2.0)]).is_none(), "a triangle"); + assert!(axis_rect(&[]).is_none()); + assert!(axis_rect(&[v(f64::NAN, 0.0), v(1.0, 0.0), v(1.0, 1.0), v(0.0, 1.0)]).is_none()); + } + + #[test] + fn each_level_is_half_the_size_and_averages_its_pixels() { + let r = raster(4, 3); + let (half, w, h) = halved(r.rgba(), 4, 3).unwrap(); + assert_eq!((w, h), (2, 2)); + assert_eq!(half.len(), 16); + // Columns 0 and 1 of rows 0 and 1: red 0.5 rounds up, green likewise. + assert_eq!(&half[..4], &[1, 1, 0, 255]); + // The odd last row counts twice in the bottom-right pixel: columns 2 and 3 of row 2. + assert_eq!(&half[12..16], &[3, 2, 0, 255]); + } + + #[test] + fn the_level_follows_the_zoom() { + let img = |px: f64| DImage { raster: raster(1, 1), origin: v(0.0, 0.0), right: v(px, 0.0), down: v(0.0, -px), clips: Vec::new(), fade: 0 }; + // One raster pixel to a screen pixel or larger: all of them. + assert_eq!(level_for(&img(1.0), 1.0), 0); + assert_eq!(level_for(&img(1.0), 5.0), 0); + // Eight raster pixels to a screen pixel. + assert_eq!(level_for(&img(1.0), 0.125), 3); + assert_eq!(level_for(&img(1.0), 0.2), 2); + // Squashed flat: never a number that means anything. + assert_eq!(level_for(&img(0.0), 1.0), 0); + } + + #[test] + fn clipping_keeps_the_points_inside_and_cuts_the_rest() { + let tri = [v(0.0, 0.0), v(10.0, 0.0), v(0.0, 10.0)]; + assert_eq!(clip_to_rect(&tri, v(-1.0, -1.0), v(11.0, 11.0)), tri, "all inside, in order"); + assert!(clip_to_rect(&tri, v(20.0, 20.0), v(30.0, 30.0)).is_empty()); + let cut = clip_to_rect(&tri, v(0.0, 0.0), v(5.0, 10.0)); + assert_eq!(cut.len(), 4); + assert!(cut.iter().all(|q| q.x <= 5.0)); + } + + #[test] + fn an_image_larger_than_a_tile_is_cut_into_tiles_that_meet() { + let side = 4096; + let d = { + let mut d = Drawing::new_imperial(); + let i = Image { + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: v(side as f64, 1.0), + path: "a.png".into(), + ..Image::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap(); + d.rasters.insert("a.png", Arc::new(Raster::new(side, 1, vec![255; side as usize * 4]).unwrap())); + d + }; + let l = build(&d, &Space::Model, &Options::default()); + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(8192.0, 100.0)); + let xf = Xf { rect, center: v(side as f64 / 2.0, 0.5), scale: 2.0, twist: 0.0 }; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), max_texture_side: Some(1 << 14), ..Default::default() }, |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + draw(&painter, &xf, &l); + }); + out.textures_delta.clear(); + let m = meshes(&out.shapes); + assert_eq!(m.len(), 2, "two tiles of {MAX_TILE_SIDE} pixels"); + let edge = |m: &Mesh, right: bool| { + m.vertices.iter().map(|q| q.pos.x).fold(if right { f32::MIN } else { f32::MAX }, if right { f32::max } else { f32::min }) + }; + assert_eq!(edge(m[0].1, true), edge(m[1].1, false), "no gap between them"); + assert_ne!(m[0].1.texture_id, m[1].1.texture_id); + } + + #[test] + fn an_image_is_one_textured_rectangle() { + let l = list(|_| {}); + let (_ctx, shapes) = painted(&l); + let m = meshes(&shapes); + assert_eq!(m.len(), 1); + let (_, mesh) = m[0]; + assert_ne!(mesh.texture_id, egui::TextureId::default()); + assert_eq!(mesh.indices, [0, 1, 2, 3, 4, 5], "two triangles"); + // On screen and in the texture: top left, top right, bottom right, then top left, bottom + // right, bottom left of the picture. + let (tl, tr, br, bl) = (pos2(0.0, 0.0), pos2(100.0, 0.0), pos2(100.0, 100.0), pos2(0.0, 100.0)); + let uv = |p: egui::Pos2| pos2(p.x / 100.0, p.y / 100.0); + let got: Vec<(egui::Pos2, egui::Pos2)> = mesh.vertices.iter().map(|v| (v.pos, v.uv)).collect(); + assert_eq!(got, [tl, tr, br, tl, br, bl].map(|p| (p, uv(p)))); + assert!(mesh.vertices.iter().all(|v| v.color == Color32::WHITE)); + } + + #[test] + fn fade_dims_the_image_and_nothing_is_drawn_when_fully_faded_or_out_of_view() { + let (_c, shapes) = painted(&list(|i| i.fade = 50)); + assert_eq!(meshes(&shapes)[0].1.vertices[0].color, Color32::from_white_alpha(127)); + // Far from the view: skipped. + let (_c, shapes) = painted(&list(|i| i.insert = Vec3::new(1000.0, 1000.0, 0.0))); + assert!(meshes(&shapes).is_empty()); + // No images at all. + let (_c, shapes) = painted(&DisplayList::default()); + assert!(shapes.is_empty()); + } + + #[test] + fn an_upright_clip_boundary_clips_the_painter_and_another_shapes_the_mesh() { + // The left pixel column: a rectangle along the axes. + let (_c, shapes) = painted(&list(|i| { + i.clip = vec![v(-0.5, -0.5), v(0.5, 1.5)]; + i.clipping = true; + })); + let m = meshes(&shapes); + assert_eq!(m.len(), 1); + assert_eq!(m[0].0, Rect::from_min_max(pos2(0.0, 0.0), pos2(50.0, 100.0))); + assert_eq!(m[0].1.vertices.len(), 6, "the whole rectangle, cut by the clip"); + + // A triangle over the top-left half: the mesh is the triangle, with matching texture points. + let (_c, shapes) = painted(&list(|i| { + i.clip = vec![v(-0.5, -0.5), v(1.5, -0.5), v(-0.5, 1.5)]; + i.clipping = true; + })); + let m = meshes(&shapes); + let mesh = m[0].1; + assert!(mesh.vertices.len() >= 3 && mesh.indices.len().is_multiple_of(3) && mesh.indices.len() == mesh.vertices.len()); + for vert in &mesh.vertices { + assert!((vert.pos.x / 100.0 - vert.uv.x).abs() < 1e-4 && (vert.pos.y / 100.0 - vert.uv.y).abs() < 1e-4, "{vert:?}"); + assert!(vert.pos.x + vert.pos.y <= 100.0 + 1e-3, "inside the triangle: {vert:?}"); + } + } + + #[test] + fn a_tile_is_uploaded_once_and_dropped_with_its_pixels() { + let r = raster(4, 4); + let mut l = list(|_| {}); + // The list's pixels are this test's. + l.images[0].raster = r.clone(); + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(100.0, 100.0)); + let xf = Xf { rect, center: v(5.0, 5.0), scale: 10.0, twist: 0.0 }; + let frame = |list: &DisplayList| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + draw(&painter, &xf, list); + }); + let uploads = out.textures_delta.set.len(); + out.textures_delta.clear(); + uploads + }; + assert!(frame(&l) >= 1, "the first frame also uploads the font atlas"); + assert_eq!(frame(&l), 0, "already uploaded"); + // The pixels gone (drawing closed, image undone): the pyramid goes on the next draw. + let count = |ctx: &egui::Context| textures(ctx).0.lock().unwrap().len(); + assert_eq!(count(&ctx), 1); + drop(l); + drop(r); + let next = list(|_| {}); + frame(&next); + assert_eq!(count(&ctx), 1, "only the new image's"); + // A list with no images still lets go of the pictures that are gone. + drop(next); + frame(&DisplayList::default()); + assert_eq!(count(&ctx), 0); + } + + /// A known limit, kept visible here: on screen images are painted first, so they lie under + /// lines drawn before them. (Pictures and plots keep the draw order; see the renderer's + /// `draw_order_is_kept_in_pictures`.) When the canvas learns to interleave them, this test + /// changes with it. + #[test] + fn on_screen_images_lie_under_the_lines_whatever_the_draw_order() { + use cadcraft_doc::Line; + let line = EntityKind::Line(Line { a: Vec3::new(0.0, 5.0, 0.0), b: Vec3::new(10.0, 5.0, 0.0) }); + let image = EntityKind::Image(Image { + u: Vec3::new(5.0, 0.0, 0.0), + v: Vec3::new(0.0, 5.0, 0.0), + size: v(2.0, 2.0), + path: "a.png".into(), + ..Image::default() + }); + for order in [[line.clone(), image.clone()], [image, line]] { + let mut d = Drawing::new_imperial(); + for k in order { + d.add(&Space::Model, Common::default(), k).unwrap(); + } + d.rasters.insert("a.png", raster(2, 2)); + let l = build(&d, &Space::Model, &Options::default()); + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(100.0, 100.0)); + let xf = Xf { rect, center: v(5.0, 5.0), scale: 10.0, twist: 0.0 }; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + crate::canvas::draw_list(&painter, &xf, &l, cadcraft_color::Rgb(0, 0, 0), false); + }); + out.textures_delta.clear(); + let kinds: Vec = out.shapes.iter().map(|s| matches!(s.shape, Shape::Mesh(_))).collect(); + assert!(kinds.len() >= 2, "the image and the lines: {kinds:?}"); + assert_eq!(kinds.iter().position(|m| *m), Some(0), "the image comes first: {kinds:?}"); + assert_eq!(kinds.iter().filter(|m| **m).count(), 1); + } + } + + #[test] + fn degenerate_images_do_not_panic() { + for f in [ + (|i: &mut Image| i.u = Vec3::ZERO) as fn(&mut Image), + |i| i.v = i.u, + |i| i.u = Vec3::new(1e300, 0.0, 0.0), + |i| i.insert = Vec3::new(-1e300, 1e300, 0.0), + |i| { + i.clip = vec![v(0.0, 0.0), v(0.0, 0.0), v(0.0, 0.0)]; + i.clipping = true; + }, + |i| { + i.clip = vec![v(f64::NAN, 0.0), v(1.0, f64::INFINITY), v(-1e300, 1e300)]; + i.clipping = true; + }, + ] { + let _ = painted(&list(f)); + } + } +} diff --git a/crates/ui-egui/src/canvas/list.rs b/crates/ui-egui/src/canvas/list.rs index f965b0ae1..6a3360409 100644 --- a/crates/ui-egui/src/canvas/list.rs +++ b/crates/ui-egui/src/canvas/list.rs @@ -1,5 +1,5 @@ use super::viewport::{split_viewport, stencil_clip}; -use super::{Bounds2, CadApp, DisplayList, Handle, ListKey, Rgb, Tokens, Xf, rebuild, same_content}; +use super::{Bounds2, CadApp, DisplayList, Handle, ListKey, Rgb, Tokens, Vec2, Xf, rebuild, same_content}; /// Primitives of `list` sorted by entity handle, built on the first highlight after each list /// rebuild. Highlighting the hovered entity used to scan every primitive on every frame: ~2.5 ms @@ -163,6 +163,38 @@ pub(super) fn ensure_list(app: &mut CadApp, xf: &Xf, views: &[(Bounds2, f64)], c app.canvas.build_ms = crate::now_ms() - t0; } +/// The display list of what a running command shows in place of the drawing +/// ([`cadcraft_engine::Session::display_only`]: DVIEW's preview set), rebuilt when the drawing, +/// the set or the 2x zoom band changes. +#[derive(Default)] +pub(super) struct OnlyList { + key: Option, + pub(super) list: DisplayList, +} + +impl OnlyList { + /// `outline`: a clipped viewport's outline (model units) the preview is clipped to. + pub(super) fn update(&mut self, st: &cadcraft_engine::DocState, only: &cadcraft_engine::DisplayOnly, scale: f64, outline: Option<&[Vec2]>) { + let band = (1.0 / scale.max(1e-300)).log2().floor() as i32; + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, band, &only.handles, format!("{:?}", only.extra)).hash(&mut h); + outline.into_iter().flatten().for_each(|q| (q.x.to_bits(), q.y.to_bits()).hash(&mut h)); + h.finish() + }; + if self.key != Some(key) { + let ents = only.handles.iter().filter_map(|h| st.doc.entity(*h)).map(|e| e.as_ref()).chain(only.extra.iter()); + let list = rebuilt_preview(&st.doc, ents, 2f64.powi(-band)); + self.list = match outline.and_then(cadcraft_render::clip::PolyClip::new) { + Some(c) => cadcraft_render::clip_list(&list, &c), + None => list, + }; + self.key = Some(key); + } + } +} + /// The area a display list is built for: each view with half its larger side of margin all /// round (panning by up to that much doesn't rebuild), joined. `None` (everything) when a view /// isn't usable. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index c4e0c96ce..db99e70e4 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -21,7 +21,7 @@ fn dragged_objects_keep_their_transparency() { let h = d.add(&cadcraft_doc::Space::Model, common, cadcraft_doc::EntityKind::Line(line)).unwrap(); let drag = cadcraft_engine::Drag { handles: vec![h], matrix: cadcraft_geom::Mat3::IDENTITY, extra: Vec::new(), clipboard: false }; let rect = Rect::from_min_size(Pos2::ZERO, vec2(200.0, 100.0)); - let xf = Xf { rect, center: Vec2::new(5.0, 0.0), scale: 10.0 }; + let xf = Xf { rect, center: Vec2::new(5.0, 0.0), scale: 10.0, twist: 0.0 }; let painter = egui::Painter::new(egui::Context::default(), egui::LayerId::background(), rect); // One canvas across the toggle: the cached drag mesh must be rebuilt. let mut c = CanvasState::default(); diff --git a/crates/ui-egui/src/canvas/viewport.rs b/crates/ui-egui/src/canvas/viewport.rs index 0857e23ee..3e065f810 100644 --- a/crates/ui-egui/src/canvas/viewport.rs +++ b/crates/ui-egui/src/canvas/viewport.rs @@ -1,5 +1,5 @@ use super::draw::draw_list; -use super::{Bounds2, CadApp, DisplayList, Handle, Kind, Rect, Rgb, Vec2, Xf}; +use super::{Bounds2, CadApp, DisplayList, Handle, Kind, Mat3, Rect, Rgb, Vec2, Xf}; /// The active MSPACE viewport when its contents are drawn apart from the layout's list (see /// [`VpContent`]), with its clip outline when it is clipped. A clipped viewport is clipped with @@ -35,6 +35,8 @@ pub(super) struct VpContent { pub(super) key: Option<(u64, u64, usize, Handle, i32, bool, bool)>, /// The viewport's view (centre, height) the list was built for, and the paper area it covers. view: (Vec2, f64), + /// The view twist it was built for: [`vp_motion`] moves and scales a list, it can't turn it. + twist: f64, area: Bounds2, /// The view on the previous frame and when it last changed (ms). last_view: (Vec2, f64), @@ -61,12 +63,12 @@ impl VpContent { } /// How a list built for view `built` (centre, height) of viewport `vp` maps to its current -/// view, as `p = k·p0 + b` on paper. A model point m is at p0 = c + s0 (m − c0) in the list and -/// belongs at p = c + s (m − vc), with c the viewport's centre and s, s0 paper units per model -/// unit, so k = s / s0 and b = c (1 − k) + s (c0 − vc). +/// view, as `p = k·p0 + b` on paper. A model point m is at p0 = c + s0 R(m − c0) in the list and +/// belongs at p = c + s R(m − vc), with c the viewport's centre, R its view twist and s, s0 paper +/// units per model unit, so k = s / s0 and b = c (1 − k) + s R(c0 − vc). fn vp_motion(vp: &cadcraft_doc::Viewport, built: (Vec2, f64)) -> Option<(f64, Vec2)> { let (s, k) = (vp.height / vp.view_height, built.1 / vp.view_height); - let b = vp.center.xy() * (1.0 - k) + (built.0 - vp.view_center) * s; + let b = vp.center.xy() * (1.0 - k) + (built.0 - vp.view_center).rotate(vp.view_twist()) * s; (k.is_finite() && k > 0.0 && b.is_finite()).then_some((k, b)) } @@ -106,7 +108,7 @@ pub(super) fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Con c.last_view = view; c.moved_at = now; } - let rebuild = match vp_motion(&vp, c.view).filter(|_| c.key == Some(key)) { + let rebuild = match vp_motion(&vp, c.view).filter(|_| c.key == Some(key) && c.twist == vp.view_twist()) { Some((k, b)) => { // The viewport in the list's own coordinates. let visible = Bounds2::new((rect.min - b) / k, (rect.max - b) / k); @@ -141,6 +143,7 @@ pub(super) fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Con c.list = cadcraft_render::viewport_content(&st.doc, &st.space, h, area, &opts); c.key = Some(key); c.view = view; + c.twist = vp.view_twist(); c.area = area; c.builds = c.builds.wrapping_add(1); app.canvas.build_ms = crate::now_ms() - t0; @@ -149,7 +152,7 @@ pub(super) fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Con let (k, b) = vp_motion(&vp, c.view)?; c.motion = Some((k, b)); // Paper p = k·p0 + b, so the list is drawn by the paper transform scaled by k about b. - let xf = Xf { rect: xf_paper.rect, center: (xf_paper.center - b) / k, scale: xf_paper.scale * k }; + let xf = Xf { rect: xf_paper.rect, center: (xf_paper.center - b) / k, scale: xf_paper.scale * k, twist: xf_paper.twist }; let on_screen = Rect::from_two_pos(xf_paper.to_screen(Vec2::new(rect.min.x, rect.max.y)), xf_paper.to_screen(Vec2::new(rect.max.x, rect.min.y))); Some(VpFrame { xf, on_screen, outline: clip.map(|p| p.polygon().to_vec()) }) } @@ -163,6 +166,9 @@ pub(super) fn draw_vp_content(c: &mut VpContent, gpu: bool, p: &egui::Painter, f draw_list(p, xf, &c.list, bg, lwdisplay); return; } + // Images first, under the lines, as in `draw_list_gpu` (`p` clips them to the viewport's + // rectangle; a clipped viewport's outline masks the GPU geometry only). + super::images::draw(p, xf, &c.list); let mut stencil = crate::gpu::Stencil::Off; if let Some(outline) = &f.outline { draw_vp_mask(c, p, outline, xf_paper); @@ -193,7 +199,7 @@ pub(super) fn draw_vp_content(c: &mut VpContent, gpu: bool, p: &egui::Painter, f scale: xf.scale as f32, center: [center.x, center.y], layer: crate::gpu::LAYER_VIEWPORT, - xform: crate::gpu::IDENTITY_XFORM, + xform: xf.gpu_xform(c.mesh_origin, Mat3::IDENTITY), dash: 0.0, stencil, }, @@ -248,7 +254,7 @@ fn draw_vp_mask(c: &mut VpContent, p: &egui::Painter, outline: &[Vec2], xf_paper scale: xf_paper.scale as f32, center: [center.x, center.y], layer: crate::gpu::LAYER_MASK, - xform: crate::gpu::IDENTITY_XFORM, + xform: xf_paper.gpu_xform(c.mask_origin, Mat3::IDENTITY), dash: 0.0, stencil: crate::gpu::Stencil::Mask, }, diff --git a/crates/ui-egui/src/canvas/widgets.rs b/crates/ui-egui/src/canvas/widgets.rs index d10e40af3..4e4f37770 100644 --- a/crates/ui-egui/src/canvas/widgets.rs +++ b/crates/ui-egui/src/canvas/widgets.rs @@ -14,7 +14,7 @@ pub(super) fn tile_rects(app: &CadApp, full: Rect) -> Vec<(Rect, cadcraft_engine /// The world ↔ screen transform of a model tile drawn in `r`. pub(super) fn tile_xf(r: Rect, tile: &cadcraft_engine::ModelTile) -> Xf { - Xf { rect: r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) } + Xf { rect: r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12), twist: tile.view.twist } } /// The tiles other than the current one (each with its own view, clipped to it), then the diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index acdddb552..1f8035434 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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), diff --git a/crates/ui-egui/src/fault.rs b/crates/ui-egui/src/fault.rs new file mode 100644 index 000000000..7d087ed23 --- /dev/null +++ b/crates/ui-egui/src/fault.rs @@ -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, + /// 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::().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> = Mutex::new(None); + + /// The report of the latest panic, once one was written. + pub(super) fn last() -> Option { + 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 { + 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 = 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); + } + } +} diff --git a/crates/ui-egui/src/finddlg.rs b/crates/ui-egui/src/finddlg.rs new file mode 100644 index 000000000..7341f3cfa --- /dev/null +++ b/crates/ui-egui/src/finddlg.rs @@ -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, + /// The row Enter and Replace act on. + current: Option, + 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) { + if st.find.trim().is_empty() { + return; + } + let handles: Vec = 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> = None; + let mut do_select_all = false; + let mut pick: Option = 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 = 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::(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 +} diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 30f3b0a85..095c2d192 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -232,6 +232,8 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> } // Drawn on the CPU each frame (clipped to the view). Kind::Infinite { .. } => {} + // Drawn by the canvas as a textured mesh. + Kind::Image { .. } => {} } } m @@ -281,6 +283,13 @@ pub fn build_highlight_mesh(list: &DisplayList, prims: &[usize], origin: Vec2, c } } Kind::Infinite { .. } => {} + Kind::Image { .. } => { + let mut run = 0.0; + for (a, b) in pts.iter().zip(pts.iter().cycle().skip(1)) { + push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), color, width, run as f32); + run += a.dist(*b); + } + } } } m diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 7713378d9..cf5741cdd 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index b349db278..957c48993 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 61dc1b2b6..67a6e90f6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f36e9ed02..0925cb6af 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 528dacf55..3bcf59c0c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 457341c36..f0e6471ee 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62c77c3f9..2f391809e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e486f6337..b87edd3c3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b437397b6..b298e1c98 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -142,6 +142,8 @@ menu Modify Edita Clear Cancella Clip Delimita Clip Viewport Delimita finestra + Clip Xref Ritaglia xrif + Xref Clip Frame Cornice ritaglio xrif Clipboard Appunti Close Chiudi Close All Chiudi tutto @@ -1364,3 +1366,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 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2f269b051..3f01bb695 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 3d8f03449..3d1791c1b 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 619988f45..f1a5124f0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ecfe35b40..d67d39c03 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 24fac6a79..f9f41c798 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -113,6 +113,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { "quick", "vpscale", "options/dialog", + "finddlg", ]; let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut files = Vec::new(); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cc8576aa7..26308975f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d8c6b875..821f30c1b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -139,6 +139,8 @@ Clear Очистити Clip Обрізати Clip Viewport Обрізати видовий екран + Clip Xref Обрізати зовнішнє посилання + Xref Clip Frame Рамка обрізки зовнішнього посилання Clipboard Буфер обміну Close Закрити Close All Закрити всі @@ -198,7 +200,10 @@ Cylinder Циліндр DWG DWG DWG Reference... Посилання на DWG... + Raster Image Reference... Посилання на растрове зображення... + Raster Images Растрові зображення DXF DXF + DView Динамічний вид Dark Темна Data Extraction... Вилучення даних... Default Типово @@ -1373,3 +1378,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 diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f1b8dca7b..91ed70cf7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index de4e6a633..a07b4f027 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -149,6 +149,7 @@ Cut with Base Point 带基点剪切 Cyan 青色 Cylinder 圆柱体 + DView 动态视图 DWG DWG DXF DXF Dark 深色 @@ -448,6 +449,7 @@ Multiline Text 多行文字 Multiline Text... 多行文字... Multiple 多个 + Multiple Insert 多重插入 Multiple Point 多点 My 我的 My properties: not available yet 我的属性:暂不可用 @@ -592,6 +594,8 @@ Quit CADCraft 退出 CADCraft Radius 半径 Raster Image 光栅图像 + Raster Image Reference... 光栅图像参照... + Raster Images 光栅图像 Ray 射线 Read-only 只读 Real name 真实姓名 @@ -1377,6 +1381,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… 添加… @@ -1453,3 +1469,6 @@ menu Dimension 标注 menu Draw 绘图 menu Modify 修改 menu View 视图 + Clip Xref 剪裁外部参照 + Xref 外部参照 + Xref Clip Frame 外部参照剪裁边框 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 142a589a3..d27712da2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, /// The notice shown after one (see [`autosave::gpu_notice`]). pub gpu_notice: Option, + /// Panics caught around the frame ([`fault`]). + pub fault: fault::UiFault, + /// The notice shown while autosave fails (see [`autosave::failure_notice`]). + pub autosave_notice: Option, /// The Command Aliases dialog while open ([`aliasdlg`]). pub alias_dlg: Option, /// 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; } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 55d058811..438c06d97 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { 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 { + | "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"), diff --git a/crates/ui-egui/src/spacemouse.rs b/crates/ui-egui/src/spacemouse.rs index 3a9e2ba32..41b781dd4 100644 --- a/crates/ui-egui/src/spacemouse.rs +++ b/crates/ui-egui/src/spacemouse.rs @@ -128,7 +128,7 @@ fn navigate(app: &mut CadApp, params: &Value) -> Result { if !center.is_finite() || !height.is_finite() { return Err("3D mouse navigation would produce an invalid view".into()); } - state.set_view_quiet(View { center, height }); + state.set_view_quiet(View { center, height, twist: view.twist }); Ok(json!({"center": [center.x, center.y], "height": height})) } diff --git a/crates/ui-egui/tests/autosave_failure.rs b/crates/ui-egui/tests/autosave_failure.rs new file mode 100644 index 000000000..6e266fedd --- /dev/null +++ b/crates/ui-egui/tests/autosave_failure.rs @@ -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); +} diff --git a/crates/ui-egui/tests/it/measure_distance_snapping.rs b/crates/ui-egui/tests/it/measure_distance_snapping.rs index 9f520b4ab..0deedcea4 100644 --- a/crates/ui-egui/tests/it/measure_distance_snapping.rs +++ b/crates/ui-egui/tests/it/measure_distance_snapping.rs @@ -30,6 +30,7 @@ fn harness(paper: bool, render: bool) -> Harness<'static, CadApp> { layer_colors: Vec::new(), clip: None, on: true, + twist: 0.0, }), ) .unwrap(); diff --git a/crates/ui-egui/tests/it/mspace_viewport_view.rs b/crates/ui-egui/tests/it/mspace_viewport_view.rs index 569665021..55c639728 100644 --- a/crates/ui-egui/tests/it/mspace_viewport_view.rs +++ b/crates/ui-egui/tests/it/mspace_viewport_view.rs @@ -37,6 +37,7 @@ fn harness(clipped: bool) -> Harness<'static, CadApp> { layer_colors: Vec::new(), clip, on: true, + twist: 0.0, }), ) .unwrap(); @@ -88,7 +89,7 @@ fn revision(h: &Harness<'_, CadApp>) -> u64 { fn pan(h: &mut Harness<'_, CadApp>, dx: f64) { let st = h.state_mut().session.state_mut().unwrap(); let v = st.view(); - st.set_view_quiet(cadcraft_engine::View { center: v.center - Vec2::new(dx, 0.0), height: v.height }); + st.set_view_quiet(cadcraft_engine::View { center: v.center - Vec2::new(dx, 0.0), height: v.height, twist: v.twist }); } /// A wheel step zooming in by `f` about the viewport's view centre. diff --git a/crates/ui-egui/tests/recent_files.rs b/crates/ui-egui/tests/recent_files.rs index 34d460255..34aa6e2e8 100644 --- a/crates/ui-egui/tests/recent_files.rs +++ b/crates/ui-egui/tests/recent_files.rs @@ -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"); +} diff --git a/crates/ui-egui/tests/ui_faults.rs b/crates/ui-egui/tests/ui_faults.rs new file mode 100644 index 000000000..3c382e38f --- /dev/null +++ b/crates/ui-egui/tests/ui_faults.rs @@ -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); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d85415123..8085f422c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -85,15 +85,15 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | HATCH gradient | yes | yes (#162, fixes #80) | | | SOLID, TRACE, 3DFACE | yes | yes | | | ACAD_TABLE | yes | yes (entity + block) | | -| VIEWPORT | yes | yes; frozen-layer lists kept (#150), also as standard group 341 layer handles that other programs read | DWG: the DXF→DWG converter does not yet write group 341 (frozen layers survive a CADCraft round trip via xdata); VP layer colour overrides lost (#99, PR #197) | -| IMAGE | yes, with IMAGEDEF | yes, with IMAGEDEF and IMAGEDEF_REACTOR | no attach command or display of the picture yet (FF10) | +| VIEWPORT | yes | yes; frozen-layer lists kept (#150), also as standard group 341 layer handles that other programs read; view twist (51) | DWG: the DXF→DWG converter does not yet write group 341 (frozen layers survive a CADCraft round trip via xdata); VP layer colour overrides lost (#99, PR #197) | +| IMAGE | yes, with IMAGEDEF | yes, with IMAGEDEF and IMAGEDEF_REACTOR | the picture is loaded from its file and drawn (#1756); brightness and contrast are kept but not applied (FF10) | | WIPEOUT | yes | yes (#147, fixes #79) | Implicit or explicit closure in memory; explicit closing point on save. Same logical grips and edit results before/after DXF/DWG round trips (#2256). | | MLINE (+ MLINESTYLE), HELIX, TOLERANCE | yes (#510, #420) | yes | | | REGION | other programs' REGIONs (ACIS data) kept as Unknown | our regions are written as closed polylines tagged with CADCraft xdata (#510; ACIS has no public spec) | | | 3DSOLID, BODY, SURFACE, MESH, SHAPE, UNDERLAY, OLE2FRAME, LIGHT, CAMERA, SECTION, POINTCLOUD, proxy entities | kept as Unknown (raw tags) | written back as read, with their CLASS records (#528); a proxy entity's class number (group 91) is renumbered to its class's place in the written CLASSES (#1812); **dropped by DWG save** (acadrust) | only their layer follows edits | | Header variables | all read | 36 written | AutoCAD writes about 280 | | Tables: LAYER (+ transparency, description), LTYPE, STYLE, DIMSTYLE, BLOCK_RECORD | yes | yes | | -| Tables: VPORT, VIEW, UCS, APPID | yes | yes: saved viewport configurations (#504), named views, UCSs | UCS elevation lost | +| Tables: VPORT, VIEW, UCS, APPID | yes | yes: saved viewport configurations (#504) with each tile's view twist (51), named views, UCSs | UCS elevation lost | | Objects: LAYOUT, DICTIONARY, XRECORD, TABLESTYLE, DIMASSOC | yes | yes | | | Objects: GROUP, MLINESTYLE, MLEADERSTYLE, IMAGEDEF, SCALE | yes | yes (groups #417, scale list #468) | | | Objects: VISUALSTYLE, PLOTSETTINGS, materials, field objects and other unmodelled objects | kept raw (#528) | written back as read; a proxy object (ACAD_PROXY_OBJECT) keeps its class: its group 91 is renumbered to the written CLASSES, which keep the class it names, and one whose number names no class is dropped (#1812) | not editable | @@ -112,7 +112,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | DWS standards | open, save, CHECKSTANDARDS | no | 6–10 | | PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571); object transparency as PDF alpha when "Plot transparency" is on (the option isn't saved in DXF/DWG: no public plot layout flag) | 1–2 | | PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 | -| Raster images attach (PNG, JPEG, TIFF, BMP …) | yes | IMAGE entity exists, no attach/loading/display path | 6–10 | +| Raster images attach (PNG, JPEG, TIFF, BMP …) | yes | **partial** (#956): IMAGEATTACH (JSON form) reads PNG, JPEG, BMP and TIFF; drawn on screen and in PNG output with rotation, clip boundary and fade; reloaded on open, a missing file stays a crossed-out frame. Not yet: PDF and SVG output of the picture, prompts and dialog, detach/reload/unload, IMAGECLIP, IMAGEADJUST, images stored in the drawing | 14–22 | | PNG / JPEG / BMP / TIFF export | PNGOUT, JPGOUT, BMPOUT, TIFOUT | PNG; SVG too (AutoCAD doesn't export SVG) | 1–2 | | DWF/DWFx export, DWF underlay | yes | no | 10–15 | | DGN import/export/underlay | yes | no (needs a public spec; owner decision) | 25–40 | @@ -140,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Raster images: the picture is loaded from its file when a drawing opens and drawn on screen and in PNG output (#1756, viewport part; based on #1029) | | 2026-10-29 | minor | R2000 DXF: CLASS records written without group 91 (instance count), which came with the 2004 format; AutoCAD LT 2026 stopped responding on every R2000 DXF CADCraft saved (checked black-box in AutoCAD LT 2026) | | 2026-10-26 | minor | CC-046: ATTEDIT and -ATTEDIT value changes invalidate derived justified baselines before DXF/DWG export; preserve alignment anchors, unchanged values and Aligned/Fit endpoints. Review follow-up: baseline recomputation preserves insertion elevation independently of alignment elevation. The shared writer correction also applies to justified TEXT. Independent of attribute editor UI; reverting the follow-up restores elevation loss on recomputation, while reverting the value setter restores stale XY baselines. | | 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use | @@ -150,6 +151,7 @@ our output in AutoCAD black-box. | 2026-10-20 | minor | DIMENSION: the `*D` block's MTEXT, and the MTEXT EXPLODE leaves, hold the diameter sign as `%%c` instead of the decoded U+2300, which no SHX font has, so other programs plotted diameter dimensions in an SHX style without the sign (#1645) | | 2026-10-21 | minor | Proxy objects and entities keep their class through a save: group 91 renumbered to the written CLASSES (the class it names is kept), a proxy whose number names no class dropped (#1812) | | 2026-10-17 | minor | DWG read: DGN line style components no line style definition reaches are left out before the DXF bridge, with a read note; an 83 MB site plan with 888,483 of them (1.2 GB of DXF, over the limit) now opens (#1482) | +| 2026-11-03 | minor | View twist (group 51) read and written for each tiled viewport (VPORT) and each layout viewport (VIEWPORT); named views' twist (VIEW group 50) now applied on Restore; a twisted view's centre (VIEWPORT and VPORT group 12, VIEW group 10) is read and written in display coordinates, turned by the twist | | 2026-10-12 | minor | PDF output: object transparency plotted as PDF alpha (`/ExtGState` `/ca` `/CA`) when the page setup's or the plot's "Plot transparency" is on; PNG plots blend it, SVG uses `opacity`; plots without it are unchanged byte for byte | | 2026-10-12 | minor | DXF/DWG save: the Model layout's plot settings are written as CADCraft plots model space, drawing extents scaled to fit (PLOTSETTINGS 74 = 1, 75 = 0), instead of plot type 5 "Layout information", which other programs' Plot dialogs can't use for model space | | 2026-10-12 | minor | VIEWPORT frozen layers also written and read as AutoCAD's group 341 layer handles, so third-party viewers honour per-viewport freezing in DXF (DWG converter gap noted) | diff --git a/docs/gaps.md b/docs/gaps.md index f8ddac941..036042cac 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -16,7 +16,7 @@ do them first. | 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors; a user's QSAVE now asks before replacing the file and keeps the previous one as `.bak`, which reduces the harm but does not close this row) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) | | 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) | | 3 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) | -| 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place or clipped** (xrefs, #525, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) | +| 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place** (xrefs, #525, XCLIP, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) | | 5 | **No autosave in the web build** (the desktop app has SAVETIME autosave, Drawing Recovery, AUDIT and RECOVER since #406) | Browser users lose unsaved work when the tab or the browser goes down | 3–5 | [features](#features) | | 6 | **No CI runs the Rust tests on pull requests** (`cargo fmt` on main is fixed) | open PRs land without a gate; regressions go unseen | 2–3 | [stability](#stability) | @@ -24,8 +24,8 @@ do them first. | # | Gap | Evidence | User impact | Est. | |---|---|---|---|---| -| F1 | **[alpha blocker]** Prompt options that still print "not available yet": -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 1–3 | -| F2 | XREF editing in place and clipping: XCLIP is missing. REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | +| F1 | **[alpha blocker]** Prompt options that still print "not available yet": -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations, DVIEW CAmera, TArget, Distance, POints, CLip and Hide (they need a 3D camera; TWist, PAn, Zoom, Off and Undo work). SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs`, `cmd/view/dview.rs` | Low: the most-used commands are done | 1–3 | +| F2 | XREF editing in place: REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XCLIP clips a block or xref reference to a rectangular, polygonal or closed-polyline boundary (Invert clip, ON/OFF, Delete, generate Polyline; Clipdepth is ignored in 2D), drawn, plotted and framed as XCLIPFRAME says, with the extents cut to it, and kept through DXF and DWG as the reference's SPATIAL_FILTER (2026-10-27). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place | 12–20 | | F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks and, in the Block Editor, switches to another block after asking to save), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | @@ -63,7 +63,7 @@ or allow clicks and drag (2, default), saved in the user profile. Drag-only mode | U6 | Selection: lasso; the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) and selection cycling (#1143) landed | Medium | 3–5 | | U7 | Right-click menus: no in-place layout tab rename or Move or Copy dialog, no Select All Layouts, no Paste to command line; palette menus are on the Layers rows and the Properties background only. The command menu (held right-click: Enter, Cancel, Recent Input, options, Snap Overrides, Pan/Zoom), the selection menu with object edits, command line, drawing tab, layout tab and palette menus landed (#512) | Low | 1–2 | | U10 | SETVAR covers about 150 of ~1,000 variables (UNDO options, MREDO and UNDOCTL, and header variables such as CIRCLERAD, OFFSETDIST, POLYSIDES, HPNAME and the dimension variables in SETVAR ? landed, #461; typing a bare variable name as a command runs SETVAR on it) | Low-medium | 3–5 | -| U11 | VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463, MVIEW Restore in #633, and the Named Viewports dialog, which restores or deletes a saved configuration) | Low | 1–2 | +| U11 | VIEW Orthographic, UCS and background settings (view twist: DVIEW on the Model tab, each VPORTS tile and each layout viewport, VIEWTWIST, landed; Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463, MVIEW Restore in #633, and the Named Viewports dialog, which restores or deletes a saved configuration) | Low | 1–2 | | U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | Low-medium | 13–23 | | U13 | `.dxf` from Finder fails to open on macOS (#158) (window controls fixed, #161) | Medium | 1–2 | @@ -80,9 +80,9 @@ Detail: [file-format-parity.md](file-format-parity.md). | FF5 | **[alpha blocker]** Real-file corpus (`storytold/cadcraft-corpus`), open → save → reopen tests, black-box check in AutoCAD | High | 10–15 + human | | FF6 | **[alpha blocker]** DWG throughput: a 10 MB DWG took ~2 min to fail (#57) | High for real projects | 6–10 | | FF7 | Table record and object handles from other programs' files are renumbered on the first save (entity handles are kept; `*D` blocks no longer duplicate and once reopened, CADCraft's own files keep every handle across saves, #495) | Low | 4–6 | -| FF8 | SHX fonts (shape and big fonts) not read | Medium-high (text metrics differ) | 8–12 | -| FF9 | `.pc3` plotter configuration files; linetype shape (SHX) elements are skipped (templates and `.dwt`, user `.lin`/`.pat` and `.pgp` alias files landed, #552; `.ctb`/`.stb`, #410) | Low-medium | 5–10 | -| FF10 | PDF import and underlays (PDFATTACH); raster image attach (IMAGEATTACH). PDF export has real text in embedded TrueType subsets, PDF layers, several sheets per file and hyperlinks (#567, #571) | Medium | 20–35 | +| FF8 | SHX shape fonts, big fonts and unifonts are read and drawn (2026-10-22; looked up by file name beside the drawing, in `CADCRAFT_SHXPATH`, via a font map or registered by the web app; FONTALT). Left: no Options entry for the search path or font map; no desktop drag-drop of `.shx`; big-font code page guessed from the file name; vertical text not justified along its column; extended big font box scaling unverified against real files | Medium | 2–4 | +| FF9 | `.pc3` plotter configuration files; shape elements in user `.lin` files are dropped on load (DXF/DWG linetype shape elements are drawn since 2026-10-22; templates and `.dwt`, user `.lin`/`.pat` and `.pgp` alias files landed, #552; `.ctb`/`.stb`, #410) | Low-medium | 4–8 | +| FF10 | PDF import and underlays (PDFATTACH). Raster images (#956): IMAGEATTACH from JSON reads PNG, JPEG, BMP and TIFF, the picture is drawn on screen and in PNG exports and plot previews (rotation, clip boundary, fade), sharp at any zoom whatever its size (a pyramid of levels cut into tiles, uploaded as they come into view), and reloaded when the drawing opens; left: PDF and SVG output write only the frame, no prompts, dialog or menu row, no -IMAGE options (detach, reload, unload, path), IMAGECLIP, IMAGEADJUST or IMAGEFRAME, brightness and contrast not applied, images lie under all other objects on screen whatever their draw order, size not taken from the file's resolution, images can't be stored in the drawing. PDF export has real text in embedded TrueType subsets, PDF layers, several sheets per file and hyperlinks (#567, #571) | Medium | 24–40 | | FF11 | DWF/DWFx, DGN, WMF, EPS, ACIS/STL and other exchange formats | Low-medium | 50–100 | | FF12 | DWG in the web build | Medium | 6–10 | | FF13 | R12 binary DXF, pre-R13 DWG | Low | 10–20 | @@ -150,6 +150,8 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | XCLIP (rectangular, polygonal and polyline boundaries, Invert clip, ON/OFF, Delete, generate Polyline) and XCLIPFRAME: a boundary per block or xref reference (`Insert::clip`, block coordinates), drawn, plotted and framed clipped, the extents cut to it, and read and written as the reference's ACAD_FILTER/SPATIAL SPATIAL_FILTER in DXF and DWG: F2 and top-10 row 4 narrowed | +| 2026-11-03 | minor | SHX fonts: shape fonts, big fonts and unifonts read (`cadcraft_fonts::shx`, every count and offset bounded) and drawn for TEXT, MTEXT, attributes, dimensions and tables, with the style's big font and vertical flag; found by file name beside the drawing, in `CADCRAFT_SHXPATH`, through a font map or from `.shx` files opened in the web app, never in another CAD program's folder; FONTALT may name a font and a big font; linetype shape elements drawn when their file is found: FF8 and FF9 narrowed | | 2026-10-30 | minor | QSAVE asks before replacing a file with content the DWG writer can't keep, and ISAVEBAK keeps the previous file as `.bak`: row 1 narrowed, FF1 and F20 updated | | 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | @@ -167,6 +169,7 @@ Detail: [localization-parity.md](localization-parity.md). | 2026-10-15 | minor | BREAKLINE (Tools ▸ Express Tools ▸ Break-line Symbol): one polyline between two points with a break symbol, Block/Size/Extension options, size times DIMSCALE: E3 narrowed | | 2026-10-15 | minor | TXT2MTXT (Tools ▸ Express Tools ▸ Convert Text to Mtext): selected TEXT, and MTEXT, combined into one MTEXT top-down or in selection order with optional word wrap, or one MTEXT per TEXT; SEttings remembered for the session: E3 narrowed | | 2026-10-13 | minor | Selection window gesture preference (PICKDRAG 0/2) added to Options ▸ Selection and saved in the profile (U6), with Portuguese and Ukrainian labels | +| 2026-11-03 | minor | View twist: DVIEW starts with "Select objects or :" then its option prompt (TWist, PAn, Zoom, Off and Undo on 2D views, the 3D options added to F1), on the Model tab and inside an active layout viewport, which keeps its own twist (DXF VIEWPORT group 51) and is drawn turned on screen and in plots; VIEWTWIST, each VPORTS tile (VPORT group 51) and named views keep it: U11 narrowed | | 2026-10-12 | minor | Object transparency (entity, ByLayer, ByBlock) is drawn: on screen (TRANSPARENCYDISPLAY, on by default), in PNG/SVG exports, and in plots when the page setup's "Plot transparency" is on (PDF alpha, PNG blending, SVG opacity; PLOTTRANSPARENCYOVERRIDE): F14 narrowed | | 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode | | 2026-10-12 | minor | A bare system variable name typed at the command line (ORTHOMODE, GRIDMODE…) runs SETVAR on that variable, as in AutoCAD; U10 narrowed | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fc36dea7d..3f3ed692f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: CADCraft vs AutoCAD 2027 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (COMPAREEXPORT; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (XCLIP; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall, @@ -153,7 +153,7 @@ presence: an option that prints "not available yet" doesn't count. | Text and MTEXT | 6% | 35% | 30–45 | TrueType, formatting codes, stacking; MTEXT prompt options (#408), indents, tabs, bullets, numbering and columns (#547), annotative scaling (#468); editor is a dialog, not in place; no fields or SPELL (open PR #551), no SHX | [gaps](gaps.md#features) | | Leaders, tables, fields | 4% | 25% | 25–35 | MLEADER options and block content (#408), native MULTILEADER in DXF (#459); tables with cell styles, formulas, breaking and CSV data links (#534); no MLEADEREDIT/ALIGN/COLLECT, no FIELD | [gaps](gaps.md#features) | | Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) | -| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; no dynamic-block authoring, XCLIP, DesignCenter | [gaps](gaps.md#features) | +| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; XCLIP; no dynamic-block authoring, DesignCenter | [gaps](gaps.md#features) | | Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468); no MVIEW Restore, no system printers | [gaps](gaps.md#features) | | Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563) and COMPAREEXPORT, MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) | | Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; DOF report, G2 Smooth, ellipse/spline/block geometry and CONSTRAINTINFER landed (#546) | [geometry-parity](geometry-parity.md#constraint-solver--50) | @@ -222,6 +222,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Blocks, attributes, xrefs, groups row: XCLIP and XCLIPFRAME clip block and xref references; parity percentages and hours not re-measured | | 2026-10-24 | minor | Inquiry and utilities row: COMPAREEXPORT exports a comparison to a new drawing; parity percentages and hours not re-measured | | 2026-10-22 | minor | Draw row: RECTANG gained a centre construction mode (`Center` keyword, `center: true`) beside its corner mode (#1127); parity percentages and hours not re-measured | | 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d5ef7dcab..5d8bd5111 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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 ... @@ -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 = Vec::new(); + let mut dir: Option = 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(" "))) }