mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 08:52:05 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b639b18f69 | ||
|
|
578da3ba9c | ||
|
|
f2afcd4f02 | ||
|
|
1619e24381 | ||
|
|
ffc2c553e8 | ||
|
|
7bb6c2f06f | ||
|
|
e10f394b28 | ||
|
|
9a112830c5 | ||
|
|
b04273e897 | ||
|
|
5ed192555c | ||
|
|
4c00f8b9e3 | ||
|
|
7b6be7835f | ||
|
|
455b2a87cb | ||
|
|
1b7c705fac | ||
|
|
77142aa79e | ||
|
|
31e6e16bc0 | ||
|
|
a4ff30d11e | ||
|
|
9d83f21910 | ||
|
|
7caf369555 | ||
|
|
f691d0b349 | ||
|
|
e24c4553d2 | ||
|
|
c602a402c3 | ||
|
|
d180b28ef6 | ||
|
|
2e9761249a | ||
|
|
c956baa349 | ||
|
|
997f152cea | ||
|
|
0d3b3d21c0 | ||
|
|
4482316306 | ||
|
|
f6d84e28f3 | ||
|
|
806654aedf | ||
|
|
6c4984f5ad | ||
|
|
fdfe4d95a8 | ||
|
|
f389d6a72e | ||
|
|
eacdbe43ee | ||
|
|
096b3f9be4 | ||
|
|
bf4d51bf61 | ||
|
|
2ac500925b | ||
|
|
5ec0d215d8 | ||
|
|
3f57f8d3d4 | ||
|
|
4e874d089a | ||
|
|
3bb515485d | ||
|
|
e4cc7ffc77 | ||
|
|
03101e8d77 | ||
|
|
0bc025965a | ||
|
|
03db6261e4 | ||
|
|
cd19b205b7 | ||
|
|
69e946586f | ||
|
|
fbc47b48e8 | ||
|
|
86f3fdc9e9 | ||
|
|
7690d36bb0 | ||
|
|
7bc76f94ca | ||
|
|
6660d88785 | ||
|
|
beb6c48a59 | ||
|
|
990ff2ce9f | ||
|
|
8de925e1d3 | ||
|
|
97679d8cff | ||
|
|
9972a739c5 | ||
|
|
0f56c16d00 | ||
|
|
a60d437f0a | ||
|
|
f2cb7e6e8f | ||
|
|
0e7c71759c | ||
|
|
facae4a9a6 | ||
|
|
ea127d4702 | ||
|
|
ad95791c20 | ||
|
|
e6352d955e | ||
|
|
4a17ee78c2 | ||
|
|
c0306b1af3 | ||
|
|
25ca905815 | ||
|
|
fbd6e676f8 | ||
|
|
0674391100 | ||
|
|
f4c68e18fc | ||
|
|
ed08716f30 | ||
|
|
ab15e42c6c | ||
|
|
c4ac8761e8 | ||
|
|
a277346c2f | ||
|
|
3146b47656 | ||
|
|
dae4b7b2f5 | ||
|
|
ebc67fa81d | ||
|
|
fa03bf2a29 | ||
|
|
b006110dc5 | ||
|
|
2034d6899e | ||
|
|
e8bcc38e34 | ||
|
|
152617f4df | ||
|
|
ccbc4fa854 | ||
|
|
f0b46e34dc | ||
|
|
06c5760ea6 | ||
|
|
2d02a5faa5 | ||
|
|
5cc342b033 | ||
|
|
fb05c78590 | ||
|
|
5e9c478bfb | ||
|
|
e187c5fecb | ||
|
|
c72c0eebfb | ||
|
|
4a9371ad2c | ||
|
|
0e2bff3b2b | ||
|
|
210cfc2a1d | ||
|
|
520394858c | ||
|
|
020d81a27e | ||
|
|
9c84141e38 | ||
|
|
57e6a89d10 | ||
|
|
282ad11e60 | ||
|
|
768d25bb7d | ||
|
|
7baa970381 | ||
|
|
590b97a321 | ||
|
|
86d010eaac | ||
|
|
fc8270d8c5 | ||
|
|
b301d5782d | ||
|
|
1013b482a8 | ||
|
|
89771962fc | ||
|
|
49fdbff529 | ||
|
|
2eecaef6c2 | ||
|
|
b6c1ab74f9 | ||
|
|
c2cb5b8610 | ||
|
|
c45b49acc7 | ||
|
|
4398a1aab1 | ||
|
|
9107479ade | ||
|
|
62b9b5acd4 | ||
|
|
a13870f376 | ||
|
|
6ed822ec30 | ||
|
|
a6ba747329 |
+1
-1
@@ -71,7 +71,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted); terminology corrections: Andrea Brandi (@starise) with OpenAI Codex (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; targeted Chamfer terminology review by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
|
||||
Generated
+51
-16
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -632,6 +632,7 @@ dependencies = [
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"thiserror 2.0.21",
|
||||
@@ -643,7 +644,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -659,14 +660,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -676,7 +677,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -687,14 +688,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -703,7 +705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -724,24 +726,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust 0.14.0",
|
||||
"icu_segmenter",
|
||||
"skrifa 0.48.0",
|
||||
"unicode-bidi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -762,7 +765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -775,7 +778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -789,7 +792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -815,7 +818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -1115,6 +1118,15 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "core_maths"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -2435,6 +2447,29 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_segmenter"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
"icu_collections",
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
"icu_segmenter_data",
|
||||
"potential_utf",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_segmenter_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -5894,7 +5929,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -51,6 +51,9 @@ skrifa = "0.48"
|
||||
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
|
||||
harfrust = "0.14"
|
||||
unicode-bidi = "0.3.18"
|
||||
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
|
||||
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
|
||||
icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] }
|
||||
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)
|
||||
|
||||
@@ -230,6 +230,11 @@ How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the mode
|
||||
rectangle, centred and scaled without distortion; `--view extents` or `--view current` forces
|
||||
either framing. To frame a selection set, `zoom {"mode":"object"}` after selecting.
|
||||
|
||||
When reading a file leaves something out (records a DXF reader can't place, object records of a
|
||||
damaged DWG), `info` reports it (`skipped`, `readNotes`), `convert` warns on stderr and `run`
|
||||
prints the DWG notes with its log; the exit code stays 0. MCP `open` replies with the notes as
|
||||
`warnings`.
|
||||
|
||||
MCP tools include `command_line` (type at the prompt), `execute` (any command with JSON),
|
||||
`inspect_drawing`, `query_entities`, `render` (returns a PNG) and, when connected to the app,
|
||||
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report it privately through [GitHub's private reporting](https://github.com/storytold/cadcraft/security/advisories/new), not in a public issue or pull request. Say which version you ran, on which platform, and how to reproduce it. A small sample file helps, as long as it holds nothing private.
|
||||
|
||||
Maintainers read reports as they arrive, but there is no fixed response time yet.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Only the latest release gets security fixes. CADCraft is pre-alpha and moves quickly, so please check that a problem still exists there before reporting it.
|
||||
|
||||
## What counts
|
||||
|
||||
CADCraft treats these as untrusted, and a way past these defences is a vulnerability:
|
||||
|
||||
- **Drawing files.** Opening a DXF, DWG or other file must not run code, write outside the paths the user chose, or crash the app. Panics are bugs too, but memory exhaustion or a decoder abort on a crafted file is the more serious case.
|
||||
- **AutoLISP in a drawing's folder.** A drawing must not run code that the user didn't choose to load.
|
||||
- **The control channel.** It listens on loopback only and needs the session token from an owner-only file on every request. Another local account reaching it without that token is a vulnerability.
|
||||
- **Temporary and autosave files.** Autosave files are unsaved drawings: another account must not be able to read them or redirect a write through them.
|
||||
- **Updates.** A release must not be replaceable by someone other than the project.
|
||||
|
||||
## Known limits
|
||||
|
||||
These are known and being worked on, so they don't need a report:
|
||||
|
||||
- Agents connected through MCP or the control channel can read and write any path the user can. There are no workspace roots yet.
|
||||
- A local process can make the control channel buffer up to 512 MiB before its token is checked. The total is capped, so this can slow the app but not exhaust memory without limit.
|
||||
- The DWG worker process has no memory limit of its own yet, so a crafted file can use a lot of memory before it stops. It ends that one open or save, not the app.
|
||||
- The web build has no autosave yet.
|
||||
|
||||
## What stays on your machine
|
||||
|
||||
Crash reports are written to the `crash-reports` folder in the settings folder and are never uploaded. They hold the version, the platform, the panic message and a stack trace, and a message can include a file path, so read a report before you attach it anywhere.
|
||||
@@ -81,6 +81,7 @@ pub(crate) enum Command {
|
||||
Mcp(McpArgs),
|
||||
Perf(PerfArgs),
|
||||
Sample(SampleArgs),
|
||||
Roundtrip(RoundtripArgs),
|
||||
}
|
||||
|
||||
/// Print a drawing's summary as JSON
|
||||
@@ -308,6 +309,40 @@ fn perf_target(p: PathBuf) -> PerfTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open each DXF/DWG, save it as DXF and DWG, reopen and compare; writes report.md and report.json
|
||||
///
|
||||
/// FOLDER defaults to $CADCRAFT_CORPUS_DIR. With CADCRAFT_CORPUS_STRICT=1 it fails when a file
|
||||
/// doesn't open, save or reopen, or loses records on save.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RoundtripArgs {
|
||||
/// A drawing, or a folder of them
|
||||
#[arg(value_name = "FILE|FOLDER")]
|
||||
pub(crate) path: Option<PathBuf>,
|
||||
/// Where the report goes (default: `report` beside the files)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub(crate) out: Option<PathBuf>,
|
||||
/// Keep the saved files, in `saved` under the report folder
|
||||
#[arg(long)]
|
||||
pub(crate) keep_saved: bool,
|
||||
/// Save only in each file's own format
|
||||
#[arg(long)]
|
||||
pub(crate) same_format: bool,
|
||||
/// Don't count what each file holds (faster; no "read lost" column)
|
||||
#[arg(long)]
|
||||
pub(crate) no_census: bool,
|
||||
/// How far numbers may move and still count as equal
|
||||
#[arg(long, value_name = "T", default_value_t = cadcraft_io::roundtrip::Options::default().abs_tol, value_parser = tolerance)]
|
||||
pub(crate) tolerance: f64,
|
||||
}
|
||||
|
||||
/// `--tolerance`: a finite number, 0 or more.
|
||||
fn tolerance(v: &str) -> Result<f64, ValueError> {
|
||||
match v.parse::<f64>() {
|
||||
Ok(t) if t.is_finite() && t >= 0.0 => Ok(t),
|
||||
_ => Err(ValueError::Tolerance(v.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a built-in sample drawing
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SampleArgs {
|
||||
@@ -360,6 +395,8 @@ pub(crate) enum ValueError {
|
||||
CmdId,
|
||||
#[error(transparent)]
|
||||
CmdJson(#[from] serde_json::Error),
|
||||
#[error("expected a number of 0 or more, got `{0}`")]
|
||||
Tolerance(String),
|
||||
}
|
||||
|
||||
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::args::ConvertArgs;
|
||||
use super::error::Result;
|
||||
use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message};
|
||||
use super::output::{Streams, export_framed, export_window, read_note_messages, skipped_message, undrawn_message};
|
||||
use super::{open, unicode};
|
||||
use cadcraft_engine::Session;
|
||||
|
||||
@@ -9,7 +9,8 @@ pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result {
|
||||
let framing = a.framing.framing();
|
||||
let mut s = Session::empty();
|
||||
open(&mut s, &a.input)?;
|
||||
if let Some(m) = skipped_message(s.doc()?, i) {
|
||||
let d = s.doc()?;
|
||||
for m in read_note_messages(d, i).into_iter().chain(skipped_message(d, i)) {
|
||||
io.errln(format_args!("{m}"));
|
||||
}
|
||||
if cadcraft_io::is_image(&a.output)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::output::OutputError;
|
||||
use crate::perf::PerfError;
|
||||
use crate::roundtrip::RoundtripError;
|
||||
use crate::run::RunError;
|
||||
|
||||
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
|
||||
@@ -19,6 +20,8 @@ pub(crate) enum CliError {
|
||||
Output(#[from] OutputError),
|
||||
#[error(transparent)]
|
||||
Perf(#[from] PerfError),
|
||||
#[error(transparent)]
|
||||
Roundtrip(#[from] RoundtripError),
|
||||
/// The engine and io crates take file names as text.
|
||||
#[error("{}: the file name is not valid Unicode", .0.display())]
|
||||
NotUnicode(std::path::PathBuf),
|
||||
|
||||
@@ -16,6 +16,7 @@ mod info;
|
||||
mod mcp;
|
||||
mod output;
|
||||
mod perf;
|
||||
mod roundtrip;
|
||||
mod run;
|
||||
mod sample;
|
||||
|
||||
@@ -32,6 +33,7 @@ use output::Streams;
|
||||
#[cfg(test)]
|
||||
use output::{export_window, model_views, write_atomic};
|
||||
use perf::perf;
|
||||
use roundtrip::roundtrip;
|
||||
use run::run_cmds;
|
||||
#[cfg(test)]
|
||||
use run::{fill_last, returned_handle};
|
||||
@@ -99,7 +101,7 @@ where
|
||||
Command::Run(a) => a.settings.apply(),
|
||||
Command::Mcp(a) => a.settings.apply(),
|
||||
Command::Sample(a) => a.settings.apply(),
|
||||
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
|
||||
Command::Info(_) | Command::Commands(_) | Command::Perf(_) | Command::Roundtrip(_) => {}
|
||||
}
|
||||
let mut io = Streams::new(out, err);
|
||||
let result = match &cli.command {
|
||||
@@ -110,6 +112,7 @@ where
|
||||
Command::Mcp(a) => mcp(a),
|
||||
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
|
||||
Command::Sample(a) => sample(a, &mut io),
|
||||
Command::Roundtrip(a) => roundtrip(a, &mut io),
|
||||
};
|
||||
let mut code = 0;
|
||||
if let Err(e) = result {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
fn main() {
|
||||
// A copy of this program started to read or write a DWG file does only that.
|
||||
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
cadcraft_io::dwg_worker::use_own_worker();
|
||||
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be
|
||||
// reported as an error, not panic before anything is printed.
|
||||
let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
|
||||
|
||||
@@ -122,10 +122,21 @@ pub(crate) fn export_window(s: &Session, framing: Framing, before: Option<&[(u64
|
||||
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
|
||||
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched. An
|
||||
/// existing `out` is resolved first: the rename would otherwise replace a symlink with a file and
|
||||
/// leave the file it points to (`--in-place` through a link) unchanged.
|
||||
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<(), OutputError> {
|
||||
/// leave the file it points to (`--in-place` through a link) unchanged. Folders missing above `out`
|
||||
/// are created, and the topmost one returned for the caller to report.
|
||||
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<Option<std::path::PathBuf>, OutputError> {
|
||||
let target = std::fs::canonicalize(out).unwrap_or_else(|_| out.to_path_buf());
|
||||
cadcraft_engine::atomic_write::write_atomic(&target, bytes).map_err(|source| OutputError::Write { path: out.to_path_buf(), source })
|
||||
let folder = cadcraft_engine::atomic_write::missing_folder(&target);
|
||||
cadcraft_engine::atomic_write::write_atomic_making_folders(&target, bytes)
|
||||
.map_err(|source| OutputError::Write { path: out.to_path_buf(), source })?;
|
||||
Ok(folder)
|
||||
}
|
||||
|
||||
/// Say that `out` went into a folder that didn't exist, so a mistyped path is noticed.
|
||||
pub(crate) fn report_new_folder(folder: Option<std::path::PathBuf>, io: &mut Streams) {
|
||||
if let Some(folder) = folder {
|
||||
io.errln(format_args!("created the folder {}, which did not exist", folder.display()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
|
||||
@@ -135,7 +146,8 @@ pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
|
||||
pub(crate) fn export_framed(s: &Session, out: &Path, window: Option<Bounds2>, io: &mut Streams) -> Result {
|
||||
let d = s.doc()?;
|
||||
let bytes = cadcraft_io::write_framed(d, out, window)?;
|
||||
write_atomic(out, &bytes)?;
|
||||
let folder = write_atomic(out, &bytes)?;
|
||||
report_new_folder(folder, io);
|
||||
io.errln(format_args!("wrote {} ({} bytes)", out.display(), bytes.len()));
|
||||
if cadcraft_engine::cmd::file::drops_unknown(out) {
|
||||
let lost = cadcraft_engine::cmd::file::not_saved(d);
|
||||
@@ -165,6 +177,12 @@ pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) ->
|
||||
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,
|
||||
/// #859), one warning line each; empty for a complete read.
|
||||
pub(crate) fn read_note_messages(d: &cadcraft_engine::doc::Drawing, path: &str) -> Vec<String> {
|
||||
d.read_notes.iter().map(|note| format!("{path}: {note}")).collect()
|
||||
}
|
||||
|
||||
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
|
||||
pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
|
||||
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
@@ -175,3 +193,20 @@ pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) ->
|
||||
let records = if n == 1 { "record" } else { "records" };
|
||||
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::read_note_messages;
|
||||
|
||||
#[test]
|
||||
fn read_notes_become_one_warning_each() {
|
||||
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
|
||||
assert!(read_note_messages(&d, "a.dwg").is_empty());
|
||||
d.read_notes =
|
||||
vec!["DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them".into()];
|
||||
assert_eq!(
|
||||
read_note_messages(&d, "a.dwg"),
|
||||
vec!["a.dwg: DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! `roundtrip`: the corpus harness of `cadcraft_io::roundtrip::corpus` over a file or folder.
|
||||
|
||||
use super::args::RoundtripArgs;
|
||||
use super::error::{CliError, Result};
|
||||
use super::output::Streams;
|
||||
|
||||
/// Why `roundtrip` stopped.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum RoundtripError {
|
||||
#[error("roundtrip: give a FILE or FOLDER, or set CADCRAFT_CORPUS_DIR")]
|
||||
NoPath,
|
||||
#[error("roundtrip: no such file or folder: {}", .0.display())]
|
||||
Missing(std::path::PathBuf),
|
||||
#[error("roundtrip: no DXF or DWG files in {}", .0.display())]
|
||||
Empty(std::path::PathBuf),
|
||||
#[error("roundtrip: can't write the report to {}: {source}", .path.display())]
|
||||
Report { path: std::path::PathBuf, source: std::io::Error },
|
||||
#[error("roundtrip: {} strict failures:\n{}", .0.len(), .0.join("\n"))]
|
||||
Strict(Vec<String>),
|
||||
}
|
||||
|
||||
/// What the walk couldn't look at, so the report isn't taken for the whole corpus: said on
|
||||
/// stderr, and failures of a strict run.
|
||||
fn left_out(w: &cadcraft_io::roundtrip::corpus::Walk) -> Vec<String> {
|
||||
let mut out: Vec<String> = w.unreadable.iter().map(|u| format!("not read: {u}")).collect();
|
||||
if w.truncated {
|
||||
out.push("left out: files or folders past the harness limits".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result {
|
||||
use cadcraft_io::roundtrip::corpus;
|
||||
let path = a.path.clone().or_else(|| std::env::var_os("CADCRAFT_CORPUS_DIR").map(Into::into)).ok_or(RoundtripError::NoPath)?;
|
||||
let mut missed = Vec::new();
|
||||
let (root, files, out) = if path.is_dir() {
|
||||
let out = a.out.clone().unwrap_or_else(|| path.join("report"));
|
||||
// The report folder, and the saved copies in it, are never read back as corpus files.
|
||||
let walk = corpus::walk(&path, &[out.clone(), out.join("saved")]);
|
||||
missed = left_out(&walk);
|
||||
for m in &missed {
|
||||
io.errln(format_args!("roundtrip: {m}"));
|
||||
}
|
||||
(path, walk.files, out)
|
||||
} else if path.is_file() {
|
||||
let root = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
let out = a.out.clone().unwrap_or_else(|| root.join("report"));
|
||||
(root, vec![path], out)
|
||||
} else {
|
||||
return Err(RoundtripError::Missing(path).into());
|
||||
};
|
||||
if files.is_empty() {
|
||||
// A folder it couldn't read isn't an empty one.
|
||||
return Err(if missed.is_empty() { RoundtripError::Empty(root) } else { RoundtripError::Strict(missed) }.into());
|
||||
}
|
||||
let mut opts = corpus::CorpusOptions { cross_format: !a.same_format, census: !a.no_census, ..Default::default() };
|
||||
opts.compare.abs_tol = a.tolerance;
|
||||
if a.keep_saved {
|
||||
opts.keep_saved = Some(out.join("saved"));
|
||||
}
|
||||
let report = corpus::run(&root, &files, &opts, &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
|
||||
for f in &report.files {
|
||||
let routes: Vec<String> = f
|
||||
.routes
|
||||
.iter()
|
||||
.map(|r| match (&r.save_error, &r.reopen_error) {
|
||||
(Some(_), _) => format!("{}: save failed", r.route),
|
||||
(None, Some(_)) => format!("{}: reopen failed", r.route),
|
||||
(None, None) => {
|
||||
let t = r.comparison.total();
|
||||
format!("{}: {} lost, {} added, {} changed", r.route, t.lost, t.added, t.changed)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
match &f.open_error {
|
||||
Some(e) => io.outln(format_args!("{}: open failed: {e}", f.path)),
|
||||
None => io.outln(format_args!("{}: opened; {}", f.path, routes.join("; "))),
|
||||
}
|
||||
}
|
||||
let (md, json) = report.write(&out).map_err(|source| RoundtripError::Report { path: out.clone(), source })?;
|
||||
io.outln(format_args!("report: {}", md.display()));
|
||||
io.outln(format_args!(" {}", json.display()));
|
||||
// CADCRAFT_CORPUS_STRICT=1: a file that doesn't open, save or reopen, or records a writer
|
||||
// leaves out, fail the run (the report is written first).
|
||||
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
|
||||
let mut failed = report.strict_failures();
|
||||
failed.extend(missed);
|
||||
if !failed.is_empty() {
|
||||
return Err(CliError::from(RoundtripError::Strict(failed)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::args::{SampleArgs, SampleName};
|
||||
use super::error::Result;
|
||||
use super::output::{Streams, write_atomic};
|
||||
use super::output::{Streams, report_new_folder, write_atomic};
|
||||
|
||||
pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
|
||||
let d = match a.name {
|
||||
@@ -8,7 +8,8 @@ pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
|
||||
SampleName::Floorplan => cadcraft_engine::sample::floor_plan(),
|
||||
};
|
||||
let bytes = cadcraft_io::write(&d, &a.output)?;
|
||||
write_atomic(&a.output, &bytes)?;
|
||||
let folder = write_atomic(&a.output, &bytes)?;
|
||||
report_new_folder(folder, io);
|
||||
io.errln(format_args!("wrote {} ({} bytes)", a.output.display(), bytes.len()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! The DWG reader and writer run in a child process, so a decoder that dies ends one open or save,
|
||||
//! not the app. The worker setting is process-wide, so everything runs in one test.
|
||||
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use cadcraft_io::dwg_worker;
|
||||
|
||||
#[test]
|
||||
fn a_real_worker_reads_and_writes_and_its_death_is_an_error_here() {
|
||||
let drawing = cadcraft_engine::sample::bracket();
|
||||
let in_process = cadcraft_io::write(&drawing, "a.dwg").unwrap();
|
||||
|
||||
dwg_worker::use_worker(env!("CARGO_BIN_EXE_cadcraft-cli").into(), vec![dwg_worker::WORKER_ARG.into()]);
|
||||
let written = cadcraft_io::write(&drawing, "a.dwg").unwrap();
|
||||
assert_eq!(written.get(..6), in_process.get(..6), "the same version of file");
|
||||
let read = cadcraft_io::read(&written, "a.dwg").unwrap();
|
||||
assert_eq!(read.model.len(), drawing.model.len());
|
||||
|
||||
// A decode error comes back as that error, not as the worker stopping.
|
||||
let mut bad = in_process.clone();
|
||||
bad.truncate(64);
|
||||
let e = cadcraft_io::read(&bad, "a.dwg").unwrap_err().to_string();
|
||||
assert!(!e.contains("worker"), "{e}");
|
||||
|
||||
// The worker aborts as the third-party code can. This process goes on.
|
||||
let e = dwg_worker::crash_worker_for_test().unwrap_err();
|
||||
assert!(e.contains("worker stopped"), "{e}");
|
||||
assert!(cadcraft_io::read(&written, "a.dwg").is_ok(), "the next file gets a new worker");
|
||||
|
||||
// No worker to start: the work happens here, as without one.
|
||||
dwg_worker::use_worker("no-such-program-here".into(), Vec::new());
|
||||
assert!(cadcraft_io::read(&written, "a.dwg").is_ok());
|
||||
}
|
||||
@@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() {
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||||
written.sort();
|
||||
let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"];
|
||||
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
|
||||
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample", "roundtrip"];
|
||||
let mut expected: Vec<String> = completions.map(String::from).into();
|
||||
expected.push("cadcraft-cli.1".into());
|
||||
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! A damaged DWG opens partially (#859); `info` lists the reader's notes on what it left out
|
||||
//! (`readNotes`) and `convert` warns with them before writing the copy, so a script or agent sees
|
||||
//! the loss. The exit code stays 0.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A DWG of 200 lines, and a copy damaged so that the reader skips some of its records (found the
|
||||
/// way the reader's own test finds one: flipping a few bytes at a time across the objects area).
|
||||
fn dwg_and_damaged_copy() -> (Vec<u8>, Vec<u8>) {
|
||||
let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n");
|
||||
for i in 0..200 {
|
||||
dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n");
|
||||
}
|
||||
dxf += "0\nENDSEC\n0\nEOF\n";
|
||||
let d = cadcraft_io::read(dxf.as_bytes(), "lines.dxf").unwrap();
|
||||
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2000), binary: false };
|
||||
let dwg = cadcraft_io::write_with(&d, "lines.dwg", &opts).unwrap();
|
||||
let damaged = (dwg.len() / 4..dwg.len() * 3 / 5)
|
||||
.step_by(97)
|
||||
.map(|at| {
|
||||
let mut b = dwg.clone();
|
||||
b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5);
|
||||
b
|
||||
})
|
||||
.find(|b| cadcraft_io::read(b, "damaged.dwg").is_ok_and(|d| !d.read_notes.is_empty()))
|
||||
.expect("no damaged copy read partially");
|
||||
(dwg, damaged)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_and_convert_warns_about_read_notes() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-read-notes-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (whole, damaged) = dwg_and_damaged_copy();
|
||||
let path = dir.join("damaged.dwg");
|
||||
std::fs::write(&path, &damaged).unwrap();
|
||||
let path = path.to_str().unwrap();
|
||||
|
||||
let out = cli(&["info", path]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
let notes = v["readNotes"].as_array().unwrap();
|
||||
assert_eq!(notes.len(), 1, "{v}");
|
||||
assert!(notes[0].as_str().unwrap().contains(" couldn't be read and "), "{v}");
|
||||
|
||||
let copy = dir.join("copy.dxf");
|
||||
let out = cli(&["convert", path, copy.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let warnings: Vec<&str> = err.lines().filter(|l| l.contains(" couldn't be read and ")).collect();
|
||||
assert_eq!(warnings.len(), 1, "{err}");
|
||||
assert!(warnings[0].starts_with(&format!("{path}: DWG: ")), "{err}");
|
||||
assert!(copy.exists());
|
||||
|
||||
// A complete read: no notes, and no warning.
|
||||
let intact = dir.join("whole.dwg");
|
||||
std::fs::write(&intact, &whole).unwrap();
|
||||
let out = cli(&["info", intact.to_str().unwrap()]);
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["readNotes"], serde_json::json!([]));
|
||||
let out = cli(&["convert", intact.to_str().unwrap(), copy.to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("couldn't be read"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -24,6 +24,9 @@ log = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
# Reads eframe's app.ron to tell whether a window state was saved (main.rs `saved_window`).
|
||||
# Already in Cargo.lock through eframe; same version so nothing is built twice.
|
||||
ron = "0.12"
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
@@ -50,6 +53,9 @@ muda = { version = "0.21", default-features = false }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
|
||||
# (system_animations.rs). Already in Cargo.lock through muda.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
|
||||
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
+69
-14
@@ -86,7 +86,7 @@ impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
|
||||
self.cad.logic(ctx);
|
||||
self.cad.guarded_logic(ctx);
|
||||
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
|
||||
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
|
||||
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
|
||||
@@ -116,7 +116,7 @@ impl eframe::App for App {
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.cad.ui(ui);
|
||||
self.cad.guarded_ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
|
||||
}
|
||||
@@ -127,7 +127,8 @@ impl eframe::App for App {
|
||||
self.cad.cancel_screenshots();
|
||||
self.cad.shutdown_autosave();
|
||||
}
|
||||
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
|
||||
/// Preferences (the interface theme) survive restarts; eframe saves the window state itself
|
||||
/// (`persist_window`); egui state is not kept.
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.cad.profile_json());
|
||||
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json());
|
||||
@@ -194,8 +195,20 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
|
||||
filters
|
||||
}
|
||||
|
||||
/// The main window: its size, title bar, placement and rendering needs.
|
||||
fn window_options() -> eframe::NativeOptions {
|
||||
/// Whether eframe's storage file (`app.ron`, a map of keys to strings) holds a saved main window
|
||||
/// state. An unreadable or malformed file counts as none, as it does for eframe.
|
||||
fn saved_window(app_ron: &str) -> bool {
|
||||
ron::from_str::<std::collections::HashMap<String, String>>(app_ron).is_ok_and(|kv| kv.contains_key(WINDOW_KEY))
|
||||
}
|
||||
|
||||
/// The window state from the last session is saved and will be restored (#2595).
|
||||
fn restores_window() -> bool {
|
||||
eframe::storage_dir(APP_ID).and_then(|dir| std::fs::read_to_string(dir.join("app.ron")).ok()).is_some_and(|ron| saved_window(&ron))
|
||||
}
|
||||
|
||||
/// The main window: its size, title bar, placement and rendering needs. `restoring`: a window state
|
||||
/// saved by the last session is restored instead of opening centred.
|
||||
fn window_options(restoring: bool) -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("CADCraft")
|
||||
@@ -206,10 +219,13 @@ fn window_options() -> eframe::NativeOptions {
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// The window opens centred on its screen, not cascaded from the top-left corner (#1129).
|
||||
// The size, position and maximized state are kept between sessions (#2595); eframe keeps a
|
||||
// restored window on the monitors that are connected now.
|
||||
persist_window: true,
|
||||
// The first launch opens centred on its screen, not cascaded from the top-left corner
|
||||
// (#1129); later launches go back where the window was (eframe would centre over that).
|
||||
// Wayland compositors place windows themselves.
|
||||
centered: true,
|
||||
centered: !restoring,
|
||||
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
|
||||
// system-font discovery as a hidden fallback.
|
||||
system_font_fallback: false,
|
||||
@@ -221,6 +237,9 @@ fn window_options() -> eframe::NativeOptions {
|
||||
|
||||
const APP_ID: &str = "ai.storyteller.cadcraft";
|
||||
|
||||
/// The key eframe saves the main window state under in its storage.
|
||||
const WINDOW_KEY: &str = "window";
|
||||
|
||||
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
|
||||
@@ -239,6 +258,11 @@ fn version_string() -> String {
|
||||
|
||||
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
|
||||
fn main() -> eframe::Result {
|
||||
// A copy of this program started to read or write a DWG file does only that.
|
||||
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
cadcraft_io::dwg_worker::use_own_worker();
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
// The update helper relaunches this binary after a self-update; it runs and exits here.
|
||||
@@ -270,6 +294,9 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
install_io();
|
||||
cadcraft_engine::settings_dir::set(vars.config_dir.clone());
|
||||
if let Some(dir) = &vars.config_dir {
|
||||
cadcraft_ui_egui::fault::install_report_hook(dir.join(cadcraft_ui_egui::fault::REPORT_DIR));
|
||||
}
|
||||
if let Some(name) = &vars.font_alt {
|
||||
cadcraft_engine::fonts::ttf::set_font_alt(name);
|
||||
}
|
||||
@@ -281,7 +308,7 @@ fn main() -> eframe::Result {
|
||||
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
|
||||
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
|
||||
wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface),
|
||||
..window_options()
|
||||
..window_options(restores_window())
|
||||
};
|
||||
if let Some(icon) = app_icon() {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
@@ -310,6 +337,16 @@ fn main() -> eframe::Result {
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// macOS: Quit CADCraft (Cmd+Q), the Dock's Quit and logout end the app through AppKit's
|
||||
// terminate, which would skip the unsaved-changes prompt (#2157). winit turns each into a
|
||||
// close request for the window instead, so it asks first, as the close button does.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::macos::EventLoopBuilderExtMacOS;
|
||||
b.with_close_requested_on_terminate(true);
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"CADCraft",
|
||||
options,
|
||||
@@ -393,7 +430,7 @@ fn main() -> eframe::Result {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{filters_for_save, follow_desktop, window_options};
|
||||
use super::{filters_for_save, follow_desktop, saved_window, window_options};
|
||||
|
||||
#[test]
|
||||
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
|
||||
@@ -411,12 +448,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1129: the window opens centred, not cascaded from the top-left corner.
|
||||
/// #1129: the first launch opens centred, not cascaded from the top-left corner.
|
||||
#[test]
|
||||
fn the_window_opens_centred() {
|
||||
let o = window_options();
|
||||
fn the_first_launch_opens_centred() {
|
||||
let o = window_options(false);
|
||||
assert!(o.centered, "centred on launch");
|
||||
assert!(!o.persist_window, "no saved position to restore instead");
|
||||
assert!(o.persist_window, "the window state is saved for the next launch");
|
||||
}
|
||||
|
||||
/// #2595: a saved window state (size, position, maximized) is restored, not centred over.
|
||||
#[test]
|
||||
fn a_saved_window_reopens_where_it_was() {
|
||||
let o = window_options(true);
|
||||
assert!(!o.centered, "centring would move a restored window");
|
||||
assert!(o.persist_window);
|
||||
}
|
||||
|
||||
/// Only a readable `app.ron` with a window entry counts as a saved window state.
|
||||
#[test]
|
||||
fn a_saved_window_is_read_from_app_ron() {
|
||||
assert!(saved_window(r#"{"cadcraft.prefs": "{}", "window": "(maximized: true)"}"#));
|
||||
assert!(!saved_window(r#"{"cadcraft.prefs": "{\"window\":1}"}"#), "a value mentioning it is not the key");
|
||||
assert!(!saved_window(""));
|
||||
assert!(!saved_window("{\"window\": "), "truncated");
|
||||
assert!(!saved_window("not ron at all"));
|
||||
}
|
||||
|
||||
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
|
||||
|
||||
@@ -99,3 +99,13 @@ sync_github_name = true
|
||||
|
||||
[people.GULERTOLGA]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Tyler-Chiriboga]
|
||||
real_name = "Tyler Chiriboga"
|
||||
sync_github_name = true
|
||||
|
||||
[people.starise]
|
||||
real_name = "Andrea Brandi"
|
||||
|
||||
[people.qinshah]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -11,16 +11,13 @@ use std::collections::HashSet;
|
||||
use cadcraft_doc::{Constraint, Drawing, Handle};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
|
||||
use crate::model::{Model, Prep};
|
||||
use crate::{Comp, MAX_CONSTRAINTS, MAX_EVAL_WORK, SolveError, SolveOptions, build_with, lm};
|
||||
|
||||
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
|
||||
const MAX_NULL_COLS: usize = 600;
|
||||
/// Largest Jacobian (rows × columns × min) whose rank is computed.
|
||||
const MAX_RANK_WORK: f64 = 4e8;
|
||||
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
|
||||
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
|
||||
const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Most objects listed in one report.
|
||||
const MAX_OBJECTS: usize = 10_000;
|
||||
|
||||
@@ -139,8 +136,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
|
||||
/// (constrained or not) and the constraint components they belong to.
|
||||
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let mut members_left = crate::model::MAX_RIGID_WORK;
|
||||
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
|
||||
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
|
||||
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -179,7 +176,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
if seen.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
let m = Model::build_budgeted(d, &[h], &mut members_left);
|
||||
let m = Model::build_with(d, &[h], &mut prep);
|
||||
let Some(s) = m.slots.first() else { continue };
|
||||
seen.insert(h);
|
||||
free_objects = true;
|
||||
@@ -237,5 +234,36 @@ mod budget_tests {
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
// Solving it is refused for the same reason, before any Jacobian is built.
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
}
|
||||
|
||||
/// Many Tangent constraints on one large fit spline rebuild it once while being interpreted,
|
||||
/// and the solve is refused by its rebuild budget instead of running.
|
||||
#[test]
|
||||
fn repeated_spline_tangents_rebuild_once_and_solve_is_bounded() {
|
||||
use cadcraft_doc::{Constraint, Line};
|
||||
use cadcraft_geom::Vec3;
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<_> = (0..400).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(Spline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let l =
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-1.0, 0.0, 0.0), b: Vec3::new(-1.0, 5.0, 0.0) })).unwrap();
|
||||
for id in 1..=200 {
|
||||
d.constraints.push(Constraint {
|
||||
id,
|
||||
kind: ConstraintKind::Tangent,
|
||||
refs: vec![GeomRef::whole(h), GeomRef::whole(l)],
|
||||
name: String::new(),
|
||||
expr: String::new(),
|
||||
});
|
||||
}
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().collect();
|
||||
let (comps, skipped) = crate::build(&d, &cons, &SolveOptions::default()).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let comp = comps.first().unwrap();
|
||||
assert_eq!(comp.built.len(), 200);
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
assert!(crate::purge_invalid(&mut d).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +284,14 @@ impl Env {
|
||||
pub fn new() -> Self {
|
||||
Env::default()
|
||||
}
|
||||
/// Add or replace a named expression. Text longer than [`MAX_LEN`] could never parse, so
|
||||
/// only a bounded prefix of it is kept: it still fails (as too long), without the environment
|
||||
/// holding or copying the whole text.
|
||||
pub fn insert(&mut self, name: &str, expr: &str) {
|
||||
let expr = match expr.char_indices().map(|(i, _)| i).find(|i| *i > MAX_LEN) {
|
||||
Some(end) => expr.get(..end).unwrap_or(expr),
|
||||
None => expr,
|
||||
};
|
||||
self.exprs.insert(name.to_string(), expr.to_string());
|
||||
self.cache.clear();
|
||||
}
|
||||
@@ -309,8 +316,9 @@ impl Env {
|
||||
if stack.len() >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
|
||||
let e = parse(&text)?;
|
||||
// Parse the stored text in place: `parse` refuses an oversized expression before reading
|
||||
// it, so a long imported expression referenced many times is never copied (CWE-400).
|
||||
let e = parse(self.exprs.get(name).ok_or_else(|| ExprError::Unknown(name.to_string()))?)?;
|
||||
stack.push(name.to_string());
|
||||
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
|
||||
stack.pop();
|
||||
@@ -400,6 +408,22 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An oversized named expression is kept only as a bounded prefix and still fails, however
|
||||
/// often it is referenced.
|
||||
#[test]
|
||||
fn oversized_named_expression_is_bounded_and_fails() {
|
||||
let mut env = Env::new();
|
||||
let long = format!("1+{}", "é".repeat(500_000));
|
||||
env.insert("big", &long);
|
||||
assert!(env.exprs.get("big").is_some_and(|t| t.len() > MAX_LEN && t.len() <= MAX_LEN + 4));
|
||||
assert_eq!(env.value("big"), Err(ExprError::TooDeep));
|
||||
for _ in 0..200 {
|
||||
assert_eq!(env.eval("big"), Err(ExprError::TooDeep));
|
||||
}
|
||||
env.insert("ok", "1+1+1");
|
||||
assert_eq!(env.value("ok"), Ok(3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_replaces_whole_names_only() {
|
||||
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
|
||||
|
||||
@@ -30,7 +30,7 @@ mod lm;
|
||||
mod model;
|
||||
mod residual;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
@@ -41,7 +41,7 @@ pub use dof::{DofReport, DofStatus, ObjectDof, dof};
|
||||
pub use expr::{Env, ExprError};
|
||||
pub use model::{ref_points, rigid_points, supported};
|
||||
|
||||
use model::Model;
|
||||
use model::{Model, Prep};
|
||||
use residual::{Built, Form};
|
||||
|
||||
/// Most constraints one drawing may hold.
|
||||
@@ -50,6 +50,11 @@ pub const MAX_CONSTRAINTS: usize = 5000;
|
||||
const MAX_CONFLICT_PROBES: usize = 48;
|
||||
/// Solver iterations.
|
||||
const MAX_ITER: usize = 200;
|
||||
/// Largest residual-evaluation work (including spline rebuilds) one numerical Jacobian may take,
|
||||
/// shared by solving, redundancy checks and DOF analysis.
|
||||
pub(crate) const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps).
|
||||
const TRIAL_EVALS: f64 = 14.0;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
pub enum SolveError {
|
||||
@@ -169,10 +174,26 @@ pub fn name_in_use(d: &Drawing, name: &str) -> bool {
|
||||
d.parametric.parameters.iter().any(|p| p.name == name) || d.constraints.iter().any(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// Highest numbered default name [`next_name`] tries.
|
||||
const MAX_NAME_SUFFIX: u32 = 100_000;
|
||||
|
||||
/// Next free parameter name for a dimensional constraint kind (`d1`, `rad2`, `ang1`, …).
|
||||
pub fn next_name(d: &Drawing, kind: ConstraintKind) -> String {
|
||||
let prefix = kind.name_prefix();
|
||||
(1..=100_000).map(|i| format!("{prefix}{i}")).find(|n| !name_in_use(d, n)).unwrap_or_else(|| format!("{prefix}_x"))
|
||||
// Collect the suffixes in use in one pass, then take the first free one: checking every
|
||||
// candidate against every name is quadratic in the number of names (CWE-407). Only a
|
||||
// suffix spelled as `format!("{prefix}{i}")` spells it counts (`d07` does not take `d7`).
|
||||
let taken: HashSet<u32> = d
|
||||
.parametric
|
||||
.parameters
|
||||
.iter()
|
||||
.map(|p| p.name.as_str())
|
||||
.chain(d.constraints.iter().map(|c| c.name.as_str()))
|
||||
.filter_map(|n| n.strip_prefix(prefix))
|
||||
.filter(|s| !s.starts_with('0') && s.bytes().all(|b| b.is_ascii_digit()))
|
||||
.filter_map(|s| s.parse::<u32>().ok())
|
||||
.collect();
|
||||
(1..=MAX_NAME_SUFFIX).find(|i| !taken.contains(i)).map_or_else(|| format!("{prefix}_x"), |i| format!("{prefix}{i}"))
|
||||
}
|
||||
|
||||
/// Next free constraint id.
|
||||
@@ -251,12 +272,41 @@ impl Comp {
|
||||
pub(crate) fn rows(&self, skip: Option<usize>) -> usize {
|
||||
self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum()
|
||||
}
|
||||
/// Work of one solver iteration's residual evaluations over `free`: every residual twice per
|
||||
/// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds
|
||||
/// are shared by residuals at the same points, see [`Model`]).
|
||||
pub(crate) fn eval_work(&self, free: &[usize], skip: Option<usize>) -> f64 {
|
||||
let rows = self.rows(skip) as f64;
|
||||
let evals = 2.0 * free.len() as f64 + TRIAL_EVALS;
|
||||
let mut splines: Vec<Handle> = Vec::new();
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) != skip && b.spline_reconstruct_cost(&self.model) > 0.0 {
|
||||
splines.extend(b.refs.iter().map(|r| r.handle).filter(|h| self.model.spline_reconstruct_cost(*h) > 0.0));
|
||||
}
|
||||
}
|
||||
splines.sort_unstable();
|
||||
splines.dedup();
|
||||
let rebuilds: f64 = splines
|
||||
.iter()
|
||||
.filter_map(|h| self.model.slot(*h))
|
||||
.map(|s| {
|
||||
let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64;
|
||||
(2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle)
|
||||
})
|
||||
.sum();
|
||||
evals * rows + rebuilds
|
||||
}
|
||||
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
|
||||
let free = self.free(skip);
|
||||
let rows = self.rows(skip);
|
||||
if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
// Numerical differentiation rebuilds a fit spline through a dense solve for every move of
|
||||
// its own fit points: refuse before iterating when that alone is too much (CWE-407).
|
||||
if self.eval_work(&free, skip) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
|
||||
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
|
||||
// A solution the ellipses would be stored differently from (axes swapped, minor axis
|
||||
@@ -410,24 +460,24 @@ fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
|
||||
|
||||
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
|
||||
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut members_left = model::MAX_RIGID_WORK;
|
||||
build_budgeted(d, cons, opts, &mut members_left)
|
||||
build_with(d, cons, opts, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
|
||||
/// Build constraint components sharing one caller-owned geometry preparation (block sampling and
|
||||
/// its member budget, spline rebuilds) between every model.
|
||||
/// Return constraints that cannot be interpreted as skipped entries.
|
||||
pub(crate) fn build_budgeted(
|
||||
pub(crate) fn build_with(
|
||||
d: &Drawing,
|
||||
cons: &[&Constraint],
|
||||
opts: &SolveOptions,
|
||||
members_left: &mut usize,
|
||||
prep: &mut Prep,
|
||||
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut e = env(d);
|
||||
let mut skipped = Vec::new();
|
||||
let mut comps = Vec::new();
|
||||
for group in components(cons) {
|
||||
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
let mut model = Model::build_budgeted(d, &handles, members_left);
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
let mut built = Vec::new();
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
@@ -640,6 +690,11 @@ pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
|
||||
/// Check that a constraint's references make sense for its type.
|
||||
pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
|
||||
validate_with(d, c, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// [`validate`] sharing block samples and spline rebuilds with other checks of the same drawing.
|
||||
fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), String> {
|
||||
let handles: Vec<Handle> = c.refs.iter().map(|r| r.handle).collect();
|
||||
for h in &handles {
|
||||
match d.entity(*h) {
|
||||
@@ -648,7 +703,7 @@ pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut model = Model::build(d, &handles);
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
interpret(c, &mut model, 1.0).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -665,7 +720,12 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result<f6
|
||||
/// Returns the removed ids.
|
||||
pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
|
||||
let mut gone = Vec::new();
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect();
|
||||
// One preparation for every check: each block is sampled and each spline rebuilt once,
|
||||
// however many constraints refer to them (CWE-407). Its member budget is not shared, so
|
||||
// a constraint is never dropped because earlier ones spent it: every block is still
|
||||
// examined at most once, up to its own member limit.
|
||||
let mut prep = Prep::new(usize::MAX);
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate_with(d, c, &mut prep).is_ok()).collect();
|
||||
let mut i = 0;
|
||||
d.constraints.retain(|c| {
|
||||
let k = keep.get(i).copied().unwrap_or(true);
|
||||
@@ -752,6 +812,9 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
&& comp.built.get(pos).is_some_and(|b| b.form == Form::Lock)
|
||||
{
|
||||
let before = comp.free(Some(pos));
|
||||
if comp.eval_work(&before, Some(pos)) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let after = comp.free(None);
|
||||
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
|
||||
let rows_old = comp.rows(Some(pos));
|
||||
|
||||
+193
-62
@@ -14,9 +14,11 @@
|
||||
//! Constraints may append auxiliary unknowns after the entity parameters (contact parameters of
|
||||
//! a tangency between curves); they are never written back.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
|
||||
use cadcraft_doc::{Block, Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
|
||||
use cadcraft_geom::{Spline as GSpline, Vec2, ccw_sweep};
|
||||
|
||||
use crate::curves::{Ell, Frame, clamped, spline_end};
|
||||
@@ -70,6 +72,9 @@ pub(crate) struct Slot {
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
/// The spline last rebuilt for each handle, with the defining points it was rebuilt from.
|
||||
type SplineCache = HashMap<Handle, (Vec<Vec2>, Rc<GSpline>)>;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Model {
|
||||
pub slots: Vec<Slot>,
|
||||
@@ -79,20 +84,75 @@ pub(crate) struct Model {
|
||||
splines: HashMap<Handle, GSpline>,
|
||||
/// Block reference rigid points relative to the insertion point at rotation 0.
|
||||
rigid: HashMap<Handle, Vec<Vec2>>,
|
||||
/// Rebuilt splines, shared with every model of the same [`Prep`]: residuals that ask for the
|
||||
/// same spline at the same defining points (several constraints on one spline, or derivatives
|
||||
/// taken along other objects' parameters) reuse one rebuild instead of a dense solve each.
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
/// Geometry preparation shared by every model built for one operation (a solve, a DOF report, a
|
||||
/// purge): rigid samples per block, a case-insensitive block-name index and rebuilt splines. A
|
||||
/// block referenced by many Inserts is sampled once, an unknown block name costs one hashed
|
||||
/// lookup, and a spline referenced by many constraints is rebuilt once (CWE-407).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Prep {
|
||||
/// Block members that may still be examined across the operation.
|
||||
members_left: usize,
|
||||
/// Block-local rigid samples by the name an Insert gives (`None`: no such block).
|
||||
blocks: HashMap<String, Option<Rc<[Vec2]>>>,
|
||||
/// Lower-cased block name to the stored name it resolves to, built on the first exact miss.
|
||||
folded: Option<HashMap<String, String>>,
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
impl Prep {
|
||||
/// Preparation that may examine at most `members` block members in all.
|
||||
pub(crate) fn new(members: usize) -> Prep {
|
||||
Prep { members_left: members, blocks: HashMap::new(), folded: None, rebuilt: Rc::default() }
|
||||
}
|
||||
|
||||
/// The block an Insert names, as [`Drawing::block`] resolves it (exact name, else the first
|
||||
/// stored name equal ignoring ASCII case), without scanning every block on each miss.
|
||||
fn resolve<'d>(&mut self, d: &'d Drawing, name: &str) -> Option<&'d Block> {
|
||||
if let Some(b) = d.blocks.get(name) {
|
||||
return Some(b);
|
||||
}
|
||||
let folded = self.folded.get_or_insert_with(|| {
|
||||
let mut m = HashMap::with_capacity(d.blocks.len());
|
||||
for k in d.blocks.keys() {
|
||||
m.entry(k.to_ascii_lowercase()).or_insert_with(|| k.clone());
|
||||
}
|
||||
m
|
||||
});
|
||||
d.blocks.get(folded.get(&name.to_ascii_lowercase())?).map(|b| &**b)
|
||||
}
|
||||
|
||||
/// Finite characteristic points of a block in its own coordinates, sampled once per name.
|
||||
fn block_points(&mut self, d: &Drawing, name: &str) -> Option<Rc<[Vec2]>> {
|
||||
if let Some(s) = self.blocks.get(name) {
|
||||
return s.clone();
|
||||
}
|
||||
let sampled = self.resolve(d, name).map(|b| sample_block(b, &mut self.members_left));
|
||||
self.blocks.insert(name.to_string(), sampled.clone());
|
||||
sampled
|
||||
}
|
||||
|
||||
/// World positions of an Insert's rigid points, at most [`MAX_RIGID_POINTS`].
|
||||
fn rigid_points(&mut self, d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let Some(local) = self.block_points(d, &ins.block) else { return Vec::new() };
|
||||
let Some(block) = self.resolve(d, &ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(block.base.xy());
|
||||
local.iter().map(|p| m.apply(*p)).filter(|p| p.is_finite()).take(MAX_RIGID_POINTS).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
|
||||
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
|
||||
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
|
||||
let Some(s) = self.splines.get(&handle) else { return 0.0 };
|
||||
let n = s.fit.len() as f64;
|
||||
if fit_mode(s) {
|
||||
n * n * n
|
||||
} else {
|
||||
let n = s.control.len() as f64;
|
||||
n * n
|
||||
}
|
||||
let Some(Shape::Spline { n, fit, .. }) = self.slot(handle).map(|s| s.shape) else { return 0.0 };
|
||||
let n = n as f64;
|
||||
if fit { n * n * n } else { n * n }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,23 +175,18 @@ pub fn supported(kind: &EntityKind) -> bool {
|
||||
/// centres, vertices, nested insertion points) of the block's own objects, at most
|
||||
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
|
||||
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let mut members_left = MAX_RIGID_MEMBERS;
|
||||
rigid_points_budgeted(d, ins, &mut members_left)
|
||||
Prep::new(MAX_RIGID_MEMBERS).rigid_points(d, ins)
|
||||
}
|
||||
|
||||
/// Sample finite block points within per-insert and caller-shared member limits.
|
||||
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
|
||||
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
|
||||
let Some(block) = d.block(&ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(block.base.xy());
|
||||
/// Sample a block's finite characteristic points in block coordinates, within the per-block
|
||||
/// member limit and the caller-shared `members_left`. Members that contribute no points still
|
||||
/// consume traversal work; nested blocks are not entered.
|
||||
fn sample_block(block: &Block, members_left: &mut usize) -> Rc<[Vec2]> {
|
||||
let mut out = Vec::new();
|
||||
// Bound the members examined, not only the points collected: entities that contribute no
|
||||
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
|
||||
// every Insert — would otherwise be unbounded (CWE-407).
|
||||
let mut examined = 0usize;
|
||||
for e in block.entities.iter() {
|
||||
examined += 1;
|
||||
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
|
||||
// points (Text, Ray, …) still cost traversal (CWE-407).
|
||||
for (examined, e) in block.entities.iter().enumerate() {
|
||||
if out.len() >= MAX_RIGID_POINTS || examined >= MAX_RIGID_MEMBERS || *members_left == 0 {
|
||||
break;
|
||||
}
|
||||
*members_left -= 1;
|
||||
@@ -148,10 +203,10 @@ fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) ->
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().take(MAX_RIGID_POINTS).map(|v| v.p).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
out.extend(pts.into_iter().map(|p| m.apply(p)).filter(|p| p.is_finite()));
|
||||
out.extend(pts.into_iter().filter(|p| p.is_finite()));
|
||||
}
|
||||
out.truncate(MAX_RIGID_POINTS);
|
||||
out
|
||||
out.into()
|
||||
}
|
||||
|
||||
/// Constraint points of an object as the solver sees them (`Sub`, position): end points,
|
||||
@@ -187,13 +242,12 @@ fn fit_mode(s: &GSpline) -> bool {
|
||||
impl Model {
|
||||
/// Build a model for the given entities. Unsupported or missing entities are skipped.
|
||||
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
|
||||
let mut members_left = MAX_RIGID_WORK;
|
||||
Self::build_budgeted(d, handles, &mut members_left)
|
||||
Self::build_with(d, handles, &mut Prep::new(MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
|
||||
/// left out (too large, degenerate or unsupported).
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option<Shape> {
|
||||
let shape = match kind {
|
||||
EntityKind::Line(l) => {
|
||||
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
|
||||
@@ -243,15 +297,22 @@ impl Model {
|
||||
// fit point; a control polygon's ends are its first and last points when the
|
||||
// knots are clamped.
|
||||
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
|
||||
// Missing weights default to 1 in eval; only excess weights are unused.
|
||||
let mut kept = sp.clone();
|
||||
// Keep only what rebuilding reads, so each rebuild copies no unused data: missing
|
||||
// weights default to 1 in eval and only excess weights are unused; a control
|
||||
// spline's fit points are dropped when its control points move (write_back), and
|
||||
// a fit spline is rebuilt from its parameters, not from its old control net.
|
||||
let mut kept = if fit {
|
||||
GSpline { fit_opts: sp.fit_opts, closed: sp.closed, ..GSpline::default() }
|
||||
} else {
|
||||
GSpline { fit: Vec::new(), ..sp.clone() }
|
||||
};
|
||||
kept.weights.truncate(kept.control.len());
|
||||
m.splines.insert(h, kept);
|
||||
Shape::Spline { n, fit, ends }
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
||||
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
let pts: Vec<Vec2> = prep.rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||
let n = pts.len();
|
||||
m.rigid.insert(h, pts);
|
||||
@@ -262,16 +323,17 @@ impl Model {
|
||||
Some(shape)
|
||||
}
|
||||
|
||||
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
|
||||
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
|
||||
let mut m = Model::default();
|
||||
/// Build solver parameters for supported entities, sharing block sampling, its member budget
|
||||
/// and spline rebuilds with every other model built from `prep`.
|
||||
pub(crate) fn build_with(d: &Drawing, handles: &[Handle], prep: &mut Prep) -> Model {
|
||||
let mut m = Model { rebuilt: Rc::clone(&prep.rebuilt), ..Model::default() };
|
||||
for &h in handles {
|
||||
if m.index.contains_key(&h) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let off = m.x0.len();
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, prep) else { continue };
|
||||
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
|
||||
m.x0.truncate(off);
|
||||
continue;
|
||||
@@ -373,18 +435,20 @@ impl Model {
|
||||
self.slot(r.handle).is_some_and(|s| matches!(s.shape, Shape::Spline { .. }))
|
||||
}
|
||||
|
||||
/// The spline of a reference with the defining points taken from `x`.
|
||||
fn spline(&self, x: &[f64], s: &Slot) -> Option<GSpline> {
|
||||
/// The spline of a reference with the defining points taken from `x`. The last rebuild of
|
||||
/// each spline is kept, so asking again at the same points costs a comparison, not a rebuild.
|
||||
fn spline(&self, x: &[f64], s: &Slot) -> Option<Rc<GSpline>> {
|
||||
let Shape::Spline { n, fit, .. } = s.shape else { return None };
|
||||
let t = self.splines.get(&s.handle)?;
|
||||
let pts = (0..n).map(|i| p2(x, s.off + 2 * i)).collect::<Option<Vec<_>>>()?;
|
||||
Some(if fit {
|
||||
t.refit(&pts, t.closed)
|
||||
} else {
|
||||
let mut sp = t.clone();
|
||||
sp.control = pts;
|
||||
sp
|
||||
})
|
||||
if let Some((at, sp)) = self.rebuilt.borrow().get(&s.handle)
|
||||
&& *at == pts
|
||||
{
|
||||
return Some(Rc::clone(sp));
|
||||
}
|
||||
let sp = Rc::new(if fit { t.refit(&pts, t.closed) } else { GSpline { control: pts.clone(), ..t.clone() } });
|
||||
self.rebuilt.borrow_mut().insert(s.handle, (pts, Rc::clone(&sp)));
|
||||
Some(sp)
|
||||
}
|
||||
|
||||
/// The start (`at_end` false) or end of a curve reference, in the curve's own direction:
|
||||
@@ -421,7 +485,7 @@ impl Model {
|
||||
let e = self.ell_at(x, o, true)?;
|
||||
e.frame(if at_end { e.t1 } else { e.t0 })
|
||||
}
|
||||
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&self.spline(x, s)?, at_end),
|
||||
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&*self.spline(x, s)?, at_end),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -590,7 +654,8 @@ impl Model {
|
||||
}
|
||||
let shape = s.shape;
|
||||
// A control-point spline loses its fit data once its control points move.
|
||||
let spline = self.spline(x, s).map(|mut sp| {
|
||||
let spline = self.spline(x, s).map(|sp| {
|
||||
let mut sp = GSpline::clone(&sp);
|
||||
if matches!(shape, Shape::Spline { fit: false, .. }) {
|
||||
sp.fit.clear();
|
||||
}
|
||||
@@ -712,21 +777,21 @@ mod budget_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A second model receives no sampled block points after the first model spends the shared member budget.
|
||||
#[test]
|
||||
fn rigid_sampling_budget_is_shared_between_models() {
|
||||
let mut d = Drawing::default();
|
||||
let mut block = Block::new("BAR");
|
||||
for _ in 0..3 {
|
||||
fn add_block(d: &mut Drawing, name: &str, lines: usize) {
|
||||
let mut block = Block::new(name);
|
||||
for _ in 0..lines {
|
||||
block.entities.push(Entity {
|
||||
handle: d.new_handle(),
|
||||
common: Common::default(),
|
||||
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
|
||||
});
|
||||
}
|
||||
d.blocks.insert("BAR".into(), Arc::new(block));
|
||||
let ins = Insert {
|
||||
block: "BAR".into(),
|
||||
d.blocks.insert(name.into(), Arc::new(block));
|
||||
}
|
||||
|
||||
fn insert(block: &str) -> Insert {
|
||||
Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
@@ -735,13 +800,79 @@ mod budget_tests {
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
|
||||
let mut budget = 2;
|
||||
let first = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert_eq!(first.rigid[&h].len(), 4);
|
||||
assert_eq!(budget, 0);
|
||||
let next = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert!(next.rigid[&h].is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn add_insert(d: &mut Drawing, block: &str) -> Handle {
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert(block))).unwrap()
|
||||
}
|
||||
|
||||
/// The member budget is shared by every model of one preparation, and a block it already
|
||||
/// sampled is reused by later Inserts without examining its members again.
|
||||
#[test]
|
||||
fn rigid_sampling_budget_is_shared_and_blocks_sampled_once() {
|
||||
let mut d = Drawing::default();
|
||||
add_block(&mut d, "BAR", 3);
|
||||
add_block(&mut d, "BAZ", 3);
|
||||
let (bar, baz, bar2) = (add_insert(&mut d, "BAR"), add_insert(&mut d, "BAZ"), add_insert(&mut d, "BAR"));
|
||||
let mut prep = Prep::new(2);
|
||||
let first = Model::build_with(&d, &[bar], &mut prep);
|
||||
assert_eq!(first.rigid[&bar].len(), 4);
|
||||
assert_eq!(prep.members_left, 0);
|
||||
let other = Model::build_with(&d, &[baz], &mut prep);
|
||||
assert!(other.rigid[&baz].is_empty());
|
||||
let again = Model::build_with(&d, &[bar2], &mut prep);
|
||||
assert_eq!(again.rigid[&bar2].len(), 4);
|
||||
}
|
||||
|
||||
/// Block names resolve ignoring ASCII case, as `Drawing::block` does, through one index.
|
||||
#[test]
|
||||
fn block_names_resolve_ignoring_case() {
|
||||
let mut d = Drawing::default();
|
||||
add_block(&mut d, "Bar", 2);
|
||||
let (lower, missing) = (add_insert(&mut d, "bAR"), add_insert(&mut d, "nope"));
|
||||
let mut prep = Prep::new(MAX_RIGID_WORK);
|
||||
let m = Model::build_with(&d, &[lower, missing], &mut prep);
|
||||
assert_eq!(m.rigid[&lower].len(), 4);
|
||||
assert!(m.rigid[&missing].is_empty());
|
||||
assert!(prep.folded.as_ref().is_some_and(|f| f.len() == 1));
|
||||
assert_eq!(rigid_points(&d, &insert("BAR")).len(), 4);
|
||||
}
|
||||
|
||||
/// A spline asked for again at the same defining points is not rebuilt, also from another
|
||||
/// model of the same preparation; moved points rebuild it.
|
||||
#[test]
|
||||
fn spline_rebuilds_are_reused_at_the_same_points() {
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<Vec2> = (0..6).map(|i| Vec2::new(i as f64, (i % 2) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(GSpline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let mut prep = Prep::new(MAX_RIGID_WORK);
|
||||
let a = Model::build_with(&d, &[h], &mut prep);
|
||||
let b = Model::build_with(&d, &[h], &mut prep);
|
||||
let (sa, sb) = (a.slot(h).unwrap(), b.slot(h).unwrap());
|
||||
assert!(matches!(sa.shape, Shape::Spline { fit: true, .. }));
|
||||
let first = a.spline(&a.x0, sa).unwrap();
|
||||
assert!(Rc::ptr_eq(&first, &a.spline(&a.x0, sa).unwrap()));
|
||||
assert!(Rc::ptr_eq(&first, &b.spline(&b.x0, sb).unwrap()));
|
||||
let mut moved = a.x0.clone();
|
||||
moved[1] += 0.5;
|
||||
let other = a.spline(&moved, sa).unwrap();
|
||||
assert!(!Rc::ptr_eq(&first, &other));
|
||||
assert_eq!(other.fit[0], Vec2::new(0.0, 0.5));
|
||||
}
|
||||
|
||||
/// A control spline's template keeps no fit points and no excess weights.
|
||||
#[test]
|
||||
fn control_template_drops_unused_data() {
|
||||
let mut d = Drawing::default();
|
||||
let mut sp = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)], 3);
|
||||
sp.fit = vec![Vec2::ZERO; 10_000];
|
||||
sp.fit_opts.tolerance = 0.5;
|
||||
sp.weights = vec![1.0; 10_000];
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap();
|
||||
let m = Model::build(&d, &[h]);
|
||||
let t = &m.splines[&h];
|
||||
assert!(t.fit.is_empty());
|
||||
assert_eq!(t.weights.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,3 +489,30 @@ fn arc_solved_with_a_negative_radius_keeps_its_points() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The default name is the first free numbered one, found in one pass over many names in use.
|
||||
#[test]
|
||||
fn next_name_skips_names_in_use() {
|
||||
let mut d = Drawing::default();
|
||||
assert_eq!(next_name(&d, ConstraintKind::Radius), "rad1");
|
||||
for i in (1..=20_000).filter(|i| *i != 15_000) {
|
||||
d.parametric.parameters.push(cadcraft_doc::Parameter { name: format!("d{i}"), expr: "1".into(), description: String::new() });
|
||||
}
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d15000");
|
||||
d.parametric.parameters.push(cadcraft_doc::Parameter { name: "d15000".into(), expr: "1".into(), description: String::new() });
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d20001");
|
||||
// A differently spelled suffix does not occupy the plain name.
|
||||
let mut d = Drawing::default();
|
||||
for name in ["d01", "d+1", "d1x", "D1"] {
|
||||
d.parametric.parameters.push(cadcraft_doc::Parameter { name: name.into(), expr: "1".into(), description: String::new() });
|
||||
}
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d1");
|
||||
d.constraints.push(Constraint {
|
||||
id: 1,
|
||||
kind: ConstraintKind::Distance(DistAxis::Aligned),
|
||||
refs: Vec::new(),
|
||||
name: "d1".into(),
|
||||
expr: "1".into(),
|
||||
});
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2");
|
||||
}
|
||||
|
||||
@@ -194,7 +194,9 @@ pub struct Action {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum ActionKind {
|
||||
/// Moves the vertices inside `frame` of the objects listed (all of an object not listed).
|
||||
/// Moves the points of its objects inside `frame`: a line's or polyline's points inside it
|
||||
/// (the whole of one outside it), any other object whole. `vertices` are the point indices the
|
||||
/// file lists for each object, used only when the frame can't be tested (see `apply`).
|
||||
Stretch {
|
||||
frame: Vec<Vec2>,
|
||||
#[serde(serialize_with = "hex_vertices")]
|
||||
@@ -321,8 +323,9 @@ impl<'a> Cursor<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
|
||||
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
|
||||
/// `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph object, when the dictionary has one. Only the
|
||||
/// dictionary's own tags are read, so asking whether a block is dynamic never walks the graph.
|
||||
fn evaluation_graph<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<&'a ForeignObject> {
|
||||
let dict = objects.get(&xdict)?;
|
||||
let mut graph = None;
|
||||
let mut tags = dict.tags.iter();
|
||||
@@ -332,7 +335,12 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
|
||||
break;
|
||||
}
|
||||
}
|
||||
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
|
||||
objects.get(&graph?).copied().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))
|
||||
}
|
||||
|
||||
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
|
||||
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
|
||||
let graph = evaluation_graph(objects, xdict)?;
|
||||
let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
|
||||
// Deduplicate member handles before resolving them: a graph can list the same member many
|
||||
// times, and parsing each occurrence into its own owned parameter multiplies a single large
|
||||
@@ -341,13 +349,42 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
|
||||
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
|
||||
}
|
||||
|
||||
/// The kept foreign objects of a drawing by handle. Building it walks every object, so a caller
|
||||
/// that asks about many blocks (listing them) builds it once and passes it to
|
||||
/// [`dynamic_block_in`] and [`is_dynamic_block_in`] instead of rebuilding it per block, which
|
||||
/// multiplies the block count by the object count (CWE-407).
|
||||
pub struct ObjectIndex<'a>(HashMap<Handle, &'a ForeignObject>);
|
||||
|
||||
impl<'a> ObjectIndex<'a> {
|
||||
pub fn new(d: &'a Drawing) -> Self {
|
||||
ObjectIndex(d.foreign.objects.iter().map(|o| (o.handle, o)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension dictionary of block `name`'s record, if it has one.
|
||||
fn block_xdict(d: &Drawing, name: &str) -> Option<Handle> {
|
||||
d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict
|
||||
}
|
||||
|
||||
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
|
||||
/// block's own drawing), or `None` when it has none.
|
||||
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
|
||||
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
|
||||
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
|
||||
// A block without an extension dictionary is never dynamic: answer before indexing objects.
|
||||
block_xdict(d, name)?;
|
||||
dynamic_block_in(d, &ObjectIndex::new(d), name)
|
||||
}
|
||||
|
||||
/// Whether block `name` is a dynamic block (has an evaluation graph), without reading its
|
||||
/// parameters and actions; `objects` is the drawing's [`ObjectIndex`].
|
||||
pub fn is_dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> bool {
|
||||
block_xdict(d, name).and_then(|x| evaluation_graph(&objects.0, x)).is_some()
|
||||
}
|
||||
|
||||
/// [`dynamic_block`] with the drawing's [`ObjectIndex`] already built.
|
||||
pub fn dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> Option<DynamicBlock> {
|
||||
let xdict = block_xdict(d, name)?;
|
||||
let mut out = DynamicBlock::default();
|
||||
for o in graph_members(&objects, xdict)? {
|
||||
for o in graph_members(&objects.0, xdict)? {
|
||||
let secs = sections(&o.tags);
|
||||
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
|
||||
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
|
||||
|
||||
@@ -54,7 +54,11 @@ fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
|
||||
|
||||
/// The object entry `key` of dictionary `dict` refers to.
|
||||
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
|
||||
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
|
||||
dictionary_entry(d.foreign.objects.iter().find(|o| o.handle == dict)?, key)
|
||||
}
|
||||
|
||||
/// The object entry `key` (any case) of dictionary object `o` names.
|
||||
fn dictionary_entry(o: &ForeignObject, key: &str) -> Option<Handle> {
|
||||
let mut tags = o.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
|
||||
@@ -66,7 +70,11 @@ fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
|
||||
|
||||
/// Every entry of dictionary `dict`: (key, object).
|
||||
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
|
||||
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
|
||||
d.foreign.objects.iter().find(|o| o.handle == dict).map(dictionary_entries).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every entry of dictionary object `o`: (key, object).
|
||||
fn dictionary_entries(o: &ForeignObject) -> Vec<(String, Handle)> {
|
||||
let mut out = Vec::new();
|
||||
let mut key: Option<&str> = None;
|
||||
for t in &o.tags {
|
||||
@@ -403,7 +411,31 @@ fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon).
|
||||
/// Most points of a stretch frame whose objects' points are tested against it one by one. A frame
|
||||
/// is two corners or a short polygon; testing every point of every object against every edge of a
|
||||
/// longer one would grow with their product, so the action's own vertex lists are used instead.
|
||||
const MAX_FRAME_POINTS: usize = 1000;
|
||||
|
||||
/// The indices of `points` inside stretch frame `frame`, or `None` when none is: an object the
|
||||
/// action selects that lies outside its frame moves whole.
|
||||
fn inside(frame: &[Vec2], points: impl Iterator<Item = Vec2>) -> Option<Vec<i32>> {
|
||||
let idx: Vec<i32> = points.enumerate().filter(|(_, p)| in_frame(frame, *p)).filter_map(|(i, _)| i32::try_from(i).ok()).collect();
|
||||
(!idx.is_empty()).then_some(idx)
|
||||
}
|
||||
|
||||
/// The distance from `p` to the segment `a`-`b`, however short (`Line::dist` takes one shorter than
|
||||
/// `EPS` for its first point). It is measured from `a`, so a short segment far from the origin keeps
|
||||
/// the digits rounding there would drop.
|
||||
fn segment_dist(a: Vec2, b: Vec2, p: Vec2) -> f64 {
|
||||
let d = b - a;
|
||||
let w = p - a;
|
||||
let l2 = d.len2();
|
||||
let t = if l2 > 0.0 { (w.dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(w - d * t).len()
|
||||
}
|
||||
|
||||
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon). A point on its edge is
|
||||
/// inside, for a polygon as for two corners: within a billionth of the edge's length of it.
|
||||
fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
|
||||
match frame {
|
||||
[a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y),
|
||||
@@ -412,6 +444,9 @@ fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
|
||||
let mut j = f.len() - 1;
|
||||
for i in 0..f.len() {
|
||||
let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break };
|
||||
if segment_dist(*pj, *pi, p) <= 1e-9 * pi.dist(*pj) {
|
||||
return true;
|
||||
}
|
||||
if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x {
|
||||
inside = !inside;
|
||||
}
|
||||
@@ -464,7 +499,10 @@ fn apply(
|
||||
_ => 0.0,
|
||||
})
|
||||
};
|
||||
let mult = if mult == 0.0 { 1.0 } else { mult };
|
||||
// A zero multiplier would move nothing. AutoCAD 2024 moves such an action by the
|
||||
// parameter's change and leaves its angle offset out (measured on a stretch action written
|
||||
// with multiplier 0, angle offset 1 and group 280 = 1).
|
||||
let (mult, angle) = if mult == 0.0 { (1.0, 0.0) } else { (mult, angle) };
|
||||
let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult;
|
||||
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
|
||||
continue;
|
||||
@@ -473,6 +511,8 @@ fn apply(
|
||||
// this action's selection, so a selection that repeats a handle does not re-scan the
|
||||
// vertex list and re-clone its index vector per occurrence (CWE-400).
|
||||
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
|
||||
// Whether the frame (two corners or a polygon) is tested point by point.
|
||||
let framed = (2..=MAX_FRAME_POINTS).contains(&frame.len());
|
||||
let mut handled: HashSet<Handle> = HashSet::new();
|
||||
for h in &a.selection {
|
||||
if !handled.insert(*h) {
|
||||
@@ -502,7 +542,18 @@ fn apply(
|
||||
continue;
|
||||
}
|
||||
let Some(c) = copy_of(h) else { continue };
|
||||
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
|
||||
// The points of a line or polyline that move are those inside the frame where the
|
||||
// dynamic block has them, all of them when none is, as AutoCAD 2024 moves them. It
|
||||
// doesn't go by the vertex lists the action stores, which a block from another program
|
||||
// can get wrong. A polyline's vertices are in its own plane: in the block's when its
|
||||
// extrusion is exactly along z (a mirrored one is read flipped into it). Any tilt, however
|
||||
// small, moves them by as much as the elevation times the tilt, so a tilted one keeps the
|
||||
// stored list.
|
||||
let idx = match def.entities.get(*h).map(|e| (&e.kind, e.common.extrusion)) {
|
||||
Some((EntityKind::Line(l), _)) if framed => inside(frame, [l.a.xy(), l.b.xy()].into_iter()),
|
||||
Some((EntityKind::LwPolyline(pl), n)) if framed && n.x == 0.0 && n.y == 0.0 => inside(frame, pl.vertices.iter().map(|v| v.p)),
|
||||
_ => vtx_by_handle.get(h).map(|i| (*i).clone()),
|
||||
};
|
||||
moves.entry(c).or_default().push((idx, v));
|
||||
}
|
||||
}
|
||||
@@ -649,19 +700,33 @@ fn refill_hatches(b: &mut Block) {
|
||||
|
||||
/// The value records of the references to the dynamic block whose record is `def_record` (from
|
||||
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
|
||||
///
|
||||
/// Each record is copied once at most: a key naming a record an earlier key already took is
|
||||
/// skipped. Records are copied per key (and copied again per key when a new reference's data is
|
||||
/// made), so a file listing one large record under many keys would otherwise multiply its size by
|
||||
/// the key count (CWE-400); AutoCAD gives every key its own record.
|
||||
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
|
||||
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
|
||||
// Dictionary entries looked up through the index, not by scanning every object per
|
||||
// dictionary (which is quadratic in the object count).
|
||||
let entry = |dict: Handle, key: &str| objects.get(&dict).and_then(|o| dictionary_entry(o, key));
|
||||
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
|
||||
let mut taken: HashSet<Handle> = HashSet::new();
|
||||
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
|
||||
// A representation dictionary whose data names this dynamic block.
|
||||
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
|
||||
let Some(rd) = dictionary_entry(rep, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
|
||||
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
|
||||
continue;
|
||||
}
|
||||
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
|
||||
for (k, h) in entries(d, values) {
|
||||
let Some(values) = dictionary_entry(rep, "AppDataCache").and_then(|c| entry(c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
|
||||
let Some(values) = objects.get(&values) else { continue };
|
||||
for (k, h) in dictionary_entries(values) {
|
||||
if out.contains_key(&k) || taken.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
|
||||
out.entry(k).or_insert_with(|| x.tags.clone());
|
||||
taken.insert(h);
|
||||
out.insert(k, x.tags.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1001,4 +1066,23 @@ mod tests {
|
||||
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
|
||||
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_record_listed_under_many_keys_is_copied_once() {
|
||||
// One large XRECORD named by many keys of a reference's value dictionary: the templates
|
||||
// keep one copy, not one per key.
|
||||
let mut d = Drawing::new_imperial();
|
||||
let (def_record, rep, rd, cache, values, record) = (Handle(0x10), Handle(0x20), Handle(0x21), Handle(0x22), Handle(0x23), Handle(0x24));
|
||||
let keys: Vec<String> = (0..1000).map(|i| i.to_string()).collect();
|
||||
let entries: Vec<(&str, Handle)> = keys.iter().map(|k| (k.as_str(), record)).collect();
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
f.objects.push(dictionary(rep, Handle(1), &[("AcDbRepData", rd), ("AppDataCache", cache)]));
|
||||
f.objects.push(ForeignObject { handle: rd, kind: "ACDB_BLOCKREPRESENTATION_DATA".into(), tags: vec![tag(340, def_record.hex())] });
|
||||
f.objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", values)]));
|
||||
f.objects.push(dictionary(values, cache, &entries));
|
||||
f.objects.push(ForeignObject { handle: record, kind: "XRECORD".into(), tags: (0..1000).map(|_| tag(1, "x")).collect() });
|
||||
let t = templates(&d, def_record);
|
||||
assert_eq!(t.len(), 1, "one record, one template");
|
||||
assert_eq!(t.get("0").map(Vec::len), Some(1000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ mod wipeout;
|
||||
|
||||
pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal};
|
||||
pub use common::{Common, Handle, Lineweight, RawTag, Transparency};
|
||||
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid};
|
||||
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, PolyfaceMesh, Polyline3d, RayLine, Solid};
|
||||
pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader};
|
||||
pub use fill::{Gradient, Hatch, HatchLoop};
|
||||
pub use layout::{Image, Table, TableCell, Viewport};
|
||||
@@ -86,6 +86,7 @@ pub enum EntityKind {
|
||||
Solid(Solid),
|
||||
Trace(Solid),
|
||||
Face3d(Face3d),
|
||||
PolyfaceMesh(PolyfaceMesh),
|
||||
Region(Region),
|
||||
Viewport(Viewport),
|
||||
Image(Image),
|
||||
@@ -140,6 +141,7 @@ impl EntityKind {
|
||||
EntityKind::Solid(_) => "2D Solid",
|
||||
EntityKind::Trace(_) => "Trace",
|
||||
EntityKind::Face3d(_) => "3D Face",
|
||||
EntityKind::PolyfaceMesh(_) => "Polyface Mesh",
|
||||
EntityKind::Region(_) => "Region",
|
||||
EntityKind::Viewport(_) => "Viewport",
|
||||
EntityKind::Image(_) => "Raster Image",
|
||||
@@ -175,6 +177,7 @@ impl EntityKind {
|
||||
EntityKind::Solid(_) => "SOLID",
|
||||
EntityKind::Trace(_) => "TRACE",
|
||||
EntityKind::Face3d(_) => "3DFACE",
|
||||
EntityKind::PolyfaceMesh(_) => "POLYLINE",
|
||||
EntityKind::Region(_) => "REGION",
|
||||
EntityKind::Viewport(_) => "VIEWPORT",
|
||||
EntityKind::Image(_) => "IMAGE",
|
||||
@@ -218,10 +221,10 @@ impl EntityKind {
|
||||
let c = &s.corners;
|
||||
vec![Prim::Fill(vec![c[0].xy(), c[1].xy(), c[3].xy(), c[2].xy()])]
|
||||
}
|
||||
EntityKind::Face3d(f) => {
|
||||
let c = &f.corners;
|
||||
let pts = [c[0].xy(), c[1].xy(), c[2].xy(), c[3].xy(), c[0].xy()];
|
||||
pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
|
||||
EntityKind::Face3d(f) => f.visible_edges().into_iter().map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect(),
|
||||
// Each face's visible edges (an edge flagged hidden, by a negative index, isn't drawn).
|
||||
EntityKind::PolyfaceMesh(m) => {
|
||||
m.faces3d().iter().flat_map(Face3d::visible_edges).map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect()
|
||||
}
|
||||
EntityKind::Region(r) => r.segments().into_iter().map(Prim::Seg).collect(),
|
||||
EntityKind::Hatch(h) => {
|
||||
@@ -359,9 +362,13 @@ impl EntityKind {
|
||||
i.scale.y *= s;
|
||||
i.scale.z *= s;
|
||||
i.rotation = cadcraft_geom::norm_angle(i.rotation + rot);
|
||||
// Mirrored as AutoCAD 2024 mirrors a block reference: the X scale factor
|
||||
// negated, the rotation 180 degrees plus twice the mirror line's angle less the
|
||||
// old one, and a MINSERT grid's columns running the other way.
|
||||
if mirror {
|
||||
i.scale.y = -i.scale.y;
|
||||
i.rotation = cadcraft_geom::norm_angle(rot - (i.rotation - rot));
|
||||
i.scale.x = -i.scale.x;
|
||||
i.rotation = cadcraft_geom::norm_angle(std::f64::consts::PI + rot - (i.rotation - rot));
|
||||
i.col_spacing = -i.col_spacing;
|
||||
}
|
||||
for a in &mut i.attribs {
|
||||
transform_text(&mut a.text, m, s, rot, false);
|
||||
@@ -424,6 +431,7 @@ impl EntityKind {
|
||||
}
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(t3),
|
||||
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(t3),
|
||||
EntityKind::PolyfaceMesh(pm) => pm.vertices.iter_mut().for_each(t3),
|
||||
EntityKind::Region(r) => {
|
||||
for v in r.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) {
|
||||
v.p = m.apply(v.p);
|
||||
@@ -542,6 +550,7 @@ impl EntityKind {
|
||||
EntityKind::Hatch(h) => scale(&mut h.elevation),
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
|
||||
EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z),
|
||||
EntityKind::PolyfaceMesh(m) => m.vertices.iter_mut().for_each(z),
|
||||
EntityKind::Viewport(v) => z(&mut v.center),
|
||||
EntityKind::Image(i) => z(&mut i.insert),
|
||||
EntityKind::Table(t) => z(&mut t.insert),
|
||||
@@ -611,6 +620,7 @@ impl EntityKind {
|
||||
}
|
||||
EntityKind::Solid(s) | EntityKind::Trace(s) => s.corners.iter().map(|c| c.xy()).collect(),
|
||||
EntityKind::Face3d(f) => f.corners.iter().map(|c| c.xy()).collect(),
|
||||
EntityKind::PolyfaceMesh(m) => m.vertices.iter().map(|v| v.xy()).collect(),
|
||||
EntityKind::Region(r) => vec![r.bounds().center()],
|
||||
EntityKind::Viewport(v) => {
|
||||
let c = v.center.xy();
|
||||
|
||||
@@ -69,6 +69,52 @@ pub struct Face3d {
|
||||
pub hidden_edges: u8,
|
||||
}
|
||||
|
||||
impl Face3d {
|
||||
/// The edges drawn: side i (corner i to the next) unless bit i of `hidden_edges` hides it,
|
||||
/// without the zero-length side of a triangle.
|
||||
pub fn visible_edges(&self) -> Vec<(Vec3, Vec3)> {
|
||||
let c = self.corners;
|
||||
(0..4).filter(|i| self.hidden_edges & (1 << i) == 0).map(|i| (c[i], c[(i + 1) % 4])).filter(|(a, b)| a != b).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// POLYLINE polyface mesh (DXF flag 64): its vertices, and faces naming three or four of them
|
||||
/// by 1-based index as DXF stores them (0: no fourth vertex; a negative index hides the edge
|
||||
/// that starts at that vertex).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PolyfaceMesh {
|
||||
pub vertices: Vec<Vec3>,
|
||||
pub faces: Vec<[i32; 4]>,
|
||||
}
|
||||
|
||||
impl PolyfaceMesh {
|
||||
/// The faces as 3D faces (what EXPLODE makes). Faces with fewer than three vertices or an
|
||||
/// index outside the vertex list are skipped.
|
||||
pub fn faces3d(&self) -> Vec<Face3d> {
|
||||
self.faces.iter().filter_map(|f| self.face3d(f)).collect()
|
||||
}
|
||||
/// One face record as a 3D face: `None` when it names fewer than three vertices before a 0,
|
||||
/// or one outside the vertex list.
|
||||
pub fn face3d(&self, f: &[i32; 4]) -> Option<Face3d> {
|
||||
let mut corners: Vec<Vec3> = Vec::with_capacity(4);
|
||||
let mut hidden = 0u8;
|
||||
for &ix in f.iter().take_while(|&&ix| ix != 0) {
|
||||
let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1));
|
||||
let p = *at.and_then(|n| self.vertices.get(n))?;
|
||||
if ix < 0 {
|
||||
hidden |= 1 << corners.len();
|
||||
}
|
||||
corners.push(p);
|
||||
}
|
||||
match corners.as_slice() {
|
||||
// A triangle's closing edge is the 3DFACE's fourth (the third is degenerate).
|
||||
[a, b, c] => Some(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }),
|
||||
[a, b, c, d] => Some(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RayLine {
|
||||
pub base: Vec3,
|
||||
|
||||
@@ -52,6 +52,11 @@ pub struct Dimension {
|
||||
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
|
||||
#[serde(default)]
|
||||
pub block: Option<String>,
|
||||
/// Insertion point (DXF group 12): a dimension moved by an older AutoCAD keeps its offset
|
||||
/// here with the `*D` block's contents left where they were, and is drawn translated by it.
|
||||
/// Zero once the geometry is regenerated from the definition points.
|
||||
#[serde(default)]
|
||||
pub insertion_point: Vec3,
|
||||
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
|
||||
/// merged over the named style.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
|
||||
@@ -207,6 +207,41 @@ pub struct MLineGeometry {
|
||||
pub fill: Vec<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
/// For each segment direction in `dirs`, the first non-zero direction at or after it and the
|
||||
/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero).
|
||||
///
|
||||
/// Two linear passes: searching from every segment separately is quadratic in a run of
|
||||
/// coincident vertices, which a hostile file can make as long as it likes (CWE-407).
|
||||
fn usable_directions(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else {
|
||||
return (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
};
|
||||
let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
// Walking backwards from the last usable direction, the latest one seen is the next ahead.
|
||||
let mut cur = Vec2::X;
|
||||
for k in 0..n {
|
||||
let i = (last + n - k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(a) = ahead.get_mut(i) {
|
||||
*a = cur;
|
||||
}
|
||||
}
|
||||
// Walking forwards from the first usable direction, the latest one seen is the nearest behind.
|
||||
for k in 0..n {
|
||||
let i = (first + k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(b) = behind.get_mut(i) {
|
||||
*b = cur;
|
||||
}
|
||||
}
|
||||
(ahead, behind)
|
||||
}
|
||||
|
||||
impl MLine {
|
||||
/// A multiline through `pts` in style `def`.
|
||||
pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine {
|
||||
@@ -268,16 +303,11 @@ impl MLine {
|
||||
let dirs = self.directions();
|
||||
let n = self.vertices.len();
|
||||
let nseg = dirs.len();
|
||||
// A degenerate segment takes its neighbour's direction.
|
||||
let dir = |i: usize| -> Vec2 {
|
||||
(0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)
|
||||
};
|
||||
let dir_back = |i: usize| -> Vec2 {
|
||||
(0..nseg)
|
||||
.map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default())
|
||||
.find(|d| *d != Vec2::ZERO)
|
||||
.unwrap_or(Vec2::X)
|
||||
};
|
||||
// A degenerate segment takes its neighbour's direction: the first usable one at or after
|
||||
// it (`ahead`), or at or before it (`behind`), wrapping around.
|
||||
let (ahead, behind) = usable_directions(&dirs);
|
||||
let dir = |i: usize| -> Vec2 { ahead.get(i).copied().unwrap_or(Vec2::X) };
|
||||
let dir_back = |i: usize| -> Vec2 { behind.get(i).copied().unwrap_or(Vec2::X) };
|
||||
let end = |d: Vec2, angle: f64| {
|
||||
let u = d.rotate(angle);
|
||||
let s = angle.sin();
|
||||
@@ -426,3 +456,61 @@ impl crate::Drawing {
|
||||
self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The nearest usable directions found by searching from each segment, as miters did before.
|
||||
fn searched(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
(ahead.collect(), behind.collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_directions_match_a_search_from_each_segment() {
|
||||
let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8));
|
||||
for dirs in [
|
||||
vec![],
|
||||
vec![z],
|
||||
vec![a],
|
||||
vec![z, z, z],
|
||||
vec![a, z, z, b],
|
||||
vec![z, a, z, b, z],
|
||||
vec![z, z, c],
|
||||
vec![c, z, z],
|
||||
vec![a, b, c],
|
||||
vec![z, a, z, z, b, c, z],
|
||||
] {
|
||||
assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_vertices_at_a_corner_take_the_corner_miter() {
|
||||
// Every vertex of a run on one point mitres between the segment before the run and the one after.
|
||||
let style = MLineStyle::default();
|
||||
let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0);
|
||||
let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)];
|
||||
let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0);
|
||||
let (expected, got) = (corner.miters(), run.miters());
|
||||
assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..]));
|
||||
assert_eq!((got.first(), got.last()), (expected.first(), expected.last()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_run_of_coincident_vertices_takes_linear_time() {
|
||||
// 200 000 vertices on one point but the last: the miters must not search the run once per
|
||||
// vertex (quadratic, minutes) to find the one usable direction.
|
||||
let mut pts = vec![Vec2::ZERO; 200_000];
|
||||
pts.push(Vec2::new(1.0, 0.0));
|
||||
let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0);
|
||||
let t = std::time::Instant::now();
|
||||
let miters = m.miters();
|
||||
assert_eq!(miters.len(), pts.len());
|
||||
assert!(miters.iter().all(|v| v.is_finite()));
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ use super::HatchLoop;
|
||||
/// Most loops one region has (hostile files and edits).
|
||||
pub const MAX_REGION_LOOPS: usize = 10_000;
|
||||
|
||||
/// Most polygon edges [`Region::renest`] tests, over all its point-in-loop tests.
|
||||
pub const MAX_RENEST_EDGE_VISITS: usize = 50_000_000;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Region {
|
||||
/// Closed loops with bulges; `outer` false for islands.
|
||||
@@ -76,20 +79,49 @@ impl Region {
|
||||
}
|
||||
|
||||
/// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is
|
||||
/// an outer loop, odd an island.
|
||||
pub fn renest(&mut self) {
|
||||
/// an outer loop, odd an island. `false`, with the flags left as they were, when that takes
|
||||
/// more than [`MAX_RENEST_EDGE_VISITS`] polygon edge tests.
|
||||
#[must_use]
|
||||
pub fn renest(&mut self) -> bool {
|
||||
let polys = self.tessellate(self.fine_tol());
|
||||
let depth: Vec<usize> = polys
|
||||
// A loop's probe can only be inside loops whose box holds it: test only those. A box with
|
||||
// a non-finite corner says nothing (an edge through a non-finite point never counts, so
|
||||
// such a polygon need not be closed), so its loop is always tested.
|
||||
let boxes: Vec<Option<Bounds2>> = polys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let Some(p) = probe(l) else { return 0 };
|
||||
polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count()
|
||||
.map(|l| {
|
||||
let b = Bounds2::from_points(l.iter().copied());
|
||||
(l.iter().all(|p| p.is_finite()) && b.min.is_finite() && b.max.is_finite()).then_some(b)
|
||||
})
|
||||
.collect();
|
||||
let mut visits = 0usize;
|
||||
let mut depth = Vec::with_capacity(polys.len());
|
||||
for (i, l) in polys.iter().enumerate() {
|
||||
let Some(p) = probe(l) else {
|
||||
depth.push(0);
|
||||
continue;
|
||||
};
|
||||
let mut d = 0;
|
||||
for (j, (o, b)) in polys.iter().zip(&boxes).enumerate() {
|
||||
if j == i || b.is_some_and(|b| !b.contains(p)) {
|
||||
continue;
|
||||
}
|
||||
// Each loop against every other is loops x vertices: bounded so a region of many
|
||||
// large nested loops cannot hold the thread (CWE-407).
|
||||
visits = visits.saturating_add(o.len());
|
||||
if visits > MAX_RENEST_EDGE_VISITS {
|
||||
return false;
|
||||
}
|
||||
if point_in_polygon(o, p) {
|
||||
d += 1;
|
||||
}
|
||||
}
|
||||
depth.push(d);
|
||||
}
|
||||
for (l, d) in self.loops.iter_mut().zip(depth) {
|
||||
l.outer = d % 2 == 0;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Area, perimeter, bounding box, centroid and second moments (outer loops counted
|
||||
@@ -128,3 +160,37 @@ fn probe(l: &[Vec2]) -> Option<Vec2> {
|
||||
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square(c: f64, half: f64, n: usize, outer: bool) -> HatchLoop {
|
||||
// `n` vertices along the bottom edge, then the other three corners.
|
||||
let mut vertices: Vec<PolyVertex> =
|
||||
(0..n).map(|k| PolyVertex::new(Vec2::new(c - half + 2.0 * half * k as f64 / n as f64, c - half))).collect();
|
||||
vertices.extend([Vec2::new(c + half, c - half), Vec2::new(c + half, c + half), Vec2::new(c - half, c + half)].map(PolyVertex::new));
|
||||
HatchLoop { vertices, outer, sources: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renest_sets_depth_parity_and_skips_loops_far_away() {
|
||||
// Three nested squares, and one beside them.
|
||||
let mut r =
|
||||
Region { loops: vec![square(0.0, 3.0, 1, false), square(0.0, 2.0, 1, false), square(0.0, 1.0, 1, false), square(10.0, 1.0, 1, false)] };
|
||||
assert!(r.renest());
|
||||
assert_eq!(r.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), [true, false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renest_of_many_large_nested_loops_stops_at_its_budget() {
|
||||
// Concentric loops of many vertices each: each probe is inside the box of every larger
|
||||
// loop (about loops² / 2 pairs), so each of those tests is a full polygon scan.
|
||||
let loops = 300;
|
||||
let n = 3 * MAX_RENEST_EDGE_VISITS / (loops * loops);
|
||||
let mut r = Region { loops: (0..loops).map(|i| square(0.0, 1.0 + i as f64, n, false)).collect() };
|
||||
let before = r.clone();
|
||||
assert!(!r.renest());
|
||||
assert_eq!(r, before, "flags left as they were");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,25 +94,48 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
|
||||
/// The box of what `e` draws (block contents on layers turned off left out).
|
||||
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false)
|
||||
bounds_of(d, e, depth, false, &mut HashMap::new())
|
||||
}
|
||||
|
||||
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
|
||||
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
|
||||
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true)
|
||||
bounds_of(d, e, 0, true, &mut HashMap::new())
|
||||
}
|
||||
|
||||
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
|
||||
/// The bounds of block contents measured so far, kept across the entities of one unchanged
|
||||
/// drawing. Measuring many entities with [`entity_bounds_cached`] or [`extent_bounds_cached`]
|
||||
/// and one cache measures each block definition once per depth for all of them; a fresh cache per
|
||||
/// entity would measure a block again for every reference to it, R references to a block of B
|
||||
/// entities costing R x B (CWE-407). Use a cache only while the drawing it measured is unchanged.
|
||||
#[derive(Default)]
|
||||
pub struct BlockBoundsCache<'a> {
|
||||
/// By block name and depth, block contents on layers turned off left out ([`entity_bounds`]).
|
||||
drawn: HashMap<(&'a str, usize), Bounds2>,
|
||||
/// The same with those contents counted ([`extent_bounds`]).
|
||||
extents: HashMap<(&'a str, usize), Bounds2>,
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
|
||||
pub fn entity_bounds_cached<'a>(d: &'a Drawing, e: &Entity, depth: usize, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false, &mut cache.drawn)
|
||||
}
|
||||
|
||||
/// [`extent_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
|
||||
pub fn extent_bounds_cached<'a>(d: &'a Drawing, e: &Entity, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true, &mut cache.extents)
|
||||
}
|
||||
|
||||
fn bounds_of<'a>(d: &'a Drawing, e: &Entity, depth: usize, off_counts: bool, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
|
||||
// A top-level annotative object measures as it draws at the current annotation scale.
|
||||
if depth == 0 {
|
||||
match d.anno_draw(e) {
|
||||
crate::AnnoDraw::Plain => {}
|
||||
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, blocks, off_counts),
|
||||
}
|
||||
}
|
||||
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
|
||||
bounds_in(d, e, depth, blocks, off_counts)
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
@@ -212,6 +235,10 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
inner
|
||||
}
|
||||
};
|
||||
// The block draws translated by the insertion point (group 12), so its bounds
|
||||
// move with it (#2634); the cache above keeps the block's own bounds.
|
||||
let off = dm.insertion_point.xy();
|
||||
let inner = if off == Vec2::ZERO { inner } else { Bounds2 { min: inner.min + off, max: inner.max + off } };
|
||||
b = b.union(&inner);
|
||||
}
|
||||
b
|
||||
@@ -438,4 +465,53 @@ mod tests {
|
||||
empty.text = Some(crate::MText { contents: " ".into(), ..mtext });
|
||||
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_block_cache_measures_as_a_fresh_one_for_each_entity() {
|
||||
let line = |a: (f64, f64), b: (f64, f64), layer: &str| {
|
||||
let mut e = Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }));
|
||||
e.common.layer = layer.into();
|
||||
e
|
||||
};
|
||||
let ins = |block: &str, at: f64| {
|
||||
Entity::new(
|
||||
crate::Handle(2),
|
||||
EntityKind::Insert(crate::Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::new(at, at, 0.0),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
)
|
||||
};
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.ensure_layer("OFF");
|
||||
if let Some(l) = d.layer_mut("OFF") {
|
||||
l.on = false;
|
||||
}
|
||||
// A block with a part on a layer turned off (left out when drawn, counted in extents),
|
||||
// and a block that nests it.
|
||||
let mut b = crate::Block::new("B");
|
||||
b.entities.push(line((0.0, 0.0), (1.0, 1.0), "0"));
|
||||
b.entities.push(line((0.0, 0.0), (5.0, 5.0), "OFF"));
|
||||
d.blocks.insert("B".into(), std::sync::Arc::new(b));
|
||||
let mut n = crate::Block::new("N");
|
||||
n.entities.push(ins("B", 10.0));
|
||||
d.blocks.insert("N".into(), std::sync::Arc::new(n));
|
||||
let ents: Vec<Entity> = (0..50).map(|i| ins(if i % 2 == 0 { "B" } else { "N" }, f64::from(i))).collect();
|
||||
// One cache for both kinds of measure, interleaved: neither may answer for the other.
|
||||
let mut cache = BlockBoundsCache::default();
|
||||
for e in &ents {
|
||||
let (drawn, extents) = (entity_bounds_cached(&d, e, 0, &mut cache), extent_bounds_cached(&d, e, &mut cache));
|
||||
let (want_drawn, want_extents) = (entity_bounds(&d, e, 0), extent_bounds(&d, e));
|
||||
assert_eq!((drawn.min, drawn.max), (want_drawn.min, want_drawn.max));
|
||||
assert_eq!((extents.min, extents.max), (want_extents.min, want_extents.max));
|
||||
assert!(extents.width() > drawn.width());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+165
-3
@@ -6,7 +6,7 @@
|
||||
//! named object dictionary, a table record, a block, an entity); references to objects that are
|
||||
//! gone are dropped. Commands don't edit it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{Handle, RawTag};
|
||||
|
||||
@@ -89,8 +89,170 @@ impl Foreign {
|
||||
&& self.data_store.is_empty()
|
||||
}
|
||||
|
||||
/// The entities the data store has records for (their 320 handles).
|
||||
/// The entities with a geometry record in the data store: an `ACDSRECORD` naming the entity
|
||||
/// (320) that holds its `ASM_Data`. A 320 without one doesn't keep an entity's geometry.
|
||||
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
|
||||
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
for rec in data_store_records(&self.data_store) {
|
||||
let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD"));
|
||||
if is_record && rec.iter().any(|t| t.code == 2 && t.value.trim().eq_ignore_ascii_case("ASM_Data")) {
|
||||
out.extend(data_store_owner(rec).and_then(|t| Handle::parse_hex(t.value.trim())));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a solid modeler entity (3DSOLID, REGION, BODY) holds its ACIS geometry as SAT text
|
||||
/// (groups 1 and 3), which DXF 2013 and later no longer has: those files keep it in the data
|
||||
/// store section, so an entity with SAT text and no record there can't be written to one (#2334).
|
||||
pub fn sat_text_acis(u: &crate::Unknown, handle: Handle, stored: &std::collections::BTreeSet<Handle>) -> bool {
|
||||
["3DSOLID", "REGION", "BODY"].iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && !stored.contains(&handle) && has_sat_text(&u.tags)
|
||||
}
|
||||
|
||||
/// The entities a DXF of 2013 or later leaves out because they hold SAT-text ACIS data
|
||||
/// ([`sat_text_acis`]), counted by DXF type, across model space, layouts and block definitions.
|
||||
pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap<String, usize> {
|
||||
let stored = d.foreign.data_store_entities();
|
||||
let mut out = BTreeMap::new();
|
||||
// The writer saves xrefs as references: their blocks (and `xref|` ones) hold nothing in the file.
|
||||
let own_blocks = d.blocks.values().filter(|b| b.xref_path.is_none() && !xref_dependent(&b.name));
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(own_blocks.map(|b| &b.entities));
|
||||
for e in stores.flat_map(|s| s.iter()) {
|
||||
if let crate::EntityKind::Unknown(u) = &e.kind
|
||||
&& sat_text_acis(u, e.handle, &stored)
|
||||
{
|
||||
let n: &mut usize = out.entry(u.dxf_type.to_ascii_uppercase()).or_default();
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a symbol name belongs to an attached xref (`xref|name`): the DXF writer leaves such
|
||||
/// symbols out, and the contents of xref blocks too ([`Block::xref_path`](crate::Block::xref_path)).
|
||||
pub fn xref_dependent(name: &str) -> bool {
|
||||
name.contains('|')
|
||||
}
|
||||
|
||||
/// The records of a data store section's groups: each starts at a `0` group and runs to the next
|
||||
/// (the section's own groups before the first record are not part of one).
|
||||
pub fn data_store_records(tags: &[RawTag]) -> Vec<&[RawTag]> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = tags.iter().position(|t| t.code == 0).unwrap_or(tags.len());
|
||||
while start < tags.len() {
|
||||
let end = tags.iter().skip(start + 1).position(|t| t.code == 0).map_or(tags.len(), |k| start + 1 + k);
|
||||
out.push(tags.get(start..end).unwrap_or(&[]));
|
||||
start = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether an entity's groups hold SAT text: groups 1 and 3 inside its `AcDbModelerGeometry`
|
||||
/// subclass. A file of a version before the subclass markers has no `100` groups: all of its
|
||||
/// groups (before the extended data) are the entity's.
|
||||
pub fn has_sat_text(tags: &[RawTag]) -> bool {
|
||||
let own = tags.iter().take_while(|r| r.code < 1000);
|
||||
let marked = tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 100);
|
||||
let mut in_modeler = !marked;
|
||||
for r in own {
|
||||
if r.code == 100 {
|
||||
in_modeler = r.value.trim().eq_ignore_ascii_case("AcDbModelerGeometry");
|
||||
} else if in_modeler && (r.code == 1 || r.code == 3) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The group naming the entity a data store record belongs to: its first `320`. A record is kept
|
||||
/// or dropped by this handle alone, in the writer and wherever the store is read.
|
||||
pub fn data_store_owner(record: &[RawTag]) -> Option<&RawTag> {
|
||||
record.iter().find(|t| t.code == 320)
|
||||
}
|
||||
|
||||
impl Foreign {
|
||||
/// The kept objects by handle (the first of any that share one).
|
||||
fn object_index(&self) -> BTreeMap<Handle, &ForeignObject> {
|
||||
let mut m = BTreeMap::new();
|
||||
for o in &self.objects {
|
||||
m.entry(o.handle).or_insert(o);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The kept object `root` and the kept objects it owns, directly or not (dictionary entries
|
||||
/// and child pointers, groups 350 and 360), `root` first.
|
||||
pub fn tree(&self, root: Handle) -> Vec<Handle> {
|
||||
// Indexed once: a file can keep many objects, and a lookup per pointer would grow with
|
||||
// their product.
|
||||
let by_handle = self.object_index();
|
||||
let mut out = vec![root];
|
||||
let mut seen = BTreeSet::from([root]);
|
||||
let mut i = 0;
|
||||
while let Some(&h) = out.get(i) {
|
||||
i += 1;
|
||||
let Some(o) = by_handle.get(&h) else { continue };
|
||||
for c in o.tags.iter().filter(|t| matches!(t.code, 350 | 360)).filter_map(|t| Handle::parse_hex(t.value.trim())) {
|
||||
if out.len() < 4096 && by_handle.contains_key(&c) && seen.insert(c) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Copy the objects of `tree` (see [`Foreign::tree`]) of `src` into this one under the handles
|
||||
/// `fresh` (the same order; two more follow for a field list), `owner` taking the place of the
|
||||
/// root's owner. References to objects outside the tree become null. Fields keep a field
|
||||
/// list: this one gets a copy of the source's if it has none.
|
||||
pub fn adopt_tree(&mut self, src: &Foreign, tree: &[Handle], fresh: &[Handle], owner: Handle) {
|
||||
if fresh.len() < tree.len() + 2 {
|
||||
return;
|
||||
}
|
||||
let map: BTreeMap<Handle, Handle> = tree.iter().copied().zip(fresh.iter().copied()).collect();
|
||||
let by_handle = src.object_index();
|
||||
let mut kinds = Vec::new();
|
||||
for (n, h) in tree.iter().enumerate() {
|
||||
let Some(o) = by_handle.get(h) else { continue };
|
||||
let mut c = (*o).clone();
|
||||
let Some(&new) = fresh.get(n) else { continue };
|
||||
c.handle = new;
|
||||
for t in &mut c.tags {
|
||||
if t.code == 5 {
|
||||
t.value = new.hex();
|
||||
} else if matches!(t.code, 320..=369 | 390..=399) {
|
||||
let to = Handle::parse_hex(t.value.trim()).and_then(|h| map.get(&h).copied()).or((n == 0 && t.code == 330).then_some(owner));
|
||||
t.value = to.map_or_else(|| "0".to_string(), Handle::hex);
|
||||
}
|
||||
}
|
||||
kinds.push(c.kind.clone());
|
||||
self.objects.push(c);
|
||||
}
|
||||
if let Some(list) = src.objects.iter().find(|o| o.kind == "FIELDLIST").filter(|_| !self.objects.iter().any(|o| o.kind == "FIELDLIST")) {
|
||||
let (Some(&h), Some(&nod)) = (fresh.get(tree.len()), fresh.get(tree.len() + 1)) else { return };
|
||||
let mut c = list.clone();
|
||||
c.handle = h;
|
||||
for t in &mut c.tags {
|
||||
match t.code {
|
||||
5 => t.value = h.hex(),
|
||||
330 => t.value = "0".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(t) = c.tags.iter_mut().find(|t| t.code == 330) {
|
||||
t.value = nod.hex();
|
||||
}
|
||||
self.targets.insert(nod, ("NOD".to_string(), String::new()));
|
||||
self.dictionary.push(("ACAD_FIELDLIST".to_string(), h));
|
||||
kinds.push(c.kind.clone());
|
||||
self.objects.push(c);
|
||||
}
|
||||
for k in kinds {
|
||||
let named = |c: &Vec<RawTag>| c.iter().any(|t| t.code == 1 && t.value.eq_ignore_ascii_case(&k));
|
||||
if !self.classes.iter().any(named) {
|
||||
self.classes.extend(src.classes.iter().filter(|c| named(c)).cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -27,7 +27,7 @@ pub use cadcraft_color as color;
|
||||
pub use cadcraft_geom as geom;
|
||||
pub use constraint::*;
|
||||
pub use entity::*;
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
|
||||
pub use extents::{BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached};
|
||||
pub use format::Format;
|
||||
pub use gdt::*;
|
||||
pub use header::{HVal, Header};
|
||||
@@ -132,6 +132,10 @@ pub struct Drawing {
|
||||
/// Records of the file this drawing was read from that the reader could not place (a damaged
|
||||
/// file), counted by the section they came from. Never written to a file.
|
||||
pub skipped_records: BTreeMap<String, usize>,
|
||||
/// A save option, set on the copy SAVEAS `portableFonts` writes: the file gets Windows fonts
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Default for Drawing {
|
||||
@@ -191,6 +195,7 @@ impl Drawing {
|
||||
read_notes: Vec::new(),
|
||||
refedit: None,
|
||||
skipped_records: BTreeMap::new(),
|
||||
portable_fonts: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,10 +383,13 @@ impl Drawing {
|
||||
/// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]).
|
||||
pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
|
||||
let mut b = cadcraft_geom::Bounds2::EMPTY;
|
||||
// One block-bounds cache for the whole space: every reference to a block reuses its
|
||||
// measured contents instead of measuring the definition again.
|
||||
let mut cache = BlockBoundsCache::default();
|
||||
if let Some(store) = self.space(space) {
|
||||
for e in store.iter() {
|
||||
if self.in_extents(e) {
|
||||
b = b.union(&extent_bounds(self, e));
|
||||
b = b.union(&extent_bounds_cached(self, e, &mut cache));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,19 @@ impl EntityStore {
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// The objects directly under and above `h` in the draw order (None at the bottom or the
|
||||
/// top), when `h` is here.
|
||||
pub fn neighbours(&self, h: Handle) -> Option<(Option<Handle>, Option<Handle>)> {
|
||||
let pos = self.chunk_pos(self.chunk_id(h)?)?;
|
||||
let items = &self.chunks.get(pos)?.items;
|
||||
let i = items.iter().position(|e| e.handle == h)?;
|
||||
let below = match i.checked_sub(1) {
|
||||
Some(j) => items.get(j),
|
||||
None => pos.checked_sub(1).and_then(|p| self.chunks.get(p)).and_then(|c| c.items.last()),
|
||||
};
|
||||
let above = items.get(i + 1).or_else(|| self.chunks.get(pos + 1).and_then(|c| c.items.first()));
|
||||
Some((below.map(|e| e.handle), above.map(|e| e.handle)))
|
||||
}
|
||||
/// Move `h` directly above (`above`) or under `anchor` in the draw order. False (and nothing
|
||||
/// moves) when either is absent or they are the same object.
|
||||
pub fn move_next_to(&mut self, h: Handle, anchor: Handle, above: bool) -> bool {
|
||||
@@ -364,4 +377,26 @@ mod tests {
|
||||
assert_eq!(s.get(Handle(h)).map(|e| e.handle.0), Some(h));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighbours_across_chunks() {
|
||||
let mut s = EntityStore::new();
|
||||
for h in 1..=3000 {
|
||||
s.push(ent(h));
|
||||
}
|
||||
assert_eq!(s.neighbours(Handle(1)), Some((None, Some(Handle(2)))));
|
||||
assert_eq!(s.neighbours(Handle(1500)), Some((Some(Handle(1499)), Some(Handle(1501)))));
|
||||
// The first and last objects of a chunk look into the chunks next to it.
|
||||
let k = CHUNK_MAX as u64;
|
||||
assert_eq!(s.neighbours(Handle(k)), Some((Some(Handle(k - 1)), Some(Handle(k + 1)))));
|
||||
assert_eq!(s.neighbours(Handle(k + 1)), Some((Some(Handle(k)), Some(Handle(k + 2)))));
|
||||
assert_eq!(s.neighbours(Handle(3000)), Some((Some(Handle(2999)), None)));
|
||||
assert_eq!(s.neighbours(Handle(9999)), None);
|
||||
assert!(s.move_next_to(Handle(3000), Handle(1), false));
|
||||
assert_eq!(s.neighbours(Handle(1)), Some((Some(Handle(3000)), Some(Handle(2)))));
|
||||
assert_eq!(s.neighbours(Handle(3000)), Some((None, Some(Handle(1)))));
|
||||
let mut one = EntityStore::new();
|
||||
one.push(ent(7));
|
||||
assert_eq!(one.neighbours(Handle(7)), Some((None, None)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,6 +799,10 @@ 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,
|
||||
/// 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")]
|
||||
pub image_origin: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl Default for PageSetup {
|
||||
@@ -820,6 +824,7 @@ impl Default for PageSetup {
|
||||
show_plot_styles: false,
|
||||
plot_transparency: false,
|
||||
paper_mm: false,
|
||||
image_origin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,11 +840,15 @@ pub struct Layout {
|
||||
/// Saved paper-space view (centre, height).
|
||||
#[serde(default)]
|
||||
pub view: Option<(Vec2, f64)>,
|
||||
/// The layout's limits (LAYOUT groups 10/11, lower left and upper right) as the file had
|
||||
/// them; `None` for a layout CADCraft made, written as 0,0 to 12,9 (#2109).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limits: Option<(Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn new(name: &str, tab_order: u32) -> Self {
|
||||
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None }
|
||||
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None, limits: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,44 @@ fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() {
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
|
||||
}
|
||||
|
||||
/// DXF group 12: a dimension moved by an older AutoCAD keeps its `*D` block where it was and
|
||||
/// the move in the insertion point, and the block draws translated by it. Extents follow the
|
||||
/// drawing, not the stale block contents (#2634).
|
||||
#[test]
|
||||
fn dimension_insertion_point_moves_its_block_in_extents() {
|
||||
let moved = |ins: Vec3| {
|
||||
let mut d = Drawing::new_metric();
|
||||
let mut blk = Block::new("*D1");
|
||||
blk.entities.push(on_layer("0", line((850.0, 920.0), (860.0, 920.0)), 2));
|
||||
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
|
||||
let dm = Dimension {
|
||||
kind: DimKind::Aligned,
|
||||
defpt: Vec3::new(5.0, 1.0, 0.0),
|
||||
text_mid: Vec3::ZERO,
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(10.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 0.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some("*D1".into()),
|
||||
insertion_point: ins,
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
|
||||
d
|
||||
};
|
||||
// Without the offset the block's far-away contents reach the extents.
|
||||
assert!(moved(Vec3::ZERO).extents(&Space::Model).max.x > 800.0);
|
||||
// With it, the block draws at the definition points and the extents stay at the drawing.
|
||||
assert!(moved(Vec3::new(-850.0, -920.0, 0.0)).extents(&Space::Model).max.x < 20.0);
|
||||
}
|
||||
|
||||
/// ZOOM Extents in AutoCAD 2024 (accoreconsole, EXTMIN/EXTMAX read back; AutoCAD pads them by a
|
||||
/// few hundredths): a layer turned off counts and a frozen one does not, for an entity, for an
|
||||
/// entity inside a block, and for a block reference itself.
|
||||
@@ -637,6 +675,7 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
|
||||
overrides: Default::default(),
|
||||
assoc: vec![],
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! A 3D face draws only its visible edges: `hidden_edges` bit i hides the side from corner i to
|
||||
//! the next, as DXF group 70 defines it, and a triangle's zero-length side isn't drawn.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Face3d, Prim};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
fn sides(f: Face3d) -> usize {
|
||||
EntityKind::Face3d(f).prims().iter().filter(|p| matches!(p, Prim::Seg(_))).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_edges_are_not_drawn() {
|
||||
let quad = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 0.0)];
|
||||
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 0 }), 4);
|
||||
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 1 | 4 }), 2);
|
||||
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 15 }), 0);
|
||||
let triangle = [quad[0], quad[1], quad[2], quad[2]];
|
||||
assert_eq!(sides(Face3d { corners: triangle, hidden_edges: 0 }), 3);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
mod face3d_hidden_edges;
|
||||
mod lin_pat_parse;
|
||||
mod sub_segment_alias;
|
||||
|
||||
@@ -8,9 +8,12 @@ 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).
|
||||
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies]
|
||||
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
|
||||
acadrust = "0.6.5"
|
||||
acadrust = "0.6.6"
|
||||
|
||||
+316
-18
@@ -19,10 +19,8 @@
|
||||
//! crashing.
|
||||
//!
|
||||
//! These bound the input- and output-size variants. A small file that declares a huge internal
|
||||
//! table or section can still make the dependency allocate before any size is known; fully fixing
|
||||
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
|
||||
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
|
||||
//! keep the blast radius bounded in the meantime.
|
||||
//! table or section can still make the dependency allocate before any size is known, so the app
|
||||
//! runs both directions in a child process ([`worker`]): what that kills is the child.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -64,6 +62,11 @@ fn mb(n: u64) -> String {
|
||||
if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") }
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub mod worker;
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_noted, serve_if_asked, use_own_worker, use_worker};
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod native {
|
||||
use std::io::{Cursor, Write};
|
||||
@@ -110,8 +113,11 @@ mod native {
|
||||
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
|
||||
}
|
||||
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
|
||||
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
|
||||
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
|
||||
let written = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)?;
|
||||
mtext_contents(&doc, &mut out)
|
||||
}))
|
||||
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
|
||||
if out.exceeded {
|
||||
return Err(format!(
|
||||
"DWG: converting this {} file produced more than {} of drawing data (the limit); \
|
||||
@@ -124,6 +130,94 @@ mod native {
|
||||
Ok((add_header_vars(out.buf, &doc.header), notes))
|
||||
}
|
||||
|
||||
/// acadrust 0.6.6's ASCII writer treats a caret followed by a literal space as already
|
||||
/// escaped, and turns line feeds into paragraph codes. Serialize affected MTEXT contents
|
||||
/// from the native values instead, using its lossless string writer. All other groups,
|
||||
/// including embedded-object data, remain byte-for-byte as the DXF writer emitted them.
|
||||
fn mtext_contents(doc: &acadrust::CadDocument, out: &mut CappedWriter) -> acadrust::Result<()> {
|
||||
use acadrust::io::dxf::{DxfStreamWriter, DxfTextWriter};
|
||||
let contents: std::collections::HashMap<_, _> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::EntityType::MText(t) if t.value.contains(['^', '\t', '\n']) => Some((t.common.handle.value(), t.value.as_str())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if contents.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let dxf = std::mem::take(&mut out.buf);
|
||||
let mut lines = dxf.split_inclusive(|b| *b == b'\n');
|
||||
let (mut mtext, mut text, mut written) = (false, None, false);
|
||||
while let Some(code) = lines.next() {
|
||||
let value = lines.next().ok_or_else(|| std::io::Error::other("incomplete DWG bridge DXF pair"))?;
|
||||
match code.trim_ascii() {
|
||||
b"0" => {
|
||||
mtext = value.trim_ascii() == b"MTEXT";
|
||||
text = None;
|
||||
written = false;
|
||||
}
|
||||
b"5" if mtext => {
|
||||
text = std::str::from_utf8(value.trim_ascii()).ok().and_then(|s| u64::from_str_radix(s, 16).ok()).and_then(|h| contents.get(&h));
|
||||
}
|
||||
b"101" => mtext = false,
|
||||
b"1" | b"3" if mtext && text.is_some() => {
|
||||
if let Some(&text) = text
|
||||
&& !written
|
||||
{
|
||||
let mut writer = DxfTextWriter::new(&mut *out);
|
||||
if doc.version < acadrust::DxfVersion::AC1021
|
||||
&& let Some(encoding) = acadrust::io::dxf::code_page::encoding_from_code_page(&doc.header.code_page)
|
||||
{
|
||||
writer.set_encoding(encoding);
|
||||
}
|
||||
// Each character can expand to two caret-escape characters. Keep
|
||||
// those pairs together and each group within 250 characters.
|
||||
let mut rest = text;
|
||||
while let Some((end, _)) = rest.char_indices().nth(125) {
|
||||
let (chunk, tail) = rest.split_at(end);
|
||||
writer.write_xrecord_string(3, chunk)?;
|
||||
rest = tail;
|
||||
}
|
||||
writer.write_xrecord_string(1, rest)?;
|
||||
written = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out.write_all(code)?;
|
||||
out.write_all(value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn mtext_contents_keeps_other_groups_and_the_output_limit() -> acadrust::Result<()> {
|
||||
for version in [acadrust::DxfVersion::AC1015, acadrust::DxfVersion::AC1032] {
|
||||
let mut doc = acadrust::CadDocument::new();
|
||||
doc.version = version;
|
||||
doc.header.code_page = "ANSI_1252".into();
|
||||
let mut t = acadrust::MText::with_value("é ^ C", acadrust::Vector3::ZERO);
|
||||
t.common.handle = acadrust::Handle::new(0x10);
|
||||
doc.add_entity(acadrust::EntityType::MText(Box::new(t)))?;
|
||||
// Only the MTEXT's own 1/3 groups change. The embedded object's groups and the
|
||||
// following TEXT record must survive byte-for-byte, including their spaces.
|
||||
let prefix = b" 0\r\nMTEXT\r\n 5\r\n10\r\n 8\r\n0\r\n";
|
||||
let suffix = b"101\r\nEmbedded Object\r\n 1\r\n^ embedded\r\n 3\r\n^ tail\r\n 0\r\nTEXT\r\n 1\r\n^ plain\r\n";
|
||||
let raw = [prefix.as_slice(), b" 3\r\nold\r\n 1\r\ntext\r\n", suffix.as_slice()].concat();
|
||||
let mut out = CappedWriter { buf: raw.clone(), limit: 4096, exceeded: false };
|
||||
mtext_contents(&doc, &mut out)?;
|
||||
let value: &[u8] = if version == acadrust::DxfVersion::AC1015 { b"\xe9 ^ C" } else { "é ^ C".as_bytes() };
|
||||
assert_eq!(out.buf, [prefix.as_slice(), b" 1\r\n", value, b"\r\n", suffix.as_slice()].concat());
|
||||
let mut limited = CappedWriter { buf: raw, limit: 1, exceeded: false };
|
||||
assert!(mtext_contents(&doc, &mut limited).is_err());
|
||||
assert!(limited.exceeded);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Most record types named in the skipped-records note.
|
||||
const NOTE_TYPES: usize = 6;
|
||||
|
||||
@@ -241,12 +335,51 @@ mod native {
|
||||
/// can cover rows × cols cells, so the total is bounded too.
|
||||
const MAX_MERGE_CELLS: usize = 1 << 24;
|
||||
|
||||
/// A table cell's own text height only when the file says it overrides the table style's.
|
||||
/// acadrust's DXF writer writes a cell style's height (group 140) whenever the cell has a
|
||||
/// style record, even a default 0.18 with no override flag, and CADCraft reads a 140 as an
|
||||
/// override (#2501). The override is the cell style's TEXT_HEIGHT property flag or the
|
||||
/// content's format override bit 128; without either the height is cleared (0 is no
|
||||
/// override), so the table style's height applies.
|
||||
fn table_cell_heights(doc: &mut acadrust::CadDocument) {
|
||||
use acadrust::entities::table::CellStylePropertyFlags;
|
||||
const CONTENT_TEXT_HEIGHT: i32 = 128;
|
||||
for e in doc.entities_mut() {
|
||||
let acadrust::entities::EntityType::Table(t) = e else { continue };
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
// The first content's own height (group 144) when its format override sets bit 128.
|
||||
// CADCraft keeps one height for the cell's text, so a later content's override
|
||||
// must not stand in for the first content's.
|
||||
let content_height = cell
|
||||
.contents
|
||||
.first()
|
||||
.filter(|c| c.format_override_flags & CONTENT_TEXT_HEIGHT != 0)
|
||||
.map(|c| c.text_height)
|
||||
.filter(|h| h.is_finite() && *h > 0.0);
|
||||
if let Some(h) = content_height {
|
||||
// CADCraft reads only the style's height (group 140), so carry it there.
|
||||
cell.style.get_or_insert_with(acadrust::entities::table::CellStyle::new).text_height = h;
|
||||
continue;
|
||||
}
|
||||
// A style overrides through its property flags or, in newer files, its override
|
||||
// flags; anything else is the writer's default and is cleared.
|
||||
if let Some(style) = cell.style.as_mut()
|
||||
&& !style.property_flags.contains(CellStylePropertyFlags::TEXT_HEIGHT)
|
||||
&& style.override_flags & CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32 == 0
|
||||
{
|
||||
style.text_height = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fix-ups made to a document after the DWG read, returning how many orphaned DGN
|
||||
/// components were dropped. The document comes from a third-party reader and is hostile.
|
||||
fn post_process(doc: &mut acadrust::CadDocument) -> usize {
|
||||
super::fail_in_test("post_process");
|
||||
current_style_names(doc);
|
||||
table_merges(doc);
|
||||
table_cell_heights(doc);
|
||||
table_style_rows(doc, false);
|
||||
keep_sort_handles(doc);
|
||||
drop_orphaned_dgn_components(doc)
|
||||
@@ -344,7 +477,7 @@ mod native {
|
||||
/// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification,
|
||||
/// common dimension data), which acadrust keeps no copy of from DXF: it is set here as
|
||||
/// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128,
|
||||
/// acadrust 0.6.5 reads and writes itself.
|
||||
/// acadrust 0.6.6 reads and writes itself.
|
||||
fn dimension_flags(doc: &mut acadrust::CadDocument) {
|
||||
for e in doc.entities_mut() {
|
||||
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
|
||||
@@ -411,15 +544,22 @@ mod native {
|
||||
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
|
||||
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
|
||||
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many
|
||||
/// records of each DXF type were left out, so a save can say so.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten {
|
||||
let version = doc.version;
|
||||
let mut dropped = super::Unwritten::new();
|
||||
let mut count = |name: &str| {
|
||||
let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
};
|
||||
let unwritable: Vec<acadrust::Handle> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Unknown(u)
|
||||
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
count(&u.dxf_name);
|
||||
Some(u.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
@@ -432,9 +572,10 @@ mod native {
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(h, o)| match o {
|
||||
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
|
||||
acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. }
|
||||
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
count(type_name);
|
||||
Some(*h)
|
||||
}
|
||||
_ => None,
|
||||
@@ -443,7 +584,7 @@ mod native {
|
||||
for h in &objects {
|
||||
doc.objects.remove(h);
|
||||
}
|
||||
unwritable.len() + objects.len()
|
||||
dropped
|
||||
}
|
||||
|
||||
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
|
||||
@@ -511,12 +652,19 @@ mod native {
|
||||
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
|
||||
/// (AC1018).
|
||||
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
convert(dxf, None)
|
||||
convert(dxf, None).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"),
|
||||
/// whatever version the DXF declares.
|
||||
pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each
|
||||
/// DXF type it can't write (other programs' custom entities and objects that the DWG reader
|
||||
/// didn't read from a DWG of this version), empty when the DWG keeps everything.
|
||||
pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
let v = match version.trim().to_ascii_uppercase().as_str() {
|
||||
"AC1015" => acadrust::DxfVersion::AC1015,
|
||||
"AC1018" => acadrust::DxfVersion::AC1018,
|
||||
@@ -574,7 +722,7 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
// Reject a pathologically large DXF before handing it to the third-party decoder, which
|
||||
// allocates tables and section buffers from the input's declared sizes and cannot be
|
||||
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
|
||||
@@ -599,21 +747,23 @@ mod native {
|
||||
None => {}
|
||||
}
|
||||
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
|
||||
guarded(AssertUnwindSafe(|| {
|
||||
let unwritten = guarded(AssertUnwindSafe(|| {
|
||||
super::fail_in_test("convert");
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
let unwritten = drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
current_layer(&mut doc);
|
||||
unwritten
|
||||
}))
|
||||
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
let dwg = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
|
||||
.map_err(|e| format!("DWG write: {e}"))
|
||||
.map_err(|e| format!("DWG write: {e}"))?;
|
||||
Ok((dwg, unwritten))
|
||||
}
|
||||
|
||||
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
|
||||
@@ -730,6 +880,34 @@ mod native {
|
||||
assert_eq!(table_merges_within(&mut doc, usize::MAX), 100);
|
||||
}
|
||||
|
||||
/// A height marked only in the style's override flags (as newer files store it) is kept,
|
||||
/// and the same height with no flag at all is cleared (review on #2588). Tested on the
|
||||
/// read document because acadrust's DWG writer doesn't write the override flags.
|
||||
#[test]
|
||||
fn a_style_override_flag_keeps_the_cell_height() {
|
||||
use acadrust::entities::table::{CellStyle, CellStylePropertyFlags};
|
||||
let height = |override_flags: i32| {
|
||||
let mut doc = doc_with_table(1, 1, 0);
|
||||
for e in doc.entities_mut() {
|
||||
if let EntityType::Table(t) = e {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
let mut s = CellStyle::new();
|
||||
s.text_height = 0.5;
|
||||
s.override_flags = override_flags;
|
||||
cell.style = Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
table_cell_heights(&mut doc);
|
||||
doc.entities().find_map(|e| match e {
|
||||
EntityType::Table(t) => t.rows.first()?.cells.first()?.style.as_ref().map(|s| s.text_height),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
assert_eq!(height(CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32), Some(0.5));
|
||||
assert_eq!(height(0), Some(0.0));
|
||||
}
|
||||
|
||||
/// A panic in the post-read fix-ups is an error, not a crash (#1934).
|
||||
#[test]
|
||||
fn a_panic_in_post_processing_is_an_error() {
|
||||
@@ -742,7 +920,11 @@ mod native {
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_noted};
|
||||
|
||||
/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer
|
||||
/// can't write.
|
||||
pub type Unwritten = std::collections::BTreeMap<String, usize>;
|
||||
|
||||
// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The
|
||||
// stage then panics on entry, so a test through the public API shows whether it is guarded.
|
||||
@@ -772,6 +954,28 @@ fn unsupported(version: &str) -> String {
|
||||
#[cfg(no_dwg_bridge)]
|
||||
const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" };
|
||||
|
||||
/// There's no worker without a bridge.
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub const WORKER_ARG: &str = "--dwg-worker";
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
None
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn use_own_worker() {}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
dwg_to_dxf_noted(bytes, limits)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_as(dxf, version)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
dxf_to_dwg_noted(dxf, version)
|
||||
}
|
||||
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
@@ -792,6 +996,10 @@ pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1076,4 +1284,94 @@ mod tests {
|
||||
assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}");
|
||||
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also
|
||||
/// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action
|
||||
/// from DXF is written whole, with the byte AutoCAD writes (0).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() {
|
||||
use acadrust::objects::*;
|
||||
let scale_action = |doc: &acadrust::CadDocument| {
|
||||
doc.objects.values().find_map(|o| match o {
|
||||
ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let mut doc = acadrust::CadDocument::new();
|
||||
let h = doc.allocate_handle();
|
||||
let conn = |code, name: &str| BlockConnection { code, name: name.into() };
|
||||
let mut action = DynamicBlockObject::new("BLOCKSCALEACTION", "AcDbBlockScaleAction");
|
||||
action.handle = h;
|
||||
action.owner = doc.header.named_objects_dict_handle;
|
||||
let mut a = BlockBasePointAction::default();
|
||||
a.action.action.element.name = "Scale".into();
|
||||
a.action.connections = vec![conn(2, "UpdatedBaseX"), conn(2, "UpdatedBaseY")];
|
||||
a.action.dependent = true;
|
||||
a.connections = vec![conn(2, "Scale"), conn(2, "XScale"), conn(2, "YScale")];
|
||||
a.dwg_tail_byte = 7;
|
||||
action.data = DynamicBlockData::ScaleAction(a.clone());
|
||||
doc.objects.insert(h, ObjectType::DynamicBlock(action));
|
||||
|
||||
let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap();
|
||||
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
assert_eq!(scale_action(&back).map(|s| s.dwg_tail_byte), Some(7), "the byte isn't written");
|
||||
|
||||
let mut dxf = Vec::new();
|
||||
acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf).unwrap();
|
||||
for version in ["AC1018", "AC1032"] {
|
||||
let dwg = dxf_to_dwg_as(&dxf, version).unwrap();
|
||||
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
let read = scale_action(&back).unwrap_or_else(|| panic!("{version}: the scale action wasn't written"));
|
||||
assert_eq!(read.connections, a.connections, "{version}");
|
||||
assert_eq!(read.action.connections, a.action.connections, "{version}");
|
||||
assert_eq!(read.dwg_tail_byte, 0, "{version}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF
|
||||
/// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG
|
||||
/// keeps whole reports nothing.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn saving_lists_the_types_the_dwg_writer_leaves_out() {
|
||||
let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n";
|
||||
let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n";
|
||||
let dxf = |body: &str| {
|
||||
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n")
|
||||
};
|
||||
for version in ["AC1015", "AC1018", "AC1032"] {
|
||||
let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap();
|
||||
assert!(is_dwg(&dwg), "{version}");
|
||||
assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::<Unwritten>(), "{version}");
|
||||
}
|
||||
let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap();
|
||||
assert!(unwritten.is_empty(), "{unwritten:?}");
|
||||
}
|
||||
|
||||
/// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to
|
||||
/// the DWG with its data: it isn't in the list of what the writer left out.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn ole_frames_are_written_with_their_data() {
|
||||
let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n";
|
||||
let dxf =
|
||||
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n");
|
||||
for version in ["AC1015", "AC1018", "AC1032"] {
|
||||
let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap();
|
||||
assert!(unwritten.is_empty(), "{version} {unwritten:?}");
|
||||
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
let frames: Vec<Vec<u8>> = back
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! The DWG reader and writer in a child process.
|
||||
//!
|
||||
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
|
||||
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
|
||||
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
|
||||
//! that reads one request from standard input and writes one reply to standard output. A file that
|
||||
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
|
||||
//!
|
||||
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
|
||||
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
|
||||
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
|
||||
//!
|
||||
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
|
||||
//! untrusted input.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc::{RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::Limits;
|
||||
|
||||
/// The argument that makes the app a worker.
|
||||
pub const WORKER_ARG: &str = "--dwg-worker";
|
||||
/// Bumped when the messages change: an app updated while running may start a newer copy.
|
||||
const PROTOCOL: u8 = 1;
|
||||
const DECODE: u8 = 1;
|
||||
const ENCODE: u8 = 2;
|
||||
const CRASH: u8 = 0xff;
|
||||
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
|
||||
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
/// Most bytes accepted for what a reply carries besides the drawing.
|
||||
const MAX_NOTES_BYTES: u64 = 64 << 20;
|
||||
/// Most bytes accepted as a written DWG.
|
||||
const MAX_DWG_REPLY: u64 = 2 << 30;
|
||||
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
|
||||
const MAX_REQUEST: u64 = (2 << 30) + 256;
|
||||
|
||||
struct Worker {
|
||||
program: PathBuf,
|
||||
args: Vec<OsString>,
|
||||
}
|
||||
|
||||
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
|
||||
|
||||
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
|
||||
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
|
||||
if let Ok(mut w) = WORKER.lock() {
|
||||
*w = Some(Worker { program, args });
|
||||
}
|
||||
}
|
||||
|
||||
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
|
||||
/// found, the reader and writer stay in this process.
|
||||
pub fn use_own_worker() {
|
||||
match std::env::current_exe() {
|
||||
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
|
||||
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one request and return the exit code if this process was started as a worker.
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
|
||||
}
|
||||
|
||||
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
// Before starting a process, and so the same limit holds with or without one.
|
||||
if bytes.len() > limits.max_dwg_bytes {
|
||||
return super::dwg_to_dxf_noted(bytes, limits);
|
||||
}
|
||||
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
|
||||
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
|
||||
Some(reply) => reply?,
|
||||
None => super::dwg_to_dxf_noted(bytes, limits),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
encode_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
|
||||
/// back as notes of `TYPE<tab>count`.
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
if u8::try_from(version.len()).is_err() {
|
||||
return Err(super::unsupported(version));
|
||||
}
|
||||
match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") {
|
||||
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))),
|
||||
None => super::dxf_to_dwg_noted(dxf, version),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten_from(notes: &[String]) -> super::Unwritten {
|
||||
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
|
||||
super::Unwritten::new(),
|
||||
|mut m, (kind, n)| {
|
||||
*m.entry(kind).or_insert(0) = n;
|
||||
m
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
|
||||
#[doc(hidden)]
|
||||
pub fn crash_worker_for_test() -> Result<(), String> {
|
||||
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
|
||||
}
|
||||
|
||||
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
|
||||
let mut r = vec![PROTOCOL, op];
|
||||
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
|
||||
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
|
||||
r.push(u8::try_from(version.len()).unwrap_or(0));
|
||||
r.extend(version.as_bytes());
|
||||
r
|
||||
}
|
||||
|
||||
type Reply = Result<(Vec<u8>, Vec<String>), String>;
|
||||
|
||||
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
|
||||
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
|
||||
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
|
||||
let mut command = {
|
||||
let worker = WORKER.lock().ok()?;
|
||||
let worker = worker.as_ref()?;
|
||||
let mut c = Command::new(&worker.program);
|
||||
c.args(&worker.args);
|
||||
c
|
||||
};
|
||||
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let mut child = match command.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("DWG worker: can't start it: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
};
|
||||
let received = std::thread::scope(|scope| {
|
||||
// Written beside the read: a worker that replies before it has read everything, or dies,
|
||||
// must block neither side. A failed write shows up as the reply going wrong.
|
||||
scope.spawn(move || {
|
||||
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
scope.spawn(move || {
|
||||
let mut reply = Vec::new();
|
||||
let read = from_child.take(reply_limit).read_to_end(&mut reply);
|
||||
let _ = tx.send(read.map(|_| reply));
|
||||
});
|
||||
let received = rx.recv_timeout(TIMEOUT);
|
||||
if matches!(received, Err(RecvTimeoutError::Timeout)) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
received
|
||||
});
|
||||
let status = child.wait();
|
||||
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
|
||||
Some(match (received, status) {
|
||||
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
|
||||
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
|
||||
_ => Err(stopped()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Take `n` bytes off the front of `b`.
|
||||
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
|
||||
let (head, tail) = b.split_at_checked(n)?;
|
||||
*b = tail;
|
||||
Some(head)
|
||||
}
|
||||
|
||||
fn take_u32(b: &mut &[u8]) -> Option<usize> {
|
||||
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
|
||||
usize::try_from(u32::from_le_bytes(bytes)).ok()
|
||||
}
|
||||
|
||||
fn take_u64(b: &mut &[u8]) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
|
||||
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
|
||||
/// of the memory the app may use: they are kept, not copied.
|
||||
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
|
||||
let total = reply.len();
|
||||
let mut b = &reply[..];
|
||||
match *take(&mut b, 1)?.first()? {
|
||||
0 => {
|
||||
let count = take_u32(&mut b)?;
|
||||
let mut notes = Vec::new();
|
||||
for _ in 0..count {
|
||||
let n = take_u32(&mut b)?;
|
||||
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
|
||||
}
|
||||
let n = usize::try_from(take_u64(&mut b)?).ok()?;
|
||||
if b.len() != n {
|
||||
return None;
|
||||
}
|
||||
reply.drain(..total - n);
|
||||
Some(Ok((reply, notes)))
|
||||
}
|
||||
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
|
||||
match reply {
|
||||
Ok((bytes, notes)) => {
|
||||
out.write_all(&[0])?;
|
||||
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
|
||||
for note in ¬es {
|
||||
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
|
||||
if u32::try_from(note.len()).is_ok() {
|
||||
out.write_all(note.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
|
||||
out.write_all(&bytes)?;
|
||||
}
|
||||
Err(e) => {
|
||||
out.write_all(&[1])?;
|
||||
out.write_all(e.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
|
||||
/// What a request asks for, or why it can't be served.
|
||||
fn handle(mut b: &[u8]) -> Reply {
|
||||
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
|
||||
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
|
||||
return Err(bad());
|
||||
}
|
||||
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
match op {
|
||||
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
|
||||
ENCODE => super::dxf_to_dwg_noted(b, version).map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())),
|
||||
CRASH => std::process::abort(),
|
||||
_ => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
|
||||
fn serve(input: impl Read, mut output: impl Write) -> i32 {
|
||||
let mut request = Vec::new();
|
||||
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
|
||||
Ok(_) => handle(&request),
|
||||
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
|
||||
};
|
||||
i32::from(write_reply(&mut output, reply).is_err())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wire(reply: Reply) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
write_reply(&mut out, reply).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_writer_left_out_survives_the_reply() {
|
||||
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
|
||||
assert_eq!(unwritten_from(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip() {
|
||||
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
|
||||
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
|
||||
let failed: Reply = Err("DWG: no".into());
|
||||
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_cut_short_or_made_up_is_refused() {
|
||||
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
|
||||
for n in 0..whole.len() {
|
||||
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
|
||||
}
|
||||
assert_eq!(parse_reply(vec![7, 0, 0]), None);
|
||||
// A note count far beyond what follows ends at the first missing note.
|
||||
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
|
||||
for request in [
|
||||
&[][..],
|
||||
&[PROTOCOL + 1, DECODE],
|
||||
&[PROTOCOL],
|
||||
&[PROTOCOL, DECODE, 1, 2, 3],
|
||||
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
] {
|
||||
assert!(handle(request).is_err(), "{request:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serving_reports_a_decode_failure_as_a_reply() {
|
||||
let mut request = self::request(DECODE, Limits::DEFAULT, "");
|
||||
request.extend(b"AC1032 not really a drawing");
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(serve(&request[..], &mut out), 0);
|
||||
assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}");
|
||||
}
|
||||
}
|
||||
+57
-9
@@ -23,8 +23,11 @@ pub enum DxfError {
|
||||
Eof,
|
||||
/// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the
|
||||
/// limit was reached), `limit` the maximum.
|
||||
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads")]
|
||||
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")]
|
||||
TooLarge { tags: usize, limit: usize },
|
||||
/// The memory for `tags` group codes couldn't be had: an error, not the process ending.
|
||||
#[error("not enough memory to read this drawing's {tags} DXF group codes")]
|
||||
OutOfMemory { tags: usize },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DxfError>;
|
||||
@@ -163,16 +166,32 @@ fn typed(code: i32, text: &str) -> Value {
|
||||
}
|
||||
|
||||
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
/// Most group codes read from one file. A `Tag` takes 40 bytes plus its text, so this bounds the
|
||||
/// tag list to a few GB; real drawings stay far below (a 10 MB DWG is typically a few million).
|
||||
pub const MAX_TAGS: usize = 50_000_000;
|
||||
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
|
||||
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
|
||||
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
|
||||
/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to
|
||||
/// raise or lower it.
|
||||
pub const MAX_TAGS: usize = 100_000_000;
|
||||
|
||||
/// Environment variable overriding [`MAX_TAGS`] (a positive integer).
|
||||
pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS";
|
||||
|
||||
/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else
|
||||
/// [`MAX_TAGS`].
|
||||
pub fn max_tags() -> usize {
|
||||
max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
fn max_tags_from(v: Option<&str>) -> usize {
|
||||
v.and_then(|s| s.trim().replace('_', "").parse::<usize>().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS)
|
||||
}
|
||||
|
||||
mod codepage;
|
||||
pub use codepage::decode_code_page;
|
||||
|
||||
/// Parse a DXF file (ASCII or binary).
|
||||
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_with_limit(bytes, MAX_TAGS)
|
||||
parse_with_limit(bytes, max_tags())
|
||||
}
|
||||
|
||||
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
|
||||
@@ -199,7 +218,7 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if tags > max_tags {
|
||||
return Err(DxfError::TooLarge { tags, limit: max_tags });
|
||||
}
|
||||
parse_ascii_with_limit(&codepage::decode_ascii_file(bytes), max_tags)
|
||||
parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags)
|
||||
}
|
||||
|
||||
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
|
||||
@@ -271,12 +290,17 @@ fn unescape_unicode(s: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
|
||||
parse_ascii_with_limit(text, MAX_TAGS)
|
||||
parse_ascii_counted(text, max_tags(), 0)
|
||||
}
|
||||
|
||||
/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first,
|
||||
/// with an allocation that can fail: where the system refuses that much memory, opening the file
|
||||
/// is an error instead of the end of the app. Growing the list instead could also briefly take
|
||||
/// half as much again.
|
||||
#[allow(clippy::while_let_loop)]
|
||||
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
|
||||
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
|
||||
let mut tags = Vec::new();
|
||||
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
|
||||
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
|
||||
loop {
|
||||
let Some((ln, code_line)) = lines.next() else { break };
|
||||
@@ -357,7 +381,7 @@ impl<'a> Cur<'a> {
|
||||
|
||||
/// Binary DXF (R13+: 2-byte group codes).
|
||||
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_binary_with_limit(b, MAX_TAGS)
|
||||
parse_binary_with_limit(b, max_tags())
|
||||
}
|
||||
|
||||
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
@@ -777,3 +801,27 @@ mod tests {
|
||||
assert_eq!(parse(&b).unwrap()[0].str(), "图号");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod limit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tag_limit_defaults_and_env_override() {
|
||||
assert_eq!(max_tags_from(None), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("0")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("abc")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000);
|
||||
// Issue #1948: the default must admit a DXF rendition well above 50 million group codes.
|
||||
const { assert!(MAX_TAGS >= 100_000_000) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() {
|
||||
let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap();
|
||||
assert_eq!((tags.len(), tags.capacity()), (3, 3));
|
||||
let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err();
|
||||
assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,27 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_with(path, permissions, |file| file.write_all(bytes))
|
||||
}
|
||||
|
||||
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
|
||||
/// `None` when the file's folder exists, or when `path` itself exists (a dangling symlink's target
|
||||
/// folder is not ours to make).
|
||||
pub fn missing_folder(path: &Path) -> Option<PathBuf> {
|
||||
if std::fs::symlink_metadata(path).is_ok() {
|
||||
return None;
|
||||
}
|
||||
path.ancestors().skip(1).filter(|a| !a.as_os_str().is_empty()).take_while(|a| std::fs::symlink_metadata(a).is_err()).last().map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// [`write_atomic`] to a path the user named, first creating the folders missing above it: a
|
||||
/// mistyped folder costs less than a drawing that could not be written. The caller reports
|
||||
/// [`missing_folder`], asked before this call, so the new folder is never a surprise.
|
||||
pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if let Some(folder) = missing_folder(path) {
|
||||
let dir = path.parent().unwrap_or(&folder);
|
||||
std::fs::create_dir_all(dir).map_err(|e| io::Error::new(e.kind(), format!("the folder {} could not be created: {e}", dir.display())))?;
|
||||
}
|
||||
write_atomic(path, bytes)
|
||||
}
|
||||
|
||||
/// [`write_atomic`] filling the temporary file through `fill` (streamed rather than one buffer),
|
||||
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
|
||||
/// at `path` is replaced by the new file, never written through.
|
||||
@@ -159,6 +180,30 @@ fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Resul
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn creates_missing_parent_directories() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mkdir-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
let dest = dir.join("new_folder").join("deeper").join("out.bin");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert_eq!(missing_folder(&dest), Some(dir.join("new_folder")));
|
||||
assert!(write_atomic(&dest, b"created").is_err(), "the plain writer never makes folders");
|
||||
write_atomic_making_folders(&dest, b"created").unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"created");
|
||||
assert_eq!(missing_folder(&dest), None);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_cannot_be_made_is_named_in_the_error() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-nomk-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let blocker = dir.join("a_file");
|
||||
std::fs::write(&blocker, b"x").unwrap();
|
||||
let e = write_atomic_making_folders(&blocker.join("sub").join("out.bin"), b"new").unwrap_err();
|
||||
assert!(e.to_string().contains("could not be created"), "{e}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_contents_atomically() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
@@ -259,6 +304,8 @@ mod tests {
|
||||
let link = dir.join("link.dxf");
|
||||
symlink("missing/real.dxf", &link).unwrap();
|
||||
assert!(write_atomic(&link, b"new").is_err());
|
||||
assert!(write_atomic_making_folders(&link, b"new").is_err());
|
||||
assert!(!dir.join("missing").exists(), "a link's unavailable target folder must not be created");
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -234,9 +234,10 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
missing("table style", "Standard", out);
|
||||
d.table_styles.insert(0, TableStyle::default());
|
||||
}
|
||||
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
|
||||
// Name sets built once: both tables come from the drawing, so a scan per reference is quadratic.
|
||||
let linetypes: HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
|
||||
for l in &mut d.layers {
|
||||
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
|
||||
if !linetypes.contains(&l.linetype.to_ascii_lowercase()) {
|
||||
out.push(issue(
|
||||
"missingLinetype",
|
||||
"tables",
|
||||
@@ -247,8 +248,8 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
l.linetype = "Continuous".into();
|
||||
}
|
||||
}
|
||||
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
|
||||
let styles: HashSet<String> = d.text_styles.iter().map(|s| s.name.to_ascii_lowercase()).collect();
|
||||
let known = |n: &str| styles.contains(&n.to_ascii_lowercase());
|
||||
for s in &mut d.dim_styles {
|
||||
if !known(&s.text_style) {
|
||||
out.push(issue(
|
||||
|
||||
+140
-21
@@ -4,15 +4,18 @@
|
||||
//! autosave is written as DXF to `<SAVEFILEPATH>/<drawing>_<instance>_<uid>.sv$`. The file goes
|
||||
//! away when the drawing is saved or closed and when the app exits normally, so a file left behind
|
||||
//! means the app (or the machine) went down; the next start offers it for recovery. A running app
|
||||
//! keeps a lock file (`<instance>.lock`) fresh, so another copy running at the same time doesn't
|
||||
//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from
|
||||
//! an instance id ([`is_instance_id`]) are ever read as ours or deleted.
|
||||
//! holds an operating-system lock on its lock file (`<instance>.lock`) and keeps the file fresh, so
|
||||
//! another copy running at the same time doesn't offer its files. The system drops the lock the
|
||||
//! moment a process ends, however it ended, so a relaunch after a crash can offer recovery at once.
|
||||
//! Where locks aren't available, and for lock files of older versions, a file not refreshed for
|
||||
//! [`STALE_LOCK_SECS`] counts as stopped. SAVEFILEPATH may be a folder other programs use too, so
|
||||
//! only names built from an instance id ([`is_instance_id`]) are ever read as ours or deleted.
|
||||
//!
|
||||
//! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop
|
||||
//! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH
|
||||
//! are profile settings: process-wide, not saved in the drawing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
@@ -29,6 +32,10 @@ pub const DEFAULT_SAVETIME: u32 = 10;
|
||||
pub const MAX_SAVETIME: u32 = 600;
|
||||
/// A lock file not refreshed for this long belongs to an app that is no longer running.
|
||||
pub const STALE_LOCK_SECS: u64 = 5 * 60;
|
||||
/// How a lock file ends when its app holds the system lock on it, so a free lock proves the app
|
||||
/// stopped. Older versions write only the process id and never hold one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const LOCK_HELD: &str = " held";
|
||||
/// How often a running app refreshes its lock file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const HEARTBEAT_SECS: f64 = 60.0;
|
||||
@@ -149,13 +156,21 @@ pub struct Autosaver {
|
||||
docs: HashMap<u64, Tracked>,
|
||||
/// Drawings whose autosave is being written right now.
|
||||
busy: HashSet<u64>,
|
||||
/// Writes in progress; each tells whether its file was written.
|
||||
/// Why the last autosave of each drawing failed, until one succeeds or the drawing no longer
|
||||
/// needs it. Ordered, so [`Autosaver::failure`] doesn't change between frames.
|
||||
failed: BTreeMap<u64, String>,
|
||||
/// Writes in progress; each ends with why its file wasn't written, if it wasn't.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
|
||||
running: Vec<(u64, std::thread::JoinHandle<std::result::Result<(), String>>)>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: Option<f64>,
|
||||
/// This app's lock files: one per folder, because SAVEFILEPATH can change while files remain
|
||||
/// in the old folder. Each with the file it holds open for the system lock, or `None` where
|
||||
/// locks don't work and only the file's age counts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
locks: Vec<(std::path::PathBuf, Option<std::fs::File>)>,
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -164,12 +179,15 @@ impl Autosaver {
|
||||
instance: instance.into(),
|
||||
docs: HashMap::new(),
|
||||
busy: HashSet::new(),
|
||||
failed: BTreeMap::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant::now(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
locks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +205,11 @@ impl Autosaver {
|
||||
self.docs.values().filter_map(|t| t.file.clone()).collect()
|
||||
}
|
||||
|
||||
/// The drawings with no up-to-date recovery copy and why, for the host to show the user.
|
||||
pub fn failures(&self) -> impl Iterator<Item = (u64, &str)> {
|
||||
self.failed.iter().map(|(uid, why)| (*uid, why.as_str()))
|
||||
}
|
||||
|
||||
/// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder
|
||||
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
|
||||
/// files of drawings that were saved (no unsaved changes left) or closed.
|
||||
@@ -220,6 +243,7 @@ impl Autosaver {
|
||||
jobs.push(Job::Delete { path });
|
||||
}
|
||||
*t = Tracked::default();
|
||||
self.failed.remove(&st.uid);
|
||||
continue;
|
||||
}
|
||||
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
|
||||
@@ -248,6 +272,8 @@ impl Autosaver {
|
||||
t.since = None;
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc });
|
||||
}
|
||||
let docs = &self.docs;
|
||||
self.failed.retain(|uid, _| docs.contains_key(uid));
|
||||
jobs
|
||||
}
|
||||
|
||||
@@ -277,12 +303,14 @@ pub use native::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::{
|
||||
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name,
|
||||
savefilepath, savetime,
|
||||
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, LOCK_HELD, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name,
|
||||
parse_lock_name, savefilepath, savetime,
|
||||
};
|
||||
|
||||
/// A new instance id: the process id and the start time.
|
||||
@@ -307,6 +335,28 @@ mod native {
|
||||
Some(SystemTime::now().duration_since(m).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Whether the app instance named by a lock file in `dir` may still be running: its system
|
||||
/// lock answers at once, and the file's age decides when it can't (no lock support, a lock
|
||||
/// file from an older version).
|
||||
fn owner_alive(dir: &Path, instance: &str) -> bool {
|
||||
let by_age = || instance_alive(lock_age(dir, instance));
|
||||
let path = lock_path(dir, instance);
|
||||
// Only a plain file is opened: opening a FIFO planted in a shared folder would block.
|
||||
if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_file()) {
|
||||
return by_age();
|
||||
}
|
||||
let Ok(file) = File::open(&path) else { return by_age() };
|
||||
match file.try_lock() {
|
||||
Err(TryLockError::WouldBlock) => true,
|
||||
Err(TryLockError::Error(_)) => by_age(),
|
||||
Ok(()) => {
|
||||
let mut text = String::new();
|
||||
let held = file.take(64).read_to_string(&mut text).is_ok() && text.ends_with(LOCK_HELD);
|
||||
!held && by_age()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The autosave files in `dir` left behind by apps that are no longer running (never those of
|
||||
/// `own`, this app's instance), oldest first.
|
||||
pub fn recoverable(dir: &Path, own: &str) -> Vec<Recoverable> {
|
||||
@@ -316,7 +366,7 @@ mod native {
|
||||
for e in entries.flatten().take(10_000) {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let Some((title, instance, _)) = parse_file_name(&name) else { continue };
|
||||
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) {
|
||||
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| owner_alive(dir, instance)) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = e.metadata() else { continue };
|
||||
@@ -358,7 +408,7 @@ mod native {
|
||||
for n in &names {
|
||||
let Some(instance) = parse_lock_name(n) else { continue };
|
||||
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
|
||||
if !has_files && !instance_alive(lock_age(dir, instance)) {
|
||||
if !has_files && !owner_alive(dir, instance) {
|
||||
let _ = std::fs::remove_file(dir.join(n));
|
||||
}
|
||||
}
|
||||
@@ -423,24 +473,60 @@ mod native {
|
||||
self.running.iter().any(|(_, h)| !h.is_finished())
|
||||
}
|
||||
|
||||
/// Whether a write's result hasn't been taken in yet, finished or not: the host needs
|
||||
/// another [`Autosaver::tick`] to learn whether it failed.
|
||||
pub fn results_pending(&self) -> bool {
|
||||
!self.running.is_empty()
|
||||
}
|
||||
|
||||
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
|
||||
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
|
||||
/// so it is tried again SAVETIME later even if it doesn't change.
|
||||
/// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`]
|
||||
/// says why until a write succeeds.
|
||||
pub fn reap(&mut self) {
|
||||
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
|
||||
self.running = running;
|
||||
for (uid, h) in done {
|
||||
self.busy.remove(&uid);
|
||||
if !h.join().unwrap_or(false) {
|
||||
self.unwritten(uid);
|
||||
}
|
||||
self.settle(uid, h);
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten(&mut self, uid: u64) {
|
||||
fn settle(&mut self, uid: u64, h: std::thread::JoinHandle<std::result::Result<(), String>>) {
|
||||
self.busy.remove(&uid);
|
||||
match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) {
|
||||
Ok(()) => {
|
||||
self.failed.remove(&uid);
|
||||
}
|
||||
Err(e) => self.unwritten(uid, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write every drawing with changes not yet autosaved and wait until it is done, because
|
||||
/// the app has gone wrong and may not get another frame. Unlike [`Autosaver::save_now`]
|
||||
/// this also runs when SAVETIME is 0: unsaved work comes before that preference. Returns
|
||||
/// how many drawings were written.
|
||||
pub fn emergency_save(&mut self, s: &Session) -> usize {
|
||||
for (uid, h) in std::mem::take(&mut self.running) {
|
||||
self.settle(uid, h);
|
||||
}
|
||||
let now = self.start.elapsed().as_secs_f64();
|
||||
let dir = savefilepath();
|
||||
let jobs = self.plan_with(s, now, savetime().max(1), &dir, true);
|
||||
let uids: Vec<u64> = jobs.iter().filter_map(|j| if let Job::Write { uid, .. } = j { Some(*uid) } else { None }).collect();
|
||||
self.perform(jobs);
|
||||
self.last_beat = None;
|
||||
self.heartbeat(&dir, now);
|
||||
for (uid, h) in std::mem::take(&mut self.running) {
|
||||
self.settle(uid, h);
|
||||
}
|
||||
uids.iter().filter(|uid| !self.failed.contains_key(uid)).count()
|
||||
}
|
||||
|
||||
fn unwritten(&mut self, uid: u64, why: String) {
|
||||
if let Some(t) = self.docs.get_mut(&uid) {
|
||||
t.written = None;
|
||||
}
|
||||
self.failed.insert(uid, why);
|
||||
}
|
||||
|
||||
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
|
||||
@@ -451,16 +537,17 @@ mod native {
|
||||
Job::Write { uid, path, doc } => {
|
||||
let Some(write) = crate::cmd::file::io().map(|h| h.write) else {
|
||||
log::warn!("autosave: file formats are not available in this build");
|
||||
self.unwritten(uid, "file formats are not available in this build".to_string());
|
||||
continue;
|
||||
};
|
||||
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
|
||||
Ok(()) => {
|
||||
log::info!("autosaved {path}");
|
||||
true
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("autosave {path}: {e}");
|
||||
false
|
||||
Err(format!("{path}: {e}"))
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
@@ -470,7 +557,7 @@ mod native {
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("autosave: {e}");
|
||||
self.unwritten(uid);
|
||||
self.unwritten(uid, e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,12 +572,40 @@ mod native {
|
||||
}
|
||||
self.last_beat = Some(now);
|
||||
let dir = Path::new(dir);
|
||||
let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
|
||||
if let Err(e) = r {
|
||||
if let Err(e) = create_folder(dir).and_then(|()| self.refresh_lock(&lock_path(dir, &self.instance))) {
|
||||
log::warn!("autosave: lock file in {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh this app's lock files, and start one at `path` if there isn't one yet, taking
|
||||
/// the system lock on it if it can. Without that lock the file still works by its age.
|
||||
/// Only trouble at `path` is an error: an older folder may be gone by now.
|
||||
fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> {
|
||||
let id = std::process::id();
|
||||
let mut current = None;
|
||||
for (held, file) in &self.locks {
|
||||
let refreshed = match file {
|
||||
Some(file) => file.set_modified(SystemTime::now()),
|
||||
None => std::fs::write(held, id.to_string()),
|
||||
};
|
||||
if held == path {
|
||||
current = Some(refreshed);
|
||||
}
|
||||
}
|
||||
if let Some(refreshed) = current {
|
||||
return refreshed;
|
||||
}
|
||||
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
|
||||
if file.try_lock().is_err() {
|
||||
self.locks.push((path.to_path_buf(), None));
|
||||
return std::fs::write(path, id.to_string());
|
||||
}
|
||||
file.set_len(0)?;
|
||||
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
|
||||
self.locks.push((path.to_path_buf(), Some(file)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app is closing normally: finish writes in progress, then delete this app's autosave
|
||||
/// files and its lock file.
|
||||
pub fn shutdown(&mut self) {
|
||||
@@ -502,6 +617,10 @@ mod native {
|
||||
discard(&path);
|
||||
}
|
||||
self.docs.clear();
|
||||
for (path, file) in self.locks.drain(..) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
let dir = savefilepath();
|
||||
if !dir.is_empty() && is_instance_id(&self.instance) {
|
||||
let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance));
|
||||
|
||||
@@ -268,11 +268,12 @@ impl DocState {
|
||||
}
|
||||
|
||||
/// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh
|
||||
/// undo history, nothing selected, model space.
|
||||
/// undo history, nothing selected, nothing for OOPS, model space.
|
||||
fn enter_block_editor(&mut self, be: BlockEdit) {
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.undo_ctl = Default::default();
|
||||
self.erased = Default::default();
|
||||
self.selection.clear();
|
||||
self.previous_selection.clear();
|
||||
self.space = cadcraft_doc::Space::Model;
|
||||
@@ -338,6 +339,7 @@ impl DocState {
|
||||
self.undo = host.undo;
|
||||
self.redo = host.redo;
|
||||
self.undo_ctl = host.undo_ctl;
|
||||
self.erased = host.erased;
|
||||
// The drawing comes back with nothing selected; Previous is what was selected before.
|
||||
self.selection.clear();
|
||||
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
|
||||
|
||||
@@ -22,9 +22,11 @@ mod inquiry;
|
||||
mod layer;
|
||||
mod layout;
|
||||
mod lisp;
|
||||
mod merge;
|
||||
mod mleader_content;
|
||||
mod mleader_edit;
|
||||
mod modify;
|
||||
pub(crate) use modify::Erased;
|
||||
mod modify2;
|
||||
mod mtext_opts;
|
||||
mod overkill;
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions
|
||||
mod angular;
|
||||
mod common;
|
||||
mod edit;
|
||||
mod leader;
|
||||
mod linear;
|
||||
mod mleader;
|
||||
mod radial;
|
||||
@@ -88,7 +89,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["mld"])
|
||||
.params("{points: [[arrow], ..., [landing]] ([location] with leaderType none), text?, leaderType?: straight|spline|none, block?: name, blockAttach?: center|insertion}")
|
||||
.interactive(|s| Ok(Box::new(MLeaderM::new(s)))),
|
||||
CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"),
|
||||
CommandSpec::new("leader", "Leader", run_leader)
|
||||
.alias(&["lead"])
|
||||
.params("{points: [[x,y]...], text?}")
|
||||
.interactive(|_| Ok(Box::new(leader::LeaderM::default()))),
|
||||
CommandSpec::new("dimstyle.update", "Update", run_update)
|
||||
.menu(&["Dimension", "Update"])
|
||||
.params("{handles?}")
|
||||
|
||||
@@ -22,6 +22,7 @@ pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2,
|
||||
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole, synthetic drawings): the
|
||||
//! points, Format and Undo, then annotation text, no annotation, or a block.
|
||||
//!
|
||||
//! The leader takes a hook line, a horizontal last segment DIMASZ long, when its last segment is
|
||||
//! straight and more than 15° off horizontal and text follows it; the text sits DIMGAP past the
|
||||
//! leader's end, above it (DIMTAD on) or centred on it (DIMTAD 0), on the side the leader runs to.
|
||||
//! All sizes are scaled by DIMSCALE. The text and the leader are separate objects here: the
|
||||
//! drawing model has no link from a leader to its annotation.
|
||||
|
||||
use super::common::style;
|
||||
use super::{Accept, EntityKind, Handle, Input, Interactive, MText, Prompt, Result, Session, Step, Vec2, v3};
|
||||
use crate::cmd::curves::KW;
|
||||
|
||||
/// Where LEADER is in its prompts.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Ask {
|
||||
#[default]
|
||||
Points,
|
||||
Format,
|
||||
FirstLine,
|
||||
NextLine,
|
||||
Option,
|
||||
/// Copy: the text or multiline text to copy.
|
||||
Copy,
|
||||
BlockName,
|
||||
BlockPoint,
|
||||
BlockX,
|
||||
BlockY,
|
||||
BlockRotation,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct LeaderM {
|
||||
ask: Ask,
|
||||
pts: Vec<Vec2>,
|
||||
spline: bool,
|
||||
no_arrow: bool,
|
||||
lines: Vec<String>,
|
||||
block: String,
|
||||
block_at: Vec2,
|
||||
block_scale: Vec2,
|
||||
}
|
||||
|
||||
/// What follows the leader.
|
||||
enum Annotation {
|
||||
Text(Vec<String>),
|
||||
/// A copy of a text or multiline text: its contents, height, width and style.
|
||||
Copy(MText),
|
||||
None,
|
||||
Block {
|
||||
name: String,
|
||||
at: Vec2,
|
||||
scale: Vec2,
|
||||
rotation: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Interactive for LeaderM {
|
||||
fn name(&self) -> &'static str {
|
||||
"LEADER"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let last = self.pts.last().copied();
|
||||
match self.ask {
|
||||
Ask::Points => match self.pts.len() {
|
||||
0 => Prompt::new("Specify leader start point", Accept::POINT),
|
||||
1 => Prompt::new("Specify next point", Accept::POINT).base_opt(last),
|
||||
_ => Prompt::new("Specify next point", Accept::POINT).kw(&["Annotation", "Format", "Undo"]).default("Annotation").base_opt(last),
|
||||
},
|
||||
Ask::Format => Prompt::new("Enter leader format option", KW).kw(&["Spline", "STraight", "Arrow", "None"]).default("Exit"),
|
||||
// "or <options>": the default is the options, not a keyword list.
|
||||
Ask::FirstLine => Prompt::new("Enter first line of annotation text or", Accept::TEXT).default("options"),
|
||||
Ask::NextLine => Prompt::new("Enter next line of annotation text", Accept::TEXT),
|
||||
Ask::Copy => Prompt::new("Select an object to copy", Accept::SELECT),
|
||||
Ask::Option => Prompt::new("Enter an annotation option", KW).kw(&["Tolerance", "Copy", "Block", "None", "Mtext"]).default("Mtext"),
|
||||
Ask::BlockName => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]),
|
||||
Ask::BlockPoint => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]),
|
||||
Ask::BlockX => Prompt::new("Enter X scale factor, specify opposite corner", Accept::NUMBER).kw(&["Corner", "XYZ"]).default("1"),
|
||||
Ask::BlockY => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
||||
Ask::BlockRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.ask {
|
||||
Ask::Points => match i {
|
||||
Input::Point(p) => self.pts.push(p),
|
||||
// Enter after one point: no leader.
|
||||
Input::Enter if self.pts.len() < 2 => return Ok(Step::Done),
|
||||
Input::Enter => self.ask = Ask::FirstLine,
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Annotation" => self.ask = Ask::FirstLine,
|
||||
"Format" => self.ask = Ask::Format,
|
||||
_ => {
|
||||
self.pts.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
},
|
||||
Ask::Format => {
|
||||
// Enter, or Exit typed, changes nothing; either way back to the points.
|
||||
if let Input::Keyword(k) = &i {
|
||||
match k.as_str() {
|
||||
"Spline" => self.spline = true,
|
||||
"STraight" => self.spline = false,
|
||||
"Arrow" => self.no_arrow = false,
|
||||
_ => self.no_arrow = true,
|
||||
}
|
||||
}
|
||||
self.ask = Ask::Points;
|
||||
}
|
||||
Ask::FirstLine | Ask::NextLine => match i {
|
||||
Input::Text(t) if !t.is_empty() => {
|
||||
self.lines.push(t);
|
||||
self.ask = Ask::NextLine;
|
||||
}
|
||||
_ if self.ask == Ask::NextLine => {
|
||||
let lines = std::mem::take(&mut self.lines);
|
||||
return self.finish(s, Annotation::Text(lines));
|
||||
}
|
||||
_ => self.ask = Ask::Option,
|
||||
},
|
||||
Ask::Option => match &i {
|
||||
Input::Keyword(k) if k == "None" => return self.finish(s, Annotation::None),
|
||||
Input::Keyword(k) if k == "Block" => self.ask = Ask::BlockName,
|
||||
Input::Keyword(k) if k == "Copy" => self.ask = Ask::Copy,
|
||||
Input::Text(_) => s.echo("Invalid option keyword."),
|
||||
// Mtext (the default) and Tolerance open an editor or a dialog in AutoCAD; at the
|
||||
// command line, as in accoreconsole, they come back to the first line.
|
||||
_ => self.ask = Ask::FirstLine,
|
||||
},
|
||||
Ask::Copy => {
|
||||
let picked = match &i {
|
||||
Input::Pick(hs) => hs.first().copied(),
|
||||
Input::Point(p) => crate::select::pick(s.doc()?, &s.state()?.edit_space(), *p, s.settings.pickbox.max(1.0) * s.pixel_size()),
|
||||
_ => None,
|
||||
};
|
||||
let text = picked.and_then(|h| s.doc().ok()?.entity(h).cloned()).and_then(|e| match &e.kind {
|
||||
EntityKind::Text(t) => Some(MText {
|
||||
insert: t.insert,
|
||||
height: t.height,
|
||||
width: 0.0,
|
||||
attach: 4,
|
||||
rotation: 0.0,
|
||||
style: t.style.clone(),
|
||||
contents: t.value.clone(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}),
|
||||
EntityKind::MText(m) => Some(m.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match text {
|
||||
Some(m) => return self.finish(s, Annotation::Copy(m)),
|
||||
None if picked.is_some() => s.echo("The object is not text."),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ask::BlockName => {
|
||||
let name = match i {
|
||||
Input::Text(t) => t.trim().to_string(),
|
||||
Input::Keyword(k) => k,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if name == "?" {
|
||||
let names: Vec<String> = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect();
|
||||
s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") }));
|
||||
} else if s.doc()?.block(&name).is_none() {
|
||||
s.echo(format!("Block \"{name}\" not found."));
|
||||
} else {
|
||||
self.block = name;
|
||||
self.ask = Ask::BlockPoint;
|
||||
}
|
||||
}
|
||||
Ask::BlockPoint => match i {
|
||||
Input::Point(p) => {
|
||||
self.block_at = p;
|
||||
self.ask = Ask::BlockX;
|
||||
}
|
||||
Input::Keyword(_) => s.echo("This option is not available here yet."),
|
||||
_ => {}
|
||||
},
|
||||
Ask::BlockX => match scale(&i) {
|
||||
Some(x) => {
|
||||
self.block_scale = Vec2::new(x, x);
|
||||
self.ask = Ask::BlockY;
|
||||
}
|
||||
None => s.echo("Requires a nonzero number."),
|
||||
},
|
||||
Ask::BlockY => {
|
||||
match scale(&i) {
|
||||
Some(y) if !matches!(i, Input::Enter) => self.block_scale.y = y,
|
||||
Some(_) => {}
|
||||
None => {
|
||||
s.echo("Requires a nonzero number.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
}
|
||||
self.ask = Ask::BlockRotation;
|
||||
}
|
||||
Ask::BlockRotation => {
|
||||
let rotation = match &i {
|
||||
Input::Enter => 0.0,
|
||||
Input::Text(t) => match s.angle_settings().direction(t) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
s.echo("Requires a valid angle.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
},
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let a = Annotation::Block { name: self.block.clone(), at: self.block_at, scale: self.block_scale, rotation };
|
||||
return self.finish(s, a);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
/// A scale factor typed, or 1 for Enter; `None` for zero or anything that isn't a number.
|
||||
fn scale(i: &Input) -> Option<f64> {
|
||||
match i {
|
||||
Input::Enter => Some(1.0),
|
||||
Input::Text(t) => t.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v != 0.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl LeaderM {
|
||||
fn finish(&mut self, s: &mut Session, ann: Annotation) -> Result<Step> {
|
||||
let pts = std::mem::take(&mut self.pts);
|
||||
add_leader(s, &pts, self.spline, !self.no_arrow, ann)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// The dimension settings LEADER draws with, from the current dimension style and DIMSCALE.
|
||||
struct Sizes {
|
||||
arrow: f64,
|
||||
gap: f64,
|
||||
text: f64,
|
||||
above: bool,
|
||||
text_style: String,
|
||||
}
|
||||
|
||||
fn sizes(s: &Session) -> Sizes {
|
||||
// The current style with the drawing's DIM* overrides (what SETVAR DIMTXT… sets).
|
||||
let st = s.doc().ok().and_then(|d| d.dim_style(&style(s)).map(|st| st.with_overrides(&d.dim_overrides()))).unwrap_or_default();
|
||||
let k = if st.scale > 0.0 { st.scale } else { 1.0 };
|
||||
let text_style = if st.text_style.is_empty() { "Standard".to_string() } else { st.text_style.clone() };
|
||||
Sizes { arrow: st.arrow_size * k, gap: st.text_gap.abs() * k, text: st.text_height * k, above: st.text_above != 0, text_style }
|
||||
}
|
||||
|
||||
/// LEADER's JSON form: a straight leader with an arrow through `pts`, and `text` (lines split at
|
||||
/// `\n`) placed as the command line places it. The handles of the leader and its text.
|
||||
pub(super) fn json_leader(s: &mut Session, pts: &[Vec2], text: Option<&str>) -> Result<Vec<Handle>> {
|
||||
let ann = match text {
|
||||
Some(t) if !t.is_empty() => Annotation::Text(t.split('\n').map(str::to_string).collect()),
|
||||
_ => Annotation::None,
|
||||
};
|
||||
add_leader(s, pts, false, true, ann)
|
||||
}
|
||||
|
||||
/// Draw the leader through `pts` and its annotation, as AutoCAD 2024 places them; their handles.
|
||||
fn add_leader(s: &mut Session, pts: &[Vec2], spline: bool, arrow: bool, ann: Annotation) -> Result<Vec<Handle>> {
|
||||
let (Some(&end), Some(&before)) = (pts.last(), pts.len().checked_sub(2).and_then(|i| pts.get(i))) else { return Ok(Vec::new()) };
|
||||
let z = sizes(s);
|
||||
// The side the leader runs to: right unless its last segment runs left.
|
||||
let dir = if end.x < before.x { -1.0 } else { 1.0 };
|
||||
let mut vertices = pts.to_vec();
|
||||
if let Annotation::Text(_) | Annotation::Copy(_) = ann {
|
||||
let d = end - before;
|
||||
// A copied text is centred on its line, and with DIMTAD on that line is above the leader:
|
||||
// the leader ends half the text height lower.
|
||||
if let (Annotation::Copy(_), true, Some(last)) = (&ann, z.above, vertices.last_mut()) {
|
||||
last.y -= z.text / 2.0;
|
||||
}
|
||||
let end = vertices.last().copied().unwrap_or(end);
|
||||
// More than 15° off horizontal: a hook line DIMASZ long into the end.
|
||||
if !spline && d.y.abs() > d.x.abs() * 15f64.to_radians().tan() {
|
||||
let at = vertices.len() - 1;
|
||||
vertices.insert(at, Vec2::new(end.x - dir * z.arrow, end.y));
|
||||
}
|
||||
}
|
||||
let leader = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
|
||||
vertices: vertices.iter().map(|p| v3(*p)).collect(),
|
||||
arrow,
|
||||
spline,
|
||||
style: style(s),
|
||||
}))?;
|
||||
let annotation = match ann {
|
||||
Annotation::Text(lines) => {
|
||||
let (attach, lift) = match (z.above, dir > 0.0) {
|
||||
(true, true) => (7, z.gap),
|
||||
(true, false) => (9, z.gap),
|
||||
(false, true) => (4, 0.0),
|
||||
(false, false) => (6, 0.0),
|
||||
};
|
||||
Some(s.add_entity(EntityKind::MText(MText {
|
||||
insert: v3(end + Vec2::new(dir * z.gap, lift)),
|
||||
height: z.text,
|
||||
width: 0.0,
|
||||
attach,
|
||||
rotation: 0.0,
|
||||
style: z.text_style,
|
||||
contents: lines.join("\\P"),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}))?)
|
||||
}
|
||||
Annotation::Copy(m) => {
|
||||
let lift = if z.above { z.gap } else { 0.0 };
|
||||
let attach = if dir > 0.0 { 4 } else { 6 };
|
||||
Some(s.add_entity(EntityKind::MText(MText { insert: v3(end + Vec2::new(dir * z.gap, lift)), attach, rotation: 0.0, ..m }))?)
|
||||
}
|
||||
Annotation::Block { name, at, scale, rotation } => {
|
||||
match crate::cmd::blocks::insert_with(s, &name, at, scale, rotation, None, &serde_json::Map::new()) {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
s.doc_mut()?.remove_entity(leader);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Annotation::None => None,
|
||||
};
|
||||
Ok(std::iter::once(leader).chain(annotation).collect())
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::common::style;
|
||||
use super::{
|
||||
Accept, EngineError, EntityKind, Input, Interactive, LeaderOrder, MLeader, MLeaderOptions, MText, Prompt, Result, Session, Step, Value, Vec2,
|
||||
bad, json, points_param, str_param, v3,
|
||||
@@ -57,33 +56,8 @@ pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("leader", "need 2+ points"));
|
||||
}
|
||||
let st = style(s);
|
||||
let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
|
||||
vertices: pts.iter().map(|q| v3(*q)).collect(),
|
||||
arrow: true,
|
||||
spline: false,
|
||||
style: st,
|
||||
}))?;
|
||||
let mut out = vec![h.hex()];
|
||||
if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) {
|
||||
let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18);
|
||||
out.push(
|
||||
s.add_entity(EntityKind::MText(MText {
|
||||
insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)),
|
||||
height: th,
|
||||
width: 0.0,
|
||||
attach: 4,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: t.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}))?
|
||||
.hex(),
|
||||
);
|
||||
}
|
||||
Ok(json!({ "handles": out }))
|
||||
let hs = super::leader::json_leader(s, &pts, str_param(p, "text"))?;
|
||||
Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// The MLEADER Options prompt being answered.
|
||||
|
||||
@@ -374,10 +374,14 @@ fn run_arraypolar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?;
|
||||
let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c);
|
||||
let row_spacing = f64_or(p, "rowSpacing", 1.0);
|
||||
let fill = f64_or(p, "angle", 360.0);
|
||||
if !(fill.abs() >= 1e-9 && fill.abs() <= 360.0 + 1e-9) {
|
||||
return Err(other("Requires a fill angle between -360 and 360 degrees (not 0)."));
|
||||
}
|
||||
let kind = ArrayKind::Polar(PolarArray {
|
||||
center: c - base,
|
||||
items: count32(n),
|
||||
fill: f64_or(p, "angle", 360.0),
|
||||
fill: fill.clamp(-360.0, 360.0),
|
||||
rotate: bool_or(p, "rotate", true),
|
||||
rows: count32(rows),
|
||||
row_spacing,
|
||||
|
||||
+191
-123
@@ -7,7 +7,7 @@ pub(in crate::cmd) mod purge;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign};
|
||||
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
@@ -17,12 +17,14 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, po
|
||||
use super::has_doc;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attdef;
|
||||
mod attedit;
|
||||
mod burst;
|
||||
mod description;
|
||||
mod dyngrip;
|
||||
mod extract;
|
||||
pub(crate) mod refedit;
|
||||
mod wblock;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -41,14 +43,18 @@ 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"])
|
||||
.params("{tag, prompt?, default?, at, height?, invisible?}")
|
||||
.interactive(|_| Ok(Box::new(AttdefM::default()))),
|
||||
.interactive(|_| Ok(Box::new(attdef::AttdefM::default()))),
|
||||
attedit::attedit_spec(),
|
||||
attedit::dash_attedit_spec(),
|
||||
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
|
||||
wblock::wblock_spec(),
|
||||
wblock::dash_wblock_spec(),
|
||||
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
|
||||
CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic)
|
||||
.params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)")
|
||||
@@ -184,9 +190,16 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
|
||||
b.entities.push(e);
|
||||
}
|
||||
doc.blocks.insert(name.to_string(), Arc::new(b));
|
||||
if keep != Keep::Retain {
|
||||
for h in hs {
|
||||
doc.remove_entity(*h);
|
||||
match keep {
|
||||
Keep::Retain => {}
|
||||
Keep::Convert => {
|
||||
for h in hs {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
}
|
||||
// As after ERASE, OOPS brings them back.
|
||||
Keep::Delete => {
|
||||
super::modify::erase_kept(s, hs)?;
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
@@ -223,6 +236,33 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option
|
||||
ins
|
||||
}
|
||||
|
||||
/// MINSERT's rectangular array of a block reference: rows and columns, and the distances between
|
||||
/// them along the reference's rotated axes (not scaled with it). One row and one column is a plain
|
||||
/// reference.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Grid {
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
row_spacing: f64,
|
||||
col_spacing: f64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
const ONE: Grid = Grid { rows: 1, cols: 1, row_spacing: 0.0, col_spacing: 0.0 };
|
||||
/// The most rows or columns MINSERT takes, as AutoCAD 2024 asks for them.
|
||||
const MAX: u32 = 32767;
|
||||
|
||||
/// A distance is kept only along an axis with more than one copy: AutoCAD 2024 asks for no
|
||||
/// other and stores 0 for it (one row of three columns: 44 = 20, 45 = 0).
|
||||
fn new(rows: u32, cols: u32, row_spacing: f64, col_spacing: f64) -> Grid {
|
||||
Grid { rows, cols, row_spacing: if rows > 1 { row_spacing } else { 0.0 }, col_spacing: if cols > 1 { col_spacing } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn apply(self, ins: &mut Insert) {
|
||||
(ins.rows, ins.cols, ins.row_spacing, ins.col_spacing) = (self.rows, self.cols, self.row_spacing, self.col_spacing);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
|
||||
/// [`insert_entity`]).
|
||||
pub(crate) fn insert_with(
|
||||
@@ -234,10 +274,12 @@ pub(crate) fn insert_with(
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values, Grid::ONE)
|
||||
}
|
||||
|
||||
/// [`insert_with`] with a Z scale factor of its own.
|
||||
/// [`insert_with`] with a Z scale factor of its own, and MINSERT's grid. A grid has the one set
|
||||
/// of attributes (AutoCAD 2024 asks for their values once).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_scaled(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
@@ -246,9 +288,11 @@ fn insert_scaled(
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
grid: Grid,
|
||||
) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut ins = insert_entity(&blk, at, scale, rotation, base);
|
||||
grid.apply(&mut ins);
|
||||
let m = ins.transform(blk.base.xy());
|
||||
for e in blk.entities.iter() {
|
||||
if let EntityKind::AttDef(ad) = &e.kind {
|
||||
@@ -300,24 +344,52 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
insert_json(s, p, "insert", Grid::ONE)
|
||||
}
|
||||
|
||||
/// MINSERT's JSON form: INSERT's parameters and the grid.
|
||||
fn run_minsert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let count = |key: &str| -> Result<u32> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(1),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.fract() == 0.0 && *n >= 1.0 && *n <= f64::from(Grid::MAX))
|
||||
.map(|n| n as u32)
|
||||
.ok_or_else(|| bad("minsert", format!("`{key}` must be an integer between 1 and {}", Grid::MAX))),
|
||||
}
|
||||
};
|
||||
// A distance given must be a number: `"15"` is refused, not taken as 0.
|
||||
let spacing = |key: &str| -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(0.0),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("minsert", format!("`{key}` must be a number"))),
|
||||
}
|
||||
};
|
||||
let grid = Grid::new(count("rows")?, count("columns")?, spacing("rowSpacing")?, spacing("columnSpacing")?);
|
||||
insert_json(s, p, "minsert", grid)
|
||||
}
|
||||
|
||||
/// INSERT's and MINSERT's JSON form.
|
||||
fn insert_json(s: &mut Session, p: &Value, cmd: &str, grid: Grid) -> Result<Value> {
|
||||
let p = &insert_file::json_params(s, p)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
|
||||
let at = point_req("insert", p, "at")?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
|
||||
let at = point_req(cmd, p, "at")?;
|
||||
// `scale`: one factor, or [x, y] for different X and Y scale factors.
|
||||
let scale = match p.get("scale") {
|
||||
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?,
|
||||
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad(cmd, "`scale` must be a number or [x, y]"))?,
|
||||
_ => {
|
||||
let f = f64_or(p, "scale", 1.0);
|
||||
Vec2::new(f, f)
|
||||
}
|
||||
};
|
||||
if scale.x == 0.0 || scale.y == 0.0 {
|
||||
return Err(bad("insert", "scale cannot be 0"));
|
||||
return Err(bad(cmd, "scale cannot be 0"));
|
||||
}
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?;
|
||||
if bool_or(p, "explode", false) {
|
||||
let h = insert_scaled(s, &name, at, Vec3::new(scale.x, scale.y, scale.x), rot, point_param(p, "basePoint"), &vals, grid)?;
|
||||
if cmd == "insert" && bool_or(p, "explode", false) {
|
||||
// The EXPLODE command body, not `execute`: INSERT records the one undo step.
|
||||
let explode = find_command("explode").ok_or_else(|| EngineError::UnknownCommand("explode".into()))?;
|
||||
return (explode.run)(s, &json!({ "handles": [h.hex()] }));
|
||||
@@ -361,61 +433,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
|
||||
}
|
||||
|
||||
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
|
||||
let src = s.doc()?;
|
||||
let mut d = cadcraft_doc::Drawing::new_imperial();
|
||||
// The written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its
|
||||
// units ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches
|
||||
// and scaled on insert, and its defaults (text size, current styles, annotation scales).
|
||||
d.header = src.header.clone();
|
||||
d.layers = src.layers.clone();
|
||||
d.linetypes = src.linetypes.clone();
|
||||
d.text_styles = src.text_styles.clone();
|
||||
d.dim_styles = src.dim_styles.clone();
|
||||
d.mleader_styles = src.mleader_styles.clone();
|
||||
d.table_styles = src.table_styles.clone();
|
||||
d.mline_styles = src.mline_styles.clone();
|
||||
d.scales = src.scales.clone();
|
||||
d.blocks = src.blocks.clone();
|
||||
// The base point becomes the written drawing's origin, its insertion base: by default 0,0 for
|
||||
// objects and the block's base point for a block (`base` is then in block coordinates).
|
||||
let base = point_param(p, "base");
|
||||
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
|
||||
let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO));
|
||||
let ents: Vec<Entity> = if let Some(name) = str_param(p, "name") {
|
||||
let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?;
|
||||
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
|
||||
b.entities
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(&m);
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
targets(s, p)?
|
||||
.iter()
|
||||
.filter_map(|h| src.entity(*h))
|
||||
.map(|e| {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(&m);
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for mut e in ents {
|
||||
e.handle = d.new_handle();
|
||||
d.model.push(e);
|
||||
}
|
||||
let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?;
|
||||
let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() }))
|
||||
}
|
||||
|
||||
fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = point_req("base", p, "at")?;
|
||||
s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at)));
|
||||
@@ -424,8 +441,11 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
// One object index for the whole listing: rebuilding it per block multiplies the block count
|
||||
// by the drawing's kept object count (CWE-407).
|
||||
let objects = cadcraft_doc::dynblock::ObjectIndex::new(d);
|
||||
let block = |b: &Block| {
|
||||
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
|
||||
let dynamic = cadcraft_doc::dynblock::is_dynamic_block_in(d, &objects, &b.name);
|
||||
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
|
||||
};
|
||||
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
|
||||
@@ -602,6 +622,15 @@ enum InsStage {
|
||||
/// XYZ: the Z scale factor, after X and Y.
|
||||
ZScale,
|
||||
Rotation,
|
||||
/// MINSERT: the number of rows, then of columns.
|
||||
Rows,
|
||||
Cols,
|
||||
/// MINSERT: the distance between rows, or a unit cell's first corner.
|
||||
RowSpacing,
|
||||
/// MINSERT: the unit cell's opposite corner (its width between columns, its height between
|
||||
/// rows).
|
||||
Cell(Vec2),
|
||||
ColSpacing,
|
||||
Attribs,
|
||||
/// A Verify attribute asked again.
|
||||
Verify,
|
||||
@@ -634,6 +663,12 @@ struct InsertM {
|
||||
explode: bool,
|
||||
/// The file waiting for the Redefine answer.
|
||||
file: Option<insert_file::FileRef>,
|
||||
/// MINSERT: after the rotation, the grid's rows and columns and the distances between them.
|
||||
minsert: bool,
|
||||
rows: Option<u32>,
|
||||
cols: Option<u32>,
|
||||
row_spacing: Option<f64>,
|
||||
col_spacing: Option<f64>,
|
||||
}
|
||||
|
||||
impl InsertM {
|
||||
@@ -643,6 +678,8 @@ impl InsertM {
|
||||
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
|
||||
} else if self.rotation.is_none() {
|
||||
InsStage::Rotation
|
||||
} else if let Some(g) = self.grid_stage() {
|
||||
g
|
||||
} else {
|
||||
InsStage::Attribs
|
||||
};
|
||||
@@ -661,7 +698,7 @@ impl InsertM {
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
{
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values, self.grid())?;
|
||||
if self.explode {
|
||||
insert_file::explode_insert(s, h)?;
|
||||
}
|
||||
@@ -670,6 +707,30 @@ impl InsertM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// MINSERT's next grid prompt, if any is left (as AutoCAD 2024 asks them): the rows, the
|
||||
/// columns, then the distance between rows when there is more than one row and between
|
||||
/// columns when there is more than one column. A unit cell gives both distances.
|
||||
fn grid_stage(&self) -> Option<InsStage> {
|
||||
if !self.minsert {
|
||||
return None;
|
||||
}
|
||||
let (Some(rows), Some(cols)) = (self.rows, self.cols) else {
|
||||
return Some(if self.rows.is_none() { InsStage::Rows } else { InsStage::Cols });
|
||||
};
|
||||
if rows > 1 && self.row_spacing.is_none() {
|
||||
Some(InsStage::RowSpacing)
|
||||
} else if cols > 1 && self.col_spacing.is_none() {
|
||||
Some(InsStage::ColSpacing)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid answered so far (one copy for INSERT).
|
||||
fn grid(&self) -> Grid {
|
||||
Grid::new(self.rows.unwrap_or(1), self.cols.unwrap_or(1), self.row_spacing.unwrap_or(0.0), self.col_spacing.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// The Verify attributes asked for.
|
||||
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
|
||||
self.attdefs.iter().filter(|a| a.3).collect()
|
||||
@@ -740,13 +801,18 @@ impl InsertM {
|
||||
}
|
||||
}
|
||||
|
||||
/// A distance typed at MINSERT's grid prompts: any finite number, negative or 0 too.
|
||||
fn distance(t: &str) -> Result<f64> {
|
||||
number(t).filter(|v| v.is_finite()).ok_or_else(|| EngineError::Other("Requires a distance.".into()))
|
||||
}
|
||||
|
||||
fn nonzero(t: &str) -> Result<f64> {
|
||||
number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))
|
||||
}
|
||||
|
||||
impl Interactive for InsertM {
|
||||
fn name(&self) -> &'static str {
|
||||
"INSERT"
|
||||
if self.minsert { "MINSERT" } else { "INSERT" }
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let at = self.at.unwrap_or_default();
|
||||
@@ -773,9 +839,16 @@ impl Interactive for InsertM {
|
||||
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
|
||||
InsStage::Rows => Prompt::new("Enter number of rows (---)", Accept::NUMBER).default("1"),
|
||||
InsStage::Cols => Prompt::new("Enter number of columns (|||)", Accept::NUMBER).default("1"),
|
||||
InsStage::RowSpacing => Prompt::new("Enter distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
|
||||
InsStage::Cell(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
InsStage::ColSpacing => Prompt::new("Specify distance between columns (|||)", Accept::NUMBER),
|
||||
InsStage::Attribs => {
|
||||
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
|
||||
let p = Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT);
|
||||
// No default shown when there is none ("Value:", AutoCAD 2024), not "<>".
|
||||
if default.is_empty() { p } else { p.default(default) }
|
||||
}
|
||||
InsStage::Verify => {
|
||||
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
|
||||
@@ -797,9 +870,9 @@ impl Interactive for InsertM {
|
||||
s.echo(format!("Defined blocks: {}", names.join(", ")));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// `*name`: insert exploded.
|
||||
self.explode = n.starts_with('*');
|
||||
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
|
||||
// `*name`: insert exploded (INSERT only: a grid has no exploded form).
|
||||
self.explode = !self.minsert && n.starts_with('*');
|
||||
let n = if self.explode { n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string() } else { n };
|
||||
match insert_file::file_ref(s, &n) {
|
||||
Some(f) if f.ask_redefine(s) => {
|
||||
self.file = Some(f);
|
||||
@@ -939,6 +1012,45 @@ impl Interactive for InsertM {
|
||||
});
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::Rows | InsStage::Cols => {
|
||||
let n = match i {
|
||||
Input::Text(t) => t
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= f64::from(Grid::MAX))
|
||||
.map(|v| v as u32)
|
||||
.ok_or_else(|| EngineError::Other(format!("Requires an integer between 1 and {}.", Grid::MAX)))?,
|
||||
Input::Enter => 1,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if self.stage == InsStage::Rows {
|
||||
self.rows = Some(n);
|
||||
} else {
|
||||
self.cols = Some(n);
|
||||
}
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::RowSpacing => match i {
|
||||
Input::Point(a) => self.stage = InsStage::Cell(a),
|
||||
Input::Text(t) => {
|
||||
self.row_spacing = Some(distance(&t)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
InsStage::Cell(a) => {
|
||||
if let Input::Point(b) = i {
|
||||
(self.col_spacing, self.row_spacing) = (Some(b.x - a.x), Some(b.y - a.y));
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
InsStage::ColSpacing => {
|
||||
if let Input::Text(t) = i {
|
||||
self.col_spacing = Some(distance(&t)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
InsStage::Attribs => {
|
||||
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let v = match i {
|
||||
@@ -979,53 +1091,9 @@ impl Interactive for InsertM {
|
||||
_ => Vec2::new(1.0, 1.0),
|
||||
});
|
||||
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
|
||||
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AttdefM {
|
||||
tag: Option<String>,
|
||||
prompt: Option<String>,
|
||||
default: Option<String>,
|
||||
}
|
||||
|
||||
impl Interactive for AttdefM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTDEF"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (&self.tag, &self.prompt, &self.default) {
|
||||
(None, ..) => Prompt::new("Enter attribute tag name", Accept::TEXT),
|
||||
(Some(_), None, _) => Prompt::new("Enter attribute prompt", Accept::TEXT),
|
||||
(Some(_), Some(_), None) => Prompt::new("Enter default attribute value", Accept::TEXT),
|
||||
_ => Prompt::new("Specify start point of text", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let text = match &i {
|
||||
Input::Text(t) => Some(t.clone()),
|
||||
Input::Enter => Some(String::new()),
|
||||
_ => None,
|
||||
};
|
||||
match (&self.tag, &self.prompt, &self.default) {
|
||||
(None, ..) => {
|
||||
if let Some(t) = text.filter(|t| !t.trim().is_empty() && !t.contains(' ')) {
|
||||
self.tag = Some(t.to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
(Some(_), None, _) => self.prompt = text,
|
||||
(Some(_), Some(_), None) => self.default = text,
|
||||
(Some(tag), Some(pr), Some(def)) => {
|
||||
if let Input::Point(p) = i {
|
||||
let h = s.doc()?.header.f64("TEXTSIZE", 0.2);
|
||||
let k = attdef_kind(s, tag, pr, def, p, h, false);
|
||||
s.add_entity(k)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
let mut ins = insert_entity(b, at, self.scale3(scale), rot, self.base);
|
||||
self.grid().apply(&mut ins);
|
||||
vec![EntityKind::Insert(ins)]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole): the attribute modes
|
||||
//! (kept in AFLAGS), the tag, prompt and default value (a constant attribute's value instead),
|
||||
//! then the text's point, justification, style, height and rotation.
|
||||
|
||||
use cadcraft_doc::{AttribModes, HAlign, VAlign};
|
||||
|
||||
use super::{Accept, Attrib, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Text, Vec2, number, v3};
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::draw::justify;
|
||||
|
||||
/// AFLAGS bits, as AutoCAD numbers the modes.
|
||||
const INVISIBLE: i64 = 1;
|
||||
const CONSTANT: i64 = 2;
|
||||
const VERIFY: i64 = 4;
|
||||
const PRESET: i64 = 8;
|
||||
const LOCK: i64 = 16;
|
||||
const MULTILINE: i64 = 32;
|
||||
|
||||
const MODES: [&str; 7] = ["Invisible", "Constant", "Verify", "Preset", "Lock position", "Annotative", "Multiple lines"];
|
||||
const JUSTIFY: [&str; 15] = ["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"];
|
||||
|
||||
/// Where -ATTDEF is in its prompts.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Ask {
|
||||
#[default]
|
||||
Modes,
|
||||
Tag,
|
||||
Prompt,
|
||||
Value,
|
||||
Point,
|
||||
Justify,
|
||||
Style,
|
||||
Height,
|
||||
Rotation,
|
||||
}
|
||||
|
||||
pub(super) struct AttdefM {
|
||||
ask: Ask,
|
||||
flags: i64,
|
||||
tag: String,
|
||||
prompt: String,
|
||||
value: String,
|
||||
justify: (HAlign, VAlign),
|
||||
style: String,
|
||||
at: Vec2,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
impl Default for AttdefM {
|
||||
fn default() -> Self {
|
||||
AttdefM {
|
||||
ask: Ask::Modes,
|
||||
flags: LOCK,
|
||||
tag: String::new(),
|
||||
prompt: String::new(),
|
||||
value: String::new(),
|
||||
justify: (HAlign::Left, VAlign::Baseline),
|
||||
style: "Standard".into(),
|
||||
at: Vec2::ZERO,
|
||||
height: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AttdefM {
|
||||
fn echo_modes(&self, s: &mut Session) {
|
||||
let yn = |bit: i64| if self.flags & bit != 0 { "Y" } else { "N" };
|
||||
s.echo("Current attribute modes:");
|
||||
s.echo(format!(
|
||||
"Invisible={} Constant={} Verify={} Preset={} Lock position={} Annotative=N Multiple line={}",
|
||||
yn(INVISIBLE),
|
||||
yn(CONSTANT),
|
||||
yn(VERIFY),
|
||||
yn(PRESET),
|
||||
yn(LOCK),
|
||||
yn(MULTILINE)
|
||||
));
|
||||
}
|
||||
|
||||
fn echo_style(&self, s: &mut Session) -> Result<()> {
|
||||
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
|
||||
s.echo(format!("Current text style: \"{}\" Text height: {th:.4} Justify: {}", self.style, justify_name(self.justify)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn point_prompt(&self) -> &'static str {
|
||||
match self.justify {
|
||||
(HAlign::Center, VAlign::Baseline) => "Specify center point of text",
|
||||
(HAlign::Right, VAlign::Baseline) => "Specify right endpoint of text baseline",
|
||||
(HAlign::Middle, _) | (HAlign::Center, VAlign::Middle) => "Specify middle point of text",
|
||||
(HAlign::Left, VAlign::Top) => "Specify top-left point of text",
|
||||
(HAlign::Center, VAlign::Top) => "Specify top-center point of text",
|
||||
(HAlign::Right, VAlign::Top) => "Specify top-right point of text",
|
||||
(HAlign::Left, VAlign::Middle) => "Specify middle-left point of text",
|
||||
(HAlign::Right, VAlign::Middle) => "Specify middle-right point of text",
|
||||
(HAlign::Left, VAlign::Bottom) => "Specify bottom-left point of text",
|
||||
(HAlign::Center, VAlign::Bottom) => "Specify bottom-center point of text",
|
||||
(HAlign::Right, VAlign::Bottom) => "Specify bottom-right point of text",
|
||||
_ => "Specify start point of text",
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, s: &mut Session, rotation: f64) -> Result<Step> {
|
||||
let (h, v) = self.justify;
|
||||
let left = h == HAlign::Left && v == VAlign::Baseline;
|
||||
s.add_entity(EntityKind::AttDef(Attrib {
|
||||
tag: self.tag.to_uppercase(),
|
||||
text: Text {
|
||||
insert: v3(self.at),
|
||||
align_pt: if left { None } else { Some(v3(self.at)) },
|
||||
height: self.height,
|
||||
value: std::mem::take(&mut self.value),
|
||||
rotation,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: self.style.clone(),
|
||||
halign: h,
|
||||
valign: v,
|
||||
},
|
||||
invisible: self.flags & INVISIBLE != 0,
|
||||
constant: self.flags & CONSTANT != 0,
|
||||
prompt: std::mem::take(&mut self.prompt),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: AttribModes { verify: self.flags & VERIFY != 0, preset: self.flags & PRESET != 0, lock_position: self.flags & LOCK != 0 },
|
||||
}))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// The justification's name in "Current text style: … Justify: …".
|
||||
fn justify_name(j: (HAlign, VAlign)) -> &'static str {
|
||||
JUSTIFY.into_iter().find(|k| justify(k) == Some(j)).unwrap_or("Left")
|
||||
}
|
||||
|
||||
impl Interactive for AttdefM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTDEF"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let d = s.doc()?;
|
||||
self.flags = d.header.i64("AFLAGS", LOCK);
|
||||
self.style = d.header.str("TEXTSTYLE", "Standard");
|
||||
self.echo_modes(s);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.ask {
|
||||
Ask::Modes => Prompt::new("Enter an option to change", KW).kw(&MODES).default("done"),
|
||||
Ask::Tag => Prompt::new("Enter attribute tag name", Accept::TEXT),
|
||||
Ask::Prompt => Prompt::new("Enter attribute prompt", Accept::TEXT),
|
||||
Ask::Value if self.flags & CONSTANT != 0 => Prompt::new("Enter attribute value", Accept::TEXT),
|
||||
Ask::Value => Prompt::new("Enter default attribute value", Accept::TEXT),
|
||||
// Once a justification is chosen AutoCAD asks for its point alone.
|
||||
Ask::Point if self.justify == (HAlign::Left, VAlign::Baseline) => {
|
||||
Prompt::new(self.point_prompt(), Accept::POINT).kw(&["Justify", "Style"])
|
||||
}
|
||||
Ask::Point => Prompt::new(self.point_prompt(), Accept::POINT),
|
||||
Ask::Justify => Prompt::new("Enter an option", Accept::TEXT).kw(&JUSTIFY),
|
||||
Ask::Style => Prompt::new("Enter style name", Accept::TEXT).kw(&["?"]).default(self.style.clone()),
|
||||
Ask::Height => {
|
||||
let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2);
|
||||
Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(self.at)
|
||||
}
|
||||
Ask::Rotation => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(self.at),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.ask {
|
||||
Ask::Modes => match &i {
|
||||
Input::Keyword(k) => {
|
||||
let bit = match k.as_str() {
|
||||
"Invisible" => INVISIBLE,
|
||||
"Constant" => CONSTANT,
|
||||
"Verify" => VERIFY,
|
||||
"Preset" => PRESET,
|
||||
"Lock position" => LOCK,
|
||||
_ => {
|
||||
s.echo(format!("{k} attributes are not available yet."));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
};
|
||||
self.flags ^= bit;
|
||||
s.doc_mut()?.header.set_i64("AFLAGS", self.flags);
|
||||
self.echo_modes(s);
|
||||
}
|
||||
Input::Enter => self.ask = Ask::Tag,
|
||||
Input::Text(_) => s.echo("Invalid option keyword."),
|
||||
_ => {}
|
||||
},
|
||||
Ask::Tag => match &i {
|
||||
// AutoCAD takes spaces in a tag here, and upper-cases it.
|
||||
Input::Text(t) if !t.trim().is_empty() => {
|
||||
self.tag = t.trim().to_string();
|
||||
self.ask = if self.flags & CONSTANT != 0 { Ask::Value } else { Ask::Prompt };
|
||||
}
|
||||
Input::Text(_) | Input::Enter => s.echo("Tag cannot be null"),
|
||||
_ => {}
|
||||
},
|
||||
Ask::Prompt | Ask::Value => {
|
||||
let t = match i {
|
||||
Input::Text(t) => t,
|
||||
Input::Enter => String::new(),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if self.ask == Ask::Prompt {
|
||||
self.prompt = t;
|
||||
self.ask = Ask::Value;
|
||||
} else {
|
||||
self.value = t;
|
||||
self.echo_style(s)?;
|
||||
self.ask = Ask::Point;
|
||||
}
|
||||
}
|
||||
Ask::Point => match i {
|
||||
Input::Point(p) => {
|
||||
self.at = p;
|
||||
self.ask = Ask::Height;
|
||||
}
|
||||
Input::Keyword(k) if k == "Justify" => self.ask = Ask::Justify,
|
||||
Input::Keyword(_) => self.ask = Ask::Style,
|
||||
_ => {}
|
||||
},
|
||||
Ask::Justify => {
|
||||
// A justification goes on to its point; anything else is asked again.
|
||||
if let Input::Keyword(k) | Input::Text(k) = &i {
|
||||
match justify(k) {
|
||||
// Aligned and fitted text take two points: not here yet.
|
||||
Some((HAlign::Aligned | HAlign::Fit, _)) => s.echo("Align and Fit are not available here yet."),
|
||||
Some(j) => {
|
||||
self.justify = j;
|
||||
self.ask = Ask::Point;
|
||||
}
|
||||
None => s.echo("Invalid option keyword."),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ask::Style => {
|
||||
let name = match i {
|
||||
Input::Enter => self.style.clone(),
|
||||
Input::Text(t) | Input::Keyword(t) => t.trim().to_string(),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if name == "?" {
|
||||
let names: Vec<String> = s.doc()?.text_styles.iter().map(|st| st.name.clone()).collect();
|
||||
s.echo(format!("Text styles: {}", names.join(", ")));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match s.doc()?.text_style(&name).map(|st| st.name.clone()) {
|
||||
Some(n) => {
|
||||
self.style = n;
|
||||
self.echo_style(s)?;
|
||||
self.ask = Ask::Point;
|
||||
}
|
||||
None => s.echo(format!("Cannot find text style \"{name}\".")),
|
||||
}
|
||||
}
|
||||
Ask::Height => {
|
||||
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
|
||||
let h = match &i {
|
||||
Input::Enter => th,
|
||||
Input::Point(p) => self.at.dist(*p),
|
||||
// A distance, as TEXT takes its height (1'6" in feet and inches, too).
|
||||
Input::Text(t) => number(t).unwrap_or(0.0),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if !(h.is_finite() && h > 0.0) {
|
||||
s.echo("Requires a positive height.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.height = h;
|
||||
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
|
||||
self.ask = Ask::Rotation;
|
||||
}
|
||||
Ask::Rotation => {
|
||||
let r = match &i {
|
||||
Input::Enter => 0.0,
|
||||
Input::Point(p) => self.at.angle_to(*p),
|
||||
Input::Text(t) => match s.angle_settings().direction(t) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
s.echo("Requires an angle.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
},
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
return self.finish(s, r);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (names this drawing already has keep this drawing's definitions). The file is read through the
|
||||
//! host's io hooks like OPEN, so the engine stays I/O-agnostic.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle};
|
||||
@@ -59,7 +59,7 @@ pub(super) fn file_ref(s: &Session, t: &str) -> Option<FileRef> {
|
||||
}
|
||||
|
||||
/// The file name without folders and extension (either separator, so Windows paths work anywhere).
|
||||
fn stem(path: &str) -> String {
|
||||
pub(super) 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(),
|
||||
@@ -102,14 +102,14 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
// Redefining keeps the existing definition's name (block names are case-insensitive).
|
||||
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
merge_tables(&mut d, src);
|
||||
let renames = block_names(&d, src, &name);
|
||||
let renames = crate::cmd::merge::block_renames(&d, src, Some(&name));
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
nb.name = to.clone();
|
||||
nb.entities = Default::default();
|
||||
for e in b.entities.iter() {
|
||||
let e = adopt(&mut d, e, &renames);
|
||||
let e = adopt(&mut d, src, e, &renames);
|
||||
nb.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(to.clone(), Arc::new(nb));
|
||||
@@ -118,7 +118,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
b.base = src.header.point("INSBASE").filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()).unwrap_or_default();
|
||||
let mut ents = Vec::new();
|
||||
for e in src.model.iter() {
|
||||
let e = adopt(&mut d, e, &renames);
|
||||
let e = adopt(&mut d, src, e, &renames);
|
||||
d.ensure_layer(&e.common.layer);
|
||||
ents.push(e);
|
||||
}
|
||||
@@ -136,13 +136,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition).
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
merge(&mut d.linetypes, &src.linetypes, |l| &l.name);
|
||||
merge(&mut d.text_styles, &src.text_styles, |t| &t.name);
|
||||
@@ -152,40 +146,22 @@ 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, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
let mut e = e.clone();
|
||||
let old = e.handle;
|
||||
e.handle = d.new_handle();
|
||||
// An attribute definition keeps its field: its extension dictionary and the objects in it.
|
||||
if matches!(e.kind, EntityKind::AttDef(_))
|
||||
&& let Some(root) = src.foreign.entities.get(&old).and_then(|x| x.xdict)
|
||||
{
|
||||
let tree = src.foreign.tree(root);
|
||||
let fresh: Vec<Handle> = (0..tree.len() + 2).map(|_| d.new_handle()).collect();
|
||||
let foreign = Arc::make_mut(&mut d.foreign);
|
||||
foreign.adopt_tree(&src.foreign, &tree, &fresh, e.handle);
|
||||
foreign.entities.entry(e.handle).or_default().xdict = fresh.first().copied();
|
||||
}
|
||||
match &mut e.kind {
|
||||
EntityKind::Insert(i) => {
|
||||
if let Some(n) = renames.get(&i.block) {
|
||||
|
||||
@@ -197,7 +197,21 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
Kind::Linetypes => {
|
||||
let used = lower(&mut entities(d).map(|e| e.common.linetype.clone()).chain(d.layers.iter().map(|l| l.linetype.clone())));
|
||||
// Objects and layers use linetypes, and so do dimension styles and a dimension's own
|
||||
// overrides (DIMLTYPE, DIMLTEX1, DIMLTEX2), and multiline styles' elements.
|
||||
let styles = d.dim_styles.iter().flat_map(|s| [s.dim_linetype.clone(), s.ext_linetype1.clone(), s.ext_linetype2.clone()]);
|
||||
let overrides = entities(d).filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm) } else { None }).flat_map(|dm| {
|
||||
["dimLinetype", "extLinetype1", "extLinetype2"].into_iter().filter_map(|k| dm.overrides.get(k)?.as_str().map(str::to_string))
|
||||
});
|
||||
let mlines = d.mline_styles.iter().flat_map(|s| s.elements.iter().map(|el| el.linetype.clone()));
|
||||
let used = lower(
|
||||
&mut entities(d)
|
||||
.map(|e| e.common.linetype.clone())
|
||||
.chain(d.layers.iter().map(|l| l.linetype.clone()))
|
||||
.chain(styles)
|
||||
.chain(overrides)
|
||||
.chain(mlines),
|
||||
);
|
||||
let cur = d.header.str("CELTYPE", "ByLayer");
|
||||
d.linetypes
|
||||
.iter()
|
||||
@@ -218,6 +232,7 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
let used = lower(&mut entities(d).filter_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some(dm.style.clone()),
|
||||
EntityKind::Tolerance(t) => Some(t.style.clone()),
|
||||
EntityKind::Leader(l) => Some(l.style.clone()),
|
||||
_ => None,
|
||||
}));
|
||||
let cur = d.header.str("DIMSTYLE", "Standard");
|
||||
@@ -299,6 +314,41 @@ fn retain<T>(v: &mut Vec<T>, name: impl Fn(&T) -> &String, n: &str) -> bool {
|
||||
v.len() != before
|
||||
}
|
||||
|
||||
/// Most passes [`purge_unused`] makes: each can free what the last one's objects used (a nested
|
||||
/// block, its layer, that layer's linetype).
|
||||
const MAX_PASSES: usize = 64;
|
||||
|
||||
/// Remove every named object drawing `d` doesn't use, as PURGE All would, nested ones included;
|
||||
/// the defaults and the current ones stay. For a drawing being written (WBLOCK's), not the one
|
||||
/// being edited: nothing goes through the session or its undo.
|
||||
pub(in crate::cmd) fn purge_unused(d: &mut Drawing) {
|
||||
for _ in 0..MAX_PASSES {
|
||||
let mut removed = false;
|
||||
for kind in ALL {
|
||||
for name in candidates(d, *kind) {
|
||||
removed |= match kind {
|
||||
Kind::Blocks => {
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&name)).cloned();
|
||||
key.is_some_and(|k| d.blocks.remove(&k).is_some())
|
||||
}
|
||||
Kind::Layers => retain(&mut d.layers, |l| &l.name, &name),
|
||||
Kind::TextStyles => retain(&mut d.text_styles, |t| &t.name, &name),
|
||||
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, &name),
|
||||
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, &name),
|
||||
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, &name),
|
||||
Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, &name),
|
||||
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, &name),
|
||||
Kind::Groups => retain(&mut d.groups, |g| &g.name, &name),
|
||||
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if !removed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-space and layout objects with no length, or text with no characters.
|
||||
fn degenerate(d: &Drawing, kind: Kind) -> Vec<Handle> {
|
||||
let zero = |e: &EntityKind| match e {
|
||||
|
||||
@@ -281,6 +281,8 @@ fn close(s: &mut Session, save: bool) -> Result<Value> {
|
||||
}
|
||||
nd.end_refedit_discard(&r);
|
||||
*s.doc_mut()? = nd;
|
||||
// The objects the edit managed were its own: OOPS doesn't bring them back after it.
|
||||
s.state_mut()?.erased.forget(|h| r.owns(h));
|
||||
s.set_selection(Vec::new());
|
||||
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! WBLOCK (its JSON form) and -WBLOCK at the command line: objects, a block or the whole drawing
|
||||
//! written to a drawing file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, Handle};
|
||||
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::curves::KW;
|
||||
use super::super::file::{drops_unknown, new_folder, not_saved, not_saved_message, report_new_folder, report_not_saved};
|
||||
use super::super::machines::{SelOutcome, SelectPhase};
|
||||
use super::super::modify::erase_kept;
|
||||
use super::super::{CommandSpec, bad, point_param, str_param, targets};
|
||||
use super::insert_file::stem;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn wblock_spec() -> CommandSpec {
|
||||
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}")
|
||||
}
|
||||
|
||||
/// -WBLOCK erases the objects it writes, whatever its mOde option says: AutoCAD 2024 does, and
|
||||
/// keeps "Object conversion=Delete" as the setting. OOPS brings them back, as after ERASE.
|
||||
pub(super) fn dash_wblock_spec() -> CommandSpec {
|
||||
CommandSpec::new("-wblock", "Write Block", run_dash_wblock)
|
||||
.params("{path, name? | handles?, base?} (the objects written are erased)")
|
||||
.interactive(|_| Ok(Box::new(DashWblockM::default())))
|
||||
}
|
||||
|
||||
/// The web build has no file system: -WBLOCK would erase objects it can't save.
|
||||
const NO_FILES: &str = "files can't be written in the web build";
|
||||
|
||||
/// What WBLOCK writes.
|
||||
enum Source<'a> {
|
||||
/// These objects, `base` becoming the written drawing's origin.
|
||||
Objects(&'a [Handle], Vec2),
|
||||
/// A block's objects, from its base point or `base` (in block coordinates).
|
||||
Block(&'a str, Option<Vec2>),
|
||||
/// The whole drawing.
|
||||
Whole,
|
||||
}
|
||||
|
||||
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
|
||||
let base = point_param(p, "base");
|
||||
let folder = new_folder(&path);
|
||||
let (entities, bytes, lost) = match str_param(p, "name") {
|
||||
Some(name) => write(s, "wblock", &path, Source::Block(name, base))?,
|
||||
None => write(s, "wblock", &path, Source::Objects(&targets(s, p)?, base.unwrap_or(Vec2::ZERO)))?,
|
||||
};
|
||||
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
|
||||
report_new_folder(s, &mut r, folder);
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn run_dash_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err(bad("-wblock", NO_FILES));
|
||||
}
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("-wblock", "`path` is required"))?.to_string();
|
||||
let base = point_param(p, "base");
|
||||
let folder = new_folder(&path);
|
||||
let (entities, bytes, lost) = match str_param(p, "name") {
|
||||
Some(name) => write(s, "-wblock", &path, Source::Block(name, base))?,
|
||||
None => {
|
||||
let hs = targets(s, p)?;
|
||||
// As at the command line, nothing selected writes no file.
|
||||
if !hs.iter().any(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())) {
|
||||
return Err(bad("-wblock", "no objects selected"));
|
||||
}
|
||||
let written = write(s, "-wblock", &path, Source::Objects(&hs, base.unwrap_or(Vec2::ZERO)))?;
|
||||
erase(s, &hs)?;
|
||||
written
|
||||
}
|
||||
};
|
||||
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
|
||||
report_new_folder(s, &mut r, folder);
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Write `what` to `path` through the io hooks; the objects written, the file's size and what a
|
||||
/// DWG file left out, for the caller to report as a save does. The
|
||||
/// written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its units
|
||||
/// ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches and
|
||||
/// scaled on insert, and its defaults (text size, current styles, annotation scales).
|
||||
fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usize, BTreeMap<String, usize>)> {
|
||||
let src = s.doc()?;
|
||||
// The base point becomes the written drawing's origin: by default 0,0 for objects and the
|
||||
// block's base point for a block.
|
||||
let d = match what {
|
||||
Source::Whole => src.clone(),
|
||||
Source::Block(name, base) => {
|
||||
let b = src.block(name).ok_or_else(|| bad(cmd, format!("no block `{name}`")))?;
|
||||
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
|
||||
with_objects(src, b.entities.iter().map(|e| moved(e, &m)))
|
||||
}
|
||||
Source::Objects(hs, base) => {
|
||||
let m = Mat3::translate(-base);
|
||||
with_objects(src, hs.iter().filter_map(|h| src.entity(*h)).map(|e| moved(e, &m)))
|
||||
}
|
||||
};
|
||||
let hooks = super::super::file::io().ok_or_else(|| bad(cmd, "file formats are not available in this build"))?;
|
||||
let bytes = (hooks.write)(&d, path).map_err(|e| bad(cmd, e))?;
|
||||
// Through a temporary file, as SAVE writes, so a failed write keeps the file it replaces.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
let lost = if drops_unknown(path) { not_saved(&d) } else { BTreeMap::new() };
|
||||
Ok((d.model.len(), bytes.len(), lost))
|
||||
}
|
||||
|
||||
/// Say on the command line what the written DWG file left out.
|
||||
fn echo_not_saved(s: &mut Session, lost: &BTreeMap<String, usize>) {
|
||||
if let Some(msg) = not_saved_message(lost) {
|
||||
s.echo(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new drawing of `ents`, with `src`'s settings and the layers, linetypes, styles and blocks
|
||||
/// they use, as AutoCAD's WBLOCK writes them (#2423): what nothing in it uses is purged, and
|
||||
/// the defaults and the current ones stay. Its insertion base is 0,0.
|
||||
fn with_objects(src: &Drawing, ents: impl Iterator<Item = Entity>) -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.header = src.header.clone();
|
||||
d.layers = src.layers.clone();
|
||||
d.linetypes = src.linetypes.clone();
|
||||
d.text_styles = src.text_styles.clone();
|
||||
d.dim_styles = src.dim_styles.clone();
|
||||
d.mleader_styles = src.mleader_styles.clone();
|
||||
d.table_styles = src.table_styles.clone();
|
||||
d.mline_styles = src.mline_styles.clone();
|
||||
d.scales = src.scales.clone();
|
||||
d.blocks = src.blocks.clone();
|
||||
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
|
||||
// The copied blocks keep their objects' handles, so the new ones start above them.
|
||||
let kept: Vec<Handle> = d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.handle)).collect();
|
||||
for h in kept {
|
||||
d.bump_handseed(h);
|
||||
}
|
||||
for mut e in ents {
|
||||
e.handle = d.new_handle();
|
||||
d.model.push(e);
|
||||
}
|
||||
super::purge::purge_unused(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
fn moved(e: &Entity, m: &Mat3) -> Entity {
|
||||
let mut e = e.clone();
|
||||
e.kind.transform(m);
|
||||
e
|
||||
}
|
||||
|
||||
fn erase(s: &mut Session, hs: &[Handle]) -> Result<()> {
|
||||
erase_kept(s, hs)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The file -WBLOCK writes for a typed name: as named when it ends in .dwg or .dxf, else with
|
||||
/// .dwg added, as AutoCAD names a drawing.
|
||||
fn output_path(typed: &str) -> String {
|
||||
let t = typed.trim().trim_matches('"');
|
||||
let lower = t.to_ascii_lowercase();
|
||||
if lower.ends_with(".dwg") || lower.ends_with(".dxf") { t.to_string() } else { format!("{t}.dwg") }
|
||||
}
|
||||
|
||||
/// Whether a file is there to replace; the web has no file system.
|
||||
fn exists(path: &str) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::path::Path::new(path).exists()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Where -WBLOCK is in its prompts, as AutoCAD 2024 asks them.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Ask {
|
||||
#[default]
|
||||
File,
|
||||
/// The file exists: "… already exists, do you want to replace it? [Yes/No] <N>".
|
||||
Replace,
|
||||
/// A DXF file's decimal places of accuracy.
|
||||
Precision,
|
||||
/// An existing block, `=` (the block named as the file), `*` (the whole drawing), or Enter
|
||||
/// for objects.
|
||||
Block,
|
||||
Base,
|
||||
/// mOde at the base point prompt: asked, but the objects are erased whatever it says.
|
||||
Mode,
|
||||
Objects,
|
||||
}
|
||||
|
||||
const SAME_NAME: &str = "= (block=output file)";
|
||||
const WHOLE: &str = "* (whole drawing)";
|
||||
|
||||
#[derive(Default)]
|
||||
struct DashWblockM {
|
||||
ask: Ask,
|
||||
path: String,
|
||||
base: Vec2,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl Interactive for DashWblockM {
|
||||
fn name(&self) -> &'static str {
|
||||
"WBLOCK"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
s.echo(format!("-WBLOCK: {NO_FILES}."));
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
s.echo("Current settings: Object conversion=Delete");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.ask {
|
||||
Ask::File => Prompt::new("Enter name of output file", Accept::TEXT),
|
||||
Ask::Replace => Prompt::new(format!("{} already exists, do you want to replace it?", self.path), KW).kw(&["Yes", "No"]).default("N"),
|
||||
Ask::Precision => Prompt::new("Enter decimal places of accuracy (0 to 16)", Accept::NUMBER).kw(&["Binary", "Preview"]).default("16"),
|
||||
Ask::Block => Prompt::new("Enter name of existing block", Accept::TEXT).kw(&[SAME_NAME, WHOLE]).default("define new drawing"),
|
||||
Ask::Base => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["mOde"]),
|
||||
Ask::Mode => {
|
||||
Prompt::new("Choose mode for selected object conversion", KW).kw(&["Convert to block", "Retain", "Delete"]).default("Delete")
|
||||
}
|
||||
Ask::Objects => self.sel.prompt(),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.ask {
|
||||
Ask::File => {
|
||||
if let Input::Text(t) = &i
|
||||
&& !t.trim().trim_matches('"').is_empty()
|
||||
{
|
||||
self.path = output_path(t);
|
||||
self.ask = if exists(&self.path) { Ask::Replace } else { self.after_file(s) };
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Ask::Replace => match &i {
|
||||
// Yes goes on; No, the default, ends the command; anything else is asked again.
|
||||
Input::Keyword(k) if k == "Yes" => {
|
||||
self.ask = self.after_file(s);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(_) | Input::Enter => Ok(Step::Done),
|
||||
_ => {
|
||||
s.echo("Invalid option keyword.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
Ask::Precision => {
|
||||
match &i {
|
||||
Input::Enter => self.ask = Ask::Block,
|
||||
// The file is written at full precision whatever is asked.
|
||||
Input::Text(t) if t.trim().parse::<u8>().is_ok_and(|n| n <= 16) => self.ask = Ask::Block,
|
||||
Input::Keyword(k) if k == "Binary" => s.echo("Binary DXF output is not available."),
|
||||
Input::Keyword(_) => {}
|
||||
_ => s.echo("Requires an integer between 0 and 16."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Ask::Block => {
|
||||
let name = match &i {
|
||||
Input::Enter => {
|
||||
self.ask = Ask::Base;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Input::Keyword(k) if k == WHOLE => return self.write(s, Source::Whole),
|
||||
Input::Keyword(k) if k == SAME_NAME => stem(&self.path),
|
||||
Input::Text(t) => t.trim().to_string(),
|
||||
// A point (from a LISP list) is a name too: AutoCAD answers Block "0,0" not found.
|
||||
Input::Point(p) => format!("{},{}", p.x, p.y),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if s.doc()?.block(&name).is_none() {
|
||||
s.echo(format!("Block \"{name}\" not found."));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.write(s, Source::Block(&name, None))
|
||||
}
|
||||
Ask::Base => {
|
||||
match i {
|
||||
Input::Keyword(_) => self.ask = Ask::Mode,
|
||||
Input::Enter | Input::Text(_) => s.echo("Point or option keyword required."),
|
||||
Input::Point(p) => {
|
||||
self.base = p;
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
let picked = self.sel.picked.clone();
|
||||
return self.write_objects(s, &picked);
|
||||
}
|
||||
self.ask = Ask::Objects;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Ask::Mode => {
|
||||
// Any answer, Enter included, goes back to the base point.
|
||||
self.ask = Ask::Base;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Ask::Objects => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
// Nothing selected: nothing is written.
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => self.write_objects(s, &hs),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DashWblockM {
|
||||
/// The prompt after the file name: a DXF file's precision, else the block.
|
||||
fn after_file(&self, s: &mut Session) -> Ask {
|
||||
if self.path.to_ascii_lowercase().ends_with(".dxf") {
|
||||
s.echo("Current DXF settings: Precision= 16 Format= ASCII Preview= No");
|
||||
Ask::Precision
|
||||
} else {
|
||||
Ask::Block
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, s: &mut Session, what: Source) -> Result<Step> {
|
||||
match write(s, "-wblock", &self.path, what) {
|
||||
Ok((.., lost)) => echo_not_saved(s, &lost),
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
fn write_objects(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
if hs.is_empty() {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
match write(s, "-wblock", &self.path, Source::Objects(hs, self.base)) {
|
||||
Ok((.., lost)) => {
|
||||
echo_not_saved(s, &lost);
|
||||
erase(s, hs)?;
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
|
||||
//! block empty with a status saying why; nothing here stops the host drawing from opening.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -208,7 +209,7 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
|
||||
}
|
||||
let (files, refused) = candidates(path, host, chosen);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let found = files.into_iter().find(|p| p.is_file());
|
||||
let found = files.iter().find(|p| p.is_file()).cloned().or_else(|| files.iter().find_map(|p| case_insensitive(p)));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let found: Option<PathBuf> = {
|
||||
let _ = files;
|
||||
@@ -217,6 +218,43 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
|
||||
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
|
||||
}
|
||||
|
||||
/// Upper bound on directory entries scanned per path component by [`case_insensitive`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_CASE_SCAN: usize = 10_000;
|
||||
|
||||
/// The existing file `p` names when its letters' case is ignored, on file systems that don't
|
||||
/// ignore it themselves. Drawings made on Windows often store a reference as `PLAN.DWG` for
|
||||
/// `Plan.dwg`; Windows finds it, and so should Linux. Each missing component is matched against
|
||||
/// its folder's entries; an exact match always wins, and an ambiguous one finds nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn case_insensitive(p: &Path) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let mut out = PathBuf::new();
|
||||
for c in p.components() {
|
||||
let Component::Normal(name) = c else {
|
||||
out.push(c.as_os_str());
|
||||
continue;
|
||||
};
|
||||
let exact = out.join(name);
|
||||
if exact.exists() {
|
||||
out = exact;
|
||||
continue;
|
||||
}
|
||||
let want = name.to_str()?.to_lowercase();
|
||||
let mut hits = std::fs::read_dir(if out.as_os_str().is_empty() { Path::new(".") } else { &out })
|
||||
.ok()?
|
||||
.take(MAX_CASE_SCAN)
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| n.to_lowercase() == want));
|
||||
let hit = hits.next()?;
|
||||
if hits.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
out.push(hit.file_name());
|
||||
}
|
||||
out.is_file().then_some(out)
|
||||
}
|
||||
|
||||
/// Read a drawing file through the host's io hooks, counting its bytes against the load's total.
|
||||
fn read(file: &Path, cx: &mut Ctx) -> Result<Drawing, String> {
|
||||
let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?;
|
||||
@@ -302,14 +340,15 @@ fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &
|
||||
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
|
||||
Ok((x, file)) => match import(d, &key, &x, cx) {
|
||||
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
|
||||
Err(e) => (XrefStatus::Unresolved, e),
|
||||
Err(e) => {
|
||||
// Whatever made it in before the failure goes again. A fetch that failed imported
|
||||
// nothing, so it doesn't pay for a second scan of the block table.
|
||||
clear(d, &key);
|
||||
(XrefStatus::Unresolved, e)
|
||||
}
|
||||
},
|
||||
Err(e) => e,
|
||||
};
|
||||
if status != XrefStatus::Loaded {
|
||||
// Whatever made it in before the failure goes again.
|
||||
clear(d, &key);
|
||||
}
|
||||
set_status(d, &key, status);
|
||||
(status, detail)
|
||||
}
|
||||
@@ -353,9 +392,15 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
return Err(format!("more than {MAX_XREF_OBJECTS} objects"));
|
||||
}
|
||||
let dep = |sym: Sym, n: &str| (!shared(sym, n)).then(|| format!("{key}|{n}"));
|
||||
// The host's names per table, looked up in a set built once: both drawings are hostile input,
|
||||
// so a scan per imported record would be quadratic.
|
||||
fn names<T>(all: &[T], name: fn(&T) -> &str) -> HashSet<String> {
|
||||
all.iter().map(|t| name(t).to_ascii_uppercase()).collect()
|
||||
}
|
||||
let mut have = names(&d.layers, |l| &l.name);
|
||||
for l in &x.layers {
|
||||
if let Some(n) = dep(Sym::Layer, &l.name)
|
||||
&& d.layer(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
let mut l = l.clone();
|
||||
l.name = n;
|
||||
@@ -363,39 +408,44 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.linetypes, |t| &t.name);
|
||||
for t in &x.linetypes {
|
||||
if let Some(n) = dep(Sym::Linetype, &t.name)
|
||||
&& d.linetype(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
d.linetypes.push(cadcraft_doc::Linetype { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.text_styles, |t| &t.name);
|
||||
for t in &x.text_styles {
|
||||
if let Some(n) = dep(Sym::Style, &t.name)
|
||||
&& d.text_style(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
d.text_styles.push(cadcraft_doc::TextStyle { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.dim_styles, |t| &t.name);
|
||||
for t in &x.dim_styles {
|
||||
if let Some(n) = dep(Sym::DimStyle, &t.name)
|
||||
&& d.dim_style(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.dim_styles.push(cadcraft_doc::DimStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.mleader_styles, |t| &t.name);
|
||||
for t in &x.mleader_styles {
|
||||
if let Some(n) = dep(Sym::MLeaderStyle, &t.name)
|
||||
&& !d.mleader_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.mleader_styles.push(cadcraft_doc::MLeaderStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.table_styles, |t| &t.name);
|
||||
for t in &x.table_styles {
|
||||
if let Some(n) = dep(Sym::TableStyle, &t.name)
|
||||
&& !d.table_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
d.table_styles.push(cadcraft_doc::TableStyle { name: n, ..t.clone() });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
|
||||
//! end can put it on the system clipboard and paste it into another CADCraft window.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
|
||||
@@ -279,20 +279,14 @@ fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
|
||||
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
|
||||
/// their name in `defs` to their name in `d`.
|
||||
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::cmd::merge::merge_named as add;
|
||||
add(&mut d.layers, &defs.layers, |l| &l.name);
|
||||
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
|
||||
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
|
||||
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
|
||||
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
|
||||
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
|
||||
let renames = block_names(d, defs);
|
||||
let renames = crate::cmd::merge::block_renames(d, defs, None);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = defs.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -308,34 +302,6 @@ fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
renames
|
||||
}
|
||||
|
||||
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
|
||||
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
|
||||
/// already has keeps `d`'s definition (not copied).
|
||||
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
|
||||
for (name, b) in &defs.blocks {
|
||||
let upper = up(name);
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(up(&to));
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
|
||||
/// objects of the source drawing (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -33,6 +33,7 @@ use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray};
|
||||
use polygon::{PolygonM, run_polygon};
|
||||
use solid::{SolidM, run_solid};
|
||||
use spline::{SplineM, run_spline};
|
||||
pub(crate) use text::justify;
|
||||
use text::{MTextM, TextM, run_mtext, run_text};
|
||||
|
||||
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
|
||||
|
||||
@@ -153,19 +153,17 @@ impl Interactive for HelixM {
|
||||
}
|
||||
match (self.center, self.rb, self.rt) {
|
||||
(None, ..) => Prompt::new("Specify center point of base", Accept::POINT),
|
||||
(Some(c), None, _) => {
|
||||
Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER)
|
||||
.kw(if self.diameter { &[] } else { &["Diameter"] })
|
||||
.default("1.0000")
|
||||
.base(c)
|
||||
}
|
||||
(Some(c), None, _) => Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius" }, Accept::POINT_OR_NUMBER)
|
||||
.kw(if self.diameter { &[] } else { &["Diameter"] })
|
||||
.default("1.0000")
|
||||
.base(c),
|
||||
(Some(c), Some(rb), None) => {
|
||||
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER)
|
||||
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius" }, Accept::POINT_OR_NUMBER)
|
||||
.kw(if self.diameter { &[] } else { &["Diameter"] })
|
||||
.default(format!("{rb:.4}"))
|
||||
.base(c)
|
||||
}
|
||||
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER)
|
||||
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["Axis endpoint", "Turns", "turn Height", "tWist"])
|
||||
.default("1.0000")
|
||||
.base(c),
|
||||
|
||||
@@ -119,7 +119,7 @@ impl Interactive for XlineM2 {
|
||||
Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b),
|
||||
},
|
||||
XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT),
|
||||
XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]),
|
||||
XMode::AngAsk => Prompt::new("Enter angle of xline (0)", Accept::NUMBER).kw(&["Reference"]),
|
||||
XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT),
|
||||
XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"),
|
||||
XMode::Bisect => match self.bis.len() {
|
||||
@@ -129,7 +129,7 @@ impl Interactive for XlineM2 {
|
||||
},
|
||||
XMode::OffDist => {
|
||||
let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0);
|
||||
Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
|
||||
Prompt::new("Specify offset distance", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
|
||||
"Through".into()
|
||||
} else {
|
||||
format!("{d:.4}")
|
||||
|
||||
@@ -549,13 +549,7 @@ fn run_pasteorig(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let mut hs = s.selection();
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let d = s.doc_mut()?;
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
if d.remove_entity(h).is_some() {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let n = super::modify::erase_kept(s, &hs)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(json!({ "erased": n }))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! stays I/O-agnostic; the JSON forms never open dialogs (the UI asks for files when these
|
||||
//! commands come from a menu, the tool bar or the command line).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Rgb;
|
||||
@@ -82,7 +82,7 @@ fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
|
||||
{
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -117,14 +117,19 @@ fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !EXPORT_FORMATS.contains(&format.as_str()) {
|
||||
return Err(bad("export", "`format` must be dxf, dwg, svg, png or pdf (or give a `path` with one of those extensions)"));
|
||||
}
|
||||
let bytes = (hooks.write)(s.doc()?, &format!("export.{format}")).map_err(|e| bad("export", e))?;
|
||||
match path {
|
||||
let file = format!("export.{format}");
|
||||
let bytes = (hooks.write)(s.doc()?, &file).map_err(|e| bad("export", e))?;
|
||||
let folder = path.and_then(super::file::new_folder);
|
||||
let mut r = match path {
|
||||
Some(path) => {
|
||||
write_file("export", path, &bytes)?;
|
||||
Ok(json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") }))
|
||||
json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") })
|
||||
}
|
||||
None => Ok(json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() })),
|
||||
}
|
||||
None => json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() }),
|
||||
};
|
||||
super::file::report_new_folder(s, &mut r, folder);
|
||||
super::file::report_loss(s, &mut r, &file);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
// ---------- IMPORT ----------
|
||||
@@ -161,7 +166,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let (layers, styles) = merge_tables(d, src);
|
||||
let renames = block_names(d, src);
|
||||
let renames = crate::cmd::merge::block_renames(d, src, None);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -208,16 +213,7 @@ fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition). Returns how many layers and how many other records were added.
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
||||
let mut n = 0;
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
let layers = merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
let others = merge(&mut d.linetypes, &src.linetypes, |l| &l.name)
|
||||
+ merge(&mut d.text_styles, &src.text_styles, |t| &t.name)
|
||||
@@ -227,35 +223,6 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
||||
(layers, others)
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
||||
/// already has keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let anonymous = b.anonymous || name.starts_with('*');
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if anonymous {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
@@ -330,17 +297,23 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
|
||||
page.landscape = l;
|
||||
}
|
||||
// A layout's saved page setup (plot window, scale to fit, scale, centring) applies unless the call says otherwise (#2493).
|
||||
// An explicit `fit: false` without a `scale` asks for the plain 1:1 plot.
|
||||
let plain = p.get("fit").and_then(Value::as_bool) == Some(false) && p.get("scale").is_none();
|
||||
let saved = if space.is_paper() && !plain { cadcraft_render::paper::saved_plot(&page) } else { Default::default() };
|
||||
let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
|
||||
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
|
||||
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
|
||||
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
|
||||
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
||||
let given_scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
||||
// A saved scale to fit applies only when the call gives neither `fit` nor `scale`, as in the PDF.
|
||||
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model) || (saved.fit && given_scale.is_none()));
|
||||
let scale = given_scale.or(saved.scale);
|
||||
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
|
||||
let height = ((width as f64) * sheet.size.y / sheet.size.x).round();
|
||||
let height = if height.is_finite() { (height as u64).clamp(16, MAX_PREVIEW_PX) } else { width };
|
||||
let px_per_paper_unit = width as f64 / sheet.size.x;
|
||||
// Curves are flattened to about half a pixel.
|
||||
let est = plot_scale(&d.extents(&space), &sheet, fit, scale);
|
||||
let est = plot_scale(&saved.window.unwrap_or_else(|| d.extents(&space)), &sheet, fit, scale);
|
||||
let tol = 0.5 / (px_per_paper_unit * est);
|
||||
let ropts = cadcraft_render::Options {
|
||||
tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 },
|
||||
@@ -352,9 +325,13 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
..Default::default()
|
||||
};
|
||||
let list = cadcraft_render::build_plot(d, &space, &ropts);
|
||||
let b = list.bounds;
|
||||
let b = saved.window.unwrap_or(list.bounds);
|
||||
// Paper point q shows drawing point (q - to) / k + from.
|
||||
let (from, k, to) = if fit || space == Space::Model {
|
||||
let (from, k, to) = if saved.window.is_some() {
|
||||
// A saved window, fitted or at a saved scale: its centre (or corner) on the printable area's.
|
||||
let c = saved.center;
|
||||
(if c { b.center() } else { b.min }, plot_scale(&b, &sheet, fit, scale), if c { sheet.printable.center() } else { sheet.printable.min })
|
||||
} else if fit || space == Space::Model {
|
||||
(if b.is_empty() { Vec2::ZERO } else { b.center() }, plot_scale(&b, &sheet, fit, scale), sheet.printable.center())
|
||||
} else {
|
||||
(Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO)
|
||||
@@ -372,7 +349,20 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
px_per_mm: if lineweights && px_per_mm.is_finite() { px_per_mm } else { 0.0 },
|
||||
antialias: true,
|
||||
};
|
||||
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
|
||||
let mut pm = cadcraft_render::raster::render(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
|
||||
// A placed plot (fitted, or a saved window) shows only inside the printable area, as the PDF and PNG plots clip it.
|
||||
if fit || saved.window.is_some() || space == Space::Model {
|
||||
// The view may scale and centre the sheet (the height cap), so map the printable corners
|
||||
// (paper point q shows drawing point (q - to) / k + from) through it.
|
||||
let screen = |q: Vec2| {
|
||||
let (x, y) = view.to_screen((q - to) / k + from);
|
||||
(f64::from(x), f64::from(y))
|
||||
};
|
||||
let (min, max) = (sheet.printable.min, sheet.printable.max);
|
||||
let (a, b) = (screen(Vec2::new(min.x, max.y)), screen(Vec2::new(max.x, min.y)));
|
||||
cadcraft_render::raster::blank_outside_pixels(&mut pm, a, b);
|
||||
}
|
||||
let png = pm.encode_png().map_err(|_| bad("preview", "rendering the preview failed"))?;
|
||||
let layout = space.tab_name();
|
||||
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
|
||||
}
|
||||
@@ -738,6 +728,140 @@ mod tests {
|
||||
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
|
||||
}
|
||||
|
||||
/// A layout's saved page setup (plot window, scale to fit) shapes its preview by default, as
|
||||
/// it shapes the PDF; `fit: false` still gives the plain 1:1 sheet (#2493).
|
||||
#[test]
|
||||
fn preview_uses_the_layouts_saved_page_setup_by_default() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
|
||||
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
|
||||
let unsaved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = [20.0, 5.0, 836.0, 589.0];
|
||||
page.scale_to_fit = true;
|
||||
let saved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
|
||||
assert_ne!(saved, unsaved, "the saved window and scale to fit change the preview");
|
||||
assert_eq!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false })), unsaved, "fit: false is the plain 1:1 sheet");
|
||||
}
|
||||
|
||||
/// With a saved plot window the preview follows the page setup's centring (fitted or not) and an
|
||||
/// explicit `scale` beats a saved scale to fit, as in the PDF.
|
||||
/// A placed saved window shows only inside the printable area, as the PDF and PNG plots clip it:
|
||||
/// geometry in the margin leaves the preview unchanged.
|
||||
#[test]
|
||||
fn preview_clips_a_saved_window_to_the_printable_area() {
|
||||
let build = |margin_line: bool| {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0.0, 30.0], [100.0, 30.0]] })).unwrap();
|
||||
if margin_line {
|
||||
s.execute("line", &json!({ "points": [[-3.0, 0.0], [-3.0, 60.0]] })).unwrap();
|
||||
}
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [10, 10, 10, 10] })).unwrap();
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = [0.0, 0.0, 100.0, 60.0];
|
||||
page.scale_to_fit = true;
|
||||
page.center = true;
|
||||
s.execute("preview", &json!({ "layout": "Layout1", "width": 600 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
assert_eq!(build(true), build(false), "a line in the margin is clipped away");
|
||||
}
|
||||
|
||||
/// On a tall sheet the height cap makes the view scale and centre the sheet in the image; the
|
||||
/// printable clip must follow it, so ink in the margin is still blanked.
|
||||
#[test]
|
||||
fn preview_clip_follows_the_view_when_the_height_cap_applies() {
|
||||
let build = |margin_line: bool| {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0.0, 50.0], [100.0, 50.0]] })).unwrap();
|
||||
if margin_line {
|
||||
s.execute("line", &json!({ "points": [[-2.6, 0.0], [-2.6, 100.0]] })).unwrap();
|
||||
}
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "margins": [10, 10, 10, 10] })).unwrap();
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.width_mm = 210.0;
|
||||
page.height_mm = 1200.0;
|
||||
page.landscape = false;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = [0.0, 0.0, 100.0, 100.0];
|
||||
page.scale_to_fit = true;
|
||||
page.center = true;
|
||||
s.execute("preview", &json!({ "layout": "Layout1", "width": 1000 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
assert_eq!(build(true), build(false), "a line in the margin is clipped away on a tall sheet");
|
||||
}
|
||||
|
||||
/// Hostile saved setups (a degenerate or far-away window, a scale of 0 or not finite) fall back
|
||||
/// to the plain preview and never panic, and a layout with no saved setup previews exactly as
|
||||
/// the plain 1:1 sheet does.
|
||||
#[test]
|
||||
fn preview_survives_hostile_saved_setups_and_default_is_unchanged() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[20.0, 5.0], [200.0, 100.0]] })).unwrap();
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true })).unwrap();
|
||||
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
|
||||
let call = json!({ "layout": "Layout1", "width": 200 });
|
||||
let plain = png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false }));
|
||||
assert_eq!(png(&mut s, call.clone()), plain, "no saved setup: the default preview is the plain sheet");
|
||||
let set = |s: &mut Session, window: [f64; 4], scale: f64, fit: bool| {
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = window;
|
||||
page.scale = scale;
|
||||
page.scale_to_fit = fit;
|
||||
};
|
||||
let good = [0.0, 0.0, 100.0, 60.0];
|
||||
let (nan, inf) = (f64::NAN, f64::INFINITY);
|
||||
for window in [[5.0, 5.0, 5.0, 40.0], [5.0, 5.0, 40.0, 5.0], [nan, 0.0, 10.0, 10.0], [0.0, 0.0, inf, 10.0]] {
|
||||
set(&mut s, window, 1.0, true);
|
||||
assert_eq!(png(&mut s, call.clone()), plain, "{window:?}: a degenerate window changes nothing");
|
||||
}
|
||||
for scale in [0.0, -2.0, nan, inf] {
|
||||
set(&mut s, good, scale, false);
|
||||
let _ = png(&mut s, call.clone());
|
||||
set(&mut s, [5.0, 5.0, 5.0, 40.0], scale, false);
|
||||
assert_eq!(png(&mut s, call.clone()), plain, "{scale}: an unusable saved scale is ignored");
|
||||
}
|
||||
set(&mut s, [1e9, 1e9, 1e9 + 10.0, 1e9 + 10.0], 1.0, true);
|
||||
let _ = png(&mut s, call.clone());
|
||||
set(&mut s, [-1e300, -1e300, 1e300, 1e300], 1.0, true);
|
||||
let _ = png(&mut s, call.clone());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_honours_saved_centering_and_an_explicit_scale() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = [20.0, 5.0, 400.0, 200.0];
|
||||
page.scale_to_fit = true;
|
||||
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
|
||||
let set_center = |s: &mut Session, c: bool| s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page.center = c;
|
||||
set_center(&mut s, true);
|
||||
let centred = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
|
||||
set_center(&mut s, false);
|
||||
// The window is fitted by width, so only the other axis shows where it sits.
|
||||
let corner = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
|
||||
assert_ne!(corner, centred, "a saved centre: false puts the fitted window at the printable corner");
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.center = true;
|
||||
let by_scale = png(&mut s, json!({ "layout": "Layout1", "width": 200, "scale": 0.5 }));
|
||||
assert_ne!(by_scale, centred, "an explicit scale is not the fitted plot");
|
||||
assert_eq!(
|
||||
by_scale,
|
||||
png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })),
|
||||
"scale decides, not the saved fit"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn pdfbook_collects_frames_into_one_pdf_per_row() {
|
||||
our_hooks();
|
||||
|
||||
+154
-20
@@ -85,12 +85,13 @@ pub fn deliver() -> Option<DeliverHook> {
|
||||
DELIVER.get().copied()
|
||||
}
|
||||
|
||||
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`) and
|
||||
/// binary instead of ASCII DXF.
|
||||
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`), binary
|
||||
/// instead of ASCII DXF, and Windows fonts for Linux ones ([`cadcraft_doc::Drawing::portable_fonts`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SaveAs {
|
||||
pub version: Option<Version>,
|
||||
pub binary: bool,
|
||||
pub portable_fonts: bool,
|
||||
}
|
||||
|
||||
/// Write a drawing with [`SaveAs`] options (installed by hosts whose writer supports them).
|
||||
@@ -131,7 +132,12 @@ fn save_options(cmd: &str, p: &Value) -> Result<SaveAs> {
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`binary` must be true or false")),
|
||||
};
|
||||
Ok(SaveAs { version, binary })
|
||||
let portable_fonts = match p.get("portableFonts") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`portableFonts` must be true or false")),
|
||||
};
|
||||
Ok(SaveAs { version, binary, portable_fonts })
|
||||
}
|
||||
|
||||
/// Whether `path` names a DXF file (or has no extension, which saves as DXF).
|
||||
@@ -148,7 +154,9 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
|
||||
}
|
||||
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
|
||||
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
|
||||
let r = if *opts == SaveAs::default() {
|
||||
let copy = (opts.portable_fonts && is_drawing_file(path)).then(|| Drawing { portable_fonts: true, ..d.clone() });
|
||||
let d = copy.as_ref().unwrap_or(d);
|
||||
let r = if opts.version.is_none() && !opts.binary {
|
||||
(hooks.write)(d, path)
|
||||
} else {
|
||||
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
|
||||
@@ -178,11 +186,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("saveas", "Save As...", run_saveas_reporting)
|
||||
.menu(&["File", "Save As..."])
|
||||
.key("Cmd+Shift+S")
|
||||
.params("{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool}")
|
||||
.params(
|
||||
"{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool, portableFonts?: bool (Windows fonts for Linux ones, in this file)}",
|
||||
)
|
||||
.noundo(),
|
||||
CommandSpec::new("document.switch", "Switch Drawing", run_switch).params("{index}").enabled(always).noundo(),
|
||||
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
|
||||
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
|
||||
.params("{format?: dxf, version?: 2000…2018, binary?: bool, portableFonts?: bool} → {data: base64}")
|
||||
.noundo(),
|
||||
CommandSpec::new("script", "Run Script...", run_script)
|
||||
.menu(&["Tools", "Run Script..."])
|
||||
@@ -395,7 +405,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
|
||||
let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(path) };
|
||||
let opts = SaveAs { binary: s.state()?.binary_dxf && is_dxf(path), ..SaveAs::default() };
|
||||
save_as(s, "save", path, &opts)
|
||||
}
|
||||
|
||||
@@ -430,13 +440,14 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
|
||||
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if is_drawing_file(path) && crate::autosave::isavebak() {
|
||||
// A file in a folder still to be made has nothing to back up.
|
||||
if is_drawing_file(path) && crate::autosave::isavebak() && crate::atomic_write::missing_folder(std::path::Path::new(path)).is_none() {
|
||||
backup(std::path::Path::new(path))
|
||||
.map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?;
|
||||
}
|
||||
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -677,6 +688,7 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if is_drawing_file(&path) {
|
||||
r["version"] = json!(opts.version.unwrap_or_else(|| drawing_version(s, &path)).year());
|
||||
r["binary"] = json!(opts.binary);
|
||||
r["portableFonts"] = json!(opts.portable_fonts);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
@@ -684,8 +696,11 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The version a drawing is saved in at `path` when none is asked for: its `$ACADVER` (2018 by
|
||||
/// default), and for DWG at least 2004 (the io layer's rule: R2000 DWG has no true colours).
|
||||
fn drawing_version(s: &Session, path: &str) -> Version {
|
||||
let v =
|
||||
s.doc().ok().and_then(|d| d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver)).unwrap_or_default();
|
||||
let v = s
|
||||
.state()
|
||||
.ok()
|
||||
.and_then(|st| st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver))
|
||||
.unwrap_or_default();
|
||||
if is_dxf(path) { v } else { v.max(Version::R2004) }
|
||||
}
|
||||
|
||||
@@ -714,6 +729,8 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
|
||||
if drops_unknown(format!("drawing.{fmt}")) {
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
} else if version >= Version::R2013 && is_dxf_file(format!("drawing.{fmt}")) {
|
||||
report_sat_text_dropped(s, &mut r);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
@@ -731,15 +748,28 @@ pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
|
||||
/// store section, which is written back with the drawing (`Foreign::data_store`).
|
||||
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
|
||||
|
||||
/// Entity types CADCraft doesn't model that the DWG bridge writes all the same: acadrust reads
|
||||
/// them from our DXF into entities of its own and writes them to DWG of every version we save,
|
||||
/// an OLE object with its frame and its embedded object's bytes unchanged. Matched exactly: the
|
||||
/// bridge gets the type as stored, and acadrust knows only this spelling of it.
|
||||
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.
|
||||
/// 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() };
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
|
||||
for e in stores.flat_map(|s| s.iter()) {
|
||||
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
|
||||
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
|
||||
// A DWG before 2013 writes a solid's SAT text as it is; from 2013 the geometry has to be
|
||||
// in the data store, and SAT text alone is lost (#2334).
|
||||
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t))
|
||||
&& (stored.contains(&e.handle) || (version < Version::R2013 && cadcraft_doc::foreign::has_sat_text(&u.tags)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if DWG_WRITES.contains(&u.dxf_type.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let n = out.entry(u.dxf_type.clone()).or_insert(0);
|
||||
@@ -755,20 +785,39 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
|
||||
return None;
|
||||
}
|
||||
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
|
||||
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
|
||||
Some(format!(
|
||||
"Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them, solids only when saved as 2010 or earlier): {}",
|
||||
list.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
/// What saving the active drawing to `path` would leave out, before anything is written: empty
|
||||
/// when the format keeps everything. The interactive save asks with this; [`save_reporting`]
|
||||
/// reports the same list afterwards to callers that never get a dialog.
|
||||
pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap<String, usize> {
|
||||
let version = drawing_version(s, path);
|
||||
if is_dxf_file(path) && version >= Version::R2013 {
|
||||
// The drawing a file holds: the host while the Block Editor is open.
|
||||
return s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
|
||||
}
|
||||
if !drops_unknown(path) {
|
||||
return Default::default();
|
||||
}
|
||||
let version = drawing_version(s, path);
|
||||
s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Report what the file `path`, just written at the drawing's own version, left out.
|
||||
pub(super) fn report_loss(s: &mut Session, r: &mut Value, path: &str) {
|
||||
if is_dxf_file(path) {
|
||||
if drawing_version(s, path) >= Version::R2013 {
|
||||
report_sat_text_dropped(s, r);
|
||||
}
|
||||
} else {
|
||||
let lost = loss_on_save(s, path);
|
||||
report_not_saved(s, r, &lost);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
|
||||
/// bridge drops types it doesn't know); DXF writes them back as read.
|
||||
pub fn drops_unknown(path: impl AsRef<std::path::Path>) -> bool {
|
||||
@@ -780,8 +829,53 @@ fn is_drawing_file(path: impl AsRef<std::path::Path>) -> bool {
|
||||
Format::from_path(path).is_some_and(Format::is_drawing)
|
||||
}
|
||||
|
||||
/// Whether `path` is a DXF file.
|
||||
fn is_dxf_file(path: impl AsRef<std::path::Path>) -> bool {
|
||||
Format::from_path(path) == Some(Format::Dxf)
|
||||
}
|
||||
|
||||
/// Say on the command line which solid modeler entities a DXF of 2013 or later left out because
|
||||
/// they hold SAT-text ACIS data, which such files keep in a data store instead (#2334).
|
||||
fn report_sat_text_dropped(s: &mut Session, r: &mut Value) {
|
||||
// The drawing a file holds: the host while the Block Editor is open.
|
||||
let lost = s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
|
||||
if lost.is_empty() {
|
||||
return;
|
||||
}
|
||||
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
|
||||
s.echo(format!(
|
||||
"Not saved in the DXF file (2013 and later keep ACIS geometry in a data store these entities have no record in; save as 2010 or earlier to keep them): {}",
|
||||
list.join(", ")
|
||||
));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("notSaved".into(), json!(lost));
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder a write to `path` has to create, asked before writing so it can be reported after.
|
||||
/// The web has no folders.
|
||||
pub(super) fn new_folder(path: &str) -> Option<String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return crate::atomic_write::missing_folder(std::path::Path::new(path)).map(|f| f.display().to_string());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Say on the command line that the file went into a folder that didn't exist, so a mistyped path
|
||||
/// is noticed, and name the folder in the result's `createdFolder`.
|
||||
pub(super) fn report_new_folder(s: &mut Session, r: &mut Value, folder: Option<String>) {
|
||||
let Some(folder) = folder else { return };
|
||||
s.echo(format!("Created the folder {folder}, which did not exist. Check the path if you did not mean to."));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("createdFolder".into(), json!(folder));
|
||||
}
|
||||
}
|
||||
|
||||
/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`.
|
||||
fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
|
||||
pub(super) fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
|
||||
let Some(msg) = not_saved_message(lost) else { return };
|
||||
s.echo(msg);
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
@@ -790,24 +884,64 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr
|
||||
}
|
||||
|
||||
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
|
||||
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
|
||||
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>, asked: Option<Version>) -> Result<Value> {
|
||||
// What the file will hold depends on the version it is saved as (the data store from 2013).
|
||||
// QSAVE takes no `version` and keeps the drawing's own; SAVEAS writes the one asked for.
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
|
||||
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
|
||||
let version = asked.unwrap_or_else(|| drawing_version(s, &path));
|
||||
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
|
||||
let folder = new_folder(&path);
|
||||
let mut r = run(s, p)?;
|
||||
report_new_folder(s, &mut r, folder);
|
||||
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
} else if version >= Version::R2013 && r.get("path").and_then(Value::as_str).is_some_and(is_dxf_file) {
|
||||
report_sat_text_dropped(s, &mut r);
|
||||
}
|
||||
if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) {
|
||||
report_fonts(s, &mut r);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Name the text styles whose font in the saved file AutoCAD on Windows doesn't have: in the
|
||||
/// result, and at the command line when the list changed since the last save. Styles from an xref
|
||||
/// (`XREF|name`) aren't saved: AutoCAD takes them from the xref.
|
||||
fn report_fonts(s: &mut Session, r: &mut Value) {
|
||||
use cadcraft_fonts::windows::{missing_on_windows, windows_font, written_font};
|
||||
let portable = r.get("portableFonts").and_then(Value::as_bool).unwrap_or(false);
|
||||
let Ok(d) = s.state().map(|st| st.drawing()) else { return };
|
||||
let fonts: Vec<(String, String)> = d
|
||||
.text_styles
|
||||
.iter()
|
||||
.filter(|t| !t.name.contains('|') && missing_on_windows(written_font(&t.font, portable)))
|
||||
.map(|t| (t.name.clone(), t.font.clone()))
|
||||
.collect();
|
||||
let list = fonts.iter().map(|(style, font)| format!("{style} ({font})")).collect::<Vec<_>>().join(", ");
|
||||
let changed = s.state_mut().map(|st| std::mem::replace(&mut st.fonts_noted, list.clone()) != list).unwrap_or(true);
|
||||
if fonts.is_empty() {
|
||||
return;
|
||||
}
|
||||
if changed {
|
||||
let hint = if fonts.iter().any(|(_, font)| windows_font(font).is_some()) {
|
||||
" SAVEAS with portableFonts writes Windows fonts for those that have one."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
s.echo(format!("Fonts AutoCAD on Windows doesn't have: {list}.{hint}"));
|
||||
}
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("nonPortableFonts".into(), json!(fonts.iter().map(|(style, font)| json!({ "style": style, "font": font })).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
|
||||
fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
save_reporting(s, p, run_save)
|
||||
save_reporting(s, p, run_save, None)
|
||||
}
|
||||
|
||||
fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
save_reporting(s, p, run_saveas)
|
||||
let asked = save_options("saveas", p)?.version;
|
||||
save_reporting(s, p, run_saveas, asked)
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, Layer, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad};
|
||||
@@ -197,6 +197,7 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
d.skipped_records.clear();
|
||||
d.handseed = d.handseed.max(other.handseed);
|
||||
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
merge(&mut d.layers, &other.layers, |t| &t.name);
|
||||
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
|
||||
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
|
||||
@@ -235,22 +236,17 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
|
||||
}
|
||||
|
||||
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
|
||||
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
|
||||
for t in from {
|
||||
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
|
||||
to.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add copies of `objs` to model space with new handles, their block references renamed by
|
||||
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
|
||||
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
|
||||
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
|
||||
let mut members = Vec::new();
|
||||
// The layers this drawing has, looked up once per object rather than scanned.
|
||||
let mut layers: HashSet<String> = d.layers.iter().map(|l| l.name.to_ascii_uppercase()).collect();
|
||||
for (e, color) in objs {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
if layers.insert(e.common.layer.to_ascii_uppercase()) {
|
||||
d.layers.push(Layer::new(&e.common.layer));
|
||||
}
|
||||
let h = d.new_handle();
|
||||
let mut copy = Entity { handle: h, ..(*e).clone() };
|
||||
detach(&mut copy.kind);
|
||||
|
||||
@@ -217,6 +217,11 @@ fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point3_req(s, "dist", p, "p1")?;
|
||||
let b = point3_req(s, "dist", p, "p2")?;
|
||||
let d = b - a;
|
||||
if !d.len().is_finite() {
|
||||
return Err(bad("dist", "the distance is too large"));
|
||||
}
|
||||
s.distance = d.len();
|
||||
s.last_point = a.xy();
|
||||
Ok(json!({
|
||||
"distance": d.len(),
|
||||
"angle": d.xy().angle().to_degrees(),
|
||||
@@ -285,6 +290,14 @@ fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
|
||||
EntityKind::MText(t) => Some(t.insert.z),
|
||||
EntityKind::Insert(i) => Some(i.insert.z),
|
||||
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
|
||||
// Drawn as its faces' visible edges: the Z on the nearest one.
|
||||
EntityKind::PolyfaceMesh(m) => m
|
||||
.faces3d()
|
||||
.iter()
|
||||
.flat_map(cadcraft_doc::Face3d::visible_edges)
|
||||
.map(|(a, b)| on_segment(a, b))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(z, _)| z),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -422,6 +435,8 @@ fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(what) = too_large(a, per) {
|
||||
return Err(bad("area", format!("the {what} is too large")));
|
||||
}
|
||||
s.area = a;
|
||||
s.perimeter = per;
|
||||
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
|
||||
}
|
||||
|
||||
@@ -508,6 +523,13 @@ pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
total
|
||||
}
|
||||
EntityKind::Region(r) => (r.area(), r.perimeter()),
|
||||
// Like an open polyline: the area closed by the chord, the perimeter its own length.
|
||||
EntityKind::Spline(sp) if sp.is_valid() => {
|
||||
let b = sp.bounds();
|
||||
let pts = sp.tessellate_spans((b.min.dist(b.max) * 1e-7).max(1e-12));
|
||||
// A length past f64's range is too large to report, as a polyline's is, not 0.
|
||||
(cadcraft_geom::shoelace(&pts).abs(), sp.checked_length().unwrap_or(f64::INFINITY))
|
||||
}
|
||||
_ => return Err(bad("area", "object has no area")),
|
||||
})
|
||||
}
|
||||
@@ -574,6 +596,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
|
||||
// Records of the file the drawing came from that the reader could not place.
|
||||
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
|
||||
// What reading the file left out, in words (a damaged DWG's unreadable object records).
|
||||
"readNotes": d.read_notes,
|
||||
});
|
||||
if bool_or(p, "entities", true)
|
||||
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
|
||||
@@ -864,16 +888,25 @@ impl Interactive for DistM {
|
||||
self.first_source = s.picked_viewport;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(a), Input::Point(b)) => {
|
||||
(Some(first), Input::Point(b)) => {
|
||||
let (a, b) = match (self.first_source, s.picked_viewport) {
|
||||
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
|
||||
_ => (a, b),
|
||||
_ => (first, b),
|
||||
};
|
||||
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
|
||||
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
|
||||
let (a, b) = (Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
|
||||
if !(b - a).len().is_finite() {
|
||||
// The first point stays LASTPOINT, as after a measurement.
|
||||
s.last_point = first;
|
||||
return Err(bad("dist", "the distance is too large"));
|
||||
}
|
||||
let t = dist_text(s, a, b);
|
||||
for l in t.lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
// As in AutoCAD, the second point doesn't become LASTPOINT: the first stays.
|
||||
s.distance = (b - a).len();
|
||||
s.last_point = first;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Some(a), Input::Keyword(k)) if k == "Multiple points" => {
|
||||
@@ -901,7 +934,11 @@ impl DistM {
|
||||
.as_deref()
|
||||
.or(self.multi.as_deref())
|
||||
.map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) });
|
||||
if !d.is_finite() {
|
||||
return Err(bad("dist", "the distance is too large"));
|
||||
}
|
||||
s.echo(format!("Distance = {}", fmt_d(s, d)));
|
||||
s.distance = d;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
@@ -1115,10 +1152,14 @@ impl AreaM {
|
||||
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
||||
}
|
||||
s.echo(area_text(s, a, per));
|
||||
// AREA: this area, or in Add and Subtract mode the running total; PERIMETER: this area's.
|
||||
s.perimeter = per;
|
||||
if self.mode == AreaMode::Single {
|
||||
s.area = a;
|
||||
return Step::Done;
|
||||
}
|
||||
self.total = total;
|
||||
s.area = total;
|
||||
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
|
||||
Step::Continue
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result<Option<Region>> {
|
||||
}
|
||||
loops.retain(|l| l.vertices.len() >= 2);
|
||||
let mut r = Region { loops };
|
||||
r.renest();
|
||||
if !r.renest() {
|
||||
return Err(other("These regions are too complex to combine."));
|
||||
}
|
||||
Ok(Some(r))
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ fn loaded_linetype(d: &cadcraft_doc::Drawing, cmd: &str, p: &Value) -> Result<Op
|
||||
}
|
||||
|
||||
fn valid_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| c.is_control() || "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -408,12 +408,24 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("layerstate.restore", "`name` is required"))?;
|
||||
let d = s.doc_mut()?;
|
||||
let st = d.layer_states.iter().find(|st| st.name == name).cloned().ok_or_else(|| bad("layerstate.restore", "no such layer state"))?;
|
||||
for saved in st.layers {
|
||||
if let Some(l) = d.layer_mut(&saved.name) {
|
||||
*l = saved;
|
||||
restore_layers(d, st.layers);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Put the saved layers back over the drawing's layers of the same name (without regard to case);
|
||||
/// saved layers the drawing no longer has are skipped. Both lists can come from a file, so the
|
||||
/// drawing's layers are looked up in an index rather than scanned per saved layer.
|
||||
pub(crate) fn restore_layers(d: &mut cadcraft_doc::Drawing, saved: Vec<Layer>) {
|
||||
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::with_capacity(d.layers.len());
|
||||
for (i, l) in d.layers.iter().enumerate() {
|
||||
// The first of names that differ only in case, as `Drawing::layer_mut` finds it.
|
||||
index.entry(l.name.to_ascii_lowercase()).or_insert(i);
|
||||
}
|
||||
for layer in saved {
|
||||
if let Some(l) = index.get(&layer.name.to_ascii_lowercase()).and_then(|&i| d.layers.get_mut(i)) {
|
||||
*l = layer;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn run_state_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -461,6 +473,22 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Restoring many saved layers, most of them missing, takes one pass and matches without case.
|
||||
#[test]
|
||||
fn restore_layers_matches_by_name_in_linear_time() {
|
||||
let mut d = cadcraft_doc::Drawing::new_imperial();
|
||||
d.layers = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("L{i}"))).collect();
|
||||
let mut saved: Vec<cadcraft_doc::Layer> = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("missing{i}"))).collect();
|
||||
let mut back = cadcraft_doc::Layer::new("l7");
|
||||
back.frozen = true;
|
||||
saved.push(back);
|
||||
let started = std::time::Instant::now();
|
||||
super::restore_layers(&mut d, saved);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert!(d.layer("L7").is_some_and(|l| l.frozen));
|
||||
assert_eq!(d.layers.len(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_states_save_list_restore_delete() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -84,11 +84,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["File", "Print..."])
|
||||
.key("Cmd+P")
|
||||
.alias(&["print"])
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", device?: \"CADCraft PDF\"|\"CADCraft PNG\"|\"CADCraft SVG\" | a plotter from PLOTTERMANAGER | \"None\" (default: a layout's page setup device; Model, or \"None\": no device, PDF; a device sets the output format and the papers), paper?: the device's paper (default: a layout keeps its page setup's; Model takes the device's default), landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.noundo(),
|
||||
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf"))
|
||||
.menu(&["File", "Export to PDF..."])
|
||||
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
|
||||
.params("{path? (else returns base64 `data`), layout?, device? (its papers; the output stays PDF), paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
|
||||
.noundo(),
|
||||
CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace)
|
||||
.alias(&["ms"])
|
||||
@@ -313,7 +313,7 @@ fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = d.new_handle();
|
||||
store.push(Entity { handle: h, ..Entity::clone(e) });
|
||||
}
|
||||
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view });
|
||||
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view, limits: src.limits });
|
||||
Ok(json!({ "name": to }))
|
||||
}
|
||||
|
||||
@@ -840,8 +840,11 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let path = _path;
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
|
||||
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 });
|
||||
super::file::report_new_folder(s, &mut r, folder);
|
||||
Ok(r)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
||||
@@ -333,9 +333,8 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(_path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = format!("{_path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(_path), text.as_bytes())
|
||||
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
Ok(json!({ "name": name, "path": _path, "bytes": text.len() }))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -104,8 +104,10 @@ pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = template_of(d, &name)?;
|
||||
let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?;
|
||||
let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?;
|
||||
let folder = super::super::file::new_folder(&path);
|
||||
store(&path, &bytes)?;
|
||||
let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() });
|
||||
super::super::file::report_new_folder(s, &mut r, folder);
|
||||
let lost = not_saved(&t);
|
||||
if drops_unknown(&path)
|
||||
&& let Some(msg) = not_saved_message(&lost)
|
||||
@@ -124,7 +126,7 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
{
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
||||
@@ -293,3 +293,17 @@ fn mspace_edits_model_through_viewport() {
|
||||
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
|
||||
assert!(s.state().unwrap().mspace.is_none());
|
||||
}
|
||||
|
||||
/// PLOT and EXPORTPDF help name the `device` parameter, and PLOT's help every built-in device (#2557).
|
||||
#[test]
|
||||
fn plot_help_names_the_device_and_the_built_in_devices() {
|
||||
for id in ["plot", "exportpdf"] {
|
||||
let help = crate::find_command(id).map(|c| c.params).unwrap_or_default();
|
||||
assert!(help.contains("device?"), "{id}: {help}");
|
||||
}
|
||||
let help = crate::find_command("plot").map(|c| c.params).unwrap_or_default();
|
||||
for name in ["CADCraft PDF", "CADCraft PNG", "CADCraft SVG"] {
|
||||
assert!(super::super::plotters::find_device(name).is_some(), "{name} is not a device");
|
||||
assert!(help.contains(name), "plot help lacks {name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Bringing another drawing's symbol tables and blocks into this one (INSERT of a file, IMPORT,
|
||||
//! paste, COMPARE export).
|
||||
//!
|
||||
//! Both drawings are hostile input, so the work is linear in their sizes: names are looked up in a
|
||||
//! set built once per table, and a generated block name continues from the last one allocated for
|
||||
//! its prefix instead of probing every taken number again (CWE-407).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
|
||||
/// Add the entries of `theirs` whose names `ours` lacks (compared without letter case; a name that
|
||||
/// exists keeps `ours`' definition). Returns how many were added.
|
||||
pub(crate) fn merge_named<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
||||
let mut have: HashSet<String> = ours.iter().map(|o| name(o).to_ascii_uppercase()).collect();
|
||||
let mut added = 0;
|
||||
for t in theirs {
|
||||
if have.insert(name(t).to_ascii_uppercase()) {
|
||||
ours.push(t.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Which blocks of `src` to copy into `d`, and under which name: a new name keeps its own, a
|
||||
/// generated (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named
|
||||
/// block `d` already has (or `defining`, the block being defined from `src`) keeps `d`'s
|
||||
/// definition (not copied).
|
||||
pub(crate) fn block_renames(d: &Drawing, src: &Drawing, defining: Option<&str>) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.extend(defining.map(str::to_ascii_uppercase));
|
||||
// The next number to try per generated prefix: numbers below it are taken.
|
||||
let mut next: HashMap<String, usize> = HashMap::new();
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
let cursor = next.entry(prefix.to_ascii_uppercase()).or_insert(1);
|
||||
match (*cursor..=1_000_000).map(|i| (i, format!("{prefix}{i}"))).find(|(_, n)| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some((i, n)) => {
|
||||
*cursor = i + 1;
|
||||
n
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Layer};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_keeps_ours_and_adds_the_rest_without_regard_to_case() {
|
||||
let mut ours = vec![Layer::new("Walls")];
|
||||
let theirs = vec![Layer::new("WALLS"), Layer::new("Doors"), Layer::new("doors")];
|
||||
assert_eq!(merge_named(&mut ours, &theirs, |l| &l.name), 1);
|
||||
assert_eq!(ours.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Walls", "Doors"]);
|
||||
}
|
||||
|
||||
/// Many colliding anonymous blocks get consecutive names in one pass, not a rescan each.
|
||||
#[test]
|
||||
fn anonymous_collisions_take_consecutive_numbers() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut src = Drawing::new_imperial();
|
||||
for i in 1..=5000 {
|
||||
let mut b = Block::new(&format!("*U{i}"));
|
||||
b.anonymous = true;
|
||||
d.blocks.insert(b.name.clone(), Arc::new(b.clone()));
|
||||
src.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let renames = block_renames(&d, &src, None);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert_eq!(renames.len(), 5000);
|
||||
assert_eq!(renames.get("*U1").map(String::as_str), Some("*U5001"));
|
||||
let distinct: HashSet<&String> = renames.values().collect();
|
||||
assert_eq!(distinct.len(), 5000);
|
||||
// Every new name is the next free number: nothing below 5001 is free, nothing above 10000 is needed.
|
||||
assert!(renames.values().all(|n| n.strip_prefix("*U").and_then(|i| i.parse::<u32>().ok()).is_some_and(|i| (5001..=10000).contains(&i))));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod break_join;
|
||||
mod explode;
|
||||
mod fillet;
|
||||
mod offset;
|
||||
mod oops;
|
||||
mod select_then;
|
||||
mod stretch;
|
||||
mod transform;
|
||||
@@ -30,6 +31,8 @@ use explode::run_explode;
|
||||
pub(crate) use explode::{explode_kind, explode_with};
|
||||
use fillet::{FilletM, run_chamfer, run_fillet};
|
||||
use offset::{OffsetM, run_offset};
|
||||
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::transform_entities;
|
||||
@@ -43,6 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["e", "delete"])
|
||||
.params("{handles?: [hex]} (default: selection)")
|
||||
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Erase)))),
|
||||
CommandSpec::new("oops", "Oops", run_oops).params("{} (brings back what the last ERASE, BLOCK or -WBLOCK removed)"),
|
||||
CommandSpec::new("move", "Move", run_move)
|
||||
.menu(&["Modify", "Move"])
|
||||
.alias(&["m"])
|
||||
|
||||
@@ -1,19 +1,78 @@
|
||||
use cadcraft_geom::{Ellipse as GEllipse, Mat3};
|
||||
|
||||
use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3};
|
||||
|
||||
// ---------------- explode / array / order / break / join ----------------
|
||||
|
||||
/// `ell` placed by `m` as an ELLIPSE at elevation `z`; `None` when `m` flattens it.
|
||||
fn placed_ellipse(ell: GEllipse, m: &Mat3, z: f64) -> Option<EntityKind> {
|
||||
let g = ell.transformed(m);
|
||||
let len = g.major.len();
|
||||
(len.is_finite() && len > 0.0 && g.ratio.is_finite() && g.ratio > 0.0).then(|| {
|
||||
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: g.center.to3(z), major: g.major.to3(0.0), ratio: g.ratio, start: g.start, end: g.end })
|
||||
})
|
||||
}
|
||||
|
||||
/// The pieces of block object `be` placed by `m`, a transform that stretches unevenly (a block
|
||||
/// reference whose X and Y scales differ), where its own kind can't hold the result: a circle
|
||||
/// or an arc becomes an ellipse or an elliptical arc, a polyline with arc segments (and no
|
||||
/// widths) its lines and elliptical arcs. `None`: the object transforms as itself.
|
||||
fn stretched_pieces(be: &Entity, m: &Mat3) -> Option<Vec<Entity>> {
|
||||
let circular = |center: Vec2, radius: f64, start: f64, end: f64| GEllipse { center, major: Vec2::X * radius, ratio: 1.0, start, end };
|
||||
let kinds = match &be.kind {
|
||||
EntityKind::Circle(c) => vec![placed_ellipse(GEllipse::full(c.center.xy(), Vec2::X * c.radius, 1.0), m, c.center.z)?],
|
||||
EntityKind::Arc(a) => vec![placed_ellipse(circular(a.center.xy(), a.radius, a.start, a.end), m, a.center.z)?],
|
||||
EntityKind::Ellipse(el) => {
|
||||
let g = GEllipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
vec![placed_ellipse(g, m, el.center.z)?]
|
||||
}
|
||||
EntityKind::LwPolyline(p)
|
||||
if p.vertices.iter().any(|v| v.bulge != 0.0)
|
||||
&& p.const_width == 0.0
|
||||
&& p.vertices.iter().all(|v| v.start_width == 0.0 && v.end_width == 0.0) =>
|
||||
{
|
||||
let z = p.elevation;
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
pl.segments()
|
||||
.into_iter()
|
||||
.map(|s| match s {
|
||||
Segment::Line(l) => Some(EntityKind::Line(cadcraft_doc::Line { a: m.apply(l.a).to3(z), b: m.apply(l.b).to3(z) })),
|
||||
Segment::Arc { arc: a, .. } => placed_ellipse(circular(a.center, a.radius, a.start, a.end), m, z),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
// The pieces of a polyline are new objects, as when it explodes itself: its xdata goes.
|
||||
let mut common = be.common.clone();
|
||||
if matches!(be.kind, EntityKind::LwPolyline(_)) {
|
||||
common.xdata.clear();
|
||||
}
|
||||
Some(kinds.into_iter().map(|kind| Entity { handle: Handle(0), common: common.clone(), kind }).collect())
|
||||
}
|
||||
|
||||
fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option<Vec<Entity>> {
|
||||
if let Some(items) = crate::cmd::array::explode_array(d, e) {
|
||||
return Some(items);
|
||||
}
|
||||
// A MINSERT grid stays whole, as in AutoCAD 2024 (see `explode`).
|
||||
if minserted(ins) {
|
||||
return None;
|
||||
}
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let stretched = m.similarity_scale().is_none();
|
||||
Some(
|
||||
blk.entities
|
||||
.iter()
|
||||
.map(|be| {
|
||||
let mut ne = (**be).clone();
|
||||
ne.kind.transform(&m);
|
||||
.flat_map(|be| {
|
||||
stretched.then(|| stretched_pieces(be, &m)).flatten().unwrap_or_else(|| {
|
||||
let mut ne = (**be).clone();
|
||||
ne.kind.transform(&m);
|
||||
vec![ne]
|
||||
})
|
||||
})
|
||||
.map(|mut ne| {
|
||||
if ne.common.layer == "0" {
|
||||
ne.common.layer = e.common.layer.clone();
|
||||
}
|
||||
@@ -151,6 +210,12 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
// A polyface mesh explodes into its 3D faces; one without any stays as it is.
|
||||
EntityKind::PolyfaceMesh(m) => {
|
||||
let faces = m.faces3d();
|
||||
(!faces.is_empty())
|
||||
.then(|| faces.into_iter().map(|f| Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Face3d(f) }).collect())
|
||||
}
|
||||
EntityKind::MLine(m) => Some(crate::cmd::draw2::mline::explode(e, m)),
|
||||
EntityKind::MText(m) => Some(explode_mtext(e, m)),
|
||||
// A region explodes into its boundary's lines and arcs.
|
||||
@@ -162,9 +227,24 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
}
|
||||
|
||||
pub(super) fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
|
||||
// AutoCAD 2024 refuses a MINSERT grid: "The object is minserted." (Locked ones are left
|
||||
// without a word, as `explode_with` leaves them.)
|
||||
let d = s.doc()?;
|
||||
let grids = hs
|
||||
.iter()
|
||||
.filter(|h| !crate::cmd::curves::is_locked(s, **h) && d.entity(**h).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if minserted(i))))
|
||||
.count();
|
||||
for _ in 0..grids {
|
||||
s.echo("The object is minserted.");
|
||||
}
|
||||
explode_with(s, hs, explode_kind)
|
||||
}
|
||||
|
||||
/// A block reference with more than one row or column (MINSERT).
|
||||
fn minserted(i: &cadcraft_doc::Insert) -> bool {
|
||||
i.rows > 1 || i.cols > 1
|
||||
}
|
||||
|
||||
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
|
||||
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
|
||||
pub(crate) fn explode_with(
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! OOPS: bring back the objects the last ERASE (or the Delete key) removed, or BLOCK deleting the
|
||||
//! objects it makes a block of, or -WBLOCK the objects it writes, with their handles and each where
|
||||
//! it was in the draw order, as AutoCAD 2024 does (accoreconsole, the drawing's `entnext` order read
|
||||
//! back afterwards).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, EntityStore, Handle, Space};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// One erased object, with the objects that were directly under and above it.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Taken {
|
||||
space: Space,
|
||||
entity: Arc<Entity>,
|
||||
below: Option<Handle>,
|
||||
above: Option<Handle>,
|
||||
}
|
||||
|
||||
/// What the drawing's last erase removed, for OOPS.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Erased(Vec<Taken>);
|
||||
|
||||
impl Erased {
|
||||
/// Never bring back the objects `drop` picks.
|
||||
pub(crate) fn forget(&mut self, drop: impl Fn(Handle) -> bool) {
|
||||
self.0.retain(|t| !drop(t.entity.handle));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the objects `hs` and keep them for OOPS in place of what an earlier erase kept.
|
||||
/// Returns how many went; when none did, OOPS keeps what it had.
|
||||
pub(crate) fn erase_kept(s: &mut Session, hs: &[Handle]) -> Result<usize> {
|
||||
let d = s.doc_mut()?;
|
||||
// Every neighbour is read before anything goes: objects erased together may be neighbours.
|
||||
let at: Vec<(Handle, Space, Option<Handle>, Option<Handle>)> = hs
|
||||
.iter()
|
||||
.filter_map(|&h| {
|
||||
let space = d.space_of(h)?;
|
||||
let (below, above) = d.space(&space)?.neighbours(h)?;
|
||||
Some((h, space, below, above))
|
||||
})
|
||||
.collect();
|
||||
let mut taken = Vec::with_capacity(at.len());
|
||||
for (h, space, below, above) in at {
|
||||
if let Some(entity) = d.space_mut(&space).and_then(|st| st.remove(h)) {
|
||||
taken.push(Taken { space, entity, below, above });
|
||||
}
|
||||
}
|
||||
let n = taken.len();
|
||||
// During REFEDIT, removing an object outside the working set gets the command undone
|
||||
// (`refedit::after_command`), so what OOPS had stays.
|
||||
let refused = taken.iter().any(|t| crate::cmd::blocks::refedit::locked(d, t.entity.handle));
|
||||
if n > 0 && !refused {
|
||||
s.state_mut()?.erased = Erased(taken);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
pub(super) fn run_oops(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let taken = s.state()?.erased.0.clone();
|
||||
let editing = s.state()?.block_edit.as_ref().map(|b| b.name.clone());
|
||||
let back: Vec<bool> = {
|
||||
let d = s.doc()?;
|
||||
let in_blocks = in_blocks(d, &taken, editing.as_deref());
|
||||
taken.iter().map(|t| !in_blocks.contains(&t.entity.handle) && can_return(d, t)).collect()
|
||||
};
|
||||
// Nothing to bring back leaves the drawing as it is: no undo step.
|
||||
if !back.contains(&true) {
|
||||
return Ok(json!({ "restored": 0 }));
|
||||
}
|
||||
let n = restore(s.doc_mut()?, &taken, &back);
|
||||
Ok(json!({ "restored": n }))
|
||||
}
|
||||
|
||||
/// The handles of `taken` that a block's object has now (an undo can take the drawing back to
|
||||
/// before an erased object was made, and a block's new object take its handle). The block open
|
||||
/// in the Block Editor (`editing`) doesn't count: its objects there keep the handles they have in
|
||||
/// it.
|
||||
fn in_blocks(d: &Drawing, taken: &[Taken], editing: Option<&str>) -> HashSet<Handle> {
|
||||
let hs: HashSet<Handle> = taken.iter().map(|t| t.entity.handle).collect();
|
||||
let mut out = HashSet::new();
|
||||
for b in d.blocks.values() {
|
||||
if editing.is_some_and(|n| b.name.eq_ignore_ascii_case(n)) {
|
||||
continue;
|
||||
}
|
||||
// Through whichever is smaller.
|
||||
if b.entities.len() < hs.len() {
|
||||
out.extend(b.entities.iter().map(|e| e.handle).filter(|h| hs.contains(h)));
|
||||
} else {
|
||||
out.extend(hs.iter().copied().filter(|h| b.entities.contains(*h)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `t` can come back: it is not in the drawing (OOPS again, or an undo brought it back),
|
||||
/// its handle is one the drawing has given out, a reference being edited in place (REFEDIT)
|
||||
/// doesn't keep it out, and its layout, layer and block are still there.
|
||||
fn can_return(d: &Drawing, t: &Taken) -> bool {
|
||||
let h = t.entity.handle;
|
||||
h.0 < d.handseed
|
||||
&& d.entity(h).is_none()
|
||||
&& !crate::cmd::blocks::refedit::locked(d, h)
|
||||
&& d.space(&t.space).is_some()
|
||||
&& d.layer(&t.entity.common.layer).is_some()
|
||||
&& match &t.entity.kind {
|
||||
EntityKind::Insert(i) => d.block(&i.block).is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Put back the objects `back` marks. Each run of objects that were neighbours comes back from
|
||||
/// its bottom one up, so each goes directly above the one that was under it.
|
||||
fn restore(d: &mut Drawing, taken: &[Taken], back: &[bool]) -> usize {
|
||||
let index: HashMap<Handle, usize> = taken.iter().enumerate().map(|(i, t)| (t.entity.handle, i)).collect();
|
||||
let next: HashMap<Handle, usize> = taken.iter().enumerate().filter_map(|(i, t)| Some((t.below.filter(|b| index.contains_key(b))?, i))).collect();
|
||||
let mut done = vec![false; taken.len()];
|
||||
let mut n = 0;
|
||||
for first in 0..taken.len() {
|
||||
if taken[first].below.is_some_and(|b| index.contains_key(&b)) {
|
||||
continue;
|
||||
}
|
||||
let mut at = Some(first);
|
||||
while let Some(i) = at {
|
||||
if std::mem::replace(&mut done[i], true) {
|
||||
break;
|
||||
}
|
||||
if back[i] && put_back(d, taken, &index, i) {
|
||||
n += 1;
|
||||
}
|
||||
at = next.get(&taken[i].entity.handle).copied();
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Put `taken[i]` back directly above the nearest object that was under it and is here; when
|
||||
/// those are gone, directly under the nearest one that was above it; else on top.
|
||||
fn put_back(d: &mut Drawing, taken: &[Taken], index: &HashMap<Handle, usize>, i: usize) -> bool {
|
||||
let t = &taken[i];
|
||||
let Some(store) = d.space_mut(&t.space) else { return false };
|
||||
let h = t.entity.handle;
|
||||
store.push(Entity::clone(&t.entity));
|
||||
match nearest(store, taken, index, t.below, |t| t.below) {
|
||||
Some(Some(b)) => {
|
||||
store.move_next_to(h, b, true);
|
||||
}
|
||||
Some(None) => {
|
||||
store.send_to_back(h);
|
||||
}
|
||||
None => {
|
||||
if let Some(Some(a)) = nearest(store, taken, index, t.above, |t| t.above) {
|
||||
store.move_next_to(h, a, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// From `h`, past objects erased with it that are not here, to the first object that is
|
||||
/// (`Some(Some)`), the end of the draw order (`Some(None)`), or an object that is gone (`None`).
|
||||
fn nearest(
|
||||
store: &EntityStore,
|
||||
taken: &[Taken],
|
||||
index: &HashMap<Handle, usize>,
|
||||
mut h: Option<Handle>,
|
||||
step: fn(&Taken) -> Option<Handle>,
|
||||
) -> Option<Option<Handle>> {
|
||||
for _ in 0..=taken.len() {
|
||||
let Some(x) = h else { return Some(None) };
|
||||
if store.contains(x) {
|
||||
return Some(Some(x));
|
||||
}
|
||||
h = step(taken.get(*index.get(&x)?)?);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets};
|
||||
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, erase_kept, f64_req, json, point_param, point_req, targets};
|
||||
|
||||
// ---------------- core operations ----------------
|
||||
|
||||
@@ -54,14 +54,9 @@ fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut
|
||||
}
|
||||
|
||||
pub(super) fn erase(s: &mut Session, hs: &[Handle]) -> Result<usize> {
|
||||
let d = s.doc_mut()?;
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked);
|
||||
if !locked && d.remove_entity(*h).is_some() {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let free: Vec<Handle> = hs.iter().copied().filter(|h| !d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked)).collect();
|
||||
let n = erase_kept(s, &free)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -283,6 +283,28 @@ pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A polyline arc segment on `ga`'s circle that ends at `end` after turning through `sweep`
|
||||
/// (signed, positive counterclockwise), run on round the circle to the nearest boundary edge: the
|
||||
/// new end point and the segment's new bulge. `None` when no edge crosses the rest of the circle.
|
||||
fn extend_arc_segment(ga: &Arc, end: Vec2, sweep: f64, cut: &[Segment]) -> Option<(Vec2, f64)> {
|
||||
let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*ga }, ccw: true };
|
||||
let a_end = ga.center.angle_to(end);
|
||||
let ccw = sweep > 0.0;
|
||||
// How far past `end` each crossing is, turning the way the segment turns; crossings on the
|
||||
// segment itself (the last |sweep| of a turn) are not ahead of it.
|
||||
let (x, more) = cut
|
||||
.iter()
|
||||
.flat_map(|c| intersect_ext(&full, c, false))
|
||||
.map(|x| {
|
||||
let a = ga.center.angle_to(x);
|
||||
(x, norm_angle(if ccw { a - a_end } else { a_end - a }))
|
||||
})
|
||||
.filter(|(_, more)| *more > 1e-9 && *more + sweep.abs() < TAU - 1e-9)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let total = sweep.abs() + more;
|
||||
Some((x, cadcraft_geom::arc_to_bulge(if ccw { total } else { -total })))
|
||||
}
|
||||
|
||||
/// Extend the end of `h` nearest `pick` to the nearest boundary edge.
|
||||
pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> {
|
||||
if crate::cmd::curves::is_locked(s, h) {
|
||||
@@ -326,18 +348,34 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Re
|
||||
let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) };
|
||||
let end = vs.get(i_end).map(|v| v.p).unwrap_or_default();
|
||||
let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default();
|
||||
let dir = (end - prev).normalized();
|
||||
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
|
||||
let np = cut
|
||||
.iter()
|
||||
.flat_map(|c| intersect_ext(&ray, c, false))
|
||||
.filter(|x| (*x - end).dot(dir) > 1e-9)
|
||||
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
|
||||
.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?;
|
||||
if let Some(v) = vs.get_mut(i_end) {
|
||||
v.p = np;
|
||||
// The end segment's bulge, signed for travel from `prev` out to `end`.
|
||||
let i_seg = if from_end { i_prev } else { i_end };
|
||||
let bulge = vs.get(i_seg).map_or(0.0, |v| if from_end { v.bulge } else { -v.bulge });
|
||||
let none = || EngineError::Other("Object does not intersect an edge.".into());
|
||||
if let Some((ga, _)) = cadcraft_geom::bulge_to_arc(prev, end, bulge) {
|
||||
// An arc end segment runs on round its circle, as an arc does.
|
||||
let (np, b) = extend_arc_segment(&ga, end, 4.0 * bulge.atan(), &cut).ok_or_else(none)?;
|
||||
if let Some(v) = vs.get_mut(i_end) {
|
||||
v.p = np;
|
||||
}
|
||||
if let Some(v) = vs.get_mut(i_seg) {
|
||||
v.bulge = if from_end { b } else { -b };
|
||||
}
|
||||
} else {
|
||||
let dir = (end - prev).normalized();
|
||||
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
|
||||
let np = cut
|
||||
.iter()
|
||||
.flat_map(|c| intersect_ext(&ray, c, false))
|
||||
.filter(|x| (*x - end).dot(dir) > 1e-9)
|
||||
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
|
||||
.ok_or_else(none)?;
|
||||
if let Some(v) = vs.get_mut(i_end) {
|
||||
v.p = np;
|
||||
}
|
||||
}
|
||||
lwpoly(vs, false)
|
||||
// Width, elevation and linetype generation stay as they were.
|
||||
EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, ..p.clone() })
|
||||
}
|
||||
k => crate::cmd::modify2::extend_other(k, &cut, pick)?,
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ impl Interactive for BlendM {
|
||||
return Prompt::new("Enter continuity", curves::KW).kw(&["Tangent", "Smooth"]).default("Tangent");
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Select first object or", Accept::POINT).kw(&["CONtinuity"]),
|
||||
None => Prompt::new("Select first object", Accept::POINT).kw(&["CONtinuity"]),
|
||||
Some(_) => Prompt::new("Select second point", Accept::POINT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,14 +194,14 @@ impl Interactive for LengthenM {
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.phase {
|
||||
LPhase::Main => {
|
||||
Prompt::new("Select an object to measure or", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic")
|
||||
Prompt::new("Select an object to measure", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic")
|
||||
}
|
||||
LPhase::Delta => Prompt::new("Enter delta length or", Accept::NUMBER).kw(&["Angle"]).default("0.0000"),
|
||||
LPhase::Delta => Prompt::new("Enter delta length", Accept::NUMBER).kw(&["Angle"]).default("0.0000"),
|
||||
LPhase::DeltaAngle => Prompt::new("Enter delta angle", Accept::NUMBER).default("0"),
|
||||
LPhase::Percent => Prompt::new("Enter percentage length", Accept::NUMBER).default("100.0000"),
|
||||
LPhase::Total => Prompt::new("Specify total length or", Accept::NUMBER).kw(&["Angle"]).default("1.0000"),
|
||||
LPhase::Total => Prompt::new("Specify total length", Accept::NUMBER).kw(&["Angle"]).default("1.0000"),
|
||||
LPhase::TotalAngle => Prompt::new("Specify total angle", Accept::NUMBER).default("57"),
|
||||
LPhase::Change => Prompt::new("Select an object to change or", Accept::POINT).kw(&["Undo"]),
|
||||
LPhase::Change => Prompt::new("Select an object to change", Accept::POINT).kw(&["Undo"]),
|
||||
LPhase::DynEnd(..) => Prompt::new("Specify new end point", Accept::POINT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ impl Interactive for NcopyM {
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.selecting_done, self.base) {
|
||||
(false, _) => Prompt::new("Select nested objects to copy or", Accept::POINT).kw(&["Settings"]),
|
||||
(true, None) => Prompt::new("Specify base point or", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"),
|
||||
(false, _) => Prompt::new("Select nested objects to copy", Accept::POINT).kw(&["Settings"]),
|
||||
(true, None) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"),
|
||||
(true, Some(b)) => Prompt::new("Specify second point or <use first point as displacement>", Accept::POINT).base(b),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@ impl Interactive for PeditM {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.phase {
|
||||
PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_prompt(),
|
||||
PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]),
|
||||
PPhase::Select => Prompt::new("Select polyline", Accept::POINT).kw(&["Multiple"]),
|
||||
PPhase::Convert => {
|
||||
Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y")
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
|
||||
EntityKind::Hatch(h) => h.elevation = 0.0,
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
|
||||
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z),
|
||||
EntityKind::PolyfaceMesh(m) => m.vertices.iter_mut().for_each(z),
|
||||
EntityKind::Region(_) => {}
|
||||
EntityKind::Image(i) => {
|
||||
z(&mut i.insert);
|
||||
|
||||
@@ -146,20 +146,49 @@ impl Opts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Round every number of a JSON value to a multiple of `q`.
|
||||
fn quantize(v: &mut Value, q: f64) {
|
||||
/// Take the measurements (floating-point numbers) out of a JSON value, in order, leaving a marker
|
||||
/// in their place: two objects are equal when what is left matches, counts and flags included,
|
||||
/// and each pair of measurements is within the tolerance.
|
||||
fn split_numbers(v: &mut Value, nums: &mut Vec<f64>) {
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
if let Some(x) = n.as_f64() {
|
||||
*v = json!((x / q).round());
|
||||
if n.is_f64()
|
||||
&& let Some(x) = n.as_f64()
|
||||
{
|
||||
nums.push(x);
|
||||
*v = Value::String(NUMBER.into());
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| quantize(x, q)),
|
||||
Value::Object(o) => o.values_mut().for_each(|x| quantize(x, q)),
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| split_numbers(x, nums)),
|
||||
Value::Object(o) => o.values_mut().for_each(|x| split_numbers(x, nums)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`split_numbers`] leaves where a number was.
|
||||
const NUMBER: &str = "\u{0}#";
|
||||
|
||||
/// Whether two lists of numbers match pairwise to within `tol`.
|
||||
fn within(a: &[f64], b: &[f64], tol: f64) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
/// The numbers summed with weights in [1, 2) that no simple pattern shares (golden-ratio steps),
|
||||
/// so points on a diagonal of a grid don't all get the same sum; and the sum of the weights.
|
||||
fn weighted_sum(nums: &[f64]) -> (f64, f64) {
|
||||
nums.iter().enumerate().fold((0.0, 0.0), |(sum, total), (j, x)| {
|
||||
let w = 1.0 + (j as f64 * 0.618_033_988_749_895).fract();
|
||||
(sum + w * x, total + w)
|
||||
})
|
||||
}
|
||||
|
||||
/// The cell of `x` on a grid of `step`. Values within one step of each other land in the same or
|
||||
/// neighbouring cells; values too large for the grid share the outermost ones.
|
||||
fn cell_of(x: f64, step: f64) -> i64 {
|
||||
let k = (x / step).floor();
|
||||
if k.is_finite() { k.clamp(-9e15, 9e15) as i64 } else { i64::MIN }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Report {
|
||||
duplicates: usize,
|
||||
@@ -234,33 +263,37 @@ fn overkill(s: &mut Session, hs: Option<Vec<Handle>>, o: &Opts) -> Result<Report
|
||||
|
||||
/// Equal objects (numbers compared to the tolerance): the first in draw order stays, unless a
|
||||
/// later one is followed by an associative object.
|
||||
///
|
||||
/// Objects are hashed by their properties and shape (their JSON without the numbers), then by a
|
||||
/// weighted sum of their numbers on a grid of the tolerance times the weights' sum: two equal
|
||||
/// objects' sums are that close, so they land in the same or neighbouring cells, wherever the
|
||||
/// numbers fall.
|
||||
fn duplicates(cands: &[Cand], o: &Opts, gone: &mut [bool], rep: &mut Report) {
|
||||
let mut seen: HashMap<(u32, String), usize> = HashMap::new();
|
||||
for (i, c) in cands.iter().enumerate() {
|
||||
// Keepers so far: (properties, shape) → cell of the sum → their numbers.
|
||||
type Keepers = HashMap<i64, Vec<Vec<f64>>>;
|
||||
let mut seen: HashMap<(u32, String), Keepers> = HashMap::new();
|
||||
// Followed objects first, so a keeper is never displaced: everything deleted is within the
|
||||
// tolerance of an object that stays.
|
||||
let (followed, rest): (Vec<usize>, Vec<usize>) = (0..cands.len()).partition(|i| cands.get(*i).is_some_and(|c| c.protected));
|
||||
for i in followed.into_iter().chain(rest) {
|
||||
let Some(c) = cands.get(i) else { continue };
|
||||
let Ok(mut v) = serde_json::to_value(&c.e.kind) else { continue };
|
||||
if o.tol > 0.0 {
|
||||
quantize(&mut v, o.q());
|
||||
let mut nums = Vec::new();
|
||||
split_numbers(&mut v, &mut nums);
|
||||
let (sum, weights) = weighted_sum(&nums);
|
||||
let cell = cell_of(sum, o.q() * weights.max(1.0));
|
||||
let cells = seen.entry((c.prop, v.to_string())).or_default();
|
||||
let found = [cell.saturating_sub(1), cell, cell.saturating_add(1)]
|
||||
.into_iter()
|
||||
.any(|k| cells.get(&k).is_some_and(|keepers| keepers.iter().any(|n| within(n, &nums, o.tol))));
|
||||
if !found || c.protected {
|
||||
// A followed object stays even when it matches another.
|
||||
cells.entry(cell).or_default().push(nums);
|
||||
continue;
|
||||
}
|
||||
let key = (c.prop, v.to_string());
|
||||
match seen.get(&key).copied() {
|
||||
None => {
|
||||
seen.insert(key, i);
|
||||
}
|
||||
Some(k) => {
|
||||
let keeper_protected = cands.get(k).is_some_and(|x| x.protected);
|
||||
let victim = match (keeper_protected, c.protected) {
|
||||
(_, false) => i,
|
||||
(false, true) => {
|
||||
seen.insert(key, i);
|
||||
k
|
||||
}
|
||||
(true, true) => continue,
|
||||
};
|
||||
if let Some(g) = gone.get_mut(victim) {
|
||||
*g = true;
|
||||
rep.duplicates += 1;
|
||||
}
|
||||
}
|
||||
if let Some(g) = gone.get_mut(i) {
|
||||
*g = true;
|
||||
rep.duplicates += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,8 +454,8 @@ fn collinear(members: &[&LineRec], cands: &[Cand], o: &Opts, gone: &mut [bool],
|
||||
fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap<usize, EntityKind>, rep: &mut Report) {
|
||||
let q = o.q();
|
||||
let g = q.max(1e-9);
|
||||
let cell = |x: f64| (x / g).round() as i64;
|
||||
let mut buckets: HashMap<(u32, i64, i64, i64, i64), Vec<usize>> = HashMap::new();
|
||||
// (index, properties, centre and radius); cells of `g` hold them.
|
||||
let mut recs: Vec<(usize, u32, [f64; 4])> = Vec::new();
|
||||
for (i, c) in cands.iter().enumerate() {
|
||||
if gone.get(i).copied().unwrap_or(true) || c.protected {
|
||||
continue;
|
||||
@@ -442,12 +475,45 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap<usize
|
||||
{
|
||||
continue;
|
||||
}
|
||||
buckets.entry((c.prop, cell(center.x), cell(center.y), cell(r), cell(center.z))).or_default().push(i);
|
||||
recs.push((i, c.prop, [center.x, center.y, r, center.z]));
|
||||
}
|
||||
let mut keys: Vec<_> = buckets.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
for key in keys {
|
||||
let Some(group) = buckets.get(&key) else { continue };
|
||||
// Those within `g` of each other in centre and radius are on one circle. Each object joins the
|
||||
// first group whose every member is within `g` of it, or starts one: matches don't chain, so
|
||||
// nothing is deleted for matching only an object that itself matches the keeper. Members are
|
||||
// looked for in the neighbouring cells too, so values on either side of a cell boundary meet.
|
||||
let mut grid: HashMap<(u32, [i64; 4]), Vec<usize>> = HashMap::new();
|
||||
let mut group_of: Vec<usize> = Vec::with_capacity(recs.len());
|
||||
// Each group's records, in draw order; groups in the order of their first object.
|
||||
let mut members: Vec<Vec<usize>> = Vec::new();
|
||||
for (ri, (_, prop, v)) in recs.iter().enumerate() {
|
||||
let cells = v.map(|x| cell_of(x, g));
|
||||
let mut near_groups: Vec<usize> = Vec::new();
|
||||
for n in 0..81_i64 {
|
||||
let near = [n % 3 - 1, n / 3 % 3 - 1, n / 9 % 3 - 1, n / 27 % 3 - 1];
|
||||
let mut key = cells;
|
||||
for (k, d) in key.iter_mut().zip(near) {
|
||||
*k = k.saturating_add(d);
|
||||
}
|
||||
near_groups.extend(grid.get(&(*prop, key)).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|rj| group_of.get(*rj)));
|
||||
}
|
||||
near_groups.sort_unstable();
|
||||
near_groups.dedup();
|
||||
let fits = |gi: &usize| members.get(*gi).is_some_and(|m| m.iter().all(|rk| recs.get(*rk).is_some_and(|(_, _, w)| within(v, w, g))));
|
||||
let gi = match near_groups.into_iter().find(fits) {
|
||||
Some(gi) => gi,
|
||||
None => {
|
||||
members.push(Vec::new());
|
||||
members.len() - 1
|
||||
}
|
||||
};
|
||||
if let Some(m) = members.get_mut(gi) {
|
||||
m.push(ri);
|
||||
}
|
||||
group_of.push(gi);
|
||||
grid.entry((*prop, cells)).or_default().push(ri);
|
||||
}
|
||||
let groups = members.iter().map(|m| m.iter().filter_map(|ri| recs.get(*ri).map(|r| r.0)).collect::<Vec<usize>>());
|
||||
for group in groups {
|
||||
if group.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
@@ -465,7 +531,7 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap<usize
|
||||
}
|
||||
continue;
|
||||
}
|
||||
same_circle(group, cands, o, gone, changed, rep);
|
||||
same_circle(&group, cands, o, gone, changed, rep);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,9 +101,7 @@ impl PlotterStore {
|
||||
write: |n, t| {
|
||||
let p = file(n)?;
|
||||
std::fs::create_dir_all(dir()?).map_err(|e| e.to_string())?;
|
||||
let tmp = p.with_extension("json.cadcraft-tmp");
|
||||
std::fs::write(&tmp, t).map_err(|e| format!("{n}: {e}"))?;
|
||||
std::fs::rename(&tmp, &p).map_err(|e| format!("{n}: {e}"))
|
||||
crate::atomic_write::write_atomic(&p, t.as_bytes()).map_err(|e| format!("{n}: {e}"))
|
||||
},
|
||||
delete: |n| std::fs::remove_file(file(n)?).map_err(|e| format!("{n}: {e}")),
|
||||
}
|
||||
|
||||
@@ -765,10 +765,13 @@ fn run_style_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Text style names used by entities (in model, paper spaces and blocks) and dimension styles.
|
||||
/// Text style names used by entities (in model, paper spaces and blocks), dimension,
|
||||
/// multileader and table styles (their cell styles), and the text of complex linetypes.
|
||||
pub(super) fn text_styles_in_use(d: &cadcraft_doc::Drawing) -> Vec<String> {
|
||||
let mut out: Vec<String> = d.dim_styles.iter().map(|s| s.text_style.clone()).collect();
|
||||
out.extend(d.mleader_styles.iter().map(|s| s.text_style.clone()));
|
||||
out.extend(d.table_styles.iter().flat_map(|s| s.cell_styles.iter().map(|c| c.text_style.clone())));
|
||||
out.extend(d.linetypes.iter().flat_map(|lt| lt.pattern.iter().filter_map(|el| el.style.clone())));
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
|
||||
for st in stores {
|
||||
for e in st.iter() {
|
||||
|
||||
@@ -66,17 +66,10 @@ impl Units {
|
||||
}
|
||||
|
||||
/// A typed direction of angle 0, in radians counterclockwise from +X: an amount in the angle
|
||||
/// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, and in surveyor's
|
||||
/// units N, E, S or W), measured from +X as the drawing measures angles.
|
||||
/// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, `N`), measured
|
||||
/// from +X as the drawing measures angles.
|
||||
fn direction(&self, t: &str) -> Option<f64> {
|
||||
let cardinal = match t.trim().to_ascii_lowercase().as_str() {
|
||||
"e" if self.aunits == 4 => Some(0.0),
|
||||
"n" if self.aunits == 4 => Some(90f64.to_radians()),
|
||||
"w" if self.aunits == 4 => Some(180f64.to_radians()),
|
||||
"s" if self.aunits == 4 => Some(270f64.to_radians()),
|
||||
_ => None,
|
||||
};
|
||||
let a = cardinal.or_else(|| AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t))?;
|
||||
let a = AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t)?;
|
||||
let a = if self.clockwise { -a } else { a };
|
||||
a.is_finite().then(|| a.rem_euclid(TAU))
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ impl Interactive for BreakM {
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.table {
|
||||
None => Prompt::new("Select a table", Accept::SELECT),
|
||||
Some(_) => Prompt::new("Enter maximum part height or", Accept::POINT_OR_NUMBER).kw(&["Off"]),
|
||||
Some(_) => Prompt::new("Enter maximum part height", Accept::POINT_OR_NUMBER).kw(&["Off"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
|
||||
@@ -26,7 +26,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params("{mode: point|distance|angle|intersection, points: [[x,y],...]} → {value} | {point}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<PickM>::default())),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params(
|
||||
"{find, replace?, matchCase?: bool, wholeWord?: bool, types?: [\"Text\", \"MText\", ...], handles?: [...]} → \
|
||||
{matches: [{handle, type, count, snippet, hit, hitLen}], total, message}",
|
||||
),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
|
||||
.params("{mode: distance|radius|angle|area|volume, ...}")
|
||||
.noundo()
|
||||
@@ -194,6 +197,46 @@ fn run_quickcalc_pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------- FIND ----------------
|
||||
|
||||
/// Characters of context kept on each side of a hit in the Find dialog's result list.
|
||||
const SNIPPET_CONTEXT: usize = 18;
|
||||
|
||||
/// The text around the first hit of `find` in `text`, for the result list: at most
|
||||
/// [`SNIPPET_CONTEXT`] characters before the hit and as many after it, the hit's offset in the
|
||||
/// snippet (in characters) and its length (in characters).
|
||||
///
|
||||
/// The offset and length are character counts, not bytes: the dialog slices the snippet by
|
||||
/// character so a Chinese or Vietnamese drawing never splits a glyph. Lower-casing can change a
|
||||
/// string's character count in rare scripts, so every slice goes through `get` and a snippet that
|
||||
/// cannot be cut is reported as absent rather than guessed at.
|
||||
fn snippet_at(text: &str, find: &str, case: bool, whole: bool) -> Option<(String, usize, usize)> {
|
||||
if find.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (hay, needle) = if case { (text.to_string(), find.to_string()) } else { (text.to_lowercase(), find.to_lowercase()) };
|
||||
let mut at = 0;
|
||||
let pos = loop {
|
||||
let rest = hay.get(at..)?;
|
||||
let p = rest.find(&needle)? + at;
|
||||
let end = p + needle.len();
|
||||
let boundary = |i: usize| hay.get(..i).and_then(|s| s.chars().last()).is_none_or(|c| !c.is_alphanumeric());
|
||||
let after = hay.get(end..).and_then(|s| s.chars().next()).is_none_or(|c| !c.is_alphanumeric());
|
||||
if !whole || (boundary(p) && after) {
|
||||
break p;
|
||||
}
|
||||
at = end.max(p + 1);
|
||||
if at >= hay.len() {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let start = hay.get(..pos)?.chars().count();
|
||||
let hit_len = needle.chars().count();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let from = start.saturating_sub(SNIPPET_CONTEXT);
|
||||
let to = start.saturating_add(hit_len).saturating_add(SNIPPET_CONTEXT).min(chars.len());
|
||||
let snippet: String = chars.get(from..to)?.iter().collect();
|
||||
Some((snippet, start.saturating_sub(from), hit_len))
|
||||
}
|
||||
|
||||
fn replace_in(text: &str, find: &str, rep: &str, case: bool, whole: bool) -> (String, usize) {
|
||||
if find.is_empty() {
|
||||
return (text.to_string(), 0);
|
||||
@@ -228,21 +271,40 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rep = str_param(p, "replace").map(str::to_string);
|
||||
let case = bool_or(p, "matchCase", false);
|
||||
let whole = bool_or(p, "wholeWord", false);
|
||||
// Only the named object types, when the dialog narrows the search; `type_name()` is the same
|
||||
// name the result list shows, so a caller never has to guess the spelling.
|
||||
let types: Option<Vec<String>> =
|
||||
p.get("types").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect());
|
||||
let space = s.space();
|
||||
let d = s.doc()?;
|
||||
let hs: Vec<Handle> = d.space(&space).map(|st| st.handles()).unwrap_or_default();
|
||||
// The whole current space by default; the Find window narrows it to the objects its result
|
||||
// list already holds, so Replace All touches exactly what the user can see.
|
||||
let hs: Vec<Handle> = match p.get("handles").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect(),
|
||||
None => d.space(&space).map(|st| st.handles()).unwrap_or_default(),
|
||||
};
|
||||
let mut found: Vec<Value> = Vec::new();
|
||||
let mut total = 0;
|
||||
let mut edits: Vec<(Handle, EntityKind)> = Vec::new();
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
if let Some(names) = &types
|
||||
&& !names.iter().any(|n| n == &e.kind.type_name().to_ascii_lowercase())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut k = e.kind.clone();
|
||||
let mut hits = 0;
|
||||
// The first hit's surroundings, taken before a replacement rewrites the text.
|
||||
let mut snippet: Option<(String, usize, usize)> = None;
|
||||
let r = rep.as_deref().unwrap_or(&find);
|
||||
let mut apply = |t: &mut String| {
|
||||
let (nt, n) = replace_in(t, &find, r, case, whole);
|
||||
if n > 0 {
|
||||
hits += n;
|
||||
if snippet.is_none() {
|
||||
snippet = snippet_at(t, &find, case, whole);
|
||||
}
|
||||
if rep.is_some() {
|
||||
*t = nt;
|
||||
}
|
||||
@@ -263,7 +325,15 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if hits > 0 {
|
||||
total += hits;
|
||||
found.push(json!({ "handle": h.hex(), "type": e.kind.type_name(), "count": hits }));
|
||||
let (text, at, len) = snippet.unwrap_or_default();
|
||||
found.push(json!({
|
||||
"handle": h.hex(),
|
||||
"type": e.kind.type_name(),
|
||||
"count": hits,
|
||||
"snippet": text,
|
||||
"hit": at,
|
||||
"hitLen": len,
|
||||
}));
|
||||
if rep.is_some() {
|
||||
edits.push((h, k));
|
||||
}
|
||||
@@ -577,4 +647,35 @@ mod tests {
|
||||
assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3));
|
||||
assert_eq!(replace_in("x", "", "y", true, false).1, 0);
|
||||
}
|
||||
|
||||
/// `snippet_at` returns the whole text when the hit fills it, with the hit at offset 0.
|
||||
#[test]
|
||||
fn snippet_reports_the_hit_offset() {
|
||||
assert_eq!(snippet_at("BOLT-01", "BOLT-01", true, false), Some(("BOLT-01".to_string(), 0, 7)));
|
||||
}
|
||||
|
||||
/// Matching ignores case but the snippet keeps the text as it is written, so the result list
|
||||
/// shows the drawing's own spelling.
|
||||
#[test]
|
||||
fn snippet_keeps_the_original_case() {
|
||||
assert_eq!(snippet_at("法兰 bolt-01 安装孔", "BOLT-01", false, false), Some(("法兰 bolt-01 安装孔".to_string(), 3, 7)));
|
||||
}
|
||||
|
||||
/// A long text keeps [`SNIPPET_CONTEXT`] characters on each side of the hit, counted in
|
||||
/// characters so a CJK hit is never split.
|
||||
#[test]
|
||||
fn snippet_is_windowed_in_characters() {
|
||||
let text = format!("{}命中{}", "前".repeat(30), "后".repeat(30));
|
||||
let (snippet, hit, len) = snippet_at(&text, "命中", true, false).expect("a hit");
|
||||
assert_eq!((hit, len), (SNIPPET_CONTEXT, 2));
|
||||
assert_eq!(snippet.chars().count(), SNIPPET_CONTEXT * 2 + 2);
|
||||
}
|
||||
|
||||
/// A whole-word search skips an embedded hit, and text without a hit has no snippet.
|
||||
#[test]
|
||||
fn snippet_respects_whole_word() {
|
||||
assert_eq!(snippet_at("BOLTS", "BOLT", true, true), None);
|
||||
assert_eq!(snippet_at("a BOLT b", "BOLT", true, true).map(|s| s.1), Some(2));
|
||||
assert_eq!(snippet_at("nothing", "BOLT", true, false), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d<a]`), arithmetic and vector
|
||||
//! arithmetic, numeric and geometric functions; unit conversion; values picked from the drawing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -67,6 +69,9 @@ struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
vars: &'a Map<String, Value>,
|
||||
/// `vars` by ASCII-lower-cased name (the first of names that differ only in case), so a
|
||||
/// case-insensitive lookup is not a scan of the map per reference.
|
||||
folded: HashMap<String, &'a Value>,
|
||||
depth: usize,
|
||||
unary_depth: usize,
|
||||
}
|
||||
@@ -230,7 +235,7 @@ impl Parser<'_> {
|
||||
match name.as_str() {
|
||||
"pi" => Ok(Val::Num(std::f64::consts::PI)),
|
||||
"e" => Ok(Val::Num(std::f64::consts::E)),
|
||||
_ => var(self.vars, &name).ok_or_else(|| format!("unknown name `{name}`")),
|
||||
_ => var(self.vars, &self.folded, &name).ok_or_else(|| format!("unknown name `{name}`")),
|
||||
}
|
||||
}
|
||||
_ => Err("expected a number".into()),
|
||||
@@ -239,8 +244,8 @@ impl Parser<'_> {
|
||||
}
|
||||
|
||||
/// A variable: a number, or a point as `[x, y]`. Names are case-insensitive.
|
||||
fn var(vars: &Map<String, Value>, name: &str) -> Option<Val> {
|
||||
let v = vars.get(name).or_else(|| vars.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?;
|
||||
fn var(vars: &Map<String, Value>, folded: &HashMap<String, &Value>, name: &str) -> Option<Val> {
|
||||
let v = vars.get(name).or_else(|| folded.get(&name.to_ascii_lowercase()).copied())?;
|
||||
if let Some(n) = v.as_f64() {
|
||||
return Some(Val::Num(n));
|
||||
}
|
||||
@@ -411,7 +416,11 @@ pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
|
||||
if expr.len() > MAX_LEN {
|
||||
return Err("expression too long".into());
|
||||
}
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0, unary_depth: 0 };
|
||||
let mut folded = HashMap::with_capacity(vars.len());
|
||||
for (k, v) in vars {
|
||||
folded.entry(k.to_ascii_lowercase()).or_insert(v);
|
||||
}
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, folded, depth: 0, unary_depth: 0 };
|
||||
let v = p.expr()?;
|
||||
if p.peek().is_some() {
|
||||
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
|
||||
@@ -587,6 +596,21 @@ mod tests {
|
||||
eval_val(s, &Map::new())
|
||||
}
|
||||
|
||||
/// Variables are found without regard to case; an exact match wins over a case variant.
|
||||
/// (Names are read lower-cased, so `X` finds `x` too.)
|
||||
#[test]
|
||||
fn variables_ignore_case_and_prefer_exact_names() {
|
||||
let mut vars = Map::new();
|
||||
for i in 0..10_000 {
|
||||
vars.insert(format!("v{i}"), json!(i));
|
||||
}
|
||||
vars.insert("X".into(), json!(1));
|
||||
vars.insert("x".into(), json!(2));
|
||||
assert!(matches!(eval_val("V9999 + 1", &vars), Ok(Val::Num(n)) if n == 10_000.0));
|
||||
assert!(matches!(eval_val("x", &vars), Ok(Val::Num(n)) if n == 2.0));
|
||||
assert!(eval_val("nope", &vars).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_and_vectors() {
|
||||
assert_eq!(ev("[1,2]+[3,4]*2"), Ok(Val::Pt(Vec2::new(7.0, 10.0))));
|
||||
|
||||
@@ -185,13 +185,7 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
|
||||
if let Some(state) = &v.layer_state {
|
||||
let d = s.doc_mut()?;
|
||||
match d.layer_states.iter().find(|l| l.name.eq_ignore_ascii_case(state)).cloned() {
|
||||
Some(saved) => {
|
||||
for layer in saved.layers {
|
||||
if let Some(l) = d.layer_mut(&layer.name) {
|
||||
*l = layer;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(saved) => super::super::layer::restore_layers(d, saved.layers),
|
||||
None => msg.push_str(&format!(" Its layer snapshot \"{state}\" is missing.")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ pub struct DocState {
|
||||
/// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the
|
||||
/// drawing itself waits in it ([`DocState::drawing`]).
|
||||
pub block_edit: Option<blockedit::BlockEdit>,
|
||||
/// What the last erase removed, for OOPS.
|
||||
pub(crate) erased: crate::cmd::Erased,
|
||||
/// The fonts missing on Windows that a save last named: the next save names them again only
|
||||
/// when they change.
|
||||
pub(crate) fonts_noted: String,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -61,6 +66,8 @@ impl DocState {
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
binary_dxf: false,
|
||||
block_edit: None,
|
||||
erased: Default::default(),
|
||||
fonts_noted: String::new(),
|
||||
}
|
||||
}
|
||||
/// The drawing has changes not saved to its file, or the Block Editor has changes not yet
|
||||
@@ -68,6 +75,14 @@ impl DocState {
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved()
|
||||
}
|
||||
/// Show the drawing an undo or redo step holds, with nothing selected; returns the drawing as
|
||||
/// it was, as the step back.
|
||||
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.revision += 1;
|
||||
cur
|
||||
}
|
||||
/// After undo/redo replaced the drawing (`before` is the one shown until then): if the current
|
||||
/// layout no longer exists, follow it to its other name when it was renamed (same tab order,
|
||||
/// a name `before` did not have), else go to the Model tab.
|
||||
|
||||
@@ -226,6 +226,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
|
||||
}
|
||||
EntityKind::Solid(s) | EntityKind::Trace(s) => set_xy(s.corners.get_mut(index)?, to),
|
||||
EntityKind::Face3d(f) => set_xy(f.corners.get_mut(index)?, to),
|
||||
EntityKind::PolyfaceMesh(m) => set_xy(m.vertices.get_mut(index)?, to),
|
||||
EntityKind::Region(_) => return Some(translated(kind, delta)),
|
||||
EntityKind::Viewport(v) => {
|
||||
let opposite = *grips.get((index + 2) % 4)?;
|
||||
|
||||
@@ -92,6 +92,12 @@ pub struct Session {
|
||||
pub last_command: Option<String>,
|
||||
/// LASTPOINT.
|
||||
pub last_point: Vec2,
|
||||
/// AREA and PERIMETER: the last area AREA or MEASUREGEOM measured (in their Add and Subtract
|
||||
/// modes the running total) and that area's perimeter. Not saved, as in AutoCAD.
|
||||
pub area: f64,
|
||||
pub perimeter: f64,
|
||||
/// DISTANCE: the last distance DIST or MEASUREGEOM measured (with Multiple points, the total).
|
||||
pub distance: f64,
|
||||
/// The last segment given at a point prompt (its base point and the point): polar angles
|
||||
/// relative to the last segment (POLARMODE bit 1) are measured from it.
|
||||
pub last_segment: Option<(Vec2, Vec2)>,
|
||||
@@ -173,6 +179,9 @@ impl Session {
|
||||
log: Vec::new(),
|
||||
last_command: None,
|
||||
last_point: Vec2::ZERO,
|
||||
area: 0.0,
|
||||
perimeter: 0.0,
|
||||
distance: 0.0,
|
||||
last_segment: None,
|
||||
last_polar: None,
|
||||
point_mods: Vec::new(),
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Handle};
|
||||
|
||||
use crate::{Input, Prompt, Session};
|
||||
use crate::{Input, Prompt, Session, undoctl};
|
||||
use eval::{Ctx, Machine, Need, Outcome};
|
||||
use value::{Sym, Value};
|
||||
|
||||
@@ -111,6 +111,8 @@ pub struct LispState {
|
||||
run: Option<Box<Run>>,
|
||||
/// A routine runs, from its start to its end (also while its machine is out of `run`).
|
||||
active: bool,
|
||||
/// The running routine's own undo steps (also while its machine is out of `run`).
|
||||
steps: RoutineSteps,
|
||||
/// Files loaded by APPLOAD, by name, for `(load name)` where there is no file system.
|
||||
files: HashMap<String, String>,
|
||||
/// Where startup files are read from (see `startup.rs`).
|
||||
@@ -123,6 +125,19 @@ pub struct LispState {
|
||||
pub(crate) limits: Limits,
|
||||
}
|
||||
|
||||
/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing
|
||||
/// was before it, last last. The routine is still one undo step once it ends.
|
||||
#[derive(Default)]
|
||||
struct RoutineSteps {
|
||||
/// The drawing the routine runs in.
|
||||
uid: u64,
|
||||
undo: std::collections::VecDeque<crate::Snapshot>,
|
||||
redo: std::collections::VecDeque<crate::Snapshot>,
|
||||
/// Where the routine's undo step starts, when U or REDO inside it went on to the drawing's
|
||||
/// own steps; else where the routine started.
|
||||
base: Option<(Arc<Drawing>, Vec<Handle>)>,
|
||||
}
|
||||
|
||||
/// What a run may use (see [`value`]'s memory notes).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Limits {
|
||||
@@ -247,6 +262,7 @@ impl Session {
|
||||
let st = self.state()?;
|
||||
let before = Some((st.doc.clone(), st.selection.clone()));
|
||||
let uid = st.uid;
|
||||
self.lisp.steps = RoutineSteps { uid, ..RoutineSteps::default() };
|
||||
self.lisp.run = Some(Box::new(Run {
|
||||
machine,
|
||||
uid,
|
||||
@@ -324,7 +340,7 @@ impl Session {
|
||||
Ok(o) => o,
|
||||
Err(p) => {
|
||||
// A panic inside a built-in: put the drawing back as the routine found it.
|
||||
if let Some((doc, sel)) = &run.before
|
||||
if let Some((doc, sel)) = self.lisp.steps.base.as_ref().or(run.before.as_ref())
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid)
|
||||
{
|
||||
st.doc = doc.clone();
|
||||
@@ -422,6 +438,7 @@ impl Session {
|
||||
fn lisp_end(&mut self, result: std::result::Result<Value, String>) {
|
||||
let Some(run) = self.lisp.run.take() else { return };
|
||||
self.lisp.active = false;
|
||||
let steps = std::mem::take(&mut self.lisp.steps);
|
||||
let mut env = self.lisp.envs.remove(&run.uid).unwrap_or_default();
|
||||
self.lisp_flush(&mut env, true);
|
||||
self.lisp.envs.insert(run.uid, env);
|
||||
@@ -431,7 +448,7 @@ impl Session {
|
||||
self.echo(text);
|
||||
}
|
||||
}
|
||||
if let Some((doc, sel)) = run.before
|
||||
if let Some((doc, sel)) = steps.base.or(run.before)
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid)
|
||||
&& !Arc::ptr_eq(&doc, &st.doc)
|
||||
{
|
||||
@@ -489,6 +506,7 @@ impl Session {
|
||||
self.lisp_error(LispError::quit(msg));
|
||||
} else if self.lisp.active {
|
||||
self.lisp.active = false;
|
||||
self.lisp.steps = RoutineSteps::default();
|
||||
self.echo(format!("; error: {msg}"));
|
||||
self.lisp_last = Some(Err(msg));
|
||||
} else {
|
||||
@@ -577,6 +595,67 @@ impl Session {
|
||||
self.lisp.active
|
||||
}
|
||||
|
||||
/// A command the running routine ran changed the drawing (`snap` holds it as it was): U inside
|
||||
/// the routine takes it back, as AutoCAD's does, while the routine stays one undo step. UNDO
|
||||
/// Control One keeps only the last, None none.
|
||||
pub(crate) fn lisp_record_step(&mut self, snap: crate::Snapshot) {
|
||||
let Some(uid) = self.routine_steps() else { return };
|
||||
let mut control = undoctl::Control::All;
|
||||
// A command ends what REDO could bring back, the drawing's own steps too.
|
||||
if let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) {
|
||||
st.redo.clear();
|
||||
control = st.undo_ctl.control;
|
||||
}
|
||||
let steps = &mut self.lisp.steps;
|
||||
steps.redo.clear();
|
||||
match control {
|
||||
undoctl::Control::None => steps.undo.clear(),
|
||||
undoctl::Control::One => {
|
||||
steps.undo.clear();
|
||||
steps.undo.push_back(snap);
|
||||
}
|
||||
// As many as the drawing keeps.
|
||||
undoctl::Control::All => {
|
||||
steps.undo.push_back(snap);
|
||||
if steps.undo.len() > undoctl::MAX_UNDO {
|
||||
steps.undo.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// U (or with `redo`, REDO) inside the running routine: take its last command back (or bring
|
||||
/// back what U took). None when it has none left, and the drawing's own steps go on from there.
|
||||
pub(crate) fn lisp_take_back(&mut self, redo: bool) -> Option<String> {
|
||||
let uid = self.routine_steps()?;
|
||||
let st = self.docs.iter_mut().find(|d| d.uid == uid)?;
|
||||
let steps = &mut self.lisp.steps;
|
||||
let (from, to) = if redo { (&mut steps.redo, &mut steps.undo) } else { (&mut steps.undo, &mut steps.redo) };
|
||||
let cur = st.swap_in(from.pop_back()?);
|
||||
let label = cur.label.clone();
|
||||
to.push_back(cur);
|
||||
Some(label)
|
||||
}
|
||||
|
||||
/// U or REDO inside the running routine went on to the drawing's steps before it: the
|
||||
/// routine's own undo step starts from the drawing as that left it, and REDO brings back that
|
||||
/// step before what U took back of the routine's.
|
||||
pub(crate) fn lisp_rebase(&mut self) {
|
||||
if self.routine_steps().is_none() {
|
||||
return;
|
||||
}
|
||||
if let Ok(st) = self.state() {
|
||||
self.lisp.steps.base = Some((st.doc.clone(), st.selection.clone()));
|
||||
self.lisp.steps.redo.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The active drawing's uid when a routine runs in it.
|
||||
fn routine_steps(&self) -> Option<u64> {
|
||||
let uid = self.state().ok()?.uid;
|
||||
(self.lisp.active && self.lisp.steps.uid == uid).then_some(uid)
|
||||
}
|
||||
|
||||
/// The commands the active drawing's LISP defines (`c:` functions), uppercase and sorted.
|
||||
pub fn lisp_commands(&self) -> Vec<String> {
|
||||
let Ok(uid) = self.state().map(|s| s.uid) else { return Vec::new() };
|
||||
|
||||
@@ -26,6 +26,41 @@ impl Accept {
|
||||
pub const SELECT: Accept = Accept { point: true, number: false, text: false, select: true, enter: true };
|
||||
}
|
||||
|
||||
/// Prompts that ask for one of their keywords, which AutoCAD 2024 writes without "or" before them:
|
||||
/// "Enter an option [?/Make/Set/…]:", "Enter layout option [Copy/Delete/…/?] <set>:", "New color
|
||||
/// [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; 27] = [
|
||||
"Bring to front",
|
||||
"Calculate rectangle dimensions based on",
|
||||
"Choose mode for selected object conversion",
|
||||
"Create annotative block",
|
||||
"Enter a configuration option",
|
||||
"Enter a plotting preference",
|
||||
"Enter an annotation option",
|
||||
"Enter an option",
|
||||
"Enter an option to change",
|
||||
"Enter attribute visibility setting",
|
||||
"Enter justification",
|
||||
"Enter layout option",
|
||||
"Enter leader format option",
|
||||
"Enter line spacing type",
|
||||
"Enter object ordering option",
|
||||
"Enter polyline linetype generation option",
|
||||
"Enter property to change",
|
||||
"Enter the type of array",
|
||||
"Enter type of unused objects to purge",
|
||||
"Enter viewport arrangement",
|
||||
"Explode block",
|
||||
"New color",
|
||||
"Orient relative to sheet in paper space viewports",
|
||||
"Select a content type",
|
||||
"Select a leader type",
|
||||
"Use landing",
|
||||
"Viewport View Locking",
|
||||
];
|
||||
|
||||
/// A prompt shown on the command line.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -148,15 +183,7 @@ impl Prompt {
|
||||
}
|
||||
let mut s = self.message.clone();
|
||||
if !self.keywords.is_empty() {
|
||||
// A question offers its answers ("Fix any errors detected? [Yes/No]"); anything else
|
||||
// offers options besides what it asks for ("Specify next point or [Undo]").
|
||||
s.push_str(if self.message.is_empty() {
|
||||
"["
|
||||
} else if self.message.ends_with('?') {
|
||||
" ["
|
||||
} else {
|
||||
" or ["
|
||||
});
|
||||
s.push_str(self.keyword_lead());
|
||||
s.push_str(&self.keywords.join("/"));
|
||||
s.push(']');
|
||||
}
|
||||
@@ -166,6 +193,20 @@ impl Prompt {
|
||||
s.push(':');
|
||||
s
|
||||
}
|
||||
/// What goes between the message and its keywords. A question offers its answers ("Fix any
|
||||
/// errors detected? [Yes/No]"), and so does a prompt for an option ([`NO_OR`]): " [". Anything
|
||||
/// else offers options besides what it asks for ("Specify next point or [Undo]"): " or [". With
|
||||
/// no message, "[". The command line in the window draws the keywords itself and takes this
|
||||
/// too.
|
||||
pub fn keyword_lead(&self) -> &'static str {
|
||||
if self.message.is_empty() {
|
||||
"["
|
||||
} else if self.message.ends_with('?') || NO_OR.contains(&self.message.as_str()) {
|
||||
" ["
|
||||
} else {
|
||||
" or ["
|
||||
}
|
||||
}
|
||||
/// Match typed text against keywords: full word or the capitalised shortcut, case-insensitive.
|
||||
pub fn match_keyword(&self, text: &str) -> Option<String> {
|
||||
let t = text.trim();
|
||||
@@ -338,6 +379,13 @@ mod tests {
|
||||
assert_eq!(p.display(), "Specify next point or [Undo/Close]:");
|
||||
let q = Prompt::new("Erase polyline?", Accept::TEXT).kw(&["Yes", "No"]).default("No");
|
||||
assert_eq!(q.display(), "Erase polyline? [Yes/No] <No>:");
|
||||
// A prompt for an option has no "or", as AutoCAD writes it.
|
||||
let o = Prompt::new("Enter an option", Accept::TEXT).kw(&["Close", "Join"]);
|
||||
assert_eq!(o.display(), "Enter an option [Close/Join]:");
|
||||
let c = Prompt::new("New color", Accept::TEXT).kw(&["Truecolor", "COlorbook"]).default("BYLAYER");
|
||||
assert_eq!(c.display(), "New color [Truecolor/COlorbook] <BYLAYER>:");
|
||||
let b = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]);
|
||||
assert_eq!(b.display(), "Enter block name or [?]:");
|
||||
assert_eq!(p.match_keyword("u").as_deref(), Some("Undo"));
|
||||
assert_eq!(p.match_keyword("CL").as_deref(), Some("Close"));
|
||||
assert_eq!(p.match_keyword("x"), None);
|
||||
|
||||
@@ -32,6 +32,7 @@ fn dim(kind: DimKind, p13: Vec2, p14: Vec2, defpt: Vec2) -> EntityKind {
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,6 +183,7 @@ pub fn bracket() -> Drawing {
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
@@ -203,6 +205,7 @@ pub fn bracket() -> Drawing {
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user