mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-06 02:19:49 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6676f94a7 | ||
|
|
af2d413d5e |
@@ -85,4 +85,3 @@ 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 }}
|
||||
|
||||
@@ -78,4 +78,3 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending |
|
||||
| `crates/ui-egui/src/i18n/ka.tsv` | Davit Matchakhelidze (@devius), native speaker, with Claude Code | Original Georgian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; terminology and doubtful strings decided by a native Georgian speaker; no font assets added (the Georgian face comes from craft-fonts) |
|
||||
|
||||
Generated
+20
-49
@@ -691,7 +691,6 @@ name = "cadcraft-dwg"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -729,7 +728,6 @@ name = "cadcraft-fonts"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"encoding_rs",
|
||||
"harfrust 0.14.0",
|
||||
"icu_segmenter",
|
||||
"skrifa 0.48.0",
|
||||
@@ -756,7 +754,6 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-pdf",
|
||||
"image",
|
||||
"miniz_oxide 0.9.1",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
@@ -776,7 +773,6 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"log",
|
||||
"serde_json",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -809,7 +805,6 @@ dependencies = [
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
@@ -825,10 +820,8 @@ name = "cadcraft-web"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-io",
|
||||
"cadcraft-ui-egui",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"log",
|
||||
@@ -1232,15 +1225,6 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -1525,19 +1509,6 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1606,26 +1577,6 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -6292,3 +6243,23 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_system_font_provider"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
+4
-3
@@ -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", "bmp", "tiff"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
encoding_rs = "0.8.35"
|
||||
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
|
||||
quick-xml = "0.42"
|
||||
@@ -63,8 +63,6 @@ quick-xml = "0.42"
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui-wgpu = "0.37"
|
||||
@@ -118,5 +116,8 @@ egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -118,7 +118,7 @@ numbers):
|
||||
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
|
||||
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
|
||||
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar), Turkish (Türkçe), Greek (Ελληνικά), Bosnian (Bosanski) and Georgian (ქართული), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar) and Turkish (Türkçe), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
|
||||
|
||||
## Quick start
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report it privately through [GitHub's private reporting](https://github.com/storytold/cadcraft/security/advisories/new), not in a public issue or pull request. Say which version you ran, on which platform, and how to reproduce it. A small sample file helps, as long as it holds nothing private.
|
||||
|
||||
Maintainers read reports as they arrive, but there is no fixed response time yet.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Only the latest release gets security fixes. CADCraft is pre-alpha and moves quickly, so please check that a problem still exists there before reporting it.
|
||||
|
||||
## What counts
|
||||
|
||||
CADCraft treats these as untrusted, and a way past these defences is a vulnerability:
|
||||
|
||||
- **Drawing files.** Opening a DXF, DWG or other file must not run code, write outside the paths the user chose, or crash the app. Panics are bugs too, but memory exhaustion or a decoder abort on a crafted file is the more serious case.
|
||||
- **AutoLISP in a drawing's folder.** A drawing must not run code that the user didn't choose to load.
|
||||
- **The control channel.** It listens on loopback only and needs the session token from an owner-only file on every request. Another local account reaching it without that token is a vulnerability.
|
||||
- **Temporary and autosave files.** Autosave files are unsaved drawings: another account must not be able to read them or redirect a write through them.
|
||||
- **Updates.** A release must not be replaceable by someone other than the project.
|
||||
|
||||
## Known limits
|
||||
|
||||
These are known and being worked on, so they don't need a report:
|
||||
|
||||
- Agents connected through MCP or the control channel can read and write any path the user can. There are no workspace roots yet.
|
||||
- A local process can make the control channel buffer up to 512 MiB before its token is checked. The total is capped, so this can slow the app but not exhaust memory without limit.
|
||||
- The DWG worker process has no memory limit of its own yet, so a crafted file can use a lot of memory before it stops. It ends that one open or save, not the app.
|
||||
- The web build has no autosave yet.
|
||||
|
||||
## What stays on your machine
|
||||
|
||||
Crash reports are written to the `crash-reports` folder in the settings folder and are never uploaded. They hold the version, the platform, the panic message and a stack trace, and a message can include a file path, so read a report before you attach it anywhere.
|
||||
@@ -81,7 +81,6 @@ pub(crate) enum Command {
|
||||
Mcp(McpArgs),
|
||||
Perf(PerfArgs),
|
||||
Sample(SampleArgs),
|
||||
Roundtrip(RoundtripArgs),
|
||||
}
|
||||
|
||||
/// Print a drawing's summary as JSON
|
||||
@@ -309,40 +308,6 @@ fn perf_target(p: PathBuf) -> PerfTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open each DXF/DWG, save it as DXF and DWG, reopen and compare; writes report.md and report.json
|
||||
///
|
||||
/// FOLDER defaults to $CADCRAFT_CORPUS_DIR. With CADCRAFT_CORPUS_STRICT=1 it fails when a file
|
||||
/// doesn't open, save or reopen, or loses records on save.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RoundtripArgs {
|
||||
/// A drawing, or a folder of them
|
||||
#[arg(value_name = "FILE|FOLDER")]
|
||||
pub(crate) path: Option<PathBuf>,
|
||||
/// Where the report goes (default: `report` beside the files)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub(crate) out: Option<PathBuf>,
|
||||
/// Keep the saved files, in `saved` under the report folder
|
||||
#[arg(long)]
|
||||
pub(crate) keep_saved: bool,
|
||||
/// Save only in each file's own format
|
||||
#[arg(long)]
|
||||
pub(crate) same_format: bool,
|
||||
/// Don't count what each file holds (faster; no "read lost" column)
|
||||
#[arg(long)]
|
||||
pub(crate) no_census: bool,
|
||||
/// How far numbers may move and still count as equal
|
||||
#[arg(long, value_name = "T", default_value_t = cadcraft_io::roundtrip::Options::default().abs_tol, value_parser = tolerance)]
|
||||
pub(crate) tolerance: f64,
|
||||
}
|
||||
|
||||
/// `--tolerance`: a finite number, 0 or more.
|
||||
fn tolerance(v: &str) -> Result<f64, ValueError> {
|
||||
match v.parse::<f64>() {
|
||||
Ok(t) if t.is_finite() && t >= 0.0 => Ok(t),
|
||||
_ => Err(ValueError::Tolerance(v.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a built-in sample drawing
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SampleArgs {
|
||||
@@ -395,8 +360,6 @@ pub(crate) enum ValueError {
|
||||
CmdId,
|
||||
#[error(transparent)]
|
||||
CmdJson(#[from] serde_json::Error),
|
||||
#[error("expected a number of 0 or more, got `{0}`")]
|
||||
Tolerance(String),
|
||||
}
|
||||
|
||||
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::output::OutputError;
|
||||
use crate::perf::PerfError;
|
||||
use crate::roundtrip::RoundtripError;
|
||||
use crate::run::RunError;
|
||||
|
||||
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
|
||||
@@ -20,8 +19,6 @@ pub(crate) enum CliError {
|
||||
Output(#[from] OutputError),
|
||||
#[error(transparent)]
|
||||
Perf(#[from] PerfError),
|
||||
#[error(transparent)]
|
||||
Roundtrip(#[from] RoundtripError),
|
||||
/// The engine and io crates take file names as text.
|
||||
#[error("{}: the file name is not valid Unicode", .0.display())]
|
||||
NotUnicode(std::path::PathBuf),
|
||||
|
||||
@@ -16,7 +16,6 @@ mod info;
|
||||
mod mcp;
|
||||
mod output;
|
||||
mod perf;
|
||||
mod roundtrip;
|
||||
mod run;
|
||||
mod sample;
|
||||
|
||||
@@ -33,7 +32,6 @@ use output::Streams;
|
||||
#[cfg(test)]
|
||||
use output::{export_window, model_views, write_atomic};
|
||||
use perf::perf;
|
||||
use roundtrip::roundtrip;
|
||||
use run::run_cmds;
|
||||
#[cfg(test)]
|
||||
use run::{fill_last, returned_handle};
|
||||
@@ -45,7 +43,6 @@ 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())
|
||||
});
|
||||
@@ -102,7 +99,7 @@ where
|
||||
Command::Run(a) => a.settings.apply(),
|
||||
Command::Mcp(a) => a.settings.apply(),
|
||||
Command::Sample(a) => a.settings.apply(),
|
||||
Command::Info(_) | Command::Commands(_) | Command::Perf(_) | Command::Roundtrip(_) => {}
|
||||
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
|
||||
}
|
||||
let mut io = Streams::new(out, err);
|
||||
let result = match &cli.command {
|
||||
@@ -113,7 +110,6 @@ where
|
||||
Command::Mcp(a) => mcp(a),
|
||||
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
|
||||
Command::Sample(a) => sample(a, &mut io),
|
||||
Command::Roundtrip(a) => roundtrip(a, &mut io),
|
||||
};
|
||||
let mut code = 0;
|
||||
if let Err(e) = result {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
#![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());
|
||||
|
||||
@@ -162,31 +162,19 @@ pub(crate) fn export_framed(s: &Session, out: &Path, window: Option<Bounds2>, io
|
||||
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
|
||||
pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
|
||||
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
|
||||
let mut ole = 0usize;
|
||||
for e in d.model.iter() {
|
||||
// An OLE2FRAME's frame outline is drawn, so it isn't an undrawn entity; its content is
|
||||
// not, which gets a note of its own.
|
||||
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
|
||||
if u.is_ole_frame() && cadcraft_engine::doc::ole_frame(&u.tags).is_some() {
|
||||
ole = ole.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
let mut lines = Vec::new();
|
||||
if n > 0 {
|
||||
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
|
||||
let entities = if n == 1 { "entity" } else { "entities" };
|
||||
lines.push(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")));
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
if ole > 0 {
|
||||
let objects = if ole == 1 { "object" } else { "objects" };
|
||||
lines.push(format!("{path}: {ole} OLE {objects} shown as the frame only (the OLE content is not drawn)"));
|
||||
}
|
||||
if lines.is_empty() { None } else { Some(lines.join("\n")) }
|
||||
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
|
||||
let entities = if n == 1 { "entity" } else { "entities" };
|
||||
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
/// The reader's notes on what it left out of `path` (a damaged DWG's unreadable object records,
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
//! `roundtrip`: the corpus harness of `cadcraft_io::roundtrip::corpus` over a file or folder.
|
||||
|
||||
use super::args::RoundtripArgs;
|
||||
use super::error::{CliError, Result};
|
||||
use super::output::Streams;
|
||||
|
||||
/// Why `roundtrip` stopped.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum RoundtripError {
|
||||
#[error("roundtrip: give a FILE or FOLDER, or set CADCRAFT_CORPUS_DIR")]
|
||||
NoPath,
|
||||
#[error("roundtrip: no such file or folder: {}", .0.display())]
|
||||
Missing(std::path::PathBuf),
|
||||
#[error("roundtrip: no DXF or DWG files in {}", .0.display())]
|
||||
Empty(std::path::PathBuf),
|
||||
#[error("roundtrip: can't write the report to {}: {source}", .path.display())]
|
||||
Report { path: std::path::PathBuf, source: std::io::Error },
|
||||
#[error("roundtrip: {} strict failures:\n{}", .0.len(), .0.join("\n"))]
|
||||
Strict(Vec<String>),
|
||||
}
|
||||
|
||||
/// What the walk couldn't look at, so the report isn't taken for the whole corpus: said on
|
||||
/// stderr, and failures of a strict run.
|
||||
fn left_out(w: &cadcraft_io::roundtrip::corpus::Walk) -> Vec<String> {
|
||||
let mut out: Vec<String> = w.unreadable.iter().map(|u| format!("not read: {u}")).collect();
|
||||
if w.truncated {
|
||||
out.push("left out: files or folders past the harness limits".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result {
|
||||
use cadcraft_io::roundtrip::corpus;
|
||||
let path = a.path.clone().or_else(|| std::env::var_os("CADCRAFT_CORPUS_DIR").map(Into::into)).ok_or(RoundtripError::NoPath)?;
|
||||
let mut missed = Vec::new();
|
||||
let (root, files, out) = if path.is_dir() {
|
||||
let out = a.out.clone().unwrap_or_else(|| path.join("report"));
|
||||
// The report folder, and the saved copies in it, are never read back as corpus files.
|
||||
let walk = corpus::walk(&path, &[out.clone(), out.join("saved")]);
|
||||
missed = left_out(&walk);
|
||||
for m in &missed {
|
||||
io.errln(format_args!("roundtrip: {m}"));
|
||||
}
|
||||
(path, walk.files, out)
|
||||
} else if path.is_file() {
|
||||
let root = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
let out = a.out.clone().unwrap_or_else(|| root.join("report"));
|
||||
(root, vec![path], out)
|
||||
} else {
|
||||
return Err(RoundtripError::Missing(path).into());
|
||||
};
|
||||
if files.is_empty() {
|
||||
// A folder it couldn't read isn't an empty one.
|
||||
return Err(if missed.is_empty() { RoundtripError::Empty(root) } else { RoundtripError::Strict(missed) }.into());
|
||||
}
|
||||
let mut opts = corpus::CorpusOptions { cross_format: !a.same_format, census: !a.no_census, ..Default::default() };
|
||||
opts.compare.abs_tol = a.tolerance;
|
||||
if a.keep_saved {
|
||||
opts.keep_saved = Some(out.join("saved"));
|
||||
}
|
||||
let report = corpus::run(&root, &files, &opts, &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
|
||||
for f in &report.files {
|
||||
let routes: Vec<String> = f
|
||||
.routes
|
||||
.iter()
|
||||
.map(|r| match (&r.save_error, &r.reopen_error) {
|
||||
(Some(_), _) => format!("{}: save failed", r.route),
|
||||
(None, Some(_)) => format!("{}: reopen failed", r.route),
|
||||
(None, None) => {
|
||||
let t = r.comparison.total();
|
||||
format!("{}: {} lost, {} added, {} changed", r.route, t.lost, t.added, t.changed)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
match &f.open_error {
|
||||
Some(e) => io.outln(format_args!("{}: open failed: {e}", f.path)),
|
||||
None => io.outln(format_args!("{}: opened; {}", f.path, routes.join("; "))),
|
||||
}
|
||||
}
|
||||
let (md, json) = report.write(&out).map_err(|source| RoundtripError::Report { path: out.clone(), source })?;
|
||||
io.outln(format_args!("report: {}", md.display()));
|
||||
io.outln(format_args!(" {}", json.display()));
|
||||
// CADCRAFT_CORPUS_STRICT=1: a file that doesn't open, save or reopen, or records a writer
|
||||
// leaves out, fail the run (the report is written first).
|
||||
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
|
||||
let mut failed = report.strict_failures();
|
||||
failed.extend(missed);
|
||||
if !failed.is_empty() {
|
||||
return Err(CliError::from(RoundtripError::Strict(failed)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//! 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 drawing whose kept objects came from a 2018 file: the worker gets that version with the
|
||||
// request and reads the bridge DXF as it, as this process does.
|
||||
let mut kept = drawing.clone();
|
||||
kept.foreign = std::sync::Arc::new(cadcraft_engine::doc::foreign::Foreign { version: Some("AC1032".into()), ..Default::default() });
|
||||
let written = cadcraft_io::write(&kept, "a.dwg").unwrap();
|
||||
assert_eq!(cadcraft_io::read(&written, "a.dwg").unwrap().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());
|
||||
}
|
||||
@@ -7,25 +7,6 @@ fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A fresh scratch directory that is removed on drop, also when an assertion fails.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-{tag}-{}", std::process::id()));
|
||||
// A directory left behind by an earlier run with the same process id must not leak in.
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).expect("create scratch dir");
|
||||
Scratch(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A line, two foreign REGIONs and a PDF underlay: the last three are kept as unknown entities.
|
||||
const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
|
||||
0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n\
|
||||
@@ -36,8 +17,8 @@ const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
|
||||
|
||||
#[test]
|
||||
fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
|
||||
let scratch = Scratch::new("undrawn");
|
||||
let dir = scratch.0.clone();
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-undrawn-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let input = dir.join("proxies.dxf");
|
||||
std::fs::write(&input, PROXIES).unwrap();
|
||||
let input = input.to_str().unwrap();
|
||||
@@ -68,37 +49,5 @@ fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
|
||||
let out = cli(&["convert", sample.to_str().unwrap(), dir.join("sample.svg").to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
/// An OLE2FRAME's frame outline is drawn, so an image export doesn't call it an undrawn entity (#2422); it
|
||||
/// says once that the OLE content inside the frame is not drawn (review on #2587).
|
||||
#[test]
|
||||
fn an_ole_frame_is_noted_as_frame_only() {
|
||||
let scratch = Scratch::new("ole");
|
||||
let dir = scratch.0.clone();
|
||||
let input = dir.join("ole.dxf");
|
||||
let ole = "0\nSECTION\n2\nENTITIES\n0\nOLE2FRAME\n8\n0\n100\nAcDbOle2Frame\n10\n0\n20\n0\n30\n0\n11\n5\n21\n-3\n31\n0\n0\nENDSEC\n0\nEOF\n";
|
||||
std::fs::write(&input, ole).unwrap();
|
||||
let out = cli(&["convert", input.to_str().unwrap(), dir.join("out.png").to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!err.contains("entity not drawn") && !err.contains("entities not drawn"), "{err}");
|
||||
let notes: Vec<&str> = err.lines().filter(|l| l.contains("OLE")).collect();
|
||||
assert_eq!(notes, [format!("{}: 1 OLE object shown as the frame only (the OLE content is not drawn)", input.display())], "{err}");
|
||||
}
|
||||
|
||||
/// An OLE2FRAME without usable corners (here group 11/21 missing) has no outline to draw, so it is
|
||||
/// an undrawn entity, not "shown as the frame only" (review on #2587).
|
||||
#[test]
|
||||
fn an_ole_frame_without_corners_is_counted_as_not_drawn() {
|
||||
let scratch = Scratch::new("ole-nocorner");
|
||||
let dir = scratch.0.clone();
|
||||
let input = dir.join("ole.dxf");
|
||||
let ole = "0\nSECTION\n2\nENTITIES\n0\nOLE2FRAME\n8\n0\n100\nAcDbOle2Frame\n10\n0\n20\n0\n30\n0\n0\nENDSEC\n0\nEOF\n";
|
||||
std::fs::write(&input, ole).unwrap();
|
||||
let out = cli(&["convert", input.to_str().unwrap(), dir.join("out.png").to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains(&format!("{}: 1 entity not drawn (OLE2FRAME: 1)", input.display())), "{err}");
|
||||
assert!(!err.contains("frame only"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() {
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||||
written.sort();
|
||||
let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"];
|
||||
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample", "roundtrip"];
|
||||
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
|
||||
let mut expected: Vec<String> = completions.map(String::from).into();
|
||||
expected.push("cadcraft-cli.1".into());
|
||||
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
|
||||
|
||||
@@ -18,9 +18,6 @@ path = "src/main.rs"
|
||||
cadcraft-engine = { workspace = true }
|
||||
cadcraft-ui-egui = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -33,4 +30,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
|
||||
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
|
||||
|
||||
+102
-62
@@ -1,37 +1,28 @@
|
||||
//! Host modes: a page that runs CADCraft opens drawings in it and stores what it saves instead
|
||||
//! of the browser downloading. The messages and the two transports are `craft-web-host`'s
|
||||
//! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
|
||||
//! `cadcraft:` prefix. This module is CADCraft's part of it.
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! CADCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
|
||||
//!
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
|
||||
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `cadcraft:ready` once the app is listening; send a drawing now. CADCraft reads bytes, so it
|
||||
//! says `file: false` and a host sends `bytes`.
|
||||
//! - `cadcraft:open` (from the host): `{ name, bytes }`. It opens like a file picked with
|
||||
//! File › Open or dropped on the window; an oversized drawing is refused before it is copied.
|
||||
//! - `cadcraft:save` (to the host): `{ name, saveAs, bytes }` for every file CADCraft would
|
||||
//! otherwise download (SAVE, SAVEAS, exports): it replaces the engine's delivery hook. The host
|
||||
//! stores the file and reports failures itself. CADCraft doesn't tell the host which saves
|
||||
//! make a file the document, so `saveAs` is always `false`.
|
||||
//! - `cadcraft:dirty` whenever unsaved changes appear or go away, `cadcraft:failed` when the app
|
||||
//! couldn't start.
|
||||
//! - `cadcraft:ready` (to the parent): `{ version }`, the app is listening; send a drawing now.
|
||||
//! - `cadcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a file picked with File › Open or dropped on the window.
|
||||
//! - `cadcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
|
||||
//! file CADCraft would otherwise download (SAVE, SAVEAS, exports): it replaces the engine's
|
||||
//! delivery hook. The parent stores the file and reports failures itself.
|
||||
//! - `cadcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `cadcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! In both host modes the app starts on the Start tab rather than an empty Drawing1, and the
|
||||
//! host guards leaving the page.
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the app
|
||||
//! starts on the Start tab rather than an empty Drawing1, and the parent guards leaving the page.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use cadcraft_ui_egui::CadApp;
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::js_sys;
|
||||
|
||||
/// The app id in the host messages (`cadcraft:ready`).
|
||||
const APP: &str = "cadcraft";
|
||||
|
||||
/// The name a drawing gets when the host sent none (the crate says `document` for a blank name).
|
||||
const DEFAULT_NAME: &str = "Drawing.dxf";
|
||||
/// Longest file name taken from the parent page.
|
||||
const MAX_NAME_CHARS: usize = 255;
|
||||
|
||||
thread_local! {
|
||||
/// The bridge [`deliver`] posts through (the engine's hook is a plain `fn`).
|
||||
@@ -40,15 +31,25 @@ thread_local! {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
host: Rc<Host>,
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
/// The unsaved-changes state last reported to the parent.
|
||||
#[derive(Default)]
|
||||
pub struct DirtyReport(Cell<Option<bool>>);
|
||||
|
||||
impl Bridge {
|
||||
/// The bridge for this page, or `None` when CADCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
|
||||
/// `None` when the page isn't framed (there is no parent to talk to).
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
/// Make this the bridge [`deliver`] uses.
|
||||
@@ -57,55 +58,94 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
self.send(&[("type", "cadcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.host.post_failed(error, None);
|
||||
self.send(&[("type", "cadcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
/// Hear drawings from the host; they arrive through the upload inbox.
|
||||
/// Hear drawings from the parent; they arrive through the upload inbox.
|
||||
pub fn listen(&self, inbox: crate::upload::Inbox) {
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
receive_open(&name, &data, &inbox);
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("cadcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name")
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.map(|n| n.chars().take(MAX_NAME_CHARS).collect())
|
||||
.unwrap_or_else(|| "Drawing.dxf".to_string());
|
||||
// Refuse an oversized drawing before copying it into the app's memory.
|
||||
let Some(array) = byte_array(&data) else { return };
|
||||
if let Some(e) = CadApp::open_size_error(&name, u64::from(array.length())) {
|
||||
inbox.refuse(e);
|
||||
return;
|
||||
}
|
||||
inbox.receive(name, array.to_vec());
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// After each logic callback (including a hidden tab's): tell the host when unsaved changes
|
||||
/// appear or go away. The crate does not repeat a state it already reported, so this is
|
||||
/// idempotent.
|
||||
pub fn report_dirty(&self, app: &CadApp) {
|
||||
self.host.post_dirty(app.session.docs.iter().any(|d| d.is_dirty()));
|
||||
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
|
||||
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
|
||||
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
|
||||
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
|
||||
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.0.set(Some(dirty));
|
||||
}
|
||||
}
|
||||
|
||||
fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
self.host.post_save(name, bytes, None, false)
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[("type", "cadcraft:save".into()), ("name", name.into()), ("bytes", buffer.clone().into())])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's delivery hook in host mode ([`cadcraft_engine::cmd::file::set_deliver`]): hand
|
||||
/// the file to the host page. `Ok` once it was sent; the engine then counts a drawing as saved.
|
||||
/// the file to the parent page. `Ok` once it was posted; the engine then counts a drawing as saved.
|
||||
pub fn deliver(name: &str, _mime: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let bridge = BRIDGE.with(|b| b.borrow().clone()).ok_or("the page that embeds CADCraft is gone")?;
|
||||
bridge.post_save(name, bytes)
|
||||
}
|
||||
|
||||
/// The `cadcraft:open` payload, `{ name, bytes }` where `bytes` holds a drawing: it opens like a
|
||||
/// file picked with File › Open or dropped on the window. The crate has cleaned the name (and cut
|
||||
/// it to 255 characters); a blank one comes as `document` and becomes `Drawing.dxf`. An oversized
|
||||
/// drawing is refused before it is copied.
|
||||
fn receive_open(name: &str, data: &FileData, inbox: &crate::upload::Inbox) {
|
||||
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
|
||||
let FileData::Bytes(array) = data else {
|
||||
inbox.refuse(format!("Open: {name} came as a File; CADCraft reads bytes"));
|
||||
return;
|
||||
};
|
||||
// Refuse an oversized drawing before copying it into the app's memory (the data's own length).
|
||||
if let Some(e) = CadApp::open_size_error(&name, data.size() as u64) {
|
||||
inbox.refuse(e);
|
||||
return;
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` (an `ArrayBuffer` or `Uint8Array`) as a view, not yet copied.
|
||||
fn byte_array(data: &JsValue) -> Option<js_sys::Uint8Array> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.clone())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
inbox.receive(name, array.to_vec());
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
//! with egui shapes instead of the GPU canvas, `?sample` opens the sample drawing. Saving downloads
|
||||
//! the drawing through the browser (`download.rs`). With `?host=parent` (inside an `<iframe>`), a
|
||||
//! same-origin parent page sends the drawing to open and stores what CADCraft saves instead
|
||||
//! (`host.rs`); a page can also host CADCraft in its own document (no iframe) with
|
||||
//! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`).
|
||||
//! (`host.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -28,11 +27,16 @@ mod web {
|
||||
const LOADING_ID: &str = "cadcraft_loading";
|
||||
|
||||
/// The app, the files File > Open… and drag and drop read for it in the background, the
|
||||
/// host page (`?host=parent`, or `data-host="page"` on the canvas), the browser's
|
||||
/// `prefers-reduced-motion` (it stays
|
||||
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
|
||||
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
|
||||
/// and the document title last written to the page.
|
||||
struct Shell(CadApp, crate::upload::Inbox, Option<crate::host::Bridge>, Option<web_sys::MediaQueryList>, Option<String>);
|
||||
struct Shell(
|
||||
CadApp,
|
||||
crate::upload::Inbox,
|
||||
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
|
||||
Option<web_sys::MediaQueryList>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
impl eframe::App for Shell {
|
||||
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
|
||||
@@ -47,10 +51,10 @@ mod web {
|
||||
self.0.logic(ctx);
|
||||
let drawing = self.0.session.state().ok();
|
||||
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
|
||||
// The crate suppresses duplicates, so reporting after every logic callback
|
||||
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
|
||||
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
|
||||
if let Some(bridge) = &self.2 {
|
||||
bridge.report_dirty(&self.0);
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
}
|
||||
}
|
||||
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
|
||||
@@ -96,10 +100,8 @@ mod web {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The app's canvas: the one element `index.html` and every embedding page must provide,
|
||||
/// with the id the app looks up.
|
||||
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
|
||||
web_sys::window()?.document()?.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
|
||||
@@ -117,7 +119,6 @@ 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())
|
||||
});
|
||||
@@ -126,17 +127,10 @@ mod web {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
});
|
||||
let Some(canvas) = app_canvas() else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
|
||||
// drawings in it and stores what it saves. Otherwise `data-host="page"` on the canvas
|
||||
// opts in a page that runs CADCraft in its own document (no iframe) and talks over DOM
|
||||
// `CustomEvent`s on the canvas (`host`). With neither, there is no file system here:
|
||||
// saved files reach the user as browser downloads; an unframed `?host=parent` stays
|
||||
// that normal app.
|
||||
let bridge = crate::host::Bridge::connect(&canvas);
|
||||
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
|
||||
// files reach the user as browser downloads.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
match &bridge {
|
||||
Some(bridge) => {
|
||||
bridge.install();
|
||||
@@ -147,6 +141,10 @@ mod web {
|
||||
let app_bridge = bridge.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
@@ -158,7 +156,7 @@ mod web {
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
// Embedded, the host sends the drawing: start on the Start tab, not Drawing1.
|
||||
// Embedded, the parent sends the drawing: start on the Start tab, not Drawing1.
|
||||
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
|
||||
let mut app = CadApp::new(session, services());
|
||||
app.system_languages =
|
||||
@@ -193,9 +191,10 @@ mod web {
|
||||
picker.pick();
|
||||
None
|
||||
}));
|
||||
let host = app_bridge.inspect(|bridge| {
|
||||
let host = app_bridge.map(|bridge| {
|
||||
bridge.listen(inbox.clone());
|
||||
bridge.post_ready();
|
||||
(bridge, crate::host::DirtyReport::default())
|
||||
});
|
||||
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
//! a path from, so the picker is a hidden `<input type="file">`; 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;
|
||||
@@ -17,14 +12,8 @@ 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, 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;
|
||||
/// What the picker offers: the formats OPEN reads.
|
||||
const ACCEPT: &str = ".dxf,.dwg";
|
||||
|
||||
/// A file read in the browser (name, contents), or why it couldn't be read.
|
||||
type Upload = Result<(String, Vec<u8>), String>;
|
||||
@@ -33,14 +22,12 @@ type Upload = Result<(String, Vec<u8>), String>;
|
||||
#[derive(Clone)]
|
||||
pub struct Inbox {
|
||||
files: Rc<Cell<Vec<Upload>>>,
|
||||
/// Bytes of the files being read now.
|
||||
reading: Rc<Cell<u64>>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn new(ctx: &egui::Context) -> Self {
|
||||
Inbox { files: Rc::default(), reading: Rc::default(), ctx: ctx.clone() }
|
||||
Inbox { files: Rc::default(), ctx: ctx.clone() }
|
||||
}
|
||||
|
||||
/// Show the browser's file picker and return at once. A chosen file arrives through
|
||||
@@ -64,23 +51,13 @@ 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 files were chosen (browsers don't fire `change` on cancel).
|
||||
// Called once, when a file was chosen (browsers don't fire `change` on cancel).
|
||||
let on_change = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
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<String> = (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(", "))));
|
||||
}
|
||||
if let Some(file) = chosen.files().and_then(|f| f.get(0)) {
|
||||
inbox.read(file);
|
||||
}
|
||||
chosen.remove();
|
||||
});
|
||||
@@ -110,18 +87,12 @@ 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);
|
||||
});
|
||||
}
|
||||
@@ -147,21 +118,9 @@ impl Inbox {
|
||||
self.ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Open what arrived since the last frame (or report why it couldn't be read).
|
||||
pub fn drain(&self, app: &mut CadApp) {
|
||||
let (fonts, files): (Vec<Upload>, Vec<Upload>) = 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 {
|
||||
for upload in self.files.take() {
|
||||
match upload {
|
||||
Ok((name, bytes)) => app.open_bytes(&name, &bytes),
|
||||
Err(e) => app.open_failed(e),
|
||||
@@ -170,11 +129,6 @@ 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:?}"))
|
||||
}
|
||||
|
||||
@@ -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.guarded_logic(ctx);
|
||||
self.cad.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.guarded_ui(ui);
|
||||
self.cad.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
|
||||
}
|
||||
@@ -146,7 +146,6 @@ 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())
|
||||
});
|
||||
@@ -259,11 +258,6 @@ 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.
|
||||
@@ -295,9 +289,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -88,10 +88,6 @@ sync_github_name = false
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.assamaral]
|
||||
real_name = "assamaral"
|
||||
sync_github_name = false
|
||||
|
||||
[people.2ez4gcx]
|
||||
real_name = "Doan Huynh Huu Khuong"
|
||||
display_name = "Doan Huynh Huu Khuong"
|
||||
@@ -104,15 +100,5 @@ 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
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
@@ -11,13 +11,16 @@ use std::collections::HashSet;
|
||||
use cadcraft_doc::{Constraint, Drawing, Handle};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::model::{Model, Prep};
|
||||
use crate::{Comp, MAX_CONSTRAINTS, MAX_EVAL_WORK, SolveError, SolveOptions, build_with, lm};
|
||||
use crate::model::Model;
|
||||
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, 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;
|
||||
|
||||
@@ -136,8 +139,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
|
||||
/// (constrained or not) and the constraint components they belong to.
|
||||
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
|
||||
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
|
||||
let mut members_left = crate::model::MAX_RIGID_WORK;
|
||||
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -176,7 +179,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
if seen.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
let m = Model::build_with(d, &[h], &mut prep);
|
||||
let m = Model::build_budgeted(d, &[h], &mut members_left);
|
||||
let Some(s) = m.slots.first() else { continue };
|
||||
seen.insert(h);
|
||||
free_objects = true;
|
||||
@@ -234,36 +237,5 @@ mod budget_tests {
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
// Solving it is refused for the same reason, before any Jacobian is built.
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
}
|
||||
|
||||
/// Many Tangent constraints on one large fit spline rebuild it once while being interpreted,
|
||||
/// and the solve is refused by its rebuild budget instead of running.
|
||||
#[test]
|
||||
fn repeated_spline_tangents_rebuild_once_and_solve_is_bounded() {
|
||||
use cadcraft_doc::{Constraint, Line};
|
||||
use cadcraft_geom::Vec3;
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<_> = (0..400).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(Spline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let l =
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-1.0, 0.0, 0.0), b: Vec3::new(-1.0, 5.0, 0.0) })).unwrap();
|
||||
for id in 1..=200 {
|
||||
d.constraints.push(Constraint {
|
||||
id,
|
||||
kind: ConstraintKind::Tangent,
|
||||
refs: vec![GeomRef::whole(h), GeomRef::whole(l)],
|
||||
name: String::new(),
|
||||
expr: String::new(),
|
||||
});
|
||||
}
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().collect();
|
||||
let (comps, skipped) = crate::build(&d, &cons, &SolveOptions::default()).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let comp = comps.first().unwrap();
|
||||
assert_eq!(comp.built.len(), 200);
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
assert!(crate::purge_invalid(&mut d).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,14 +284,7 @@ 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();
|
||||
}
|
||||
@@ -316,9 +309,8 @@ impl Env {
|
||||
if stack.len() >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
// 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()))?)?;
|
||||
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
|
||||
let e = parse(&text)?;
|
||||
stack.push(name.to_string());
|
||||
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
|
||||
stack.pop();
|
||||
@@ -408,22 +400,6 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An oversized named expression is kept only as a bounded prefix and still fails, however
|
||||
/// often it is referenced.
|
||||
#[test]
|
||||
fn oversized_named_expression_is_bounded_and_fails() {
|
||||
let mut env = Env::new();
|
||||
let long = format!("1+{}", "é".repeat(500_000));
|
||||
env.insert("big", &long);
|
||||
assert!(env.exprs.get("big").is_some_and(|t| t.len() > MAX_LEN && t.len() <= MAX_LEN + 4));
|
||||
assert_eq!(env.value("big"), Err(ExprError::TooDeep));
|
||||
for _ in 0..200 {
|
||||
assert_eq!(env.eval("big"), Err(ExprError::TooDeep));
|
||||
}
|
||||
env.insert("ok", "1+1+1");
|
||||
assert_eq!(env.value("ok"), Ok(3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_replaces_whole_names_only() {
|
||||
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
|
||||
|
||||
@@ -30,7 +30,7 @@ mod lm;
|
||||
mod model;
|
||||
mod residual;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
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, Prep};
|
||||
use model::Model;
|
||||
use residual::{Built, Form};
|
||||
|
||||
/// Most constraints one drawing may hold.
|
||||
@@ -50,11 +50,6 @@ 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 {
|
||||
@@ -174,26 +169,10 @@ 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();
|
||||
// Collect the suffixes in use in one pass, then take the first free one: checking every
|
||||
// candidate against every name is quadratic in the number of names (CWE-407). Only a
|
||||
// suffix spelled as `format!("{prefix}{i}")` spells it counts (`d07` does not take `d7`).
|
||||
let taken: HashSet<u32> = d
|
||||
.parametric
|
||||
.parameters
|
||||
.iter()
|
||||
.map(|p| p.name.as_str())
|
||||
.chain(d.constraints.iter().map(|c| c.name.as_str()))
|
||||
.filter_map(|n| n.strip_prefix(prefix))
|
||||
.filter(|s| !s.starts_with('0') && s.bytes().all(|b| b.is_ascii_digit()))
|
||||
.filter_map(|s| s.parse::<u32>().ok())
|
||||
.collect();
|
||||
(1..=MAX_NAME_SUFFIX).find(|i| !taken.contains(i)).map_or_else(|| format!("{prefix}_x"), |i| format!("{prefix}{i}"))
|
||||
(1..=100_000).map(|i| format!("{prefix}{i}")).find(|n| !name_in_use(d, n)).unwrap_or_else(|| format!("{prefix}_x"))
|
||||
}
|
||||
|
||||
/// Next free constraint id.
|
||||
@@ -272,41 +251,12 @@ impl Comp {
|
||||
pub(crate) fn rows(&self, skip: Option<usize>) -> usize {
|
||||
self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum()
|
||||
}
|
||||
/// Work of one solver iteration's residual evaluations over `free`: every residual twice per
|
||||
/// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds
|
||||
/// are shared by residuals at the same points, see [`Model`]).
|
||||
pub(crate) fn eval_work(&self, free: &[usize], skip: Option<usize>) -> f64 {
|
||||
let rows = self.rows(skip) as f64;
|
||||
let evals = 2.0 * free.len() as f64 + TRIAL_EVALS;
|
||||
let mut splines: Vec<Handle> = Vec::new();
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) != skip && b.spline_reconstruct_cost(&self.model) > 0.0 {
|
||||
splines.extend(b.refs.iter().map(|r| r.handle).filter(|h| self.model.spline_reconstruct_cost(*h) > 0.0));
|
||||
}
|
||||
}
|
||||
splines.sort_unstable();
|
||||
splines.dedup();
|
||||
let rebuilds: f64 = splines
|
||||
.iter()
|
||||
.filter_map(|h| self.model.slot(*h))
|
||||
.map(|s| {
|
||||
let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64;
|
||||
(2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle)
|
||||
})
|
||||
.sum();
|
||||
evals * rows + rebuilds
|
||||
}
|
||||
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
|
||||
let free = self.free(skip);
|
||||
let rows = self.rows(skip);
|
||||
if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
// Numerical differentiation rebuilds a fit spline through a dense solve for every move of
|
||||
// its own fit points: refuse before iterating when that alone is too much (CWE-407).
|
||||
if self.eval_work(&free, skip) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
|
||||
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
|
||||
// A solution the ellipses would be stored differently from (axes swapped, minor axis
|
||||
@@ -460,24 +410,24 @@ fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
|
||||
|
||||
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
|
||||
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
build_with(d, cons, opts, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
let mut members_left = model::MAX_RIGID_WORK;
|
||||
build_budgeted(d, cons, opts, &mut members_left)
|
||||
}
|
||||
|
||||
/// Build constraint components sharing one caller-owned geometry preparation (block sampling and
|
||||
/// its member budget, spline rebuilds) between every model.
|
||||
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
|
||||
/// Return constraints that cannot be interpreted as skipped entries.
|
||||
pub(crate) fn build_with(
|
||||
pub(crate) fn build_budgeted(
|
||||
d: &Drawing,
|
||||
cons: &[&Constraint],
|
||||
opts: &SolveOptions,
|
||||
prep: &mut Prep,
|
||||
members_left: &mut usize,
|
||||
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut e = env(d);
|
||||
let mut skipped = Vec::new();
|
||||
let mut comps = Vec::new();
|
||||
for group in components(cons) {
|
||||
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
let mut model = Model::build_budgeted(d, &handles, members_left);
|
||||
let mut built = Vec::new();
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
@@ -690,11 +640,6 @@ pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
|
||||
/// Check that a constraint's references make sense for its type.
|
||||
pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
|
||||
validate_with(d, c, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// [`validate`] sharing block samples and spline rebuilds with other checks of the same drawing.
|
||||
fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), String> {
|
||||
let handles: Vec<Handle> = c.refs.iter().map(|r| r.handle).collect();
|
||||
for h in &handles {
|
||||
match d.entity(*h) {
|
||||
@@ -703,7 +648,7 @@ fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), Str
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
let mut model = Model::build(d, &handles);
|
||||
interpret(c, &mut model, 1.0).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -720,12 +665,7 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result<f6
|
||||
/// Returns the removed ids.
|
||||
pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
|
||||
let mut gone = Vec::new();
|
||||
// One preparation for every check: each block is sampled and each spline rebuilt once,
|
||||
// however many constraints refer to them (CWE-407). Its member budget is not shared, so
|
||||
// a constraint is never dropped because earlier ones spent it: every block is still
|
||||
// examined at most once, up to its own member limit.
|
||||
let mut prep = Prep::new(usize::MAX);
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate_with(d, c, &mut prep).is_ok()).collect();
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect();
|
||||
let mut i = 0;
|
||||
d.constraints.retain(|c| {
|
||||
let k = keep.get(i).copied().unwrap_or(true);
|
||||
@@ -812,9 +752,6 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
&& comp.built.get(pos).is_some_and(|b| b.form == Form::Lock)
|
||||
{
|
||||
let before = comp.free(Some(pos));
|
||||
if comp.eval_work(&before, Some(pos)) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let after = comp.free(None);
|
||||
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
|
||||
let rows_old = comp.rows(Some(pos));
|
||||
|
||||
+62
-194
@@ -14,11 +14,9 @@
|
||||
//! 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::{Block, Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
|
||||
use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
|
||||
use cadcraft_geom::{Spline as GSpline, Vec2, ccw_sweep};
|
||||
|
||||
use crate::curves::{Ell, Frame, clamped, spline_end};
|
||||
@@ -72,9 +70,6 @@ pub(crate) struct Slot {
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
/// The spline last rebuilt for each handle, with the defining points it was rebuilt from.
|
||||
type SplineCache = HashMap<Handle, (Vec<Vec2>, Rc<GSpline>)>;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Model {
|
||||
pub slots: Vec<Slot>,
|
||||
@@ -84,75 +79,20 @@ pub(crate) struct Model {
|
||||
splines: HashMap<Handle, GSpline>,
|
||||
/// Block reference rigid points relative to the insertion point at rotation 0.
|
||||
rigid: HashMap<Handle, Vec<Vec2>>,
|
||||
/// Rebuilt splines, shared with every model of the same [`Prep`]: residuals that ask for the
|
||||
/// same spline at the same defining points (several constraints on one spline, or derivatives
|
||||
/// taken along other objects' parameters) reuse one rebuild instead of a dense solve each.
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
/// Geometry preparation shared by every model built for one operation (a solve, a DOF report, a
|
||||
/// purge): rigid samples per block, a case-insensitive block-name index and rebuilt splines. A
|
||||
/// block referenced by many Inserts is sampled once, an unknown block name costs one hashed
|
||||
/// lookup, and a spline referenced by many constraints is rebuilt once (CWE-407).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Prep {
|
||||
/// Block members that may still be examined across the operation.
|
||||
members_left: usize,
|
||||
/// Block-local rigid samples by the name an Insert gives (`None`: no such block).
|
||||
blocks: HashMap<String, Option<Rc<[Vec2]>>>,
|
||||
/// Lower-cased block name to the stored name it resolves to, built on the first exact miss.
|
||||
folded: Option<HashMap<String, String>>,
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
impl Prep {
|
||||
/// Preparation that may examine at most `members` block members in all.
|
||||
pub(crate) fn new(members: usize) -> Prep {
|
||||
Prep { members_left: members, blocks: HashMap::new(), folded: None, rebuilt: Rc::default() }
|
||||
}
|
||||
|
||||
/// The block an Insert names, as [`Drawing::block`] resolves it (exact name, else the first
|
||||
/// stored name equal ignoring ASCII case), without scanning every block on each miss.
|
||||
fn resolve<'d>(&mut self, d: &'d Drawing, name: &str) -> Option<&'d Block> {
|
||||
if let Some(b) = d.blocks.get(name) {
|
||||
return Some(b);
|
||||
}
|
||||
let folded = self.folded.get_or_insert_with(|| {
|
||||
let mut m = HashMap::with_capacity(d.blocks.len());
|
||||
for k in d.blocks.keys() {
|
||||
m.entry(k.to_ascii_lowercase()).or_insert_with(|| k.clone());
|
||||
}
|
||||
m
|
||||
});
|
||||
d.blocks.get(folded.get(&name.to_ascii_lowercase())?).map(|b| &**b)
|
||||
}
|
||||
|
||||
/// Finite characteristic points of a block in its own coordinates, sampled once per name.
|
||||
fn block_points(&mut self, d: &Drawing, name: &str) -> Option<Rc<[Vec2]>> {
|
||||
if let Some(s) = self.blocks.get(name) {
|
||||
return s.clone();
|
||||
}
|
||||
let sampled = self.resolve(d, name).map(|b| sample_block(b, &mut self.members_left));
|
||||
self.blocks.insert(name.to_string(), sampled.clone());
|
||||
sampled
|
||||
}
|
||||
|
||||
/// World positions of an Insert's rigid points, at most [`MAX_RIGID_POINTS`].
|
||||
fn rigid_points(&mut self, d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let Some(local) = self.block_points(d, &ins.block) else { return Vec::new() };
|
||||
let Some(block) = self.resolve(d, &ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(block.base.xy());
|
||||
local.iter().map(|p| m.apply(*p)).filter(|p| p.is_finite()).take(MAX_RIGID_POINTS).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
|
||||
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
|
||||
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
|
||||
let Some(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 }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,18 +115,23 @@ pub fn supported(kind: &EntityKind) -> bool {
|
||||
/// centres, vertices, nested insertion points) of the block's own objects, at most
|
||||
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
|
||||
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
Prep::new(MAX_RIGID_MEMBERS).rigid_points(d, ins)
|
||||
let mut members_left = MAX_RIGID_MEMBERS;
|
||||
rigid_points_budgeted(d, ins, &mut members_left)
|
||||
}
|
||||
|
||||
/// 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]> {
|
||||
/// Sample finite block points within per-insert and caller-shared member limits.
|
||||
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
|
||||
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
|
||||
let Some(block) = d.block(&ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(block.base.xy());
|
||||
let mut out = Vec::new();
|
||||
// Bound the members examined, not only the points collected: entities that contribute no
|
||||
// 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 {
|
||||
// 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 {
|
||||
break;
|
||||
}
|
||||
*members_left -= 1;
|
||||
@@ -203,10 +148,10 @@ fn sample_block(block: &Block, members_left: &mut usize) -> Rc<[Vec2]> {
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().take(MAX_RIGID_POINTS).map(|v| v.p).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
out.extend(pts.into_iter().filter(|p| p.is_finite()));
|
||||
out.extend(pts.into_iter().map(|p| m.apply(p)).filter(|p| p.is_finite()));
|
||||
}
|
||||
out.truncate(MAX_RIGID_POINTS);
|
||||
out.into()
|
||||
out
|
||||
}
|
||||
|
||||
/// Constraint points of an object as the solver sees them (`Sub`, position): end points,
|
||||
@@ -242,12 +187,13 @@ 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 {
|
||||
Self::build_with(d, handles, &mut Prep::new(MAX_RIGID_WORK))
|
||||
let mut members_left = MAX_RIGID_WORK;
|
||||
Self::build_budgeted(d, handles, &mut members_left)
|
||||
}
|
||||
|
||||
/// 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, prep: &mut Prep) -> Option<Shape> {
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
|
||||
let shape = match kind {
|
||||
EntityKind::Line(l) => {
|
||||
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
|
||||
@@ -297,22 +243,15 @@ 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)));
|
||||
// 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() }
|
||||
};
|
||||
// Missing weights default to 1 in eval; only excess weights are unused.
|
||||
let mut kept = sp.clone();
|
||||
kept.weights.truncate(kept.control.len());
|
||||
m.splines.insert(h, kept);
|
||||
Shape::Spline { n, fit, ends }
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
||||
let pts: Vec<Vec2> = prep.rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).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);
|
||||
@@ -323,17 +262,16 @@ impl Model {
|
||||
Some(shape)
|
||||
}
|
||||
|
||||
/// 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() };
|
||||
/// 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();
|
||||
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, prep) else { continue };
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) 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;
|
||||
@@ -435,20 +373,18 @@ 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`. The last rebuild of
|
||||
/// each spline is kept, so asking again at the same points costs a comparison, not a rebuild.
|
||||
fn spline(&self, x: &[f64], s: &Slot) -> Option<Rc<GSpline>> {
|
||||
/// The spline of a reference with the defining points taken from `x`.
|
||||
fn spline(&self, x: &[f64], s: &Slot) -> Option<GSpline> {
|
||||
let Shape::Spline { n, fit, .. } = s.shape else { return None };
|
||||
let t = self.splines.get(&s.handle)?;
|
||||
let pts = (0..n).map(|i| p2(x, s.off + 2 * i)).collect::<Option<Vec<_>>>()?;
|
||||
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)
|
||||
Some(if fit {
|
||||
t.refit(&pts, t.closed)
|
||||
} else {
|
||||
let mut sp = t.clone();
|
||||
sp.control = pts;
|
||||
sp
|
||||
})
|
||||
}
|
||||
|
||||
/// The start (`at_end` false) or end of a curve reference, in the curve's own direction:
|
||||
@@ -485,7 +421,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,
|
||||
}
|
||||
}
|
||||
@@ -654,8 +590,7 @@ 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(|sp| {
|
||||
let mut sp = GSpline::clone(&sp);
|
||||
let spline = self.spline(x, s).map(|mut sp| {
|
||||
if matches!(shape, Shape::Spline { fit: false, .. }) {
|
||||
sp.fit.clear();
|
||||
}
|
||||
@@ -777,21 +712,21 @@ mod budget_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn add_block(d: &mut Drawing, name: &str, lines: usize) {
|
||||
let mut block = Block::new(name);
|
||||
for _ in 0..lines {
|
||||
/// 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 {
|
||||
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(name.into(), Arc::new(block));
|
||||
}
|
||||
|
||||
fn insert(block: &str) -> Insert {
|
||||
Insert {
|
||||
block: block.into(),
|
||||
d.blocks.insert("BAR".into(), Arc::new(block));
|
||||
let ins = Insert {
|
||||
block: "BAR".into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
@@ -800,80 +735,13 @@ mod budget_tests {
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
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<Vec2> = (0..6).map(|i| Vec2::new(i as f64, (i % 2) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(GSpline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let mut prep = Prep::new(MAX_RIGID_WORK);
|
||||
let a = Model::build_with(&d, &[h], &mut prep);
|
||||
let b = Model::build_with(&d, &[h], &mut prep);
|
||||
let (sa, sb) = (a.slot(h).unwrap(), b.slot(h).unwrap());
|
||||
assert!(matches!(sa.shape, Shape::Spline { fit: true, .. }));
|
||||
let first = a.spline(&a.x0, sa).unwrap();
|
||||
assert!(Rc::ptr_eq(&first, &a.spline(&a.x0, sa).unwrap()));
|
||||
assert!(Rc::ptr_eq(&first, &b.spline(&b.x0, sb).unwrap()));
|
||||
let mut moved = a.x0.clone();
|
||||
moved[1] += 0.5;
|
||||
let other = a.spline(&moved, sa).unwrap();
|
||||
assert!(!Rc::ptr_eq(&first, &other));
|
||||
assert_eq!(other.fit[0], Vec2::new(0.0, 0.5));
|
||||
}
|
||||
|
||||
/// A control spline's template keeps no fit points and no excess weights.
|
||||
#[test]
|
||||
fn control_template_drops_unused_data() {
|
||||
let mut d = Drawing::default();
|
||||
let mut sp = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)], 3);
|
||||
sp.fit = vec![Vec2::ZERO; 10_000];
|
||||
sp.fit_opts.tolerance = 0.5;
|
||||
sp.weights = vec![1.0; 10_000];
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap();
|
||||
let m = Model::build(&d, &[h]);
|
||||
let t = &m.splines[&h];
|
||||
assert!(t.fit.is_empty());
|
||||
assert_eq!(t.weights.len(), 4);
|
||||
};
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,30 +489,3 @@ 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");
|
||||
}
|
||||
|
||||
@@ -302,7 +302,6 @@ 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));
|
||||
@@ -344,7 +343,6 @@ 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));
|
||||
|
||||
@@ -228,7 +228,6 @@ 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) });
|
||||
}
|
||||
|
||||
@@ -60,13 +60,6 @@ pub struct Unknown {
|
||||
pub tags: Vec<RawTag>,
|
||||
}
|
||||
|
||||
impl Unknown {
|
||||
/// Whether this is an OLE2FRAME (the type name is matched without regard to case).
|
||||
pub fn is_ole_frame(&self) -> bool {
|
||||
self.dxf_type.eq_ignore_ascii_case("OLE2FRAME")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum EntityKind {
|
||||
@@ -369,13 +362,9 @@ impl EntityKind {
|
||||
i.scale.y *= s;
|
||||
i.scale.z *= s;
|
||||
i.rotation = cadcraft_geom::norm_angle(i.rotation + rot);
|
||||
// Mirrored as AutoCAD 2024 mirrors a block reference: the X scale factor
|
||||
// negated, the rotation 180 degrees plus twice the mirror line's angle less the
|
||||
// old one, and a MINSERT grid's columns running the other way.
|
||||
if mirror {
|
||||
i.scale.x = -i.scale.x;
|
||||
i.rotation = cadcraft_geom::norm_angle(std::f64::consts::PI + rot - (i.rotation - rot));
|
||||
i.col_spacing = -i.col_spacing;
|
||||
i.scale.y = -i.scale.y;
|
||||
i.rotation = cadcraft_geom::norm_angle(rot - (i.rotation - rot));
|
||||
}
|
||||
for a in &mut i.attribs {
|
||||
transform_text(&mut a.text, m, s, rot, false);
|
||||
@@ -476,7 +465,6 @@ impl EntityKind {
|
||||
let a = if mirror { t.rotation() } else { t.rotation() + rot };
|
||||
t.dir = Vec2::from_angle(cadcraft_geom::norm_angle(a)).to3(0.0);
|
||||
}
|
||||
EntityKind::Unknown(u) if u.is_ole_frame() => crate::ole_frame_transform(&mut u.tags, m),
|
||||
EntityKind::Unknown(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,25 +161,6 @@ 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<crate::ClipBoundary>,
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use cadcraft_geom::Mat3;
|
||||
|
||||
use super::common::Handle;
|
||||
use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes};
|
||||
|
||||
@@ -27,39 +25,6 @@ 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 {
|
||||
@@ -76,7 +41,6 @@ impl Default for Viewport {
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
twist: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,9 +59,8 @@ 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 (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.
|
||||
/// 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.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub clip: Vec<Vec2>,
|
||||
/// The clip boundary is applied.
|
||||
|
||||
@@ -207,41 +207,6 @@ pub struct MLineGeometry {
|
||||
pub fill: Vec<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
/// For each segment direction in `dirs`, the first non-zero direction at or after it and the
|
||||
/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero).
|
||||
///
|
||||
/// Two linear passes: searching from every segment separately is quadratic in a run of
|
||||
/// coincident vertices, which a hostile file can make as long as it likes (CWE-407).
|
||||
fn usable_directions(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else {
|
||||
return (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
};
|
||||
let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
// Walking backwards from the last usable direction, the latest one seen is the next ahead.
|
||||
let mut cur = Vec2::X;
|
||||
for k in 0..n {
|
||||
let i = (last + n - k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(a) = ahead.get_mut(i) {
|
||||
*a = cur;
|
||||
}
|
||||
}
|
||||
// Walking forwards from the first usable direction, the latest one seen is the nearest behind.
|
||||
for k in 0..n {
|
||||
let i = (first + k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(b) = behind.get_mut(i) {
|
||||
*b = cur;
|
||||
}
|
||||
}
|
||||
(ahead, behind)
|
||||
}
|
||||
|
||||
impl MLine {
|
||||
/// A multiline through `pts` in style `def`.
|
||||
pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine {
|
||||
@@ -303,11 +268,16 @@ impl MLine {
|
||||
let dirs = self.directions();
|
||||
let n = self.vertices.len();
|
||||
let nseg = dirs.len();
|
||||
// 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) };
|
||||
// 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)
|
||||
};
|
||||
let end = |d: Vec2, angle: f64| {
|
||||
let u = d.rotate(angle);
|
||||
let s = angle.sin();
|
||||
@@ -456,61 +426,3 @@ impl crate::Drawing {
|
||||
self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The nearest usable directions found by searching from each segment, as miters did before.
|
||||
fn searched(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
(ahead.collect(), behind.collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_directions_match_a_search_from_each_segment() {
|
||||
let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8));
|
||||
for dirs in [
|
||||
vec![],
|
||||
vec![z],
|
||||
vec![a],
|
||||
vec![z, z, z],
|
||||
vec![a, z, z, b],
|
||||
vec![z, a, z, b, z],
|
||||
vec![z, z, c],
|
||||
vec![c, z, z],
|
||||
vec![a, b, c],
|
||||
vec![z, a, z, z, b, c, z],
|
||||
] {
|
||||
assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_vertices_at_a_corner_take_the_corner_miter() {
|
||||
// Every vertex of a run on one point mitres between the segment before the run and the one after.
|
||||
let style = MLineStyle::default();
|
||||
let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0);
|
||||
let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)];
|
||||
let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0);
|
||||
let (expected, got) = (corner.miters(), run.miters());
|
||||
assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..]));
|
||||
assert_eq!((got.first(), got.last()), (expected.first(), expected.last()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_run_of_coincident_vertices_takes_linear_time() {
|
||||
// 200 000 vertices on one point but the last: the miters must not search the run once per
|
||||
// vertex (quadratic, minutes) to find the one usable direction.
|
||||
let mut pts = vec![Vec2::ZERO; 200_000];
|
||||
pts.push(Vec2::new(1.0, 0.0));
|
||||
let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0);
|
||||
let t = std::time::Instant::now();
|
||||
let miters = m.miters();
|
||||
assert_eq!(miters.len(), pts.len());
|
||||
assert!(miters.iter().all(|v| v.is_finite()));
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@ 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.
|
||||
@@ -79,49 +76,20 @@ 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. `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 {
|
||||
/// an outer loop, odd an island.
|
||||
pub fn renest(&mut self) {
|
||||
let polys = self.tessellate(self.fine_tol());
|
||||
// A loop's probe can only be inside loops whose box holds it: test only those. A box with
|
||||
// a non-finite corner says nothing (an edge through a non-finite point never counts, so
|
||||
// such a polygon need not be closed), so its loop is always tested.
|
||||
let boxes: Vec<Option<Bounds2>> = polys
|
||||
let depth: Vec<usize> = polys
|
||||
.iter()
|
||||
.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)
|
||||
.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()
|
||||
})
|
||||
.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
|
||||
@@ -160,37 +128,3 @@ fn probe(l: &[Vec2]) -> Option<Vec2> {
|
||||
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square(c: f64, half: f64, n: usize, outer: bool) -> HatchLoop {
|
||||
// `n` vertices along the bottom edge, then the other three corners.
|
||||
let mut vertices: Vec<PolyVertex> =
|
||||
(0..n).map(|k| PolyVertex::new(Vec2::new(c - half + 2.0 * half * k as f64 / n as f64, c - half))).collect();
|
||||
vertices.extend([Vec2::new(c + half, c - half), Vec2::new(c + half, c + half), Vec2::new(c - half, c + half)].map(PolyVertex::new));
|
||||
HatchLoop { vertices, outer, sources: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renest_sets_depth_parity_and_skips_loops_far_away() {
|
||||
// Three nested squares, and one beside them.
|
||||
let mut r =
|
||||
Region { loops: vec![square(0.0, 3.0, 1, false), square(0.0, 2.0, 1, false), square(0.0, 1.0, 1, false), square(10.0, 1.0, 1, false)] };
|
||||
assert!(r.renest());
|
||||
assert_eq!(r.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), [true, false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renest_of_many_large_nested_loops_stops_at_its_budget() {
|
||||
// Concentric loops of many vertices each: each probe is inside the box of every larger
|
||||
// loop (about loops² / 2 pairs), so each of those tests is a full polygon scan.
|
||||
let loops = 300;
|
||||
let n = 3 * MAX_RENEST_EDGE_VISITS / (loops * loops);
|
||||
let mut r = Region { loops: (0..loops).map(|i| square(0.0, 1.0 + i as f64, n, false)).collect() };
|
||||
let before = r.clone();
|
||||
assert!(!r.renest());
|
||||
assert_eq!(r, before, "flags left as they were");
|
||||
}
|
||||
}
|
||||
|
||||
+9
-682
@@ -9,239 +9,6 @@ use crate::{Drawing, Entity, EntityKind, LwPolyline, Prim};
|
||||
/// Nested block references deeper than this are ignored (cyclic or hostile files).
|
||||
pub const MAX_BLOCK_DEPTH: usize = 16;
|
||||
|
||||
/// Positions in `tags` of the groups outside application-defined `102 {` .. `102 }` groups, whose
|
||||
/// codes are the application's own and not the entity's.
|
||||
fn outside_app(tags: &[crate::RawTag]) -> impl Iterator<Item = usize> + '_ {
|
||||
let mut in_app = false;
|
||||
tags.iter().enumerate().filter_map(move |(i, t)| {
|
||||
if t.code == 102 {
|
||||
in_app = t.value.starts_with('{');
|
||||
None
|
||||
} else {
|
||||
(!in_app).then_some(i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The positions of the frame's corner groups 10, 20, 11, 21 (in that order) and their values:
|
||||
/// the first of each outside application groups, found in one pass that stops at the last.
|
||||
fn ole_corner_groups(tags: &[crate::RawTag]) -> Option<[(usize, f64); 4]> {
|
||||
let mut found: [Option<(usize, f64)>; 4] = [None; 4];
|
||||
for i in outside_app(tags) {
|
||||
let Some(t) = tags.get(i) else { continue };
|
||||
let slot = match t.code {
|
||||
10 => 0,
|
||||
20 => 1,
|
||||
11 => 2,
|
||||
21 => 3,
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(f) = found.get_mut(slot).filter(|f| f.is_none()) {
|
||||
*f = Some((i, t.value.trim().parse::<f64>().ok().filter(|v| v.is_finite())?));
|
||||
if found.iter().all(Option::is_some) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [Some(a), Some(b), Some(c), Some(d)] = found else { return None };
|
||||
Some([a, b, c, d])
|
||||
}
|
||||
|
||||
/// The closed outline of an OLE2FRAME from its upper-left (groups 10, 20) and lower-right
|
||||
/// (groups 11, 21) corners (DXF Reference, OLE2FRAME); `None` when either is missing or not finite.
|
||||
pub fn ole_frame(tags: &[crate::RawTag]) -> Option<[Vec2; 5]> {
|
||||
let [(_, x1), (_, y1), (_, x2), (_, y2)] = ole_corner_groups(tags)?;
|
||||
let (a, b) = (Vec2::new(x1, y1), Vec2::new(x2, y2));
|
||||
Some([a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y), a])
|
||||
}
|
||||
|
||||
/// The bytes of the OLE data before its corners: a 2-byte marker.
|
||||
const OLE_HEADER: usize = 2;
|
||||
/// The corners in the OLE data: four of x, y, z as little-endian doubles (upper left, upper right,
|
||||
/// lower right, lower left).
|
||||
const OLE_CORNERS: usize = 12;
|
||||
|
||||
/// Replace the four corners at the start of the OLE data (the 310 groups' hex, in order) by `new`
|
||||
/// (upper left, upper right, lower right, lower left), keeping z and every group's length and
|
||||
/// letter case. Only the groups that cover the header are decoded and rewritten; the rest of the
|
||||
/// payload is not touched. Data that is not valid hex, too short, or whose corners are not the old
|
||||
/// `frame`'s is left alone.
|
||||
fn ole_data_transform(tags: &mut [crate::RawTag], new: [Vec2; 4], frame: [f64; 4]) {
|
||||
const END: usize = OLE_HEADER + OLE_CORNERS * 8;
|
||||
let mut bytes: Vec<u8> = Vec::with_capacity(END);
|
||||
// The groups read, and how many leading bytes of each belong to the header.
|
||||
let mut spans: Vec<(usize, usize)> = Vec::new();
|
||||
for i in outside_app(tags).filter(|&i| tags.get(i).is_some_and(|t| t.code == 310)) {
|
||||
if bytes.len() >= END {
|
||||
break;
|
||||
}
|
||||
let Some(t) = tags.get(i) else { return };
|
||||
let s = t.value.trim();
|
||||
if s.len() % 2 != 0 || !s.is_ascii() {
|
||||
return;
|
||||
}
|
||||
let take = (s.len() / 2).min(END - bytes.len());
|
||||
for k in 0..take {
|
||||
// `from_str_radix` also takes a sign, so each digit is checked first.
|
||||
let Some(h) = s.get(2 * k..2 * k + 2).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())) else { return };
|
||||
let Ok(b) = u8::from_str_radix(h, 16) else { return };
|
||||
bytes.push(b);
|
||||
}
|
||||
spans.push((i, take));
|
||||
}
|
||||
if bytes.len() < END {
|
||||
return;
|
||||
}
|
||||
// Only data whose corners are this frame's corners (UL, UR, LR, LL) has the layout above; any
|
||||
// other header is not ours to rewrite.
|
||||
let [x1, y1, x2, y2] = frame;
|
||||
let want = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)];
|
||||
// Two corners match within a fixed tolerance for ordinary coordinates (CORNER_EPS at most), or
|
||||
// within a few ulps of the coordinate where that is larger, which is from about 1.1e9 up.
|
||||
let near = |a: f64, b: f64| {
|
||||
let m = a.abs().max(b.abs());
|
||||
(a - b).abs() <= (1e-9 * m.max(1.0)).min(CORNER_EPS).max(4.0 * f64::EPSILON * m)
|
||||
};
|
||||
for (c, (wx, wy)) in want.iter().enumerate() {
|
||||
let at = OLE_HEADER + c * 24;
|
||||
let rd = |o: usize| bytes.get(o..o + 8).and_then(|s| <[u8; 8]>::try_from(s).ok()).map(f64::from_le_bytes);
|
||||
let (Some(x), Some(y)) = (rd(at), rd(at + 8)) else { return };
|
||||
if !(near(x, *wx) && near(y, *wy)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (c, p) in new.iter().enumerate() {
|
||||
let at = OLE_HEADER + c * 24;
|
||||
if let Some(s) = bytes.get_mut(at..at + 8) {
|
||||
s.copy_from_slice(&p.x.to_le_bytes());
|
||||
}
|
||||
if let Some(s) = bytes.get_mut(at + 8..at + 16) {
|
||||
s.copy_from_slice(&p.y.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let mut pos = 0;
|
||||
for (i, n) in spans {
|
||||
let (Some(t), Some(chunk)) = (tags.get_mut(i), bytes.get(pos..pos + n)) else { return };
|
||||
// Leave any padding around the digits, and the digits after the header, as they were.
|
||||
let start = t.value.len() - t.value.trim_start().len();
|
||||
// Only the rewritten digits and a few after them, for the case of the nearest letter: a
|
||||
// hostile 310 line can be as long as the file (the value is checked to be ASCII above).
|
||||
let end = start.saturating_add(2 * n + 64).min(t.value.len());
|
||||
let hex = recase(chunk, t.value.get(start..end).unwrap_or_default());
|
||||
t.value.replace_range(start..start + 2 * n, &hex);
|
||||
pos += n;
|
||||
}
|
||||
}
|
||||
|
||||
/// The hex of `chunk`, each digit in the case of the digit at the same place in `old`. A digit that
|
||||
/// was a number and becomes a letter takes the case of the nearest letter in `old`, upper when there
|
||||
/// is none, so bytes that did not change keep their exact text.
|
||||
fn recase(chunk: &[u8], old: &str) -> String {
|
||||
let old = old.as_bytes();
|
||||
// For each place, whether the letter nearest to it (the one at it, else the closest before or
|
||||
// after; the one before on a tie) is lower case: one pass for the next letter at or after each
|
||||
// place, one for the previous, then the nearer of the two.
|
||||
let mut next: Vec<Option<(usize, bool)>> = vec![None; old.len()];
|
||||
let mut seen: Option<(usize, bool)> = None;
|
||||
for (i, c) in old.iter().enumerate().rev() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
seen = Some((i, c.is_ascii_lowercase()));
|
||||
}
|
||||
if let Some(slot) = next.get_mut(i) {
|
||||
*slot = seen;
|
||||
}
|
||||
}
|
||||
let mut lower_at = vec![false; old.len()];
|
||||
let mut prev: Option<(usize, bool)> = None;
|
||||
for (i, c) in old.iter().enumerate() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
prev = Some((i, c.is_ascii_lowercase()));
|
||||
}
|
||||
let after = next.get(i).copied().flatten();
|
||||
let lower = match (prev, after) {
|
||||
(Some((p, lp)), Some((q, lq))) => {
|
||||
if i - p <= q - i {
|
||||
lp
|
||||
} else {
|
||||
lq
|
||||
}
|
||||
}
|
||||
(Some((_, l)), None) | (None, Some((_, l))) => l,
|
||||
(None, None) => false,
|
||||
};
|
||||
if let Some(slot) = lower_at.get_mut(i) {
|
||||
*slot = lower;
|
||||
}
|
||||
}
|
||||
let mut out = String::with_capacity(chunk.len() * 2);
|
||||
for (k, b) in chunk.iter().enumerate() {
|
||||
for (j, d) in [b >> 4, b & 15].into_iter().enumerate() {
|
||||
let digit = char::from_digit(d.into(), 16).unwrap_or('0');
|
||||
out.push(if lower_at.get(2 * k + j).copied().unwrap_or(false) { digit } else { digit.to_ascii_uppercase() });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The fixed part of the tolerance when matching the corners in the OLE data to the frame's. The
|
||||
/// header holds the same f64 the frame groups print, so they agree to a few ulps. Up to about 1.1e9
|
||||
/// a fixed tolerance of at most this decides, so that distinct geometry does not match; above that,
|
||||
/// four ulps of the coordinate (`4 * EPSILON * |x|`) exceed it and decide instead.
|
||||
const CORNER_EPS: f64 = 1e-6;
|
||||
|
||||
/// Transform an OLE2FRAME by `m`: its two corners are mapped through `m` (a DWG save writes the
|
||||
/// frame's position from these groups), and so are the corners at the start of its OLE data (310
|
||||
/// groups), which a reader of the saved file may trust instead. The frame stays axis aligned with
|
||||
/// its upper-left corner first. A matrix that keeps the axes or swaps them (a move, scale, mirror or
|
||||
/// quarter turn) gives the box of the two mapped corners. Any other turn would collapse that box
|
||||
/// (a square turned 45° maps its two corners level), so the frame's centre is mapped and its size
|
||||
/// kept, scaled by `m`'s, its sides swapped when the turn is nearer a quarter turn. Commands refuse
|
||||
/// a rotation or mirror (see [`ole_frame_accepts`]), but internal callers such as EXPLODE, REFEDIT
|
||||
/// and paste still apply them, and must leave a consistent frame. A frame with no usable corners is
|
||||
/// left alone.
|
||||
pub fn ole_frame_transform(tags: &mut [crate::RawTag], m: &cadcraft_geom::Mat3) {
|
||||
let Some([(ix1, x1), (iy1, y1), (ix2, x2), (iy2, y2)]) = ole_corner_groups(tags) else { return };
|
||||
let frame = [x1, y1, x2, y2];
|
||||
let t = 1e-12 * (m.a.abs() + m.b.abs() + m.c.abs() + m.d.abs());
|
||||
let axes_kept = (m.b.abs() <= t && m.c.abs() <= t) || (m.a.abs() <= t && m.d.abs() <= t);
|
||||
let (a, b) = if axes_kept {
|
||||
(m.apply(Vec2::new(x1, y1)), m.apply(Vec2::new(x2, y2)))
|
||||
} else {
|
||||
let center = m.apply(Vec2::new((x1 + x2) / 2.0, (y1 + y2) / 2.0));
|
||||
// A plain turn keeps the size exactly; cos² + sin² can miss 1 by an ulp or two.
|
||||
let s = (m.a * m.d - m.b * m.c).abs().sqrt();
|
||||
let scale = if (s - 1.0).abs() < 1e-12 { 1.0 } else { s };
|
||||
let (mut w, mut h) = ((x2 - x1).abs() * scale, (y2 - y1).abs() * scale);
|
||||
// Where the frame's x axis goes: nearer to y than to x is nearer a quarter turn.
|
||||
if m.b.abs() > m.a.abs() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
(center - Vec2::new(w, h) / 2.0, center + Vec2::new(w, h) / 2.0)
|
||||
};
|
||||
if !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)));
|
||||
let (ul, lr) = (Vec2::new(lo.x, hi.y), Vec2::new(hi.x, lo.y));
|
||||
for (i, v) in [(ix1, ul.x), (iy1, ul.y), (ix2, lr.x), (iy2, lr.y)] {
|
||||
if let Some(t) = tags.get_mut(i) {
|
||||
t.value = v.to_string();
|
||||
}
|
||||
}
|
||||
ole_data_transform(tags, [ul, Vec2::new(lr.x, ul.y), lr, Vec2::new(ul.x, lr.y)], frame);
|
||||
}
|
||||
|
||||
/// Whether `m` can move an OLE2FRAME: a translation (move, copy, stretch of the whole frame) or a
|
||||
/// uniform scale with a positive factor, which keep the frame axis aligned and its upper-left corner
|
||||
/// upper-left.
|
||||
pub fn ole_frame_accepts(m: &cadcraft_geom::Mat3) -> bool {
|
||||
const T: f64 = 1e-12;
|
||||
// The tolerance grows with the scale: a matrix built from two angles (ALIGN) leaves rounding noise
|
||||
// in b and c of about `scale * 1e-16`.
|
||||
let t = T * m.a.abs();
|
||||
m.b.abs() < t && m.c.abs() < t && m.a > 0.0 && (m.a - m.d).abs() <= t
|
||||
}
|
||||
|
||||
/// Bounds of a single-line TEXT / ATTDEF / ATTRIB of `len` characters, following its justification
|
||||
/// the same way the renderer places it (the stroke font is about 0.88 of the height per character).
|
||||
fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
|
||||
@@ -327,48 +94,25 @@ 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, &mut HashMap::new())
|
||||
bounds_of(d, e, depth, false)
|
||||
}
|
||||
|
||||
/// 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, &mut HashMap::new())
|
||||
bounds_of(d, e, 0, true)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> 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, blocks, off_counts),
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
|
||||
}
|
||||
}
|
||||
bounds_in(d, e, depth, blocks, off_counts)
|
||||
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
@@ -412,23 +156,11 @@ 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 {
|
||||
// The grid's four corner copies (as many as are drawn): turned, the far corner
|
||||
// alone misses the side ones.
|
||||
let (cols, rows) =
|
||||
(f64::from(ins.cols.clamp(1, 10_000).saturating_sub(1)), f64::from(ins.rows.clamp(1, 10_000).saturating_sub(1)));
|
||||
let one = b;
|
||||
for (c, r) in [(cols, 0.0), (0.0, rows), (cols, rows)] {
|
||||
let off = Vec2::new(ins.col_spacing * c, ins.row_spacing * r).rotate(ins.rotation);
|
||||
b = b.union(&Bounds2::from_points(one.corners().map(|p| p + off)));
|
||||
}
|
||||
let off =
|
||||
Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
|
||||
.rotate(ins.rotation);
|
||||
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
|
||||
}
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
@@ -516,8 +248,6 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
Bounds2::from_points([o, o + u, o + v, o + u + v])
|
||||
}
|
||||
EntityKind::LwPolyline(p) => lwpolyline_bounds(p),
|
||||
// An OLE object's content is not drawn, but its frame is, so the frame counts (#2422).
|
||||
EntityKind::Unknown(u) if u.is_ole_frame() => ole_frame(&u.tags).map_or(Bounds2::EMPTY, Bounds2::from_points),
|
||||
kind => {
|
||||
let mut b = Bounds2::EMPTY;
|
||||
for p in kind.prims() {
|
||||
@@ -712,407 +442,4 @@ mod tests {
|
||||
empty.text = Some(crate::MText { contents: " ".into(), ..mtext });
|
||||
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
|
||||
}
|
||||
|
||||
/// An OLE2FRAME counts in the extents through its corner tags (#2422); a missing or
|
||||
/// non-finite corner counts as nothing.
|
||||
#[test]
|
||||
fn ole_frame_counts_in_extents() {
|
||||
let tag = |code: i32, value: &str| crate::RawTag { code, value: value.into() };
|
||||
let frame = |tags: Vec<crate::RawTag>| EntityKind::Unknown(crate::Unknown { dxf_type: "OLE2FRAME".into(), tags });
|
||||
let b = bounds(frame(vec![tag(10, "30"), tag(20, "-19"), tag(11, "35"), tag(21, "-22")]));
|
||||
assert_eq!((b.min, b.max), (Vec2::new(30.0, -22.0), Vec2::new(35.0, -19.0)));
|
||||
assert!(bounds(frame(vec![tag(10, "1"), tag(20, "2")])).is_empty(), "one corner is not a frame");
|
||||
assert!(bounds(frame(vec![tag(10, "NaN"), tag(20, "0"), tag(11, "1"), tag(21, "inf")])).is_empty());
|
||||
assert!(bounds(EntityKind::Unknown(crate::Unknown { dxf_type: "OTHER".into(), tags: vec![tag(10, "5"), tag(20, "5")] })).is_empty());
|
||||
}
|
||||
|
||||
/// Groups 10/20 inside an application-defined `102 {...}` group belong to the application, not
|
||||
/// to the frame: the real corners come after it (review on #2587).
|
||||
#[test]
|
||||
fn ole_frame_skips_application_groups() {
|
||||
let tag = |code: i32, value: &str| crate::RawTag { code, value: value.into() };
|
||||
let tags = vec![
|
||||
tag(102, "{ACAD_REACTORS"),
|
||||
tag(10, "900"),
|
||||
tag(20, "900"),
|
||||
tag(11, "901"),
|
||||
tag(21, "901"),
|
||||
tag(102, "}"),
|
||||
tag(10, "30"),
|
||||
tag(20, "-19"),
|
||||
tag(11, "35"),
|
||||
tag(21, "-22"),
|
||||
];
|
||||
let q = ole_frame(&tags).expect("frame");
|
||||
assert_eq!((q[0], q[2]), (Vec2::new(30.0, -19.0), Vec2::new(35.0, -22.0)));
|
||||
assert!(ole_frame(&tags[..6]).is_none(), "corners only inside the group are not a frame");
|
||||
}
|
||||
|
||||
#[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<Entity> = (0..50).map(|i| ins(if i % 2 == 0 { "B" } else { "N" }, f64::from(i))).collect();
|
||||
// One cache for both kinds of measure, interleaved: neither may answer for the other.
|
||||
let mut cache = BlockBoundsCache::default();
|
||||
for e in &ents {
|
||||
let (drawn, extents) = (entity_bounds_cached(&d, e, 0, &mut cache), extent_bounds_cached(&d, e, &mut cache));
|
||||
let (want_drawn, want_extents) = (entity_bounds(&d, e, 0), extent_bounds(&d, e));
|
||||
assert_eq!((drawn.min, drawn.max), (want_drawn.min, want_drawn.max));
|
||||
assert_eq!((extents.min, extents.max), (want_extents.min, want_extents.max));
|
||||
assert!(extents.width() > drawn.width());
|
||||
}
|
||||
}
|
||||
|
||||
/// A turned array of references reaches its side copies, not only the first and the last;
|
||||
/// a hostile count measures only the copies that are drawn.
|
||||
#[test]
|
||||
fn a_rotated_array_counts_every_corner_copy() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let mut b = crate::Block::new("B");
|
||||
b.entities.push(Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) })));
|
||||
d.blocks.insert("B".into(), std::sync::Arc::new(b));
|
||||
let array = |cols: u32, rows: u32, rotation: f64| {
|
||||
let ins = crate::Insert {
|
||||
block: "B".into(),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation,
|
||||
cols,
|
||||
rows,
|
||||
col_spacing: 10.0,
|
||||
row_spacing: 10.0,
|
||||
..crate::Insert::default()
|
||||
};
|
||||
Entity::new(crate::Handle(2), EntityKind::Insert(ins))
|
||||
};
|
||||
// Turned 45°: the copies sit at (0, 0), (7.07, 7.07), (-7.07, 7.07) and (0, 14.14).
|
||||
let b = entity_bounds(&d, &array(2, 2, std::f64::consts::FRAC_PI_4), 0);
|
||||
let side = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
|
||||
assert!((b.min.x + side).abs() < 1e-9 && b.max.x >= side, "{b:?}");
|
||||
assert!((b.max.y - 2.0 * side - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-9, "{b:?}");
|
||||
// The renderer draws at most 10,000 columns.
|
||||
let b = entity_bounds(&d, &array(u32::MAX, 1, 0.0), 0);
|
||||
assert!((b.max.x - (10.0 * 9_999.0 + 1.0)).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// An OLE2FRAME at large coordinates, with its OLE data header holding the frame's corners
|
||||
/// shifted by `dx` in x.
|
||||
fn big_frame(dx: f64) -> Vec<crate::RawTag> {
|
||||
let (x1, y1, x2, y2) = (1e9, 1e9, 1e9 + 10.0, 1e9 + 5.0);
|
||||
let mut blob = vec![1u8, 0];
|
||||
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
|
||||
for v in [x + dx, y, 7.0] {
|
||||
blob.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let tag = |code: i32, value: String| crate::RawTag { code, value };
|
||||
vec![
|
||||
tag(10, x1.to_string()),
|
||||
tag(20, y1.to_string()),
|
||||
tag(11, x2.to_string()),
|
||||
tag(21, y2.to_string()),
|
||||
tag(310, blob.iter().map(|b| format!("{b:02X}")).collect()),
|
||||
]
|
||||
}
|
||||
|
||||
/// The x of the first corner in the OLE data of a [`big_frame`].
|
||||
fn data_x(tags: &[crate::RawTag]) -> f64 {
|
||||
let hex = tags.last().map(|t| t.value.as_str()).unwrap_or_default();
|
||||
let bytes: Vec<u8> = (0..hex.len() / 2).filter_map(|i| u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()).collect();
|
||||
bytes.get(2..10).and_then(|s| <[u8; 8]>::try_from(s).ok()).map(f64::from_le_bytes).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// The match between the frame and the corners in its OLE data does not loosen with the
|
||||
/// coordinate: half a unit off at 1e9 is not the frame, so the bytes stay as they are (review
|
||||
/// on #2587).
|
||||
#[test]
|
||||
fn a_half_unit_offset_at_large_coordinates_is_not_the_frame() {
|
||||
let mut tags = big_frame(0.5);
|
||||
let before = tags.last().cloned();
|
||||
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(1.0, 1.0)));
|
||||
assert_eq!(tags.first().map(|t| t.value.as_str()), Some("1000000001"), "the frame tags move");
|
||||
assert_eq!(tags.last().map(|t| &t.value), before.as_ref().map(|t| &t.value), "the 310 bytes stay identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_corners_at_large_coordinates_still_move() {
|
||||
let mut tags = big_frame(0.0);
|
||||
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(1.0, 1.0)));
|
||||
assert_eq!(data_x(&tags), 1e9 + 1.0);
|
||||
}
|
||||
|
||||
/// A translation, and a uniform positive scale about any point, keep a frame axis aligned and
|
||||
/// upper-left; a rotation, a mirror, a non-uniform or non-positive scale do not.
|
||||
#[test]
|
||||
fn ole_frame_accepts_translations_and_uniform_positive_scales_only() {
|
||||
use cadcraft_geom::Mat3;
|
||||
let p = Vec2::new(3.0, -4.0);
|
||||
assert!(ole_frame_accepts(&Mat3::translate(p)));
|
||||
assert!(ole_frame_accepts(&Mat3::scale_about(p, 2.5)));
|
||||
assert!(ole_frame_accepts(&Mat3::scale_about(p, 1e-3)));
|
||||
assert!(!ole_frame_accepts(&Mat3::scale(2.0, 3.0)));
|
||||
assert!(!ole_frame_accepts(&Mat3::scale_about(p, -1.0)));
|
||||
assert!(!ole_frame_accepts(&Mat3::scale_about(p, 0.0)));
|
||||
assert!(!ole_frame_accepts(&Mat3::rotate_about(p, 0.5)));
|
||||
assert!(!ole_frame_accepts(&Mat3::mirror(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0))));
|
||||
}
|
||||
/// ALIGN with a scale builds its matrix from two angles that agree only to rounding, so a large
|
||||
/// scale leaves a rotation part of about `scale * 1e-16`. That is a scale, not a turn: the
|
||||
/// tolerance for it grows with the scale, while a real turn is still refused (review on #2587).
|
||||
#[test]
|
||||
fn ole_frame_accepts_a_large_scale_with_rounding_noise_in_its_rotation() {
|
||||
use cadcraft_geom::Mat3;
|
||||
let p = Vec2::new(3.0, -4.0);
|
||||
let noisy = Mat3::scale_about(p, 1e6).then_before(Mat3::rotate_about(p, 2e-16));
|
||||
assert!(noisy.b.abs() > 1e-12, "the setup must exceed an absolute tolerance: {}", noisy.b);
|
||||
assert!(ole_frame_accepts(&noisy));
|
||||
let turned = Mat3::scale_about(p, 1e6).then_before(Mat3::rotate_about(p, 1e-6));
|
||||
assert!(!ole_frame_accepts(&turned));
|
||||
}
|
||||
|
||||
/// The corners (x, y) in the OLE data header of a frame built by [`big_frame`]-style tags.
|
||||
fn header_corners(tags: &[crate::RawTag]) -> Vec<(f64, f64)> {
|
||||
let hex: String = tags.iter().filter(|t| t.code == 310).map(|t| t.value.as_str()).collect();
|
||||
let bytes: Vec<u8> = (0..hex.len() / 2).filter_map(|i| u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()).collect();
|
||||
let f = |o: usize| bytes.get(o..o + 8).and_then(|s| <[u8; 8]>::try_from(s).ok()).map(f64::from_le_bytes).unwrap_or(f64::NAN);
|
||||
(0..4).map(|c| (f(2 + c * 24), f(10 + c * 24))).collect()
|
||||
}
|
||||
|
||||
fn small_frame() -> Vec<crate::RawTag> {
|
||||
let mut blob = vec![1u8, 0];
|
||||
for (x, y) in [(30.0f64, -19.0f64), (35.0, -19.0), (35.0, -22.0), (30.0, -22.0)] {
|
||||
for v in [x, y, 7.0] {
|
||||
blob.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let tag = |code: i32, value: &str| crate::RawTag { code, value: value.into() };
|
||||
vec![
|
||||
tag(10, "30"),
|
||||
tag(20, "-19"),
|
||||
tag(11, "35"),
|
||||
tag(21, "-22"),
|
||||
crate::RawTag { code: 310, value: blob.iter().map(|b| format!("{b:02X}")).collect() },
|
||||
]
|
||||
}
|
||||
|
||||
/// Internal callers (EXPLODE, REFEDIT, paste, array placement) transform a frame by any matrix.
|
||||
/// It stays axis aligned: groups 10/20 are the upper-left of its box, 11/21 the lower-right,
|
||||
/// and the OLE data holds the same box (review on #2587). A mirror keeps the axes, so the box
|
||||
/// is that of the mapped corners; a 30° turn keeps the frame's own 5 x 3 about its turned
|
||||
/// centre, where the corners' box would flatten it.
|
||||
#[test]
|
||||
fn a_rotated_frame_stays_axis_aligned_and_consistent() {
|
||||
use cadcraft_geom::Mat3;
|
||||
let rotate = Mat3::rotate_about(Vec2::new(1.0, 2.0), 30f64.to_radians());
|
||||
let mirror = Mat3::mirror(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0));
|
||||
for (m, turned) in [(rotate, true), (mirror, false)] {
|
||||
let mut tags = small_frame();
|
||||
let mapped = if turned {
|
||||
let c = m.apply(Vec2::new(32.5, -20.5));
|
||||
[c - Vec2::new(2.5, 1.5), c + Vec2::new(2.5, 1.5)]
|
||||
} else {
|
||||
[m.apply(Vec2::new(30.0, -19.0)), m.apply(Vec2::new(35.0, -22.0))]
|
||||
};
|
||||
let (minx, maxx) = (mapped[0].x.min(mapped[1].x), mapped[0].x.max(mapped[1].x));
|
||||
let (miny, maxy) = (mapped[0].y.min(mapped[1].y), mapped[0].y.max(mapped[1].y));
|
||||
ole_frame_transform(&mut tags, &m);
|
||||
let g = |code: i32| tags.iter().find(|t| t.code == code).and_then(|t| t.value.parse::<f64>().ok()).unwrap_or(f64::NAN);
|
||||
assert_eq!((g(10), g(20), g(11), g(21)), (minx, maxy, maxx, miny));
|
||||
assert_eq!(header_corners(&tags), vec![(minx, maxy), (maxx, maxy), (maxx, miny), (minx, miny)]);
|
||||
}
|
||||
}
|
||||
/// `from_str_radix` accepts a leading sign, so "+A" must not count as a hex byte (review on
|
||||
/// #2587): data with one is not valid and MOVE leaves its bytes alone.
|
||||
#[test]
|
||||
fn a_plus_sign_in_ole_data_is_not_hex() {
|
||||
let mut tags = small_frame();
|
||||
let hex = tags.last().map(|t| t.value.clone()).unwrap_or_default();
|
||||
// The marker's first byte is 01: "+1" would decode to the same byte.
|
||||
let signed = format!("+1{}", hex.get(2..).unwrap_or_default());
|
||||
if let Some(t) = tags.last_mut() {
|
||||
t.value = signed.clone();
|
||||
}
|
||||
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(10.0, 5.0)));
|
||||
assert_eq!(tags.first().map(|t| t.value.as_str()), Some("40"), "the frame tags still move");
|
||||
assert_eq!(tags.last().map(|t| t.value.as_str()), Some(signed.as_str()));
|
||||
}
|
||||
|
||||
/// A payload of 1,000 groups of 127 bytes: only the group(s) holding the 98-byte header are
|
||||
/// rewritten. Later groups stay as they were, even ones that are not valid hex or have padding
|
||||
/// (the old code re-encoded the whole payload and gave up on any bad group).
|
||||
#[test]
|
||||
fn a_large_ole_payload_only_rewrites_its_leading_groups() {
|
||||
let mut tags = small_frame();
|
||||
let first = tags.pop().map(|t| t.value).unwrap_or_default();
|
||||
let mut head = first.clone();
|
||||
// Fill the first group up to 127 bytes.
|
||||
head.push_str(&"ab".repeat(127 - first.len() / 2));
|
||||
tags.push(crate::RawTag { code: 310, value: head });
|
||||
for g in 1..1000 {
|
||||
let value = match g {
|
||||
2 => format!(" {} ", "Ab".repeat(127)),
|
||||
500 => "ZZ".repeat(127),
|
||||
_ => "ab".repeat(127),
|
||||
};
|
||||
tags.push(crate::RawTag { code: 310, value });
|
||||
}
|
||||
let rest: Vec<String> = tags.iter().skip(5).map(|t| t.value.clone()).collect();
|
||||
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(10.0, 5.0)));
|
||||
assert_eq!(header_corners(&tags[..5]), vec![(40.0, -14.0), (45.0, -14.0), (45.0, -17.0), (40.0, -17.0)]);
|
||||
let after: Vec<String> = tags.iter().skip(5).map(|t| t.value.clone()).collect();
|
||||
assert_eq!(after, rest, "every group after the first is string-identical");
|
||||
assert_eq!(tags.get(4).map(|t| t.value.len()), Some(254), "the first group keeps its length");
|
||||
}
|
||||
|
||||
/// Above about 8.6e9 one ulp of an f64 is more than the fixed ceiling on the corner match, so a
|
||||
/// text corner one ulp from the header double must still count as the same corner (review on
|
||||
/// #2587).
|
||||
#[test]
|
||||
fn ole_frame_moves_at_2e10_with_a_one_ulp_text_corner() {
|
||||
let mut tags = big_frame(0.0);
|
||||
// Rebuild the frame at 2e10: the header keeps the exact corners, the text is one ulp off.
|
||||
let (x1, y1, x2, y2) = (2e10f64, 2e10f64, 2e10 + 10.0, 2e10 + 5.0);
|
||||
let mut blob = vec![1u8, 0];
|
||||
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
|
||||
for v in [x, y, 7.0] {
|
||||
blob.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let text_x1 = f64::from_bits(x1.to_bits() + 1);
|
||||
tags[0].value = text_x1.to_string();
|
||||
tags[1].value = y1.to_string();
|
||||
tags[2].value = x2.to_string();
|
||||
tags[3].value = y2.to_string();
|
||||
if let Some(t) = tags.last_mut() {
|
||||
t.value = blob.iter().map(|b| format!("{b:02X}")).collect();
|
||||
}
|
||||
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(1e4, 1e4)));
|
||||
assert_eq!(header_corners(&tags)[0].0, text_x1 + 1e4, "the header moved with the frame");
|
||||
}
|
||||
|
||||
/// Below scale 1 the rotation bound is still relative to the scale: a small real turn at a tiny
|
||||
/// scale is refused, and so is a non-uniform scale whose two factors differ by more than a
|
||||
/// relative 1e-12 (review on #2587).
|
||||
#[test]
|
||||
fn ole_frame_refuses_a_small_turn_at_a_tiny_scale() {
|
||||
use cadcraft_geom::Mat3;
|
||||
let p = Vec2::new(3.0, -4.0);
|
||||
let turned = Mat3::scale_about(p, 1e-9).then_before(Mat3::rotate_about(p, 0.05f64.to_radians()));
|
||||
assert!(!ole_frame_accepts(&turned));
|
||||
assert!(ole_frame_accepts(&Mat3::scale_about(p, 1e-9)));
|
||||
assert!(!ole_frame_accepts(&Mat3::scale(1e-6, 1e-6 + 5e-13)));
|
||||
}
|
||||
|
||||
/// Hex digits keep the case they had: bytes the transform does not change come back as the
|
||||
/// identical text, also in a group that mixes upper and lower case (review on #2587).
|
||||
#[test]
|
||||
fn moving_an_ole_frame_keeps_each_hex_digits_case() {
|
||||
let mut tags = small_frame();
|
||||
let old = tags.last().map(|t| t.value.clone()).unwrap_or_default();
|
||||
let mixed: String = old.chars().enumerate().map(|(i, c)| if i < 24 { c.to_ascii_lowercase() } else { c }).collect();
|
||||
assert_ne!(mixed, old);
|
||||
if let Some(t) = tags.last_mut() {
|
||||
t.value = mixed.clone();
|
||||
}
|
||||
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(10.0, 5.0)));
|
||||
let new = tags.last().map(|t| t.value.clone()).unwrap_or_default();
|
||||
assert_eq!(new.len(), mixed.len());
|
||||
let byte = |s: &str, i: usize| s.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
let mut same = 0;
|
||||
for i in 0..mixed.len() / 2 {
|
||||
if byte(&mixed, i) == byte(&new, i) {
|
||||
same += 1;
|
||||
assert_eq!(mixed.get(2 * i..2 * i + 2), new.get(2 * i..2 * i + 2), "byte {i}");
|
||||
}
|
||||
}
|
||||
assert!(same > 40, "most of the header is unchanged: {same}");
|
||||
assert_eq!(header_corners(&tags)[0], (40.0, -14.0));
|
||||
}
|
||||
|
||||
/// A frame from `(x1, y1)` (upper left) to `(x2, y2)` (lower right), its OLE header holding the
|
||||
/// same corners.
|
||||
fn frame_at(x1: f64, y1: f64, x2: f64, y2: f64) -> Vec<crate::RawTag> {
|
||||
let mut blob = vec![1u8, 0];
|
||||
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
|
||||
for v in [x, y, 0.0] {
|
||||
blob.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let tag = |code: i32, value: String| crate::RawTag { code, value };
|
||||
vec![
|
||||
tag(10, x1.to_string()),
|
||||
tag(20, y1.to_string()),
|
||||
tag(11, x2.to_string()),
|
||||
tag(21, y2.to_string()),
|
||||
tag(310, blob.iter().map(|b| format!("{b:02X}")).collect()),
|
||||
]
|
||||
}
|
||||
|
||||
fn size(tags: &[crate::RawTag]) -> (f64, f64, Vec2) {
|
||||
let o = ole_frame(tags).unwrap();
|
||||
let b = Bounds2::from_points(o);
|
||||
(b.width(), b.height(), b.center())
|
||||
}
|
||||
|
||||
/// A frame turned by an angle that isn't a quarter turn keeps its size about its turned
|
||||
/// centre instead of collapsing to the box of its two corners (a 10 x 10 square turned 45°
|
||||
/// became 14.14 x 0), and the OLE header follows (#2587 review).
|
||||
#[test]
|
||||
fn a_frame_turned_off_the_axes_keeps_its_size() {
|
||||
use cadcraft_geom::Mat3;
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
let mut tags = frame_at(0.0, 10.0, 10.0, 0.0);
|
||||
ole_frame_transform(&mut tags, &Mat3::rotate(std::f64::consts::FRAC_PI_4));
|
||||
let (w, h, c) = size(&tags);
|
||||
assert!(close(w, 10.0) && close(h, 10.0), "{w} x {h}");
|
||||
assert!(close(c.x, 0.0) && close(c.y, 50f64.sqrt()), "{c:?}");
|
||||
let (ul, lr) = (header_corners(&tags)[0], header_corners(&tags)[2]);
|
||||
assert!(close(ul.0, -5.0) && close(lr.1, 50f64.sqrt() - 5.0), "the header follows: {ul:?} {lr:?}");
|
||||
// Nearer a quarter turn, the sides swap: a 10 wide, 4 tall frame turned 100° is 4 x 10.
|
||||
let mut tags = frame_at(0.0, 4.0, 10.0, 0.0);
|
||||
ole_frame_transform(&mut tags, &Mat3::rotate(100f64.to_radians()));
|
||||
let (w, h, _) = size(&tags);
|
||||
assert!(close(w, 4.0) && close(h, 10.0), "{w} x {h}");
|
||||
// A turn and its inverse bring the frame back.
|
||||
let mut tags = frame_at(2.0, 9.0, 7.0, 3.0);
|
||||
let m = Mat3::rotate(0.3);
|
||||
ole_frame_transform(&mut tags, &m);
|
||||
ole_frame_transform(&mut tags, &m.inverse().unwrap());
|
||||
let (w, h, c) = size(&tags);
|
||||
assert!(close(w, 5.0) && close(h, 6.0) && close(c.x, 4.5) && close(c.y, 6.0), "{w} x {h} at {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,19 +75,9 @@ pub struct Foreign {
|
||||
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
|
||||
/// names its entity with a 320 handle (see [`Foreign::data_store_entities`]).
|
||||
pub data_store: Vec<RawTag>,
|
||||
/// The `$ACADVER` of the file all this was read from: its groups are in that version's form.
|
||||
/// The drawing's own `$ACADVER` is the version it is saved as, which SAVEAS changes, so a
|
||||
/// later save must not take it for this one (#2471).
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl Foreign {
|
||||
/// The class of the file's CLASSES a proxy's group 91 names (see [`class_number`]), if any.
|
||||
/// Saving as DWG keeps a proxy with that class and leaves out one whose class it can't name.
|
||||
pub fn proxy_class(&self, tags: &[RawTag]) -> Option<&str> {
|
||||
class_index(class_number(tags.iter().map(|r| (r.code, r.value.as_str())))).and_then(|i| self.class_order.get(i)).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.objects.is_empty()
|
||||
&& self.dictionary.is_empty()
|
||||
@@ -266,29 +256,3 @@ impl Foreign {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a proxy's class number is: its first group 91 outside the `102 {NAME` … `102 }` groups
|
||||
/// (an application's can hold a 91 of its own) and before any xdata.
|
||||
pub fn class_group<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)>) -> Option<usize> {
|
||||
let mut in_group = false;
|
||||
for (i, (code, value)) in groups.into_iter().enumerate() {
|
||||
match code {
|
||||
1000.. => return None,
|
||||
102 => in_group = value.as_ref().trim().starts_with('{'),
|
||||
91 if !in_group => return Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A proxy's class number (see [`class_group`]).
|
||||
pub fn class_number<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)> + Clone) -> Option<i64> {
|
||||
let i = class_group(groups.clone())?;
|
||||
groups.into_iter().nth(i).and_then(|(_, v)| v.as_ref().trim().parse().ok())
|
||||
}
|
||||
|
||||
/// The place in CLASSES a class number names (500 is the first class).
|
||||
pub fn class_index(number: Option<i64>) -> Option<usize> {
|
||||
usize::try_from(number?.checked_sub(500)?).ok()
|
||||
}
|
||||
|
||||
@@ -90,23 +90,6 @@ impl Header {
|
||||
.filter(|p| p.is_finite() && if allow_zero { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 })
|
||||
.unwrap_or(Vec2::new(0.5, 0.5))
|
||||
}
|
||||
/// The model view the drawing was saved at: `$VIEWCTR` (centre) and `$VIEWSIZE` (height, in
|
||||
/// drawing units), when both are present and usable. A DXF file keeps the pair in HEADER and
|
||||
/// repeats it in the `*ACTIVE` VPORT record (12/22 and 40); a reader opens the drawing on it
|
||||
/// rather than on the extents, so a drawing saved on one detail of a large sheet opens there.
|
||||
pub fn saved_view(&self) -> Option<(Vec2, f64)> {
|
||||
let center = self.point("VIEWCTR")?.xy();
|
||||
let height = self.get("VIEWSIZE").and_then(HVal::as_f64)?;
|
||||
(center.is_finite() && height.is_finite() && height > 1e-9).then_some((center, height))
|
||||
}
|
||||
/// Record the view the drawing is saved at (see [`Header::saved_view`]). A view that cannot
|
||||
/// be shown is not recorded, so a usable stored view is never replaced by an unusable one.
|
||||
pub fn set_saved_view(&mut self, center: Vec2, height: f64) {
|
||||
if center.is_finite() && height.is_finite() && height > 1e-9 {
|
||||
self.set("VIEWCTR", HVal::Point(center.to3(0.0)));
|
||||
self.set_f64("VIEWSIZE", height);
|
||||
}
|
||||
}
|
||||
pub fn set_f64(&mut self, name: &str, v: f64) {
|
||||
self.set(name, HVal::Real(v));
|
||||
}
|
||||
|
||||
+2
-15
@@ -16,11 +16,9 @@ mod header;
|
||||
pub mod library;
|
||||
mod plotstyle;
|
||||
mod plotter;
|
||||
mod raster;
|
||||
mod refedit;
|
||||
mod store;
|
||||
mod tables;
|
||||
mod xclip;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -29,20 +27,15 @@ pub use cadcraft_color as color;
|
||||
pub use cadcraft_geom as geom;
|
||||
pub use constraint::*;
|
||||
pub use entity::*;
|
||||
pub use extents::{
|
||||
BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached, ole_frame, ole_frame_accepts,
|
||||
ole_frame_transform,
|
||||
};
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
|
||||
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;
|
||||
@@ -143,8 +136,6 @@ 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 {
|
||||
@@ -205,7 +196,6 @@ impl Drawing {
|
||||
refedit: None,
|
||||
skipped_records: BTreeMap::new(),
|
||||
portable_fonts: false,
|
||||
rasters: Rasters::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,13 +383,10 @@ 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_cached(self, e, &mut cache));
|
||||
b = b.union(&extent_bounds(self, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
//! 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<u8>,
|
||||
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<u8>) -> Option<Raster> {
|
||||
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<BTreeMap<String, Arc<Raster>>>);
|
||||
|
||||
impl Rasters {
|
||||
pub fn get(&self, path: &str) -> Option<&Arc<Raster>> {
|
||||
self.0.get(path)
|
||||
}
|
||||
pub fn insert(&mut self, path: &str, raster: Arc<Raster>) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -799,13 +799,6 @@ pub struct PageSetup {
|
||||
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
|
||||
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
|
||||
pub paper_mm: bool,
|
||||
/// Paper size (DXF groups 44, 45) and plot rotation (group 73) as the file stored them.
|
||||
/// Saving writes them back while they still describe `width_mm`, `height_mm` and
|
||||
/// `landscape`, so each program's convention for media defined wider than tall survives a
|
||||
/// round trip: AutoCAD stores a portrait size and a quarter turn, ZWCAD the media's own size
|
||||
/// and no turn (#1463). `None` for page setups made in CADCraft.
|
||||
#[serde(skip)]
|
||||
pub file_paper: Option<(f64, f64, i64)>,
|
||||
/// The paper image origin (DXF groups 148/149, in plot paper units) as the file had it, so a
|
||||
/// save keeps where the sheet sits; `None` for a page CADCraft made, written as 0,0 (#2109).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -831,7 +824,6 @@ impl Default for PageSetup {
|
||||
show_plot_styles: false,
|
||||
plot_transparency: false,
|
||||
paper_mm: false,
|
||||
file_paper: None,
|
||||
image_origin: None,
|
||||
}
|
||||
}
|
||||
@@ -871,7 +863,7 @@ pub struct NamedView {
|
||||
/// The layer state restored with the view (its layer snapshot).
|
||||
#[serde(default)]
|
||||
pub layer_state: Option<String>,
|
||||
/// View twist (radians, counterclockwise); VIEW Restore applies it to the model space view.
|
||||
/// View twist (radians, counterclockwise); kept for files, not applied to the 2D display.
|
||||
#[serde(default)]
|
||||
pub twist: f64,
|
||||
/// The layout a paper space view belongs to (`None`: a model space view; an empty name: a
|
||||
@@ -892,9 +884,6 @@ 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
|
||||
|
||||
@@ -189,7 +189,6 @@ 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")));
|
||||
@@ -243,7 +242,6 @@ fn visible_attributes_of_a_missing_block_still_count() {
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
clip: None,
|
||||
}),
|
||||
)
|
||||
};
|
||||
@@ -287,7 +285,6 @@ 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))));
|
||||
@@ -306,7 +303,6 @@ fn insert_bounds_with_cyclic_block_terminate() {
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
clip: None,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -424,7 +420,6 @@ fn plain_insert(block: &str) -> EntityKind {
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
clip: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
//! 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<Vec2>,
|
||||
/// 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<Vec2>) -> 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<Vec2> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,6 @@ 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).
|
||||
|
||||
+18
-466
@@ -19,8 +19,10 @@
|
||||
//! 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, so the app
|
||||
//! runs both directions in a child process ([`worker`]): what that kills is the child.
|
||||
//! 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.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -62,11 +64,6 @@ 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_kept_noted, encode_noted, serve_if_asked, use_own_worker, use_worker};
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod native {
|
||||
use std::io::{Cursor, Write};
|
||||
@@ -544,22 +541,15 @@ mod native {
|
||||
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
|
||||
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
|
||||
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many
|
||||
/// records of each DXF type were left out, so a save can say so.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten {
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
|
||||
let version = doc.version;
|
||||
let mut dropped = super::Unwritten::new();
|
||||
let mut count = |name: &str| {
|
||||
let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
};
|
||||
let unwritable: Vec<acadrust::Handle> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Unknown(u)
|
||||
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
count(&u.dxf_name);
|
||||
Some(u.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
@@ -572,10 +562,9 @@ mod native {
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(h, o)| match o {
|
||||
acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. }
|
||||
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
|
||||
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
count(type_name);
|
||||
Some(*h)
|
||||
}
|
||||
_ => None,
|
||||
@@ -584,7 +573,7 @@ mod native {
|
||||
for h in &objects {
|
||||
doc.objects.remove(h);
|
||||
}
|
||||
dropped
|
||||
unwritable.len() + objects.len()
|
||||
}
|
||||
|
||||
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
|
||||
@@ -652,45 +641,13 @@ mod native {
|
||||
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
|
||||
/// (AC1018).
|
||||
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
convert(dxf, None, None).map(|(dwg, _)| dwg)
|
||||
convert(dxf, None)
|
||||
}
|
||||
|
||||
/// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"),
|
||||
/// whatever version the DXF declares.
|
||||
pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each
|
||||
/// DXF type it can't write (other programs' custom entities and objects that the DWG reader
|
||||
/// didn't read from a DWG of this version), empty when the DWG keeps everything.
|
||||
pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
convert(dxf, Some(writable(version)?), None)
|
||||
}
|
||||
|
||||
/// Like [`dxf_to_dwg_as`], for a DXF that declares an older version than the groups it carries
|
||||
/// over from another file. CADCraft's R2000 DWG bridge holds the objects it keeps without
|
||||
/// modelling them (render settings, fields, associative and dynamic block data) as they were
|
||||
/// read from a file of version `kept` ("AC1032"), and acadrust reads several of those
|
||||
/// differently by version: read as R2000, an AC1032 file's mental ray render settings came out
|
||||
/// a class version too high, for which the ODA rejects the whole DWG, and a field lost its
|
||||
/// cached value. So the DXF is read as `kept` declares. A `kept` version that is unknown, or
|
||||
/// not newer than the DXF's own, changes nothing.
|
||||
pub fn dxf_to_dwg_kept_as(dxf: &[u8], version: &str, kept: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_kept_noted(dxf, version, kept).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`dxf_to_dwg_kept_as`], also returning what the DWG writer left out, as
|
||||
/// [`dxf_to_dwg_noted`] does.
|
||||
pub fn dxf_to_dwg_kept_noted(dxf: &[u8], version: &str, kept: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
let kept = acadrust::DxfVersion::from_version_string(kept.trim().to_ascii_uppercase().as_str());
|
||||
let kept = (kept != acadrust::DxfVersion::Unknown).then_some(kept);
|
||||
convert(dxf, Some(writable(version)?), kept)
|
||||
}
|
||||
|
||||
/// The DWG version for a code from [`super::VERSIONS`].
|
||||
fn writable(version: &str) -> Result<acadrust::DxfVersion, String> {
|
||||
Ok(match version.trim().to_ascii_uppercase().as_str() {
|
||||
let v = match version.trim().to_ascii_uppercase().as_str() {
|
||||
"AC1015" => acadrust::DxfVersion::AC1015,
|
||||
"AC1018" => acadrust::DxfVersion::AC1018,
|
||||
"AC1021" => acadrust::DxfVersion::AC1021,
|
||||
@@ -698,72 +655,8 @@ mod native {
|
||||
"AC1027" => acadrust::DxfVersion::AC1027,
|
||||
"AC1032" => acadrust::DxfVersion::AC1032,
|
||||
_ => return Err(super::unsupported(version)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The ASCII DXF with its `$ACADVER` set to `kept` when that is newer than the version it
|
||||
/// declares, or `None` to read it as it is (binary, no `$ACADVER`, or non-ASCII text without a
|
||||
/// `$DWGCODEPAGE` to decode it by). From AutoCAD 2007 (AC1021) a DXF is UTF-8, so other bytes
|
||||
/// are first decoded with the code page the DXF declares; `\U+XXXX` escapes read the same in
|
||||
/// every version, and CADCraft's R2000 DXF is ASCII with such escapes.
|
||||
pub(super) fn declare_version(dxf: &[u8], kept: acadrust::DxfVersion) -> Option<Vec<u8>> {
|
||||
if dxf.starts_with(b"AutoCAD Binary DXF") {
|
||||
return None;
|
||||
}
|
||||
let (_, declared) = header_value(dxf, b"$ACADVER")?;
|
||||
let declared = acadrust::DxfVersion::from_version_string(std::str::from_utf8(declared).ok()?);
|
||||
if declared >= kept {
|
||||
return None;
|
||||
}
|
||||
let text: std::borrow::Cow<'_, [u8]> = if dxf.is_ascii() || kept < acadrust::DxfVersion::AC1021 {
|
||||
std::borrow::Cow::Borrowed(dxf)
|
||||
} else {
|
||||
// As acadrust decodes an older DXF: by its code page, Windows-1252 for one it doesn't
|
||||
// know; a code page that names UTF-8 needs no decoding.
|
||||
let (_, code_page) = header_value(dxf, b"$DWGCODEPAGE")?;
|
||||
match acadrust::io::dxf::code_page::encoding_from_code_page(std::str::from_utf8(code_page).ok()?) {
|
||||
Some(encoding) => {
|
||||
let (text, errors) = encoding.decode_without_bom_handling(dxf);
|
||||
if errors {
|
||||
return None;
|
||||
}
|
||||
std::borrow::Cow::Owned(text.into_owned().into_bytes())
|
||||
}
|
||||
None => std::borrow::Cow::Borrowed(std::str::from_utf8(dxf).ok()?.as_bytes()),
|
||||
}
|
||||
};
|
||||
let (at, value) = header_value(&text, b"$ACADVER")?;
|
||||
let code = kept.to_string();
|
||||
let mut out = Vec::with_capacity(text.len().saturating_add(code.len()));
|
||||
out.extend_from_slice(text.get(..at)?);
|
||||
out.extend_from_slice(code.as_bytes());
|
||||
out.extend_from_slice(text.get(at.checked_add(value.len())?..)?);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The byte offset and the trimmed value of header variable `name` in an ASCII DXF: the value
|
||||
/// of the group that follows the `9` group naming it. The file is read as (group code,
|
||||
/// value) line pairs, in one pass with no buffering, up to the first `0`/`ENDSEC`, the end
|
||||
/// of the HEADER section: a large input without one costs time, not memory (a line index of
|
||||
/// a 32 MiB file used 0.8 GB). Lines may end in `\r\n`.
|
||||
fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> {
|
||||
let mut offset = 0usize;
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|line| {
|
||||
let at = offset.saturating_add(line.len() - line.trim_ascii_start().len());
|
||||
offset = offset.saturating_add(line.len()).saturating_add(1);
|
||||
(at, line.trim_ascii())
|
||||
});
|
||||
while let (Some((_, code)), Some((_, value))) = (lines.next(), lines.next()) {
|
||||
if code == b"0" && value == b"ENDSEC" {
|
||||
return None;
|
||||
}
|
||||
if code == b"9" && value == name {
|
||||
let (_, code) = lines.next()?;
|
||||
let (at, value) = lines.next()?;
|
||||
return (code != b"0" && code != b"9").then_some((at, value));
|
||||
}
|
||||
}
|
||||
None
|
||||
convert(dxf, Some(v))
|
||||
}
|
||||
|
||||
/// The current dimension and text styles' names from their handles: a DWG stores the
|
||||
@@ -800,141 +693,6 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXF names of an ASCII DXF's CLASS records, in CLASSES order: a proxy's class number
|
||||
/// (group 91) is 500 plus its class's place here (DXF Reference, ACAD_PROXY_OBJECT). Empty for
|
||||
/// one without a CLASSES section. `None` for a binary DXF, whose classes this doesn't read:
|
||||
/// CADCraft's own bridge DXF is always ASCII, and only a binary DXF given to the public
|
||||
/// conversion functions comes here.
|
||||
pub(super) fn dxf_class_names(dxf: &[u8]) -> Option<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
if dxf.starts_with(b"AutoCAD Binary DXF") {
|
||||
return None;
|
||||
}
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|l| l.trim_ascii());
|
||||
let (mut in_classes, mut in_class) = (false, false);
|
||||
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
|
||||
match (code, value) {
|
||||
(b"0", b"ENDSEC") if in_classes => break,
|
||||
(b"2", b"CLASSES") => in_classes = true,
|
||||
(b"0", v) if in_classes => {
|
||||
in_class = v == b"CLASS";
|
||||
// Every record keeps its place, named or not: a proxy numbers its class by
|
||||
// place, so a record without a name must not move the ones after it. An empty
|
||||
// name matches no class, and such a proxy is left out.
|
||||
if in_class {
|
||||
names.push(String::new());
|
||||
}
|
||||
}
|
||||
(b"1", v) if in_class => {
|
||||
if let Some(last) = names.last_mut() {
|
||||
*last = String::from_utf8_lossy(v).into_owned();
|
||||
}
|
||||
in_class = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// Proxy objects' and entities' class numbers as the DWG numbers its classes. A DXF proxy
|
||||
/// names its class by its place in the DXF's CLASSES (`classes`, from 500), but acadrust's
|
||||
/// reader puts its own default classes first and numbers the DXF's after them, so the DXF
|
||||
/// number named another class in the DWG: a 2018 drawing's piping proxies became
|
||||
/// LARGE_RADIAL_DIMENSION and SECTIONOBJECT, and the ODA File Converter refused the file. A
|
||||
/// proxy whose number names no class of the DXF is left out: kept as it is, its number would
|
||||
/// name some other class of the DWG, for the same reason.
|
||||
///
|
||||
/// Before 2013 (AC1027) acadrust writes only the classes it knows a pre-2013 DWG needs, and a
|
||||
/// proxy's class is not one of them: the proxy would name a missing class (the ODA refuses
|
||||
/// the file: "No ClassId") or, by its old number, some other class. Such a DWG leaves the
|
||||
/// proxies out instead, as it does other programs' data it can't write (`not_saved` reports
|
||||
/// the entities). So does a DXF whose classes are unknown (`classes` is `None`, a binary
|
||||
/// DXF): its proxies' numbers can't be mapped, and kept as they are they would name the
|
||||
/// wrong classes and the ODA would refuse the file. Returns the proxies left out, by DXF type, for
|
||||
/// what the DWG writer left out ([`super::Unwritten`]).
|
||||
fn proxy_classes(doc: &mut acadrust::CadDocument, classes: Option<&[String]>) -> super::Unwritten {
|
||||
let classes = match classes {
|
||||
Some(c) if doc.version >= acadrust::DxfVersion::AC1027 => c,
|
||||
_ => {
|
||||
let entities: Vec<acadrust::Handle> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Extended(x) if matches!(x.data, acadrust::entities::ExtendedEntityData::Proxy(_)) => {
|
||||
Some(x.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let objects: Vec<acadrust::Handle> =
|
||||
doc.objects.iter().filter_map(|(h, o)| matches!(o, acadrust::objects::ObjectType::ProxyObject(_)).then_some(*h)).collect();
|
||||
for h in &entities {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
for h in &objects {
|
||||
doc.objects.remove(h);
|
||||
}
|
||||
return left_out(objects.len(), entities.len());
|
||||
}
|
||||
};
|
||||
let number = |id: i32| -> Option<i32> {
|
||||
let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?;
|
||||
doc.classes.get_by_name(name).map(|c| i32::from(c.class_number))
|
||||
};
|
||||
let objects: Vec<(acadrust::Handle, Option<i32>)> = doc
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(h, o)| match o {
|
||||
acadrust::objects::ObjectType::ProxyObject(p) => Some((*h, number(p.class_id))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let entities: Vec<(acadrust::Handle, Option<i32>)> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Extended(x) => match &x.data {
|
||||
acadrust::entities::ExtendedEntityData::Proxy(p) => Some((x.common.handle, number(p.class_id))),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let unmapped = |list: &[(acadrust::Handle, Option<i32>)]| list.iter().filter(|(_, n)| n.is_none()).map(|(h, _)| *h).collect::<Vec<_>>();
|
||||
let (gone_objects, gone_entities) = (unmapped(&objects), unmapped(&entities));
|
||||
for h in &gone_objects {
|
||||
doc.objects.remove(h);
|
||||
}
|
||||
for h in &gone_entities {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
let objects = objects.into_iter().filter_map(|(h, n)| Some((h, n?)));
|
||||
let entities = entities.into_iter().filter_map(|(h, n)| Some((h, n?)));
|
||||
for (h, n) in objects {
|
||||
if let Some(acadrust::objects::ObjectType::ProxyObject(p)) = doc.objects.get_mut(&h) {
|
||||
p.class_id = n;
|
||||
}
|
||||
}
|
||||
let entities: std::collections::HashMap<acadrust::Handle, i32> = entities.into_iter().collect();
|
||||
for e in doc.entities_mut() {
|
||||
if let acadrust::entities::EntityType::Extended(x) = e {
|
||||
let Some(n) = entities.get(&x.common.handle).copied() else { continue };
|
||||
if let acadrust::entities::ExtendedEntityData::Proxy(p) = &mut x.data {
|
||||
p.class_id = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
left_out(gone_objects.len(), gone_entities.len())
|
||||
}
|
||||
|
||||
/// Proxy objects and entities left out of the DWG, by their DXF types.
|
||||
fn left_out(objects: usize, entities: usize) -> super::Unwritten {
|
||||
[("ACAD_PROXY_OBJECT", objects), ("ACAD_PROXY_ENTITY", entities)]
|
||||
.into_iter()
|
||||
.filter(|(_, n)| *n > 0)
|
||||
.map(|(t, n)| (t.to_string(), n))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG
|
||||
/// stores the handle of the layer, and the one read with the DXF needn't be the layer's in
|
||||
/// the document, so the drawing reopened with layer "0" current (#2252).
|
||||
@@ -946,8 +704,7 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// `kept`: the version the DXF's carried-over groups were read from ([`dxf_to_dwg_kept_as`]).
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>, kept: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||
// Reject a pathologically large DXF before handing it to the third-party decoder, which
|
||||
// allocates tables and section buffers from the input's declared sizes and cannot be
|
||||
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
|
||||
@@ -959,19 +716,7 @@ mod native {
|
||||
mb(Limits::DEFAULT.max_dxf_bytes as u64)
|
||||
));
|
||||
}
|
||||
// Decoding a code page to UTF-8 can grow the text, so the limit applies again.
|
||||
let data = match kept.and_then(|k| declare_version(dxf, k)) {
|
||||
Some(d) if d.len() > Limits::DEFAULT.max_dxf_bytes => {
|
||||
return Err(format!(
|
||||
"DXF: the input is {} as UTF-8, larger than the {} CADCraft converts to DWG",
|
||||
mb(d.len() as u64),
|
||||
mb(Limits::DEFAULT.max_dxf_bytes as u64)
|
||||
));
|
||||
}
|
||||
Some(d) => d,
|
||||
None => dxf.to_vec(),
|
||||
};
|
||||
let classes = dxf_class_names(&data);
|
||||
let data = dxf.to_vec();
|
||||
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
|
||||
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
|
||||
.map_err(|e| format!("DXF: {e}"))?;
|
||||
@@ -984,28 +729,21 @@ mod native {
|
||||
None => {}
|
||||
}
|
||||
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
|
||||
let unwritten = guarded(AssertUnwindSafe(|| {
|
||||
guarded(AssertUnwindSafe(|| {
|
||||
super::fail_in_test("convert");
|
||||
let proxies = proxy_classes(&mut doc, classes.as_deref());
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
let mut unwritten = drop_unwritable(&mut doc);
|
||||
for (kind, n) in proxies {
|
||||
let total = unwritten.entry(kind).or_insert(0);
|
||||
*total = total.saturating_add(n);
|
||||
}
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
current_layer(&mut doc);
|
||||
unwritten
|
||||
}))
|
||||
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
|
||||
let dwg = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
|
||||
.map_err(|e| format!("DWG write: {e}"))?;
|
||||
Ok((dwg, unwritten))
|
||||
.map_err(|e| format!("DWG write: {e}"))
|
||||
}
|
||||
|
||||
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
|
||||
@@ -1162,13 +900,7 @@ mod native {
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use native::{
|
||||
dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_kept_as, dxf_to_dwg_kept_noted, dxf_to_dwg_noted,
|
||||
};
|
||||
|
||||
/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer
|
||||
/// can't write.
|
||||
pub type Unwritten = std::collections::BTreeMap<String, usize>;
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
|
||||
|
||||
// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The
|
||||
// stage then panics on entry, so a test through the public API shows whether it is guarded.
|
||||
@@ -1198,35 +930,6 @@ fn unsupported(version: &str) -> String {
|
||||
#[cfg(no_dwg_bridge)]
|
||||
const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" };
|
||||
|
||||
/// There's no worker without a bridge.
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub const WORKER_ARG: &str = "--dwg-worker";
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
None
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn use_own_worker() {}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
dwg_to_dxf_noted(bytes, limits)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_as(dxf, version)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
dxf_to_dwg_noted(dxf, version)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
match kept {
|
||||
Some(kept) => dxf_to_dwg_kept_noted(dxf, version, kept),
|
||||
None => dxf_to_dwg_noted(dxf, version),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
@@ -1247,18 +950,6 @@ pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dxf_to_dwg_kept_as(_dxf: &[u8], _version: &str, _kept: &str) -> Result<Vec<u8>, String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dxf_to_dwg_kept_noted(_dxf: &[u8], _version: &str, _kept: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1544,102 +1235,6 @@ mod tests {
|
||||
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
|
||||
}
|
||||
|
||||
/// An R2000 DXF header (`\r\n` lines, as CADCraft writes them) with `extra` after `$ACADVER`.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
fn r2000(extra: &[u8]) -> Vec<u8> {
|
||||
let mut dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n 9\r\n$DWGCODEPAGE\r\n 3\r\nANSI_1252\r\n".to_vec();
|
||||
dxf.extend_from_slice(b" 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\n");
|
||||
dxf.extend_from_slice(extra);
|
||||
dxf.extend_from_slice(b"\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n");
|
||||
dxf
|
||||
}
|
||||
|
||||
/// The kept version replaces only the `$ACADVER` value; everything else stays byte for byte.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn declare_version_changes_only_acadver() {
|
||||
let dxf = r2000(b"A\\U+00E7\\U+00E3o");
|
||||
let out = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap();
|
||||
assert_eq!(String::from_utf8(out).unwrap(), String::from_utf8(dxf).unwrap().replacen("AC1015", "AC1032", 1));
|
||||
}
|
||||
|
||||
/// A kept version not newer than the declared one, a binary DXF and a DXF without `$ACADVER`
|
||||
/// are read as they are.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn declare_version_leaves_what_it_need_not_change() {
|
||||
let dxf = r2000(b"x");
|
||||
assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1015), None);
|
||||
assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1009), None);
|
||||
assert_eq!(native::declare_version(b"AutoCAD Binary DXF\r\n\x1a\0", acadrust::DxfVersion::AC1032), None);
|
||||
let no_version = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
|
||||
assert_eq!(native::declare_version(no_version, acadrust::DxfVersion::AC1032), None);
|
||||
// A `$ACADVER` outside the HEADER section is not the file's version.
|
||||
let late = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n";
|
||||
assert_eq!(native::declare_version(late, acadrust::DxfVersion::AC1032), None);
|
||||
}
|
||||
|
||||
/// The header is read as group code and value pairs up to the first `0`/`ENDSEC`: a `$ACADVER`
|
||||
/// name whose value would come after it, or a file that never ends its HEADER, is no version
|
||||
/// to replace, and `\r\n` lines keep the value's offset right.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn declare_version_reads_the_header_in_pairs_up_to_endsec() {
|
||||
let cut = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 0\r\nENDSEC\r\n 1\r\nAC1015\r\n";
|
||||
assert_eq!(native::declare_version(cut, acadrust::DxfVersion::AC1032), None);
|
||||
let endless = vec![b'\n'; 1 << 20];
|
||||
assert_eq!(native::declare_version(&endless, acadrust::DxfVersion::AC1032), None);
|
||||
let crlf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1015\r\n 0\r\nENDSEC\r\n";
|
||||
let out = native::declare_version(crlf, acadrust::DxfVersion::AC1032).unwrap();
|
||||
assert_eq!(out, b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1032\r\n 0\r\nENDSEC\r\n");
|
||||
}
|
||||
|
||||
/// Declared as 2007 or later, a DXF is UTF-8: code page text is decoded first. Declared as an
|
||||
/// older version, the code page bytes stay as they are.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn declare_version_decodes_a_code_page_for_utf8_versions() {
|
||||
let dxf = r2000(b"A\xe7\xe3o");
|
||||
let utf8 = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap();
|
||||
let text = String::from_utf8(utf8).unwrap();
|
||||
assert!(text.contains("\r\nAção\r\n") && text.contains("$ACADVER\r\n 1\r\nAC1032\r\n"), "{text}");
|
||||
let legacy = native::declare_version(&dxf, acadrust::DxfVersion::AC1018).unwrap();
|
||||
assert!(legacy.windows(4).any(|w| w == b"A\xe7\xe3o"), "the code page bytes stay");
|
||||
assert!(legacy.windows(6).any(|w| w == b"AC1018"));
|
||||
// A code page it doesn't know is read as Windows-1252, as acadrust reads an older DXF.
|
||||
let at = dxf.windows(9).position(|w| w == b"ANSI_1252").unwrap();
|
||||
let unknown = [&dxf[..at], b"NO_SUCH_CP".as_slice(), &dxf[at + 9..]].concat();
|
||||
let text = String::from_utf8(native::declare_version(&unknown, acadrust::DxfVersion::AC1032).unwrap()).unwrap();
|
||||
assert!(text.contains("\r\nAção\r\n"), "{text}");
|
||||
// Without a `$DWGCODEPAGE` there is nothing to decode it by: it is read as it is.
|
||||
let none = [&dxf[..at - 21], &dxf[at + 11..]].concat();
|
||||
assert!(!none.windows(12).any(|w| w == b"$DWGCODEPAGE"), "{}", String::from_utf8_lossy(&none));
|
||||
assert_eq!(native::declare_version(&none, acadrust::DxfVersion::AC1032), None);
|
||||
}
|
||||
|
||||
/// An unknown kept version converts as [`dxf_to_dwg_as`] does.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn unknown_kept_version_converts_as_before() {
|
||||
let dxf = r2000(b"x");
|
||||
assert_eq!(dxf_to_dwg_kept_as(&dxf, "AC1032", "AC9999"), dxf_to_dwg_as(&dxf, "AC1032"));
|
||||
assert!(dxf_to_dwg_kept_as(&dxf, "AC0000", "AC1032").is_err(), "an unwritable version is still an error");
|
||||
}
|
||||
|
||||
/// The CLASS names in CLASSES order; nothing for a binary DXF or one without CLASSES.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn dxf_class_names_in_classes_order() {
|
||||
let dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 1\r\nWIDGETA\r\n 2\r\nAcDbWidgetA\r\n 3\r\nApp\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 2\r\nAcDbWidgetB\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\nNOT_A_CLASS\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
|
||||
assert_eq!(native::dxf_class_names(dxf).unwrap(), ["WIDGETA", "WIDGETB"]);
|
||||
// A CLASS record without a name keeps its place, so the classes after it keep theirs
|
||||
// (#2837 review).
|
||||
let unnamed = b" 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 2\r\nAcDbNoName\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
|
||||
assert_eq!(native::dxf_class_names(unnamed).unwrap(), ["", "WIDGETB"]);
|
||||
assert_eq!(native::dxf_class_names(b" 0\nSECTION\n 2\nHEADER\n 0\nENDSEC\n 0\nEOF\n"), Some(Vec::new()));
|
||||
assert_eq!(native::dxf_class_names(b"AutoCAD Binary DXF\r\n\x1a\0"), None);
|
||||
}
|
||||
|
||||
/// AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with a byte after its three
|
||||
/// connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD
|
||||
/// read past the object and refused every DWG CADCraft wrote with a dynamic block that scales
|
||||
@@ -1686,47 +1281,4 @@ mod tests {
|
||||
assert_eq!(read.dwg_tail_byte, 0, "{version}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF
|
||||
/// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG
|
||||
/// keeps whole reports nothing.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn saving_lists_the_types_the_dwg_writer_leaves_out() {
|
||||
let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n";
|
||||
let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n";
|
||||
let dxf = |body: &str| {
|
||||
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n")
|
||||
};
|
||||
for version in ["AC1015", "AC1018", "AC1032"] {
|
||||
let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap();
|
||||
assert!(is_dwg(&dwg), "{version}");
|
||||
assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::<Unwritten>(), "{version}");
|
||||
}
|
||||
let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap();
|
||||
assert!(unwritten.is_empty(), "{unwritten:?}");
|
||||
}
|
||||
|
||||
/// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to
|
||||
/// the DWG with its data: it isn't in the list of what the writer left out.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn ole_frames_are_written_with_their_data() {
|
||||
let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n";
|
||||
let dxf =
|
||||
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n");
|
||||
for version in ["AC1015", "AC1018", "AC1032"] {
|
||||
let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap();
|
||||
assert!(unwritten.is_empty(), "{version} {unwritten:?}");
|
||||
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
let frames: Vec<Vec<u8>> = back
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
//! 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 = 2;
|
||||
const DECODE: u8 = 1;
|
||||
const ENCODE: u8 = 2;
|
||||
const CRASH: u8 = 0xff;
|
||||
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
|
||||
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
/// Most bytes accepted for what a reply carries besides the drawing.
|
||||
const MAX_NOTES_BYTES: u64 = 64 << 20;
|
||||
/// Most bytes accepted as a written DWG.
|
||||
const MAX_DWG_REPLY: u64 = 2 << 30;
|
||||
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
|
||||
const MAX_REQUEST: u64 = (2 << 30) + 256;
|
||||
|
||||
struct Worker {
|
||||
program: PathBuf,
|
||||
args: Vec<OsString>,
|
||||
}
|
||||
|
||||
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
|
||||
|
||||
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
|
||||
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
|
||||
if let Ok(mut w) = WORKER.lock() {
|
||||
*w = Some(Worker { program, args });
|
||||
}
|
||||
}
|
||||
|
||||
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
|
||||
/// found, the reader and writer stay in this process.
|
||||
pub fn use_own_worker() {
|
||||
match std::env::current_exe() {
|
||||
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
|
||||
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one request and return the exit code if this process was started as a worker.
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
|
||||
}
|
||||
|
||||
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
// Before starting a process, and so the same limit holds with or without one.
|
||||
if bytes.len() > limits.max_dwg_bytes {
|
||||
return super::dwg_to_dxf_noted(bytes, limits);
|
||||
}
|
||||
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
|
||||
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
|
||||
Some(reply) => reply?,
|
||||
None => super::dwg_to_dxf_noted(bytes, limits),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
encode_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
|
||||
/// back as notes of `TYPE<tab>count`.
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
encode_kept_noted(dxf, version, None)
|
||||
}
|
||||
|
||||
/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept`
|
||||
/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`].
|
||||
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
if u8::try_from(version.len()).is_err() {
|
||||
return Err(super::unsupported(version));
|
||||
}
|
||||
// A kept version too long to send is no version CADCraft knows: it changes nothing.
|
||||
let kept = kept.filter(|k| u8::try_from(k.len()).is_ok());
|
||||
let mut header = request(ENCODE, Limits::DEFAULT, version);
|
||||
push_str(&mut header, kept.unwrap_or(""));
|
||||
match call(&header, dxf, MAX_DWG_REPLY, "writing this file") {
|
||||
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))),
|
||||
None => encode_here(dxf, version, kept),
|
||||
}
|
||||
}
|
||||
|
||||
/// The conversion in this process.
|
||||
fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
match kept {
|
||||
Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept),
|
||||
None => super::dxf_to_dwg_noted(dxf, version),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten_from(notes: &[String]) -> super::Unwritten {
|
||||
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
|
||||
super::Unwritten::new(),
|
||||
|mut m, (kind, n)| {
|
||||
*m.entry(kind).or_insert(0) = n;
|
||||
m
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
|
||||
#[doc(hidden)]
|
||||
pub fn crash_worker_for_test() -> Result<(), String> {
|
||||
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
|
||||
}
|
||||
|
||||
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
|
||||
let mut r = vec![PROTOCOL, op];
|
||||
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
|
||||
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
|
||||
push_str(&mut r, version);
|
||||
r
|
||||
}
|
||||
|
||||
/// `text` with a one-byte length before it; callers keep it under 256 bytes.
|
||||
fn push_str(r: &mut Vec<u8>, text: &str) {
|
||||
r.push(u8::try_from(text.len()).unwrap_or(0));
|
||||
r.extend(text.as_bytes());
|
||||
}
|
||||
|
||||
type Reply = Result<(Vec<u8>, Vec<String>), String>;
|
||||
|
||||
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
|
||||
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
|
||||
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
|
||||
let mut command = {
|
||||
let worker = WORKER.lock().ok()?;
|
||||
let worker = worker.as_ref()?;
|
||||
let mut c = Command::new(&worker.program);
|
||||
c.args(&worker.args);
|
||||
c
|
||||
};
|
||||
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let mut child = match command.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("DWG worker: can't start it: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
};
|
||||
let received = std::thread::scope(|scope| {
|
||||
// Written beside the read: a worker that replies before it has read everything, or dies,
|
||||
// must block neither side. A failed write shows up as the reply going wrong.
|
||||
scope.spawn(move || {
|
||||
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
scope.spawn(move || {
|
||||
let mut reply = Vec::new();
|
||||
let read = from_child.take(reply_limit).read_to_end(&mut reply);
|
||||
let _ = tx.send(read.map(|_| reply));
|
||||
});
|
||||
let received = rx.recv_timeout(TIMEOUT);
|
||||
if matches!(received, Err(RecvTimeoutError::Timeout)) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
received
|
||||
});
|
||||
let status = child.wait();
|
||||
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
|
||||
Some(match (received, status) {
|
||||
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
|
||||
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
|
||||
_ => Err(stopped()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Take `n` bytes off the front of `b`.
|
||||
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
|
||||
let (head, tail) = b.split_at_checked(n)?;
|
||||
*b = tail;
|
||||
Some(head)
|
||||
}
|
||||
|
||||
fn take_u32(b: &mut &[u8]) -> Option<usize> {
|
||||
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
|
||||
usize::try_from(u32::from_le_bytes(bytes)).ok()
|
||||
}
|
||||
|
||||
fn take_u64(b: &mut &[u8]) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
|
||||
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
|
||||
/// of the memory the app may use: they are kept, not copied.
|
||||
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
|
||||
let total = reply.len();
|
||||
let mut b = &reply[..];
|
||||
match *take(&mut b, 1)?.first()? {
|
||||
0 => {
|
||||
let count = take_u32(&mut b)?;
|
||||
let mut notes = Vec::new();
|
||||
for _ in 0..count {
|
||||
let n = take_u32(&mut b)?;
|
||||
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
|
||||
}
|
||||
let n = usize::try_from(take_u64(&mut b)?).ok()?;
|
||||
if b.len() != n {
|
||||
return None;
|
||||
}
|
||||
reply.drain(..total - n);
|
||||
Some(Ok((reply, notes)))
|
||||
}
|
||||
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
|
||||
match reply {
|
||||
Ok((bytes, notes)) => {
|
||||
out.write_all(&[0])?;
|
||||
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
|
||||
for note in ¬es {
|
||||
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
|
||||
if u32::try_from(note.len()).is_ok() {
|
||||
out.write_all(note.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
|
||||
out.write_all(&bytes)?;
|
||||
}
|
||||
Err(e) => {
|
||||
out.write_all(&[1])?;
|
||||
out.write_all(e.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
|
||||
/// What a request asks for, or why it can't be served.
|
||||
fn handle(mut b: &[u8]) -> Reply {
|
||||
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
|
||||
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
|
||||
return Err(bad());
|
||||
}
|
||||
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
match op {
|
||||
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
|
||||
ENCODE => {
|
||||
// The version the DXF's carried-over groups were read from; empty for none.
|
||||
let kept_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let kept = take(&mut b, usize::from(kept_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
encode_here(b, version, (!kept.is_empty()).then_some(kept))
|
||||
.map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect()))
|
||||
}
|
||||
CRASH => std::process::abort(),
|
||||
_ => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
|
||||
fn serve(input: impl Read, mut output: impl Write) -> i32 {
|
||||
let mut request = Vec::new();
|
||||
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
|
||||
Ok(_) => handle(&request),
|
||||
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
|
||||
};
|
||||
i32::from(write_reply(&mut output, reply).is_err())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wire(reply: Reply) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
write_reply(&mut out, reply).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_writer_left_out_survives_the_reply() {
|
||||
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
|
||||
assert_eq!(unwritten_from(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip() {
|
||||
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
|
||||
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
|
||||
let failed: Reply = Err("DWG: no".into());
|
||||
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_cut_short_or_made_up_is_refused() {
|
||||
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
|
||||
for n in 0..whole.len() {
|
||||
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
|
||||
}
|
||||
assert_eq!(parse_reply(vec![7, 0, 0]), None);
|
||||
// A note count far beyond what follows ends at the first missing note.
|
||||
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
|
||||
for request in [
|
||||
&[][..],
|
||||
&[PROTOCOL + 1, DECODE],
|
||||
&[PROTOCOL],
|
||||
&[PROTOCOL, DECODE, 1, 2, 3],
|
||||
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
] {
|
||||
assert!(handle(request).is_err(), "{request:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An encode request carries the kept version after the target one; a request without it is
|
||||
/// one it can't read, and one with it reaches the converter.
|
||||
#[test]
|
||||
fn an_encode_request_carries_the_kept_version() {
|
||||
let bad = "the DWG worker got a request it can't read (a different CADCraft version?)";
|
||||
let short = self::request(ENCODE, Limits::DEFAULT, "AC1032");
|
||||
assert_eq!(handle(&short).unwrap_err(), bad);
|
||||
for kept in ["", "AC1032"] {
|
||||
let mut request = self::request(ENCODE, Limits::DEFAULT, "AC1032");
|
||||
push_str(&mut request, kept);
|
||||
request.extend(b"not a drawing");
|
||||
let err = handle(&request).unwrap_err();
|
||||
assert_ne!(err, bad, "kept {kept:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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:?}");
|
||||
}
|
||||
}
|
||||
+10
-78
@@ -129,7 +129,7 @@ pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
|
||||
/// line (the group code `3` between them), looked for in the HEADER section only (the scan stops
|
||||
/// at the first `ENDSEC`). Works on the raw bytes, before they are decoded.
|
||||
fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> {
|
||||
let mut lines = bytes.split(|&b| b == b'\n' || b == b'\r').map(<[u8]>::trim_ascii).filter(|l| !l.is_empty());
|
||||
let mut lines = bytes.split(|&b| b == b'\n').map(<[u8]>::trim_ascii);
|
||||
while let Some(line) = lines.next() {
|
||||
if line == b"ENDSEC" {
|
||||
return None;
|
||||
@@ -144,35 +144,10 @@ fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
/// True for the multi-byte East Asian code pages (Shift-JIS, GBK, EUC-KR/Wansung, Big5).
|
||||
/// Their short double-byte strings are often also valid UTF-8, so UTF-8 is only trusted for
|
||||
/// a whole file there (see [`decode_ascii_file`] and [`BinaryText`]).
|
||||
fn is_east_asian(self) -> bool {
|
||||
use encoding_rs as e;
|
||||
matches!(self, Decoder::Encoding(x) if x == e::SHIFT_JIS || x == e::GBK || x == e::EUC_KR || x == e::BIG5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one line of an ASCII DXF file: valid UTF-8 is read as UTF-8 (R2007+ files, pure-ASCII
|
||||
/// text, and older files from writers that ignored the code page); anything else comes from the
|
||||
/// code page `d`, so one stray byte spoils one line, not the whole file. Binary strings don't use
|
||||
/// this rule: a pre-R2007 binary file has no way to escape to UTF-8, so its strings are read
|
||||
/// from the code page (see [`BinaryText::string`]).
|
||||
pub(crate) fn decode_text(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => decode(bytes, d),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a whole ASCII DXF file. A file that is valid UTF-8 throughout is UTF-8. Otherwise, for
|
||||
/// a single-byte code page, each line is decoded on its own (see [`decode_text`]): lines that
|
||||
/// aren't valid UTF-8 come from the file's `$DWGCODEPAGE`, or from Windows-1252 (the usual
|
||||
/// `ANSI_1252`) when the header has none or names a code page CADCraft doesn't know, which is
|
||||
/// logged as a warning. For a multi-byte East Asian code page the whole file is decoded from
|
||||
/// that code page, because a short double-byte string (two half-width katakana, many kanji
|
||||
/// pairs) can be valid UTF-8 by accident and must not be misread line by line.
|
||||
/// Decode a whole ASCII DXF file. Valid UTF-8 is read as UTF-8: R2007+ files, pure-ASCII files,
|
||||
/// and older files from writers that ignored the code page. Anything else is decoded from the
|
||||
/// file's `$DWGCODEPAGE`, or from Windows-1252 (the usual `ANSI_1252`) when the header has none
|
||||
/// or names a code page CADCraft doesn't know, which is logged as a warning.
|
||||
pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> {
|
||||
if let Ok(s) = std::str::from_utf8(bytes) {
|
||||
return Cow::Borrowed(s);
|
||||
@@ -187,19 +162,10 @@ pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> {
|
||||
}
|
||||
None => Decoder::Utf8,
|
||||
};
|
||||
if d.is_east_asian() {
|
||||
return decode(bytes, d);
|
||||
}
|
||||
// Split after each line terminator (CR, LF or CRLF are all ASCII, so they never occur inside
|
||||
// a multi-byte character of the supported code pages) and decode each line on its own.
|
||||
let mut out = String::with_capacity(bytes.len());
|
||||
for line in bytes.split_inclusive(|&b| b == b'\n' || b == b'\r') {
|
||||
out.push_str(&decode_text(line, d));
|
||||
}
|
||||
Cow::Owned(out)
|
||||
decode(bytes, d)
|
||||
}
|
||||
|
||||
/// The text state of a binary DXF file, learnt from the header as the tags go by: `$ACADVER`
|
||||
/// The text state of a binary DXF file, learnt from its header as the tags go by: `$ACADVER`
|
||||
/// (R2007+ strings are UTF-8) and `$DWGCODEPAGE` (the code page of older files). Both come
|
||||
/// first in the HEADER section and are ASCII, so every later string is decoded right.
|
||||
#[derive(Default)]
|
||||
@@ -208,12 +174,6 @@ pub(crate) struct BinaryText {
|
||||
var: Option<HeaderVar>,
|
||||
utf8: bool,
|
||||
code_page: Option<Decoder>,
|
||||
/// Decode every string of an East Asian code page file from the code page, never as UTF-8
|
||||
/// (the second pass of [`BinaryText::needs_code_page_pass`]).
|
||||
code_page_only: bool,
|
||||
/// Non-ASCII strings seen under an East Asian code page: valid UTF-8 / not valid UTF-8.
|
||||
ea_utf8: bool,
|
||||
ea_other: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -223,40 +183,12 @@ enum HeaderVar {
|
||||
}
|
||||
|
||||
impl BinaryText {
|
||||
/// The state for the second pass over a file whose strings are a mix of valid UTF-8 and
|
||||
/// not: every string of an East Asian code page is decoded from that code page.
|
||||
pub(crate) fn code_page_pass() -> Self {
|
||||
Self { code_page_only: true, ..Self::default() }
|
||||
}
|
||||
|
||||
/// True when the file declared a multi-byte East Asian code page and its non-ASCII strings are
|
||||
/// not all valid UTF-8 although some are: those can be short double-byte strings that look
|
||||
/// like UTF-8 by accident, so the file must be parsed again with [`Self::code_page_pass`]. (A
|
||||
/// file whose strings are all valid UTF-8 is UTF-8; one with none is plainly the code page.)
|
||||
pub(crate) fn needs_code_page_pass(&self) -> bool {
|
||||
!self.code_page_only && self.ea_utf8 && self.ea_other
|
||||
}
|
||||
|
||||
/// Decode one string value of group `code` and update the state from it. Strings of R2007+
|
||||
/// files, and of files without a code page, are UTF-8 when valid (else Windows-1252); a
|
||||
/// pre-R2007 file's strings are in its code page, which a binary DXF cannot escape to, so
|
||||
/// they are read from the code page even when their bytes are valid UTF-8 by accident —
|
||||
/// except under an East Asian code page, where a file of all-valid-UTF-8 strings is taken
|
||||
/// as UTF-8 (see [`Self::needs_code_page_pass`]).
|
||||
/// files, and of files without a code page, are UTF-8 when valid (else Windows-1252); older
|
||||
/// files' strings are in their code page.
|
||||
pub(crate) fn string(&mut self, code: i32, raw: &[u8]) -> String {
|
||||
let d = if self.utf8 { Decoder::Utf8 } else { self.code_page.unwrap_or(Decoder::Utf8) };
|
||||
let s = if d.is_east_asian() && !self.code_page_only {
|
||||
if !raw.is_ascii() {
|
||||
if std::str::from_utf8(raw).is_ok() {
|
||||
self.ea_utf8 = true;
|
||||
} else {
|
||||
self.ea_other = true;
|
||||
}
|
||||
}
|
||||
decode_text(raw, d).into_owned()
|
||||
} else {
|
||||
decode(raw, d).into_owned()
|
||||
};
|
||||
let s = decode(raw, d).into_owned();
|
||||
match (code, self.var.take()) {
|
||||
(9, _) => {
|
||||
self.var = match s.trim() {
|
||||
|
||||
+29
-175
@@ -23,11 +23,8 @@ 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 (set CADCRAFT_MAX_DXF_TAGS to read more)")]
|
||||
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads")]
|
||||
TooLarge { tags: usize, limit: usize },
|
||||
/// The memory for `tags` group codes couldn't be had: an error, not the process ending.
|
||||
#[error("not enough memory to read this drawing's {tags} DXF group codes")]
|
||||
OutOfMemory { tags: usize },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DxfError>;
|
||||
@@ -65,9 +62,7 @@ impl Value {
|
||||
match self {
|
||||
Value::Int(i) => *i,
|
||||
Value::Real(r) if r.is_finite() => *r as i64,
|
||||
Value::Str(s) => {
|
||||
s.trim().parse::<i64>().unwrap_or_else(|_| s.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| f as i64))
|
||||
}
|
||||
Value::Str(s) => s.trim().parse::<i64>().unwrap_or_else(|_| s.trim().parse::<f64>().map(|f| f as i64).unwrap_or(0)),
|
||||
Value::Bool(b) => i64::from(*b),
|
||||
_ => 0,
|
||||
}
|
||||
@@ -159,52 +154,25 @@ fn typed(code: i32, text: &str) -> Value {
|
||||
match kind_of(code) {
|
||||
Kind::Real => t.parse::<f64>().ok().filter(|v| v.is_finite()).map(Value::Real).unwrap_or(Value::Real(0.0)),
|
||||
Kind::Int16 | Kind::Int32 | Kind::Int64 => {
|
||||
// A float spelling ("1e3", "12.0") is accepted when finite, clamped to what the
|
||||
// group's width holds, signed or unsigned (files write 16-bit flags up to 65535, as
|
||||
// the integer spelling reads them); NaN, inf and text become 0.
|
||||
let (lo, hi) = match kind_of(code) {
|
||||
Kind::Int16 => (i64::from(i16::MIN), i64::from(u16::MAX)),
|
||||
Kind::Int32 => (i64::from(i32::MIN), i64::from(u32::MAX)),
|
||||
_ => (i64::MIN, i64::MAX),
|
||||
};
|
||||
t.parse::<i64>()
|
||||
.map(Value::Int)
|
||||
.unwrap_or_else(|_| Value::Int(t.parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| (f as i64).clamp(lo, hi))))
|
||||
t.parse::<i64>().map(Value::Int).unwrap_or_else(|_| Value::Int(t.parse::<f64>().map(|f| f as i64).unwrap_or(0)))
|
||||
}
|
||||
Kind::Bool => Value::Bool(t.parse::<i64>().unwrap_or(0) != 0),
|
||||
Kind::Hex => Value::Hex(normalize_hex(t)),
|
||||
Kind::Hex => Value::Hex(t.to_string()),
|
||||
Kind::Str => Value::Str(text.trim_end_matches(['\r', '\n']).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
|
||||
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
|
||||
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
|
||||
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
|
||||
/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to
|
||||
/// raise or lower it.
|
||||
pub const MAX_TAGS: usize = 100_000_000;
|
||||
|
||||
/// Environment variable overriding [`MAX_TAGS`] (a positive integer).
|
||||
pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS";
|
||||
|
||||
/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else
|
||||
/// [`MAX_TAGS`].
|
||||
pub fn max_tags() -> usize {
|
||||
max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
fn max_tags_from(v: Option<&str>) -> usize {
|
||||
v.and_then(|s| s.trim().replace('_', "").parse::<usize>().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS)
|
||||
}
|
||||
/// 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;
|
||||
|
||||
mod codepage;
|
||||
pub use codepage::decode_code_page;
|
||||
|
||||
/// Parse a DXF file (ASCII or binary).
|
||||
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_with_limit(bytes, max_tags())
|
||||
parse_with_limit(bytes, MAX_TAGS)
|
||||
}
|
||||
|
||||
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
|
||||
@@ -214,10 +182,8 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
|
||||
return parse_binary_with_limit(body, max_tags);
|
||||
}
|
||||
let bytes = bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes);
|
||||
// Count nonblank group-code/value pairs (LF, CRLF and a lone CR each end a line), stopping at
|
||||
// EOF just as the parser does. Blank lines do not count toward the limit.
|
||||
let mut lines = byte_lines(bytes);
|
||||
// Count nonblank group-code/value pairs, stopping at EOF just as the parser does.
|
||||
let mut lines = bytes.split(|&b| b == b'\n');
|
||||
let mut tags = 0;
|
||||
while let Some(code) = lines.next() {
|
||||
let code = code.trim_ascii();
|
||||
@@ -233,7 +199,7 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if tags > max_tags {
|
||||
return Err(DxfError::TooLarge { tags, limit: max_tags });
|
||||
}
|
||||
parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags)
|
||||
parse_ascii_with_limit(&codepage::decode_ascii_file(bytes), max_tags)
|
||||
}
|
||||
|
||||
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
|
||||
@@ -304,52 +270,14 @@ fn unescape_unicode(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The lines of an ASCII DXF text: LF, CRLF and a lone CR (old Mac files) all end a line, like
|
||||
/// [`str::lines`] otherwise (no empty line after a final terminator).
|
||||
fn ascii_lines(text: &str) -> impl Iterator<Item = &str> {
|
||||
let mut rest = text;
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.find(['\r', '\n']).unwrap_or(rest.len());
|
||||
let line = rest.get(..end).unwrap_or_default();
|
||||
let after = rest.get(end..).unwrap_or_default();
|
||||
rest = after.strip_prefix("\r\n").or_else(|| after.strip_prefix(['\r', '\n'])).unwrap_or(after);
|
||||
Some(line)
|
||||
})
|
||||
}
|
||||
|
||||
/// Byte-level twin of [`ascii_lines`] for the up-front limit check, so the count matches the parser.
|
||||
fn byte_lines(mut rest: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(rest.len());
|
||||
let line = &rest[..end];
|
||||
let after = &rest[end..];
|
||||
rest = match after {
|
||||
[b'\r', b'\n', tail @ ..] | [b'\r' | b'\n', tail @ ..] => tail,
|
||||
_ => after,
|
||||
};
|
||||
Some(line)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
|
||||
parse_ascii_counted(text, max_tags(), 0)
|
||||
parse_ascii_with_limit(text, MAX_TAGS)
|
||||
}
|
||||
|
||||
/// [`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_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
|
||||
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
|
||||
let mut tags = Vec::new();
|
||||
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
|
||||
let mut lines = ascii_lines(text.strip_prefix('\u{feff}').unwrap_or(text)).enumerate();
|
||||
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
|
||||
loop {
|
||||
let Some((ln, code_line)) = lines.next() else { break };
|
||||
let ct = code_line.trim();
|
||||
@@ -367,7 +295,7 @@ fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<V
|
||||
if tags.len() > max_tags {
|
||||
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
|
||||
}
|
||||
if code == 0 && tags.last().is_some_and(|t| t.str().trim_end() == "EOF") {
|
||||
if code == 0 && tags.last().is_some_and(|t| t.str() == "EOF") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -429,23 +357,12 @@ impl<'a> Cur<'a> {
|
||||
|
||||
/// Binary DXF (R13+: 2-byte group codes).
|
||||
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_binary_with_limit(b, max_tags())
|
||||
parse_binary_with_limit(b, MAX_TAGS)
|
||||
}
|
||||
|
||||
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
let mut text = codepage::BinaryText::default();
|
||||
let tags = parse_binary_pass(b, max_tags, &mut text)?;
|
||||
if !text.needs_code_page_pass() {
|
||||
return Ok(tags);
|
||||
}
|
||||
// The first pass's tags go before the second pass runs: both held at once would double
|
||||
// the peak near the tag limit.
|
||||
drop(tags);
|
||||
parse_binary_pass(b, max_tags, &mut codepage::BinaryText::code_page_pass())
|
||||
}
|
||||
|
||||
fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText) -> Result<Vec<Tag>> {
|
||||
let mut c = Cur { b, p: 0 };
|
||||
let mut text = codepage::BinaryText::default();
|
||||
let mut tags = Vec::new();
|
||||
while c.p < b.len() {
|
||||
let mut code = i32::from(c.i16()?);
|
||||
@@ -454,7 +371,7 @@ fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText)
|
||||
}
|
||||
let v = match kind_of(code) {
|
||||
Kind::Str => Value::Str(unescape_unicode(&text.string(code, c.cstr()?))),
|
||||
Kind::Real => Value::Real(Some(c.f64()?).filter(|v| v.is_finite()).unwrap_or(0.0)),
|
||||
Kind::Real => Value::Real(c.f64()?),
|
||||
Kind::Int16 => Value::Int(i64::from(c.i16()?)),
|
||||
Kind::Int32 => Value::Int(i64::from(c.i32()?)),
|
||||
Kind::Int64 => Value::Int(c.i64()?),
|
||||
@@ -469,7 +386,7 @@ fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText)
|
||||
Value::Hex(s)
|
||||
}
|
||||
};
|
||||
let eof = code == 0 && matches!(&v, Value::Str(s) if s.trim_end() == "EOF");
|
||||
let eof = code == 0 && matches!(&v, Value::Str(s) if s == "EOF");
|
||||
tags.push(Tag { code, value: v });
|
||||
if tags.len() > max_tags {
|
||||
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
|
||||
@@ -544,22 +461,7 @@ fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
|
||||
Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()),
|
||||
Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()),
|
||||
Value::Hex(h) => {
|
||||
// The DXF Reference limits a binary chunk line to 254 hex digits (127 bytes);
|
||||
// longer data continues in further groups of the same code. Only hex digits are
|
||||
// written, so a hostile value can't inject tags.
|
||||
let digits = normalize_hex(h);
|
||||
let mut chunks: Vec<&str> = digits.as_bytes().chunks(254).map(|c| std::str::from_utf8(c).unwrap_or_default()).collect();
|
||||
if chunks.is_empty() {
|
||||
chunks.push("");
|
||||
}
|
||||
for (n, chunk) in chunks.into_iter().enumerate() {
|
||||
if n > 0 {
|
||||
let _ = write!(out, "\r\n{:>3}\r\n", t.code);
|
||||
}
|
||||
out.extend_from_slice(chunk.as_bytes());
|
||||
}
|
||||
}
|
||||
Value::Hex(h) => out.extend_from_slice(h.as_bytes()),
|
||||
}
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
@@ -607,11 +509,13 @@ fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
}
|
||||
Kind::Int16 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&wrap_i16(t.value.as_i64()));
|
||||
let v = t.value.as_i64().clamp(i64::from(i16::MIN), i64::from(i16::MAX));
|
||||
out.extend_from_slice(&i16::try_from(v).unwrap_or_default().to_le_bytes());
|
||||
}
|
||||
Kind::Int32 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&wrap_i32(t.value.as_i64()));
|
||||
let v = t.value.as_i64().clamp(i64::from(i32::MIN), i64::from(i32::MAX));
|
||||
out.extend_from_slice(&i32::try_from(v).unwrap_or_default().to_le_bytes());
|
||||
}
|
||||
Kind::Int64 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
@@ -639,38 +543,12 @@ fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Hex text cleaned up: only the hex digits are kept, and an odd trailing digit is padded with
|
||||
/// `0` (so the bytes before it are never shifted). Used by the reader and both writers.
|
||||
fn normalize_hex(s: &str) -> String {
|
||||
let mut out: String = s.chars().filter(char::is_ascii_hexdigit).collect();
|
||||
if out.len() % 2 == 1 {
|
||||
out.push('0');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of hex text, two digits each (see [`normalize_hex`]).
|
||||
/// The bytes of hex text, two digits each; anything that isn't a pair of hex digits is skipped.
|
||||
fn hex_bytes(s: &str) -> Vec<u8> {
|
||||
let digits: Vec<u8> = normalize_hex(s).bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect();
|
||||
let digits: Vec<u8> = s.bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect();
|
||||
digits.as_chunks::<2>().0.iter().map(|[hi, lo]| (hi << 4) | lo).collect()
|
||||
}
|
||||
|
||||
/// A 16- or 32-bit integer for binary DXF: values that fit the unsigned width wrap into the
|
||||
/// signed one (65535 is -1, as writers that store unsigned flags expect); anything else clamps.
|
||||
fn wrap_i16(v: i64) -> [u8; 2] {
|
||||
match u16::try_from(v) {
|
||||
Ok(u) => u.to_le_bytes(),
|
||||
Err(_) => i16::try_from(v.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default().to_le_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_i32(v: i64) -> [u8; 4] {
|
||||
match u32::try_from(v) {
|
||||
Ok(u) => u.to_le_bytes(),
|
||||
Err(_) => i32::try_from(v.clamp(i64::from(i32::MIN), i64::from(i32::MAX))).unwrap_or_default().to_le_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` are a binary DXF file (they start with the binary sentinel).
|
||||
pub fn is_binary(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(BINARY_SENTINEL)
|
||||
@@ -732,12 +610,12 @@ pub fn sections(tags: &[Tag]) -> Vec<Section> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < tags.len() {
|
||||
let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str().trim_end() == "SECTION");
|
||||
let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str() == "SECTION");
|
||||
if is_sec {
|
||||
let name = tags.get(i + 1).filter(|t| t.code == 2).map(|t| t.str().trim_end().to_string()).unwrap_or_default();
|
||||
let name = tags.get(i + 1).filter(|t| t.code == 2).map(Tag::str).unwrap_or_default();
|
||||
let start = i + 2;
|
||||
let mut j = start;
|
||||
while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str().trim_end() == "ENDSEC")) {
|
||||
while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str() == "ENDSEC")) {
|
||||
j += 1;
|
||||
}
|
||||
out.push(Section { name, tags: tags.get(start..j).unwrap_or(&[]).to_vec() });
|
||||
@@ -899,27 +777,3 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
//! Hostile-input hardening of the DXF reader and writer (#1025): one regression test per finding.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, parse, parse_with_limit, sections, write_ascii, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
|
||||
fn bin_with(f: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
|
||||
let mut b = SENTINEL.to_vec();
|
||||
f(&mut b);
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF\0");
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_reals_that_are_not_finite_become_zero() {
|
||||
for bits in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let b = bin_with(|b| {
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&bits.to_le_bytes());
|
||||
});
|
||||
let tags = parse(&b).unwrap();
|
||||
assert_eq!(tags[0], Tag::f(10, 0.0), "{bits}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_accepts_bom_and_cr_only_line_ends() {
|
||||
let want = vec![Tag::s(0, "SECTION"), Tag::s(2, "ENTITIES"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")];
|
||||
let text = "0\rSECTION\r2\rENTITIES\r0\rENDSEC\r0\rEOF\r";
|
||||
assert_eq!(parse(text.as_bytes()).unwrap(), want);
|
||||
let mut bom = b"\xEF\xBB\xBF".to_vec();
|
||||
bom.extend_from_slice(text.replace('\r', "\r\n").as_bytes());
|
||||
assert_eq!(parse(&bom).unwrap(), want);
|
||||
// Mixed terminators.
|
||||
assert_eq!(parse(b"0\r\nSECTION\r2\nENTITIES\n0\rENDSEC\r\n0\nEOF").unwrap(), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cr_only_files_count_against_the_tag_limit() {
|
||||
let mut text = String::new();
|
||||
for _ in 0..50 {
|
||||
text.push_str("999\rcomment\r");
|
||||
}
|
||||
text.push_str("0\rEOF\r");
|
||||
assert!(parse_with_limit(text.as_bytes(), 10).is_err());
|
||||
assert_eq!(parse_with_limit(text.as_bytes(), 100).unwrap().len(), 51);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_byte_spoils_one_line_not_the_file() {
|
||||
// Header says ANSI_1251; one line is UTF-8, one is Windows-1251, one has a stray byte.
|
||||
let mut b = b"9\n$DWGCODEPAGE\n3\nANSI_1251\n1\n".to_vec();
|
||||
b.extend_from_slice("Größe".as_bytes());
|
||||
b.extend_from_slice(b"\n1\n\xCF\xF0\xE8\xE2\xE5\xF2\n0\nEOF\n");
|
||||
let t = parse(&b).unwrap();
|
||||
assert_eq!(t[1].str(), "ANSI_1251");
|
||||
assert_eq!(t[2].str(), "Größe");
|
||||
assert_eq!(t[3].str(), "Привет");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_uses_utf8_per_line_and_binary_stays_in_the_code_page() {
|
||||
// ANSI_1252 file with UTF-8 bytes in one string and a Latin-1 byte in another. The ASCII
|
||||
// reader takes a line that is valid UTF-8 as UTF-8; the binary reader reads every string
|
||||
// from the code page, because a pre-R2007 binary file has no way to escape to UTF-8.
|
||||
let utf8 = "Größe".as_bytes();
|
||||
let latin = b"Caf\xE9";
|
||||
let mut a = b"9\n$DWGCODEPAGE\n3\nANSI_1252\n1\n".to_vec();
|
||||
a.extend_from_slice(utf8);
|
||||
a.extend_from_slice(b"\n1\n");
|
||||
a.extend_from_slice(latin);
|
||||
a.extend_from_slice(b"\n0\nEOF\n");
|
||||
let from_ascii = parse(&a).unwrap();
|
||||
let b = bin_with(|b| {
|
||||
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, b"ANSI_1252"), (1, utf8), (1, latin)] {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s);
|
||||
b.push(0);
|
||||
}
|
||||
});
|
||||
let from_binary = parse(&b).unwrap();
|
||||
assert_eq!(from_ascii[2].str(), "Größe");
|
||||
assert_eq!(from_ascii[3].str(), "Café");
|
||||
assert_eq!(from_binary[2].str(), "Größe");
|
||||
assert_eq!(from_binary[3].str(), "Café");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_spaces_keep_sections_and_eof() {
|
||||
let t = parse(b"0\nSECTION \n2\nENTITIES \n0\nLINE\n0\nENDSEC \n0\nEOF \n0\nSECTION\n2\nX\n0\nENDSEC\n").unwrap();
|
||||
// The EOF with a trailing space still ends the file.
|
||||
assert_eq!(t.len(), 5);
|
||||
let s = sections(&t);
|
||||
assert_eq!(s.len(), 1);
|
||||
assert_eq!(s[0].name, "ENTITIES");
|
||||
assert_eq!(s[0].tags.len(), 1);
|
||||
// Binary EOF too.
|
||||
let mut b = SENTINEL.to_vec();
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF \0");
|
||||
b.extend_from_slice(&1i16.to_le_bytes());
|
||||
b.extend_from_slice(b"after\0");
|
||||
assert_eq!(parse(&b).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_groups_reject_non_finite_and_clamp() {
|
||||
let t = parse(b"70\ninf\n70\nNaN\n70\n1e30\n70\n-1e30\n90\n1e30\n160\n1e30\n160\n-inf\n62\n12.0\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0], Tag::i(70, 0));
|
||||
assert_eq!(t[1], Tag::i(70, 0));
|
||||
// Clamped to what the group's width holds, unsigned values included (the binary writer
|
||||
// keeps up to 65535 and 4294967295 as their bit patterns).
|
||||
assert_eq!(t[2], Tag::i(70, i64::from(u16::MAX)));
|
||||
assert_eq!(t[3], Tag::i(70, i64::from(i16::MIN)));
|
||||
assert_eq!(t[4], Tag::i(90, i64::from(u32::MAX)));
|
||||
assert_eq!(t[5], Tag::i(160, i64::MAX));
|
||||
assert_eq!(t[6], Tag::i(160, 0));
|
||||
assert_eq!(t[7], Tag::i(62, 12));
|
||||
assert_eq!(Value::Str("inf".into()).as_i64(), 0);
|
||||
}
|
||||
|
||||
/// An unsigned value spelled as a float reads as the same spelled as an integer (#1026 review):
|
||||
/// `65535.0` is 65535 like `65535`, not 32767.
|
||||
#[test]
|
||||
fn unsigned_values_read_the_same_in_either_spelling() {
|
||||
let t = parse(b"70\n65535.0\n70\n65535\n90\n4294967295.0\n90\n4294967295\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0], t[1]);
|
||||
assert_eq!(t[0], Tag::i(70, 65535));
|
||||
assert_eq!(t[2], t[3]);
|
||||
assert_eq!(t[2], Tag::i(90, 4_294_967_295));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_groups_pad_odd_digits_and_drop_junk() {
|
||||
let t = parse(b"310\nABC\n310\nA-B zC1\n310\n\n1004\n0aF\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].value, Value::Hex("ABC0".into()));
|
||||
assert_eq!(t[1].value, Value::Hex("ABC1".into()));
|
||||
assert_eq!(t[2].value, Value::Hex(String::new()));
|
||||
assert_eq!(t[3].value, Value::Hex("0aF0".into()));
|
||||
// The binary writer agrees with the reader: the odd digit is padded, nothing shifts.
|
||||
let back = parse(&write_binary(&[Tag::new(310, Value::Hex("ABC".into())), Tag::s(0, "EOF")], true)).unwrap();
|
||||
assert_eq!(back[0].value, Value::Hex("ABC0".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_hex_cannot_inject_tags_and_is_split_at_254_digits() {
|
||||
let evil = Tag::new(310, Value::Hex("AB\r\n0\r\nEOF".into()));
|
||||
let text = write_ascii(&[evil, Tag::s(0, "EOF")]);
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.len(), 2, "{text:?}");
|
||||
assert_eq!(back[0].value, Value::Hex("AB0EF0".into()));
|
||||
let long = "AB".repeat(300);
|
||||
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")]).as_bytes()).unwrap();
|
||||
let sizes: Vec<usize> = back.iter().filter(|t| t.code == 310).map(|t| t.str().len()).collect();
|
||||
assert_eq!(sizes, vec![254, 254, 92]);
|
||||
// An empty chunk is still one group.
|
||||
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(String::new())), Tag::s(0, "EOF")]).as_bytes()).unwrap();
|
||||
assert_eq!(back[0].code, 310);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_sixteen_and_thirty_two_bit_values_wrap_in_range_and_clamp_outside() {
|
||||
let tags = [
|
||||
Tag::i(70, 65535),
|
||||
Tag::i(70, 40000),
|
||||
Tag::i(70, 70000),
|
||||
Tag::i(70, -40000),
|
||||
Tag::i(90, 0xFFFF_FFFF),
|
||||
Tag::i(90, 1 << 40),
|
||||
Tag::i(90, -(1 << 40)),
|
||||
Tag::s(0, "EOF"),
|
||||
];
|
||||
let back = parse(&write_binary(&tags, true)).unwrap();
|
||||
assert_eq!(back[0], Tag::i(70, -1));
|
||||
assert_eq!(back[1], Tag::i(70, 40000 - 65536));
|
||||
assert_eq!(back[2], Tag::i(70, i64::from(i16::MAX)));
|
||||
assert_eq!(back[3], Tag::i(70, i64::from(i16::MIN)));
|
||||
assert_eq!(back[4], Tag::i(90, -1));
|
||||
assert_eq!(back[5], Tag::i(90, i64::from(i32::MAX)));
|
||||
assert_eq!(back[6], Tag::i(90, i64::from(i32::MIN)));
|
||||
}
|
||||
|
||||
/// Strings of a `$DWGCODEPAGE`-declared file as ASCII text and as a binary file.
|
||||
fn both_forms(code_page: &str, strings: &[&[u8]]) -> [Vec<Tag>; 2] {
|
||||
let mut a = format!("9\n$DWGCODEPAGE\n3\n{code_page}\n").into_bytes();
|
||||
for s in strings {
|
||||
a.extend_from_slice(b"1\n");
|
||||
a.extend_from_slice(s);
|
||||
a.push(b'\n');
|
||||
}
|
||||
a.extend_from_slice(b"0\nEOF\n");
|
||||
let b = bin_with(|b| {
|
||||
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, code_page.as_bytes())].into_iter().chain(strings.iter().map(|s| (1, *s))) {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s);
|
||||
b.push(0);
|
||||
}
|
||||
});
|
||||
[parse(&a).unwrap(), parse(&b).unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_short_strings_that_look_like_utf8_stay_in_the_code_page() {
|
||||
// C3 B1 is valid UTF-8 ("ñ") but is also two half-width katakana in Shift-JIS; the second
|
||||
// string is plainly Shift-JIS, so the file is not UTF-8.
|
||||
for t in both_forms("ANSI_932", &[b"\xC3\xB1", b"\x90\x7D\x96\xCA"]) {
|
||||
assert_eq!(t[2].str(), "\u{FF83}\u{FF71}");
|
||||
assert_eq!(t[3].str(), "図面");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_file_that_is_all_utf8_reads_as_utf8() {
|
||||
for t in both_forms("ANSI_932", &["図面".as_bytes(), b"\xC3\xB1", b"plain"]) {
|
||||
assert_eq!(t[2].str(), "図面");
|
||||
assert_eq!(t[3].str(), "ñ");
|
||||
assert_eq!(t[4].str(), "plain");
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,8 @@ fn arc_geom(a: &cadcraft_doc::Arc) -> cadcraft_geom::Arc {
|
||||
|
||||
/// Where a snap sits on an object now.
|
||||
pub fn snap_point(d: &Drawing, a: &DimAssoc) -> Option<Vec2> {
|
||||
snap_on(d, &d.entity(a.handle)?.kind, &a.snap)
|
||||
}
|
||||
|
||||
/// Where `snap` sits on an object of kind `kind` (an intersection finds its other line in `d`).
|
||||
fn snap_on(d: &Drawing, kind: &EntityKind, snap: &AssocSnap) -> Option<Vec2> {
|
||||
match (kind, snap) {
|
||||
let e = d.entity(a.handle)?;
|
||||
match (&e.kind, &a.snap) {
|
||||
(EntityKind::Line(l), AssocSnap::Start) => Some(l.a.xy()),
|
||||
(EntityKind::Line(l), AssocSnap::End) => Some(l.b.xy()),
|
||||
(EntityKind::Line(l), AssocSnap::Mid) => Some(l.a.xy().mid(l.b.xy())),
|
||||
@@ -92,61 +88,6 @@ fn candidates(kind: &EntityKind) -> Vec<(AssocSnap, Vec2)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two snaps are the same kind of object point: an endpoint or vertex, a centre, or a
|
||||
/// midpoint.
|
||||
fn same_kind(a: &AssocSnap, b: &AssocSnap) -> bool {
|
||||
matches!(
|
||||
(a, b),
|
||||
(AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. }, AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. })
|
||||
| (AssocSnap::Center, AssocSnap::Center)
|
||||
| (AssocSnap::Mid, AssocSnap::Mid)
|
||||
)
|
||||
}
|
||||
|
||||
/// BREAK or TRIM reshaped the object `pieces[0]` (which was `old`) and added the other pieces:
|
||||
/// move the dimension attachments on it as AutoCAD 2024 does. An attachment whose point is still
|
||||
/// a point of the same kind on the object stays there (a closed polyline opened by BREAK numbers
|
||||
/// its vertices from the break); one whose point is on a new piece moves to that piece, so a
|
||||
/// dimension across a line broken in its middle stays where it was; any other is left as it was,
|
||||
/// so the dimension follows the object's new end when that end was cut off. Attachments at an
|
||||
/// angle on a circle or arc and at an intersection stay on the object. [`update`] moves the
|
||||
/// definition points after the command.
|
||||
pub fn follow_pieces(d: &mut Drawing, old: &EntityKind, pieces: &[Handle]) {
|
||||
let Some(&h) = pieces.first() else { return };
|
||||
let mut changes: Vec<(Handle, Vec<DimAssoc>)> = Vec::new();
|
||||
for store in std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)) {
|
||||
for e in store.iter() {
|
||||
let EntityKind::Dimension(dm) = &e.kind else { continue };
|
||||
if !dm.assoc.iter().any(|a| a.handle == h) {
|
||||
continue;
|
||||
}
|
||||
let assoc: Vec<DimAssoc> = dm.assoc.iter().map(|a| on_piece(d, old, pieces, a).unwrap_or_else(|| a.clone())).collect();
|
||||
if assoc != dm.assoc {
|
||||
changes.push((e.handle, assoc));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (dh, assoc) in changes {
|
||||
let _ = d.modify_entity(dh, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.assoc = assoc;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The piece attachment `a` moves to when its object became `pieces` (see [`follow_pieces`]).
|
||||
fn on_piece(d: &Drawing, old: &EntityKind, pieces: &[Handle], a: &DimAssoc) -> Option<DimAssoc> {
|
||||
if pieces.first() != Some(&a.handle) || matches!(a.snap, AssocSnap::OnCircle { .. } | AssocSnap::Intersection { .. }) {
|
||||
return None;
|
||||
}
|
||||
let p = snap_on(d, old, &a.snap)?;
|
||||
pieces.iter().find_map(|&ph| {
|
||||
let (snap, _) = candidates(&d.entity(ph)?.kind).into_iter().find(|(s, q)| same_kind(&a.snap, s) && close(p, *q))?;
|
||||
Some(DimAssoc { point: a.point.clone(), handle: ph, snap })
|
||||
})
|
||||
}
|
||||
|
||||
/// An object point exactly at `p` (an endpoint, vertex, centre or midpoint) to attach to.
|
||||
pub fn find_snap(d: &Drawing, space: &Space, p: Vec2) -> Option<(Handle, AssocSnap)> {
|
||||
if !p.is_finite() {
|
||||
@@ -303,10 +244,8 @@ pub fn recompute(dm: &Dimension, prev: Option<&Dimension>, before: &Drawing, aft
|
||||
let was = prev.unwrap_or(dm);
|
||||
nd.assoc.retain(|a| {
|
||||
let Some(old) = def_point(was, &a.point) else { return false };
|
||||
// Only follow if the point sat on the object before the change, as attached then (BREAK
|
||||
// and TRIM may have moved the attachment to a piece of the object since).
|
||||
let held = was.assoc.iter().find(|o| o.point == a.point).unwrap_or(a);
|
||||
if let Some(prev) = snap_point(before, held)
|
||||
// Only follow if the point sat on the object before the change.
|
||||
if let Some(prev) = snap_point(before, a)
|
||||
&& !close(prev, old.xy())
|
||||
{
|
||||
return false;
|
||||
@@ -365,20 +304,16 @@ pub fn update(after: &mut Arc<Drawing>, before: &Drawing) -> usize {
|
||||
if dm.assoc.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let prev = match before.entity(e.handle).map(|x| &x.kind) {
|
||||
Some(EntityKind::Dimension(p)) => Some(p),
|
||||
_ => None,
|
||||
};
|
||||
// Its objects changed, or the command moved an attachment (BREAK and TRIM move them
|
||||
// to new pieces, which a later pick of the same TRIM may reshape again).
|
||||
let touched = dm.assoc.iter().any(|a| {
|
||||
src_changed(before, d, a.handle)
|
||||
|| matches!(a.snap, AssocSnap::Intersection { other } if src_changed(before, d, other))
|
||||
|| prev.is_some_and(|p| !p.assoc.contains(a))
|
||||
src_changed(before, d, a.handle) || matches!(a.snap, AssocSnap::Intersection { other } if src_changed(before, d, other))
|
||||
});
|
||||
if !touched {
|
||||
continue;
|
||||
}
|
||||
let prev = match before.entity(e.handle).map(|x| &x.kind) {
|
||||
Some(EntityKind::Dimension(p)) => Some(p),
|
||||
_ => None,
|
||||
};
|
||||
let nd = recompute(dm, prev, before, d);
|
||||
if nd != *dm {
|
||||
changes.push((e.handle, nd));
|
||||
|
||||
@@ -234,10 +234,9 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
missing("table style", "Standard", out);
|
||||
d.table_styles.insert(0, TableStyle::default());
|
||||
}
|
||||
// Name sets built once: both tables come from the drawing, so a scan per reference is quadratic.
|
||||
let linetypes: HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
|
||||
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
|
||||
for l in &mut d.layers {
|
||||
if !linetypes.contains(&l.linetype.to_ascii_lowercase()) {
|
||||
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
|
||||
out.push(issue(
|
||||
"missingLinetype",
|
||||
"tables",
|
||||
@@ -248,8 +247,8 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
l.linetype = "Continuous".into();
|
||||
}
|
||||
}
|
||||
let styles: HashSet<String> = d.text_styles.iter().map(|s| s.name.to_ascii_lowercase()).collect();
|
||||
let known = |n: &str| styles.contains(&n.to_ascii_lowercase());
|
||||
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
|
||||
for s in &mut d.dim_styles {
|
||||
if !known(&s.text_style) {
|
||||
out.push(issue(
|
||||
|
||||
+26
-123
@@ -166,11 +166,9 @@ pub struct Autosaver {
|
||||
start: std::time::Instant,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: Option<f64>,
|
||||
/// This app's lock files: one per folder, because SAVEFILEPATH can change while files remain
|
||||
/// in the old folder. Each with the file it holds open for the system lock, or `None` where
|
||||
/// locks don't work and only the file's age counts.
|
||||
/// The lock file this app holds the system lock on, open for as long as the lock is wanted.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
locks: Vec<(std::path::PathBuf, Option<std::fs::File>)>,
|
||||
lock: Option<(std::path::PathBuf, std::fs::File)>,
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -187,7 +185,7 @@ impl Autosaver {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
locks: Vec::new(),
|
||||
lock: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,9 +203,9 @@ impl Autosaver {
|
||||
self.docs.values().filter_map(|t| t.file.clone()).collect()
|
||||
}
|
||||
|
||||
/// The drawings with no up-to-date recovery copy and why, for the host to show the user.
|
||||
pub fn failures(&self) -> impl Iterator<Item = (u64, &str)> {
|
||||
self.failed.iter().map(|(uid, why)| (*uid, why.as_str()))
|
||||
/// Why a drawing currently has no up-to-date recovery copy, for the host to show the user.
|
||||
pub fn failure(&self) -> Option<&str> {
|
||||
self.failed.values().next().map(String::as_str)
|
||||
}
|
||||
|
||||
/// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder
|
||||
@@ -405,11 +403,10 @@ mod native {
|
||||
pub fn prune_locks(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let names: Vec<String> = entries.flatten().take(10_000).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
// The instances that own autosave files, parsed once rather than once per lock file.
|
||||
let with_files: std::collections::HashSet<&str> = names.iter().filter_map(|f| parse_file_name(f)).map(|(_, i, _)| i).collect();
|
||||
for n in &names {
|
||||
let Some(instance) = parse_lock_name(n) else { continue };
|
||||
if !with_files.contains(instance) && !owner_alive(dir, instance) {
|
||||
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
|
||||
if !has_files && !owner_alive(dir, instance) {
|
||||
let _ = std::fs::remove_file(dir.join(n));
|
||||
}
|
||||
}
|
||||
@@ -436,22 +433,7 @@ mod native {
|
||||
}
|
||||
// Create a fresh temporary file exclusively and rename it into place, so a planted
|
||||
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
|
||||
// The file holds unsaved work: owner-only from its creation, whatever the umask.
|
||||
crate::atomic_write::write_atomic_with(Path::new(path), private_mode(), |f| f.write_all(&bytes)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Owner read/write only (`None` where file modes don't exist).
|
||||
fn private_mode() -> Option<std::fs::Permissions> {
|
||||
#[cfg(unix)]
|
||||
return Some(std::os::unix::fs::PermissionsExt::from_mode(0o600));
|
||||
#[cfg(not(unix))]
|
||||
None
|
||||
}
|
||||
|
||||
/// Write this app's lock file at `path` without following a link planted there: the file is
|
||||
/// replaced (exclusive temporary file, then rename), never written through (CWE-59).
|
||||
fn write_lock(path: &Path, id: u32) -> std::io::Result<()> {
|
||||
crate::atomic_write::write_atomic_with(path, private_mode(), |f| f.write_all(id.to_string().as_bytes()))
|
||||
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -489,12 +471,6 @@ mod native {
|
||||
self.running.iter().any(|(_, h)| !h.is_finished())
|
||||
}
|
||||
|
||||
/// Whether a write's result hasn't been taken in yet, finished or not: the host needs
|
||||
/// another [`Autosaver::tick`] to learn whether it failed.
|
||||
pub fn results_pending(&self) -> bool {
|
||||
!self.running.is_empty()
|
||||
}
|
||||
|
||||
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
|
||||
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
|
||||
/// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`]
|
||||
@@ -503,41 +479,16 @@ mod native {
|
||||
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
|
||||
self.running = running;
|
||||
for (uid, h) in done {
|
||||
self.settle(uid, h);
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(&mut self, uid: u64, h: std::thread::JoinHandle<std::result::Result<(), String>>) {
|
||||
self.busy.remove(&uid);
|
||||
match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) {
|
||||
Ok(()) => {
|
||||
self.failed.remove(&uid);
|
||||
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),
|
||||
}
|
||||
Err(e) => self.unwritten(uid, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write every drawing with changes not yet autosaved and wait until it is done, because
|
||||
/// the app has gone wrong and may not get another frame. Unlike [`Autosaver::save_now`]
|
||||
/// this also runs when SAVETIME is 0: unsaved work comes before that preference. Returns
|
||||
/// how many drawings were written.
|
||||
pub fn emergency_save(&mut self, s: &Session) -> usize {
|
||||
for (uid, h) in std::mem::take(&mut self.running) {
|
||||
self.settle(uid, h);
|
||||
}
|
||||
let now = self.start.elapsed().as_secs_f64();
|
||||
let dir = savefilepath();
|
||||
let jobs = self.plan_with(s, now, savetime().max(1), &dir, true);
|
||||
let uids: Vec<u64> = jobs.iter().filter_map(|j| if let Job::Write { uid, .. } = j { Some(*uid) } else { None }).collect();
|
||||
self.perform(jobs);
|
||||
self.last_beat = None;
|
||||
self.heartbeat(&dir, now);
|
||||
for (uid, h) in std::mem::take(&mut self.running) {
|
||||
self.settle(uid, h);
|
||||
}
|
||||
uids.iter().filter(|uid| !self.failed.contains_key(uid)).count()
|
||||
}
|
||||
|
||||
fn unwritten(&mut self, uid: u64, why: String) {
|
||||
if let Some(t) = self.docs.get_mut(&uid) {
|
||||
t.written = None;
|
||||
@@ -593,44 +544,23 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Refresh the lock file at `path`, taking the system lock on it first if this app doesn't
|
||||
/// hold it there yet. Without one the file still works by its age.
|
||||
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)]
|
||||
if let Some((held, file)) = &self.lock
|
||||
&& held == path
|
||||
{
|
||||
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"));
|
||||
return file.set_modified(SystemTime::now());
|
||||
}
|
||||
self.lock = None;
|
||||
let id = std::process::id();
|
||||
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
|
||||
if file.try_lock().is_err() {
|
||||
self.locks.push((path.to_path_buf(), None));
|
||||
return write_lock(path, id);
|
||||
return std::fs::write(path, id.to_string());
|
||||
}
|
||||
file.set_len(0)?;
|
||||
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
|
||||
self.locks.push((path.to_path_buf(), Some(file)));
|
||||
self.lock = Some((path.to_path_buf(), file));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -645,40 +575,13 @@ mod native {
|
||||
discard(&path);
|
||||
}
|
||||
self.docs.clear();
|
||||
for (path, file) in self.locks.drain(..) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
self.lock = None;
|
||||
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)]
|
||||
|
||||
@@ -18,12 +18,10 @@ 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;
|
||||
@@ -201,7 +199,6 @@ 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());
|
||||
|
||||
@@ -159,7 +159,6 @@ 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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -248,16 +247,9 @@ fn max_count(s: &Session, others: u64, objs: usize) -> u64 {
|
||||
}
|
||||
|
||||
fn copy_all(s: &mut Session, hs: &[Handle], mats: impl Iterator<Item = Mat3>) -> Result<usize> {
|
||||
// Every copy is checked before the first is made: a refusal part way would leave the copies
|
||||
// already added in the drawing (a failed command is not rolled back).
|
||||
let mats: Vec<Mat3> = mats.collect();
|
||||
// A matrix every frame accepts needs no look at the objects; otherwise they are looked at once.
|
||||
if mats.iter().any(|m| !cadcraft_doc::ole_frame_accepts(m)) && super::modify::holds_ole_frame(s, hs, true)? {
|
||||
return Err(super::modify::ole_refusal());
|
||||
}
|
||||
let mut n = 0;
|
||||
for m in &mats {
|
||||
n += transform_entities(s, hs, m, true)?.len();
|
||||
for m in mats {
|
||||
n += transform_entities(s, hs, &m, true)?.len();
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
+15
-174
@@ -25,7 +25,6 @@ mod dyngrip;
|
||||
mod extract;
|
||||
pub(crate) mod refedit;
|
||||
mod wblock;
|
||||
mod xclip;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -44,9 +43,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["i", "-insert", "ddinsert"])
|
||||
.params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}")
|
||||
.interactive(|_| Ok(Box::new(InsertM::default()))),
|
||||
CommandSpec::new("minsert", "Multiple Insert", run_minsert)
|
||||
.params("{name, file?: DXF/DWG path (as INSERT), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y], attribs?: {TAG: value}, rows?: 1..32767 (default 1), columns?: 1..32767 (default 1), rowSpacing?, columnSpacing? (along the reference's own axes, not scaled with it)}")
|
||||
.interactive(|_| Ok(Box::new(InsertM { minsert: true, ..InsertM::default() }))),
|
||||
CommandSpec::new("attdef", "Define Attributes...", run_attdef)
|
||||
.menu(&["Draw", "Block", "Define Attributes..."])
|
||||
.alias(&["att", "-attdef"])
|
||||
@@ -93,7 +89,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.chain(attribs::attdisp_menu_specs())
|
||||
.chain(xref::specs())
|
||||
.chain(refedit::specs())
|
||||
.chain(xclip::specs())
|
||||
.chain(bedit::specs())
|
||||
.collect()
|
||||
}
|
||||
@@ -230,7 +225,6 @@ 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);
|
||||
@@ -239,33 +233,6 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option
|
||||
ins
|
||||
}
|
||||
|
||||
/// MINSERT's rectangular array of a block reference: rows and columns, and the distances between
|
||||
/// them along the reference's rotated axes (not scaled with it). One row and one column is a plain
|
||||
/// reference.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Grid {
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
row_spacing: f64,
|
||||
col_spacing: f64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
const ONE: Grid = Grid { rows: 1, cols: 1, row_spacing: 0.0, col_spacing: 0.0 };
|
||||
/// The most rows or columns MINSERT takes, as AutoCAD 2024 asks for them.
|
||||
const MAX: u32 = 32767;
|
||||
|
||||
/// A distance is kept only along an axis with more than one copy: AutoCAD 2024 asks for no
|
||||
/// other and stores 0 for it (one row of three columns: 44 = 20, 45 = 0).
|
||||
fn new(rows: u32, cols: u32, row_spacing: f64, col_spacing: f64) -> Grid {
|
||||
Grid { rows, cols, row_spacing: if rows > 1 { row_spacing } else { 0.0 }, col_spacing: if cols > 1 { col_spacing } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn apply(self, ins: &mut Insert) {
|
||||
(ins.rows, ins.cols, ins.row_spacing, ins.col_spacing) = (self.rows, self.cols, self.row_spacing, self.col_spacing);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
|
||||
/// [`insert_entity`]).
|
||||
pub(crate) fn insert_with(
|
||||
@@ -277,12 +244,10 @@ pub(crate) fn insert_with(
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values, Grid::ONE)
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
|
||||
}
|
||||
|
||||
/// [`insert_with`] with a Z scale factor of its own, and MINSERT's grid. A grid has the one set
|
||||
/// of attributes (AutoCAD 2024 asks for their values once).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// [`insert_with`] with a Z scale factor of its own.
|
||||
fn insert_scaled(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
@@ -291,11 +256,9 @@ fn insert_scaled(
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
grid: Grid,
|
||||
) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut ins = insert_entity(&blk, at, scale, rotation, base);
|
||||
grid.apply(&mut ins);
|
||||
let m = ins.transform(blk.base.xy());
|
||||
for e in blk.entities.iter() {
|
||||
if let EntityKind::AttDef(ad) = &e.kind {
|
||||
@@ -347,52 +310,24 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
insert_json(s, p, "insert", Grid::ONE)
|
||||
}
|
||||
|
||||
/// MINSERT's JSON form: INSERT's parameters and the grid.
|
||||
fn run_minsert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let count = |key: &str| -> Result<u32> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(1),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.fract() == 0.0 && *n >= 1.0 && *n <= f64::from(Grid::MAX))
|
||||
.map(|n| n as u32)
|
||||
.ok_or_else(|| bad("minsert", format!("`{key}` must be an integer between 1 and {}", Grid::MAX))),
|
||||
}
|
||||
};
|
||||
// A distance given must be a number: `"15"` is refused, not taken as 0.
|
||||
let spacing = |key: &str| -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(0.0),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("minsert", format!("`{key}` must be a number"))),
|
||||
}
|
||||
};
|
||||
let grid = Grid::new(count("rows")?, count("columns")?, spacing("rowSpacing")?, spacing("columnSpacing")?);
|
||||
insert_json(s, p, "minsert", grid)
|
||||
}
|
||||
|
||||
/// INSERT's and MINSERT's JSON form.
|
||||
fn insert_json(s: &mut Session, p: &Value, cmd: &str, grid: Grid) -> Result<Value> {
|
||||
let p = &insert_file::json_params(s, p)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
|
||||
let at = point_req(cmd, p, "at")?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
|
||||
let at = point_req("insert", p, "at")?;
|
||||
// `scale`: one factor, or [x, y] for different X and Y scale factors.
|
||||
let scale = match p.get("scale") {
|
||||
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad(cmd, "`scale` must be a number or [x, y]"))?,
|
||||
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?,
|
||||
_ => {
|
||||
let f = f64_or(p, "scale", 1.0);
|
||||
Vec2::new(f, f)
|
||||
}
|
||||
};
|
||||
if scale.x == 0.0 || scale.y == 0.0 {
|
||||
return Err(bad(cmd, "scale cannot be 0"));
|
||||
return Err(bad("insert", "scale cannot be 0"));
|
||||
}
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let h = insert_scaled(s, &name, at, Vec3::new(scale.x, scale.y, scale.x), rot, point_param(p, "basePoint"), &vals, grid)?;
|
||||
if cmd == "insert" && bool_or(p, "explode", false) {
|
||||
let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?;
|
||||
if bool_or(p, "explode", false) {
|
||||
// The EXPLODE command body, not `execute`: INSERT records the one undo step.
|
||||
let explode = find_command("explode").ok_or_else(|| EngineError::UnknownCommand("explode".into()))?;
|
||||
return (explode.run)(s, &json!({ "handles": [h.hex()] }));
|
||||
@@ -625,15 +560,6 @@ enum InsStage {
|
||||
/// XYZ: the Z scale factor, after X and Y.
|
||||
ZScale,
|
||||
Rotation,
|
||||
/// MINSERT: the number of rows, then of columns.
|
||||
Rows,
|
||||
Cols,
|
||||
/// MINSERT: the distance between rows, or a unit cell's first corner.
|
||||
RowSpacing,
|
||||
/// MINSERT: the unit cell's opposite corner (its width between columns, its height between
|
||||
/// rows).
|
||||
Cell(Vec2),
|
||||
ColSpacing,
|
||||
Attribs,
|
||||
/// A Verify attribute asked again.
|
||||
Verify,
|
||||
@@ -666,12 +592,6 @@ struct InsertM {
|
||||
explode: bool,
|
||||
/// The file waiting for the Redefine answer.
|
||||
file: Option<insert_file::FileRef>,
|
||||
/// MINSERT: after the rotation, the grid's rows and columns and the distances between them.
|
||||
minsert: bool,
|
||||
rows: Option<u32>,
|
||||
cols: Option<u32>,
|
||||
row_spacing: Option<f64>,
|
||||
col_spacing: Option<f64>,
|
||||
}
|
||||
|
||||
impl InsertM {
|
||||
@@ -681,8 +601,6 @@ impl InsertM {
|
||||
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
|
||||
} else if self.rotation.is_none() {
|
||||
InsStage::Rotation
|
||||
} else if let Some(g) = self.grid_stage() {
|
||||
g
|
||||
} else {
|
||||
InsStage::Attribs
|
||||
};
|
||||
@@ -701,7 +619,7 @@ impl InsertM {
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
{
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values, self.grid())?;
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
|
||||
if self.explode {
|
||||
insert_file::explode_insert(s, h)?;
|
||||
}
|
||||
@@ -710,30 +628,6 @@ impl InsertM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// MINSERT's next grid prompt, if any is left (as AutoCAD 2024 asks them): the rows, the
|
||||
/// columns, then the distance between rows when there is more than one row and between
|
||||
/// columns when there is more than one column. A unit cell gives both distances.
|
||||
fn grid_stage(&self) -> Option<InsStage> {
|
||||
if !self.minsert {
|
||||
return None;
|
||||
}
|
||||
let (Some(rows), Some(cols)) = (self.rows, self.cols) else {
|
||||
return Some(if self.rows.is_none() { InsStage::Rows } else { InsStage::Cols });
|
||||
};
|
||||
if rows > 1 && self.row_spacing.is_none() {
|
||||
Some(InsStage::RowSpacing)
|
||||
} else if cols > 1 && self.col_spacing.is_none() {
|
||||
Some(InsStage::ColSpacing)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid answered so far (one copy for INSERT).
|
||||
fn grid(&self) -> Grid {
|
||||
Grid::new(self.rows.unwrap_or(1), self.cols.unwrap_or(1), self.row_spacing.unwrap_or(0.0), self.col_spacing.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// The Verify attributes asked for.
|
||||
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
|
||||
self.attdefs.iter().filter(|a| a.3).collect()
|
||||
@@ -804,18 +698,13 @@ impl InsertM {
|
||||
}
|
||||
}
|
||||
|
||||
/// A distance typed at MINSERT's grid prompts: any finite number, negative or 0 too.
|
||||
fn distance(t: &str) -> Result<f64> {
|
||||
number(t).filter(|v| v.is_finite()).ok_or_else(|| EngineError::Other("Requires a distance.".into()))
|
||||
}
|
||||
|
||||
fn nonzero(t: &str) -> Result<f64> {
|
||||
number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))
|
||||
}
|
||||
|
||||
impl Interactive for InsertM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.minsert { "MINSERT" } else { "INSERT" }
|
||||
"INSERT"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let at = self.at.unwrap_or_default();
|
||||
@@ -842,16 +731,9 @@ impl Interactive for InsertM {
|
||||
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
|
||||
InsStage::Rows => Prompt::new("Enter number of rows (---)", Accept::NUMBER).default("1"),
|
||||
InsStage::Cols => Prompt::new("Enter number of columns (|||)", Accept::NUMBER).default("1"),
|
||||
InsStage::RowSpacing => Prompt::new("Enter distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
|
||||
InsStage::Cell(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
InsStage::ColSpacing => Prompt::new("Specify distance between columns (|||)", Accept::NUMBER),
|
||||
InsStage::Attribs => {
|
||||
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let p = Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT);
|
||||
// No default shown when there is none ("Value:", AutoCAD 2024), not "<>".
|
||||
if default.is_empty() { p } else { p.default(default) }
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
|
||||
}
|
||||
InsStage::Verify => {
|
||||
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
|
||||
@@ -873,9 +755,9 @@ impl Interactive for InsertM {
|
||||
s.echo(format!("Defined blocks: {}", names.join(", ")));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// `*name`: insert exploded (INSERT only: a grid has no exploded form).
|
||||
self.explode = !self.minsert && n.starts_with('*');
|
||||
let n = if self.explode { n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string() } else { n };
|
||||
// `*name`: insert exploded.
|
||||
self.explode = n.starts_with('*');
|
||||
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
|
||||
match insert_file::file_ref(s, &n) {
|
||||
Some(f) if f.ask_redefine(s) => {
|
||||
self.file = Some(f);
|
||||
@@ -1015,45 +897,6 @@ impl Interactive for InsertM {
|
||||
});
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::Rows | InsStage::Cols => {
|
||||
let n = match i {
|
||||
Input::Text(t) => t
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= f64::from(Grid::MAX))
|
||||
.map(|v| v as u32)
|
||||
.ok_or_else(|| EngineError::Other(format!("Requires an integer between 1 and {}.", Grid::MAX)))?,
|
||||
Input::Enter => 1,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if self.stage == InsStage::Rows {
|
||||
self.rows = Some(n);
|
||||
} else {
|
||||
self.cols = Some(n);
|
||||
}
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::RowSpacing => match i {
|
||||
Input::Point(a) => self.stage = InsStage::Cell(a),
|
||||
Input::Text(t) => {
|
||||
self.row_spacing = Some(distance(&t)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
InsStage::Cell(a) => {
|
||||
if let Input::Point(b) = i {
|
||||
(self.col_spacing, self.row_spacing) = (Some(b.x - a.x), Some(b.y - a.y));
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
InsStage::ColSpacing => {
|
||||
if let Input::Text(t) = i {
|
||||
self.col_spacing = Some(distance(&t)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
InsStage::Attribs => {
|
||||
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let v = match i {
|
||||
@@ -1094,9 +937,7 @@ impl Interactive for InsertM {
|
||||
_ => Vec2::new(1.0, 1.0),
|
||||
});
|
||||
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
|
||||
let mut ins = insert_entity(b, at, self.scale3(scale), rot, self.base);
|
||||
self.grid().apply(&mut ins);
|
||||
vec![EntityKind::Insert(ins)]
|
||||
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (names this drawing already has keep this drawing's definitions). The file is read through the
|
||||
//! host's io hooks like OPEN, so the engine stays I/O-agnostic.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle};
|
||||
@@ -102,7 +102,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
// Redefining keeps the existing definition's name (block names are case-insensitive).
|
||||
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
merge_tables(&mut d, src);
|
||||
let renames = crate::cmd::merge::block_renames(&d, src, Some(&name));
|
||||
let renames = block_names(&d, src, &name);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -136,7 +136,13 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition).
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
merge(&mut d.linetypes, &src.linetypes, |l| &l.name);
|
||||
merge(&mut d.text_styles, &src.text_styles, |t| &t.name);
|
||||
@@ -146,6 +152,35 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
merge(&mut d.mline_styles, &src.mline_styles, |t| &t.name);
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
||||
/// already has (or the block being defined) keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.insert(defining.to_ascii_uppercase());
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
//! 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<CommandSpec> {
|
||||
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<Handle> {
|
||||
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<Vec2> {
|
||||
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<Vec2>, bool)> {
|
||||
if let Some(r) = p.get("rectangle") {
|
||||
let pts: Vec<Vec2> = 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<Vec2> =
|
||||
pts.iter().map(|v| point_value(v).ok_or_else(|| bad("xclip", "every boundary point must be a finite [x, y]"))).collect::<Result<_>>()?;
|
||||
Ok((pts, false))
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Vec2> = 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<Value> = 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::<Vec<_>>()),
|
||||
"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<String> {
|
||||
hs.iter().map(|h| h.hex()).collect()
|
||||
}
|
||||
|
||||
fn clip_of(s: &Session, h: Handle) -> Option<ClipBoundary> {
|
||||
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<Vec<Vec2>> {
|
||||
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<ClipBoundary>) -> 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<Value> {
|
||||
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<Step> {
|
||||
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<Handle>,
|
||||
invert: bool,
|
||||
pts: Vec<Vec2>,
|
||||
}
|
||||
|
||||
impl XclipM {
|
||||
fn selected(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<String> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Load the xrefs of drawing `i`, just opened (OPEN): the opened state counts as saved, and the
|
||||
/// view frames the drawing with them, unless the drawing opened on the view it was saved at.
|
||||
/// view frames the drawing with them.
|
||||
pub(crate) fn after_open(s: &mut Session, i: usize) {
|
||||
let px = s.viewport_px;
|
||||
let Some(st) = s.docs.get_mut(i) else { return };
|
||||
@@ -61,21 +61,12 @@ pub(crate) fn after_open(s: &mut Session, i: usize) {
|
||||
let out = load::load_all(Arc::make_mut(&mut st.doc), path.as_deref());
|
||||
st.saved = st.doc.clone();
|
||||
let ext = st.doc.extents(&cadcraft_doc::Space::Model);
|
||||
if !ext.is_empty() && crate::view::saved_view(&st.doc).is_none() {
|
||||
// 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 });
|
||||
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) });
|
||||
}
|
||||
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<std::path::PathBuf> {
|
||||
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<String> {
|
||||
s.state().ok().and_then(|st| st.path.clone())
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
|
||||
//! block empty with a status saying why; nothing here stops the host drawing from opening.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -340,15 +339,14 @@ fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &
|
||||
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
|
||||
Ok((x, file)) => match import(d, &key, &x, cx) {
|
||||
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
|
||||
Err(e) => {
|
||||
// Whatever made it in before the failure goes again. A fetch that failed imported
|
||||
// nothing, so it doesn't pay for a second scan of the block table.
|
||||
clear(d, &key);
|
||||
(XrefStatus::Unresolved, e)
|
||||
}
|
||||
Err(e) => (XrefStatus::Unresolved, e),
|
||||
},
|
||||
Err(e) => e,
|
||||
};
|
||||
if status != XrefStatus::Loaded {
|
||||
// Whatever made it in before the failure goes again.
|
||||
clear(d, &key);
|
||||
}
|
||||
set_status(d, &key, status);
|
||||
(status, detail)
|
||||
}
|
||||
@@ -392,15 +390,9 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
return Err(format!("more than {MAX_XREF_OBJECTS} objects"));
|
||||
}
|
||||
let dep = |sym: Sym, n: &str| (!shared(sym, n)).then(|| format!("{key}|{n}"));
|
||||
// The host's names per table, looked up in a set built once: both drawings are hostile input,
|
||||
// so a scan per imported record would be quadratic.
|
||||
fn names<T>(all: &[T], name: fn(&T) -> &str) -> HashSet<String> {
|
||||
all.iter().map(|t| name(t).to_ascii_uppercase()).collect()
|
||||
}
|
||||
let mut have = names(&d.layers, |l| &l.name);
|
||||
for l in &x.layers {
|
||||
if let Some(n) = dep(Sym::Layer, &l.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.layer(&n).is_none()
|
||||
{
|
||||
let mut l = l.clone();
|
||||
l.name = n;
|
||||
@@ -408,44 +400,39 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.linetypes, |t| &t.name);
|
||||
for t in &x.linetypes {
|
||||
if let Some(n) = dep(Sym::Linetype, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.linetype(&n).is_none()
|
||||
{
|
||||
d.linetypes.push(cadcraft_doc::Linetype { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.text_styles, |t| &t.name);
|
||||
for t in &x.text_styles {
|
||||
if let Some(n) = dep(Sym::Style, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.text_style(&n).is_none()
|
||||
{
|
||||
d.text_styles.push(cadcraft_doc::TextStyle { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.dim_styles, |t| &t.name);
|
||||
for t in &x.dim_styles {
|
||||
if let Some(n) = dep(Sym::DimStyle, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& d.dim_style(&n).is_none()
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.dim_styles.push(cadcraft_doc::DimStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.mleader_styles, |t| &t.name);
|
||||
for t in &x.mleader_styles {
|
||||
if let Some(n) = dep(Sym::MLeaderStyle, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& !d.mleader_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.mleader_styles.push(cadcraft_doc::MLeaderStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.table_styles, |t| &t.name);
|
||||
for t in &x.table_styles {
|
||||
if let Some(n) = dep(Sym::TableStyle, &t.name)
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
&& !d.table_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
{
|
||||
d.table_styles.push(cadcraft_doc::TableStyle { name: n, ..t.clone() });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
|
||||
//! end can put it on the system clipboard and paste it into another CADCraft window.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
|
||||
@@ -279,14 +279,20 @@ fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
|
||||
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
|
||||
/// their name in `defs` to their name in `d`.
|
||||
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
use crate::cmd::merge::merge_named as add;
|
||||
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
add(&mut d.layers, &defs.layers, |l| &l.name);
|
||||
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
|
||||
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
|
||||
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
|
||||
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
|
||||
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
|
||||
let renames = crate::cmd::merge::block_renames(d, defs, None);
|
||||
let renames = block_names(d, defs);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = defs.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -302,6 +308,34 @@ fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
renames
|
||||
}
|
||||
|
||||
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
|
||||
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
|
||||
/// already has keeps `d`'s definition (not copied).
|
||||
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
|
||||
for (name, b) in &defs.blocks {
|
||||
let upper = up(name);
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(up(&to));
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
|
||||
/// objects of the source drawing (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! stays I/O-agnostic; the JSON forms never open dialogs (the UI asks for files when these
|
||||
//! commands come from a menu, the tool bar or the command line).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Rgb;
|
||||
@@ -55,11 +55,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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", |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())),
|
||||
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()))
|
||||
})
|
||||
.params("{path: image file, …} as IMAGEATTACH; other file types are not available yet"),
|
||||
.params("not available yet")
|
||||
.noundo(),
|
||||
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 = crate::cmd::merge::block_renames(d, src, None);
|
||||
let renames = block_names(d, src);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -213,7 +213,16 @@ fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition). Returns how many layers and how many other records were added.
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
||||
let mut n = 0;
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
let layers = merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
let others = merge(&mut d.linetypes, &src.linetypes, |l| &l.name)
|
||||
+ merge(&mut d.text_styles, &src.text_styles, |t| &t.name)
|
||||
@@ -223,6 +232,35 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
||||
(layers, others)
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
||||
/// already has keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let anonymous = b.anonymous || name.starts_with('*');
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if anonymous {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
@@ -642,7 +680,6 @@ mod tests {
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
clip: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -70,19 +70,6 @@ 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<cadcraft_doc::Raster, String>;
|
||||
|
||||
static IMAGE: OnceLock<ImageHook> = OnceLock::new();
|
||||
|
||||
pub fn set_image_decoder(h: ImageHook) {
|
||||
let _ = IMAGE.set(h);
|
||||
}
|
||||
pub fn image_decoder() -> Option<ImageHook> {
|
||||
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>;
|
||||
@@ -324,7 +311,6 @@ pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -334,12 +320,6 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile
|
||||
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
|
||||
// asks where to save instead of exporting over the original.
|
||||
let path = path.filter(|p| is_drawing_file(p));
|
||||
// The SHX fonts its text styles name are looked for beside the drawing too.
|
||||
if let Some(dir) = path.as_deref().and_then(|p| std::path::Path::new(p).parent()) {
|
||||
// A bare file name is in the current folder.
|
||||
let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir };
|
||||
cadcraft_fonts::shx::add_drawing_dir(&std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()));
|
||||
}
|
||||
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
|
||||
}
|
||||
|
||||
@@ -365,12 +345,10 @@ pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option
|
||||
fn opened(s: &Session, i: usize, entities: bool) -> Value {
|
||||
let d = s.docs.get(i).map(|st| &st.doc);
|
||||
let warnings = d.map(|d| d.read_notes.clone()).unwrap_or_default();
|
||||
// Opened on the view it was saved at: a viewer keeps it instead of fitting the extents.
|
||||
let saved_view = d.is_some_and(|d| crate::view::saved_view(d).is_some());
|
||||
if entities {
|
||||
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings, "saved_view": saved_view })
|
||||
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings })
|
||||
} else {
|
||||
json!({ "index": i, "warnings": warnings, "saved_view": saved_view })
|
||||
json!({ "index": i, "warnings": warnings })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +363,6 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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")]
|
||||
@@ -396,7 +373,6 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -779,10 +755,7 @@ const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
|
||||
const DWG_WRITES: [&str; 1] = ["OLE2FRAME"];
|
||||
|
||||
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
|
||||
/// store has a record for keeps its geometry and is not counted; neither are [`DWG_WRITES`], nor
|
||||
/// another program's proxy entity whose class the file names: the DWG writes it with that class.
|
||||
/// Before 2013, or when its group 91 names no class of the file, the DWG leaves the proxy out and
|
||||
/// it is counted.
|
||||
/// store has a record for keeps its geometry and is not counted; neither are [`DWG_WRITES`].
|
||||
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
|
||||
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
|
||||
@@ -799,21 +772,10 @@ pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap
|
||||
if DWG_WRITES.contains(&u.dxf_type.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if version >= Version::R2013 && u.dxf_type.eq_ignore_ascii_case("ACAD_PROXY_ENTITY") && d.foreign.proxy_class(&u.tags).is_some() {
|
||||
continue;
|
||||
}
|
||||
let n = out.entry(u.dxf_type.clone()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
// Another program's proxy objects (in the OBJECTS section) follow the same rule as its proxy
|
||||
// entities: the DWG writer leaves them out before 2013, or when their class can't be named.
|
||||
for o in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_PROXY_OBJECT")) {
|
||||
if version < Version::R2013 || d.foreign.proxy_class(&o.tags).is_none() {
|
||||
let n = out.entry("ACAD_PROXY_OBJECT".to_string()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, Layer, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad};
|
||||
@@ -197,7 +197,6 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
d.skipped_records.clear();
|
||||
d.handseed = d.handseed.max(other.handseed);
|
||||
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
merge(&mut d.layers, &other.layers, |t| &t.name);
|
||||
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
|
||||
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
|
||||
@@ -236,17 +235,22 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
|
||||
}
|
||||
|
||||
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
|
||||
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
|
||||
for t in from {
|
||||
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
|
||||
to.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add copies of `objs` to model space with new handles, their block references renamed by
|
||||
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
|
||||
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
|
||||
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
|
||||
let mut members = Vec::new();
|
||||
// The layers this drawing has, looked up once per object rather than scanned.
|
||||
let mut layers: HashSet<String> = d.layers.iter().map(|l| l.name.to_ascii_uppercase()).collect();
|
||||
for (e, color) in objs {
|
||||
if layers.insert(e.common.layer.to_ascii_uppercase()) {
|
||||
d.layers.push(Layer::new(&e.common.layer));
|
||||
}
|
||||
d.ensure_layer(&e.common.layer);
|
||||
let h = d.new_handle();
|
||||
let mut copy = Entity { handle: h, ..(*e).clone() };
|
||||
detach(&mut copy.kind);
|
||||
|
||||
@@ -59,8 +59,7 @@ pub(super) fn run_new(s: &mut Session, p: &Value) -> Result<Option<Value>> {
|
||||
&& hi.y > lo.y
|
||||
{
|
||||
let center = cadcraft_geom::Vec2::new((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0);
|
||||
let twist = st.view().twist;
|
||||
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6), twist });
|
||||
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6) });
|
||||
}
|
||||
Ok(Some(json!({ "index": i, "title": title, "template": path })))
|
||||
}
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
//! 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<CommandSpec> {
|
||||
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<Item = &Image> {
|
||||
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<String> {
|
||||
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<Vec<u8>, 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<Raster, String> {
|
||||
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<String> {
|
||||
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<std::result::Result<Raster, String>> {
|
||||
// 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<Raster, String> {
|
||||
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<Option<std::result::Result<Raster, String>>> = 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<String> = 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<Value> {
|
||||
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<Placement> {
|
||||
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<Arc<Raster>> {
|
||||
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<Value> {
|
||||
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<Value> = 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()])) })
|
||||
}
|
||||
@@ -128,9 +128,7 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result<Option<Region>> {
|
||||
}
|
||||
loops.retain(|l| l.vertices.len() >= 2);
|
||||
let mut r = Region { loops };
|
||||
if !r.renest() {
|
||||
return Err(other("These regions are too complex to combine."));
|
||||
}
|
||||
r.renest();
|
||||
Ok(Some(r))
|
||||
}
|
||||
|
||||
|
||||
@@ -408,24 +408,12 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("layerstate.restore", "`name` is required"))?;
|
||||
let d = s.doc_mut()?;
|
||||
let st = d.layer_states.iter().find(|st| st.name == name).cloned().ok_or_else(|| bad("layerstate.restore", "no such layer state"))?;
|
||||
restore_layers(d, st.layers);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Put the saved layers back over the drawing's layers of the same name (without regard to case);
|
||||
/// saved layers the drawing no longer has are skipped. Both lists can come from a file, so the
|
||||
/// drawing's layers are looked up in an index rather than scanned per saved layer.
|
||||
pub(crate) fn restore_layers(d: &mut cadcraft_doc::Drawing, saved: Vec<Layer>) {
|
||||
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::with_capacity(d.layers.len());
|
||||
for (i, l) in d.layers.iter().enumerate() {
|
||||
// The first of names that differ only in case, as `Drawing::layer_mut` finds it.
|
||||
index.entry(l.name.to_ascii_lowercase()).or_insert(i);
|
||||
}
|
||||
for layer in saved {
|
||||
if let Some(l) = index.get(&layer.name.to_ascii_lowercase()).and_then(|&i| d.layers.get_mut(i)) {
|
||||
*l = layer;
|
||||
for saved in st.layers {
|
||||
if let Some(l) = d.layer_mut(&saved.name) {
|
||||
*l = saved;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn run_state_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -473,22 +461,6 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Restoring many saved layers, most of them missing, takes one pass and matches without case.
|
||||
#[test]
|
||||
fn restore_layers_matches_by_name_in_linear_time() {
|
||||
let mut d = cadcraft_doc::Drawing::new_imperial();
|
||||
d.layers = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("L{i}"))).collect();
|
||||
let mut saved: Vec<cadcraft_doc::Layer> = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("missing{i}"))).collect();
|
||||
let mut back = cadcraft_doc::Layer::new("l7");
|
||||
back.frozen = true;
|
||||
saved.push(back);
|
||||
let started = std::time::Instant::now();
|
||||
super::restore_layers(&mut d, saved);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert!(d.layer("L7").is_some_and(|l| l.frozen));
|
||||
assert_eq!(d.layers.len(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_states_save_list_restore_delete() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -524,7 +524,6 @@ 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,
|
||||
@@ -786,28 +785,10 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
// Checked here for a clear error; the plotter resolves the names the same way.
|
||||
let sheets = p.get("layouts").map(|v| plot_sheets(cmd, s.doc()?, v)).transpose()?;
|
||||
|
||||
// `layout` and `layouts` must name the same sheet(s), or the reply would name one sheet and the render another.
|
||||
if let (Some(n), Some(names)) = (str_param(p, "layout"), sheets.as_deref())
|
||||
&& !matches!(names, [only] if only.eq_ignore_ascii_case(n))
|
||||
{
|
||||
return Err(bad(cmd, "`layout` and `layouts` name different sheets; give only one of them"));
|
||||
}
|
||||
let is_image = p.get("output").and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("png") || o.eq_ignore_ascii_case("svg"));
|
||||
let space = match str_param(p, "layout") {
|
||||
Some(n) if n.eq_ignore_ascii_case("model") => Space::Model,
|
||||
Some(n) => Space::Paper(layout_name(cmd, s.doc()?, n)?),
|
||||
// A PNG or SVG holds one render: a single `layouts` entry names the sheet to draw (the PDF
|
||||
// path resolves `layouts` itself), not whatever space the session happens to show.
|
||||
None => match sheets.as_deref() {
|
||||
Some([only]) if is_image => {
|
||||
if only.eq_ignore_ascii_case("model") {
|
||||
Space::Model
|
||||
} else {
|
||||
Space::Paper(only.clone())
|
||||
}
|
||||
}
|
||||
_ => s.space(),
|
||||
},
|
||||
None => s.space(),
|
||||
};
|
||||
let mut opts = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(raw) = p.get("offset") {
|
||||
@@ -846,17 +827,12 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
None => o.remove("plotTransparency"),
|
||||
};
|
||||
}
|
||||
// A PNG or SVG holds one render: several sheets would report `pages: N` but write only one.
|
||||
if is_image && sheets.as_ref().is_some_and(|names| names.len() > 1) {
|
||||
return Err(bad(cmd, "`layouts` with several sheets needs PDF output; PNG and SVG hold one sheet, so plot each layout separately"));
|
||||
}
|
||||
let bytes = hook(s.doc()?, &space, &opts).map_err(|e| bad(cmd, e))?;
|
||||
let layout = match &sheets {
|
||||
Some(names) => names.first().cloned().unwrap_or_default(),
|
||||
None => space.tab_name().to_string(),
|
||||
};
|
||||
let pages = sheets.as_ref().map_or(1, Vec::len);
|
||||
let format = opts.get("output").and_then(Value::as_str).map(str::to_ascii_lowercase).unwrap_or_else(|| "pdf".into());
|
||||
match str_param(p, "path") {
|
||||
Some(_path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -866,17 +842,17 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
|
||||
let folder = super::file::new_folder(path);
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
let mut r = json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages, "format": format });
|
||||
let mut r = json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages });
|
||||
super::file::report_new_folder(s, &mut r, folder);
|
||||
Ok(r)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
super::exchange::deliver_file(cmd, _path, &bytes, super::file::deliver())?;
|
||||
Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages, "format": format }))
|
||||
Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
|
||||
}
|
||||
}
|
||||
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages, "format": format })),
|
||||
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages })),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,24 +230,6 @@ fn plot_goes_through_the_hook() {
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners");
|
||||
assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet");
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "scale": -1})).is_err());
|
||||
// Image output holds one sheet: several `layouts` would report pages:N but write one render.
|
||||
for out in ["x.png", "x.svg"] {
|
||||
let e = s.execute("plot", &json!({"path": out, "layouts": ["Layout1", "Layout2"]})).unwrap_err();
|
||||
assert!(e.to_string().contains("layouts"), "{e}");
|
||||
}
|
||||
assert!(s.execute("plot", &json!({"output": "png", "layouts": ["Layout1", "Layout2"]})).is_err());
|
||||
// `layout` and `layouts` naming different sheets are refused (the reply would name one sheet and the render another); agreeing ones pass.
|
||||
for p in
|
||||
[json!({"output": "png", "layout": "Model", "layouts": ["Layout1"]}), json!({"output": "pdf", "layout": "Layout1", "layouts": ["Model"]})]
|
||||
{
|
||||
let e = s.execute("plot", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("different"), "{e}");
|
||||
}
|
||||
assert_eq!(s.execute("plot", &json!({"output": "png", "layout": "layout1", "layouts": ["Layout1"]})).unwrap()["layout"], "Layout1");
|
||||
let one = s.execute("plot", &json!({"output": "png", "layouts": ["Layout1"]})).unwrap();
|
||||
assert_eq!(one["layout"], "Layout1");
|
||||
let text = String::from_utf8(super::super::file::base64_decode(one["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(text.contains("Paper(\"Layout1\")"), "one sheet renders that layout, not the session space: {text}");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-plot-test-{}.pdf", std::process::id()));
|
||||
@@ -301,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, twist: v.twist });
|
||||
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 });
|
||||
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);
|
||||
@@ -325,50 +307,3 @@ fn plot_help_names_the_device_and_the_built_in_devices() {
|
||||
assert!(help.contains(name), "plot help lacks {name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The path's extension picks the plot format as EXPORT does, in any case; one that names no
|
||||
/// format (or none) is refused instead of writing a PDF under that name, and a device that writes
|
||||
/// another format is refused.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn plot_path_extension_picks_the_format_like_export() {
|
||||
super::super::file::set_io(super::super::file::IoHooks {
|
||||
read: |_, _| Err("no reader in tests".into()),
|
||||
write: |_, _| Err("no writer in tests".into()),
|
||||
plot: Some(fake_plot),
|
||||
});
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return; // another test installed hooks without a plotter first
|
||||
}
|
||||
let mut s = session_with_model();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-plot-ext-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
|
||||
let upper = s.execute("plot", &json!({"path": path("X.PNG")})).unwrap();
|
||||
assert_eq!(upper["format"], "png", "an upper-case extension still picks png");
|
||||
let e = s.execute("plot", &json!({"path": path("x.txt")})).unwrap_err().to_string();
|
||||
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
|
||||
assert!(!dir.join("x.txt").exists(), "nothing written under an unknown extension");
|
||||
let e = s.execute("plot", &json!({"path": path("noext")})).unwrap_err().to_string();
|
||||
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
|
||||
assert!(!dir.join("noext").exists());
|
||||
let e = s.execute("plot", &json!({"path": path("y.png"), "device": "CADCraft PDF"})).unwrap_err().to_string();
|
||||
assert!(e.contains("writes pdf") && e.contains("png"), "{e}");
|
||||
// A device that resolves to none, or a null/unknown `output`, names no format: the refusal stands.
|
||||
for p in [
|
||||
json!({"path": path("x.txt"), "device": "None"}),
|
||||
json!({"path": path("x.txt"), "output": null}),
|
||||
json!({"path": path("x.txt"), "output": "bogus"}),
|
||||
] {
|
||||
let e = s.execute("plot", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
|
||||
assert!(!dir.join("x.txt").exists(), "nothing written under an unknown extension");
|
||||
}
|
||||
// An explicit output still decides for an odd name.
|
||||
assert_eq!(s.execute("plot", &json!({"path": path("z.txt"), "output": "pdf"})).unwrap()["format"], "pdf");
|
||||
// But one that contradicts the extension is refused, as a device that does is (#2585 review).
|
||||
let e = s.execute("plot", &json!({"path": path("w.png"), "output": "svg"})).unwrap_err().to_string();
|
||||
assert!(e.contains("svg") && e.contains("png"), "{e}");
|
||||
assert!(!dir.join("w.png").exists(), "nothing written when `output` and the path disagree");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ pub(super) fn run_vpclip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let k = cur.view_height / cur.height.max(1e-12);
|
||||
let mut v = cur;
|
||||
let c = b.center();
|
||||
let view_center = v.paper_to_model(c);
|
||||
let view_center = v.view_center + (c - v.center.xy()) * k;
|
||||
let view_height = b.height() * k;
|
||||
if view_center.is_finite() && view_height.is_finite() && view_height > 0.0 {
|
||||
v.view_center = view_center;
|
||||
|
||||
@@ -109,7 +109,7 @@ fn parse_color(t: &str) -> Result<Color> {
|
||||
fn viewport_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let st = s.state().ok()?;
|
||||
let at = match st.active_viewport() {
|
||||
Some((_, vp)) => vp.model_to_paper(p),
|
||||
Some((_, vp)) => vp.center.xy() + (p - vp.view_center) * (vp.height / vp.view_height),
|
||||
None => p,
|
||||
};
|
||||
super::viewports::viewport_at(s, at)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
//! Bringing another drawing's symbol tables and blocks into this one (INSERT of a file, IMPORT,
|
||||
//! paste, COMPARE export).
|
||||
//!
|
||||
//! Both drawings are hostile input, so the work is linear in their sizes: names are looked up in a
|
||||
//! set built once per table, and a generated block name continues from the last one allocated for
|
||||
//! its prefix instead of probing every taken number again (CWE-407).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
|
||||
/// Add the entries of `theirs` whose names `ours` lacks (compared without letter case; a name that
|
||||
/// exists keeps `ours`' definition). Returns how many were added.
|
||||
pub(crate) fn merge_named<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
||||
let mut have: HashSet<String> = ours.iter().map(|o| name(o).to_ascii_uppercase()).collect();
|
||||
let mut added = 0;
|
||||
for t in theirs {
|
||||
if have.insert(name(t).to_ascii_uppercase()) {
|
||||
ours.push(t.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Which blocks of `src` to copy into `d`, and under which name: a new name keeps its own, a
|
||||
/// generated (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named
|
||||
/// block `d` already has (or `defining`, the block being defined from `src`) keeps `d`'s
|
||||
/// definition (not copied).
|
||||
pub(crate) fn block_renames(d: &Drawing, src: &Drawing, defining: Option<&str>) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.extend(defining.map(str::to_ascii_uppercase));
|
||||
// The next number to try per generated prefix: numbers below it are taken.
|
||||
let mut next: HashMap<String, usize> = HashMap::new();
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
let cursor = next.entry(prefix.to_ascii_uppercase()).or_insert(1);
|
||||
match (*cursor..=1_000_000).map(|i| (i, format!("{prefix}{i}"))).find(|(_, n)| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some((i, n)) => {
|
||||
*cursor = i + 1;
|
||||
n
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Layer};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_keeps_ours_and_adds_the_rest_without_regard_to_case() {
|
||||
let mut ours = vec![Layer::new("Walls")];
|
||||
let theirs = vec![Layer::new("WALLS"), Layer::new("Doors"), Layer::new("doors")];
|
||||
assert_eq!(merge_named(&mut ours, &theirs, |l| &l.name), 1);
|
||||
assert_eq!(ours.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Walls", "Doors"]);
|
||||
}
|
||||
|
||||
/// Many colliding anonymous blocks get consecutive names in one pass, not a rescan each.
|
||||
#[test]
|
||||
fn anonymous_collisions_take_consecutive_numbers() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut src = Drawing::new_imperial();
|
||||
for i in 1..=5000 {
|
||||
let mut b = Block::new(&format!("*U{i}"));
|
||||
b.anonymous = true;
|
||||
d.blocks.insert(b.name.clone(), Arc::new(b.clone()));
|
||||
src.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let renames = block_renames(&d, &src, None);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert_eq!(renames.len(), 5000);
|
||||
assert_eq!(renames.get("*U1").map(String::as_str), Some("*U5001"));
|
||||
let distinct: HashSet<&String> = renames.values().collect();
|
||||
assert_eq!(distinct.len(), 5000);
|
||||
// Every new name is the next free number: nothing below 5001 is free, nothing above 10000 is needed.
|
||||
assert!(renames.values().all(|n| n.strip_prefix("*U").and_then(|i| i.parse::<u32>().ok()).is_some_and(|i| (5001..=10000).contains(&i))));
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,6 @@ pub(super) fn apply(s: &Session, k: EntityKind, c: &Content) -> Result<EntityKin
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
clip: None,
|
||||
};
|
||||
if *center {
|
||||
// Move the block so the centre of its extents lands on the anchor.
|
||||
|
||||
@@ -35,7 +35,7 @@ use oops::run_oops;
|
||||
pub(crate) use oops::{Erased, erase_kept};
|
||||
use select_then::{DisplaceM, Op, SelectThen};
|
||||
use stretch::{StretchM, run_stretch};
|
||||
pub(crate) use transform::{holds_ole_frame, ole_refusal, refuse_ole_frames, transform_entities};
|
||||
pub(crate) use transform::transform_entities;
|
||||
use transform::{run_copy, run_erase, run_mirror, run_move, run_rotate, run_scale};
|
||||
pub(crate) use trim::{Edges, extend, trim};
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["Modify", "Trim"])
|
||||
.alias(&["tr"])
|
||||
.params(
|
||||
"{handle, pick: [x,y] | fence: [[x,y],...] | crossing: [[x,y],[x,y]], edges?: [hex], edgeMode?: \"extend\"|\"none\", mode?: \"quick\"|\"standard\"} → {handles: the object (kept, reshaped) then the new pieces; none when it was erased} (pick) | {trimmed} (fence, crossing)",
|
||||
"{handle, pick: [x,y] | fence: [[x,y],...] | crossing: [[x,y],[x,y]], edges?: [hex], edgeMode?: \"extend\"|\"none\", mode?: \"quick\"|\"standard\"}",
|
||||
)
|
||||
.interactive(|_| Ok(Box::new(TrimM::new(false)))),
|
||||
CommandSpec::new("extend", "Extend", trimextend::run_extend)
|
||||
@@ -112,9 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("break", "Break", run_break)
|
||||
.menu(&["Modify", "Break"])
|
||||
.alias(&["br"])
|
||||
.params("{handle, p1, p2} → {handles: the object (kept, reshaped) then the new pieces; none when nothing is left}")
|
||||
.params("{handle, p1, p2}")
|
||||
.interactive(|_| Ok(Box::new(BreakM::default()))),
|
||||
CommandSpec::new("breakatpoint", "Break At Point", run_breakat).menu(&["Modify", "Break At Point"]).params("{handle, at} → {handles: the object (kept, reshaped) then the new piece}"),
|
||||
CommandSpec::new("breakatpoint", "Break At Point", run_breakat).menu(&["Modify", "Break At Point"]).params("{handle, at}"),
|
||||
CommandSpec::new("join", "Join", run_join)
|
||||
.menu(&["Modify", "Join"])
|
||||
.alias(&["j"])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::trim::{param_on, replace_with_pieces, sub_polyline};
|
||||
use super::trim::{param_on, sub_polyline};
|
||||
use super::{
|
||||
Accept, Arc, EngineError, Entity, EntityKind, Handle, Input, Interactive, Line, PolyVertex, Polyline, Prompt, Result, Segment, Session, Step,
|
||||
Value, Vec2, arc, bad, json, lwpoly, norm_angle, point_param, point_req, targets,
|
||||
@@ -105,7 +105,19 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
|
||||
}
|
||||
_ => return Err(EngineError::Other("Cannot break that object.".into())),
|
||||
};
|
||||
replace_with_pieces(s, h, pieces)
|
||||
let common = e.common.clone();
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
d.remove_entity(h);
|
||||
let mut out = Vec::new();
|
||||
for k in pieces {
|
||||
let nh = d.new_handle();
|
||||
if let Some(st) = d.space_mut(&space) {
|
||||
st.push(Entity { handle: nh, common: common.clone(), kind: k });
|
||||
out.push(nh);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn run_break(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -1,78 +1,19 @@
|
||||
use cadcraft_geom::{Ellipse as GEllipse, Mat3};
|
||||
|
||||
use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3};
|
||||
|
||||
// ---------------- explode / array / order / break / join ----------------
|
||||
|
||||
/// `ell` placed by `m` as an ELLIPSE at elevation `z`; `None` when `m` flattens it.
|
||||
fn placed_ellipse(ell: GEllipse, m: &Mat3, z: f64) -> Option<EntityKind> {
|
||||
let g = ell.transformed(m);
|
||||
let len = g.major.len();
|
||||
(len.is_finite() && len > 0.0 && g.ratio.is_finite() && g.ratio > 0.0).then(|| {
|
||||
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: g.center.to3(z), major: g.major.to3(0.0), ratio: g.ratio, start: g.start, end: g.end })
|
||||
})
|
||||
}
|
||||
|
||||
/// The pieces of block object `be` placed by `m`, a transform that stretches unevenly (a block
|
||||
/// reference whose X and Y scales differ), where its own kind can't hold the result: a circle
|
||||
/// or an arc becomes an ellipse or an elliptical arc, a polyline with arc segments (and no
|
||||
/// widths) its lines and elliptical arcs. `None`: the object transforms as itself.
|
||||
fn stretched_pieces(be: &Entity, m: &Mat3) -> Option<Vec<Entity>> {
|
||||
let circular = |center: Vec2, radius: f64, start: f64, end: f64| GEllipse { center, major: Vec2::X * radius, ratio: 1.0, start, end };
|
||||
let kinds = match &be.kind {
|
||||
EntityKind::Circle(c) => vec![placed_ellipse(GEllipse::full(c.center.xy(), Vec2::X * c.radius, 1.0), m, c.center.z)?],
|
||||
EntityKind::Arc(a) => vec![placed_ellipse(circular(a.center.xy(), a.radius, a.start, a.end), m, a.center.z)?],
|
||||
EntityKind::Ellipse(el) => {
|
||||
let g = GEllipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
vec![placed_ellipse(g, m, el.center.z)?]
|
||||
}
|
||||
EntityKind::LwPolyline(p)
|
||||
if p.vertices.iter().any(|v| v.bulge != 0.0)
|
||||
&& p.const_width == 0.0
|
||||
&& p.vertices.iter().all(|v| v.start_width == 0.0 && v.end_width == 0.0) =>
|
||||
{
|
||||
let z = p.elevation;
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
pl.segments()
|
||||
.into_iter()
|
||||
.map(|s| match s {
|
||||
Segment::Line(l) => Some(EntityKind::Line(cadcraft_doc::Line { a: m.apply(l.a).to3(z), b: m.apply(l.b).to3(z) })),
|
||||
Segment::Arc { arc: a, .. } => placed_ellipse(circular(a.center, a.radius, a.start, a.end), m, z),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
// The pieces of a polyline are new objects, as when it explodes itself: its xdata goes.
|
||||
let mut common = be.common.clone();
|
||||
if matches!(be.kind, EntityKind::LwPolyline(_)) {
|
||||
common.xdata.clear();
|
||||
}
|
||||
Some(kinds.into_iter().map(|kind| Entity { handle: Handle(0), common: common.clone(), kind }).collect())
|
||||
}
|
||||
|
||||
fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option<Vec<Entity>> {
|
||||
if let Some(items) = crate::cmd::array::explode_array(d, e) {
|
||||
return Some(items);
|
||||
}
|
||||
// A MINSERT grid stays whole, as in AutoCAD 2024 (see `explode`).
|
||||
if minserted(ins) {
|
||||
return None;
|
||||
}
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let stretched = m.similarity_scale().is_none();
|
||||
Some(
|
||||
blk.entities
|
||||
.iter()
|
||||
.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| {
|
||||
.map(|be| {
|
||||
let mut ne = (**be).clone();
|
||||
ne.kind.transform(&m);
|
||||
if ne.common.layer == "0" {
|
||||
ne.common.layer = e.common.layer.clone();
|
||||
}
|
||||
@@ -227,24 +168,9 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
}
|
||||
|
||||
pub(super) fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
|
||||
// AutoCAD 2024 refuses a MINSERT grid: "The object is minserted." (Locked ones are left
|
||||
// without a word, as `explode_with` leaves them.)
|
||||
let d = s.doc()?;
|
||||
let grids = hs
|
||||
.iter()
|
||||
.filter(|h| !crate::cmd::curves::is_locked(s, **h) && d.entity(**h).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if minserted(i))))
|
||||
.count();
|
||||
for _ in 0..grids {
|
||||
s.echo("The object is minserted.");
|
||||
}
|
||||
explode_with(s, hs, explode_kind)
|
||||
}
|
||||
|
||||
/// A block reference with more than one row or column (MINSERT).
|
||||
fn minserted(i: &cadcraft_doc::Insert) -> bool {
|
||||
i.rows > 1 || i.cols > 1
|
||||
}
|
||||
|
||||
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
|
||||
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
|
||||
pub(crate) fn explode_with(
|
||||
|
||||
@@ -5,7 +5,6 @@ use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, er
|
||||
// ---------------- core operations ----------------
|
||||
|
||||
pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> {
|
||||
refuse_ole_frames(s, hs, m, copy)?;
|
||||
edit_entities(s, hs, copy, |e| e.kind.transform(m))
|
||||
}
|
||||
|
||||
@@ -13,7 +12,6 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
|
||||
/// and every height of the objects about its Z, their thickness too.
|
||||
pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64, copy: bool) -> Result<Vec<Handle>> {
|
||||
let m = Mat3::scale_about(base.xy(), f);
|
||||
refuse_ole_frames(s, hs, &m, copy)?;
|
||||
edit_entities(s, hs, copy, |e| {
|
||||
e.kind.transform(&m);
|
||||
e.kind.scale_heights(base.z, f);
|
||||
@@ -21,34 +19,6 @@ pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64,
|
||||
})
|
||||
}
|
||||
|
||||
/// An OLE2FRAME only translates or scales uniformly (its corners stay axis aligned): refuse any other `m` before anything
|
||||
/// changes, instead of reporting success and leaving the frame where it was.
|
||||
pub(crate) fn refuse_ole_frames(s: &Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<()> {
|
||||
if cadcraft_doc::ole_frame_accepts(m) || !holds_ole_frame(s, hs, copy)? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ole_refusal())
|
||||
}
|
||||
|
||||
/// The refusal for a transform an OLE2FRAME cannot take.
|
||||
pub(crate) fn ole_refusal() -> crate::EngineError {
|
||||
crate::EngineError::Other(
|
||||
"An OLE object can only be moved, copied or scaled uniformly; other transforms (rotation, mirroring, non-uniform scale, alignment) are not supported.".into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `hs` holds an OLE2FRAME a transform would touch. An in-place edit (`copy` false) skips
|
||||
/// an object on a locked layer (see edit_entities), so such a frame cannot be refused.
|
||||
pub(crate) fn holds_ole_frame(s: &Session, hs: &[Handle], copy: bool) -> Result<bool> {
|
||||
let d = s.doc()?;
|
||||
Ok(hs.iter().any(|h| {
|
||||
d.entity(*h).is_some_and(|e| {
|
||||
let locked = !copy && d.layer(&e.common.layer).is_some_and(|l| l.locked);
|
||||
!locked && matches!(&e.kind, cadcraft_doc::EntityKind::Unknown(u) if u.is_ole_frame())
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// `p` at the current elevation (ELEVATION), the Z AutoCAD gives a point entered without one.
|
||||
pub(crate) fn at_elevation(s: &Session, p: Vec2) -> Vec3 {
|
||||
let z = s.doc().map(|d| d.header.f64("ELEVATION", 0.0)).ok().filter(|z| z.is_finite());
|
||||
|
||||
@@ -170,46 +170,18 @@ pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Resu
|
||||
EntityKind::LwPolyline(p) => trim_polyline(p, &cut, pick)?,
|
||||
k => crate::cmd::modify2::trim_other(k, &cut, pick)?,
|
||||
};
|
||||
replace_with_pieces(s, h, pieces)
|
||||
}
|
||||
|
||||
/// Put `pieces` in place of object `h` as AutoCAD 2024's BREAK and TRIM leave them: the first
|
||||
/// piece (the one holding the object's start, or the only piece of a circle or closed polyline)
|
||||
/// is the object itself, keeping its handle, its place in the drawing order, its groups and its
|
||||
/// extended data; the others are new objects with its properties, added at the end of its space
|
||||
/// (`entlast`); with no pieces the object is erased. Returns the pieces' handles. (A line broken
|
||||
/// in its middle keeps its handle on the piece from its start point, the piece to its end point is
|
||||
/// new; a circle broken or trimmed becomes an arc with the circle's handle.)
|
||||
///
|
||||
/// Dimensions attached to the object follow [`crate::assoc::follow_pieces`]. Its parametric
|
||||
/// constraints are removed: its new shape no longer meets them, and solving them again would move
|
||||
/// the objects they tie it to.
|
||||
pub(super) fn replace_with_pieces(s: &mut Session, h: Handle, pieces: Vec<EntityKind>) -> Result<Vec<Handle>> {
|
||||
let common = e.common.clone();
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let mut pieces = pieces.into_iter();
|
||||
let Some(first) = pieces.next() else {
|
||||
d.remove_entity(h);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let (Some(space), Some(old)) = (d.space_of(h), d.entity(h).cloned()) else {
|
||||
return Err(EngineError::Other("no such object".into()));
|
||||
};
|
||||
d.modify_entity(h, |e| e.kind = first)?;
|
||||
let mut out = vec![h];
|
||||
for kind in pieces {
|
||||
let handle = d.new_handle();
|
||||
d.remove_entity(h);
|
||||
let mut out = Vec::new();
|
||||
for k in pieces {
|
||||
let nh = d.new_handle();
|
||||
if let Some(st) = d.space_mut(&space) {
|
||||
st.push(Entity { handle, common: old.common.clone(), kind });
|
||||
out.push(handle);
|
||||
st.push(Entity { handle: nh, common: common.clone(), kind: k });
|
||||
out.push(nh);
|
||||
}
|
||||
}
|
||||
crate::assoc::follow_pieces(d, &old.kind, &out);
|
||||
let n = d.constraints.len();
|
||||
d.constraints.retain(|c| c.refs.iter().all(|r| r.handle != h));
|
||||
let removed = n - d.constraints.len();
|
||||
if removed > 0 {
|
||||
s.echo(format!("{removed} constraint(s) of the broken or trimmed object removed."));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -311,28 +283,6 @@ 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) {
|
||||
@@ -376,34 +326,18 @@ 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();
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Width, elevation and linetype generation stay as they were.
|
||||
EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, ..p.clone() })
|
||||
lwpoly(vs, false)
|
||||
}
|
||||
k => crate::cmd::modify2::extend_other(k, &cut, pick)?,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::other;
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::helpers::line;
|
||||
use crate::cmd::machines::{SelOutcome, SelectPhase, number};
|
||||
use crate::cmd::modify::{refuse_ole_frames, transform_entities};
|
||||
use crate::cmd::modify::transform_entities;
|
||||
use crate::cmd::{bad, point_req, point_value, targets};
|
||||
use crate::prompt::Drag;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
@@ -108,15 +108,6 @@ pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Every option is checked before the drawing changes.
|
||||
let ops = list.iter().enumerate().map(|(i, v)| parse_op(i, v)).collect::<Result<Vec<_>>>()?;
|
||||
// An OLE frame only translates or scales uniformly: a rotate step (or any step) that would refuse it must do so before an
|
||||
// earlier move or copy changes the drawing (a refused command is not rolled back).
|
||||
for op in &ops {
|
||||
match *op {
|
||||
Op::Rotate(a) => refuse_ole_frames(s, &objs, &Mat3::rotate_about(base, a), false)?,
|
||||
Op::Scale(f) => refuse_ole_frames(s, &objs, &Mat3::scale_about(base, f), false)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for op in ops {
|
||||
copies.extend(apply(s, &objs, &mut base, op)?);
|
||||
|
||||
@@ -35,23 +35,14 @@ pub(crate) fn arraypath(
|
||||
};
|
||||
crate::cmd::array::check_size(s, n as u64, objs.len())?;
|
||||
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
|
||||
let mut mats = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for i in 1..n {
|
||||
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
|
||||
let mut m = Mat3::translate(p - p0);
|
||||
if align {
|
||||
m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m);
|
||||
}
|
||||
mats.push(m);
|
||||
}
|
||||
// Every copy is checked before the first is made: a refusal part way would leave the copies
|
||||
// already added in the drawing (a failed command is not rolled back).
|
||||
if mats.iter().any(|m| !cadcraft_doc::ole_frame_accepts(m)) && crate::cmd::modify::holds_ole_frame(s, &objs, true)? {
|
||||
return Err(crate::cmd::modify::ole_refusal());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for m in &mats {
|
||||
out.extend(crate::cmd::modify::transform_entities(s, &objs, m, true)?);
|
||||
out.extend(crate::cmd::modify::transform_entities(s, &objs, &m, true)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -182,7 +182,8 @@ fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>)
|
||||
};
|
||||
let m = match viewport_of(s, &layout, vp) {
|
||||
Some(v) => {
|
||||
let to_paper = v.model_to_paper_matrix();
|
||||
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));
|
||||
if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper }
|
||||
}
|
||||
None => Mat3::IDENTITY,
|
||||
|
||||
@@ -411,7 +411,6 @@ 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();
|
||||
|
||||
@@ -265,11 +265,6 @@ pub fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// The output a path's extension (any case) asks for: png, svg or pdf; other extensions ask for none.
|
||||
fn path_output(path: &str) -> Option<PlotOutput> {
|
||||
PlotOutput::parse(&std::path::Path::new(path).extension()?.to_string_lossy())
|
||||
}
|
||||
|
||||
/// The options PLOT / EXPORTPDF hand the plotter with `device` and the device's papers
|
||||
/// resolved: `device` (else the layout's page setup device) picks the output (`output`: pdf,
|
||||
/// png or svg; EXPORTPDF always writes PDF) and `resolution`; a paper of the device becomes
|
||||
@@ -291,42 +286,9 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
None => layout.as_ref().and_then(|pg| find_device(&pg.device)),
|
||||
};
|
||||
let mut out = if p.is_object() { p.clone() } else { json!({}) };
|
||||
// The path's extension names the format, like EXPORT, unless a device is given; a given
|
||||
// device that writes another format is refused. EXPORTPDF always writes PDF.
|
||||
let by_path = if cmd == "exportpdf" { None } else { str_param(p, "path").and_then(path_output) };
|
||||
// A path whose extension names no format (or has none) would hold a PDF under that name:
|
||||
// refuse it, as EXPORT does, unless `output` or a device says what to write.
|
||||
if cmd == "plot"
|
||||
&& by_path.is_none()
|
||||
&& !p.get("output").and_then(Value::as_str).is_some_and(|o| PlotOutput::parse(o).is_some())
|
||||
&& !(dev.is_some() && given.is_some_and(|n| !n.is_empty()))
|
||||
&& let Some(path) = str_param(p, "path")
|
||||
{
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("can't tell the format of `{path}`: give a path ending in .png, .svg or .pdf, or set `output` to png, svg or pdf"),
|
||||
));
|
||||
}
|
||||
if let (Some(want), Some(dev), true) = (by_path, &dev, given.is_some_and(|n| !n.is_empty()))
|
||||
&& dev.output != want
|
||||
{
|
||||
return Err(bad(cmd, format!("plotter `{}` writes {}, but the path asks for {}", dev.name, dev.output.as_str(), want.as_str())));
|
||||
}
|
||||
// An `output` that says another format than the path's extension is refused too, as a device
|
||||
// that does is: either way the file would not hold what its name says.
|
||||
if let (Some(want), Some(asked)) = (by_path, p.get("output").and_then(Value::as_str).and_then(PlotOutput::parse))
|
||||
&& asked != want
|
||||
{
|
||||
return Err(bad(cmd, format!("`output` is {}, but the path asks for {}", asked.as_str(), want.as_str())));
|
||||
}
|
||||
let (Some(dev), Some(o)) = (dev, out.as_object_mut()) else {
|
||||
if let (Some(want), Some(o)) = (by_path, out.as_object_mut()) {
|
||||
o.insert("output".into(), json!(want.as_str()));
|
||||
}
|
||||
return Ok(out);
|
||||
};
|
||||
let (Some(dev), Some(o)) = (dev, out.as_object_mut()) else { return Ok(out) };
|
||||
o.remove("device");
|
||||
let output = if cmd == "exportpdf" { PlotOutput::Pdf } else { by_path.unwrap_or(dev.output) };
|
||||
let output = if cmd == "exportpdf" { PlotOutput::Pdf } else { dev.output };
|
||||
o.insert("output".into(), json!(output.as_str()));
|
||||
o.insert("resolution".into(), json!(dev.resolution));
|
||||
let paper = match str_param(p, "paper") {
|
||||
|
||||
@@ -10,7 +10,7 @@ mod rename;
|
||||
pub use matchprop::MatchProps;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{EntityKind, Handle, Lineweight, Transparency};
|
||||
use cadcraft_doc::{EntityKind, Lineweight, Transparency};
|
||||
use cadcraft_geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -21,9 +21,9 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
|
||||
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)} → {changed, locked, message?}: objects on locked layers (and, during REFEDIT, outside the working set) are left out, and `message` says how many"),
|
||||
CommandSpec::new("chprop", "Change Properties", run_set)
|
||||
.params("as properties.set, leaving out objects on locked layers (and, during REFEDIT, outside the working set) → {changed, locked, message?} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)")
|
||||
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
|
||||
CommandSpec::new("chprop", "Change Properties", chprop::run_chprop)
|
||||
.params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)")
|
||||
.interactive(|_| Ok(Box::new(chprop::ChpropM::new()))),
|
||||
CommandSpec::new("change", "Change", change::run_change)
|
||||
.alias(&["-ch"])
|
||||
@@ -196,74 +196,11 @@ fn set_xy(p: &mut Vec3, v: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects `properties.set` leaves alone, as CHPROP, MATCHPROP and MOVE do: those on locked
|
||||
/// layers and, while a reference is edited in place, those outside the working set.
|
||||
#[derive(Default)]
|
||||
struct LeftOut {
|
||||
locked: usize,
|
||||
outside: usize,
|
||||
}
|
||||
|
||||
impl LeftOut {
|
||||
/// `hs` without the objects that can't be edited, each object once, and how many those were. A
|
||||
/// handle that names no object is an error, before anything changes.
|
||||
fn split(s: &Session, hs: Vec<Handle>) -> Result<(Vec<Handle>, LeftOut)> {
|
||||
let d = s.doc()?;
|
||||
// The working set, once: `RefEdit::is_working` searches a list.
|
||||
let working: Option<std::collections::HashSet<Handle>> = d.refedit.as_ref().map(|r| r.items.iter().map(|i| i.copy).collect());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = LeftOut::default();
|
||||
let mut free = Vec::new();
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(h) else {
|
||||
return Err(cadcraft_doc::DocError::NoEntity(h).into());
|
||||
};
|
||||
if !seen.insert(h) {
|
||||
continue;
|
||||
}
|
||||
if working.as_ref().is_some_and(|w| !w.contains(&h)) {
|
||||
out.outside += 1;
|
||||
} else if d.layer(&e.common.layer).is_some_and(|l| l.locked) {
|
||||
out.locked += 1;
|
||||
} else {
|
||||
free.push(h);
|
||||
}
|
||||
}
|
||||
Ok((free, out))
|
||||
}
|
||||
|
||||
/// `{changed, locked}`, and a message saying how many were left out when any were.
|
||||
fn result(&self, changed: usize) -> Value {
|
||||
let mut notes = Vec::new();
|
||||
match self.locked {
|
||||
0 => {}
|
||||
1 => notes.push("1 object was on a locked layer.".to_string()),
|
||||
n => notes.push(format!("{n} objects were on a locked layer.")),
|
||||
}
|
||||
match self.outside {
|
||||
0 => {}
|
||||
1 => notes.push("1 object was not in the working set.".to_string()),
|
||||
n => notes.push(format!("{n} objects were not in the working set.")),
|
||||
}
|
||||
if changed == 0 && !notes.is_empty() {
|
||||
notes.push("Nothing was changed.".to_string());
|
||||
}
|
||||
let mut r = json!({ "changed": changed, "locked": self.locked + self.outside });
|
||||
if !notes.is_empty()
|
||||
&& let Some(o) = r.as_object_mut()
|
||||
{
|
||||
o.insert("message".into(), json!(notes.join(" ")));
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
if hs.is_empty() {
|
||||
return Err(bad("properties.set", "nothing selected"));
|
||||
}
|
||||
let (hs, left_out) = LeftOut::split(s, hs)?;
|
||||
let color = p.get("color").map(|c| parse_color(c).ok_or_else(|| bad("properties.set", "bad colour"))).transpose()?;
|
||||
let transparency = p
|
||||
.get("transparency")
|
||||
@@ -274,7 +211,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|v| v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("properties.set", "thickness must be a finite number")))
|
||||
.transpose()?;
|
||||
let lw = p.get("lineweight").map(|v| super::layer::parse_lw(v).ok_or_else(|| bad("properties.set", "bad lineweight"))).transpose()?;
|
||||
let d = s.doc()?;
|
||||
let d = s.doc_mut()?;
|
||||
// An explicit object linetype must be ByLayer, ByBlock or a loaded definition (as `linetype current` requires).
|
||||
let linetype = str_param(p, "linetype")
|
||||
.map(|n| {
|
||||
@@ -285,18 +222,13 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
})
|
||||
.transpose()?;
|
||||
if let Some(l) = str_param(p, "layer") {
|
||||
d.ensure_layer(l);
|
||||
}
|
||||
let dim_style = str_param(p, "dimStyle")
|
||||
.map(|n| d.dim_style(n).map(|st| st.name.clone()).ok_or_else(|| bad("properties.set", format!("dimension style `{n}` not found"))))
|
||||
.transpose()?;
|
||||
let dim_over = dim_overrides(p)?;
|
||||
if hs.is_empty() {
|
||||
// Every object was left out: the drawing stays as it was, with no undo step.
|
||||
return Ok(left_out.result(0));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
if let Some(l) = str_param(p, "layer") {
|
||||
d.ensure_layer(l);
|
||||
}
|
||||
let anno_scale = d.current_anno_scale();
|
||||
let common = CommonEntityProperties { anno_scale: &anno_scale, color, linetype: linetype.as_deref(), lineweight: lw, transparency, thickness };
|
||||
for h in &hs {
|
||||
@@ -305,7 +237,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_kind_properties(e, p, dim_style.as_ref(), &dim_over);
|
||||
})?;
|
||||
}
|
||||
Ok(left_out.result(hs.len()))
|
||||
Ok(json!({ "changed": hs.len() }))
|
||||
}
|
||||
|
||||
fn apply_kind_properties(
|
||||
|
||||
@@ -23,6 +23,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::super::curves::{KW, is_locked};
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::targets;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"];
|
||||
@@ -89,18 +90,13 @@ impl ChpropM {
|
||||
if vals.all(|v| v == first) { first } else { "varies".into() }
|
||||
}
|
||||
|
||||
/// Apply `props` to the selection through `properties.set`, saying what it left out: objects
|
||||
/// this command moved onto a locked layer, say.
|
||||
/// Apply `props` to the selection through `properties.set`.
|
||||
fn apply(&self, s: &mut Session, props: Value) -> Result<()> {
|
||||
let mut p = json!({ "handles": self.handles.iter().map(|h| h.hex()).collect::<Vec<_>>() });
|
||||
if let (Some(p), Value::Object(props)) = (p.as_object_mut(), props) {
|
||||
p.extend(props);
|
||||
}
|
||||
let r = super::run_set(s, &p)?;
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
s.echo(m.to_string());
|
||||
}
|
||||
Ok(())
|
||||
super::run_set(s, &p).map(|_| ())
|
||||
}
|
||||
|
||||
/// Take an answer to `ask`; `false` when it was refused (the prompt comes again).
|
||||
@@ -241,6 +237,33 @@ pub(super) fn feed_unlocked(select: &mut SelectPhase, s: &mut Session, i: Input)
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the
|
||||
/// command leaves out too; "locked" says how many were.
|
||||
pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let locked = locked(s, &hs);
|
||||
if locked.is_empty() {
|
||||
return super::run_set(s, p).map(|r| with_locked(r, 0));
|
||||
}
|
||||
let free: Vec<Handle> = hs.into_iter().filter(|h| !locked.contains(h)).collect();
|
||||
let r = if free.is_empty() {
|
||||
json!({ "changed": 0 })
|
||||
} else {
|
||||
let mut q = p.as_object().cloned().unwrap_or_default();
|
||||
q.insert("handles".into(), json!(free.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
super::run_set(s, &Value::Object(q))?
|
||||
};
|
||||
Ok(with_locked(r, locked.len()))
|
||||
}
|
||||
|
||||
/// `r` with how many objects were left out on locked layers.
|
||||
fn with_locked(mut r: Value, n: usize) -> Value {
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("locked".into(), json!(n));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A colour typed at CHPROP: 1–255, a name, BYLAYER or BYBLOCK (not 0 or 256, which COLOR takes).
|
||||
fn color(t: &str) -> Option<Color> {
|
||||
if t.bytes().all(|b| b.is_ascii_digit()) {
|
||||
|
||||
@@ -36,7 +36,7 @@ fn misses_edges(e: &EngineError) -> bool {
|
||||
}
|
||||
|
||||
/// Trim or extend `h` at `at`. A Quick-mode (`quick`) trim of an object no cutting edge crosses
|
||||
/// erases it. Returns what `h` became: its pieces, `h` itself first (none when it was erased).
|
||||
/// erases it. Returns the handles that replace `h` (none when it was erased).
|
||||
fn apply(s: &mut Session, h: Handle, at: Vec2, extending: bool, edges: Edges, quick: bool) -> Result<Vec<Handle>> {
|
||||
if extending {
|
||||
extend(s, h, at, edges)?;
|
||||
@@ -118,8 +118,8 @@ fn crossing(s: &mut Session, a: Vec2, b: Vec2, extending: bool, edges: Edges, qu
|
||||
run_picks(s, work, extending, edges, quick, tol)
|
||||
}
|
||||
|
||||
/// Trim (or extend) each object at its pick points. A trim leaves the object in pieces, so later
|
||||
/// picks go to the piece within `tol` of the point (none: that part is already gone).
|
||||
/// Trim (or extend) each object at its pick points. A trim replaces the object with pieces, so
|
||||
/// later picks go to the piece within `tol` of the point (none: that part is already gone).
|
||||
fn run_picks(s: &mut Session, work: Vec<(Handle, Vec<Vec2>)>, extending: bool, edges: Edges, quick: bool, tol: f64) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
let mut last_err = None;
|
||||
|
||||
@@ -26,10 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params("{mode: point|distance|angle|intersection, points: [[x,y],...]} → {value} | {point}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<PickM>::default())),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params(
|
||||
"{find, replace?, matchCase?: bool, wholeWord?: bool, types?: [\"Text\", \"MText\", ...], handles?: [...]} → \
|
||||
{matches: [{handle, type, count, snippet, hit, hitLen}], total, message}",
|
||||
),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
|
||||
.params("{mode: distance|radius|angle|area|volume, ...}")
|
||||
.noundo()
|
||||
@@ -197,46 +194,6 @@ fn run_quickcalc_pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------- FIND ----------------
|
||||
|
||||
/// Characters of context kept on each side of a hit in the Find dialog's result list.
|
||||
const SNIPPET_CONTEXT: usize = 18;
|
||||
|
||||
/// The text around the first hit of `find` in `text`, for the result list: at most
|
||||
/// [`SNIPPET_CONTEXT`] characters before the hit and as many after it, the hit's offset in the
|
||||
/// snippet (in characters) and its length (in characters).
|
||||
///
|
||||
/// The offset and length are character counts, not bytes: the dialog slices the snippet by
|
||||
/// character so a Chinese or Vietnamese drawing never splits a glyph. Lower-casing can change a
|
||||
/// string's character count in rare scripts, so every slice goes through `get` and a snippet that
|
||||
/// cannot be cut is reported as absent rather than guessed at.
|
||||
fn snippet_at(text: &str, find: &str, case: bool, whole: bool) -> Option<(String, usize, usize)> {
|
||||
if find.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (hay, needle) = if case { (text.to_string(), find.to_string()) } else { (text.to_lowercase(), find.to_lowercase()) };
|
||||
let mut at = 0;
|
||||
let pos = loop {
|
||||
let rest = hay.get(at..)?;
|
||||
let p = rest.find(&needle)? + at;
|
||||
let end = p + needle.len();
|
||||
let boundary = |i: usize| hay.get(..i).and_then(|s| s.chars().last()).is_none_or(|c| !c.is_alphanumeric());
|
||||
let after = hay.get(end..).and_then(|s| s.chars().next()).is_none_or(|c| !c.is_alphanumeric());
|
||||
if !whole || (boundary(p) && after) {
|
||||
break p;
|
||||
}
|
||||
at = end.max(p + 1);
|
||||
if at >= hay.len() {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let start = hay.get(..pos)?.chars().count();
|
||||
let hit_len = needle.chars().count();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let from = start.saturating_sub(SNIPPET_CONTEXT);
|
||||
let to = start.saturating_add(hit_len).saturating_add(SNIPPET_CONTEXT).min(chars.len());
|
||||
let snippet: String = chars.get(from..to)?.iter().collect();
|
||||
Some((snippet, start.saturating_sub(from), hit_len))
|
||||
}
|
||||
|
||||
fn replace_in(text: &str, find: &str, rep: &str, case: bool, whole: bool) -> (String, usize) {
|
||||
if find.is_empty() {
|
||||
return (text.to_string(), 0);
|
||||
@@ -271,40 +228,21 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rep = str_param(p, "replace").map(str::to_string);
|
||||
let case = bool_or(p, "matchCase", false);
|
||||
let whole = bool_or(p, "wholeWord", false);
|
||||
// Only the named object types, when the dialog narrows the search; `type_name()` is the same
|
||||
// name the result list shows, so a caller never has to guess the spelling.
|
||||
let types: Option<Vec<String>> =
|
||||
p.get("types").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect());
|
||||
let space = s.space();
|
||||
let d = s.doc()?;
|
||||
// The whole current space by default; the Find window narrows it to the objects its result
|
||||
// list already holds, so Replace All touches exactly what the user can see.
|
||||
let hs: Vec<Handle> = match p.get("handles").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect(),
|
||||
None => d.space(&space).map(|st| st.handles()).unwrap_or_default(),
|
||||
};
|
||||
let hs: Vec<Handle> = d.space(&space).map(|st| st.handles()).unwrap_or_default();
|
||||
let mut found: Vec<Value> = Vec::new();
|
||||
let mut total = 0;
|
||||
let mut edits: Vec<(Handle, EntityKind)> = Vec::new();
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
if let Some(names) = &types
|
||||
&& !names.iter().any(|n| n == &e.kind.type_name().to_ascii_lowercase())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut k = e.kind.clone();
|
||||
let mut hits = 0;
|
||||
// The first hit's surroundings, taken before a replacement rewrites the text.
|
||||
let mut snippet: Option<(String, usize, usize)> = None;
|
||||
let r = rep.as_deref().unwrap_or(&find);
|
||||
let mut apply = |t: &mut String| {
|
||||
let (nt, n) = replace_in(t, &find, r, case, whole);
|
||||
if n > 0 {
|
||||
hits += n;
|
||||
if snippet.is_none() {
|
||||
snippet = snippet_at(t, &find, case, whole);
|
||||
}
|
||||
if rep.is_some() {
|
||||
*t = nt;
|
||||
}
|
||||
@@ -325,15 +263,7 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if hits > 0 {
|
||||
total += 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,
|
||||
}));
|
||||
found.push(json!({ "handle": h.hex(), "type": e.kind.type_name(), "count": hits }));
|
||||
if rep.is_some() {
|
||||
edits.push((h, k));
|
||||
}
|
||||
@@ -647,35 +577,4 @@ mod tests {
|
||||
assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3));
|
||||
assert_eq!(replace_in("x", "", "y", true, false).1, 0);
|
||||
}
|
||||
|
||||
/// `snippet_at` returns the whole text when the hit fills it, with the hit at offset 0.
|
||||
#[test]
|
||||
fn snippet_reports_the_hit_offset() {
|
||||
assert_eq!(snippet_at("BOLT-01", "BOLT-01", true, false), Some(("BOLT-01".to_string(), 0, 7)));
|
||||
}
|
||||
|
||||
/// Matching ignores case but the snippet keeps the text as it is written, so the result list
|
||||
/// shows the drawing's own spelling.
|
||||
#[test]
|
||||
fn snippet_keeps_the_original_case() {
|
||||
assert_eq!(snippet_at("法兰 bolt-01 安装孔", "BOLT-01", false, false), Some(("法兰 bolt-01 安装孔".to_string(), 3, 7)));
|
||||
}
|
||||
|
||||
/// A long text keeps [`SNIPPET_CONTEXT`] characters on each side of the hit, counted in
|
||||
/// characters so a CJK hit is never split.
|
||||
#[test]
|
||||
fn snippet_is_windowed_in_characters() {
|
||||
let text = format!("{}命中{}", "前".repeat(30), "后".repeat(30));
|
||||
let (snippet, hit, len) = snippet_at(&text, "命中", true, false).expect("a hit");
|
||||
assert_eq!((hit, len), (SNIPPET_CONTEXT, 2));
|
||||
assert_eq!(snippet.chars().count(), SNIPPET_CONTEXT * 2 + 2);
|
||||
}
|
||||
|
||||
/// A whole-word search skips an embedded hit, and text without a hit has no snippet.
|
||||
#[test]
|
||||
fn snippet_respects_whole_word() {
|
||||
assert_eq!(snippet_at("BOLTS", "BOLT", true, true), None);
|
||||
assert_eq!(snippet_at("a BOLT b", "BOLT", true, true).map(|s| s.1), Some(2));
|
||||
assert_eq!(snippet_at("nothing", "BOLT", true, false), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d<a]`), arithmetic and vector
|
||||
//! arithmetic, numeric and geometric functions; unit conversion; values picked from the drawing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -69,9 +67,6 @@ struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
vars: &'a Map<String, Value>,
|
||||
/// `vars` by ASCII-lower-cased name (the first of names that differ only in case), so a
|
||||
/// case-insensitive lookup is not a scan of the map per reference.
|
||||
folded: HashMap<String, &'a Value>,
|
||||
depth: usize,
|
||||
unary_depth: usize,
|
||||
}
|
||||
@@ -235,7 +230,7 @@ impl Parser<'_> {
|
||||
match name.as_str() {
|
||||
"pi" => Ok(Val::Num(std::f64::consts::PI)),
|
||||
"e" => Ok(Val::Num(std::f64::consts::E)),
|
||||
_ => var(self.vars, &self.folded, &name).ok_or_else(|| format!("unknown name `{name}`")),
|
||||
_ => var(self.vars, &name).ok_or_else(|| format!("unknown name `{name}`")),
|
||||
}
|
||||
}
|
||||
_ => Err("expected a number".into()),
|
||||
@@ -244,8 +239,8 @@ impl Parser<'_> {
|
||||
}
|
||||
|
||||
/// A variable: a number, or a point as `[x, y]`. Names are case-insensitive.
|
||||
fn var(vars: &Map<String, Value>, folded: &HashMap<String, &Value>, name: &str) -> Option<Val> {
|
||||
let v = vars.get(name).or_else(|| folded.get(&name.to_ascii_lowercase()).copied())?;
|
||||
fn var(vars: &Map<String, Value>, name: &str) -> Option<Val> {
|
||||
let v = vars.get(name).or_else(|| vars.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?;
|
||||
if let Some(n) = v.as_f64() {
|
||||
return Some(Val::Num(n));
|
||||
}
|
||||
@@ -416,11 +411,7 @@ pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
|
||||
if expr.len() > MAX_LEN {
|
||||
return Err("expression too long".into());
|
||||
}
|
||||
let mut folded = HashMap::with_capacity(vars.len());
|
||||
for (k, v) in vars {
|
||||
folded.entry(k.to_ascii_lowercase()).or_insert(v);
|
||||
}
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, folded, depth: 0, unary_depth: 0 };
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0, unary_depth: 0 };
|
||||
let v = p.expr()?;
|
||||
if p.peek().is_some() {
|
||||
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
|
||||
@@ -596,21 +587,6 @@ mod tests {
|
||||
eval_val(s, &Map::new())
|
||||
}
|
||||
|
||||
/// Variables are found without regard to case; an exact match wins over a case variant.
|
||||
/// (Names are read lower-cased, so `X` finds `x` too.)
|
||||
#[test]
|
||||
fn variables_ignore_case_and_prefer_exact_names() {
|
||||
let mut vars = Map::new();
|
||||
for i in 0..10_000 {
|
||||
vars.insert(format!("v{i}"), json!(i));
|
||||
}
|
||||
vars.insert("X".into(), json!(1));
|
||||
vars.insert("x".into(), json!(2));
|
||||
assert!(matches!(eval_val("V9999 + 1", &vars), Ok(Val::Num(n)) if n == 10_000.0));
|
||||
assert!(matches!(eval_val("x", &vars), Ok(Val::Num(n)) if n == 2.0));
|
||||
assert!(eval_val("nope", &vars).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_and_vectors() {
|
||||
assert_eq!(ev("[1,2]+[3,4]*2"), Ok(Val::Pt(Vec2::new(7.0, 10.0))));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), the view
|
||||
//! twist (DVIEW TWist), layout switching.
|
||||
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), layout
|
||||
//! switching.
|
||||
|
||||
use cadcraft_doc::{Space, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
@@ -10,7 +10,6 @@ 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;
|
||||
|
||||
@@ -96,13 +95,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["r"])
|
||||
.noundo()
|
||||
.transparent(),
|
||||
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.set", "Set View", run_view_set).params("{center: [x,y], height}").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, "twist": v.twist.to_degrees(), "viewportPx": [s.viewport_px.0, s.viewport_px.1]}))
|
||||
Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "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(),
|
||||
@@ -124,11 +120,8 @@ 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));
|
||||
// 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 });
|
||||
let height = b.height().max(b.width() / aspect.max(1e-6)).max(1e-9);
|
||||
st.set_zoom_frame(View { center: b.center(), height });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -157,18 +150,18 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 { height: v.height / f, ..v });
|
||||
st.set_zoom_frame(View { center: v.center, height: v.height / f });
|
||||
}
|
||||
"center" | "c" | "dynamic" | "d" => {
|
||||
let c = point_req("zoom", p, "center")?;
|
||||
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 });
|
||||
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) });
|
||||
}
|
||||
"scale" | "s" => {
|
||||
let v = s.state()?.zoom_frame();
|
||||
let height = factor_height(s, p, v.height)?;
|
||||
s.state_mut()?.set_zoom_frame(View { height, ..v });
|
||||
s.state_mut()?.set_zoom_frame(View { center: v.center, height });
|
||||
}
|
||||
"realtime" => {
|
||||
let v = s.state()?.zoom_frame();
|
||||
@@ -176,7 +169,7 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if f <= 0.0 {
|
||||
return Err(bad("zoom", "factor must be positive"));
|
||||
}
|
||||
s.state_mut()?.set_zoom_frame(View { height: (v.height / f).clamp(1e-9, 1e12), ..v });
|
||||
s.state_mut()?.set_zoom_frame(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
|
||||
}
|
||||
"object" | "o" => {
|
||||
let hs = targets(s, p)?;
|
||||
@@ -249,8 +242,7 @@ fn pan_frac(s: &mut Session, f: Vec2) -> Result<Value> {
|
||||
let st = s.state_mut()?;
|
||||
let v = st.view();
|
||||
let ww = v.height * w / h.max(1.0);
|
||||
// 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 });
|
||||
st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height), height: v.height });
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -266,7 +258,7 @@ fn run_pan(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = point_req("pan", p, "to")?;
|
||||
v.center - (b - a)
|
||||
};
|
||||
st.set_view(View { center: c, ..v });
|
||||
st.set_view(View { center: c, height: v.height });
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -276,33 +268,9 @@ fn run_view_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if h <= 0.0 {
|
||||
return Err(bad("view.set", "height must be positive"));
|
||||
}
|
||||
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;
|
||||
if !s.state_mut()?.set_view(View { center: c, height: h }) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -326,7 +294,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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, twist: 0.0 });
|
||||
st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15 });
|
||||
}
|
||||
s.touch();
|
||||
ok()
|
||||
@@ -473,7 +441,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, twist: b.frame.twist });
|
||||
st.set_zoom_frame(View { center: b.center, height: b.height });
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::Dynamic(mut b), Input::Point(p)) => {
|
||||
@@ -597,7 +565,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 { height: (v.height / f).clamp(1e-9, 1e12), ..v });
|
||||
st.set_view_quiet(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
//! DVIEW: the dynamic view, on CADCraft's 2D plan views.
|
||||
//!
|
||||
//! It starts with "Select objects or <use DVIEWBLOCK>:": 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<Target> {
|
||||
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<f64> {
|
||||
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<cadcraft_doc::Drawing>, Vec<Handle>), 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<cadcraft_doc::Drawing>, Vec<Handle>)> {
|
||||
let st = s.state()?;
|
||||
Ok((st.doc.clone(), st.selection.clone()))
|
||||
}
|
||||
|
||||
fn run_dview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Handle>,
|
||||
/// 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<View>,
|
||||
/// What DVIEW works on, and the drawing as it started (a viewport's twist change is one
|
||||
/// undo step).
|
||||
target: Option<Target>,
|
||||
before: Option<(Arc<cadcraft_doc::Drawing>, Vec<Handle>)>,
|
||||
}
|
||||
|
||||
impl DviewM {
|
||||
fn target(&self, s: &Session) -> Result<Target> {
|
||||
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<Handle>) -> Result<Step> {
|
||||
// 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<Step> {
|
||||
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<Step> {
|
||||
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<DisplayOnly> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,25 +110,15 @@ fn save(s: &mut Session, name: &str, window: Option<Bounds2>, 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()));
|
||||
}
|
||||
// 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)
|
||||
(b.center(), b.height(), b.width())
|
||||
}
|
||||
None => {
|
||||
let f = frame;
|
||||
let f = if in_viewport { st.zoom_frame() } else { st.view() };
|
||||
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)
|
||||
}
|
||||
@@ -149,7 +139,7 @@ fn save(s: &mut Session, name: &str, window: Option<Bounds2>, 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, layout });
|
||||
d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist: 0.0, 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 }))
|
||||
}
|
||||
@@ -178,7 +168,7 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
|
||||
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, twist: 0.0 });
|
||||
st.set_view(View { center: v.center, height });
|
||||
}
|
||||
None => {
|
||||
// A model space view: into the active MSPACE viewport, else on the Model tab.
|
||||
@@ -187,18 +177,21 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
|
||||
}
|
||||
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);
|
||||
let twist = cadcraft_geom::norm_angle(v.twist);
|
||||
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect), twist });
|
||||
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect) });
|
||||
}
|
||||
}
|
||||
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) => super::super::layer::restore_layers(d, saved.layers),
|
||||
Some(saved) => {
|
||||
for layer in saved.layers {
|
||||
if let Some(l) = d.layer_mut(&layer.name) {
|
||||
*l = layer;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => msg.push_str(&format!(" Its layer snapshot \"{state}\" is missing.")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ fn rect(t: &VportTile) -> Rect {
|
||||
}
|
||||
|
||||
fn view_of(t: &VportTile) -> View {
|
||||
View { center: t.center, height: t.height, twist: if t.twist.is_finite() { t.twist } else { 0.0 } }
|
||||
View { center: t.center, height: t.height }
|
||||
}
|
||||
|
||||
fn active_config(d: &Drawing) -> Option<&VportConfig> {
|
||||
@@ -117,7 +117,7 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec<VportTile>, 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, twist: now.twist }], 0);
|
||||
return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect }], 0);
|
||||
};
|
||||
let mut tiles = cfg.tiles.clone();
|
||||
let synced =
|
||||
@@ -125,11 +125,11 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec<VportTile>, 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, t.twist) = (v.center, v.height, v.twist);
|
||||
(t.center, t.height) = (v.center, v.height);
|
||||
}
|
||||
}
|
||||
if let Some(t) = tiles.get_mut(active) {
|
||||
(t.center, t.height, t.twist) = (now.center, now.height, now.twist);
|
||||
(t.center, t.height) = (now.center, now.height);
|
||||
}
|
||||
// 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<Value> {
|
||||
let rows: Vec<Value> = 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, "twist": t.twist.to_degrees(), "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, "current": i == active }))
|
||||
.collect();
|
||||
Ok(json!({ "tiles": rows }))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::view::{ModelTiles, View, usable_view};
|
||||
use super::{Arc, Drawing, Handle, Space, Vec2, blockedit, undoctl};
|
||||
use super::{Arc, Drawing, Handle, Space, blockedit, undoctl};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
@@ -80,7 +80,6 @@ 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
|
||||
}
|
||||
@@ -136,8 +135,7 @@ impl DocState {
|
||||
match self.active_viewport() {
|
||||
Some((_, vp)) => {
|
||||
let k = vp.view_height / vp.height;
|
||||
// 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() }
|
||||
View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
|
||||
}
|
||||
None => pv,
|
||||
}
|
||||
@@ -152,18 +150,16 @@ 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.model_to_paper(v.center);
|
||||
let paper = View { center: pc, height: v.height / k0, twist: pv.twist };
|
||||
let pc = vp.center.xy() + (v.center - vp.view_center) / k0;
|
||||
let paper = View { center: pc, height: v.height / k0 };
|
||||
if !usable_view(&paper) {
|
||||
return false;
|
||||
}
|
||||
self.put_paper_view(paper);
|
||||
} else {
|
||||
// `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 center = v.center - (pv.center - vp.center.xy()) * k;
|
||||
let height = vp.height * k;
|
||||
if !usable_view(&View { center, height, twist }) {
|
||||
if !usable_view(&View { center, height }) {
|
||||
return false;
|
||||
}
|
||||
let doc = Arc::make_mut(&mut self.doc);
|
||||
@@ -172,7 +168,6 @@ impl DocState {
|
||||
if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind {
|
||||
x.view_center = center;
|
||||
x.view_height = height;
|
||||
x.twist = twist;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -187,36 +182,6 @@ 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.
|
||||
@@ -245,7 +210,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, twist: v.view_twist() },
|
||||
Some(v) => View { center: v.view_center, height: v.view_height },
|
||||
None => self.view(),
|
||||
}
|
||||
}
|
||||
@@ -260,9 +225,7 @@ impl DocState {
|
||||
Some(vp) => {
|
||||
let pv = self.paper_view();
|
||||
let k = f.height / vp.height;
|
||||
// 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 }
|
||||
View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
|
||||
}
|
||||
None => f,
|
||||
};
|
||||
@@ -270,7 +233,7 @@ impl DocState {
|
||||
}
|
||||
/// Set without recording view history (realtime pan/zoom frames).
|
||||
pub fn set_view_quiet(&mut self, v: View) {
|
||||
if !usable_view(&v) {
|
||||
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
|
||||
return;
|
||||
}
|
||||
self.store_view(v);
|
||||
|
||||
@@ -66,14 +66,6 @@ impl Interactive for Guarded {
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
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<EntityKind> {
|
||||
if self.broken.get().is_some() {
|
||||
return Vec::new();
|
||||
@@ -110,15 +102,6 @@ impl Interactive for Guarded {
|
||||
None
|
||||
})
|
||||
}
|
||||
fn display_only(&self, s: &Session) -> Option<crate::prompt::DisplayOnly> {
|
||||
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)]
|
||||
|
||||
@@ -27,7 +27,6 @@ pub mod mtext_edit;
|
||||
pub mod pointmod;
|
||||
pub mod prompt;
|
||||
mod recent;
|
||||
pub mod render_look;
|
||||
pub mod sample;
|
||||
pub mod select;
|
||||
pub mod settings_dir;
|
||||
@@ -50,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, DisplayOnly, Drag, Input, Interactive, Picker, Prompt, Step};
|
||||
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
|
||||
|
||||
mod doc_state;
|
||||
mod error;
|
||||
|
||||
@@ -31,7 +31,7 @@ impl Accept {
|
||||
/// [Truecolor/COlorbook] <BYLAYER>:". 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; 28] = [
|
||||
const NO_OR: [&str; 27] = [
|
||||
"Bring to front",
|
||||
"Calculate rectangle dimensions based on",
|
||||
"Choose mode for selected object conversion",
|
||||
@@ -47,7 +47,6 @@ const NO_OR: [&str; 28] = [
|
||||
"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",
|
||||
@@ -267,9 +266,6 @@ pub trait Interactive: Send {
|
||||
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
|
||||
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<EntityKind> {
|
||||
Vec::new()
|
||||
@@ -291,22 +287,6 @@ pub trait Interactive: Send {
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
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<DisplayOnly> {
|
||||
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<Handle>,
|
||||
/// 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<cadcraft_doc::Entity>,
|
||||
}
|
||||
|
||||
/// See [`Interactive::drag`].
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
//! How a render request draws: its `background` and `plot_style` (#803). The app's control
|
||||
//! channel and the headless MCP backend both answer `ui.render`, and both read these here.
|
||||
//!
|
||||
//! Colour 7 (white/black) follows the background, as on a plot: the rasteriser draws it white on
|
||||
//! a dark background and black on a light one ([`cadcraft_color::display_rgb`]).
|
||||
//!
|
||||
//! A render with a plot style table draws on white unless `background` says otherwise, as a plot
|
||||
//! prints on paper: the tables that change colours print colour 7 (and, in `monochrome.ctb`,
|
||||
//! every colour) black, which can hardly be seen on the dark canvas.
|
||||
//!
|
||||
//! `plot_style: "none"` (or `""`) draws every object with its own properties, also in a layout
|
||||
//! whose page setup has "Display plot styles" on; without `plot_style` such a layout draws with
|
||||
//! its own table, as on screen.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Rgb;
|
||||
use cadcraft_doc::{Drawing, PlotStyleTable};
|
||||
use cadcraft_render::raster::RasterOptions;
|
||||
use serde_json::Value;
|
||||
|
||||
/// The background a render draws on without `background`: the dark canvas.
|
||||
pub const DARK: Rgb = RasterOptions::DARK_BACKGROUND;
|
||||
/// The background of `"white"` and `"paper"`.
|
||||
pub const WHITE: Rgb = Rgb(255, 255, 255);
|
||||
|
||||
/// What `background` accepts, for the error message and the docs.
|
||||
pub const BACKGROUNDS: &str = "\"dark\" (the default without a plot style), \"white\" (the default with one), \"paper\" or \"#rrggbb\"";
|
||||
|
||||
/// Most table names an unknown `plot_style` error lists from the drawing's own tables (the
|
||||
/// built-in ones are always listed).
|
||||
const MAX_LISTED_TABLES: usize = 10;
|
||||
|
||||
/// Longest part of a refused value repeated in its error message, in characters.
|
||||
const MAX_ECHO_CHARS: usize = 40;
|
||||
|
||||
/// The look of a render: what it draws on and with which plot style table.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RenderLook {
|
||||
pub background: Rgb,
|
||||
/// `None` = draw with the objects' own properties (or, in a layout showing plot styles, its
|
||||
/// page setup's table). `plot_style: "none"` is a table that keeps every property, so such a
|
||||
/// layout's table doesn't apply either.
|
||||
pub plot_style_table: Option<Arc<PlotStyleTable>>,
|
||||
}
|
||||
|
||||
impl Default for RenderLook {
|
||||
fn default() -> Self {
|
||||
RenderLook { background: DARK, plot_style_table: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderLook {
|
||||
/// `background` and `plot_style` of a request's params for drawing `d`; absent or `null`
|
||||
/// keeps the default. `Err` says what is accepted.
|
||||
pub fn from_params(d: &Drawing, p: &Value) -> Result<RenderLook, String> {
|
||||
let text = |key: &str| match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.as_str())),
|
||||
Some(_) => Err(format!("`{key}` must be a string")),
|
||||
};
|
||||
let mut look = RenderLook::default();
|
||||
if let Some(name) = text("plot_style")? {
|
||||
look.plot_style_table = Some(Arc::new(match plot_style(d, name)? {
|
||||
Some(t) => {
|
||||
// Plotted on paper, unless `background` says otherwise.
|
||||
look.background = WHITE;
|
||||
t
|
||||
}
|
||||
None => object_properties(),
|
||||
}));
|
||||
}
|
||||
if let Some(b) = text("background")? {
|
||||
look.background = background(b).ok_or_else(|| format!("unknown background `{}`: use {BACKGROUNDS}", echo(b)))?;
|
||||
}
|
||||
Ok(look)
|
||||
}
|
||||
|
||||
/// The raster options of a render with this look.
|
||||
pub fn raster(&self) -> RasterOptions {
|
||||
RasterOptions { background: self.background, ..RasterOptions::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour of a `background` value (any case): `dark`, `white`, `paper` or `#rrggbb`.
|
||||
pub fn background(v: &str) -> Option<Rgb> {
|
||||
let v = v.trim();
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"dark" => Some(DARK),
|
||||
"white" | "paper" => Some(WHITE),
|
||||
hex if hex.starts_with('#') => Rgb::parse_hex(hex),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The plot style table `name` names: one the drawing holds, or a built-in one (`.ctb` may be
|
||||
/// left out); `none` or an empty name is no table. `Err` lists the tables there are.
|
||||
fn plot_style(d: &Drawing, name: &str) -> Result<Option<PlotStyleTable>, String> {
|
||||
if cadcraft_doc::no_plot_style_table(name) {
|
||||
return Ok(None);
|
||||
}
|
||||
cadcraft_doc::plot_style_table(d, name.trim())
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("unknown plot style table `{}`: use none or one of {}", echo(name), table_list(d)))
|
||||
}
|
||||
|
||||
/// The tables a drawing can use, for an error message: the built-in ones, then at most
|
||||
/// [`MAX_LISTED_TABLES`] of the drawing's own and how many more there are.
|
||||
fn table_list(d: &Drawing) -> String {
|
||||
let builtin = |n: &String| cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES.iter().any(|b| b.eq_ignore_ascii_case(n));
|
||||
let names = cadcraft_doc::plot_style_table_names(d);
|
||||
let own: Vec<String> = names.iter().filter(|n| !builtin(n)).map(|n| echo(n)).collect();
|
||||
let mut list: Vec<String> = cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES.iter().map(|b| (*b).to_string()).collect();
|
||||
list.extend(own.iter().take(MAX_LISTED_TABLES).cloned());
|
||||
let mut text = list.join(", ");
|
||||
if own.len() > MAX_LISTED_TABLES {
|
||||
text.push_str(&format!(" and {} more of the drawing's", own.len() - MAX_LISTED_TABLES));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A table that prints every object with its own properties: what `plot_style: "none"` draws
|
||||
/// with, so a layout's own table doesn't apply either.
|
||||
fn object_properties() -> PlotStyleTable {
|
||||
PlotStyleTable::color_dependent("None", "Object properties", |_| None)
|
||||
}
|
||||
|
||||
/// The start of a refused value, to repeat in its error message.
|
||||
fn echo(v: &str) -> String {
|
||||
v.chars().take(MAX_ECHO_CHARS).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn backgrounds() {
|
||||
assert_eq!(background("dark"), Some(DARK));
|
||||
assert_eq!(DARK, RasterOptions::default().background, "the default is the rasteriser's canvas");
|
||||
assert_eq!(background(" Paper "), Some(WHITE));
|
||||
assert_eq!(background("WHITE"), Some(WHITE));
|
||||
assert_eq!(background("#336699"), Some(Rgb(0x33, 0x66, 0x99)));
|
||||
for bad in ["", "purple", "336699", "#12345", "#1234567", "##336699", "#33669g"] {
|
||||
assert_eq!(background(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_params() {
|
||||
let d = Drawing::new_imperial();
|
||||
let look = RenderLook::from_params(&d, &json!({})).unwrap();
|
||||
assert_eq!(look.background, DARK);
|
||||
assert!(look.plot_style_table.is_none());
|
||||
// "none" keeps the dark canvas and draws every object with its own properties, also in a
|
||||
// layout that shows its own plot styles.
|
||||
for none in ["none", "", " None "] {
|
||||
let look = RenderLook::from_params(&d, &json!({"background": null, "plot_style": none})).unwrap();
|
||||
assert_eq!(look.background, DARK, "{none:?}");
|
||||
let t = look.plot_style_table.unwrap();
|
||||
assert!(t.is_color_dependent() && t.styles.iter().all(|s| s.keeps_color() && s.lineweight.is_none()), "{none:?}");
|
||||
}
|
||||
let look = RenderLook::from_params(&d, &json!({"background": "white", "plot_style": "Monochrome"})).unwrap();
|
||||
assert_eq!(look.raster().background, WHITE);
|
||||
assert_eq!(look.plot_style_table.map(|t| t.name.clone()).as_deref(), Some("monochrome.ctb"));
|
||||
// A plot style plots on paper unless `background` says otherwise.
|
||||
for table in ["monochrome.ctb", "grayscale.ctb", "default.stb"] {
|
||||
assert_eq!(RenderLook::from_params(&d, &json!({"plot_style": table})).unwrap().background, WHITE, "{table}");
|
||||
let look = RenderLook::from_params(&d, &json!({"plot_style": table, "background": "dark"})).unwrap();
|
||||
assert_eq!(look.background, DARK, "{table}");
|
||||
}
|
||||
let e = RenderLook::from_params(&d, &json!({"plot_style": "nope.ctb"})).unwrap_err();
|
||||
assert!(e.contains("nope.ctb") && e.contains("grayscale.ctb") && e.contains("none"), "{e}");
|
||||
let e = RenderLook::from_params(&d, &json!({"background": "x".repeat(10_000)})).unwrap_err();
|
||||
assert!(e.len() < 200 && e.contains("#rrggbb"), "{e}");
|
||||
assert!(RenderLook::from_params(&d, &json!({"background": 1})).is_err());
|
||||
assert!(RenderLook::from_params(&d, &json!({"plot_style": {}})).is_err());
|
||||
}
|
||||
|
||||
/// An unknown plot style lists the built-in tables and only some of the drawing's own.
|
||||
#[test]
|
||||
fn unknown_plot_style_lists_a_few_tables() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
for i in 0..500 {
|
||||
d.plot_style_tables.push(PlotStyleTable::color_dependent(&format!("{}{i}.ctb", "own".repeat(30)), "", |_| None));
|
||||
}
|
||||
let e = RenderLook::from_params(&d, &json!({"plot_style": "nope.ctb"})).unwrap_err();
|
||||
for b in cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES {
|
||||
assert!(e.contains(b), "{b}: {e}");
|
||||
}
|
||||
assert!(e.contains("and 490 more"), "{e}");
|
||||
assert!(e.len() < 1000, "{} bytes: {e}", e.len());
|
||||
assert!(RenderLook::from_params(&d, &json!({"plot_style": d.plot_style_tables[7].name.clone()})).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,6 @@ pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
}
|
||||
v
|
||||
}
|
||||
// An OLE object's frame is drawn, so it is picked on its outline (the corners the extents and
|
||||
// the renderer use); a frame with no width or height is drawn as a line, so it is picked as
|
||||
// one. It stays out of prims(), which TRIM, object snap and the dimension commands read.
|
||||
EntityKind::Unknown(u) if u.is_ole_frame() => match cadcraft_doc::ole_frame(&u.tags) {
|
||||
Some(q) => vec![q.to_vec()],
|
||||
None => Vec::new(),
|
||||
},
|
||||
kind => {
|
||||
let mut out = Vec::new();
|
||||
for p in kind.prims() {
|
||||
@@ -514,23 +507,6 @@ mod tests {
|
||||
assert_eq!(pick(&d, &Space::Model, Vec2::new(41.3, 50.0), 1.0), Some(line));
|
||||
}
|
||||
|
||||
/// An OLE2FRAME draws as its frame, so a click on an edge picks it (review on #2587); a click in
|
||||
/// the middle of the frame, or on a frame without both corners, does not.
|
||||
#[test]
|
||||
fn clicking_an_ole_frame_edge_selects_it() {
|
||||
let tag = |code: i32, value: &str| cadcraft_doc::RawTag { code, value: value.into() };
|
||||
let frame = |tags: Vec<cadcraft_doc::RawTag>| EntityKind::Unknown(cadcraft_doc::Unknown { dxf_type: "OLE2FRAME".into(), tags });
|
||||
let mut d = Drawing::new_metric();
|
||||
let h = d.add(&Space::Model, Common::default(), frame(vec![tag(10, "30"), tag(20, "-19"), tag(11, "35"), tag(21, "-22")])).unwrap();
|
||||
d.add(&Space::Model, Common::default(), frame(vec![tag(10, "80"), tag(20, "80")])).unwrap();
|
||||
let line = d.add(&Space::Model, Common::default(), frame(vec![tag(10, "60"), tag(20, "-19"), tag(11, "60"), tag(21, "-22")])).unwrap();
|
||||
assert_eq!(pick(&d, &Space::Model, Vec2::new(32.5, -19.0), 0.2), Some(h), "top edge");
|
||||
assert_eq!(pick(&d, &Space::Model, Vec2::new(35.0, -20.5), 0.2), Some(h), "right edge");
|
||||
assert_eq!(pick(&d, &Space::Model, Vec2::new(32.5, -20.5), 0.2), None, "inside the frame");
|
||||
assert_eq!(pick(&d, &Space::Model, Vec2::new(80.0, 80.0), 0.2), None, "a frame without a second corner");
|
||||
assert_eq!(pick(&d, &Space::Model, Vec2::new(60.0, -20.0), 0.2), Some(line), "a frame with no width is drawn as a line and picked as one");
|
||||
}
|
||||
|
||||
/// A block holding a diagonal dashed line, inserted at `scale`.
|
||||
fn dashed_block(length: f64, scale: f64) -> (Drawing, Handle) {
|
||||
let mut d = Drawing::new_imperial();
|
||||
@@ -551,7 +527,6 @@ 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)
|
||||
|
||||
@@ -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, twist: 0.0 } } else { View::default() });
|
||||
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() });
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
@@ -17,21 +17,12 @@ impl Session {
|
||||
/// Append a loaded drawing, fit the view to its model extents (aspect-corrected, minimum height 1e-6) when non-empty, end a running command, make it active, bump the epoch and return its index.
|
||||
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
|
||||
let mut st = DocState::new(d, title, path);
|
||||
// The view the file was saved at is the one every other reader opens on. Fitting the
|
||||
// extents instead showed a drawing whose extents are dominated by far-off geometry at a
|
||||
// view hundreds of times its content, with the drawing sub-pixel and no way to zoom in
|
||||
// (#1602). The extents remain the fallback for a drawing that stores no view.
|
||||
let saved = crate::view::saved_view(&st.doc);
|
||||
let ext = st.doc.extents(&Space::Model);
|
||||
// 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 let Some(v) = saved {
|
||||
st.set_view_quiet(View { twist, ..v });
|
||||
} else 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 });
|
||||
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),
|
||||
});
|
||||
}
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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,
|
||||
@@ -201,7 +199,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 = self.window_is_crossing(pw.corner, p);
|
||||
let crossing = p.x < pw.corner.x;
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
|
||||
}
|
||||
@@ -281,10 +279,7 @@ 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) => {
|
||||
run.machine.cancelled(self);
|
||||
Ok(Step::Cancel)
|
||||
}
|
||||
Some(Input::Cancel) => Ok(Step::Cancel),
|
||||
Some(i) => {
|
||||
if let Input::Point(p) = &i {
|
||||
self.last_point = *p;
|
||||
@@ -443,8 +438,7 @@ impl Session {
|
||||
}
|
||||
if self.running.is_some() {
|
||||
if self.suspended.is_some() {
|
||||
if let Some(mut nested) = self.running.take() {
|
||||
nested.machine.cancelled(self);
|
||||
if let Some(nested) = self.running.take() {
|
||||
self.finish(nested, true);
|
||||
}
|
||||
self.running = self.suspended.take();
|
||||
@@ -529,13 +523,6 @@ 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<DisplayOnly> {
|
||||
[&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<Entity>)> {
|
||||
|
||||
@@ -16,13 +16,6 @@ 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
|
||||
@@ -31,7 +24,7 @@ impl Session {
|
||||
let space = self.space();
|
||||
let pickadd = self.settings.pickadd;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = self.window_is_crossing(pw.corner, p);
|
||||
let crossing = p.x < pw.corner.x;
|
||||
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() };
|
||||
|
||||
@@ -23,10 +23,8 @@ 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 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 });
|
||||
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 });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -40,7 +38,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, twist: v.twist });
|
||||
st.set_view_quiet(View { center: c, height: h });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user