mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-02 16:31:48 -06:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
751aa33812 | ||
|
|
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 | ||
|
|
57e6a89d10 | ||
|
|
282ad11e60 | ||
|
|
768d25bb7d | ||
|
|
7baa970381 | ||
|
|
590b97a321 | ||
|
|
86d010eaac | ||
|
|
fc8270d8c5 | ||
|
|
b301d5782d | ||
|
|
1013b482a8 | ||
|
|
89771962fc | ||
|
|
49fdbff529 | ||
|
|
2eecaef6c2 | ||
|
|
b6c1ab74f9 | ||
|
|
4398a1aab1 | ||
|
|
6ed822ec30 |
+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
+57
-19
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.6.6"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8923c99ea1f151cb7fc17ce2fc14f8b1e330fa22611e535602d985c0cc3c89a2"
|
||||
checksum = "eb201e11e1c75584e4deefc2b30afb83e2f34d16338733439d38664cb9599929"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -632,6 +632,7 @@ dependencies = [
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"thiserror 2.0.21",
|
||||
@@ -727,8 +728,9 @@ name = "cadcraft-fonts"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust 0.14.0",
|
||||
"skrifa 0.48.0",
|
||||
"harfrust 0.15.0",
|
||||
"icu_segmenter",
|
||||
"skrifa 0.49.0",
|
||||
"unicode-bidi",
|
||||
]
|
||||
|
||||
@@ -1115,6 +1117,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"
|
||||
@@ -1548,9 +1559,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
version = "0.8.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
@@ -2118,14 +2129,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "harfrust"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86aa776d6d32b072119e1f1650f4db843ff4382a4aaa6a2c9c4e35703cd377cb"
|
||||
checksum = "865dcc4105d51c323d8fedbb2d75b5e9c8ecddf5613f74c82f851d0afbc97114"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"read-fonts 0.45.1",
|
||||
"read-fonts 0.46.0",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -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"
|
||||
@@ -2776,9 +2811,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "lyon_geom"
|
||||
@@ -3870,14 +3905,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.45.1"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
|
||||
checksum = "bdaa4fffe9ff3ca2c353fa9729792805b9b3e324695ff5a081f860612e81b330"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash 0.2.0",
|
||||
"font-types 0.12.7",
|
||||
"once_cell",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
@@ -3963,12 +3999,11 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.2"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
@@ -4334,12 +4369,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.48.0"
|
||||
version = "0.49.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034"
|
||||
checksum = "a7459fb60888ad9a5f367dd293fa2fb9df142cb451aa6bfd6cd66a7d6a9b739d"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.45.1",
|
||||
"once_cell",
|
||||
"read-fonts 0.46.0",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+5
-2
@@ -46,11 +46,14 @@ rayon = "1"
|
||||
# rendering / formats
|
||||
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] }
|
||||
lyon_tessellation = "1.0.22"
|
||||
skrifa = "0.48"
|
||||
skrifa = "0.49"
|
||||
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; egui's epaint is still on 0.12,
|
||||
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
|
||||
harfrust = "0.14"
|
||||
harfrust = "0.15"
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -165,6 +165,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 +181,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,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]
|
||||
|
||||
+59
-12
@@ -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;
|
||||
|
||||
@@ -281,7 +300,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 +329,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 +422,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 +440,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,6 @@ sync_github_name = true
|
||||
|
||||
[people.GULERTOLGA]
|
||||
sync_github_name = true
|
||||
|
||||
[people.starise]
|
||||
real_name = "Andrea Brandi"
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -403,7 +403,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 +436,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 +491,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 +503,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 +534,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
@@ -424,6 +427,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 +546,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 +616,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")]
|
||||
|
||||
@@ -212,6 +212,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
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,4 +13,4 @@ workspace = true
|
||||
# 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"
|
||||
|
||||
+208
-3
@@ -110,8 +110,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 +127,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 +332,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 +474,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 };
|
||||
@@ -730,6 +860,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() {
|
||||
@@ -1076,4 +1234,51 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -25,6 +25,7 @@ mod lisp;
|
||||
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,
|
||||
|
||||
+16
-107
@@ -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![
|
||||
@@ -45,10 +47,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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 +187,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());
|
||||
@@ -361,61 +371,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)));
|
||||
@@ -983,52 +938,6 @@ impl Interactive for InsertM {
|
||||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
|
||||
mod attribs;
|
||||
mod battman;
|
||||
mod blockreplace;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -109,7 +109,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
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);
|
||||
}
|
||||
@@ -183,9 +183,20 @@ fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, S
|
||||
|
||||
/// 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,349 @@
|
||||
//! 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, not_saved, not_saved_message, 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 (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_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 (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_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(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)
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,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 +217,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")?;
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -117,14 +117,17 @@ 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 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_loss(s, &mut r, &file);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
// ---------- IMPORT ----------
|
||||
@@ -330,17 +333,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 +361,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 +385,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 +764,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();
|
||||
|
||||
+127
-18
@@ -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)
|
||||
}
|
||||
|
||||
@@ -677,6 +687,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 +695,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 +728,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 +747,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 +784,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 +828,31 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +861,62 @@ 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 mut r = run(s, p)?;
|
||||
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+/";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -151,6 +151,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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
let list = cadcraft_render::build_entities(
|
||||
d,
|
||||
std::iter::once(e),
|
||||
&cadcraft_render::Options { tolerance: tol, text: false, ..Default::default() },
|
||||
&cadcraft_render::Options { tolerance: tol, text: false, linetypes: false, ..Default::default() },
|
||||
);
|
||||
v = list.prims.iter().map(|p| list.points(p).to_vec()).collect();
|
||||
v.push(vec![c[0], c[1], c[2], c[3], c[0]]);
|
||||
@@ -117,11 +117,15 @@ fn in_text(d: &Drawing, e: &Entity, p: Vec2) -> bool {
|
||||
// A dimension with a block (as AutoCAD saves them) is drawn from that block: its text is
|
||||
// the text there, not the text the dimension's own geometry would place.
|
||||
EntityKind::Dimension(dm) => match dm.block.as_ref().and_then(|n| d.block(n)) {
|
||||
Some(blk) => blk.entities.iter().any(|be| match &be.kind {
|
||||
EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p),
|
||||
EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p),
|
||||
_ => false,
|
||||
}),
|
||||
// The block draws translated by the insertion point (group 12): test there (#2634).
|
||||
Some(blk) => {
|
||||
let p = p - dm.insertion_point.xy();
|
||||
blk.entities.iter().any(|be| match &be.kind {
|
||||
EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p),
|
||||
EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
None => {
|
||||
let g = cadcraft_render::dimension_in(d, dm);
|
||||
in_box(g.text.iter().flatten().chain(g.text_glyphs.iter().flatten().flatten()).copied(), g.text_angle, p)
|
||||
@@ -502,6 +506,56 @@ mod tests {
|
||||
// Only one within reach: that one, whatever the order.
|
||||
assert_eq!(pick(&d, &Space::Model, Vec2::new(41.3, 50.0), 1.0), Some(line));
|
||||
}
|
||||
|
||||
/// A block holding a diagonal dashed line, inserted at `scale`.
|
||||
fn dashed_block(length: f64, scale: f64) -> (Drawing, Handle) {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.linetypes.extend(cadcraft_doc::library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED"));
|
||||
let mut line =
|
||||
Entity::new(Handle(1), EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(length, length, 0.0) }));
|
||||
line.common.linetype = "DASHED".into();
|
||||
let mut blk = cadcraft_doc::Block::new("B");
|
||||
blk.entities.push(line);
|
||||
d.blocks.insert("B".into(), std::sync::Arc::new(blk));
|
||||
let insert = cadcraft_doc::Insert {
|
||||
block: "B".into(),
|
||||
insert: Vec3::new(0.0, 0.0, 0.0),
|
||||
scale: Vec3::new(scale, scale, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap();
|
||||
(d, h)
|
||||
}
|
||||
|
||||
/// Every mouse move over a block hit-tests what it draws. Two blocks with a fine pattern on
|
||||
/// long lines (inserted at 0.001) made 5.8 million dashes, and half a second, per move (#1285).
|
||||
#[test]
|
||||
fn hit_testing_a_block_does_not_lay_out_its_dashes() {
|
||||
let (d, h) = dashed_block(20_000.0, 0.001);
|
||||
let e = d.entity(h).unwrap();
|
||||
let drawn = cadcraft_render::build_entities(&d, std::iter::once(e.as_ref()), &cadcraft_render::Options { text: false, ..Default::default() });
|
||||
assert!(drawn.prims.len() > 10_000, "the pattern draws {} dashes", drawn.prims.len());
|
||||
// The line, and the block's bounding rectangle.
|
||||
assert_eq!(hit_polylines(&d, e, 0.01).len(), 2);
|
||||
}
|
||||
|
||||
/// A dashed line in a block is hit across its gaps, as one outside a block is.
|
||||
#[test]
|
||||
fn a_dashed_line_in_a_block_is_picked_in_a_gap() {
|
||||
let (d, h) = dashed_block(100.0, 1.0);
|
||||
let e = d.entity(h).unwrap();
|
||||
let drawn = cadcraft_render::build_entities(&d, std::iter::once(e.as_ref()), &cadcraft_render::Options { text: false, ..Default::default() });
|
||||
let (a, b) = (drawn.prims.first().unwrap(), drawn.prims.get(1).unwrap());
|
||||
let (end, start) = (*drawn.points(a).last().unwrap(), *drawn.points(b).first().unwrap());
|
||||
assert!(end.dist(start) > 0.01, "a gap between the first two dashes");
|
||||
let gap = end + (start - end) * 0.5;
|
||||
assert_eq!(pick(&d, &Space::Model, gap, end.dist(start) / 8.0), Some(h));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -66,17 +66,25 @@ impl Session {
|
||||
(r, _) => r,
|
||||
};
|
||||
let grouped = self.lisp_groups_undo();
|
||||
let mut in_routine = None;
|
||||
if spec.undoable
|
||||
&& let Some((doc, sel, uid)) = before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& !Arc::ptr_eq(&doc, &st.doc)
|
||||
{
|
||||
// Inside a LISP routine the routine's single undo step records it.
|
||||
if !grouped {
|
||||
st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel });
|
||||
let snap = Snapshot { label: spec.label.to_string(), doc, selection: sel };
|
||||
// Inside a LISP routine the routine's single undo step records it, and the routine
|
||||
// keeps it for U inside it.
|
||||
if grouped {
|
||||
in_routine = Some(snap);
|
||||
} else {
|
||||
st.push_undo(snap);
|
||||
}
|
||||
st.revision += 1;
|
||||
}
|
||||
if let Some(snap) = in_routine {
|
||||
self.lisp_record_step(snap);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -89,27 +97,32 @@ impl Session {
|
||||
|
||||
// ---------------- undo ----------------
|
||||
|
||||
/// Take the last step back. Inside a LISP routine, the routine's last command first; past
|
||||
/// those, the drawing's step before the routine, which then starts from there.
|
||||
pub fn undo(&mut self) -> Result<Option<String>> {
|
||||
if let Some(label) = self.lisp_take_back(false) {
|
||||
return Ok(Some(label));
|
||||
}
|
||||
let st = self.state_mut()?;
|
||||
let Some(snap) = st.undo.pop() else { return Ok(None) };
|
||||
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
|
||||
st.doc = snap.doc;
|
||||
st.follow_layout(&cur.doc);
|
||||
st.selection = Vec::new();
|
||||
let cur = st.swap_in(snap);
|
||||
let label = cur.label.clone();
|
||||
st.redo.push(cur);
|
||||
st.revision += 1;
|
||||
Ok(Some(snap.label))
|
||||
self.lisp_rebase();
|
||||
Ok(Some(label))
|
||||
}
|
||||
|
||||
/// Bring back what the last undo took back; inside a LISP routine, what U inside it took first.
|
||||
pub fn redo(&mut self) -> Result<Option<String>> {
|
||||
if let Some(label) = self.lisp_take_back(true) {
|
||||
return Ok(Some(label));
|
||||
}
|
||||
let st = self.state_mut()?;
|
||||
let Some(snap) = st.redo.pop() else { return Ok(None) };
|
||||
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
|
||||
st.doc = snap.doc;
|
||||
st.follow_layout(&cur.doc);
|
||||
st.selection = Vec::new();
|
||||
let cur = st.swap_in(snap);
|
||||
let label = cur.label.clone();
|
||||
st.undo.push(cur);
|
||||
st.revision += 1;
|
||||
Ok(Some(snap.label))
|
||||
self.lisp_rebase();
|
||||
Ok(Some(label))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,12 +379,16 @@ impl Session {
|
||||
}
|
||||
self.after_command(Some(&run.before), None, true);
|
||||
// A transparent command's changes belong to the command it ran inside, and a command a
|
||||
// LISP routine ran belongs to the routine's single undo step.
|
||||
// LISP routine ran belongs to the routine's single undo step (the routine keeps it for U
|
||||
// inside it).
|
||||
if self.suspended.is_some() || self.lisp_groups_undo() {
|
||||
if let Ok(st) = self.state_mut()
|
||||
&& !Arc::ptr_eq(&run.before, &st.doc)
|
||||
{
|
||||
st.revision += 1;
|
||||
if self.suspended.is_none() {
|
||||
self.lisp_record_step(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ const SESSION_VARS: &[&str] = &[
|
||||
"ZOOMFACTOR",
|
||||
"MEASUREINIT",
|
||||
"LASTPOINT",
|
||||
"AREA",
|
||||
"PERIMETER",
|
||||
"DISTANCE",
|
||||
"FONTALT",
|
||||
"FONTFALLBACK",
|
||||
"SAVETIME",
|
||||
@@ -145,6 +148,9 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"ZOOMFACTOR" => json!(st.zoomfactor),
|
||||
"MEASUREINIT" => json!(i32::from(st.measureinit)),
|
||||
"LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]),
|
||||
"AREA" => json!(s.area),
|
||||
"PERIMETER" => json!(s.perimeter),
|
||||
"DISTANCE" => json!(s.distance),
|
||||
// Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG.
|
||||
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
|
||||
"CONSTRAINTINFER" => json!(i32::from(s.doc().ok()?.parametric.settings.infer)),
|
||||
@@ -249,6 +255,9 @@ const READ_ONLY: &[&str] = &[
|
||||
"BLOCKEDITOR",
|
||||
"CTAB",
|
||||
"LASTPOINT",
|
||||
"AREA",
|
||||
"PERIMETER",
|
||||
"DISTANCE",
|
||||
"VIEWCTR",
|
||||
"VIEWSIZE",
|
||||
"EXTMIN",
|
||||
@@ -464,11 +473,6 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
|
||||
pub fn list(s: &Session) -> Value {
|
||||
let mut m = serde_json::Map::new();
|
||||
for n in SESSION_VARS {
|
||||
if let Some(v) = get(s, n) {
|
||||
m.insert((*n).to_string(), v);
|
||||
}
|
||||
}
|
||||
if let Ok(d) = s.doc() {
|
||||
for (k, v) in &d.header.vars {
|
||||
if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT" | "CECOLOR") {
|
||||
@@ -478,6 +482,13 @@ pub fn list(s: &Session) -> Value {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The session's values last: a drawing's header can carry the same names (ORTHOMODE,
|
||||
// LWDISPLAY, an AREA), and `get` reads the session's.
|
||||
for n in SESSION_VARS {
|
||||
if let Some(v) = get(s, n) {
|
||||
m.insert((*n).to_string(), v);
|
||||
}
|
||||
}
|
||||
table::list(s, &mut m);
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
+28
-20
@@ -277,7 +277,8 @@ impl AngleSettings {
|
||||
}
|
||||
|
||||
/// A typed angle size in radians (included angles, sweeps, angles from an axis): a bare
|
||||
/// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`.
|
||||
/// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`; a
|
||||
/// surveyor's bearing (`N45dE`, or `N`, `E`, `S`, `W`) is its angle counterclockwise from east.
|
||||
pub fn amount(&self, s: &str) -> Option<f64> {
|
||||
parse_units(s, self.aunits).map(|(a, _)| a)
|
||||
}
|
||||
@@ -288,20 +289,19 @@ impl AngleSettings {
|
||||
}
|
||||
|
||||
/// A typed direction in radians counterclockwise from +X: the amount measured from ANGBASE in
|
||||
/// the ANGDIR sense. Surveyor's bearings (`N45dE`) are absolute. The overrides measure from
|
||||
/// +X counterclockwise whatever ANGBASE and ANGDIR say: `<<a` in degrees, `<<<a` in AUNITS.
|
||||
/// the ANGDIR sense, a surveyor's bearing too, as AutoCAD 2024 measures it (`N45dE` with
|
||||
/// ANGBASE 90 and ANGDIR 1 is 45 degrees). The overrides measure from +X counterclockwise
|
||||
/// whatever ANGBASE and ANGDIR say: `<<<a` in AUNITS or as a bearing, `<<a` in degrees only
|
||||
/// (AutoCAD refuses `<<N45dE` and `<<N`).
|
||||
pub fn direction(&self, s: &str) -> Option<f64> {
|
||||
let t = s.trim();
|
||||
if let Some(r) = t.strip_prefix("<<<") {
|
||||
return parse_units(r, self.aunits).map(|(a, _)| a);
|
||||
}
|
||||
if let Some(r) = t.strip_prefix("<<") {
|
||||
return parse_units(r, 0).map(|(a, _)| a);
|
||||
}
|
||||
let (a, absolute) = parse_units(t, self.aunits)?;
|
||||
if absolute {
|
||||
return Some(a);
|
||||
return parse_units(r, 0).filter(|(_, bearing)| !bearing).map(|(a, _)| a);
|
||||
}
|
||||
let (a, _) = parse_units(t, self.aunits)?;
|
||||
let v = self.angbase + if self.clockwise { -a } else { a };
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
@@ -328,7 +328,8 @@ pub fn parse_angle(s: &str) -> Option<f64> {
|
||||
AngleSettings::default().direction(s)
|
||||
}
|
||||
|
||||
/// A typed angle in radians and whether it is absolute (a surveyor's bearing).
|
||||
/// A typed angle in radians and whether it is a surveyor's bearing (`N45dE`, or `N`, `E`, `S`,
|
||||
/// `W`), whose angle is its direction counterclockwise from east.
|
||||
fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.is_empty() {
|
||||
@@ -339,10 +340,7 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
|
||||
(num(r)?, false)
|
||||
} else if let Some(g) = t.strip_suffix('g') {
|
||||
((num(g)? * 0.9).to_radians(), false)
|
||||
} else if let Some(b) = bearing(&t) {
|
||||
(b, true)
|
||||
} else if let Some(b) = (aunits == 4).then(|| cardinal(&t)).flatten() {
|
||||
// A due bearing as surveyor's units show it: one letter.
|
||||
} else if let Some(b) = cardinal(&t).or_else(|| bearing(&t)) {
|
||||
(b, true)
|
||||
} else if let Some(v) = num(&t) {
|
||||
let a = match aunits {
|
||||
@@ -357,7 +355,8 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
|
||||
v.0.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// A due bearing (`n`, `e`, `s`, `w`, lower case) as radians counterclockwise from +X.
|
||||
/// A compass point (`n`, `e`, `s`, `w`, lower case), a due bearing as surveyor's units show it,
|
||||
/// as radians counterclockwise from +X. AutoCAD 2024 takes one in any angle format.
|
||||
fn cardinal(t: &str) -> Option<f64> {
|
||||
match t {
|
||||
"e" => Some(0.0),
|
||||
@@ -567,14 +566,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn surveyors_units_read_a_due_bearing_typed_as_one_letter() {
|
||||
// What surveyor's units show for a due bearing can be typed back, e.g. `@10<E`.
|
||||
let a = AngleSettings { aunits: 4, angbase: 1.0, clockwise: true };
|
||||
for (t, want) in [("E", 0.0), ("n", PI / 2.0), ("W", PI), ("s", 1.5 * PI)] {
|
||||
let got = a.direction(t).unwrap();
|
||||
// What surveyor's units show for a due bearing can be typed back, e.g. `@10<E`. AutoCAD 2024
|
||||
// reads the letter as an angle like any other, from ANGBASE in the ANGDIR sense: with
|
||||
// ANGBASE 90 and ANGDIR 1, `@10<N` draws east.
|
||||
let a = AngleSettings { aunits: 4, angbase: PI / 2.0, clockwise: true };
|
||||
for (t, want) in [("E", PI / 2.0), ("n", 0.0), ("W", 1.5 * PI), ("s", PI)] {
|
||||
let got = a.direction(t).unwrap().rem_euclid(2.0 * PI);
|
||||
assert!((got - want).abs() < 1e-12, "{t}: {got}");
|
||||
}
|
||||
// Other angle units don't take a bare letter.
|
||||
assert_eq!(AngleSettings { aunits: 0, angbase: 0.0, clockwise: false }.direction("E"), None);
|
||||
// And in any angle format: `@10<E` draws east at AUNITS 0, `@10<S` south at AUNITS 1.
|
||||
for (aunits, t, want) in [(0, "E", 0.0), (1, "S", 1.5 * PI)] {
|
||||
let got = AngleSettings { aunits, ..AngleSettings::default() }.direction(t);
|
||||
assert!(got.is_some_and(|a| (a - want).abs() < 1e-12), "AUNITS {aunits} {t}: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -644,6 +648,10 @@ mod tests {
|
||||
assert!((parse_angle("90").unwrap() - PI / 2.0).abs() < 1e-12);
|
||||
assert!((parse_angle("45d30'").unwrap().to_degrees() - 45.5).abs() < 1e-9);
|
||||
assert!((parse_angle("N45dE").unwrap().to_degrees() - 45.0).abs() < 1e-9);
|
||||
// A compass point on its own, in either case.
|
||||
for (t, deg) in [("N", 90.0), ("e", 0.0), ("W", 180.0), ("s", 270.0)] {
|
||||
assert!((parse_angle(t).unwrap().to_degrees() - deg).abs() < 1e-9, "{t}");
|
||||
}
|
||||
assert!((parse_angle("1.5r").unwrap() - 1.5).abs() < 1e-12);
|
||||
assert_eq!(format_angle(PI / 2.0, 0, 0), "90");
|
||||
assert_eq!(format_angle(PI / 4.0, 4, 0), "N 45d E");
|
||||
|
||||
@@ -208,6 +208,7 @@ fn derived_dimension_fields_are_not_differences() {
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
};
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim(30.0, 7.0))).unwrap();
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! -WBLOCK at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts, the
|
||||
//! drawing read back afterwards). It writes objects from a base point and erases them, whatever
|
||||
//! its mOde option says; a block by name, `=` (the block named as the file) or `*` (the whole
|
||||
//! drawing); asks before replacing a file; and writes nothing when nothing is selected.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::{Drawing, EntityKind, Handle};
|
||||
use serde_json::json;
|
||||
|
||||
#[path = "support/temp_dir.rs"]
|
||||
mod temp_dir;
|
||||
|
||||
use temp_dir::TempDir;
|
||||
|
||||
/// One line per object written: a line's start (where the base point puts it), else its type.
|
||||
fn write(d: &Drawing, _path: &str) -> Result<Vec<u8>, String> {
|
||||
let mut out = String::new();
|
||||
for e in d.model.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Line(l) => out.push_str(&format!("LINE {},{}\n", l.a.x, l.a.y)),
|
||||
k => out.push_str(&format!("{}\n", k.dxf_name())),
|
||||
}
|
||||
}
|
||||
Ok(out.into_bytes())
|
||||
}
|
||||
|
||||
/// A session writing through [`write`], with the drawing the measured scripts start from: line A
|
||||
/// (0,0)-(10,0), circle B at (5,5) radius 3, line C (0,20)-(10,20).
|
||||
fn drawing() -> (Session, Handle, Handle, Handle) {
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None });
|
||||
let mut s = Session::new();
|
||||
let handle = |v: serde_json::Value| Handle::parse_hex(v["handles"][0].as_str().or(v["handle"].as_str()).unwrap()).unwrap();
|
||||
let a = handle(s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap());
|
||||
let b = handle(s.execute("circle", &json!({ "center": [5, 5], "radius": 3 })).unwrap());
|
||||
let c = handle(s.execute("line", &json!({ "points": [[0, 20], [10, 20]] })).unwrap());
|
||||
(s, a, b, c)
|
||||
}
|
||||
|
||||
/// A file in a test's own folder, with forward slashes for a LISP string.
|
||||
fn file(dir: &TempDir, name: &str) -> String {
|
||||
dir.path_str(name).replace('\\', "/")
|
||||
}
|
||||
|
||||
fn lisp(s: &mut Session, expr: &str) {
|
||||
s.cmdline(expr).unwrap();
|
||||
}
|
||||
|
||||
fn ent(h: Handle) -> String {
|
||||
format!("(handent \"{}\")", h.hex())
|
||||
}
|
||||
|
||||
fn alive(s: &Session, h: Handle) -> bool {
|
||||
s.doc().unwrap().entity(h).is_some()
|
||||
}
|
||||
|
||||
fn read(path: &str) -> Vec<String> {
|
||||
std::fs::read_to_string(path).unwrap().lines().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_are_written_from_the_base_point_and_erased() {
|
||||
// WBLOCK-000: A and B from (5,5); AutoCAD erases both and keeps C.
|
||||
let (mut s, a, b, c) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
let path = file(&dir, "WB00.dwg");
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(5.0 5.0) {} {} \"\")", ent(a), ent(b)));
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]);
|
||||
assert!(!alive(&s, a) && !alive(&s, b) && alive(&s, c));
|
||||
assert!(s.log.iter().any(|l| l == "Current settings: Object conversion=Delete"), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompts_are_autocads() {
|
||||
let (mut s, a, _, _) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
s.cmdline("-wblock").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Enter name of output file:"), "{}", s.prompt_text());
|
||||
s.cmdline(&file(&dir, "P.dwg")).unwrap();
|
||||
assert!(
|
||||
s.prompt_text().ends_with("Enter name of existing block or [= (block=output file)/* (whole drawing)] <define new drawing>:"),
|
||||
"{}",
|
||||
s.prompt_text()
|
||||
);
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text());
|
||||
// WBLOCK-008: anything but a point or mOde there.
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Point or option keyword required."));
|
||||
s.cmdline("_O").unwrap();
|
||||
assert!(s.prompt_text().ends_with("[Convert to block/Retain/Delete] <Delete>:"), "{}", s.prompt_text());
|
||||
s.cmdline("_R").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text());
|
||||
s.cmdline("0,0").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Select objects:"), "{}", s.prompt_text());
|
||||
lisp(&mut s, &ent(a));
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_is_asked_but_the_objects_are_erased_whatever_it_says() {
|
||||
// WBM-01, WBM-02: Retain and Convert to block are taken, yet A is erased (no block reference
|
||||
// takes its place), and the next -WBLOCK still says "Object conversion=Delete".
|
||||
for mode in ["_R", "_C"] {
|
||||
let (mut s, a, b, c) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
let first = file(&dir, "M01a.dwg");
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{first}\" \"\" \"_O\" \"{mode}\" '(0.0 0.0) {} \"\")", ent(a)));
|
||||
assert!(s.running.is_none(), "{mode}: {}", s.prompt_text());
|
||||
assert_eq!(read(&first), ["LINE 0,0"]);
|
||||
assert!(!alive(&s, a) && alive(&s, b) && alive(&s, c), "{mode}");
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2, "{mode}: nothing takes A's place");
|
||||
let before = s.log.len();
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "M01b.dwg"), ent(b)));
|
||||
assert!(s.log[before..].iter().any(|l| l == "Current settings: Object conversion=Delete"), "{mode}: {:?}", s.log);
|
||||
assert!(!alive(&s, b) && alive(&s, c), "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_selected_writes_nothing() {
|
||||
// WBM-05: the command ends; no file, nothing erased.
|
||||
let (mut s, a, b, c) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
let path = file(&dir, "M05.dwg");
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(0.0 0.0) \"\")"));
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(!std::path::Path::new(&path).exists());
|
||||
assert!(alive(&s, a) && alive(&s, b) && alive(&s, c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_file_is_replaced_only_on_yes() {
|
||||
// WBM-06: No (the default) ends the command; WBM-07: Yes goes on to the block prompt.
|
||||
let (mut s, a, b, _) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
let path = file(&dir, "M06.dwg");
|
||||
std::fs::write(&path, "old").unwrap();
|
||||
s.cmdline("-wblock").unwrap();
|
||||
s.cmdline(&path).unwrap();
|
||||
assert!(s.prompt_text().ends_with(&format!("{path} already exists, do you want to replace it? [Yes/No] <N>:")), "{}", s.prompt_text());
|
||||
// Only Yes replaces it: other text is asked again.
|
||||
s.cmdline("yodel").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword."));
|
||||
assert!(s.prompt_text().ends_with("[Yes/No] <N>:"), "{}", s.prompt_text());
|
||||
s.cmdline("_N").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "old");
|
||||
// Enter is No too.
|
||||
s.cmdline("-wblock").unwrap();
|
||||
s.cmdline(&path).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "old");
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"_Y\" \"\" '(0.0 0.0) {} \"\")", ent(b)));
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(read(&path), ["CIRCLE"]);
|
||||
assert!(alive(&s, a) && !alive(&s, b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_is_written_by_name_by_equals_or_the_whole_drawing_by_star() {
|
||||
let (mut s, a, b, c) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
s.execute("block", &json!({ "name": "WB01K", "base": [5, 5], "handles": [a.hex(), b.hex()], "keep": "delete" })).unwrap();
|
||||
// WBLOCK-001: a block by name, from its base point; nothing is erased.
|
||||
let path = file(&dir, "WB01.dwg");
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"WB01K\")"));
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]);
|
||||
assert!(alive(&s, c));
|
||||
// WBLOCK-002: "=" is the block named as the file.
|
||||
let path = file(&dir, "WB01K.dwg");
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"=\")"));
|
||||
assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]);
|
||||
// WBLOCK-003: "*" is the whole drawing.
|
||||
let path = file(&dir, "WB03.dwg");
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"*\")"));
|
||||
assert_eq!(read(&path), ["LINE 0,20"]);
|
||||
assert!(alive(&s, c));
|
||||
// WBLOCK-005: a point given there is a name too.
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" '(0.0 0.0))", file(&dir, "WB05.dwg")));
|
||||
assert!(s.log.iter().any(|l| l == "Block \"0,0\" not found."), "{:?}", s.log);
|
||||
s.cancel();
|
||||
// WBLOCK-008, WBLOCK-015: a name that isn't a block, typed or by "=", is refused and asked again.
|
||||
for (answer, name) in [("WB08ZZ", "WB08ZZ"), ("=", "WB15")] {
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"{answer}\")", file(&dir, "WB15.dwg")));
|
||||
assert!(s.log.iter().any(|l| *l == format!("Block \"{name}\" not found.")), "{answer}: {:?}", s.log);
|
||||
assert!(s.prompt_text().contains("Enter name of existing block"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
assert!(!std::path::Path::new(&file(&dir, "WB15.dwg")).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_without_extension_is_a_dwg_and_a_dxf_asks_for_its_precision() {
|
||||
// WBLOCK-004: no extension.
|
||||
let (mut s, a, b, _) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "WB04"), ent(a)));
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(read(&file(&dir, "WB04.dwg")), ["LINE 0,0"]);
|
||||
// WBLOCK-010: a DXF file asks for its decimal places first; Enter takes 16.
|
||||
let path = file(&dir, "WB10.dxf");
|
||||
s.cmdline("-wblock").unwrap();
|
||||
s.cmdline(&path).unwrap();
|
||||
assert!(s.log.iter().any(|l| l.starts_with("Current DXF settings: Precision= 16 Format= ASCII")), "{:?}", s.log);
|
||||
assert!(s.prompt_text().ends_with("Enter decimal places of accuracy (0 to 16) or [Binary/Preview] <16>:"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" \"\" '(0.0 0.0) {} \"\")", ent(b)));
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(read(&path), ["CIRCLE"]);
|
||||
assert!(!alive(&s, b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_brings_the_written_objects_back() {
|
||||
// WBM-09: U after -WBLOCK.
|
||||
let (mut s, a, b, c) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(5.0 5.0) {} {} \"\")", file(&dir, "M09.dwg"), ent(a), ent(b)));
|
||||
assert!(!alive(&s, a) && !alive(&s, b));
|
||||
s.cmdline("u").unwrap();
|
||||
assert!(alive(&s, a) && alive(&s, b) && alive(&s, c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_form_of_dash_wblock_erases_what_it_writes() {
|
||||
let (mut s, a, b, _) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
s.execute("wblock", &json!({ "path": file(&dir, "keep.dwg"), "handles": [a.hex()] })).unwrap();
|
||||
assert!(alive(&s, a), "WBLOCK's JSON form keeps them");
|
||||
s.execute("-wblock", &json!({ "path": file(&dir, "erase.dwg"), "handles": [b.hex()], "base": [5, 5] })).unwrap();
|
||||
assert_eq!(read(&file(&dir, "erase.dwg")), ["CIRCLE"]);
|
||||
assert!(!alive(&s, b));
|
||||
// Nothing selected writes no file, as at the command line.
|
||||
let none = file(&dir, "none.dwg");
|
||||
assert!(s.execute("-wblock", &json!({ "path": none, "handles": [] })).is_err());
|
||||
assert!(s.execute("-wblock", &json!({ "path": none, "handles": [b.hex()] })).is_err(), "erased already");
|
||||
assert!(s.execute("-wblock", &json!({ "path": none })).is_err());
|
||||
assert!(!std::path::Path::new(&none).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oops_brings_back_what_it_erased_where_it_was() {
|
||||
// OOPSW-001 to OOPSW-005 (the drawing's `entnext` order read back): OOPS brings back what
|
||||
// -WBLOCK erased, each where it was, as after ERASE. Only the last erase counts, and "*" (the
|
||||
// whole drawing) erases nothing.
|
||||
const OOPS: &str = "(command \"_.OOPS\")";
|
||||
// (script with @A to @D for the objects and @F for a new file, the order afterwards)
|
||||
let cases: [(&[&str], &str); 5] = [
|
||||
(&["(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B @C \"\")", OOPS], "A B C D"),
|
||||
(&["(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B \"\")", "(command \"_.ERASE\" @D \"\")", OOPS], "A C D"),
|
||||
(&["(command \"_.ERASE\" @D \"\")", "(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B \"\")", OOPS], "A B C"),
|
||||
(&["(command \"_.ERASE\" @D \"\")", "(command \"_.-WBLOCK\" @F \"*\")", OOPS], "A B C D"),
|
||||
(&["(command \"_.-WBLOCK\" @F \"\" \"_O\" \"_R\" '(0.0 0.0) @A @C \"\")", OOPS], "A B C D"),
|
||||
];
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
for (i, (script, want)) in cases.into_iter().enumerate() {
|
||||
let (mut s, a, b, c) = drawing();
|
||||
let v = s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap();
|
||||
let d = Handle::parse_hex(v["handles"][0].as_str().or(v["handle"].as_str()).unwrap()).unwrap();
|
||||
let hs = [a, b, c, d];
|
||||
for line in script {
|
||||
let mut line = line.to_string();
|
||||
for (n, h) in ["@A", "@B", "@C", "@D"].into_iter().zip(hs) {
|
||||
line = line.replace(n, &ent(h));
|
||||
}
|
||||
let line = line.replace("@F", &format!("\"{}\"", file(&dir, &format!("OOPSW{i}.dwg"))));
|
||||
lisp(&mut s, &line);
|
||||
assert!(s.running.is_none(), "{line}: {}", s.prompt_text());
|
||||
}
|
||||
let order: Vec<&str> =
|
||||
s.doc().unwrap().model.iter().map(|e| hs.iter().position(|h| *h == e.handle).map_or("E", |k| ["A", "B", "C", "D"][k])).collect();
|
||||
assert_eq!(order.join(" "), want, "{script:?}");
|
||||
}
|
||||
// The JSON form too.
|
||||
let (mut s, a, b, c) = drawing();
|
||||
s.execute("-wblock", &json!({ "path": file(&dir, "json.dwg"), "handles": [b.hex()] })).unwrap();
|
||||
assert!(!alive(&s, b));
|
||||
s.execute("oops", &json!({})).unwrap();
|
||||
let order: Vec<Handle> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
assert_eq!(order, [a, b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dash_wblock_refused_during_refedit_leaves_oops_as_it_was() {
|
||||
let (mut s, a, b, c) = drawing();
|
||||
let dir = TempDir::new("dash-wblock");
|
||||
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [a.hex()], "keep": "convert" })).unwrap();
|
||||
let ins = Handle::parse_hex(s.execute("entities", &json!({ "type": "INSERT" })).unwrap()["entities"][0]["handle"].as_str().unwrap()).unwrap();
|
||||
s.execute("erase", &json!({ "handles": [c.hex()] })).unwrap();
|
||||
s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
|
||||
// B isn't in the working set: -WBLOCK is refused and the drawing put back.
|
||||
assert!(s.execute("-wblock", &json!({ "path": file(&dir, "out.dwg"), "handles": [b.hex()] })).is_err());
|
||||
assert!(alive(&s, b));
|
||||
s.execute("refclose", &json!({ "save": false })).unwrap();
|
||||
// OOPS still has C, the last erase that stood.
|
||||
s.execute("oops", &json!({})).unwrap();
|
||||
assert!(alive(&s, c));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! `INSERT name=file` keeps the field of an attribute definition: the definition's extension
|
||||
//! dictionary and the objects in it come along under new handles, so the attributes the block's
|
||||
//! references get can carry the field when the drawing is saved.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::foreign::{ForeignExtra, ForeignObject};
|
||||
use cadcraft_engine::doc::{Attrib, Drawing, Entity, EntityKind, Handle, RawTag, Text};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
#[path = "support/temp_dir.rs"]
|
||||
mod temp_dir;
|
||||
|
||||
use temp_dir::TempDir;
|
||||
|
||||
fn tag(code: i32, value: &str) -> RawTag {
|
||||
RawTag { code, value: value.into() }
|
||||
}
|
||||
|
||||
fn object(handle: u64, kind: &str, tags: Vec<RawTag>) -> ForeignObject {
|
||||
ForeignObject { handle: Handle(handle), kind: kind.into(), tags }
|
||||
}
|
||||
|
||||
/// A drawing file in a test format: model space holds a definition NUMBER (handle 0x50) whose
|
||||
/// extension dictionary 0x60 owns the ACAD_FIELD dictionary 0x61, which owns the field 0x62 with
|
||||
/// a child field 0x63. Handles 0x61..0x63 stand for the file's own; a second, unrelated object
|
||||
/// 0x70 is not part of that tree. The bytes `plain` give the same definition without any of it.
|
||||
fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let text = Text {
|
||||
insert: Vec3::ZERO,
|
||||
align_pt: None,
|
||||
height: 2.0,
|
||||
value: "TEMPLATE".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
};
|
||||
let def = Attrib {
|
||||
tag: "NUMBER".into(),
|
||||
text,
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
};
|
||||
d.model.push(Entity::new(Handle(0x50), EntityKind::AttDef(def)));
|
||||
if bytes == b"plain" {
|
||||
return Ok(d);
|
||||
}
|
||||
let f = Arc::make_mut(&mut d.foreign);
|
||||
f.entities.insert(Handle(0x50), ForeignExtra { xdict: Some(Handle(0x60)), ..Default::default() });
|
||||
f.objects.push(object(0x60, "DICTIONARY", vec![tag(5, "60"), tag(330, "50"), tag(3, "ACAD_FIELD"), tag(360, "61")]));
|
||||
f.objects.push(object(0x61, "DICTIONARY", vec![tag(5, "61"), tag(330, "60"), tag(3, "TEXT"), tag(360, "62")]));
|
||||
f.objects.push(object(0x62, "FIELD", vec![tag(5, "62"), tag(330, "61"), tag(1, "_text"), tag(360, "63")]));
|
||||
f.objects.push(object(0x63, "FIELD", vec![tag(5, "63"), tag(330, "62"), tag(1, "AcVar")]));
|
||||
f.objects.push(object(0x70, "XRECORD", vec![tag(5, "70"), tag(330, "0")]));
|
||||
f.classes.push(vec![tag(1, "FIELD"), tag(2, "AcDbField")]);
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn hooks() {
|
||||
set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_definition_of_an_inserted_file_keeps_its_field() {
|
||||
hooks();
|
||||
let dir = TempDir::new("insert-file-field");
|
||||
let path = dir.file("border.dxf", "");
|
||||
let mut s = Session::new();
|
||||
s.execute("insert", &json!({ "name": "BORDER", "file": path, "at": [0, 0], "attribs": { "NUMBER": "A-1" } })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let def = d.block("BORDER").and_then(|b| b.entities.iter().find(|e| matches!(e.kind, EntityKind::AttDef(_)))).expect("the definition");
|
||||
assert_ne!(def.handle, Handle(0x50), "the definition has a handle of this drawing");
|
||||
let root = d.foreign.entities.get(&def.handle).and_then(|x| x.xdict).expect("the definition has an extension dictionary");
|
||||
let tree = d.foreign.tree(root);
|
||||
assert_eq!(tree.len(), 4, "the dictionaries and both fields, not the unrelated object");
|
||||
assert!(tree.iter().all(|h| ![0x60, 0x61, 0x62, 0x63, 0x70].contains(&h.0)), "the copies have new handles");
|
||||
// The root's owner is the definition, the others point at each other.
|
||||
let owner =
|
||||
|h: Handle| d.foreign.objects.iter().find(|o| o.handle == h).and_then(|o| o.tags.iter().find(|t| t.code == 330)).map(|t| t.value.clone());
|
||||
assert_eq!(owner(tree[0]), Some(def.handle.hex()));
|
||||
assert_eq!(owner(tree[1]), Some(tree[0].hex()));
|
||||
assert_eq!(owner(tree[3]), Some(tree[2].hex()));
|
||||
assert!(d.foreign.classes.iter().any(|c| c.iter().any(|t| t.code == 1 && t.value == "FIELD")), "the field class comes along");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_definition_without_a_field_gets_none() {
|
||||
hooks();
|
||||
let dir = TempDir::new("insert-file-nofield");
|
||||
let path = dir.file("plain.dxf", "plain");
|
||||
let mut s = Session::new();
|
||||
s.execute("insert", &json!({ "name": "PLAIN", "file": path, "at": [0, 0] })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.block("PLAIN").is_some());
|
||||
assert!(d.foreign.is_empty(), "nothing is kept for a drawing without fields");
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::lisp::value::Value;
|
||||
use cadcraft_engine::units::AngleSettings;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -40,8 +41,77 @@ fn polar_angles_measure_from_angbase_in_angdir() {
|
||||
assert_near(line_end(&mut s, "@10<<90"), 0.0, 10.0, "@10<<90");
|
||||
setvar(&mut s, "AUNITS", json!(3));
|
||||
assert_near(line_end(&mut s, "@10<<<3.141592653589793"), -10.0, 0.0, "@10<<<pi");
|
||||
// A surveyor's bearing is absolute.
|
||||
assert_near(line_end(&mut s, "@10<N90dE"), 10.0, 0.0, "@10<N90dE");
|
||||
// A surveyor's bearing is an angle like any other, from ANGBASE in the ANGDIR sense: AutoCAD
|
||||
// 2024 draws `@10<N45dE` at 45 degrees here, and `@10<N` east.
|
||||
setvar(&mut s, "AUNITS", json!(0));
|
||||
let h = 10.0 / 2f64.sqrt();
|
||||
assert_near(line_end(&mut s, "@10<N45dE"), h, h, "@10<N45dE");
|
||||
assert_near(line_end(&mut s, "@10<N"), 10.0, 0.0, "@10<N");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearings_and_compass_points_turn_with_angbase_and_angdir() {
|
||||
// AutoCAD 2024 (accoreconsole), LINE 0,0 to the point: (input, AUNITS, ANGBASE in degrees,
|
||||
// ANGDIR, the direction of the line in degrees counterclockwise from east). A bearing or a
|
||||
// compass point is an angle like any other, in any AUNITS; `<<<` measures it from east.
|
||||
let cases = [
|
||||
("@10<N", 0, 0.0, 0, 90.0),
|
||||
("@10<N", 4, 0.0, 0, 90.0),
|
||||
("@10<E", 0, 0.0, 0, 0.0),
|
||||
("@10<W", 4, 0.0, 0, 180.0),
|
||||
("@10<S", 1, 0.0, 0, 270.0),
|
||||
("@10<N45dE", 0, 0.0, 0, 45.0),
|
||||
("@10<N", 4, 90.0, 1, 0.0),
|
||||
("@10<N45dE", 0, 90.0, 1, 45.0),
|
||||
("@10<N45dE", 0, 0.0, 1, 315.0),
|
||||
("@10<N45dE", 0, 90.0, 0, 135.0),
|
||||
("@10<N", 0, 90.0, 0, 180.0),
|
||||
("@10<E", 0, 0.0, 1, 0.0),
|
||||
("@10<N", 0, 0.0, 1, 270.0),
|
||||
// ANGBASE 0.5 radians: AutoCAD drew it at 253.64789 degrees.
|
||||
("@10<S45dW", 0, 0.5f64.to_degrees(), 0, 225.0 + 0.5f64.to_degrees()),
|
||||
("@10<<<N45dE", 4, 90.0, 1, 45.0),
|
||||
("@10<<<N45dE", 0, 90.0, 1, 45.0),
|
||||
("@10<<<N", 4, 90.0, 1, 90.0),
|
||||
("@10<<<N", 0, 90.0, 1, 90.0),
|
||||
];
|
||||
for (to, aunits, angbase, angdir, deg) in cases {
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "AUNITS", json!(aunits));
|
||||
setvar(&mut s, "ANGBASE", json!(angbase));
|
||||
setvar(&mut s, "ANGDIR", json!(angdir));
|
||||
let a = f64::to_radians(deg);
|
||||
assert_near(line_end(&mut s, to), 10.0 * a.cos(), 10.0 * a.sin(), &format!("{to} AUNITS {aunits} ANGBASE {angbase} ANGDIR {angdir}"));
|
||||
}
|
||||
// `<<` is in degrees only: AutoCAD refuses a bearing or a compass point there.
|
||||
let settings = AngleSettings { aunits: 0, angbase: std::f64::consts::FRAC_PI_2, clockwise: true };
|
||||
assert_eq!(settings.direction("<<N45dE"), None);
|
||||
assert_eq!(settings.direction("<<N"), None);
|
||||
assert!(settings.direction("<<45").is_some_and(|a| (a - std::f64::consts::FRAC_PI_4).abs() < 1e-12));
|
||||
// A compass point as an amount turns as ANGDIR says: ROTATE by N with ANGDIR 1 turns a line
|
||||
// along +X to -Y in AutoCAD 2024.
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "ANGDIR", json!(1));
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
for t in ["rotate", "all", "", "0,0", "N"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::Line(l) => assert_near(l.b.xy(), 0.0, -10.0, "ROTATE by N with ANGDIR 1"),
|
||||
k => panic!("expected a line, got {k:?}"),
|
||||
}
|
||||
// ANGTOF reads them the same way: AutoCAD 2024 returns 0 for "N" with ANGBASE 90, ANGDIR 1
|
||||
// and AUNITS 3, and 4.18879020478639 for "S 30d W".
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "AUNITS", json!(3));
|
||||
setvar(&mut s, "ANGBASE", json!(90));
|
||||
setvar(&mut s, "ANGDIR", json!(1));
|
||||
let n = s.lisp_eval("(angtof \"N\")", false, true, Vec::new()).unwrap();
|
||||
assert!(matches!(n, Some(Ok(Value::Real(a))) if a.abs() < 1e-12), "{n:?}");
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "AUNITS", json!(4));
|
||||
let sw = s.lisp_eval("(angtof \"S 30d W\")", false, true, Vec::new()).unwrap();
|
||||
assert!(matches!(sw, Some(Ok(Value::Real(a))) if (a - 4.18879020478639).abs() < 1e-12), "{sw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -212,3 +212,20 @@ fn arrays_over_maxarray_are_refused_not_shrunk() {
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
assert_eq!(circle_centers(&s).len(), 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_arraypolar_rejects_bad_fill_angles() {
|
||||
let mut s = Session::new();
|
||||
let h = s.execute("circle", &json!({"center": [5, 0], "radius": 1})).unwrap()["handle"].clone();
|
||||
let before = s.doc().unwrap().model.iter().count();
|
||||
for bad in [0.0, 720.0, -400.0, 360.5] {
|
||||
let p = json!({"handles": [h], "center": [0, 0], "count": 4, "angle": bad, "associative": false});
|
||||
let e = s.execute("arraypolar", &p).unwrap_err();
|
||||
assert!(e.to_string().contains("Requires a fill angle between -360 and 360 degrees"), "{bad}: {e}");
|
||||
assert_eq!(s.doc().unwrap().model.iter().count(), before, "{bad}");
|
||||
}
|
||||
for ok in [360.0, -360.0] {
|
||||
let p = json!({"handles": [h], "center": [0, 0], "count": 4, "angle": ok, "associative": false});
|
||||
s.execute("arraypolar", &p).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ fn typed_arrayedit() {
|
||||
let a = rect_array(&mut s);
|
||||
typed(&mut s, &["arrayedit", "l"]);
|
||||
let p = s.prompt_text();
|
||||
assert!(p.contains("Enter an option or [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] <eXit>"), "{p}");
|
||||
assert!(p.contains("Enter an option [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] <eXit>"), "{p}");
|
||||
typed(&mut s, &["c", "4", "t", "9", "r", "2", "5", ""]);
|
||||
assert!(s.prompt_text().contains("Enter an option"), "{}", s.prompt_text());
|
||||
assert_eq!(centres(&s, &a).len(), 8);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts with
|
||||
//! TEXTSIZE 2.5 and AFLAGS 16, the drawing and AFLAGS read back afterwards).
|
||||
|
||||
use cadcraft_engine::doc::{Attrib, EntityKind, HAlign, VAlign};
|
||||
use cadcraft_engine::{Session, sysvars};
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "TEXTSIZE", "value": 2.5 })).unwrap();
|
||||
s.execute("setvar", &json!({ "name": "AFLAGS", "value": 16 })).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn attdefs(s: &Session) -> Vec<Attrib> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn aflags(s: &Session) -> i64 {
|
||||
sysvars::get(s, "AFLAGS").and_then(|v| v.as_f64()).unwrap() as i64
|
||||
}
|
||||
|
||||
fn run(script: &str) -> Session {
|
||||
let mut s = session();
|
||||
s.cmdline(script).unwrap();
|
||||
assert!(s.running.is_none(), "{script}: {}", s.prompt_text());
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_are_toggled_kept_in_aflags_and_stored_on_the_definition() {
|
||||
// (script, AFLAGS after, invisible, constant, verify, preset, lock position, tag, prompt, value)
|
||||
type Case = (&'static str, i64, [bool; 5], &'static str, &'static str, &'static str);
|
||||
let cases: [Case; 5] = [
|
||||
// ATTDEF-000: the modes as they are.
|
||||
(
|
||||
"(command \"_.-ATTDEF\" \"\" \"T1\" \"Nhap ten\" \"MAC\" '(5.0 10.0) 2.5 0)",
|
||||
16,
|
||||
[false, false, false, false, true],
|
||||
"T1",
|
||||
"Nhap ten",
|
||||
"MAC",
|
||||
),
|
||||
// ATTDEF-001: Invisible and Verify.
|
||||
(
|
||||
"(command \"_.-ATTDEF\" \"_I\" \"_V\" \"\" \"T2\" \"Hoi hai\" \"D2\" '(5.0 10.0) 3.0 30)",
|
||||
21,
|
||||
[true, false, true, false, true],
|
||||
"T2",
|
||||
"Hoi hai",
|
||||
"D2",
|
||||
),
|
||||
// ATTDEF-002: Constant asks for the value, not a prompt and a default.
|
||||
("(command \"_.-ATTDEF\" \"_C\" \"\" \"T3\" \"GIATRI\" '(5.0 10.0) 2.5 0)", 18, [false, true, false, false, true], "T3", "", "GIATRI"),
|
||||
// ATTDEF-003: Preset.
|
||||
(
|
||||
"(command \"_.-ATTDEF\" \"_P\" \"\" \"T4\" \"Hoi bon\" \"D4\" '(612350.678 2398775.432) 2.5 0)",
|
||||
24,
|
||||
[false, false, false, true, true],
|
||||
"T4",
|
||||
"Hoi bon",
|
||||
"D4",
|
||||
),
|
||||
// ATTDEF-004: Lock position off.
|
||||
(
|
||||
"(command \"_.-ATTDEF\" \"_L\" \"\" \"T5\" \"Hoi nam\" \"D5\" '(5.0 10.0) 2.5 0)",
|
||||
0,
|
||||
[false, false, false, false, false],
|
||||
"T5",
|
||||
"Hoi nam",
|
||||
"D5",
|
||||
),
|
||||
];
|
||||
for (script, flags, [inv, con, ver, pre, lock], tag, prompt, value) in cases {
|
||||
let s = run(script);
|
||||
assert_eq!(aflags(&s), flags, "{script}");
|
||||
let a = attdefs(&s).remove(0);
|
||||
assert_eq!((a.invisible, a.constant, a.modes.verify, a.modes.preset, a.modes.lock_position), (inv, con, ver, pre, lock), "{script}");
|
||||
assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.text.value.as_str()), (tag, prompt, value), "{script}");
|
||||
}
|
||||
// ATTDEF-001's height 3 and 30 degrees.
|
||||
let a = attdefs(&run(cases[1].0)).remove(0);
|
||||
assert!((a.text.height - 3.0).abs() < 1e-12 && (a.text.rotation - 30f64.to_radians()).abs() < 1e-12, "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_justification_and_style_as_autocad_takes_them() {
|
||||
// ATTDEF-021, ATTDEF-022: a tag is upper-cased, spaces and all.
|
||||
for (typed, tag) in [("maso", "MASO"), ("ten ban ve", "TEN BAN VE")] {
|
||||
let s = run(&format!("(command \"_.-ATTDEF\" \"\" \"{typed}\" \"Hoi\" \"D\" '(5.0 10.0) 2.5 0)"));
|
||||
assert_eq!(attdefs(&s)[0].tag, tag);
|
||||
}
|
||||
// ATTDEF-023: an empty prompt stays empty.
|
||||
let s = run("(command \"_.-ATTDEF\" \"\" \"T10\" \"\" \"D10\" '(612350.678 2398775.432) 2.5 0)");
|
||||
assert_eq!(attdefs(&s)[0].prompt, "");
|
||||
// ATTDEF-025: an empty tag is refused and asked again.
|
||||
let s = run("(command \"_.-ATTDEF\" \"\" \"\" \"T11\" \"Hoi muoi mot\" \"D11\" '(5.0 10.0) 2.5 0)");
|
||||
assert!(s.log.iter().any(|l| l == "Tag cannot be null"), "{:?}", s.log);
|
||||
assert_eq!(attdefs(&s)[0].tag, "T11");
|
||||
// ATTDEF-005: Justify MC at the middle point.
|
||||
let s = run("(command \"_.-ATTDEF\" \"\" \"T6\" \"Hoi sau\" \"D6\" \"_J\" \"_MC\" '(5.0 10.0) 2.5 0)");
|
||||
let a = attdefs(&s).remove(0);
|
||||
assert_eq!((a.text.halign, a.text.valign), (HAlign::Center, VAlign::Middle));
|
||||
assert!(a.text.align_pt.is_some_and(|p| p.x == 5.0 && p.y == 10.0), "{a:?}");
|
||||
// ATTDEF-006: Style, then the point.
|
||||
let s = run("(command \"_.-ATTDEF\" \"\" \"T7\" \"Hoi bay\" \"D7\" \"_S\" \"Standard\" '(0.505 0.26) 2.5 0)");
|
||||
let a = attdefs(&s).remove(0);
|
||||
assert!(a.text.style == "Standard" && a.text.insert.x == 0.505 && a.text.insert.y == 0.26, "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompts_are_autocads() {
|
||||
let mut s = session();
|
||||
s.cmdline("-attdef").unwrap();
|
||||
assert!(s.log.iter().any(|l| l == "Current attribute modes:"), "{:?}", s.log);
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Invisible=N Constant=N Verify=N Preset=N Lock position=Y Annotative=N Multiple line=N"));
|
||||
assert!(s.prompt_text().contains("[Invisible/Constant/Verify/Preset/Lock position/Annotative/Multiple lines] <done>:"), "{}", s.prompt_text());
|
||||
s.cmdline("X").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword."));
|
||||
s.cmdline("_C").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Invisible=N Constant=Y Verify=N Preset=N Lock position=Y Annotative=N Multiple line=N"));
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Enter attribute tag name:"), "{}", s.prompt_text());
|
||||
s.cmdline("T3").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Enter attribute value:"), "{}", s.prompt_text());
|
||||
s.cmdline("GIATRI").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Current text style: \"Standard\" Text height: 2.5000 Justify: Left"));
|
||||
assert!(s.prompt_text().ends_with("Specify start point of text or [Justify/Style]:"), "{}", s.prompt_text());
|
||||
s.cmdline("_J").unwrap();
|
||||
// A wrong answer, or a justification not available here, is asked again.
|
||||
for typed in ["X", "_A"] {
|
||||
s.cmdline(typed).unwrap();
|
||||
assert!(s.prompt_text().contains("[Left/Center/Right/Align/Middle/Fit/TL/"), "{typed}: {}", s.prompt_text());
|
||||
}
|
||||
s.cmdline("_MC").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Specify middle point of text:"), "{}", s.prompt_text());
|
||||
s.cmdline("0,0").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Specify height <2.5000>:"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Specify rotation angle of text <0>:"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_constant_attribute_stays_in_the_block() {
|
||||
// INSERTATT-011: the constant TC is no attribute of the reference; the other one, TN, is.
|
||||
let mut s = session();
|
||||
s.execute("line", &json!({ "points": [[612345.678, 2398765.432], [612355.678, 2398765.432]] })).unwrap();
|
||||
s.cmdline("(setq hl_A (entlast))").unwrap();
|
||||
s.cmdline("(progn (command \"_.-ATTDEF\" \"_C\" \"\" \"TC\" \"HANG\" '(612350.678 2398775.432) 2.5 0) (setq hl_a1 (entlast)))").unwrap();
|
||||
s.cmdline("(setvar \"AFLAGS\" 16)").unwrap();
|
||||
s.cmdline("(progn (command \"_.-ATTDEF\" \"\" \"TN\" \"Hoi N\" \"MN\" '(612350.678 2398785.432) 2.5 0) (setq hl_a2 (entlast)))").unwrap();
|
||||
s.cmdline("(command \"_.-BLOCK\" \"B11\" '(612345.678 2398765.432) hl_a1 hl_a2 hl_A \"\")").unwrap();
|
||||
s.cmdline("(command \"_.-INSERT\" \"B11\" '(612445.678 2398815.432) 1 1 0 \"XN\")").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let ins = s.doc().unwrap().model.iter().find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).unwrap();
|
||||
let tags: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect();
|
||||
assert_eq!(tags, [("TN".to_string(), "XN".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_height_is_a_distance() {
|
||||
let mut s = session();
|
||||
for typed in ["-attdef", "", "T1", "P", "V", "5,10", "x"] {
|
||||
s.cmdline(typed).unwrap();
|
||||
}
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Requires a positive height."));
|
||||
// Feet and inches, as TEXT takes them.
|
||||
for typed in ["1'6\"", "0"] {
|
||||
s.cmdline(typed).unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(attdefs(&s)[0].text.height, 18.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_modes_start_from_aflags() {
|
||||
// AFLAGS 0, not the default 16: Invisible toggled on gives 1, and nothing else.
|
||||
let mut s = session();
|
||||
s.execute("setvar", &json!({ "name": "AFLAGS", "value": 0 })).unwrap();
|
||||
s.cmdline("(command \"_.-ATTDEF\" \"_I\" \"\" \"T1\" \"Hoi\" \"D\" '(5.0 10.0) 2.5 0)").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(aflags(&s), 1);
|
||||
let a = attdefs(&s).remove(0);
|
||||
assert_eq!((a.invisible, a.modes.lock_position), (true, false));
|
||||
}
|
||||
@@ -314,6 +314,7 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition(
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
};
|
||||
let blocks = |dim_end: f64, first: u64| {
|
||||
let mut part = Block::new("PART");
|
||||
@@ -400,6 +401,7 @@ fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_dra
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
};
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim)));
|
||||
@@ -514,6 +516,7 @@ fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() {
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let mut drawn = Block::new("*D1");
|
||||
@@ -833,6 +836,7 @@ fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() {
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
};
|
||||
for k in 1..=MAX_BLOCK_DEPTH {
|
||||
let mut b = Block::new(&format!("B{k}"));
|
||||
|
||||
@@ -39,7 +39,7 @@ fn run(s: &mut Session, answers: &str) {
|
||||
|
||||
#[test]
|
||||
fn the_measured_scripts_set_the_units() {
|
||||
let cases: [(Units, &str, Units); 21] = [
|
||||
let cases: [(Units, &str, Units); 22] = [
|
||||
(DEFAULT, "4 16 1 0 0 \"_N\"", (4, 4, 0, 0, 0.0, 0)),
|
||||
(DEFAULT, "3 2 2 4 90 \"_Y\"", (3, 2, 1, 4, FRAC_PI_2, 1)),
|
||||
(DEFAULT, "2 3 3 2 100 \"_N\"", (2, 3, 2, 2, FRAC_PI_2, 0)),
|
||||
@@ -61,6 +61,7 @@ fn the_measured_scripts_set_the_units() {
|
||||
(DEFAULT, "\"\" \"\" \"\" \"\" \"\" \"Yes\"", (2, 4, 0, 0, 0.0, 1)),
|
||||
((2, 4, 2, 6, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 2, 6, 0.0, 0)),
|
||||
((2, 4, 4, 2, 0.0, 1), "\"\" \"\" \"\" \"\" \"S45dE\" \"\"", (2, 4, 4, 2, 0.7853981633974482, 1)),
|
||||
(DEFAULT, "\"\" \"\" \"\" \"\" \"N\" \"\"", (2, 4, 0, 0, FRAC_PI_2, 0)),
|
||||
];
|
||||
for (from, answers, to) in cases {
|
||||
let mut s = session(from);
|
||||
@@ -112,15 +113,7 @@ fn prompts_and_listings_show_the_drawings_units() {
|
||||
|
||||
// Surveyor's units, clockwise: north is listed as S, as AutoCAD lists it.
|
||||
let mut s = session((2, 4, 0, 0, 0.0, 1));
|
||||
s.script(
|
||||
"-units
|
||||
|
||||
|
||||
5
|
||||
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
s.script("-units\n\n\n5\n\n").unwrap();
|
||||
let directions = [
|
||||
"Direction for angle E:",
|
||||
" East 3 o'clock = E",
|
||||
@@ -133,15 +126,7 @@ fn prompts_and_listings_show_the_drawings_units() {
|
||||
|
||||
// The listing shows directions to the precision just given.
|
||||
let mut s = session(DEFAULT);
|
||||
s.script(
|
||||
"-units
|
||||
|
||||
|
||||
2
|
||||
2
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
s.script("-units\n\n\n2\n2\n").unwrap();
|
||||
let directions = [
|
||||
"Direction for angle 0d0':",
|
||||
" East 3 o'clock = 0d0'",
|
||||
@@ -166,27 +151,11 @@ fn a_rejected_answer_is_asked_again() {
|
||||
s.cmdline("3").unwrap();
|
||||
assert!(ends_with(&s, &["Invalid choice.", "Enter denominator of smallest fraction to display"]), "{:?}", s.log);
|
||||
assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <16>:");
|
||||
s.script(
|
||||
"
|
||||
|
||||
9
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
s.script("\n\n9\n").unwrap();
|
||||
assert!(ends_with(&s, &["Requires an integer between 0 and 8."]), "{:?}", s.log);
|
||||
s.script(
|
||||
"
|
||||
abc
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
s.script("\nabc\n").unwrap();
|
||||
assert!(ends_with(&s, &["Requires valid numeric angle or two points."]), "{:?}", s.log);
|
||||
s.script(
|
||||
"
|
||||
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
s.script("\n\n").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(same(units(&s), (4, 4, 0, 0, 0.0, 0)), "{:?}", units(&s));
|
||||
}
|
||||
@@ -196,28 +165,12 @@ fn each_answer_is_kept_as_it_is_given() {
|
||||
// Esc after the angle format: the answers before it are kept, and the new angle format's
|
||||
// precision is reset to 4.
|
||||
let mut s = session(DEFAULT);
|
||||
s.script(
|
||||
"-units
|
||||
4
|
||||
16
|
||||
2
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
s.script("-units\n4\n16\n2\n").unwrap();
|
||||
s.cancel();
|
||||
assert!(s.running.is_none());
|
||||
assert!(same(units(&s), (4, 4, 1, 4, 0.0, 0)), "{:?}", units(&s));
|
||||
let mut s = session(DEFAULT);
|
||||
s.script(
|
||||
"-units
|
||||
3
|
||||
2
|
||||
3
|
||||
5
|
||||
90
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
s.script("-units\n3\n2\n3\n5\n90\n").unwrap();
|
||||
s.cancel();
|
||||
assert!(same(units(&s), (3, 2, 2, 5, 1.413716694115407, 0)), "{:?}", units(&s));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! AREA, PERIMETER and DISTANCE as AutoCAD 2024 leaves them after AREA, DIST and MEASUREGEOM
|
||||
//! (measured with accoreconsole, running these scripts on these drawings): AREA is the area just
|
||||
//! measured, or the running total in Add and Subtract mode, PERIMETER the perimeter of the last
|
||||
//! area; DISTANCE the distance measured, or the total of DIST's Multiple points. After DIST's two
|
||||
//! points LASTPOINT is still the first; after Multiple points it is the last.
|
||||
|
||||
use cadcraft_engine::doc::{Common, EntityKind, Handle};
|
||||
use cadcraft_engine::geom::{Spline, Vec2};
|
||||
use cadcraft_engine::{Input, Session, sysvars};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn var(s: &Session, name: &str) -> f64 {
|
||||
sysvars::get(s, name).and_then(|v| v.as_f64()).unwrap_or_else(|| panic!("{name} not a number"))
|
||||
}
|
||||
|
||||
fn lastpoint(s: &Session) -> (f64, f64) {
|
||||
let v = sysvars::get(s, "LASTPOINT").unwrap();
|
||||
(v[0].as_f64().unwrap(), v[1].as_f64().unwrap())
|
||||
}
|
||||
|
||||
fn close(got: f64, want: f64) -> bool {
|
||||
(got - want).abs() <= 1e-9 * (1.0 + want.abs())
|
||||
}
|
||||
|
||||
fn handle(v: &Value) -> Handle {
|
||||
Handle::parse_hex(v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn lisp(s: &mut Session, expr: &str) {
|
||||
s.cmdline(expr).unwrap();
|
||||
assert!(s.running.is_none(), "{expr}: {}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// The closed polyline with an arc and the circle the Add-mode scripts pick.
|
||||
fn p_and_c(s: &mut Session) -> (Handle, Handle) {
|
||||
let p = handle(&s.execute("pline", &json!({ "vertices": [[0, 0], { "p": [40, 0], "bulge": 0.5 }, [40, 30], [0, 30]], "closed": true })).unwrap());
|
||||
let c = handle(&s.execute("circle", &json!({ "center": [150, 15], "radius": 10 })).unwrap());
|
||||
(p, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_of_points_sets_area_and_perimeter() {
|
||||
let mut s = Session::new();
|
||||
lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\")");
|
||||
assert!(close(var(&s, "AREA"), 1200.0) && close(var(&s, "PERIMETER"), 140.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mode_totals_the_area_and_keeps_the_last_perimeter() {
|
||||
// Points, then the circle as an object.
|
||||
let mut s = Session::new();
|
||||
let (_, c) = p_and_c(&mut s);
|
||||
lisp(
|
||||
&mut s,
|
||||
&format!(
|
||||
"(command \"_.AREA\" \"_A\" '(0.0 0.0) '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\" \"_O\" (list (handent \"{}\") '(160.0 15.0)) \"\" \"\")",
|
||||
c.hex()
|
||||
),
|
||||
);
|
||||
assert!(close(var(&s, "AREA"), 1514.159265358979), "{}", var(&s, "AREA"));
|
||||
assert!(close(var(&s, "PERIMETER"), 62.83185307179586), "{}", var(&s, "PERIMETER"));
|
||||
// Both as objects.
|
||||
let mut s = Session::new();
|
||||
let (p, c) = p_and_c(&mut s);
|
||||
lisp(
|
||||
&mut s,
|
||||
&format!(
|
||||
"(command \"_.AREA\" \"_A\" \"_O\" (list (handent \"{}\") '(0.0 15.0)) (list (handent \"{}\") '(160.0 15.0)) \"\" \"\")",
|
||||
p.hex(),
|
||||
c.hex()
|
||||
),
|
||||
);
|
||||
assert!(close(var(&s, "AREA"), 1671.411490437671), "{}", var(&s, "AREA"));
|
||||
assert!(close(var(&s, "PERIMETER"), 62.83185307179586), "{}", var(&s, "PERIMETER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtract_mode_takes_the_area_off_the_total() {
|
||||
// At large coordinates: the polyline added, the circle subtracted.
|
||||
let mut s = Session::new();
|
||||
let p = handle(
|
||||
&s.execute(
|
||||
"pline",
|
||||
&json!({ "vertices": [[-35000, 81000], { "p": [-33000, 81000], "bulge": 0.5 }, [-33000, 82500], [-35000, 82500]], "closed": true }),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let c = handle(&s.execute("circle", &json!({ "center": [-27000, 81750], "radius": 500 })).unwrap());
|
||||
lisp(
|
||||
&mut s,
|
||||
&format!(
|
||||
"(command \"_.AREA\" \"_A\" \"_O\" (list (handent \"{}\") '(-35000.0 81750.0)) \"\" \"_S\" \"_O\" (list (handent \"{}\") '(-27000.0 81750.0)) \"\" \"\")",
|
||||
p.hex(),
|
||||
c.hex()
|
||||
),
|
||||
);
|
||||
assert!(close(var(&s, "AREA"), 2607732.399299283), "{}", var(&s, "AREA"));
|
||||
assert!(close(var(&s, "PERIMETER"), 3141.592653589793), "{}", var(&s, "PERIMETER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_of_an_open_spline_closes_it_by_its_chord() {
|
||||
let control = [(0.8, 0.25), (0.82, 0.24), (0.84, 0.25), (0.84, 0.28), (0.82, 0.29), (0.8, 0.28), (0.8, 0.25)];
|
||||
let spline = Spline {
|
||||
degree: 3,
|
||||
knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 4.0, 4.0, 4.0],
|
||||
control: control.iter().map(|&(x, y)| Vec2::new(x, y)).collect(),
|
||||
weights: Vec::new(),
|
||||
fit: Vec::new(),
|
||||
closed: false,
|
||||
fit_opts: Default::default(),
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let space = s.space();
|
||||
let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap();
|
||||
lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.8 0.265)))", h.hex()));
|
||||
assert!(close(var(&s, "AREA"), 0.001308333333333334), "{}", var(&s, "AREA"));
|
||||
// The spline's length, 0.13046952991870 by an independent integration. AutoCAD reported
|
||||
// 0.1304733129441191, 2.9e-5 longer than that: its own approximation of the length.
|
||||
assert!(close(var(&s, "PERIMETER"), 0.1304695299187), "{}", var(&s, "PERIMETER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_of_a_spline_counts_its_narrow_knot_spans() {
|
||||
// A degree-1 spline is its control polygon, here the triangle (0,0) (1,0) (0,1): area 0.5,
|
||||
// perimeter 2 + sqrt(2), though two of its three knot spans are a billionth of the domain.
|
||||
let control = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (0.0, 0.0)];
|
||||
let spline = Spline {
|
||||
degree: 1,
|
||||
knots: vec![0.0, 0.0, 1e-9, 2e-9, 1.0, 1.0],
|
||||
control: control.iter().map(|&(x, y)| Vec2::new(x, y)).collect(),
|
||||
..Spline::default()
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let space = s.space();
|
||||
let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap();
|
||||
lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.5 0.0)))", h.hex()));
|
||||
assert!(close(var(&s, "AREA"), 0.5), "{}", var(&s, "AREA"));
|
||||
assert!(close(var(&s, "PERIMETER"), 2.0 + 2f64.sqrt()), "{}", var(&s, "PERIMETER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_of_a_spline_with_more_spans_than_samples_counts_each() {
|
||||
// The square (0,0) (100,0) (100,100) (0,100) as a degree-1 spline: 5000 spans along the bottom,
|
||||
// then two a millionth wide up the right side and across the top. Area 10000, perimeter (the
|
||||
// open spline's length) 300.
|
||||
let mut control: Vec<Vec2> = (0..=5000).map(|i| Vec2::new(i as f64 / 50.0, 0.0)).collect();
|
||||
control.extend([Vec2::new(100.0, 100.0), Vec2::new(0.0, 100.0)]);
|
||||
let mut knots: Vec<f64> = (0..=5000).map(f64::from).collect();
|
||||
knots.insert(0, 0.0);
|
||||
knots.extend([5000.0 + 1e-6, 5000.0 + 2e-6, 5000.0 + 2e-6]);
|
||||
let spline = Spline { degree: 1, knots, control, ..Spline::default() };
|
||||
let mut s = Session::new();
|
||||
let space = s.space();
|
||||
let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap();
|
||||
lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(50.0 0.0)))", h.hex()));
|
||||
assert!(close(var(&s, "AREA"), 1e4), "{}", var(&s, "AREA"));
|
||||
assert!(close(var(&s, "PERIMETER"), 300.0), "{}", var(&s, "PERIMETER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spline_too_long_to_measure_is_refused_and_not_kept() {
|
||||
let mut s = Session::new();
|
||||
lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(10.0 0.0) '(10.0 10.0) \"\")");
|
||||
// Finite ends 2e308 apart: its chord closes no area, but its length is past f64's range. That is
|
||||
// too large to report, as a polyline's is, not a perimeter of 0.
|
||||
let spline = Spline::from_line(Vec2::new(-1e308, 0.0), Vec2::new(1e308, 0.0), 1);
|
||||
let space = s.space();
|
||||
let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap();
|
||||
let err = s.execute("area", &json!({ "handle": h.hex() })).unwrap_err();
|
||||
assert!(err.to_string().contains("the perimeter is too large"), "{err}");
|
||||
lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.0 0.0)))", h.hex()));
|
||||
assert!(s.log.iter().any(|l| l.contains("The perimeter is too large.")), "{:?}", s.log);
|
||||
assert!(close(var(&s, "AREA"), 50.0), "{}", var(&s, "AREA"));
|
||||
assert!(close(var(&s, "PERIMETER"), 20.0 + 200f64.sqrt()), "{}", var(&s, "PERIMETER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dist_sets_the_distance_and_leaves_lastpoint_at_the_first_point() {
|
||||
let mut s = Session::new();
|
||||
lisp(&mut s, "(command \"_.DIST\" '(0.5 0.25) '(0.53 0.29))");
|
||||
assert!(close(var(&s, "DISTANCE"), 0.05), "{}", var(&s, "DISTANCE"));
|
||||
let (x, y) = lastpoint(&s);
|
||||
assert!(close(x, 0.5) && close(y, 0.25), "{x}, {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dist_with_multiple_points_sets_the_total_and_lastpoint_at_the_last() {
|
||||
let mut s = Session::new();
|
||||
lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\")");
|
||||
assert!(close(var(&s, "DISTANCE"), 110.0), "{}", var(&s, "DISTANCE"));
|
||||
let (x, y) = lastpoint(&s);
|
||||
assert!(close(x, 0.0) && close(y, 30.0), "{x}, {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_sets_them_as_dist_and_area_do() {
|
||||
let mut s = Session::new();
|
||||
// (AutoCAD's MEASUREGEOM asks for the next option after each measurement until eXit; here it
|
||||
// ends after one.)
|
||||
lisp(&mut s, "(command \"_.MEASUREGEOM\" \"_D\" '(0.0 0.0) '(30.0 40.0))");
|
||||
assert!(close(var(&s, "DISTANCE"), 50.0), "{}", var(&s, "DISTANCE"));
|
||||
lisp(&mut s, "(command \"_.MEASUREGEOM\" \"_AR\" '(0.5 0.25) '(0.54 0.25) '(0.54 0.28) '(0.5 0.28) \"\")");
|
||||
assert!(close(var(&s, "AREA"), 0.0012) && close(var(&s, "PERIMETER"), 0.14), "{} {}", var(&s, "AREA"), var(&s, "PERIMETER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_forms_set_them_too_and_they_are_read_only() {
|
||||
let mut s = Session::new();
|
||||
s.execute("area", &json!({ "points": [[0, 0], [40, 0], [40, 30], [0, 30]] })).unwrap();
|
||||
s.execute("dist", &json!({ "p1": [0, 0], "p2": [30, 40] })).unwrap();
|
||||
assert_eq!((var(&s, "AREA"), var(&s, "PERIMETER"), var(&s, "DISTANCE")), (1200.0, 140.0, 50.0));
|
||||
for name in ["AREA", "PERIMETER", "DISTANCE"] {
|
||||
assert!(s.execute("setvar", &json!({ "name": name, "value": 1 })).is_err(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lastpoint_after_multiple_points_is_the_last_point_entered() {
|
||||
// A length typed with Length extends the path, but LASTPOINT stays at the last point picked;
|
||||
// after Undo it stays at the point undone (AutoCAD 2024 leaves LASTPOINT at 10,0 and 10,10).
|
||||
let mut s = Session::new();
|
||||
lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(10.0 0.0) \"_L\" 5.0 \"\")");
|
||||
assert!(close(var(&s, "DISTANCE"), 15.0), "{}", var(&s, "DISTANCE"));
|
||||
let (x, y) = lastpoint(&s);
|
||||
assert!(close(x, 10.0) && close(y, 0.0), "{x}, {y}");
|
||||
let mut s = Session::new();
|
||||
lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(10.0 0.0) '(10.0 10.0) \"_U\" \"_T\")");
|
||||
assert!(close(var(&s, "DISTANCE"), 10.0), "{}", var(&s, "DISTANCE"));
|
||||
let (x, y) = lastpoint(&s);
|
||||
assert!(close(x, 10.0) && close(y, 10.0), "{x}, {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_distance_too_large_to_measure_is_refused_and_not_kept() {
|
||||
let mut s = Session::new();
|
||||
lisp(&mut s, "(command \"_.DIST\" '(0.5 0.25) '(0.53 0.29))");
|
||||
assert!(close(var(&s, "DISTANCE"), 0.05));
|
||||
// Finite points whose distance overflows: the JSON form, two picked points, Multiple points.
|
||||
assert!(s.execute("dist", &json!({ "p1": [-1e308, 0], "p2": [1e308, 0] })).is_err());
|
||||
s.cmdline("dist").unwrap();
|
||||
s.input(Input::Point(Vec2::new(-1e308, 0.0))).unwrap();
|
||||
s.input(Input::Point(Vec2::new(1e308, 0.0))).unwrap();
|
||||
assert!(s.log.last().is_some_and(|l| l.contains("the distance is too large")), "{:?}", s.log.last());
|
||||
// LASTPOINT stays at the first point, as after a measurement.
|
||||
assert_eq!(lastpoint(&s), (-1e308, 0.0));
|
||||
s.cancel();
|
||||
s.cmdline("dist").unwrap();
|
||||
s.input(Input::Point(Vec2::new(-1e308, 0.0))).unwrap();
|
||||
s.cmdline("m").unwrap();
|
||||
s.input(Input::Point(Vec2::new(1e308, 0.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(s.log.last().is_some_and(|l| l.contains("the distance is too large")), "{:?}", s.log.last());
|
||||
s.cancel();
|
||||
assert!(close(var(&s, "DISTANCE"), 0.05), "{}", var(&s, "DISTANCE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_variable_list_shows_the_sessions_values() {
|
||||
// A drawing's header can carry these names; GETVAR reads the session's, and so does the list.
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().header.set_f64("AREA", 99.0);
|
||||
// Every DXF header carries ORTHOMODE; the session's ortho is off.
|
||||
s.doc_mut().unwrap().header.set_i64("ORTHOMODE", 1);
|
||||
lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(10.0 0.0) '(10.0 10.0) \"\")");
|
||||
let list = s.execute("sysvars", &json!({})).unwrap();
|
||||
assert!(close(var(&s, "AREA"), 50.0));
|
||||
assert_eq!(list["AREA"].as_f64(), Some(var(&s, "AREA")), "{}", list["AREA"]);
|
||||
assert_eq!(list["ORTHOMODE"], sysvars::get(&s, "ORTHOMODE").unwrap());
|
||||
assert_eq!(list["ORTHOMODE"], 0);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! all said Z = 0, so a topographic drawing's levels couldn't be read in CADCraft.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point};
|
||||
use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point, PolyfaceMesh};
|
||||
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -56,6 +56,16 @@ fn id_reports_the_z_of_what_is_at_the_point() {
|
||||
assert!(s.log.iter().any(|l| l.contains("Z = 1883.5460")), "{:?}", s.log.iter().rev().take(3).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_reports_the_z_on_a_polyface_mesh() {
|
||||
let mut s = Session::new();
|
||||
// A sloped TIN as two triangles: z = 1000 + x / 10 over (0..100, 0..100).
|
||||
let vertices = vec![Vec3::new(0.0, 0.0, 1000.0), Vec3::new(100.0, 0.0, 1010.0), Vec3::new(100.0, 100.0, 1010.0), Vec3::new(0.0, 100.0, 1000.0)];
|
||||
add(&mut s, EntityKind::PolyfaceMesh(PolyfaceMesh { vertices, faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]] }));
|
||||
s.cmdline("zoom e").ok();
|
||||
assert!((z(&mut s, [50.0, 0.0]) - 1005.0).abs() < 1e-9, "on the mesh's edge");
|
||||
}
|
||||
|
||||
fn z_given(s: &mut Session) -> f64 {
|
||||
s.execute("id", &json!({ "at": [10, 10, 3] })).unwrap()["z"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ fn typed_layout_options_prompt_for_names() {
|
||||
|
||||
s.cmdline("LAYOUT").unwrap();
|
||||
let p = s.current_prompt().expect("LAYOUT prompts").display();
|
||||
assert_eq!(p, "Enter layout option or [Copy/Delete/New/Template/Rename/SAveas/Set/?] <set>:");
|
||||
assert_eq!(p, "Enter layout option [Copy/Delete/New/Template/Rename/SAveas/Set/?] <set>:");
|
||||
s.cmdline("n").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Enter name of new layout <Layout3>:");
|
||||
s.cmdline("Sheet9").unwrap();
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts, with
|
||||
//! DIMTAD 1, DIMGAP 0.625, DIMASZ 2.5, DIMSCALE 1 and DIMTXT 2.5 unless a case sets another, and
|
||||
//! the drawing read back afterwards).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Insert, Leader, MText};
|
||||
use serde_json::json;
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
|
||||
}
|
||||
|
||||
/// A drawing as the measured scripts start from: the dimension settings, and a text "XYZ" 2.5
|
||||
/// high at (100,40) that the Copy cases copy (`(entlast)` after it).
|
||||
fn session(setup: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
for (name, v) in [("DIMTAD", 1.0), ("DIMGAP", 0.625), ("DIMASZ", 2.5), ("DIMSCALE", 1.0), ("DIMTXT", 2.5)] {
|
||||
s.execute("setvar", &json!({ "name": name, "value": v })).unwrap();
|
||||
}
|
||||
s.execute("text", &json!({ "at": [100, 40], "text": "XYZ", "height": 2.5 })).unwrap();
|
||||
s.cmdline("(setq hl_T (entlast))").unwrap();
|
||||
if !setup.is_empty() {
|
||||
s.cmdline(setup).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn run(setup: &str, script: &str) -> Session {
|
||||
let mut s = session(setup);
|
||||
s.cmdline(script).unwrap();
|
||||
assert!(s.running.is_none(), "{script}: {}", s.prompt_text());
|
||||
s
|
||||
}
|
||||
|
||||
fn leaders(s: &Session) -> Vec<Leader> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Leader(l) = &e.kind { Some(l.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn mtexts(s: &Session) -> Vec<MText> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn inserts(s: &Session) -> Vec<Insert> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn vertices(l: &Leader) -> Vec<[f64; 2]> {
|
||||
l.vertices.iter().map(|v| [v.x, v.y]).collect()
|
||||
}
|
||||
|
||||
fn same_points(got: &[[f64; 2]], want: &[[f64; 2]]) -> bool {
|
||||
got.len() == want.len() && got.iter().zip(want).all(|(g, w)| near(g[0], w[0]) && near(g[1], w[1]))
|
||||
}
|
||||
|
||||
/// (case, setup, script, leader vertices, text insertion point, attachment, height, contents)
|
||||
type Case = (&'static str, &'static str, &'static str, &'static [[f64; 2]], [f64; 2], u8, f64, &'static str);
|
||||
|
||||
#[test]
|
||||
fn leaders_and_their_text_are_where_autocad_puts_them() {
|
||||
let cases: [Case; 17] = [
|
||||
// A horizontal last segment: no hook line; the text DIMGAP right of and above the end.
|
||||
(
|
||||
"000",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]],
|
||||
[40.625, 15.625],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
// More than 15° off horizontal: a hook line DIMASZ long into the end.
|
||||
(
|
||||
"001",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [32.5, 30.0], [35.0, 30.0]],
|
||||
[35.625, 30.625],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
(
|
||||
"002",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"AB\" \"CD\" \"\")",
|
||||
&[[0.0, 0.0], [17.5, 15.0], [20.0, 15.0]],
|
||||
[20.625, 15.625],
|
||||
7,
|
||||
2.5,
|
||||
"AB\\PCD",
|
||||
),
|
||||
// To the left: the text right-attached, DIMGAP left of the end.
|
||||
(
|
||||
"004",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(-20.0 15.0) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [-17.5, 15.0], [-20.0, 15.0]],
|
||||
[-20.625, 15.625],
|
||||
9,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
// A vertical last segment hooks to the right.
|
||||
(
|
||||
"007-VN",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(612345.678 2398765.432) '(612345.678 2398795.432) \"\" \"ABC\" \"\")",
|
||||
&[[612345.678, 2398765.432], [612343.178, 2398795.432], [612345.678, 2398795.432]],
|
||||
[612346.303, 2398796.057],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
// 14.9° and 15° keep the end; 15.1° hooks.
|
||||
(
|
||||
"100",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.32158853384029) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [40.0, 20.32158853384029]],
|
||||
[40.625, 20.94658853384029],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
(
|
||||
"101",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.358983848622454) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [40.0, 20.358983848622454]],
|
||||
[40.625, 20.983983848622454],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
(
|
||||
"102",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.39641415635812) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [37.5, 20.39641415635812], [40.0, 20.39641415635812]],
|
||||
[40.625, 21.02141415635812],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
// DIMTAD 0: centred on the end, left- or right-attached.
|
||||
(
|
||||
"020",
|
||||
"(setvar \"DIMTAD\" 0)",
|
||||
"(command \"_.LEADER\" '(0.5 0.25) '(0.52 0.265) '(0.54 0.265) \"\" \"ABC\" \"\")",
|
||||
&[[0.5, 0.25], [0.52, 0.265], [0.54, 0.265]],
|
||||
[1.165, 0.265],
|
||||
4,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
(
|
||||
"103-VN",
|
||||
"(setvar \"DIMTAD\" 0)",
|
||||
"(command \"_.LEADER\" '(612345.678 2398765.432) '(612325.678 2398780.432) '(612305.678 2398780.432) \"\" \"ABC\" \"\")",
|
||||
&[[612345.678, 2398765.432], [612325.678, 2398780.432], [612305.678, 2398780.432]],
|
||||
[612305.053, 2398780.432],
|
||||
6,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
// DIMGAP, DIMSCALE (hook, gap and height) and DIMTXT.
|
||||
(
|
||||
"021",
|
||||
"(setvar \"DIMGAP\" 2.0)",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]],
|
||||
[42.0, 17.0],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
(
|
||||
"022",
|
||||
"(setvar \"DIMSCALE\" 2.0)",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [30.0, 30.0], [35.0, 30.0]],
|
||||
[36.25, 31.25],
|
||||
7,
|
||||
5.0,
|
||||
"ABC",
|
||||
),
|
||||
(
|
||||
"024",
|
||||
"(setvar \"DIMTXT\" 5.0)",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"AB\" \"CD\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]],
|
||||
[40.625, 15.625],
|
||||
7,
|
||||
5.0,
|
||||
"AB\\PCD",
|
||||
),
|
||||
// A spline takes no hook line.
|
||||
(
|
||||
"026",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_S\" '(35.0 30.0) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [35.0, 30.0]],
|
||||
[35.625, 30.625],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
// Undo takes the last point back.
|
||||
(
|
||||
"014",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"_U\" '(40.0 15.0) \"\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]],
|
||||
[40.625, 15.625],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
// Annotation options: Mtext, at the command line, comes back to the first line.
|
||||
(
|
||||
"106",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_M\" \"ABC\" \"\")",
|
||||
&[[0.0, 0.0], [17.5, 15.0], [20.0, 15.0]],
|
||||
[20.625, 15.625],
|
||||
7,
|
||||
2.5,
|
||||
"ABC",
|
||||
),
|
||||
// Copy: centred on its line; with DIMTAD on the leader ends half the text height lower.
|
||||
(
|
||||
"016",
|
||||
"",
|
||||
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_C\" hl_T)",
|
||||
&[[0.0, 0.0], [17.5, 13.75], [20.0, 13.75]],
|
||||
[20.625, 15.625],
|
||||
4,
|
||||
2.5,
|
||||
"XYZ",
|
||||
),
|
||||
];
|
||||
for (case, setup, script, want_vertices, at, attach, height, contents) in cases {
|
||||
let s = run(setup, script);
|
||||
let ls = leaders(&s);
|
||||
assert_eq!(ls.len(), 1, "{case}");
|
||||
assert!(same_points(&vertices(&ls[0]), want_vertices), "{case}: {:?}", vertices(&ls[0]));
|
||||
let ms = mtexts(&s);
|
||||
assert_eq!(ms.len(), 1, "{case}");
|
||||
let m = &ms[0];
|
||||
assert!(near(m.insert.x, at[0]) && near(m.insert.y, at[1]), "{case}: {:?}", m.insert);
|
||||
assert_eq!((m.attach, m.contents.as_str()), (attach, contents), "{case}");
|
||||
assert!(near(m.height, height) && m.width == 0.0, "{case}: {} {}", m.height, m.width);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_sets_the_path_and_the_arrow() {
|
||||
// LEADER-011 Format None; LEADER-012 None then Arrow; LEADER-013 Spline then STraight.
|
||||
for (script, spline, arrow) in [
|
||||
("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_N\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, false),
|
||||
("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_N\" \"_F\" \"_A\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true),
|
||||
("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_S\" \"_F\" \"_ST\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true),
|
||||
("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true),
|
||||
] {
|
||||
let l = leaders(&run("", script)).remove(0);
|
||||
assert_eq!((l.spline, l.arrow), (spline, arrow), "{script}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_annotation_a_block_or_a_copied_multiline_text() {
|
||||
// LEADER-015: None leaves the leader alone, without a hook line.
|
||||
let s = run("", "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_N\")");
|
||||
assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0]]));
|
||||
assert!(mtexts(&s).is_empty());
|
||||
// LEADER-107: a block, scale 2 (Y as X), 30°, where it is placed; no hook line.
|
||||
let mut s = session("");
|
||||
s.execute("line", &json!({ "points": [[100, 0], [110, 0]] })).unwrap();
|
||||
s.cmdline("(command \"_.-BLOCK\" \"LB107\" '(100.0 0.0) (entlast) \"\")").unwrap();
|
||||
s.cmdline("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_B\" \"LB107\" '(50.0 20.0) 2 \"\" 30)").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0]]));
|
||||
let i = inserts(&s).remove(0);
|
||||
assert!(i.block == "LB107" && near(i.insert.x, 50.0) && near(i.insert.y, 20.0), "{i:?}");
|
||||
assert!(near(i.scale.x, 2.0) && near(i.scale.y, 2.0) && near(i.rotation, 30f64.to_radians()), "{i:?}");
|
||||
// LEADER-105: a copied multiline text keeps its width; LEADER-137 to the left.
|
||||
let mut s = session("");
|
||||
s.cmdline("(command \"_.MTEXT\" '(60.0 40.0) '(90.0 30.0) \"MT\" \"\")").unwrap();
|
||||
s.cmdline("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_C\" (entlast))").unwrap();
|
||||
let copy = mtexts(&s).into_iter().find(|m| near(m.insert.x, 20.625)).unwrap();
|
||||
assert!(near(copy.width, 30.0) && copy.attach == 4 && copy.contents == "MT", "{copy:?}");
|
||||
let s = run("", "(command \"_.LEADER\" '(0.0 0.0) '(-20.0 15.0) \"\" \"\" \"_C\" hl_T)");
|
||||
assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [-17.5, 13.75], [-20.0, 13.75]]));
|
||||
let m = mtexts(&s).into_iter().next().unwrap();
|
||||
assert!(near(m.insert.x, -20.625) && near(m.insert.y, 15.625) && m.attach == 6, "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_form_places_its_text_as_the_command_line_does() {
|
||||
let mut s = session("");
|
||||
let v = s.execute("leader", &json!({ "points": [[0, 0], [20, 15], [35, 30]], "text": "ABC" })).unwrap();
|
||||
assert_eq!(v["handles"].as_array().map(Vec::len), Some(2));
|
||||
assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0], [32.5, 30.0], [35.0, 30.0]]));
|
||||
let m = mtexts(&s).remove(0);
|
||||
assert!(near(m.insert.x, 35.625) && near(m.insert.y, 30.625) && m.attach == 7 && near(m.height, 2.5), "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompts_are_autocads_and_one_point_draws_nothing() {
|
||||
let mut s = session("");
|
||||
s.cmdline("leader").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Specify leader start point:"), "{}", s.prompt_text());
|
||||
s.cmdline("0,0").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Specify next point:"), "{}", s.prompt_text());
|
||||
s.cmdline("20,15").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Specify next point or [Annotation/Format/Undo] <Annotation>:"), "{}", s.prompt_text());
|
||||
s.cmdline("_F").unwrap();
|
||||
assert!(s.prompt_text().ends_with("[Spline/STraight/Arrow/None] <Exit>:"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Enter first line of annotation text or <options>:"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().ends_with("[Tolerance/Copy/Block/None/Mtext] <Mtext>:"), "{}", s.prompt_text());
|
||||
// LEADER-019: Tolerance, at the command line, comes back to the first line.
|
||||
s.cmdline("_T").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Enter first line of annotation text or <options>:"), "{}", s.prompt_text());
|
||||
// An answer that isn't an option is refused; `?` at the block name lists the blocks.
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("X").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword."));
|
||||
s.cmdline("_B").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Enter block name or [?]:"), "{}", s.prompt_text());
|
||||
s.cmdline("?").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Defined blocks: (none)"));
|
||||
s.cancel();
|
||||
// LEADER-139: one point and Enter.
|
||||
let s = run("", "(command \"_.LEADER\" '(0.0 0.0) \"\")");
|
||||
assert!(leaders(&s).is_empty() && mtexts(&s).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! U, UNDO and REDO inside a LISP routine, as AutoCAD 2024 runs them (accoreconsole: a line A, a
|
||||
//! line B, a circle C and a line D, then one routine, then the drawing's `entnext` order read back).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
|
||||
use cadcraft_engine::doc::Handle;
|
||||
use serde_json::json;
|
||||
|
||||
/// A session with A, B, C and D in draw order, each its own undo step, and their handles.
|
||||
fn drawing() -> (Session, Vec<Handle>) {
|
||||
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
|
||||
s.execute("circle", &json!({ "center": [5, 25], "radius": 3 })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap();
|
||||
let hs = handles(&s);
|
||||
(s, hs)
|
||||
}
|
||||
|
||||
fn handles(s: &Session) -> Vec<Handle> {
|
||||
s.doc().unwrap().model.iter().map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
/// The drawing's objects in draw order: A to D by their handles, E for any other.
|
||||
fn order(s: &Session, hs: &[Handle]) -> String {
|
||||
let name = |h: &Handle| hs.iter().position(|x| x == h).map_or('E', |i| (b'A' + i as u8) as char);
|
||||
handles(s).iter().map(|h| name(h).to_string()).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Run `parts` as ONE routine, `@A`…`@D` standing for the objects.
|
||||
fn routine(s: &mut Session, hs: &[Handle], parts: &[&str]) {
|
||||
let mut code = format!("(progn {})", parts.join(" "));
|
||||
for (i, h) in hs.iter().enumerate() {
|
||||
code = code.replace(&format!("@{}", (b'A' + i as u8) as char), &format!("(handent \"{}\")", h.hex()));
|
||||
}
|
||||
s.cmdline(&code).unwrap();
|
||||
assert!(s.running.is_none(), "{code}: {}", s.prompt_text());
|
||||
}
|
||||
|
||||
const EB: &str = "(command \"_.ERASE\" @B \"\")";
|
||||
const EC: &str = "(command \"_.ERASE\" @C \"\")";
|
||||
const U: &str = "(command \"_.U\")";
|
||||
const LINE: &str = "(command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")";
|
||||
const MAKE: &str = "(entmake '((0 . \"LINE\") (8 . \"0\") (10 0.0 60.0 0.0) (11 10.0 60.0 0.0)))";
|
||||
|
||||
#[test]
|
||||
fn u_in_a_routine_takes_back_the_routines_last_command() {
|
||||
// (case, the routine, the drawing's order afterwards)
|
||||
let cases: [(&str, &[&str], &str); 9] = [
|
||||
("U-001: ERASE, U", &[EB, U], "A B C D"),
|
||||
("U-002: two erases, U", &[EB, EC, U], "A C D"),
|
||||
("U-003: two erases, U, U", &[EB, EC, U, U], "A B C D"),
|
||||
("U-005: entdel, ERASE, U", &["(entdel @B)", EC, U], "A C D"),
|
||||
("U-006: ERASE, entdel, U", &[EB, "(entdel @C)", U], "A B C D"),
|
||||
("U-007: LINE, U, REDO", &[LINE, U, "(command \"_.REDO\")"], "A B C D E"),
|
||||
("U-008: two erases, UNDO 2", &[EB, EC, "(command \"_.UNDO\" \"2\")"], "A B C D"),
|
||||
("U-012: ERASE, entmake, U", &[EB, MAKE, U], "A B C D"),
|
||||
// OOPS-011 had an OOPS after the U, which brought B back: B was still erased.
|
||||
("OOPS-011: ERASE, LINE, U", &[EB, LINE, U], "A C D"),
|
||||
];
|
||||
for (case, parts, want) in cases {
|
||||
let (mut s, hs) = drawing();
|
||||
routine(&mut s, &hs, parts);
|
||||
assert_eq!(order(&s, &hs), want, "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_routine_is_still_one_undo_step_after_it() {
|
||||
let (mut s, hs) = drawing();
|
||||
let depth = s.state().unwrap().undo.len();
|
||||
routine(&mut s, &hs, &[EB, EC, LINE, U, "(entdel @A)"]);
|
||||
assert_eq!(order(&s, &hs), "D");
|
||||
assert_eq!(s.state().unwrap().undo.len(), depth + 1);
|
||||
s.cmdline("U").unwrap();
|
||||
assert_eq!(order(&s, &hs), "A B C D");
|
||||
s.cmdline("REDO").unwrap();
|
||||
assert_eq!(order(&s, &hs), "D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u_with_nothing_of_the_routine_left_takes_back_the_step_before_it() {
|
||||
let (mut s, hs) = drawing();
|
||||
let depth = s.state().unwrap().undo.len();
|
||||
routine(&mut s, &hs, &[EB, U, U]);
|
||||
// The second U takes back D's line, drawn before the routine.
|
||||
assert_eq!(order(&s, &hs), "A B C");
|
||||
assert_eq!(s.state().unwrap().undo.len(), depth - 1, "the routine changed nothing more of its own");
|
||||
// A command after that makes the routine's step, from where the U took the drawing.
|
||||
routine(&mut s, &hs, &[EB, U, U, "(command \"_.ERASE\" @A \"\")"]);
|
||||
assert_eq!(order(&s, &hs), "B");
|
||||
s.cmdline("U").unwrap();
|
||||
assert_eq!(order(&s, &hs), "A B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_off_still_refuses_inside_a_routine() {
|
||||
let (mut s, hs) = drawing();
|
||||
routine(&mut s, &hs, &["(command \"_.UNDO\" \"_C\" \"_N\")", EB, U]);
|
||||
assert_eq!(order(&s, &hs), "A C D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redo_inside_a_routine_brings_back_the_last_u_first() {
|
||||
// U past the routine's own command, then REDO: the drawing's step comes back, not the erase.
|
||||
let (mut s, hs) = drawing();
|
||||
let depth = s.state().unwrap().undo.len();
|
||||
routine(&mut s, &hs, &[EB, U, U, "(command \"_.REDO\")"]);
|
||||
assert_eq!(order(&s, &hs), "A B C D");
|
||||
assert_eq!(s.state().unwrap().undo.len(), depth, "the routine left nothing of its own to undo");
|
||||
// A command inside the routine ends what REDO could bring back from before it.
|
||||
let (mut s, hs) = drawing();
|
||||
s.cmdline("U").unwrap();
|
||||
assert_eq!(order(&s, &hs), "A B C");
|
||||
routine(&mut s, &hs, &[EB, "(command \"_.REDO\")"]);
|
||||
assert_eq!(order(&s, &hs), "A C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_without_prompts_is_a_step_of_the_routine_too() {
|
||||
// HATCHTOBACK runs as its JSON form from a routine; U takes it back, not the hatch.
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
let rect = handles(&s)[0];
|
||||
s.execute("hatch", &json!({ "handles": [rect.hex()], "pattern": "SOLID" })).unwrap();
|
||||
let both = handles(&s);
|
||||
assert_eq!(both.len(), 2);
|
||||
s.cmdline("(progn (command \"_.HATCHTOBACK\") (command \"_.U\"))").unwrap();
|
||||
assert_eq!(handles(&s), both);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_control_one_keeps_only_the_routines_last_command() {
|
||||
// As UNDO Control One keeps only the drawing's last step: the second U goes on to the step
|
||||
// before the routine (D's line), the drawing as it was then.
|
||||
let (mut s, hs) = drawing();
|
||||
routine(&mut s, &hs, &["(command \"_.UNDO\" \"_C\" \"_O\")", EB, EC, U, U]);
|
||||
assert_eq!(order(&s, &hs), "A B C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_routine_keeps_as_many_steps_as_the_drawing() {
|
||||
use cadcraft_engine::undoctl::MAX_UNDO;
|
||||
let (mut s, hs) = drawing();
|
||||
// Two lines more than the routine keeps steps for, then a U for each kept step and one more,
|
||||
// which goes on to the drawing's step before the routine (D's line): the drawing as it was
|
||||
// then, without the two lines whose steps went.
|
||||
let code =
|
||||
format!("(progn (repeat {} (command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")) (repeat {} (command \"_.U\")))", MAX_UNDO + 2, MAX_UNDO + 1);
|
||||
s.cmdline(&code).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(order(&s, &hs), "A B C");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod annotative;
|
||||
mod arc_ctrl_direction;
|
||||
mod array_commands;
|
||||
mod assoc_array;
|
||||
mod attdef_command;
|
||||
mod attdisp_attsync;
|
||||
mod attedit_prompts;
|
||||
mod attrib_modes;
|
||||
@@ -59,6 +60,7 @@ mod hatch_numeric_validation;
|
||||
mod hatch_options;
|
||||
mod helix;
|
||||
mod inquiry_keywords;
|
||||
mod inquiry_variables;
|
||||
mod inquiry_z;
|
||||
mod insert_attribute_prompts;
|
||||
mod insert_prompts;
|
||||
@@ -69,8 +71,10 @@ mod layer_cmdline;
|
||||
mod layer_merge_delete;
|
||||
mod layoff_repeat;
|
||||
mod layout_prompts;
|
||||
mod leader_command;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod lisp_corpus;
|
||||
mod lisp_undo_in_routine;
|
||||
mod malformed_handles;
|
||||
mod matchprop;
|
||||
mod measure_distance_snapping;
|
||||
@@ -90,6 +94,7 @@ mod non_finite_edits;
|
||||
mod offset_closed_spline;
|
||||
mod offset_ellipse_side;
|
||||
mod offset_move;
|
||||
mod oops_command;
|
||||
mod options_profile;
|
||||
mod overkill;
|
||||
mod parallel_display_list;
|
||||
@@ -100,8 +105,10 @@ mod pickdrag;
|
||||
mod pline_arc_xline_options;
|
||||
mod plot_styles;
|
||||
mod point_modifiers;
|
||||
mod polyface_explode;
|
||||
mod post_command_passes;
|
||||
mod prompt_keywords;
|
||||
mod prompt_or_once;
|
||||
mod props_bad_lineweight;
|
||||
mod props_insert_scale_attribs;
|
||||
mod purge_mlinestyles;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! OOPS, as AutoCAD 2024 runs it (accoreconsole: a line A, a line B, a circle C and a line D, these
|
||||
//! scripts, then the drawing's `entnext` order read back).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
|
||||
use cadcraft_engine::doc::{Common, EntityKind, Handle, Line, Space};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A session that `entmake` works in.
|
||||
fn session() -> Session {
|
||||
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
|
||||
Session::new()
|
||||
}
|
||||
|
||||
/// A session with A, B, C and D in draw order, and their handles.
|
||||
fn drawing() -> (Session, Vec<Handle>) {
|
||||
let mut s = session();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
|
||||
s.execute("circle", &json!({ "center": [5, 25], "radius": 3 })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap();
|
||||
let hs = handles(&s);
|
||||
(s, hs)
|
||||
}
|
||||
|
||||
fn handles(s: &Session) -> Vec<Handle> {
|
||||
s.doc().unwrap().model.iter().map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
/// The drawing's objects in draw order: A to D by their handles, E for any other.
|
||||
fn order(s: &Session, hs: &[Handle]) -> String {
|
||||
let name = |h: &Handle| hs.iter().position(|x| x == h).map_or('E', |i| (b'A' + i as u8) as char);
|
||||
handles(s).iter().map(|h| name(h).to_string()).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Run `script` with `@A`…`@D` standing for the objects.
|
||||
fn run(s: &mut Session, hs: &[Handle], script: &[&str]) {
|
||||
for line in script {
|
||||
let mut line = line.to_string();
|
||||
for (i, h) in hs.iter().enumerate() {
|
||||
line = line.replace(&format!("@{}", (b'A' + i as u8) as char), &format!("(handent \"{}\")", h.hex()));
|
||||
}
|
||||
s.cmdline(&line).unwrap();
|
||||
assert!(s.running.is_none(), "{line}: {}", s.prompt_text());
|
||||
}
|
||||
}
|
||||
|
||||
fn oops(s: &mut Session) -> Value {
|
||||
s.execute("oops", &json!({})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_erase_comes_back_where_it_was() {
|
||||
const LINE: &str = "(command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")";
|
||||
const OOPS: &str = "(command \"_.OOPS\")";
|
||||
// (case, script, the drawing's order afterwards: E is the line drawn after the erase)
|
||||
let cases: [(&str, &[&str], &str); 14] = [
|
||||
("OOPS-000: nothing erased yet", &[OOPS], "A B C D"),
|
||||
("OOPS-001: two in the middle", &["(command \"_.ERASE\" @B @C \"\")", OOPS], "A B C D"),
|
||||
("OOPS-002: picked in reverse", &["(command \"_.ERASE\" @C @B \"\")", OOPS], "A B C D"),
|
||||
("OOPS-003: a new object after the erase", &["(command \"_.ERASE\" @B \"\")", LINE, OOPS], "A B C D E"),
|
||||
("OOPS-004: the bottom object", &["(command \"_.ERASE\" @A \"\")", LINE, OOPS], "A B C D E"),
|
||||
("OOPS-005: twice", &["(command \"_.ERASE\" @B \"\")", OOPS, OOPS], "A B C D"),
|
||||
("OOPS-006: after U", &["(command \"_.ERASE\" @B \"\")", "U", OOPS], "A B C D"),
|
||||
("OOPS-007: the last erase only", &["(command \"_.ERASE\" @B \"\")", "(command \"_.ERASE\" @C \"\")", OOPS], "A C D"),
|
||||
("OOPS-008: U after OOPS", &["(command \"_.ERASE\" @B \"\")", OOPS, "U"], "A C D"),
|
||||
("OOPS-009: entdel", &["(entdel @B)", OOPS], "A C D"),
|
||||
("OOPS-010: -BLOCK", &["(command \"_.-BLOCK\" \"BKO\" '(0.0 0.0) @A @C \"\")", OOPS], "A B C D"),
|
||||
("OOPS-011: U of another command", &["(command \"_.ERASE\" @B \"\")", LINE, "U", OOPS], "A B C D"),
|
||||
("OOPS-012: the object under it erased since", &["(command \"_.ERASE\" @B \"\")", "(entdel @A)", OOPS], "B C D"),
|
||||
("OOPS-013: everything", &["(command \"_.ERASE\" \"_ALL\" \"\")", OOPS], "A B C D"),
|
||||
];
|
||||
for (case, script, want) in cases {
|
||||
let (mut s, hs) = drawing();
|
||||
run(&mut s, &hs, script);
|
||||
assert_eq!(order(&s, &hs), want, "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oops_says_nothing_and_counts_what_came_back() {
|
||||
let (mut s, hs) = drawing();
|
||||
s.cmdline("oops").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS"));
|
||||
s.execute("erase", &json!({ "handles": [hs[1].hex(), hs[3].hex()] })).unwrap();
|
||||
s.cmdline("oops").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS"));
|
||||
assert_eq!(order(&s, &hs), "A B C D");
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
|
||||
s.execute("u", &json!({})).unwrap();
|
||||
assert_eq!(order(&s, &hs), "A C");
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 2 }));
|
||||
// They come back as they were.
|
||||
let d = s.doc().unwrap();
|
||||
assert!(matches!(d.entity(hs[3]).map(|e| &e.kind), Some(EntityKind::Line(l)) if l.a.y == 40.0 && l.b.x == 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_delete_key_and_block_feed_oops_too() {
|
||||
// Edit > Clear, the Delete key.
|
||||
let (mut s, hs) = drawing();
|
||||
s.set_selection(vec![hs[2]]);
|
||||
s.execute("erase.selection", &json!({})).unwrap();
|
||||
assert_eq!(order(&s, &hs), "A B D");
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
|
||||
assert_eq!(order(&s, &hs), "A B C D");
|
||||
// BLOCK deleting its objects.
|
||||
let (mut s, hs) = drawing();
|
||||
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[3].hex()], "keep": "delete" })).unwrap();
|
||||
assert_eq!(order(&s, &hs), "B C");
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 2 }));
|
||||
assert_eq!(order(&s, &hs), "A B C D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_an_undo_took_away_stays_away() {
|
||||
// U past the line's creation: its handle is free again.
|
||||
let mut s = session();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
let x = handles(&s)[0];
|
||||
s.execute("erase", &json!({ "handles": [x.hex()] })).unwrap();
|
||||
s.execute("u", &json!({})).unwrap();
|
||||
s.execute("u", &json!({})).unwrap();
|
||||
assert!(handles(&s).is_empty());
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
|
||||
assert!(handles(&s).is_empty());
|
||||
// A new object takes the handle.
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap();
|
||||
assert_eq!(handles(&s), [x]);
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
|
||||
assert!(matches!(s.doc().unwrap().entity(x).map(|e| &e.kind), Some(EntityKind::Circle(_))));
|
||||
// A block's object takes the handle: one erased object of two, one of a block's two.
|
||||
for (erased, in_block) in [(2, 1), (1, 2)] {
|
||||
let mut s = session();
|
||||
for y in 0..erased {
|
||||
s.execute("line", &json!({ "points": [[0, y], [10, y]] })).unwrap();
|
||||
}
|
||||
let hs = handles(&s);
|
||||
s.execute("erase", &json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() })).unwrap();
|
||||
for _ in 0..=erased {
|
||||
s.execute("u", &json!({})).unwrap();
|
||||
}
|
||||
assert!(handles(&s).is_empty());
|
||||
s.cmdline("(entmake '((0 . \"BLOCK\") (2 . \"BX\") (10 0.0 0.0 0.0) (70 . 0)))").unwrap();
|
||||
for _ in 0..in_block {
|
||||
s.cmdline("(entmake '((0 . \"LINE\") (8 . \"0\") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0)))").unwrap();
|
||||
}
|
||||
s.cmdline("(entmake '((0 . \"ENDBLK\")))").unwrap();
|
||||
assert!(s.doc().unwrap().block("BX").is_some_and(|b| b.entities.contains(hs[0])), "{erased} {in_block}");
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 0 }), "{erased} {in_block}");
|
||||
assert!(handles(&s).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_whose_layer_or_block_is_gone_stays_away() {
|
||||
// Its layer purged.
|
||||
let (mut s, hs) = drawing();
|
||||
run(&mut s, &hs, &["(command \"_.-LAYER\" \"_M\" \"L1\" \"\")"]);
|
||||
s.execute("line", &json!({ "points": [[0, 60], [10, 60]] })).unwrap();
|
||||
let on_l1 = *handles(&s).last().unwrap();
|
||||
run(&mut s, &hs, &["(setvar \"CLAYER\" \"0\")"]);
|
||||
s.execute("erase", &json!({ "handles": [on_l1.hex()] })).unwrap();
|
||||
s.execute("purge", &json!({ "type": "layers", "names": "L1" })).unwrap();
|
||||
assert!(s.doc().unwrap().layer("L1").is_none());
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
|
||||
// Its block purged.
|
||||
let (mut s, hs) = drawing();
|
||||
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap();
|
||||
s.execute("insert", &json!({ "name": "BK", "at": [50, 0] })).unwrap();
|
||||
let ins = *handles(&s).last().unwrap();
|
||||
s.execute("erase", &json!({ "handles": [ins.hex()] })).unwrap();
|
||||
s.execute("purge", &json!({ "type": "blocks", "names": "BK" })).unwrap();
|
||||
assert!(s.doc().unwrap().block("BK").is_none());
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
|
||||
assert_eq!(order(&s, &hs), "A B C D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_block_editor_has_an_oops_of_its_own() {
|
||||
let (mut s, hs) = drawing();
|
||||
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap();
|
||||
s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "BK" })).unwrap();
|
||||
// The drawing's erased line doesn't come back into the block.
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
// Back in the drawing, it does.
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
|
||||
assert_eq!(order(&s, &hs), "A B C D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layouts_objects_come_back_to_it() {
|
||||
let mut s = session();
|
||||
let paper = Space::Paper("Layout1".into());
|
||||
let line = |y: f64| EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(10.0, y, 0.0) });
|
||||
let hs: Vec<Handle> = (0..3).map(|y| s.doc_mut().unwrap().add(&paper, Common::default(), line(f64::from(y))).unwrap()).collect();
|
||||
s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap();
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.model.is_empty());
|
||||
assert_eq!(d.space(&paper).unwrap().handles(), hs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_edited_in_place_keeps_its_objects_and_the_drawings_apart() {
|
||||
let (mut s, hs) = drawing();
|
||||
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[1].hex()], "keep": "convert" })).unwrap();
|
||||
let ins = *handles(&s).last().unwrap();
|
||||
s.execute("erase", &json!({ "handles": [hs[3].hex()] })).unwrap();
|
||||
let r = s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
|
||||
// The drawing's line stays out while the reference is edited...
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
|
||||
// ...and a working copy comes back.
|
||||
let copy = r["working"][0].as_str().unwrap().to_string();
|
||||
s.execute("erase", &json!({ "handles": [copy] })).unwrap();
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
|
||||
// Once the edit is over, its copies don't come back into the drawing.
|
||||
s.execute("erase", &json!({ "handles": [copy] })).unwrap();
|
||||
s.execute("refclose", &json!({ "save": true })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().block("BK").unwrap().entities.len(), 1);
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
|
||||
assert_eq!(order(&s, &hs), "C E");
|
||||
// The drawing's own erased line does, after an edit that ends.
|
||||
s.execute("erase", &json!({ "handles": [hs[2].hex()] })).unwrap();
|
||||
s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
|
||||
s.execute("refclose", &json!({ "save": false })).unwrap();
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
|
||||
assert_eq!(order(&s, &hs), "C E");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_refused_during_refedit_leaves_oops_as_it_was() {
|
||||
let (mut s, hs) = drawing();
|
||||
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "convert" })).unwrap();
|
||||
let ins = *handles(&s).last().unwrap();
|
||||
s.execute("erase", &json!({ "handles": [hs[3].hex()] })).unwrap();
|
||||
s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
|
||||
// B isn't in the working set: the block is refused and the drawing put back.
|
||||
let r = s.execute("block", &json!({ "name": "BK2", "base": [0, 0], "handles": [hs[1].hex()], "keep": "delete" }));
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
s.execute("refclose", &json!({ "save": false })).unwrap();
|
||||
// OOPS still has D, the last erase that stood.
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
|
||||
assert!(s.doc().unwrap().entity(hs[3]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_erase_in_the_block_editor_comes_back_there() {
|
||||
let (mut s, hs) = drawing();
|
||||
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[1].hex()], "keep": "retain" })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "BK" })).unwrap();
|
||||
let inside = handles(&s);
|
||||
assert_eq!(inside.len(), 2);
|
||||
s.execute("erase", &json!({ "handles": [inside[0].hex()] })).unwrap();
|
||||
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
|
||||
assert_eq!(handles(&s), inside);
|
||||
}
|
||||
@@ -66,6 +66,48 @@ fn duplicates_tolerance_and_ignored_properties() {
|
||||
assert!(r["message"].as_str().unwrap().contains("duplicate(s) deleted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_within_the_tolerance_are_duplicates_on_either_side_of_a_rounding_step() {
|
||||
// Each pair is closer than the tolerance, but its numbers round to different multiples of it
|
||||
// (50.004 → 5000, 50.006 → 5001 at 0.01).
|
||||
let pairs = [
|
||||
(json!({ "tolerance": 0.01 }), "circle", json!({ "center": [50.004, 50], "radius": 3 }), json!({ "center": [50.006, 50], "radius": 3 })),
|
||||
(json!({ "tolerance": 0.01 }), "circle", json!({ "center": [50, 50], "radius": 3.004 }), json!({ "center": [50, 50], "radius": 3.006 })),
|
||||
(json!({}), "circle", json!({ "center": [0.0000014, 0], "radius": 3 }), json!({ "center": [0.0000016, 0], "radius": 3 })),
|
||||
(json!({ "tolerance": 0.01 }), "point", json!({ "at": [10.004, 0] }), json!({ "at": [10.006, 0] })),
|
||||
(
|
||||
json!({ "tolerance": 0.01 }),
|
||||
"text",
|
||||
json!({ "at": [0.004, 0], "text": "A", "height": 2.5 }),
|
||||
json!({ "at": [0.006, 0], "text": "A", "height": 2.5 }),
|
||||
),
|
||||
];
|
||||
for (opts, cmd, a, b) in pairs {
|
||||
let mut s = Session::new();
|
||||
s.execute(cmd, &a).unwrap();
|
||||
s.execute(cmd, &b).unwrap();
|
||||
let r = ok(&mut s, opts);
|
||||
assert_eq!(r["deleted"], 1, "{cmd} {a} {b}: {r}");
|
||||
assert_eq!(count(&s), 1, "{cmd} {a} {b}");
|
||||
}
|
||||
// Arcs on (about) one circle are combined the same way.
|
||||
let mut s = Session::new();
|
||||
s.execute("arc", &json!({ "center": [0.004, 0], "radius": 5, "start": 0, "end": 90 })).unwrap();
|
||||
s.execute("arc", &json!({ "center": [0.006, 0], "radius": 5, "start": 45, "end": 180 })).unwrap();
|
||||
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
|
||||
assert_eq!(r["overlapping"], 1, "{r}");
|
||||
assert_eq!(count(&s), 1);
|
||||
// Farther apart than the tolerance, they stay.
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [50.004, 50], "radius": 3 })).unwrap();
|
||||
s.execute("circle", &json!({ "center": [50.016, 50], "radius": 3 })).unwrap();
|
||||
s.execute("point", &json!({ "at": [10.004, 0] })).unwrap();
|
||||
s.execute("point", &json!({ "at": [10.016, 0] })).unwrap();
|
||||
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
|
||||
assert_eq!(r["deleted"], 0, "{r}");
|
||||
assert_eq!(count(&s), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collinear_lines_overlap_and_end_to_end() {
|
||||
let mut s = Session::new();
|
||||
@@ -230,3 +272,47 @@ fn many_objects_stay_fast() {
|
||||
assert_eq!(count(&s), 4001);
|
||||
assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn within_the_tolerance_does_not_chain() {
|
||||
// At 0.01, the arc is within the tolerance of both circles, but the circles are 0.016 apart:
|
||||
// the arc goes, both circles stay, whichever comes first in draw order.
|
||||
let circle = |s: &mut Session, x: f64| s.execute("circle", &json!({ "center": [x, 0], "radius": 3 })).unwrap();
|
||||
let arc_at = |s: &mut Session, x: f64, start: f64, end: f64| {
|
||||
s.execute("arc", &json!({ "center": [x, 0], "radius": 3, "start": start, "end": end })).unwrap()
|
||||
};
|
||||
let kinds = |s: &Session| s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>();
|
||||
let mut s = Session::new();
|
||||
circle(&mut s, 50.0);
|
||||
arc_at(&mut s, 50.008, 0.0, 90.0);
|
||||
circle(&mut s, 50.016);
|
||||
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
|
||||
assert_eq!((r["duplicates"].as_u64(), r["overlapping"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
assert_eq!(kinds(&s), ["Circle", "Circle"]);
|
||||
let mut s = Session::new();
|
||||
arc_at(&mut s, 50.008, 0.0, 90.0);
|
||||
circle(&mut s, 50.0);
|
||||
circle(&mut s, 50.016);
|
||||
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
|
||||
assert_eq!(r["deleted"], 1, "{r}");
|
||||
assert_eq!(kinds(&s), ["Circle", "Circle"]);
|
||||
// Arcs alone: the third is 0.016 from the first, so it is not combined with it.
|
||||
let mut s = Session::new();
|
||||
arc_at(&mut s, 0.0, 0.0, 90.0);
|
||||
arc_at(&mut s, 0.008, 45.0, 180.0);
|
||||
arc_at(&mut s, 0.016, 100.0, 200.0);
|
||||
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
|
||||
assert_eq!(r["overlapping"], 1, "{r}");
|
||||
assert_eq!(kinds(&s), ["Arc", "Arc"]);
|
||||
// A followed line displaces the first one, which a third line matched: the third is 0.016 from
|
||||
// the followed line, so it stays.
|
||||
let mut s = Session::new();
|
||||
let first = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let third = line(&mut s, [0.0, 0.008], [10.0, 0.008]);
|
||||
let followed = line(&mut s, [0.0, -0.008], [10.0, -0.008]);
|
||||
s.execute("dimlinear", &json!({ "object": followed.hex(), "at": [5, 5] })).unwrap();
|
||||
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
|
||||
assert_eq!(r["deleted"], 1, "{r}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.entity(first).is_none() && d.entity(third).is_some() && d.entity(followed).is_some());
|
||||
}
|
||||
|
||||
@@ -104,3 +104,30 @@ fn stylesmanager_creates_edits_saves_and_loads_tables() {
|
||||
// Undo takes a table edit back.
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #1928: saving a plot style table to a file goes through the atomic writer: a symlink
|
||||
/// planted at the old fixed temporary name is left alone, and the file keeps its permissions.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn plot_style_save_is_atomic_and_keeps_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-pstyle-atomic-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("office.stb.json");
|
||||
std::fs::write(&path, "old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let victim = dir.join("victim");
|
||||
std::fs::write(&victim, "keep").unwrap();
|
||||
std::os::unix::fs::symlink(&victim, dir.join("office.stb.json.cadcraft-tmp")).unwrap();
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("stylesmanager", &json!({"action": "new", "name": "office.stb"})).unwrap();
|
||||
let p = path.to_string_lossy().to_string();
|
||||
s.execute("stylesmanager", &json!({"action": "save", "name": "office.stb", "path": p})).unwrap();
|
||||
|
||||
assert_ne!(std::fs::read_to_string(&path).unwrap(), "old");
|
||||
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! EXPLODE turns a polyface mesh into its 3D faces, as AutoCAD does; hidden edges stay hidden.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Common, EntityKind, PolyfaceMesh, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn exploding_a_polyface_mesh_gives_its_3d_faces() {
|
||||
let mut s = Session::new();
|
||||
let mesh = PolyfaceMesh {
|
||||
vertices: vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 2.0), Vec3::new(0.0, 10.0, 2.0)],
|
||||
faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]],
|
||||
};
|
||||
let h = s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::PolyfaceMesh(mesh)).unwrap();
|
||||
s.execute("explode", &json!({"handles": [h.hex()]})).unwrap();
|
||||
let faces: Vec<_> = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Face3d(f) => Some(f.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(faces.len(), 2);
|
||||
assert!(s.doc().unwrap().model.iter().all(|e| !matches!(e.kind, EntityKind::PolyfaceMesh(_))));
|
||||
// The diagonal starts at the third corner of the first triangle: edge 3 (and the closing 4th) hidden.
|
||||
assert_eq!(faces[0].corners[2], Vec3::new(10.0, 10.0, 2.0));
|
||||
assert_eq!(faces[0].hidden_edges, 4 | 8);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Prompts that offer options say "or" once, as AutoCAD 2024's do: "Enter angle of xline (0) or
|
||||
//! [Reference]:". The prompt adds "or" before its options, so a message that ended in "or" read
|
||||
//! "or or".
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn shows(s: &Session, text: &str) {
|
||||
let p = s.prompt_text();
|
||||
assert!(p.ends_with(text), "{p}");
|
||||
assert!(!p.contains(" or or "), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xline_angle_and_offset() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("xline").unwrap();
|
||||
s.cmdline("a").unwrap();
|
||||
shows(&s, "Enter angle of xline (0) or [Reference]:");
|
||||
s.cancel();
|
||||
s.cmdline("xline").unwrap();
|
||||
s.cmdline("o").unwrap();
|
||||
shows(&s, "Specify offset distance or [Through] <1.0000>:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helix_radii_and_height() {
|
||||
let mut s = Session::new();
|
||||
s.script("helix\n0,0\n").unwrap();
|
||||
shows(&s, "Specify base radius or [Diameter] <1.0000>:");
|
||||
s.cmdline("5").unwrap();
|
||||
shows(&s, "Specify top radius or [Diameter] <5.0000>:");
|
||||
s.cmdline("").unwrap();
|
||||
shows(&s, "Specify helix height or [Axis endpoint/Turns/turn Height/tWist] <1.0000>:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_first_object() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("blend").unwrap();
|
||||
shows(&s, "Select first object or [CONtinuity]:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengthen_options() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("lengthen").unwrap();
|
||||
shows(&s, "Select an object to measure or [DElta/Percent/Total/DYnamic] <DYnamic>:");
|
||||
s.cmdline("de").unwrap();
|
||||
shows(&s, "Enter delta length or [Angle] <0.0000>:");
|
||||
s.cmdline("2").unwrap();
|
||||
shows(&s, "Select an object to change or [Undo]:");
|
||||
s.cancel();
|
||||
s.cmdline("lengthen").unwrap();
|
||||
s.cmdline("t").unwrap();
|
||||
shows(&s, "Specify total length or [Angle] <1.0000>:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncopy_pedit_and_table_break() {
|
||||
let mut s = Session::new();
|
||||
s.script("line 25,-5 25,5\n\nline 0,0 10,0\n\n-block B 0,0 L\n\n").unwrap();
|
||||
s.execute("insert", &json!({ "name": "B", "at": [20, 0] })).unwrap();
|
||||
s.cmdline("zoom e").unwrap();
|
||||
s.cmdline("ncopy").unwrap();
|
||||
shows(&s, "Select nested objects to copy or [Settings]:");
|
||||
s.script("22,0\n\n").unwrap();
|
||||
shows(&s, "Specify base point or [Displacement/Multiple] <Displacement>:");
|
||||
s.cancel();
|
||||
s.cmdline("pedit").unwrap();
|
||||
shows(&s, "Select polyline or [Multiple]:");
|
||||
s.cancel();
|
||||
s.execute("table", &json!({ "at": [0, 20], "cells": [["1", ""], ["2", ""]], "rowHeight": 1.0, "colWidth": 3.0 })).unwrap();
|
||||
s.cmdline("table.break").unwrap();
|
||||
s.cmdline("1,19.5").unwrap();
|
||||
shows(&s, "Enter maximum part height or [Off]:");
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! has no thickness while a polyline's keeps it.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point};
|
||||
use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point, PolyfaceMesh};
|
||||
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -182,3 +182,14 @@ fn offset_keeps_a_polylines_elevation_and_thickness() {
|
||||
let first = k.vertices.first().unwrap().p;
|
||||
assert!(close(first.x, 0.0) && close(first.y, 3.0), "{first:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_scales_a_polyface_meshs_vertex_heights() {
|
||||
let mut s = Session::new();
|
||||
let vertices = vec![Vec3::new(0.0, 0.0, 2.0), Vec3::new(10.0, 0.0, 2.0), Vec3::new(0.0, 10.0, 6.0)];
|
||||
let m = add(&mut s, EntityKind::PolyfaceMesh(PolyfaceMesh { vertices, faces: vec![[1, 2, 3, 0]] }), 0.0);
|
||||
scale(&mut s, m, "0.0 0.0");
|
||||
let EntityKind::PolyfaceMesh(k) = get(&s, m).0 else { panic!() };
|
||||
let want = [Vec3::new(0.0, 0.0, 3.4), Vec3::new(17.0, 0.0, 3.4), Vec3::new(0.0, 17.0, 10.2)];
|
||||
assert!(k.vertices.iter().zip(want).all(|(a, b)| near3(*a, b)), "{:?}", k.vertices);
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ fn typed_vplayer_changes_layers_in_chosen_viewports() {
|
||||
|
||||
// Freeze in the current viewport (the default inside a viewport).
|
||||
s.cmdline("VPLAYER").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt]:");
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Enter an option [?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt]:");
|
||||
s.script("F\n0\n").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [All/Select/Current/Except current] <Current>:");
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Enter an option [All/Select/Current/Except current] <Current>:");
|
||||
s.script("\n").unwrap();
|
||||
assert_eq!(frozen(&s, &a), ["0"]);
|
||||
assert!(frozen(&s, &b).is_empty());
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//! `layer.new` refuses control characters in a layer name (the DXF writer would turn them into
|
||||
//! spaces and the name would change on reopen).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn layer_new_rejects_control_characters() {
|
||||
let mut s = Session::new();
|
||||
for name in ["A\nB", "A\tB", "A\u{0}B"] {
|
||||
assert!(s.execute("layer.new", &json!({"name": name})).is_err(), "{name:?} was accepted");
|
||||
}
|
||||
assert!(s.execute("layer.new", &json!({"name": "A B"})).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Text styles with Linux fonts AutoCAD on Windows doesn't have: a save names them, and SAVEAS
|
||||
//! with portableFonts writes Windows fonts in that file while the drawing keeps its own.
|
||||
|
||||
use cadcraft_doc::{Drawing, TextStyle};
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use serde_json::json;
|
||||
|
||||
fn dxf(d: &Drawing, _: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(cadcraft_io::write_dxf(d).into_bytes())
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: dxf, plot: None });
|
||||
let mut d = Drawing::new_metric();
|
||||
for (name, font) in [("Notes", "DejaVuSans.ttf"), ("Plain", "arial.ttf"), ("Tight", "Liberation Sans Narrow")] {
|
||||
d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() });
|
||||
}
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "fonts.dxf", None);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_fonts_writes_windows_fonts_in_that_file() {
|
||||
let mut s = session();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-portable-fonts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
let file = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap();
|
||||
let both = json!([{ "style": "Notes", "font": "DejaVuSans.ttf" }, { "style": "Tight", "font": "Liberation Sans Narrow" }]);
|
||||
|
||||
// portableFonts writes Verdana for DejaVu Sans; the narrow style has no Windows font.
|
||||
let r = s.execute("saveas", &json!({ "path": path("portable.dxf"), "portableFonts": true })).unwrap();
|
||||
assert_eq!(r["nonPortableFonts"], json!([{ "style": "Tight", "font": "Liberation Sans Narrow" }]));
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Fonts AutoCAD on Windows doesn't have: Tight (Liberation Sans Narrow)."));
|
||||
assert!(file("portable.dxf").contains("verdana.ttf"));
|
||||
assert!(s.state().unwrap().drawing().text_styles.iter().any(|t| t.font == "DejaVuSans.ttf"), "the drawing keeps its fonts");
|
||||
|
||||
// A later save without it writes the drawing's fonts again, and says so.
|
||||
let r = s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(r["nonPortableFonts"], both);
|
||||
assert!(s.log.last().is_some_and(|l| l.ends_with("SAVEAS with portableFonts writes Windows fonts for those that have one.")), "{:?}", s.log);
|
||||
assert!(!file("portable.dxf").contains("verdana.ttf"));
|
||||
|
||||
// The same list isn't said again on every save.
|
||||
let lines = s.log.len();
|
||||
let r = s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(r["nonPortableFonts"], both);
|
||||
assert!(s.log.iter().skip(lines).all(|l| !l.starts_with("Fonts AutoCAD")), "{:?}", s.log);
|
||||
|
||||
assert!(s.execute("saveas", &json!({ "path": path("bad.dxf"), "portableFonts": "yes" })).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! writes them back as read: nothing to report.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::cmd::file::{IoHooks, SaveAs, set_io, set_write_as};
|
||||
use cadcraft_engine::doc::{Block, Common, Drawing, EntityKind, RawTag, Space, Unknown};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -29,7 +29,7 @@ fn save_lists_the_entities_the_file_does_not_keep() {
|
||||
let r = s.execute("saveas", &json!({ "path": path.to_string_lossy() })).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(r["notSaved"], json!({ "3DSOLID": 1, "MLINE": 2, "REGION": 1 }));
|
||||
let msg = "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): 1 3DSOLID, 2 MLINE, 1 REGION";
|
||||
let msg = "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): 1 3DSOLID, 2 MLINE, 1 REGION";
|
||||
assert!(s.log.iter().any(|l| l == msg), "{:?}", s.log);
|
||||
|
||||
// DXF keeps them.
|
||||
@@ -52,6 +52,38 @@ fn save_lists_the_entities_the_file_does_not_keep() {
|
||||
assert!(r.get("notSaved").is_none() && s.log.len() == n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_and_wblock_say_what_a_dwg_leaves_out() {
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
|
||||
let mut d = Drawing::new_imperial();
|
||||
let h = d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET")).unwrap().hex();
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "foreign.dxf", None);
|
||||
let file = |ext: &str| std::env::temp_dir().join(format!("cadcraft-leaves-out-{}.{ext}", std::process::id())).to_string_lossy().into_owned();
|
||||
let said = |l: &String| l.starts_with("Not saved in the DWG file") && l.ends_with(": 1 ACME_WIDGET");
|
||||
|
||||
let (dwg, dxf) = (file("dwg"), file("dxf"));
|
||||
for (cmd, p) in [("export", json!({ "path": dwg })), ("wblock", json!({ "path": dwg, "handles": [h] }))] {
|
||||
s.log.clear();
|
||||
let r = s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(r["notSaved"], json!({ "ACME_WIDGET": 1 }), "{cmd}");
|
||||
assert!(s.log.iter().any(said), "{cmd}: {:?}", s.log);
|
||||
}
|
||||
// -WBLOCK takes the objects out of the drawing, so last.
|
||||
let r = s.execute("-wblock", &json!({ "path": dwg, "handles": [h] })).unwrap();
|
||||
assert_eq!(r["notSaved"], json!({ "ACME_WIDGET": 1 }));
|
||||
let _ = std::fs::remove_file(&dwg);
|
||||
|
||||
// DXF keeps them.
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
for (cmd, p) in [("export", json!({ "path": dxf })), ("wblock", json!({ "path": dxf, "handles": [h] }))] {
|
||||
s.log.clear();
|
||||
assert!(s.execute(cmd, &p).unwrap().get("notSaved").is_none(), "{cmd}");
|
||||
assert!(!s.log.iter().any(said), "{cmd}: {:?}", s.log);
|
||||
}
|
||||
let _ = std::fs::remove_file(&dxf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_report_what_dwg_does_not_keep() {
|
||||
use cadcraft_engine::cmd::file::drops_unknown;
|
||||
@@ -82,3 +114,130 @@ fn solids_whose_geometry_the_data_store_keeps_are_not_reported_from_2013() {
|
||||
// Before 2013 a file has no data store: every solid loses its geometry.
|
||||
assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 2), ("REGION".to_string(), 1)].into_iter().collect());
|
||||
}
|
||||
|
||||
/// A DXF of 2013 or later can't hold a solid's SAT-text geometry (it lives in the data store
|
||||
/// there), so the save leaves it out and says so; 2010 and earlier keep it (#2334).
|
||||
#[test]
|
||||
fn saving_sat_text_solids_as_dxf_2013_reports_them() {
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
|
||||
set_write_as(|_, _, _: &SaveAs| Ok(b"x".to_vec()));
|
||||
let mut d = Drawing::new_imperial();
|
||||
let sat = EntityKind::Unknown(Unknown {
|
||||
dxf_type: "REGION".into(),
|
||||
tags: vec![
|
||||
RawTag { code: 8, value: "0".into() },
|
||||
RawTag { code: 1, value: "400 1 1 0".into() },
|
||||
RawTag { code: 3, value: "End-of-ACIS-data".into() },
|
||||
],
|
||||
});
|
||||
d.add(&Space::Model, Common::default(), sat).unwrap();
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "region.dxf", None);
|
||||
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-sat-note-{}.dxf", std::process::id()));
|
||||
let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2018 })).unwrap();
|
||||
assert_eq!(r["notSaved"], json!({ "REGION": 1 }));
|
||||
assert!(s.log.iter().any(|l| l.contains("Not saved in the DXF file") && l.contains("1 REGION")), "{:?}", s.log);
|
||||
|
||||
// QSAVE takes no version: a `version` passed to it doesn't change what it writes (the
|
||||
// drawing's own 2018), so the note still comes.
|
||||
let r = s.execute("qsave", &json!({ "path": path.to_string_lossy(), "version": 2010 })).unwrap();
|
||||
assert_eq!(r["notSaved"], json!({ "REGION": 1 }));
|
||||
|
||||
let n = s.log.len();
|
||||
let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2010 })).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(r.get("notSaved").is_none() && s.log.len() == n);
|
||||
}
|
||||
|
||||
/// The interactive save asks with the same list, and EXPORT reports it, so overwriting a 2013+
|
||||
/// DXF never drops solids on a command-line note alone (#2334).
|
||||
#[test]
|
||||
fn a_dxf_2013_loss_is_known_before_saving() {
|
||||
use cadcraft_engine::cmd::file::loss_on_save;
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
|
||||
let sat = |d: &mut Drawing| {
|
||||
let tags = vec![RawTag { code: 8, value: "0".into() }, RawTag { code: 1, value: "400 1 1 0".into() }];
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Unknown(Unknown { dxf_type: "REGION".into(), tags })).unwrap();
|
||||
};
|
||||
let mut modern = Drawing::new_imperial();
|
||||
modern.header.set_str("ACADVER", "AC1032");
|
||||
sat(&mut modern);
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(modern, "region.dxf", None);
|
||||
assert_eq!(loss_on_save(&s, "region.dxf"), [("REGION".to_string(), 1)].into_iter().collect());
|
||||
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-sat-export-{}.dxf", std::process::id()));
|
||||
let r = s.execute("export", &json!({ "path": path.to_string_lossy() })).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(r["notSaved"], json!({ "REGION": 1 }));
|
||||
assert!(s.log.iter().any(|l| l.contains("Not saved in the DXF file")), "{:?}", s.log);
|
||||
|
||||
let mut old = Drawing::new_imperial();
|
||||
old.header.set_str("ACADVER", "AC1024");
|
||||
sat(&mut old);
|
||||
s.open_drawing(old, "region.dxf", None);
|
||||
assert!(loss_on_save(&s, "region.dxf").is_empty(), "2010 keeps SAT text");
|
||||
}
|
||||
|
||||
/// In the Block Editor the file still holds the host drawing: its solids and version decide the
|
||||
/// note, not the edited block's (#2334).
|
||||
#[test]
|
||||
fn the_note_counts_the_host_drawing_while_a_block_is_edited() {
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
|
||||
set_write_as(|_, _, _: &SaveAs| Ok(b"x".to_vec()));
|
||||
let mut d = Drawing::new_imperial();
|
||||
let sat = EntityKind::Unknown(Unknown {
|
||||
dxf_type: "REGION".into(),
|
||||
tags: vec![
|
||||
RawTag { code: 8, value: "0".into() },
|
||||
RawTag { code: 1, value: "400 1 1 0".into() },
|
||||
RawTag { code: 3, value: "End-of-ACIS-data".into() },
|
||||
],
|
||||
});
|
||||
d.add(&Space::Model, Common::default(), sat).unwrap();
|
||||
d.blocks.insert("Door".into(), std::sync::Arc::new(Block::new("Door")));
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "region.dxf", None);
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-sat-edit-{}.dxf", std::process::id()));
|
||||
let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2018 })).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(r["notSaved"], json!({ "REGION": 1 }));
|
||||
}
|
||||
|
||||
/// A DWG before 2013 writes a solid's SAT text as it is, so a save there doesn't list a solid that
|
||||
/// holds it; from 2013 the geometry must be in the data store and SAT text alone is lost, so the
|
||||
/// save lists it. A solid with neither is always listed (#2334).
|
||||
#[test]
|
||||
fn a_dwg_keeps_sat_text_solids_only_before_2013() {
|
||||
use cadcraft_engine::cmd::file::{Version, not_saved_as};
|
||||
let tag = |code: i32, value: &str| RawTag { code, value: value.into() };
|
||||
let solid = |tags: Vec<RawTag>| EntityKind::Unknown(Unknown { dxf_type: "3DSOLID".into(), tags });
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(1, "400 1 1 0")])).unwrap();
|
||||
d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(70, "1")])).unwrap();
|
||||
assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 1)].into_iter().collect(), "2010 keeps the SAT-text solid");
|
||||
assert_eq!(not_saved_as(&d, Version::R2018), [("3DSOLID".to_string(), 2)].into_iter().collect(), "2018 loses it");
|
||||
}
|
||||
|
||||
/// The DWG bridge writes OLE objects (`ole2frame_roundtrip` in the io tests), so saving one as
|
||||
/// DWG reports nothing for them, at any version, while other unmodelled types are still listed.
|
||||
/// The type is matched as stored: the bridge gets it as stored too, and acadrust reads only the
|
||||
/// upper-case spelling into its OLE entity, so any other spelling is still reported.
|
||||
#[test]
|
||||
fn ole_objects_are_not_reported() {
|
||||
use cadcraft_engine::cmd::file::{Version, not_saved_as};
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common::default(), unknown("OLE2FRAME")).unwrap();
|
||||
d.add(&Space::Model, Common::default(), unknown("MLINE")).unwrap();
|
||||
d.add(&Space::Model, Common::default(), unknown("ole2frame")).unwrap();
|
||||
let mut b = Block::new("Logo");
|
||||
let h = d.new_handle();
|
||||
b.entities.push(cadcraft_engine::doc::Entity { handle: h, common: Common::default(), kind: unknown("OLE2FRAME") });
|
||||
d.blocks.insert("Logo".into(), std::sync::Arc::new(b));
|
||||
for v in [Version::R2000, Version::R2010, Version::R2018] {
|
||||
assert_eq!(not_saved_as(&d, v), [("MLINE".to_string(), 1), ("ole2frame".to_string(), 1)].into_iter().collect(), "{v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user