mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-26 03:46:53 -06:00
Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1139e48c05 | ||
|
|
ab24a174f3 | ||
|
|
a5f5bdd844 | ||
|
|
004287d919 | ||
|
|
656dd4d09a | ||
|
|
876de8ad2f | ||
|
|
4feccf0abd | ||
|
|
dfddd13656 | ||
|
|
2f82ecdbf1 | ||
|
|
7fc9f2756d | ||
|
|
754b609d09 | ||
|
|
d6e712db41 | ||
|
|
d73e379fd2 | ||
|
|
8343045863 | ||
|
|
2528671106 | ||
|
|
3f92d57b79 | ||
|
|
c3c455fbbf | ||
|
|
d0a1ea5268 | ||
|
|
2994b52e03 | ||
|
|
0de051049c | ||
|
|
cddf09beb2 | ||
|
|
1c85d339c3 | ||
|
|
63a5569970 | ||
|
|
5acec7d0be | ||
|
|
8f7421737d | ||
|
|
422d42411a | ||
|
|
0c15f7106c | ||
|
|
31de470926 | ||
|
|
0f60269868 | ||
|
|
3e6e1e534d | ||
|
|
52aae9c1fb | ||
|
|
a89be5f2dd | ||
|
|
a4a3a342a0 | ||
|
|
36fcc896f7 | ||
|
|
a0a691e1f7 | ||
|
|
097c53bee3 | ||
|
|
5ec3a18aa7 | ||
|
|
b37b7ceb7b | ||
|
|
1d9ddd2406 | ||
|
|
0a14fc7d3a | ||
|
|
bbbd9eec51 | ||
|
|
f2b35e957e | ||
|
|
1fcf654ea8 | ||
|
|
e86d895ba1 | ||
|
|
c6c3aefe3f | ||
|
|
b34477d580 | ||
|
|
3d7e298b7d | ||
|
|
23d44cd9c8 | ||
|
|
8296bbc642 | ||
|
|
b90d68ec34 | ||
|
|
c42763ba0f | ||
|
|
642c3a59a3 | ||
|
|
53049d82e0 | ||
|
|
e23a362c54 | ||
|
|
f46a220375 | ||
|
|
8171c9be17 | ||
|
|
4dbcbfdaaa | ||
|
|
429554b389 | ||
|
|
17952af3fb | ||
|
|
1b0cefce9c | ||
|
|
e1cab10d01 | ||
|
|
05cad7db49 | ||
|
|
4e73fb5bb3 | ||
|
|
84e3478863 | ||
|
|
fac2d18df6 | ||
|
|
23a0bd4fdb | ||
|
|
75c54ce35d | ||
|
|
313fab8827 | ||
|
|
bda4a9924c | ||
|
|
3e64cc5d06 | ||
|
|
20087b4b5f | ||
|
|
5685085add | ||
|
|
32be8a655e | ||
|
|
b901656669 | ||
|
|
1271b06b02 | ||
|
|
6dc336160d | ||
|
|
8a64e42100 | ||
|
|
fcfdf33001 | ||
|
|
898b539630 | ||
|
|
bfa30e2192 | ||
|
|
432cbfb106 | ||
|
|
596ea36465 | ||
|
|
dc081c6f7f | ||
|
|
746942613c | ||
|
|
1b8be0b77d | ||
|
|
7fe832b284 | ||
|
|
1fcdfc87b4 | ||
|
|
5a82f090df | ||
|
|
41d9b5e957 | ||
|
|
6fe715e11c | ||
|
|
028b98d71c | ||
|
|
39e9b62829 | ||
|
|
4aed7a2267 | ||
|
|
d0e83aefa6 | ||
|
|
cda7381c3d |
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
timeout-minutes: 90
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
|
||||
@@ -29,4 +29,4 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -23,6 +23,11 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
|
||||
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
|
||||
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
|
||||
Generated
+940
-120
File diff suppressed because it is too large
Load Diff
+38
-8
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/cadcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -25,7 +25,10 @@ cadcraft-engine = { path = "crates/engine" }
|
||||
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
cadcraft-mcp = { path = "crates/mcp" }
|
||||
# Which paths stored in a drawing (xrefs, data links) may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
|
||||
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
|
||||
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
|
||||
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -50,11 +53,14 @@ encoding_rs = "0.8.35"
|
||||
quick-xml = "0.42"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
egui_kittest = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui-wgpu = "0.36"
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] }
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_kittest = "0.37"
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui-wgpu = "0.37"
|
||||
# Monotonic time on native and web (performance.now on wasm).
|
||||
web-time = "1.1"
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -80,3 +86,27 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
|
||||
# (Cargo ignores dependency-local patches).
|
||||
[patch.crates-io]
|
||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -15,10 +15,36 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/
|
||||
is available from crates.io and the repository above.
|
||||
|
||||
|
||||
None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and
|
||||
linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with
|
||||
the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md,
|
||||
each under the SIL Open Font License 1.1.
|
||||
CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code.
|
||||
The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md.
|
||||
Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its
|
||||
ATTRIBUTION.md, each under the SIL Open Font License 1.1.
|
||||
|
||||
CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
|
||||
|
||||
|
||||
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
|
||||
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
|
||||
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
|
||||
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
|
||||
(pinned through https://github.com/storytold/craft-libs/tree/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc).
|
||||
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
|
||||
Pinned licence text: https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/LICENSE
|
||||
|
||||
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
|
||||
and registered through egui's default font definitions. They derive from emoji-icon-font by
|
||||
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Original: https://github.com/jslegers/emoji-icon-font
|
||||
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -123,8 +123,7 @@ numbers):
|
||||
|
||||
## Quick start
|
||||
|
||||
Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum
|
||||
remains Rust 1.90.
|
||||
CADCraft requires Rust 1.95 or later.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/cadcraft
|
||||
|
||||
@@ -226,6 +226,12 @@ fn info(path: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
|
||||
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched.
|
||||
fn write_atomic(out: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(out), bytes)
|
||||
}
|
||||
|
||||
fn export(s: &Session, out: &str) -> Result<(), String> {
|
||||
export_framed(s, out, None)
|
||||
}
|
||||
@@ -233,7 +239,7 @@ fn export(s: &Session, out: &str) -> Result<(), String> {
|
||||
fn export_framed(s: &Session, out: &str, window: Option<Bounds2>) -> Result<(), String> {
|
||||
let d = s.doc().map_err(|e| e.to_string())?;
|
||||
let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
if cadcraft_engine::cmd::file::drops_unknown(out) {
|
||||
let lost = cadcraft_engine::cmd::file::not_saved(d);
|
||||
@@ -252,7 +258,7 @@ fn sample(args: &[String]) -> Result<(), String> {
|
||||
other => return Err(format!("unknown sample `{other}` (bracket, floorplan)")),
|
||||
};
|
||||
let bytes = cadcraft_io::write(&d, out).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -862,6 +868,37 @@ fn cli() -> ExitCode {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_replaces_file_and_leaves_no_staging_files() {
|
||||
let dir = scratch_dir("replace");
|
||||
let out = dir.join("a.dxf");
|
||||
let out = out.to_str().expect("utf-8 path");
|
||||
write_atomic(out, b"old").expect("create");
|
||||
write_atomic(out, b"new").expect("overwrite");
|
||||
assert_eq!(std::fs::read(out).expect("read"), b"new");
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_failure_keeps_destination_and_cleans_up() {
|
||||
let dir = scratch_dir("fail");
|
||||
let out = dir.join("target");
|
||||
std::fs::create_dir(&out).expect("dir as destination");
|
||||
std::fs::write(out.join("keep"), b"x").expect("seed");
|
||||
assert!(write_atomic(out.to_str().expect("utf-8 path"), b"new").is_err());
|
||||
assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x");
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
|
||||
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
@@ -26,7 +27,7 @@ serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
|
||||
|
||||
@@ -95,7 +95,8 @@ impl Bridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// After each frame: tell the parent when unsaved changes appear or go away.
|
||||
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
|
||||
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
|
||||
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
|
||||
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
|
||||
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
|
||||
@@ -41,18 +41,22 @@ mod web {
|
||||
/// document title when it changed (so the tab and an embedding page name the open
|
||||
/// drawing), and reports unsaved changes to that page.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.1.take_dropped(ctx);
|
||||
// Background reads are consume-based and run on hidden ticks too. Dropped files are
|
||||
// input and are read from `ui` only.
|
||||
self.1.drain(&mut self.0);
|
||||
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
|
||||
self.0.logic(ctx);
|
||||
let drawing = self.0.session.state().ok();
|
||||
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
|
||||
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
|
||||
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
}
|
||||
}
|
||||
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
|
||||
@@ -63,11 +67,15 @@ mod web {
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
let first_pass = ui.ctx().current_pass_index() == 0;
|
||||
if first_pass {
|
||||
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
|
||||
// re-read the same file.
|
||||
self.1.take_dropped(ui.ctx());
|
||||
}
|
||||
self.0.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,8 +68,9 @@ impl Inbox {
|
||||
}
|
||||
|
||||
/// Read the files dropped on the page this frame (eframe hands them over unread on the web).
|
||||
/// The list is taken, not cloned: a later pass or hidden tick must not read the same drop.
|
||||
pub fn take_dropped(&self, ctx: &egui::Context) {
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) {
|
||||
if let Some(file) = f.web_file() {
|
||||
self.read(file.clone());
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ description = "CADCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
publish = false
|
||||
default-run = "cadcraft"
|
||||
|
||||
@@ -16,7 +17,7 @@ cadcraft-ui-egui = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
# For the shared per-session control-token helper (control_server.rs authentication).
|
||||
cadcraft-mcp = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -33,6 +34,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
|
||||
+37
-13
@@ -65,37 +65,47 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
/// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
|
||||
}
|
||||
if let Some(m) = &mut self.native_menu {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
}
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
|
||||
self.cad.logic(ctx);
|
||||
self.spacemouse.poll(&mut self.cad, ctx);
|
||||
self.persist_workspace(false);
|
||||
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
|
||||
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
|
||||
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
|
||||
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
|
||||
if ctx.current_pass_index() == 0 {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad, ctx));
|
||||
}
|
||||
if let Some(m) = &mut self.native_menu {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
}
|
||||
self.spacemouse.poll(&mut self.cad, ctx);
|
||||
// After `cad.logic`, so a control command's workspace change is saved in the same frame.
|
||||
self.persist_workspace(false);
|
||||
}
|
||||
if std::mem::take(&mut self.cad.quit_requested) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
/// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
follow_desktop(raw, self.desktop_theme.current());
|
||||
self.cad.raw_input_hook(raw);
|
||||
self.cad.raw_input_hook(ctx, raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.cad.ui(ui);
|
||||
self.persist_workspace(false);
|
||||
}
|
||||
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
|
||||
/// crash leaves them for recovery).
|
||||
fn on_exit(&mut self) {
|
||||
self.persist_workspace(true);
|
||||
self.cad.cancel_screenshots();
|
||||
self.cad.shutdown_autosave();
|
||||
}
|
||||
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
|
||||
@@ -232,9 +242,19 @@ fn control_flag(current: Option<u16>, value: Option<String>) -> Option<u16> {
|
||||
current
|
||||
}
|
||||
|
||||
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
// The update helper relaunches this binary after a self-update; it runs and exits here.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::CADCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("CADCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let mut control_port =
|
||||
control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()));
|
||||
@@ -280,6 +300,9 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
|
||||
// system-font discovery as a hidden fallback.
|
||||
system_font_fallback: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
@@ -338,6 +361,7 @@ fn main() -> eframe::Result {
|
||||
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
|
||||
if let Some(dir) = config_dir() {
|
||||
app.enable_autosave(&dir.join("autosave"));
|
||||
app.enable_update_check();
|
||||
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
|
||||
// folder is made so it is there to put them in.
|
||||
let support = dir.join("support");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use cadcraft_ui_egui::menus::{self, Entry};
|
||||
use cadcraft_ui_egui::{CadApp, i18n};
|
||||
@@ -15,10 +16,14 @@ pub struct NativeMenu {
|
||||
submenus: Vec<(String, Submenu)>,
|
||||
predefined: Vec<(&'static str, PredefinedMenuItem)>,
|
||||
language: &'static str,
|
||||
/// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh
|
||||
/// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first
|
||||
/// ~300 ms of menu enablement.
|
||||
last_refresh: f64,
|
||||
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
|
||||
recent: Vec<String>,
|
||||
counter: usize,
|
||||
events: mpsc::Receiver<MenuEvent>,
|
||||
}
|
||||
|
||||
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
|
||||
@@ -30,7 +35,16 @@ fn accel(sc: &str) -> Option<Accelerator> {
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
pub fn install(app: &mut CadApp) -> Self {
|
||||
/// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus.
|
||||
pub fn install(app: &mut CadApp, ctx: &egui::Context) -> Self {
|
||||
// A menu choice (or its shortcut, such as Cmd+W) is no window event, so winit would not
|
||||
// wake the app for it: the handler does, and the choice runs in the next frame.
|
||||
let (sender, events) = mpsc::channel();
|
||||
let wake = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.send(event);
|
||||
wake.request_repaint();
|
||||
}));
|
||||
let menu = Menu::new();
|
||||
let mut items = HashMap::new();
|
||||
let language = app.language();
|
||||
@@ -66,7 +80,17 @@ impl NativeMenu {
|
||||
submenus.push((title, sub));
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
|
||||
Self {
|
||||
_menu: menu,
|
||||
items,
|
||||
submenus,
|
||||
predefined,
|
||||
language,
|
||||
last_refresh: f64::NEG_INFINITY,
|
||||
recent: app.ui.recent_files.clone(),
|
||||
counter,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
|
||||
@@ -87,8 +111,9 @@ impl NativeMenu {
|
||||
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
|
||||
}
|
||||
|
||||
/// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed.
|
||||
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
|
||||
while let Ok(ev) = MenuEvent::receiver().try_recv() {
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) {
|
||||
if cmd == "ui.discord" {
|
||||
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
|
||||
@@ -113,7 +138,7 @@ impl NativeMenu {
|
||||
}
|
||||
}
|
||||
let now = cadcraft_ui_egui::now_ms();
|
||||
if now - self.last_refresh < 300.0 {
|
||||
if !refresh_due(now, self.last_refresh) {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
@@ -124,6 +149,13 @@ impl NativeMenu {
|
||||
}
|
||||
}
|
||||
|
||||
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
|
||||
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
|
||||
/// suppress the first refresh for 300 ms.
|
||||
fn refresh_due(now: f64, last_refresh: f64) -> bool {
|
||||
now - last_refresh >= 300.0
|
||||
}
|
||||
|
||||
fn build(
|
||||
parent: &Submenu,
|
||||
entries: &[Entry],
|
||||
@@ -150,3 +182,17 @@ fn build(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::refresh_due;
|
||||
|
||||
/// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`.
|
||||
#[test]
|
||||
fn the_first_poll_refreshes_immediately() {
|
||||
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
|
||||
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
|
||||
assert!(!refresh_due(299.9, 0.0));
|
||||
assert!(refresh_due(300.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Op
|
||||
}
|
||||
|
||||
fn snapshot(ui: &UiState) -> Result<Value, String> {
|
||||
ui.docking.validate()?;
|
||||
cadcraft_ui_egui::docking::validate(&ui.docking)?;
|
||||
if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") {
|
||||
return Err("toolsetTab must be Drafting or Modeling".into());
|
||||
}
|
||||
@@ -85,7 +85,7 @@ fn read(path: &Path) -> Result<Option<UiState>, String> {
|
||||
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
|
||||
if let Some(docking) = docking
|
||||
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
|
||||
&& docking.validate().is_ok()
|
||||
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
|
||||
{
|
||||
ui.docking = docking;
|
||||
}
|
||||
@@ -236,7 +236,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
|
||||
use cadcraft_ui_egui::docking::Panel;
|
||||
use cadcraft_ui_egui::docking::{Panel, permissions};
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
@@ -244,19 +244,58 @@ mod tests {
|
||||
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
|
||||
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap();
|
||||
ui.docking = app.ui.docking;
|
||||
ui.docking.set_visible(Panel::ToolSets, false).unwrap();
|
||||
ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
|
||||
ui.show_toolsets = false;
|
||||
store.save_if_changed(&ui).unwrap();
|
||||
let (_, reloaded, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none());
|
||||
let mut reloaded = reloaded.unwrap();
|
||||
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
|
||||
reloaded.docking.set_visible(Panel::ToolSets, true).unwrap();
|
||||
reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap();
|
||||
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
|
||||
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_docking_file_still_loads() {
|
||||
use cadcraft_ui_egui::docking::{Panel, reveal_panel};
|
||||
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
|
||||
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
|
||||
let file = r#"{"version":1,"workspace":{
|
||||
"docking":{
|
||||
"layout":{
|
||||
"root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0},
|
||||
"first":{"Tabs":{"panels":["canvas"],"active":0}},
|
||||
"second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}},
|
||||
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
|
||||
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
|
||||
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
|
||||
"showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#;
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
fs::write(&path, file).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none(), "{error:?}");
|
||||
let mut ui = restored.unwrap();
|
||||
assert_eq!(ui.toolset_tab, "Modeling");
|
||||
let docking = &ui.docking;
|
||||
assert_eq!(docking.layout.floating.len(), 1);
|
||||
let window = &docking.layout.floating[0];
|
||||
assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}}));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"],
|
||||
json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}})
|
||||
);
|
||||
assert!(!docking.layout.contains(&Panel::ToolSets));
|
||||
reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap();
|
||||
let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
assert_eq!(tools.anchor, Some(Panel::Canvas));
|
||||
assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}]));
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_oversized_and_newer_state_are_reported_and_preserved() {
|
||||
let directory = temp_dir();
|
||||
|
||||
@@ -87,3 +87,7 @@ real_name = "Richard Sauer"
|
||||
display_name = "Richard Sauer"
|
||||
sync_github_name = false
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ mod budget_tests {
|
||||
aux_init: Vec::new(),
|
||||
aux: 0,
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,12 @@ pub struct SolveOptions {
|
||||
pub keep: Vec<Handle>,
|
||||
/// Entities that should take up the change.
|
||||
pub prefer_move: Vec<Handle>,
|
||||
/// Points held where they are for this solve, as a Fix would hold them (the grip an edit
|
||||
/// just put down): see [`edited_points`].
|
||||
pub hold: Vec<GeomRef>,
|
||||
/// Where a Fix on a derived point (by constraint id) holds it, instead of where the point is
|
||||
/// now: see [`moved_anchors`].
|
||||
pub anchors: Vec<(u32, Vec2)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
@@ -205,12 +211,19 @@ pub(crate) struct Comp {
|
||||
pub(crate) model: Model,
|
||||
pub(crate) built: Vec<Built>,
|
||||
weights: Vec<f64>,
|
||||
/// Parameters of the points held by [`SolveOptions::hold`].
|
||||
held: Vec<usize>,
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
impl Comp {
|
||||
fn locks(&self, skip: Option<usize>) -> Vec<bool> {
|
||||
let mut locked = vec![false; self.model.x0.len()];
|
||||
for &j in &self.held {
|
||||
if let Some(l) = locked.get_mut(j) {
|
||||
*l = true;
|
||||
}
|
||||
}
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) == skip || b.form != Form::Lock {
|
||||
continue;
|
||||
@@ -245,7 +258,30 @@ impl Comp {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
|
||||
Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER))
|
||||
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
|
||||
// A solution the ellipses would be stored differently from (axes swapped, minor axis
|
||||
// mirrored) holds only if the constraints still hold on the stored form: a constraint on
|
||||
// an axis end by index does not.
|
||||
if o.converged && !self.model.storable(&o.x) {
|
||||
let mut out = Vec::new();
|
||||
match self.model.stored_form(&o.x) {
|
||||
Some(mut xc) => {
|
||||
// Contact parameters are positions along the curves: they follow the stored form.
|
||||
for b in self.built.iter().filter(|b| b.form == Form::Contact) {
|
||||
for (k, r) in b.refs.iter().enumerate() {
|
||||
if let (Some(&t), Some(slot)) = (o.x.get(b.aux + k), xc.get_mut(b.aux + k)) {
|
||||
*slot = self.model.stored_t(&o.x, r.handle, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.eval(&xc, &mut out, skip);
|
||||
o.converged = lm::max_abs(&out) <= self.tol;
|
||||
o.x = xc;
|
||||
}
|
||||
None => o.converged = false,
|
||||
}
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
/// Constraint ids in conflict (each one whose removal makes the rest solvable).
|
||||
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
|
||||
@@ -346,7 +382,14 @@ pub(crate) fn build_budgeted(
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
match interpret(c, &mut model, target) {
|
||||
Ok(b) => built.push(b),
|
||||
Ok(mut b) => {
|
||||
if let Form::Anchor(_) = b.form
|
||||
&& let Some(&(_, a)) = opts.anchors.iter().find(|(id, _)| *id == c.id)
|
||||
{
|
||||
b.form = Form::Anchor(a);
|
||||
}
|
||||
built.push(b);
|
||||
}
|
||||
Err(msg) => skipped.push((c.id, msg)),
|
||||
}
|
||||
}
|
||||
@@ -393,8 +436,9 @@ pub(crate) fn build_budgeted(
|
||||
for w in weights.iter_mut().skip(geo) {
|
||||
*w = 1e-3;
|
||||
}
|
||||
let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
|
||||
let tol = scale_tol(&model.x0);
|
||||
comps.push(Comp { model, built, weights, tol });
|
||||
comps.push(Comp { model, built, weights, held, tol });
|
||||
}
|
||||
Ok((comps, skipped))
|
||||
}
|
||||
@@ -445,6 +489,9 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
|
||||
/// A Fix keeps no position of its own: the solver locks the parameters at their current values.
|
||||
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
|
||||
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
|
||||
/// An object held by a Fix on a derived point (a line's midpoint, an arc's end) has nothing to
|
||||
/// lock: when the edit only moved it (MOVE), it is put back whole, so it is not reshaped to bring
|
||||
/// the point back.
|
||||
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
let refs: Vec<GeomRef> = d
|
||||
.constraints
|
||||
@@ -461,10 +508,21 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
let mut changed = false;
|
||||
for r in &refs {
|
||||
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
|
||||
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue };
|
||||
if !same_shape {
|
||||
continue;
|
||||
}
|
||||
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else {
|
||||
if let (Some(a), Some(b)) = (now.slot(r.handle), old.slot(r.handle))
|
||||
&& translated(&now, &old, r.handle)
|
||||
&& let (Some(dst), Some(src)) = (x.get_mut(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len))
|
||||
{
|
||||
for (v, &w) in dst.iter_mut().zip(src) {
|
||||
*v = w;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
for (i, j) in i.into_iter().zip(j) {
|
||||
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
|
||||
&& *slot != v
|
||||
@@ -480,6 +538,88 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
changed
|
||||
}
|
||||
|
||||
/// The parameters of `h`'s points (see [`Model::point_refs`]) in `m`, as (x, y) pairs.
|
||||
fn point_params(m: &Model, h: Handle) -> Vec<(usize, usize)> {
|
||||
m.point_refs(h)
|
||||
.iter()
|
||||
.filter_map(|r| m.lock_indices(r))
|
||||
.flat_map(|ix| ix.as_chunks::<2>().0.iter().map(|&[x, y]| (x, y)).collect::<Vec<_>>())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Is `h` in `now` its shape in `old` moved by one vector, and nothing else?
|
||||
fn translated(now: &Model, old: &Model, h: Handle) -> bool {
|
||||
let (Some(a), Some(b)) = (now.slot(h), old.slot(h)) else { return false };
|
||||
let (Some(xa), Some(xb)) = (now.x0.get(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) else { return false };
|
||||
let tol = scale_tol(xb) * 10.0;
|
||||
let pts: Vec<(usize, usize)> =
|
||||
point_params(old, h).into_iter().filter_map(|(i, j)| Some((i.checked_sub(b.off)?, j.checked_sub(b.off)?))).collect();
|
||||
let delta = |(i, j): (usize, usize)| Some(Vec2::new(xa.get(i)? - xb.get(i)?, xa.get(j)? - xb.get(j)?));
|
||||
let Some(v) = pts.first().and_then(|&p| delta(p)) else { return false };
|
||||
if v.len() <= tol || !pts.iter().all(|&p| delta(p).is_some_and(|d| d.near(v, tol))) {
|
||||
return false;
|
||||
}
|
||||
(0..xb.len())
|
||||
.filter(|k| !pts.iter().any(|&(i, j)| *k == i || *k == j))
|
||||
.all(|k| matches!((xa.get(k), xb.get(k)), (Some(p), Some(q)) if (p - q).abs() <= tol))
|
||||
}
|
||||
|
||||
/// The points of `h` (its ends, centre or vertices) the edit from `before` to `d` moved, when it
|
||||
/// moved some of them but not all: the grip that was dragged, to pass as [`SolveOptions::hold`] so
|
||||
/// it stays where it was put and the rest of the object takes up the constraints. Empty when the
|
||||
/// object kept its points or moved them all.
|
||||
pub fn edited_points(d: &Drawing, before: &Drawing, h: Handle) -> Vec<GeomRef> {
|
||||
let (now, old) = (Model::build(d, &[h]), Model::build(before, &[h]));
|
||||
if !matches!((now.slot(h), old.slot(h)), (Some(a), Some(b)) if a.shape == b.shape) {
|
||||
return Vec::new();
|
||||
}
|
||||
let tol = scale_tol(&old.x0) * 10.0;
|
||||
let refs = now.point_refs(h);
|
||||
let moved: Vec<GeomRef> = refs
|
||||
.iter()
|
||||
.filter(|r| matches!((now.lock_indices(r), old.lock_indices(r)), (Some(i), Some(j)) if i.iter().zip(&j).any(|(&i, &j)| matches!((now.x0.get(i), old.x0.get(j)), (Some(p), Some(q)) if (p - q).abs() > tol))))
|
||||
.copied()
|
||||
.collect();
|
||||
if moved.len() == refs.len() { Vec::new() } else { moved }
|
||||
}
|
||||
|
||||
/// Where `before` had each derived point held by a Fix (a line's midpoint, an arc's end), for the
|
||||
/// Fixes whose point has moved since, to pass as [`SolveOptions::anchors`].
|
||||
///
|
||||
/// A Fix on a derived point anchors it where it is when the system is built, and
|
||||
/// [`restore_fixed`] has no parameters to put back: without this, an edit that moves the point
|
||||
/// would leave the Fix satisfied at the new place.
|
||||
pub fn moved_anchors(d: &Drawing, before: &Drawing) -> Vec<(u32, Vec2)> {
|
||||
let fixes: Vec<(u32, GeomRef)> = d
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
|
||||
.filter_map(|c| match c.refs.as_slice() {
|
||||
[r] => Some((c.id, *r)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if fixes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let handles: Vec<Handle> = fixes.iter().map(|(_, r)| r.handle).collect();
|
||||
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
|
||||
let tol = scale_tol(&old.x0);
|
||||
let mut out = Vec::new();
|
||||
for (id, r) in fixes {
|
||||
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
|
||||
if !same_shape || now.lock_indices(&r).is_some() {
|
||||
continue;
|
||||
}
|
||||
if let (Some(p), Some(a)) = (now.point(&now.x0, &r), old.point(&old.x0, &r))
|
||||
&& !p.near(a, tol)
|
||||
{
|
||||
out.push((id, a));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when every interpretable constraint holds at the current geometry.
|
||||
pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
|
||||
@@ -32,6 +32,8 @@ pub const MAX_RIGID_POINTS: usize = 256;
|
||||
const MAX_RIGID_MEMBERS: usize = 100_000;
|
||||
/// Shared across every model built for one analysis request.
|
||||
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
|
||||
/// How far past 1 a solved ellipse ratio may land by rounding and still be stored as 1.
|
||||
const RATIO_TOL: f64 = 1e-9;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Shape {
|
||||
@@ -477,6 +479,18 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// The points of an object a Fix locks directly (ends, centre, vertices, insertion point).
|
||||
pub fn point_refs(&self, h: Handle) -> Vec<GeomRef> {
|
||||
let Some(s) = self.slot(h) else { return Vec::new() };
|
||||
let at = |sub: Sub| GeomRef { handle: h, sub };
|
||||
match s.shape {
|
||||
Shape::Line => vec![at(Sub::Start), at(Sub::End)],
|
||||
Shape::Point => vec![at(Sub::Whole)],
|
||||
Shape::Circle | Shape::Arc | Shape::Ellipse { .. } | Shape::Insert { .. } => vec![at(Sub::Center)],
|
||||
Shape::Poly { n, .. } | Shape::Spline { n, .. } => (0..n).filter_map(|i| u32::try_from(i).ok()).map(|i| at(Sub::Vertex(i))).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters a Fix on this reference locks directly. `None` when the reference is a derived
|
||||
/// point (fixed through a residual instead).
|
||||
pub fn lock_indices(&self, r: &GeomRef) -> Option<Vec<usize>> {
|
||||
@@ -504,6 +518,57 @@ impl Model {
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Can `write_back` store `x` as it is? An ellipse keeps 0 < ratio <= 1, its major axis the
|
||||
/// longer one: a solution past that is stored with its axes swapped or its minor axis
|
||||
/// mirrored (see [`Model::stored_form`]), which renumbers the axis ends.
|
||||
pub fn storable(&self, x: &[f64]) -> bool {
|
||||
self.slots.iter().all(|s| match s.shape {
|
||||
Shape::Ellipse { .. } => x.get(s.off + 4).is_some_and(|r| *r > 0.0 && *r <= 1.0 + RATIO_TOL),
|
||||
_ => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// `x` with every ellipse re-expressed as [`Model::write_back`] stores it (the same curve with
|
||||
/// the major axis the longer one, a full ellipse's negative ratio made positive), so the
|
||||
/// constraints can be checked against what would be stored. `None` when an elliptical arc has
|
||||
/// a negative ratio or an ellipse a zero one.
|
||||
pub fn stored_form(&self, x: &[f64]) -> Option<Vec<f64>> {
|
||||
let mut xc = x.to_vec();
|
||||
for s in &self.slots {
|
||||
let Shape::Ellipse { arc } = s.shape else { continue };
|
||||
let o = s.off;
|
||||
let mut ratio = *x.get(o + 4)?;
|
||||
if ratio < 0.0 && !arc {
|
||||
ratio = -ratio;
|
||||
}
|
||||
if ratio <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if ratio > 1.0 + RATIO_TOL {
|
||||
let major = p2(x, o + 2)?.perp() * ratio;
|
||||
*xc.get_mut(o + 2)? = major.x;
|
||||
*xc.get_mut(o + 3)? = major.y;
|
||||
ratio = 1.0 / ratio;
|
||||
if arc {
|
||||
*xc.get_mut(o + 5)? -= std::f64::consts::FRAC_PI_2;
|
||||
*xc.get_mut(o + 6)? -= std::f64::consts::FRAC_PI_2;
|
||||
}
|
||||
}
|
||||
*xc.get_mut(o + 4)? = ratio;
|
||||
}
|
||||
Some(xc)
|
||||
}
|
||||
|
||||
/// The parameter `stored_form` gives the point at parameter `t` on ellipse `h` (unchanged for
|
||||
/// any other object): `-t` where a negative ratio was made positive, a quarter turn back where
|
||||
/// the axes were swapped.
|
||||
pub fn stored_t(&self, x: &[f64], h: Handle, t: f64) -> f64 {
|
||||
let Some(s) = self.slot(h) else { return t };
|
||||
let (Shape::Ellipse { .. }, Some(&ratio)) = (s.shape, x.get(s.off + 4)) else { return t };
|
||||
let (t, ratio) = if ratio < 0.0 { (-t, -ratio) } else { (t, ratio) };
|
||||
if ratio > 1.0 + RATIO_TOL { t - std::f64::consts::FRAC_PI_2 } else { t }
|
||||
}
|
||||
|
||||
/// Write solved parameters back. Returns the handles that moved.
|
||||
pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec<Handle> {
|
||||
let mut moved = Vec::new();
|
||||
@@ -572,8 +637,9 @@ impl Model {
|
||||
[a, b] if arc => (*a, *b),
|
||||
_ => (el.start, el.end),
|
||||
};
|
||||
// Keep the stored form: the major axis is the longer one.
|
||||
if ratio > 1.0 {
|
||||
// Keep the stored form: the major axis is the longer one. `storable` lets a
|
||||
// solved ratio exceed 1 only by rounding: that is a circle, not a swap.
|
||||
if ratio > 1.0 + RATIO_TOL {
|
||||
major = major.perp() * ratio;
|
||||
ratio = 1.0 / ratio;
|
||||
if arc {
|
||||
|
||||
@@ -305,7 +305,7 @@ fn keep_option_moves_the_other_entity() {
|
||||
let mut d = Drawing::default();
|
||||
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
|
||||
let b = line(&mut d, (0.0, 2.0), (10.0, 4.0));
|
||||
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap();
|
||||
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b], ..Default::default() }).unwrap();
|
||||
let (a0, a1) = ln(&d, a);
|
||||
assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}");
|
||||
}
|
||||
@@ -412,7 +412,7 @@ fn glyphs_and_residuals() {
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!all_satisfied(&d));
|
||||
let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap();
|
||||
let r = solve(&mut d, &SolveOptions::default()).unwrap();
|
||||
assert_eq!(r.moved, vec![a]);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
@@ -382,3 +382,83 @@ fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
|
||||
assert_eq!(report.status, DofStatus::Under);
|
||||
assert_eq!(report.dof, Some(1000));
|
||||
}
|
||||
|
||||
/// Adds a constraint that the stored ellipse can only hold with its axes renumbered: refused,
|
||||
/// with the drawing unchanged.
|
||||
fn refused_on_minor_axis_end(p: (f64, f64)) {
|
||||
let mut d = Drawing::default();
|
||||
let e = ellipse(&mut d, (0.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::TAU);
|
||||
ok(&mut d, con(K::Fix, &[s(e, Sub::Center)]));
|
||||
let p = point(&mut d, p);
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
let before = d.clone();
|
||||
let r = add(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(1)), w(p)]), &SolveOptions::default());
|
||||
assert!(matches!(r, Err(SolveError::Conflict { .. })), "{r:?}, ellipse {:?}", ell(&d, e));
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
/// Pulling a minor axis end past the major axis length would need a ratio above 1, which the
|
||||
/// stored ellipse holds only by swapping its axes (and so renumbering their ends).
|
||||
#[test]
|
||||
fn minor_axis_end_pulled_past_the_major_axis_is_refused() {
|
||||
refused_on_minor_axis_end((0.0, 6.0));
|
||||
}
|
||||
|
||||
/// Pulling a minor axis end through the centre would need a negative ratio, which the stored
|
||||
/// ellipse cannot hold: storing its absolute value would mirror the minor axis end.
|
||||
#[test]
|
||||
fn minor_axis_end_pulled_through_the_centre_is_refused() {
|
||||
refused_on_minor_axis_end((0.0, -1.0));
|
||||
}
|
||||
|
||||
/// A circle-like ellipse whose solution makes the minor axis the longer one: stored with its axes
|
||||
/// swapped, which constraints that name no axis end by index do not notice.
|
||||
fn round_ellipse(d: &mut Drawing, start: f64, end: f64) -> Handle {
|
||||
let e = ellipse(d, (0.0, 0.0), (4.0, 0.0), 1.0, start, end);
|
||||
ok(d, con(K::Fix, &[s(e, Sub::Center)]));
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let l = line(&mut d, (-10.0, 4.5), (10.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(l)]));
|
||||
ok(&mut d, con(K::Tangent, &[w(l), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_on_curve_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let p = point(&mut d, (0.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
ok(&mut d, con(K::Coincident, &[w(p), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elliptical_arc_start_that_swaps_its_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
|
||||
let p = point(&mut d, (0.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
ok(&mut d, con(K::Coincident, &[s(e, Sub::Start), w(p)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_circle_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let c = circle(&mut d, (0.0, 6.0), 1.5);
|
||||
ok(&mut d, con(K::Fix, &[w(c)]));
|
||||
ok(&mut d, con(K::Tangent, &[w(c), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
@@ -29,16 +29,21 @@ fn tag(code: i32, value: impl Into<String>) -> RawTag {
|
||||
RawTag { code, value: value.into() }
|
||||
}
|
||||
|
||||
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one.
|
||||
fn etag(d: &Drawing, h: Handle) -> Option<i64> {
|
||||
let x = &d.foreign.entities.get(&h)?.xdata;
|
||||
/// The element tag (`AcDbBlockRepETag` 1071) in `x`, if it has one.
|
||||
fn etag_in(x: &[RawTag]) -> Option<i64> {
|
||||
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
|
||||
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
|
||||
}
|
||||
|
||||
/// The element tag of object `e`: its own xdata (where the importer keeps it), else any kept in the
|
||||
/// foreign data.
|
||||
fn etag(d: &Drawing, e: &Entity) -> Option<i64> {
|
||||
etag_in(&e.common.xdata).or_else(|| etag_in(&d.foreign.entities.get(&e.handle)?.xdata))
|
||||
}
|
||||
|
||||
/// Each object of `b` keyed by its element tag, or by its position when it has none.
|
||||
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
|
||||
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
|
||||
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
|
||||
}
|
||||
|
||||
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
|
||||
@@ -542,6 +547,12 @@ fn apply(
|
||||
let Some(nh) = copies.get(&e.handle).copied() else { continue };
|
||||
let mut c: Entity = (**e).clone();
|
||||
c.handle = nh;
|
||||
// The copy's own xdata names the copy, not its original (as the kept data below does).
|
||||
for t in &mut c.common.xdata {
|
||||
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(e.handle) {
|
||||
t.value = nh.hex();
|
||||
}
|
||||
}
|
||||
if let Some(v) = shown_copy.get(&e.handle) {
|
||||
c.common.visible = *v;
|
||||
}
|
||||
@@ -964,3 +975,30 @@ fn drop_if_unused(d: &mut Drawing, name: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tagged(h: u64, tag_value: Option<&str>) -> Entity {
|
||||
let mut e = Entity::new(Handle(h), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::ZERO }));
|
||||
if let Some(v) = tag_value {
|
||||
e.common.xdata = vec![tag(1001, ETAG), tag(1071, v)];
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn element_tags_are_read_from_the_entitys_own_xdata() {
|
||||
let d = Drawing::new_imperial();
|
||||
let mut def = Block::new("DEF");
|
||||
def.entities.push(tagged(1, Some("10")));
|
||||
def.entities.push(tagged(2, Some("20")));
|
||||
let mut anon = Block::new("*U1");
|
||||
// Reordered, and the first object left out.
|
||||
anon.entities.push(tagged(5, Some("20")));
|
||||
anon.entities.push(tagged(6, None));
|
||||
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
|
||||
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ impl Handle {
|
||||
format!("{:X}", self.0)
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Handle> {
|
||||
u64::from_str_radix(s.trim(), 16).ok().map(Handle)
|
||||
let s = s.trim();
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u64::from_str_radix(s, 16).ok().map(Handle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1477,6 +1481,15 @@ mod grip_tests {
|
||||
use super::*;
|
||||
use cadcraft_geom::PolyVertex;
|
||||
|
||||
#[test]
|
||||
fn handle_parse_hex_accepts_only_hex_digits() {
|
||||
assert_eq!(Handle::parse_hex("1a"), Some(Handle(0x1A)));
|
||||
assert_eq!(Handle::parse_hex(" 1A "), Some(Handle(0x1A)));
|
||||
for bad in ["+1A", "-1A", "", "+", "1 A"] {
|
||||
assert_eq!(Handle::parse_hex(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_grips_are_vertices_and_midpoints() {
|
||||
// Grips are each vertex plus the midpoint of each segment; the segment vector is computed
|
||||
|
||||
@@ -177,6 +177,11 @@ pub struct DimStyle {
|
||||
pub dim_line_color: Color,
|
||||
pub ext_line_color: Color,
|
||||
pub text_color: Color,
|
||||
/// DIMTFILL, the background of the text: 0 none, 1 the drawing's background color,
|
||||
/// 2 `text_fill_color`.
|
||||
pub text_fill: u8,
|
||||
/// DIMTFILLCLR: the text background color when `text_fill` is 2.
|
||||
pub text_fill_color: Color,
|
||||
pub text_style: String,
|
||||
/// DIMPOST prefix/suffix "<>" template.
|
||||
pub post: String,
|
||||
@@ -296,6 +301,8 @@ pub const DIMVARS: &[(&str, &str)] = &[
|
||||
("DIMCLRD", "dimLineColor"),
|
||||
("DIMCLRE", "extLineColor"),
|
||||
("DIMCLRT", "textColor"),
|
||||
("DIMTFILL", "textFill"),
|
||||
("DIMTFILLCLR", "textFillColor"),
|
||||
("DIMTXSTY", "textStyle"),
|
||||
("DIMPOST", "post"),
|
||||
("DIMCEN", "centerMark"),
|
||||
@@ -367,6 +374,8 @@ impl Default for DimStyle {
|
||||
dim_line_color: Color::ByBlock,
|
||||
ext_line_color: Color::ByBlock,
|
||||
text_color: Color::ByBlock,
|
||||
text_fill: 0,
|
||||
text_fill_color: Color::ByBlock,
|
||||
text_style: "Standard".into(),
|
||||
post: String::new(),
|
||||
center_mark: 0.09,
|
||||
@@ -476,10 +485,19 @@ impl DimStyle {
|
||||
let coerced = match (&old, &coerced) {
|
||||
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
|
||||
(Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => {
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(-3.0, 211.0) as i64)
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round() as i64)
|
||||
}
|
||||
(Value::Number(_), Value::Number(_)) if matches!(field, "dimLineweight" | "extLineweight") => coerced,
|
||||
// A value below 0 or one that rounds above 255 is refused; DIMADEC -1 reads as 0.
|
||||
(Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => {
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64)
|
||||
let min = if field == "angularDecimals" { -1.0 } else { 0.0 };
|
||||
match n.as_f64().filter(|x| x.is_finite() && *x >= min).map(f64::round).filter(|x| *x <= 255.0) {
|
||||
Some(x) => serde_json::json!(x.max(0.0) as u64),
|
||||
None => {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => coerced,
|
||||
};
|
||||
@@ -489,7 +507,7 @@ impl DimStyle {
|
||||
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
|
||||
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
|
||||
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" => x.is_some_and(|x| x > 2.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
|
||||
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
|
||||
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
|
||||
_ => false,
|
||||
|
||||
+23
-11
@@ -413,8 +413,7 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
|
||||
for t in tags {
|
||||
let _ = write!(out, "{:>3}\r\n", t.code);
|
||||
match &t.value {
|
||||
Value::Str(s) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })),
|
||||
Value::Str(s) => escape_unicode(s, &mut out),
|
||||
Value::Str(s) => escape_unicode(s, utf8, &mut out),
|
||||
Value::Real(r) => out.push_str(&format_real(*r)),
|
||||
Value::Int(i) => out.push_str(&i.to_string()),
|
||||
Value::Bool(b) => out.push_str(&i64::from(*b).to_string()),
|
||||
@@ -446,11 +445,7 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
|
||||
v => v.as_str(),
|
||||
};
|
||||
let mut text = String::with_capacity(s.len());
|
||||
if utf8 {
|
||||
text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c }));
|
||||
} else {
|
||||
escape_unicode(&s, &mut text);
|
||||
}
|
||||
escape_unicode(&s, utf8, &mut text);
|
||||
out.extend(text.bytes().filter(|&b| b != 0));
|
||||
out.push(0);
|
||||
}
|
||||
@@ -507,12 +502,16 @@ pub fn is_binary(bytes: &[u8]) -> bool {
|
||||
|
||||
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
|
||||
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
|
||||
/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces.
|
||||
fn escape_unicode(s: &str, out: &mut String) {
|
||||
for c in s.chars() {
|
||||
/// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are.
|
||||
/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
|
||||
/// gives back the literal text. Line breaks become spaces.
|
||||
fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
match c {
|
||||
'\r' | '\n' => out.push(' '),
|
||||
c if c.is_ascii() => out.push(c),
|
||||
'\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
|
||||
c if utf8 || c.is_ascii() => out.push(c),
|
||||
c => {
|
||||
let mut units = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut units) {
|
||||
@@ -569,6 +568,19 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn literal_escape_text_roundtrips() {
|
||||
for utf8 in [false, true] {
|
||||
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
|
||||
let tags = vec![Tag::s(8, text)];
|
||||
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
|
||||
assert_eq!(back, tags, "utf8={utf8} {text}");
|
||||
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
|
||||
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_roundtrip() {
|
||||
let tags = vec![
|
||||
|
||||
@@ -208,6 +208,12 @@ impl Autosaver {
|
||||
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
|
||||
continue;
|
||||
}
|
||||
// In the Block Editor: the drawing with the editor's unsaved changes in the block. When
|
||||
// they can't be put there, nothing is written and nothing is marked as covered, so the
|
||||
// next pass tries again instead of keeping the host without them.
|
||||
let Ok(doc) = st.recovery_drawing() else {
|
||||
continue;
|
||||
};
|
||||
let path = t
|
||||
.file
|
||||
.clone()
|
||||
@@ -216,8 +222,7 @@ impl Autosaver {
|
||||
t.written = Some(Arc::downgrade(&st.doc));
|
||||
t.host = Some(Arc::downgrade(st.drawing()));
|
||||
t.since = None;
|
||||
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc });
|
||||
}
|
||||
jobs
|
||||
}
|
||||
|
||||
@@ -195,12 +195,13 @@ impl DocState {
|
||||
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
|
||||
}
|
||||
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
|
||||
/// block, so a crash doesn't lose them.
|
||||
pub fn recovery_drawing(&self) -> Arc<Drawing> {
|
||||
/// block, so a crash doesn't lose them. An error when the editor's changes can't be put in the
|
||||
/// block (it would contain itself): the host drawing alone would lose them.
|
||||
pub fn recovery_drawing(&self) -> Result<Arc<Drawing>> {
|
||||
match &self.block_edit {
|
||||
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
|
||||
Some(be) => be.host.doc.clone(),
|
||||
None => self.doc.clone(),
|
||||
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new),
|
||||
Some(be) => Ok(be.host.doc.clone()),
|
||||
None => Ok(self.doc.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annota
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, f64_req, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -9,7 +9,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, point_param, point_req, points_param, str_param, targets};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -897,7 +898,7 @@ fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
}
|
||||
|
||||
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
|
||||
fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
|
||||
pub(super) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
|
||||
match &s.doc().ok()?.entity(h)?.kind {
|
||||
EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))),
|
||||
EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),
|
||||
|
||||
@@ -5,8 +5,11 @@ use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection,
|
||||
use cadcraft_geom::{Segment, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use super::super::helpers::v3;
|
||||
use super::super::machines::{SelOutcome, SelectPhase};
|
||||
use super::super::{CommandSpec, bad, bool_or, point_param, point_req, size_param, str_param, targets};
|
||||
use super::{DimText, add_dim, circle_of, dim, dims_of, pick_at};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension.
|
||||
|
||||
@@ -6,8 +6,10 @@ use cadcraft_doc::{DimKind, EntityKind, Handle, Prim};
|
||||
use cadcraft_geom::{Segment, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use super::super::machines::{SelOutcome, SelectPhase};
|
||||
use super::super::{bad, point_param, point_req, points_param, str_param, targets};
|
||||
use super::{MAX_CHAIN, assoc_enabled, dim};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) const PARAMS: &str = "{handles?, at: [x,y] (dimension line position), mode?: continuous|staggered|baseline|ordinate|radius|diameter (default continuous), vertical?: bool (default: from `at`), datum?: [x,y] (baseline and ordinate; default the first point, or the origin for ordinate), add?: [[x,y]], remove?: [[x,y]] (dimension points; removes those within `tolerance`)}";
|
||||
|
||||
@@ -17,7 +17,8 @@ use serde_json::{Value, json};
|
||||
use super::helpers::{line, v3};
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::modify::transform_entities;
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_req, str_param, targets};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod edit;
|
||||
|
||||
@@ -16,7 +16,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::super::helpers::v3;
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::*;
|
||||
use super::super::{CommandSpec, bad, bool_or, point_param, targets};
|
||||
|
||||
use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, file, str_param};
|
||||
|
||||
use super::always;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -12,7 +12,9 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, point_req, point_value, str_param, targets};
|
||||
|
||||
use super::has_doc;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attedit;
|
||||
@@ -74,10 +76,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
attribs::attdisp_spec(),
|
||||
attribs::attsync_spec(),
|
||||
burst::burst_spec(),
|
||||
blockreplace::blockreplace_spec(),
|
||||
extract::dataextraction_spec(),
|
||||
extract::eattext_spec(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(attribs::attdisp_menu_specs())
|
||||
.chain(xref::specs())
|
||||
.chain(refedit::specs())
|
||||
.chain(bedit::specs())
|
||||
@@ -88,12 +92,19 @@ pub(crate) fn valid_block_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
/// The block an entity references, when it is a block reference.
|
||||
/// The block an entity draws, if any: a block reference's block, a dimension's block or a
|
||||
/// multileader's block content.
|
||||
fn referenced_block(e: &Entity) -> Option<String> {
|
||||
if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => dm.block.clone(),
|
||||
EntityKind::MLeader(m) => m.block.as_ref().map(|b| b.block.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references.
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references,
|
||||
/// dimension blocks and multileader block content.
|
||||
pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
let mut todo: Vec<String> = ents.iter().filter_map(referenced_block).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -878,3 +889,4 @@ impl Interactive for AttdefM {
|
||||
|
||||
mod attribs;
|
||||
mod battman;
|
||||
mod blockreplace;
|
||||
|
||||
@@ -16,6 +16,22 @@ pub(super) fn attdisp_spec() -> CommandSpec {
|
||||
CommandSpec::new("attdisp", "Attribute Display", run_attdisp)
|
||||
.params("{mode: \"normal\" | \"on\" | \"off\"} (sets ATTMODE to 1 / 2 / 0) → {attmode}")
|
||||
.interactive(|_| Ok(Box::new(AttdispM)))
|
||||
.transparent()
|
||||
}
|
||||
|
||||
/// Returns the View > Attribute Display > Normal, On and Off commands, which set ATTMODE as the matching ATTDISP option does.
|
||||
pub(super) fn attdisp_menu_specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("attdisp.normal", "Normal", |s, _| set_attmode(s, 1))
|
||||
.menu(&["View", "Attribute Display", "Normal"])
|
||||
.params("{} (ATTDISP Normal: ATTMODE 1) → {attmode}"),
|
||||
CommandSpec::new("attdisp.on", "On", |s, _| set_attmode(s, 2))
|
||||
.menu(&["View", "Attribute Display", "On"])
|
||||
.params("{} (ATTDISP ON: ATTMODE 2) → {attmode}"),
|
||||
CommandSpec::new("attdisp.off", "Off", |s, _| set_attmode(s, 0))
|
||||
.menu(&["View", "Attribute Display", "Off"])
|
||||
.params("{} (ATTDISP OFF: ATTMODE 0) → {attmode}"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn attsync_spec() -> CommandSpec {
|
||||
@@ -84,7 +100,7 @@ impl Interactive for AttdispM {
|
||||
/// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and
|
||||
/// shown as defined, keeping the values of attributes whose tag (any case) is still defined.
|
||||
/// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD.
|
||||
fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
pub(super) fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
let m = ins.transform(blk.base.xy());
|
||||
defs.iter()
|
||||
.map(|ad| {
|
||||
@@ -119,12 +135,16 @@ fn references<'a>(ents: impl Iterator<Item = &'a Arc<Entity>>, name: &str) -> Ve
|
||||
/// Apply `f` to every reference of block `name` (in model space, layouts and other blocks);
|
||||
/// returns how many references there were.
|
||||
fn update_references(d: &mut cadcraft_doc::Drawing, name: &str, mut f: impl FnMut(&mut Insert)) -> usize {
|
||||
let mut n = 0;
|
||||
let mut update = |e: &mut Entity| {
|
||||
update_reference_entities(d, name, |e| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind {
|
||||
f(ins);
|
||||
}
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
/// [`update_references`], with `f` given the whole object.
|
||||
pub(super) fn update_reference_entities(d: &mut cadcraft_doc::Drawing, name: &str, mut update: impl FnMut(&mut Entity)) -> usize {
|
||||
let mut n = 0;
|
||||
let spaces: Vec<cadcraft_doc::Space> =
|
||||
std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect();
|
||||
for sp in spaces {
|
||||
@@ -169,18 +189,22 @@ pub(super) fn rename_tag(s: &mut Session, name: &str, old: &str, new: &str) -> R
|
||||
}))
|
||||
}
|
||||
|
||||
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
|
||||
/// Returns the block's name as defined and how many references were updated.
|
||||
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let defs: Vec<&Attrib> = blk
|
||||
.entities
|
||||
/// The attribute definitions of `blk` that references carry (constant ones are not).
|
||||
pub(super) fn attdefs(blk: &Block) -> Vec<&Attrib> {
|
||||
blk.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(a) if !a.constant => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
|
||||
/// Returns the block's name as defined and how many references were updated.
|
||||
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let defs = attdefs(&blk);
|
||||
let d = s.doc_mut()?;
|
||||
let n = update_references(d, &blk.name, |ins| ins.attribs = synced(&blk, &defs, ins));
|
||||
Ok((blk.name.clone(), n))
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! BLOCKREPLACE and -BLOCKREPLACE (Express Tools): every reference of one block, in model space,
|
||||
//! the layouts and other blocks, becomes a reference of another block at the same insertion
|
||||
//! point, scale and rotation. The unreferenced block definition can then be purged.
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::attribs::{attdefs, synced, update_reference_entities};
|
||||
use super::purge::{override_arrows, used_blocks};
|
||||
use super::references_block;
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::machines::{SelOutcome, SelectPhase};
|
||||
use crate::cmd::{CommandSpec, bad, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn blockreplace_spec() -> CommandSpec {
|
||||
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
|
||||
.menu(&["Tools", "Express Tools", "Replace Block..."])
|
||||
.alias(&["-blockreplace"])
|
||||
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
|
||||
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
|
||||
}
|
||||
|
||||
/// The defined name of block `name` (names are case-insensitive), when it is a named block of
|
||||
/// this drawing: not anonymous and not one an xref brings in (`X|A`), which is never saved.
|
||||
fn named_block(d: &Drawing, name: &str) -> Option<String> {
|
||||
d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone())
|
||||
}
|
||||
|
||||
/// Why the references of block `old` cannot be replaced: a dynamic block's references keep their
|
||||
/// property values in data that names the block.
|
||||
fn not_replaceable(d: &Drawing, old: &str) -> Option<String> {
|
||||
cadcraft_doc::dynblock::dynamic_block(d, old).map(|_| format!("Block \"{old}\" is a dynamic block; its references cannot be replaced."))
|
||||
}
|
||||
|
||||
/// Why block `new` cannot replace block `old`: references of `old` inside `new` would make `new`
|
||||
/// reference itself.
|
||||
fn cannot_replace_with(d: &Drawing, old: &str, new: &str) -> Option<String> {
|
||||
let blk = d.block(new)?;
|
||||
references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::<Vec<_>>(), old)
|
||||
.then(|| format!("Block \"{new}\" references \"{old}\", so it cannot replace it."))
|
||||
}
|
||||
|
||||
/// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s
|
||||
/// definition when nothing references it afterwards. Returns (references replaced, purged).
|
||||
fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> {
|
||||
let cmd = "blockreplace";
|
||||
if s.state()?.block_edit.is_some() {
|
||||
return Err(bad(cmd, "the Block Editor is open; close it with BCLOSE first"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.refedit.is_some() {
|
||||
return Err(bad(cmd, "a block is being edited in place; finish with REFCLOSE first"));
|
||||
}
|
||||
if let Some(n) = [old, new].into_iter().find(|n| n.contains('|')) {
|
||||
return Err(bad(cmd, format!("Block \"{}\" comes from an xref; bind the xref first.", n.trim())));
|
||||
}
|
||||
let old = named_block(d, old).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", old.trim())))?;
|
||||
let new = named_block(d, new).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", new.trim())))?;
|
||||
if old.eq_ignore_ascii_case(&new) {
|
||||
return Err(bad(cmd, "the replacement block must be a different block"));
|
||||
}
|
||||
if let Some(why) = not_replaceable(d, &old).or_else(|| cannot_replace_with(d, &old, &new)) {
|
||||
return Err(bad(cmd, why));
|
||||
}
|
||||
let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?;
|
||||
let defs = attdefs(&blk);
|
||||
let scale = d.current_anno_scale();
|
||||
let d = s.doc_mut()?;
|
||||
let n = update_reference_entities(d, &old, |e| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind {
|
||||
ins.block.clone_from(&blk.name);
|
||||
ins.attribs = synced(&blk, &defs, ins);
|
||||
}
|
||||
// A reference of an annotative block is annotative, as INSERT makes it.
|
||||
if !blk.annotative {
|
||||
e.common.anno_scales.clear();
|
||||
} else if e.common.anno_scales.is_empty() {
|
||||
e.common.anno_scales = vec![scale.clone()];
|
||||
}
|
||||
});
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&old)).cloned();
|
||||
let purged = purge && !referenced(d, &old) && key.is_some_and(|k| d.blocks.remove(&k).is_some());
|
||||
Ok((n, purged))
|
||||
}
|
||||
|
||||
/// Whether anything still names block `name`: what PURGE counts as in use, and the dimensions,
|
||||
/// multileaders and block references of every other block definition, used or not.
|
||||
fn referenced(d: &Drawing, name: &str) -> bool {
|
||||
if used_blocks(d).contains(&name.to_ascii_uppercase()) {
|
||||
return true;
|
||||
}
|
||||
let names = |e: &Entity| -> Vec<String> {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::Dimension(dm) => dm.block.iter().cloned().chain(override_arrows(dm)).collect(),
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
d.blocks
|
||||
.values()
|
||||
.filter(|b| !b.name.eq_ignore_ascii_case(name))
|
||||
.flat_map(|b| b.entities.iter())
|
||||
.any(|e| names(e).iter().any(|n| n.trim().eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
fn run_blockreplace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "blockreplace";
|
||||
let old = str_param(p, "block").ok_or_else(|| bad(cmd, "`block` (the block to replace) is required"))?;
|
||||
let new = str_param(p, "with").ok_or_else(|| bad(cmd, "`with` (the replacement block) is required"))?;
|
||||
let purge = match p.get("purge") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(v) => return Err(bad(cmd, format!("`purge` must be true or false, not {v}"))),
|
||||
};
|
||||
let (old, new) = (old.to_string(), new.to_string());
|
||||
let (n, purged) = replace(s, &old, &new, purge)?;
|
||||
let d = s.doc()?;
|
||||
let defined = |name: &str| named_block(d, name).unwrap_or_else(|| name.trim().to_string());
|
||||
Ok(json!({ "block": defined(&old), "with": defined(&new), "replaced": n, "purged": purged }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Old,
|
||||
New,
|
||||
Select,
|
||||
Purge,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BlockreplaceM {
|
||||
stage: Stage,
|
||||
/// The stage a picked reference answers.
|
||||
back: Stage,
|
||||
sel: SelectPhase,
|
||||
old: String,
|
||||
new: String,
|
||||
}
|
||||
|
||||
impl BlockreplaceM {
|
||||
/// Take `name` as the answer to the current name prompt. A block that cannot take part is
|
||||
/// refused here, so the prompt asks again.
|
||||
fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> {
|
||||
let d = s.doc()?;
|
||||
let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?;
|
||||
if stage == Stage::Old {
|
||||
if let Some(why) = not_replaceable(d, &name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.old = name;
|
||||
self.stage = Stage::New;
|
||||
} else {
|
||||
if name.eq_ignore_ascii_case(&self.old) {
|
||||
return Err(EngineError::Other("The replacement block must be a different block.".into()));
|
||||
}
|
||||
if let Some(why) = cannot_replace_with(d, &self.old, &name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.new = name;
|
||||
self.stage = Stage::Purge;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self, s: &mut Session, purge: bool) -> Result<Step> {
|
||||
let (n, purged) = replace(s, &self.old, &self.new, purge)?;
|
||||
s.echo(format!(
|
||||
"{n} reference{} of block {} replaced with {}.{}",
|
||||
if n == 1 { "" } else { "s" },
|
||||
self.old,
|
||||
self.new,
|
||||
if purged { format!(" Block {} purged.", self.old) } else { String::new() }
|
||||
));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BlockreplaceM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BLOCKREPLACE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::Select => Prompt::new("Select a block reference", Accept::SELECT),
|
||||
Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
stage @ (Stage::Old | Stage::New) => match &i {
|
||||
Input::Keyword(k) if k == "?" => {
|
||||
let d = s.doc()?;
|
||||
let mut names: Vec<String> = d.blocks.keys().filter_map(|k| named_block(d, k)).collect();
|
||||
names.sort_by_key(|n| n.to_ascii_lowercase());
|
||||
s.echo(if names.is_empty() { "No blocks defined.".to_string() } else { format!("Blocks: {}", names.join(", ")) });
|
||||
}
|
||||
Input::Keyword(k) if k == "Select" => {
|
||||
self.back = stage;
|
||||
self.sel = SelectPhase::single();
|
||||
self.stage = Stage::Select;
|
||||
}
|
||||
Input::Text(t) if !t.trim().is_empty() => self.take(s, stage, t)?,
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
_ => {}
|
||||
},
|
||||
Stage::Select => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Empty => self.stage = self.back,
|
||||
SelOutcome::Done(hs) => {
|
||||
s.set_selection(Vec::new());
|
||||
self.sel = SelectPhase::single();
|
||||
let block = hs.iter().find_map(|h| match s.doc().ok()?.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(ins)) => Some(ins.block.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match block {
|
||||
Some(b) => self.take(s, self.back, &b)?,
|
||||
None => return Err(EngineError::Other("That object is not a block reference.".into())),
|
||||
}
|
||||
}
|
||||
},
|
||||
Stage::Purge => match &i {
|
||||
Input::Keyword(k) if k == "No" => return self.finish(s, false),
|
||||
Input::Keyword(_) | Input::Enter => return self.finish(s, true),
|
||||
Input::Cancel => return Ok(Step::Done),
|
||||
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
},
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
|
||||
}
|
||||
|
||||
/// The arrowhead block names a dimension's overrides set.
|
||||
fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
|
||||
pub(in crate::cmd) fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
|
||||
dm.overrides
|
||||
.iter()
|
||||
.filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock"))
|
||||
|
||||
@@ -420,6 +420,17 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
nb.anonymous = b.anonymous;
|
||||
nb.explodable = b.explodable;
|
||||
nb.units = b.units;
|
||||
// Array parameters keep their kind and spacing; block names follow the block renaming, and
|
||||
// a source edit in progress in the referenced file is not carried over.
|
||||
nb.array = b.array.as_ref().map(|a| {
|
||||
let mut a = a.clone();
|
||||
a.source = dep(Sym::Block, &a.source).unwrap_or(a.source);
|
||||
for r in &mut a.replaced {
|
||||
r.block = dep(Sym::Block, &r.block).unwrap_or_else(|| r.block.clone());
|
||||
}
|
||||
a.editing = None;
|
||||
a
|
||||
});
|
||||
for e in b.entities.iter() {
|
||||
let e = adopt(d, e);
|
||||
nb.entities.push(e);
|
||||
@@ -532,4 +543,35 @@ mod tests {
|
||||
assert!(!has_prefix("SITE", "site|"));
|
||||
assert!(!has_prefix("é", "ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_array_blocks_keep_their_parameters() {
|
||||
use cadcraft_doc::{ArrayKind, ArrayParams, Block, RectArray, ReplacedItem};
|
||||
let rect = RectArray { cols: 3, rows: 2, col_spacing: 5.0, row_spacing: 4.0, axis_angle: 0.0, row_elevation: 0.0 };
|
||||
let params = ArrayParams {
|
||||
source: "*U1".into(),
|
||||
kind: ArrayKind::Rect(rect),
|
||||
levels: 1,
|
||||
level_spacing: 0.0,
|
||||
replaced: vec![ReplacedItem { i: 1, j: 0, level: 0, block: "ALT".into() }],
|
||||
editing: Some(Default::default()),
|
||||
};
|
||||
let mut arr = Block::new("*U2");
|
||||
arr.array = Some(params);
|
||||
let mut x = Drawing::default();
|
||||
x.blocks.insert("*U2".into(), Arc::new(arr));
|
||||
|
||||
let mut d = Drawing::default();
|
||||
d.blocks.insert("SITE".into(), Arc::new(Block::new("SITE")));
|
||||
let mut cx = Ctx { chain: Vec::new(), objects: 0, files: 0, bytes: 0, notes: Vec::new() };
|
||||
assert!(import(&mut d, "SITE", &x, &mut cx).is_ok());
|
||||
|
||||
let got = d.blocks.get("SITE|*U2").and_then(|b| b.array.clone());
|
||||
let Some(got) = got else { panic!("array parameters were dropped") };
|
||||
assert_eq!(got.source, "SITE|*U1");
|
||||
assert_eq!(got.replaced[0].block, "SITE|ALT");
|
||||
assert!(got.editing.is_none());
|
||||
let ArrayKind::Rect(r) = got.kind else { panic!("not a rectangular array") };
|
||||
assert_eq!((r.cols, r.rows), (3, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ use cadcraft_geom::{Line, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, point_value, str_param, targets};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
macro_rules! gc {
|
||||
@@ -327,7 +328,7 @@ fn add_constraint(s: &mut Session, c: Constraint) -> Result<u32> {
|
||||
let keep: Vec<Handle> = c.refs.first().map(|r| r.handle).into_iter().collect();
|
||||
let prefer_move: Vec<Handle> = c.refs.iter().skip(1).map(|r| r.handle).filter(|h| !keep.contains(h)).collect();
|
||||
let mut d = s.doc()?.clone();
|
||||
let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move }).map_err(err)?;
|
||||
let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move, ..Default::default() }).map_err(err)?;
|
||||
*s.doc_mut()? = d;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -1119,7 +1120,7 @@ fn infer_after(s: &mut Session, before: &Drawing) {
|
||||
return;
|
||||
}
|
||||
let mut nd = d.clone();
|
||||
let opts = cs::SolveOptions { keep: others, prefer_move: placed };
|
||||
let opts = cs::SolveOptions { keep: others, prefer_move: placed, ..Default::default() };
|
||||
let added = cands.into_iter().take(200).filter(|c| cs::add(&mut nd, c.clone(), &opts).is_ok()).count();
|
||||
if added > 0
|
||||
&& let Ok(st) = s.state_mut()
|
||||
@@ -1148,7 +1149,8 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
let mut d = (*st.doc).clone();
|
||||
let purged = cs::purge_invalid(&mut d);
|
||||
let restored = cs::restore_fixed(&mut d, before);
|
||||
let satisfied = cs::all_satisfied(&d);
|
||||
let anchors = cs::moved_anchors(&d, before);
|
||||
let satisfied = anchors.is_empty() && cs::all_satisfied(&d);
|
||||
if purged.is_empty() && !restored && satisfied {
|
||||
return;
|
||||
}
|
||||
@@ -1167,9 +1169,25 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
keep.push(h);
|
||||
}
|
||||
}
|
||||
let mut trial = d.clone();
|
||||
match cs::solve(&mut trial, &cs::SolveOptions { keep, prefer_move: Vec::new() }) {
|
||||
Ok(_) => d = trial,
|
||||
// A grip drag moved some of an object's points: they stay where they were put, and the
|
||||
// edited objects still move least. When that cannot be solved, the edited objects are
|
||||
// only kept as a whole, as for any other edit.
|
||||
let hold: Vec<GeomRef> = keep.iter().flat_map(|&h| cs::edited_points(&d, before, h)).collect();
|
||||
let mut solved = None;
|
||||
if !hold.is_empty() {
|
||||
let mut trial = d.clone();
|
||||
let opts = cs::SolveOptions { keep: keep.clone(), prefer_move: Vec::new(), hold, anchors: anchors.clone() };
|
||||
solved = cs::solve(&mut trial, &opts).ok().map(|_| trial);
|
||||
}
|
||||
let solved = match solved {
|
||||
Some(t) => Ok(t),
|
||||
None => {
|
||||
let mut trial = d.clone();
|
||||
cs::solve(&mut trial, &cs::SolveOptions { keep, anchors, ..Default::default() }).map(|_| trial)
|
||||
}
|
||||
};
|
||||
match solved {
|
||||
Ok(t) => d = t,
|
||||
Err(e) => msg.push(format!("Constraints could not be satisfied: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
|
||||
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::*;
|
||||
use super::helpers::{arc, circle, line, lwpoly, polygon_from_edge, polygon_typed_radius_point, polygon_vertices, v3};
|
||||
|
||||
use super::machines::number;
|
||||
use super::pline_opts::{Outcome, PlineAsk};
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, bad, bool_or, distinct_points, f64_or, f64_req, find_command, point_param, point_req, point_value, points_param, size_param,
|
||||
str_param,
|
||||
};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap};
|
||||
|
||||
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
|
||||
|
||||
@@ -7,9 +7,11 @@ use cadcraft_geom::{Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Spline, TA
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::curves::{self, Chain, MAX_GEN, TanObj};
|
||||
use super::helpers::*;
|
||||
use super::helpers::{arc, circle, line, lwpoly, rect_vertices, rect_with_corners, v3};
|
||||
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, point_value, points_param, size_param, str_param, targets};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(crate) mod breakline;
|
||||
@@ -1083,7 +1085,9 @@ impl Interactive for RevcloudM {
|
||||
let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"];
|
||||
match (self.kind, self.pts.len()) {
|
||||
(CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"),
|
||||
(CloudKind::Rect, _) => Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()),
|
||||
(CloudKind::Rect, _) => {
|
||||
Prompt { corner: true, ..Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()) }
|
||||
}
|
||||
(CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"),
|
||||
(CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()),
|
||||
(CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
|
||||
@@ -1631,10 +1635,9 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a };
|
||||
let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let hd = s.add_entity(k)?;
|
||||
// A rectangle has its own thickness option and does not take THICKNESS.
|
||||
let th = f64_or(p, "thickness", 0.0);
|
||||
if th != 0.0 {
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
|
||||
}
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
|
||||
added(hd)
|
||||
}
|
||||
|
||||
@@ -1683,10 +1686,8 @@ impl RectM2 {
|
||||
}
|
||||
let first = self.origin(w, h).unwrap_or(a);
|
||||
let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
|
||||
if self.thickness != 0.0 {
|
||||
let t = self.thickness;
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
|
||||
}
|
||||
let t = self.thickness;
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
/// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first
|
||||
|
||||
@@ -9,9 +9,9 @@ use cadcraft_doc::{Drawing, EntityKind};
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::super::machines::number;
|
||||
use super::added;
|
||||
use super::curves::MAX_GEN;
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use crate::cmd::modify2::as_lwpoly;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
@@ -9,8 +9,10 @@ use cadcraft_doc::{
|
||||
use cadcraft_geom::{Line, Vec2, line_line_infinite};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::distinct_points;
|
||||
use super::super::machines::number;
|
||||
use super::super::ok;
|
||||
use super::curves::{self, MAX_GEN};
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.key("Cmd+X")
|
||||
.params("{handles?}")
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("cutclip", "CUTCLIP")))),
|
||||
CommandSpec::new("cutbase", "Cut with Base Point", run_cutbase)
|
||||
.menu(&["Edit", "Cut with Base Point"])
|
||||
.params("{handles?, base: [x,y]}")
|
||||
.interactive(|_| Ok(Box::new(CopyBaseM { cut: true, ..CopyBaseM::default() }))),
|
||||
CommandSpec::new("copyclip", "Copy", run_copyclip)
|
||||
.menu(&["Edit", "Copy"])
|
||||
.key("Cmd+C")
|
||||
@@ -434,9 +438,11 @@ impl Interactive for MredoM {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the objects of `hs` to the clipboard, each once however often `hs` lists it.
|
||||
fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<usize> {
|
||||
let d = s.doc()?;
|
||||
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let ents: Vec<Entity> = hs.iter().filter(|h| seen.insert(**h)).filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0)));
|
||||
let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO });
|
||||
let n = ents.len();
|
||||
@@ -451,22 +457,59 @@ fn run_copyclip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_copybase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = point_req("copybase", p, "base")?;
|
||||
let hs = targets(s, p)?;
|
||||
let base = point_param(p, "base");
|
||||
let n = copy_to_clip(s, &hs, base)?;
|
||||
let n = copy_to_clip(s, &hs, Some(base))?;
|
||||
Ok(json!({ "copied": n }))
|
||||
}
|
||||
|
||||
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut hs = targets(s, p)?;
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let n = copy_to_clip(s, &hs, None)?;
|
||||
/// Copy the objects that are not on locked layers to the clipboard and erase them. Returns how
|
||||
/// many were cut and the message to show: how many were on a locked layer, and that nothing was
|
||||
/// cut when that is all of them (the clipboard then keeps what it held).
|
||||
fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<(usize, Option<String>)> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let live: Vec<Handle> = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().copied().filter(|h| seen.insert(*h) && d.entity(*h).is_some()).collect()
|
||||
};
|
||||
let (hs, locked): (Vec<Handle>, Vec<Handle>) = live.into_iter().partition(|h| !super::curves::is_locked(s, *h));
|
||||
let note = match locked.len() {
|
||||
0 => None,
|
||||
1 => Some("1 object was on a locked layer.".to_string()),
|
||||
n => Some(format!("{n} objects were on a locked layer.")),
|
||||
};
|
||||
if hs.is_empty() {
|
||||
let msg = note.map_or_else(|| "Nothing was cut.".to_string(), |m| format!("{m} Nothing was cut."));
|
||||
return Ok((0, Some(msg)));
|
||||
}
|
||||
let n = copy_to_clip(s, &hs, base)?;
|
||||
let d = s.doc_mut()?;
|
||||
for h in &hs {
|
||||
d.remove_entity(*h);
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(json!({ "cut": n }))
|
||||
Ok((n, note))
|
||||
}
|
||||
|
||||
fn cut_result(n: usize, msg: Option<String>) -> Value {
|
||||
let mut r = json!({ "cut": n });
|
||||
if let Some(m) = msg {
|
||||
r["message"] = json!(m);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let (n, msg) = cut_to_clip(s, &hs, None)?;
|
||||
Ok(cut_result(n, msg))
|
||||
}
|
||||
|
||||
fn run_cutbase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = point_req("cutbase", p, "base")?;
|
||||
let hs = targets(s, p)?;
|
||||
let (n, msg) = cut_to_clip(s, &hs, Some(base))?;
|
||||
Ok(cut_result(n, msg))
|
||||
}
|
||||
|
||||
/// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its
|
||||
@@ -649,23 +692,31 @@ impl Interactive for SelectM {
|
||||
}
|
||||
}
|
||||
|
||||
/// COPYBASE: base point first, then "Select objects:" (skipped with a pickfirst selection).
|
||||
/// COPYBASE and CUTBASE: base point first, then "Select objects:" (skipped with a pickfirst
|
||||
/// selection). CUTBASE also erases the objects it copies.
|
||||
#[derive(Default)]
|
||||
struct CopyBaseM {
|
||||
base: Option<Vec2>,
|
||||
sel: SelectPhase,
|
||||
cut: bool,
|
||||
}
|
||||
|
||||
impl CopyBaseM {
|
||||
fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
copy_to_clip(s, hs, self.base)?;
|
||||
if self.cut {
|
||||
if let (_, Some(m)) = cut_to_clip(s, hs, self.base)? {
|
||||
s.echo(m);
|
||||
}
|
||||
} else {
|
||||
copy_to_clip(s, hs, self.base)?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for CopyBaseM {
|
||||
fn name(&self) -> &'static str {
|
||||
"COPYBASE"
|
||||
if self.cut { "CUTBASE" } else { "COPYBASE" }
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
|
||||
@@ -362,6 +362,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(opened(s, i, false))
|
||||
}
|
||||
|
||||
/// `close [index]`: cancel the running command, remove the drawing (default: active), keep the active index valid, and bump the document epoch; an unknown index is an error.
|
||||
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active);
|
||||
if i >= s.docs.len() {
|
||||
@@ -376,13 +377,16 @@ fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
s.bump_doc_epoch();
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `closeall`: cancel the running command, remove every drawing, reset the active index to 0 and bump the document epoch.
|
||||
fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.cancel();
|
||||
s.docs.clear();
|
||||
s.active = 0;
|
||||
s.bump_doc_epoch();
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -487,6 +491,7 @@ fn drawing_version(s: &Session, path: &str) -> Version {
|
||||
if is_dxf(path) { v } else { v.max(Version::R2004) }
|
||||
}
|
||||
|
||||
/// `document.switch index`: require the index, cancel the running command, activate that drawing and bump the epoch even if it was already active (screenshot identity).
|
||||
fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.switch", "`index` is required"))? as usize;
|
||||
if i >= s.docs.len() {
|
||||
@@ -494,6 +499,11 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.cancel();
|
||||
s.active = i;
|
||||
// Bump even when `i` is already active. This is intentionally conservative: the command
|
||||
// cancels the active command, and a screenshot client can only see observed state, not
|
||||
// whether the switch was a no-op. Rejecting an outstanding capture is safer than returning
|
||||
// an image whose queueing identity the client cannot distinguish from a real switch.
|
||||
s.bump_doc_epoch();
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! drawing). Objects are matched by geometry and properties first (preferring the same handle),
|
||||
//! then by handle: the same handle with other geometry or properties is a change. What is left
|
||||
//! is only in this drawing or only in the other. The comparison stays on the session for the
|
||||
//! canvas overlay and next/previous navigation until COMPARECLOSE.
|
||||
//! canvas overlay, next/previous navigation and COMPAREEXPORT until COMPARECLOSE.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -12,10 +12,13 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space,
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::super::{CommandSpec, bad, str_param};
|
||||
|
||||
use super::{base64_decode, io};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod export;
|
||||
|
||||
/// Objects compared per drawing; more are left out (and the result says so).
|
||||
pub const MAX_OBJECTS: usize = 200_000;
|
||||
/// Objects listed per kind of difference in the JSON result.
|
||||
@@ -36,6 +39,9 @@ pub struct Comparison {
|
||||
pub other_name: String,
|
||||
/// The other drawing (its objects are drawn where they differ).
|
||||
pub other: Arc<Drawing>,
|
||||
/// The other drawing's file, when it has one: COMPAREEXPORT resolves its relative xref
|
||||
/// paths from that file's folder.
|
||||
pub other_path: Option<String>,
|
||||
/// Only in this drawing (handles here).
|
||||
pub added: Vec<Handle>,
|
||||
/// Only in the other drawing (handles there).
|
||||
@@ -71,6 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("compare.next", "Next Change", |s, _| step(s, 1)).params("→ {region, of}: zoom to the next area with changes").noundo(),
|
||||
CommandSpec::new("compare.previous", "Previous Change", |s, _| step(s, -1)).params("→ {region, of}").noundo(),
|
||||
CommandSpec::new("compareclose", "Close Comparison", run_close).params("{}: end the comparison and its overlay").noundo(),
|
||||
export::spec(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -133,11 +140,23 @@ struct Signer<'a> {
|
||||
d: &'a Drawing,
|
||||
blocks: HashMap<String, u64>,
|
||||
work: usize,
|
||||
/// When set, a block's signature also covers its base point, units, annotative and overlay
|
||||
/// flags and xref file, with relative xref, image and data link paths taken from the folder
|
||||
/// given, and the
|
||||
/// signatures of a multileader and a dimension cover their blocks'. Each object in a block
|
||||
/// then also counts with its visibility, transparency, thickness and extrusion, and an
|
||||
/// anonymous block's signature leaves out its name (a dimension also leaves its block's name
|
||||
/// out of its own geometry; an INSERT or MLEADER keeps it).
|
||||
frames: Option<Option<&'a std::path::Path>>,
|
||||
}
|
||||
|
||||
impl<'a> Signer<'a> {
|
||||
fn new(d: &'a Drawing) -> Self {
|
||||
Signer { d, blocks: HashMap::new(), work: 0 }
|
||||
Signer { d, blocks: HashMap::new(), work: 0, frames: None }
|
||||
}
|
||||
/// A signer whose block signatures also cover what changes how a reference draws the block.
|
||||
fn with_frames(d: &'a Drawing, folder: Option<&'a std::path::Path>) -> Self {
|
||||
Signer { frames: Some(folder), ..Signer::new(d) }
|
||||
}
|
||||
fn geometry(&mut self, k: &EntityKind, depth: usize) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -156,6 +175,18 @@ impl<'a> Signer<'a> {
|
||||
if let EntityKind::Insert(i) = k {
|
||||
self.block(&i.block, depth).hash(&mut h);
|
||||
}
|
||||
if self.frames.is_some()
|
||||
&& let EntityKind::MLeader(m) = k
|
||||
&& let Some(b) = &m.block
|
||||
{
|
||||
self.block(&b.block, depth).hash(&mut h);
|
||||
}
|
||||
if self.frames.is_some()
|
||||
&& let EntityKind::Dimension(dm) = k
|
||||
&& let Some(b) = &dm.block
|
||||
{
|
||||
self.block(b, depth).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
fn block(&mut self, name: &str, depth: usize) -> u64 {
|
||||
@@ -164,17 +195,42 @@ impl<'a> Signer<'a> {
|
||||
return *v;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut h);
|
||||
let block = self.d.block(name);
|
||||
// With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the
|
||||
// same block can have another generated name in another drawing. Only a dimension's
|
||||
// geometry leaves the name out, so this matches renamed `*D` blocks, not `*U` references.
|
||||
if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) {
|
||||
key.hash(&mut h);
|
||||
}
|
||||
if depth < MAX_BLOCK_DEPTH
|
||||
&& let Some(b) = self.d.block(name)
|
||||
&& let Some(b) = block
|
||||
{
|
||||
if let Some(folder) = self.frames {
|
||||
let mut xref = b.xref_path.clone();
|
||||
if let Some(x) = xref.as_mut() {
|
||||
export::rebase_path(x, folder);
|
||||
}
|
||||
(format!("{:?}", b.base), b.units, b.annotative, b.xref_overlay, xref).hash(&mut h);
|
||||
}
|
||||
for e in b.entities.iter() {
|
||||
if self.work >= MAX_BLOCK_WORK {
|
||||
break;
|
||||
}
|
||||
self.work += 1;
|
||||
self.geometry(&e.kind, depth + 1).hash(&mut h);
|
||||
match self.frames {
|
||||
// An image's file and a table's data link, from the folder given.
|
||||
Some(folder) if matches!(e.kind, EntityKind::Image(_) | EntityKind::Table(_)) => {
|
||||
let mut k = e.kind.clone();
|
||||
export::rebase(&mut k, folder);
|
||||
self.geometry(&k, depth + 1).hash(&mut h);
|
||||
}
|
||||
_ => self.geometry(&e.kind, depth + 1).hash(&mut h),
|
||||
}
|
||||
properties(e).hash(&mut h);
|
||||
if self.frames.is_some() {
|
||||
let c = &e.common;
|
||||
format!("{:?}|{}|{}|{:?}", c.transparency, c.visible, (c.thickness * 1e6).round(), c.extrusion).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = h.finish();
|
||||
@@ -311,6 +367,7 @@ fn listed(d: &Drawing, hs: &[Handle]) -> Vec<Value> {
|
||||
|
||||
fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (other, name) = other_drawing(s, p)?;
|
||||
let other_path = export::source_path(s, p, &other);
|
||||
let st = s.state()?;
|
||||
let (uid, space, cur) = (st.uid, st.space.clone(), st.doc.clone());
|
||||
if Arc::ptr_eq(&cur, &other) {
|
||||
@@ -361,7 +418,7 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"message": message,
|
||||
});
|
||||
let changed = changed.into_iter().map(|(a, b, _, _)| (a, b)).collect();
|
||||
s.compare = Some(Comparison { uid, space, other_name: name, other, added, removed, changed, common, truncated, regions, at: None });
|
||||
s.compare = Some(Comparison { uid, space, other_name: name, other, other_path, added, removed, changed, common, truncated, regions, at: None });
|
||||
s.touch();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
//! COMPAREEXPORT: the comparison as a new drawing (a snapshot). The drawings are compared again
|
||||
//! when the command runs, so edits made since COMPARE count. The new drawing's model space holds
|
||||
//! copies of the other drawing's objects that differ, then copies of the current drawing's
|
||||
//! objects in the compared tab, as two groups, `COMPARE_COMPARED` and `COMPARE_CURRENT`. Objects
|
||||
//! only in this drawing or changed in it are drawn in the overlay's green, objects only in the
|
||||
//! other drawing or changed there in its red, and objects in both in gray (color 8). A block
|
||||
//! reference, dimension or multileader refers to a copy of its block whose objects all have that
|
||||
//! color (`COMPARE_CURRENT_<name>`, `COMPARE_COMPARED_<name>`, `COMPARE_COMMON_<name>`; for a
|
||||
//! dimension's `*D` block, another `*D` block), and a table or tolerance uses a recolored copy
|
||||
//! of its style; xrefs keep their own colors. The new
|
||||
//! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other
|
||||
//! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of
|
||||
//! the other drawing whose name this one uses is copied under a free name. Relative xref, image
|
||||
//! and data link paths become absolute paths from the folder of the drawing they came from. It
|
||||
//! opens untitled as `compare_<this> vs <other>.dwg`, with unsaved changes; saving it asks for a
|
||||
//! file name.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad};
|
||||
use super::{Comparison, MAX_OBJECTS};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Only in this drawing, or changed in it (the overlay's color).
|
||||
const THIS: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b));
|
||||
/// Only in the other drawing, or changed there.
|
||||
const OTHER: Color = Color::True(Rgb(0xff, 0x5c, 0x5c));
|
||||
/// In both drawings.
|
||||
const COMMON: Color = Color::Index(8);
|
||||
/// Block definitions copied from the other drawing (the ones they reference included), and
|
||||
/// recolored copies of blocks and styles, per export.
|
||||
const MAX_BLOCKS: usize = 10_000;
|
||||
/// Objects copied into block definitions (the other drawing's blocks and the recolored
|
||||
/// copies), per export.
|
||||
const MAX_COPIED: usize = 1_000_000;
|
||||
|
||||
pub(super) fn spec() -> CommandSpec {
|
||||
CommandSpec::new("compareexport", "Export Comparison", run)
|
||||
.params(
|
||||
"{} → {index, title, current, compared: group names, counts: {current, compared}, truncated, message}: the comparison \
|
||||
as a new drawing, opened untitled",
|
||||
)
|
||||
.noundo()
|
||||
}
|
||||
|
||||
/// What [`snapshot`] made: the drawing, the two group names, how many objects each holds,
|
||||
/// whether a drawing had more objects than were compared, whether the other drawing had more
|
||||
/// blocks to copy than [`MAX_BLOCKS`], and whether a block was left in its own colors.
|
||||
struct Snapshot {
|
||||
drawing: Drawing,
|
||||
current: String,
|
||||
compared: String,
|
||||
counts: (usize, usize),
|
||||
truncated: bool,
|
||||
blocks_left_out: bool,
|
||||
uncolored: bool,
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.state()?;
|
||||
let (uid, cur, title, cur_folder) = (st.uid, st.doc.clone(), st.title.clone(), folder_of(st.path.as_deref()));
|
||||
let c = s.compare.as_ref().filter(|c| c.uid == uid).ok_or_else(|| bad("compareexport", "no comparison: run COMPARE first"))?;
|
||||
let name = format!("compare_{} vs {}.dwg", stem(&title), stem(&c.other_name));
|
||||
let folders = Folders { here: cur_folder, there: folder_of(c.other_path.as_deref()) };
|
||||
let Snapshot { drawing, current, compared, counts, truncated, blocks_left_out, uncolored } = snapshot(&cur, c, &folders);
|
||||
let i = s.open_drawing(drawing, &name, None);
|
||||
// Not saved yet: closing it asks to save it under a name.
|
||||
let st = s.state_mut()?;
|
||||
st.saved = Arc::new((*st.doc).clone());
|
||||
let mut notes = Vec::new();
|
||||
if truncated {
|
||||
notes.push(format!("only the first {MAX_OBJECTS} objects of each drawing were compared"));
|
||||
}
|
||||
if blocks_left_out {
|
||||
notes.push("some blocks of the other drawing were too many or too large to copy".to_string());
|
||||
}
|
||||
if uncolored {
|
||||
notes.push("some blocks keep their own colors".to_string());
|
||||
}
|
||||
let notes = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) };
|
||||
Ok(json!({
|
||||
"index": i,
|
||||
"title": name,
|
||||
"current": current,
|
||||
"compared": compared,
|
||||
"counts": { "current": counts.0, "compared": counts.1 },
|
||||
"truncated": truncated || blocks_left_out || uncolored,
|
||||
"message": format!("The comparison was exported to the new drawing {name}{notes}."),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The file of the drawing COMPARE compares with: the `path` it was read from, or the open
|
||||
/// drawing's own file. `None` for `{data}` and for an open drawing never saved.
|
||||
pub(super) fn source_path(s: &Session, p: &Value, other: &Arc<Drawing>) -> Option<String> {
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) if p.get("document").is_none_or(Value::is_null) => Some(path.to_string()),
|
||||
_ => s.docs.iter().find(|d| Arc::ptr_eq(&d.doc, other)).and_then(|d| d.path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders of the two drawings' files. The snapshot has no folder of its own, so the
|
||||
/// relative file paths it copies (xrefs, images, table data links) become absolute paths from
|
||||
/// the folder of the drawing they came from.
|
||||
struct Folders {
|
||||
here: Option<PathBuf>,
|
||||
there: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The absolute folder of the file at `path`.
|
||||
fn folder_of(path: Option<&str>) -> Option<PathBuf> {
|
||||
let parent = Path::new(path?).parent()?;
|
||||
let parent = if parent.as_os_str().is_empty() { Path::new(".") } else { parent };
|
||||
Some(std::path::absolute(parent).unwrap_or_else(|_| parent.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `p` as an absolute path from `folder` when it is relative.
|
||||
pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) {
|
||||
if let Some(folder) = folder
|
||||
&& !p.trim().is_empty()
|
||||
&& Path::new(p.as_str()).is_relative()
|
||||
{
|
||||
let joined = folder.join(p.as_str());
|
||||
*p = std::path::absolute(&joined).unwrap_or(joined).to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebase the file an object refers to: an image's file, a table's data link.
|
||||
pub(super) fn rebase(k: &mut EntityKind, folder: Option<&Path>) {
|
||||
match k {
|
||||
EntityKind::Image(i) => rebase_path(&mut i.path, folder),
|
||||
EntityKind::Table(t) => {
|
||||
if let Some(l) = t.data_link.as_mut() {
|
||||
rebase_path(&mut l.path, folder);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebase a block's xref path and the files its objects refer to.
|
||||
fn rebase_block(b: &mut Block, folder: Option<&Path>) {
|
||||
if let Some(x) = b.xref_path.as_mut() {
|
||||
rebase_path(x, folder);
|
||||
}
|
||||
let mut store = EntityStore::new();
|
||||
for e in b.entities.iter() {
|
||||
let mut e = (**e).clone();
|
||||
rebase(&mut e.kind, folder);
|
||||
store.push(e);
|
||||
}
|
||||
b.entities = store;
|
||||
}
|
||||
|
||||
/// Whether a block holds a relative file path that [`rebase_block`] would change.
|
||||
fn has_relative_paths(b: &Block) -> bool {
|
||||
let rel = |p: &str| !p.trim().is_empty() && Path::new(p).is_relative();
|
||||
b.xref_path.as_deref().is_some_and(rel)
|
||||
|| b.entities.iter().any(|e| match &e.kind {
|
||||
EntityKind::Image(i) => rel(&i.path),
|
||||
EntityKind::Table(t) => t.data_link.as_ref().is_some_and(|l| rel(&l.path)),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A drawing title without its file extension.
|
||||
fn stem(title: &str) -> String {
|
||||
std::path::Path::new(title).file_stem().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| title.to_string())
|
||||
}
|
||||
|
||||
fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
let other: &Drawing = &c.other;
|
||||
let mut d = cur.clone();
|
||||
if folders.here.is_some() {
|
||||
for b in d.blocks.values_mut().filter(|b| has_relative_paths(b)) {
|
||||
rebase_block(Arc::make_mut(b), folders.here.as_deref());
|
||||
}
|
||||
}
|
||||
d.model = EntityStore::new();
|
||||
for l in &mut d.layouts {
|
||||
l.entities = EntityStore::new();
|
||||
}
|
||||
d.foreign = Arc::default();
|
||||
d.groups.clear();
|
||||
d.constraints.clear();
|
||||
// The per-object constraint bar and dimension exceptions name objects the copy doesn't have.
|
||||
d.parametric.settings.bar_exceptions.clear();
|
||||
d.parametric.settings.dim_exceptions.clear();
|
||||
d.hidden_objects.clear();
|
||||
d.read_notes.clear();
|
||||
d.refedit = None;
|
||||
d.skipped_records.clear();
|
||||
d.handseed = d.handseed.max(other.handseed);
|
||||
|
||||
merge(&mut d.layers, &other.layers, |t| &t.name);
|
||||
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
|
||||
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
|
||||
merge(&mut d.dim_styles, &other.dim_styles, |t| &t.name);
|
||||
merge(&mut d.mleader_styles, &other.mleader_styles, |t| &t.name);
|
||||
merge(&mut d.table_styles, &other.table_styles, |t| &t.name);
|
||||
merge(&mut d.mline_styles, &other.mline_styles, |t| &t.name);
|
||||
|
||||
// Compared again, so edits made since COMPARE ran are classified as they are now.
|
||||
let diff = super::diff(cur, other, &c.space);
|
||||
let ours: HashSet<Handle> = diff.added.iter().copied().chain(diff.changed.iter().map(|(a, ..)| *a)).collect();
|
||||
let here: Vec<(&Entity, Color)> = cur
|
||||
.space(&c.space)
|
||||
.into_iter()
|
||||
.flat_map(EntityStore::iter)
|
||||
.filter(|e| !matches!(e.kind, EntityKind::Viewport(_)))
|
||||
.take(MAX_OBJECTS)
|
||||
.map(|e| (e.as_ref(), if ours.contains(&e.handle) { THIS } else { COMMON }))
|
||||
.collect();
|
||||
// In the other drawing's draw order.
|
||||
let theirs: HashSet<Handle> = diff.removed.iter().copied().chain(diff.changed.iter().map(|(_, b, ..)| *b)).collect();
|
||||
let there: Vec<(&Entity, Color)> = other
|
||||
.space(&c.space)
|
||||
.into_iter()
|
||||
.flat_map(EntityStore::iter)
|
||||
.filter(|e| theirs.contains(&e.handle) && !matches!(e.kind, EntityKind::Viewport(_)))
|
||||
.map(|e| (e.as_ref(), OTHER))
|
||||
.collect();
|
||||
let mut tinted = Tinted::default();
|
||||
let (renames, blocks_left_out) = copy_blocks(&mut d, other, there.iter().map(|(e, _)| *e), folders.there.as_deref(), &mut tinted);
|
||||
|
||||
// The other drawing's objects first, so this drawing's are drawn over them.
|
||||
let compared = add_group(&mut d, "COMPARE_COMPARED", &there, &renames, folders.there.as_deref(), &mut tinted);
|
||||
let current = add_group(&mut d, "COMPARE_CURRENT", &here, &Renames::new(), folders.here.as_deref(), &mut tinted);
|
||||
let uncolored = tinted.uncolored;
|
||||
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
|
||||
}
|
||||
|
||||
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
|
||||
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
|
||||
for t in from {
|
||||
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
|
||||
to.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add copies of `objs` to model space with new handles, their block references renamed by
|
||||
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
|
||||
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
|
||||
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
|
||||
let mut members = Vec::new();
|
||||
for (e, color) in objs {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
let h = d.new_handle();
|
||||
let mut copy = Entity { handle: h, ..(*e).clone() };
|
||||
detach(&mut copy.kind);
|
||||
rebase(&mut copy.kind, folder);
|
||||
rename_refs(&mut copy.kind, renames);
|
||||
paint(d, &mut copy, *color, tinted, 0);
|
||||
d.model.push(copy);
|
||||
members.push(h);
|
||||
}
|
||||
let name = free_name(name, d.groups.len(), |n| d.groups.iter().any(|g| g.name.eq_ignore_ascii_case(n)));
|
||||
if !members.is_empty() {
|
||||
d.groups.push(Group { name: name.clone(), description: String::new(), selectable: true, members });
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Drop the links of an associative dimension or hatch to the objects it follows: the copies
|
||||
/// have new handles, so the links would name objects the snapshot does not have.
|
||||
fn detach(k: &mut EntityKind) {
|
||||
match k {
|
||||
EntityKind::Dimension(dm) => dm.assoc.clear(),
|
||||
EntityKind::Hatch(h) => {
|
||||
h.associative = false;
|
||||
h.loops.iter_mut().for_each(|l| l.sources.clear());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The block names an object refers to.
|
||||
fn block_refs(k: &EntityKind) -> Vec<String> {
|
||||
match k {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::Dimension(dm) => dm.block.iter().cloned().collect(),
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` on each block name an object refers to.
|
||||
fn each_ref(k: &mut EntityKind, mut f: impl FnMut(&mut String)) {
|
||||
match k {
|
||||
EntityKind::Insert(i) => f(&mut i.block),
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Some(n) = dm.block.as_mut() {
|
||||
f(n);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(b) = m.block.as_mut() {
|
||||
f(&mut b.block);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the block references of an object at the names its blocks were copied under.
|
||||
fn rename_refs(k: &mut EntityKind, renames: &Renames) {
|
||||
each_ref(k, |n| {
|
||||
if let Some(new) = renames.get(&n.to_ascii_uppercase()) {
|
||||
n.clone_from(new);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Recolored copies of blocks and styles made so far, and whether a block was left in its own
|
||||
/// colors because the nesting or copy limit was reached.
|
||||
#[derive(Default)]
|
||||
struct Tinted {
|
||||
/// (comparison tag, "block", "table style" or "dimension style", upper-case name) → the
|
||||
/// copy's name.
|
||||
names: HashMap<(&'static str, &'static str, String), String>,
|
||||
uncolored: bool,
|
||||
/// Objects copied into block definitions so far, counted against [`MAX_COPIED`].
|
||||
copied: usize,
|
||||
next: NextFree,
|
||||
/// The drawing's block names in upper case, filled on the first block copy. Only
|
||||
/// [`tinted_block`] adds blocks after that, and it adds their names here too.
|
||||
blocks: Option<HashSet<String>>,
|
||||
}
|
||||
|
||||
/// The next index to try for each name base. Names are only added while a snapshot is built,
|
||||
/// so an index found taken stays taken, and each search starts where the last one for the same
|
||||
/// base stopped: a name costs O(1) amortised instead of a scan from 1.
|
||||
#[derive(Default)]
|
||||
struct NextFree(HashMap<(&'static str, String), usize>);
|
||||
|
||||
impl NextFree {
|
||||
/// The first of `name(i)` for `i` from 1 that `taken` doesn't hold, skipping the indices
|
||||
/// earlier searches for `base` in namespace `kind` passed. `n` is how many names there are,
|
||||
/// so one of `name(1)`…`name(n + 1)` is free. The caller adds the returned name.
|
||||
fn find(&mut self, kind: &'static str, base: &str, n: usize, name: impl Fn(usize) -> String, taken: impl Fn(&str) -> bool) -> String {
|
||||
let key = (kind, base.to_ascii_uppercase());
|
||||
let start = self.0.get(&key).copied().unwrap_or(1);
|
||||
let i = (start..=n.saturating_add(1)).find(|&i| !taken(&name(i))).unwrap_or_else(|| n.saturating_add(2).max(start));
|
||||
self.0.insert(key, i.saturating_add(1));
|
||||
name(i)
|
||||
}
|
||||
}
|
||||
|
||||
/// `base` for 1, `base_2`, `base_3`… after it.
|
||||
fn suffixed(base: &str, i: usize) -> String {
|
||||
if i == 1 { base.to_string() } else { format!("{base}_{i}") }
|
||||
}
|
||||
|
||||
/// The word a comparison color adds to the names of its recolored copies.
|
||||
fn tag(color: Color) -> &'static str {
|
||||
if color == THIS {
|
||||
"CURRENT"
|
||||
} else if color == OTHER {
|
||||
"COMPARED"
|
||||
} else {
|
||||
"COMMON"
|
||||
}
|
||||
}
|
||||
|
||||
/// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are.
|
||||
fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String {
|
||||
(1..=n + 1).map(|i| suffixed(base, i)).find(|c| !taken(c)).unwrap_or_else(|| suffixed(base, n + 2))
|
||||
}
|
||||
|
||||
/// Give an object its comparison color: its own color, its attributes' colors, and, through
|
||||
/// recolored copies of the blocks and styles it refers to, the color of everything they draw.
|
||||
fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, depth: usize) {
|
||||
e.common.color = color;
|
||||
let attribs = match &mut e.kind {
|
||||
EntityKind::Insert(i) => Some(&mut i.attribs),
|
||||
EntityKind::MLeader(m) => m.block.as_mut().map(|b| &mut b.attribs),
|
||||
_ => None,
|
||||
};
|
||||
for a in attribs.into_iter().flatten() {
|
||||
a.props.color = Some(color);
|
||||
if let Some(ml) = a.multiline.as_mut() {
|
||||
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
|
||||
}
|
||||
}
|
||||
// Colors an object carries apart from its own: inline MTEXT colors (in multiline attributes
|
||||
// too), a dimension style's DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element
|
||||
// colors, the style colors of a table or a tolerance (through a recolored copy of the style)
|
||||
// and inline cell colors. Hatch backgrounds, multiline fills and table cell fills are left out.
|
||||
match &mut e.kind {
|
||||
EntityKind::MText(t) => t.contents = without_color_codes(&t.contents),
|
||||
EntityKind::AttDef(a) => {
|
||||
if let Some(ml) = a.multiline.as_mut() {
|
||||
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = m.text.as_mut() {
|
||||
t.contents = without_color_codes(&t.contents);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Ok(by_block) = serde_json::to_value(Color::ByBlock) {
|
||||
for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] {
|
||||
dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var));
|
||||
dm.overrides.insert(var.into(), by_block.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Tolerance(t) => {
|
||||
if let Some(style) = tinted_dim_style(d, &t.style, color, tinted) {
|
||||
t.style = style;
|
||||
}
|
||||
}
|
||||
EntityKind::Hatch(h) => {
|
||||
if let Some(g) = h.gradient.as_mut() {
|
||||
(g.color1, g.color2) = (color, color);
|
||||
}
|
||||
h.background = None;
|
||||
}
|
||||
EntityKind::MLine(m) => {
|
||||
for el in &mut m.def.elements {
|
||||
el.color = Color::ByBlock;
|
||||
}
|
||||
m.def.fill = None;
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
for cell in t.cells.iter_mut().flatten() {
|
||||
cell.text = without_color_codes(&cell.text);
|
||||
}
|
||||
if let Some(style) = tinted_table_style(d, &t.style, color, tinted) {
|
||||
t.style = style;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
// A dimension whose block can't be copied (past the limits) is drawn again from its
|
||||
// geometry: its DIMCLR* overrides are ByBlock, so the original block would keep its colors.
|
||||
dm.block = dm.block.take().and_then(|n| tinted_block(d, &n, color, tinted, depth));
|
||||
return;
|
||||
}
|
||||
each_ref(&mut e.kind, |n| {
|
||||
if let Some(copy) = tinted_block(d, n, color, tinted, depth) {
|
||||
*n = copy;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The name of a copy of dimension style `name` that draws lines and text in the object's
|
||||
/// color, made on first use. `None` when the drawing has no such style.
|
||||
fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
|
||||
let key = (tag(color), "dimension style", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
|
||||
let base = format!("COMPARE_{}_{}", tag(color), st.name);
|
||||
let new = tinted.next.find(
|
||||
"dimension style",
|
||||
&base,
|
||||
d.dim_styles.len(),
|
||||
|i| suffixed(&base, i),
|
||||
|n| d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
|
||||
);
|
||||
d.dim_styles.push(DimStyle {
|
||||
name: new.clone(),
|
||||
dim_line_color: Color::ByBlock,
|
||||
ext_line_color: Color::ByBlock,
|
||||
text_color: Color::ByBlock,
|
||||
..st
|
||||
});
|
||||
tinted.names.insert(key, new.clone());
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// The name of a copy of table style `name` whose cell styles draw text and borders in the
|
||||
/// table's color and fill no cells, made on first use. `None` when the drawing has no such style.
|
||||
fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
|
||||
let key = (tag(color), "table style", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
|
||||
let base = format!("COMPARE_{}_{}", tag(color), st.name);
|
||||
let new = tinted.next.find(
|
||||
"table style",
|
||||
&base,
|
||||
d.table_styles.len(),
|
||||
|i| suffixed(&base, i),
|
||||
|n| d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
|
||||
);
|
||||
let names =
|
||||
[CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone()));
|
||||
let mut cell_styles: Vec<CellStyle> = Vec::new();
|
||||
for n in names {
|
||||
if cell_styles.iter().any(|c| c.name.eq_ignore_ascii_case(&n)) {
|
||||
continue;
|
||||
}
|
||||
let mut cs = st.cell_style(&n);
|
||||
cs.name = n;
|
||||
cs.text_color = Color::ByBlock;
|
||||
cs.fill = None;
|
||||
let b = &mut cs.borders;
|
||||
for border in [&mut b.top, &mut b.inside_h, &mut b.bottom, &mut b.left, &mut b.inside_v, &mut b.right] {
|
||||
border.color = Color::ByBlock;
|
||||
}
|
||||
cell_styles.push(cs);
|
||||
}
|
||||
d.table_styles.push(TableStyle { name: new.clone(), cell_styles, ..st });
|
||||
tinted.names.insert(key, new.clone());
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// MTEXT contents without inline color codes (`\C1;`, `\c255;`), so the text takes the
|
||||
/// object's color. An escaped backslash (`\\`) stays.
|
||||
fn without_color_codes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut it = s.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match it.peek().copied() {
|
||||
Some('C' | 'c') => {
|
||||
for d in it.by_ref() {
|
||||
if d == ';' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(n) => {
|
||||
out.push('\\');
|
||||
out.push(n);
|
||||
it.next();
|
||||
}
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The name of a copy of block `name` whose objects all have `color`, made on first use. `None`
|
||||
/// (the reference keeps the original block) for an xref, a missing block, or past the nesting
|
||||
/// and copy limits.
|
||||
fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, depth: usize) -> Option<String> {
|
||||
let key = (tag(color), "block", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let b = d.block(name)?.clone();
|
||||
if b.xref_path.is_some() {
|
||||
return None;
|
||||
}
|
||||
if depth >= MAX_BLOCK_DEPTH || tinted.names.len() >= MAX_BLOCKS || tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
|
||||
tinted.uncolored = true;
|
||||
return None;
|
||||
}
|
||||
tinted.copied += b.entities.len();
|
||||
// A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of
|
||||
// drawing the dimension again.
|
||||
let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D"));
|
||||
let Tinted { next, blocks, .. } = tinted;
|
||||
let taken = blocks.get_or_insert_with(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect());
|
||||
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
|
||||
let new = if dim_block {
|
||||
next.find("block", "*D", d.blocks.len(), |i| format!("*D{i}"), is_taken)
|
||||
} else {
|
||||
let base = format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*'));
|
||||
next.find("block", &base, d.blocks.len(), |i| suffixed(&base, i), is_taken)
|
||||
};
|
||||
taken.insert(new.to_ascii_uppercase());
|
||||
tinted.names.insert(key, new.clone());
|
||||
// Hold the name while the nested copies pick theirs.
|
||||
d.blocks.insert(new.clone(), Arc::new(Block::new(&new)));
|
||||
let mut copy = Block { name: new.clone(), anonymous: dim_block, entities: EntityStore::new(), ..(*b).clone() };
|
||||
for e in b.entities.iter() {
|
||||
let h = d.new_handle();
|
||||
let mut e = Entity { handle: h, ..(**e).clone() };
|
||||
detach(&mut e.kind);
|
||||
paint(d, &mut e, color, tinted, depth + 1);
|
||||
copy.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(new.clone(), Arc::new(copy));
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// The name each block copied from the other drawing has in the snapshot, by its upper-case
|
||||
/// name there.
|
||||
type Renames = HashMap<String, String>;
|
||||
|
||||
/// Copy into `d` the blocks of `other` that `objs` refer to, directly or through other blocks.
|
||||
/// A named block `d` already has keeps `d`'s definition when it draws the same thing there (the
|
||||
/// same objects, base point, units, flags and xref file, in it and in the blocks it refers to);
|
||||
/// otherwise the other drawing's block is copied as `PART_2` (or `PART_3`…). An anonymous block
|
||||
/// (`*D3`, `*U7`…) whose name `d` already uses is copied under the next free name with the same
|
||||
/// prefix. Copied
|
||||
/// objects get new handles, and relative file paths are rebased on `folder`. The flag is true
|
||||
/// when more than [`MAX_BLOCKS`] blocks or [`MAX_COPIED`] objects were needed and the rest were
|
||||
/// left out.
|
||||
fn copy_blocks<'a>(
|
||||
d: &mut Drawing,
|
||||
other: &Drawing,
|
||||
objs: impl Iterator<Item = &'a Entity>,
|
||||
folder: Option<&Path>,
|
||||
tinted: &mut Tinted,
|
||||
) -> (Renames, bool) {
|
||||
let mut frontier: Vec<String> = objs.flat_map(|e| block_refs(&e.kind)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut needed: Vec<Arc<Block>> = Vec::new();
|
||||
let mut left_out = false;
|
||||
// `d`'s relative paths were made absolute from its own folder before the copy.
|
||||
let (mut mine, mut theirs) = (super::Signer::with_frames(d, None), super::Signer::with_frames(other, folder));
|
||||
// Whether `d`'s block `n` draws what `other`'s block `b` draws. Past the signers' work limit
|
||||
// the signatures no longer cover every object, so the blocks count as different.
|
||||
let mut same = |n: &str, b: &Block| {
|
||||
!b.anonymous
|
||||
&& d.block(n).is_some()
|
||||
&& mine.block(n, 0) == theirs.block(n, 0)
|
||||
&& mine.work < super::MAX_BLOCK_WORK
|
||||
&& theirs.work < super::MAX_BLOCK_WORK
|
||||
};
|
||||
while let Some(n) = frontier.pop() {
|
||||
let key = n.to_ascii_uppercase();
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() >= MAX_BLOCKS {
|
||||
left_out |= other.block(&n).is_some_and(|b| !same(&n, b));
|
||||
continue;
|
||||
}
|
||||
seen.insert(key);
|
||||
let Some(b) = other.block(&n) else { continue };
|
||||
if same(&n, b) {
|
||||
continue;
|
||||
}
|
||||
if tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
|
||||
left_out = true;
|
||||
continue;
|
||||
}
|
||||
tinted.copied += b.entities.len();
|
||||
frontier.extend(b.entities.iter().flat_map(|e| block_refs(&e.kind)));
|
||||
needed.push(b.clone());
|
||||
}
|
||||
// Names `d` does not use stay; the others get free names, avoiding the kept ones.
|
||||
let mut renames = Renames::new();
|
||||
let mut taken: HashSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
let mut next = NextFree::default();
|
||||
for b in needed.iter().filter(|b| d.block(&b.name).is_none()) {
|
||||
taken.insert(b.name.to_ascii_uppercase());
|
||||
renames.insert(b.name.to_ascii_uppercase(), b.name.clone());
|
||||
}
|
||||
for b in needed.iter().filter(|b| d.block(&b.name).is_some()) {
|
||||
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
|
||||
let free = if b.anonymous {
|
||||
let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect();
|
||||
next.find("anonymous block", &prefix, taken.len(), |i| format!("{prefix}{i}"), is_taken)
|
||||
} else {
|
||||
next.find("block", &b.name, taken.len(), |i| suffixed(&b.name, i), is_taken)
|
||||
};
|
||||
taken.insert(free.to_ascii_uppercase());
|
||||
renames.insert(b.name.to_ascii_uppercase(), free);
|
||||
}
|
||||
for b in needed {
|
||||
let Some(name) = renames.get(&b.name.to_ascii_uppercase()).cloned() else { continue };
|
||||
let mut copy = Block { name: name.clone(), entities: EntityStore::new(), ..(*b).clone() };
|
||||
for e in b.entities.iter() {
|
||||
let h = d.new_handle();
|
||||
let mut e = Entity { handle: h, ..(**e).clone() };
|
||||
detach(&mut e.kind);
|
||||
rename_refs(&mut e.kind, &renames);
|
||||
copy.entities.push(e);
|
||||
}
|
||||
rebase_block(&mut copy, folder);
|
||||
d.blocks.insert(name, Arc::new(copy));
|
||||
}
|
||||
(renames, left_out)
|
||||
}
|
||||
@@ -12,8 +12,9 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::always;
|
||||
use super::super::helpers::support_text;
|
||||
use super::*;
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, find_command};
|
||||
|
||||
/// Most user aliases held.
|
||||
pub const MAX_ALIASES: usize = 10_000;
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
use cadcraft_doc::Drawing;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{Result, Session, bad, io};
|
||||
|
||||
/// The default template (profile setting, process-wide like FONTALT): empty = none.
|
||||
fn default_template() -> MutexGuard<'static, String> {
|
||||
|
||||
@@ -5,7 +5,8 @@ use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::curves;
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, point_param, point_req, str_param, targets};
|
||||
|
||||
use crate::grips::{GripAction, GripMode, grip_action, mode_matrix, stretch_grip};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ use cadcraft_doc::{Drawing, Group, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, str_param, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod dash;
|
||||
|
||||
@@ -7,7 +7,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::qselect::wildcard;
|
||||
use super::super::*;
|
||||
use super::super::{CommandSpec, bad, bool_or, str_param, targets};
|
||||
|
||||
use super::{add_members, create, echo_list, find, remove_members, rename, ungroup, valid_name};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -401,8 +401,17 @@ pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
|
||||
(a, per)
|
||||
}
|
||||
|
||||
/// `points` as given, `None` when it is absent; a `points` that is not a list of points is an
|
||||
/// error, never a fall back to the selection.
|
||||
fn given_points(p: &Value, cmd: &str) -> Result<Option<Vec<Vec2>>> {
|
||||
match p.get("points") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => points_param(p, "points").map(Some).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points")),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, per) = if let Some(pts) = points_param(p, "points") {
|
||||
let (a, per) = if let Some(pts) = given_points(p, "area")? {
|
||||
area_of(&pts)
|
||||
} else {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
|
||||
@@ -415,6 +424,37 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
|
||||
format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per))
|
||||
}
|
||||
|
||||
/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a 2D object
|
||||
/// as AREA measures it (an open polyline as if closed), times the absolute value of a height.
|
||||
pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = if let Some(pts) = given_points(p, "volume.measure")? {
|
||||
if pts.len() < 3 {
|
||||
return Err(bad("volume.measure", "`points` needs 3 or more [x,y] points"));
|
||||
}
|
||||
area_of(&pts).0
|
||||
} else {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("volume.measure", "`points` or `handle` is required"))?;
|
||||
object_area(s, h)?.0
|
||||
};
|
||||
let height = f64_req("volume.measure", p, "height")?;
|
||||
let v = a * height.abs();
|
||||
if !v.is_finite() {
|
||||
return Err(bad("volume.measure", "the volume is too large"));
|
||||
}
|
||||
Ok(json!({ "volume": v, "area": a, "height": height, "message": volume_text(s, v) }))
|
||||
}
|
||||
|
||||
fn volume_text(s: &Session, v: f64) -> String {
|
||||
format!("Volume = {}", fmt_volume(s, v))
|
||||
}
|
||||
|
||||
/// Format a volume as a number in the drawing's `LUPREC`. Engineering and architectural units
|
||||
/// show it as a decimal number, not in feet and inches.
|
||||
fn fmt_volume(s: &Session, v: f64) -> String {
|
||||
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
|
||||
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
|
||||
}
|
||||
|
||||
/// Area and perimeter of a closed object (AREA's Object option).
|
||||
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
let d = s.doc()?;
|
||||
@@ -889,9 +929,19 @@ pub(super) struct AreaM {
|
||||
object: bool,
|
||||
/// Asking for the length of a segment (the Length option).
|
||||
length: bool,
|
||||
/// Measuring volumes (MEASUREGEOM's Volume option): each area is multiplied by the absolute value of a height.
|
||||
volume: bool,
|
||||
/// Areas measured in volume mode that still wait for their height, oldest first.
|
||||
pending: Vec<f64>,
|
||||
/// The first point of a height given by two points.
|
||||
height_from: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl AreaM {
|
||||
/// MEASUREGEOM's Volume option: AREA's prompts, then a height for each area.
|
||||
pub(super) fn volume() -> Self {
|
||||
AreaM { volume: true, ..AreaM::default() }
|
||||
}
|
||||
fn mode_prefix(&self) -> &'static str {
|
||||
match self.mode {
|
||||
AreaMode::Single => "",
|
||||
@@ -899,8 +949,49 @@ impl AreaM {
|
||||
AreaMode::Subtract => "(SUBTRACT mode) ",
|
||||
}
|
||||
}
|
||||
/// The Add and Subtract keywords: "Add area" and "Subtract area", or "Add volume" and
|
||||
/// "Subtract volume".
|
||||
fn mode_keywords(&self) -> (&'static str, &'static str) {
|
||||
if self.volume { ("Add volume", "Subtract volume") } else { ("Add area", "Subtract area") }
|
||||
}
|
||||
/// The running total with `v` added or subtracted; in single mode, the total as it is.
|
||||
fn total_with(&self, v: f64) -> f64 {
|
||||
match self.mode {
|
||||
AreaMode::Single => self.total,
|
||||
AreaMode::Add => self.total + v,
|
||||
AreaMode::Subtract => self.total - v,
|
||||
}
|
||||
}
|
||||
/// Measure the oldest pending area's volume with height `h`.
|
||||
fn height(&mut self, s: &mut Session, h: f64) -> Step {
|
||||
match self.pending.first().map(|a| a * h.abs()) {
|
||||
Some(v) if v.is_finite() && self.total_with(v).is_finite() => {
|
||||
self.pending.remove(0);
|
||||
self.volume_measured(s, v)
|
||||
}
|
||||
_ => {
|
||||
s.echo("The volume is too large.");
|
||||
Step::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Report a volume (and the running total in add/subtract mode); ends the command in single mode.
|
||||
fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step {
|
||||
s.echo(volume_text(s, v));
|
||||
if self.mode == AreaMode::Single {
|
||||
return Step::Done;
|
||||
}
|
||||
self.total = self.total_with(v);
|
||||
s.echo(format!("Total volume = {}", fmt_volume(s, self.total)));
|
||||
Step::Continue
|
||||
}
|
||||
/// Report an area (and the running total in add/subtract mode); ends the command in single mode.
|
||||
/// In volume mode the area waits for its height instead.
|
||||
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
|
||||
if self.volume {
|
||||
self.pending.push(a);
|
||||
return Step::Continue;
|
||||
}
|
||||
s.echo(area_text(s, a, per));
|
||||
match self.mode {
|
||||
AreaMode::Single => return Step::Done,
|
||||
@@ -923,9 +1014,15 @@ impl AreaM {
|
||||
|
||||
impl Interactive for AreaM {
|
||||
fn name(&self) -> &'static str {
|
||||
"AREA"
|
||||
if self.volume { "MEASUREGEOM" } else { "AREA" }
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if !self.pending.is_empty() {
|
||||
return match self.height_from {
|
||||
Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p),
|
||||
None => Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::POINT_OR_NUMBER),
|
||||
};
|
||||
}
|
||||
if self.object {
|
||||
return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT);
|
||||
}
|
||||
@@ -933,17 +1030,35 @@ impl Interactive for AreaM {
|
||||
return length_prompt();
|
||||
}
|
||||
let next = format!("{}Specify next point", self.mode_prefix());
|
||||
let (add, subtract) = self.mode_keywords();
|
||||
match (self.pts.len(), self.mode) {
|
||||
(0, AreaMode::Single) => {
|
||||
Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object")
|
||||
}
|
||||
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Subtract area"]),
|
||||
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area"]),
|
||||
(0, AreaMode::Single) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add, subtract]).default("Object"),
|
||||
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", subtract]),
|
||||
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add]),
|
||||
(1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo", "Total"]).default("Total").base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.pending.is_empty() {
|
||||
// A height is typed, or given by two points (their distance).
|
||||
match (i, self.height_from) {
|
||||
(Input::Point(p), None) => self.height_from = Some(p),
|
||||
(Input::Point(p), Some(from)) => {
|
||||
self.height_from = None;
|
||||
return Ok(self.height(s, p.dist(from)));
|
||||
}
|
||||
(Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) {
|
||||
Some(h) => return Ok(self.height(s, h)),
|
||||
None => s.echo("Requires a distance."),
|
||||
},
|
||||
(Input::Enter, None) => s.echo("Requires a height."),
|
||||
(Input::Enter, Some(_)) => s.echo("Requires a second point."),
|
||||
(Input::Text(_), Some(_)) => s.echo("Requires a point."),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.object {
|
||||
match i {
|
||||
Input::Pick(hs) => {
|
||||
@@ -953,6 +1068,10 @@ impl Interactive for AreaM {
|
||||
if self.measured(s, a, per) == Step::Done {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
// A single volume takes the first object with an area.
|
||||
if self.volume && self.mode == AreaMode::Single {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => s.echo("The selected object has no area."),
|
||||
}
|
||||
@@ -980,8 +1099,8 @@ impl Interactive for AreaM {
|
||||
Input::Point(p) => self.pts.push(p),
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Object" => self.object = true,
|
||||
"Add area" => self.mode = AreaMode::Add,
|
||||
"Subtract area" => self.mode = AreaMode::Subtract,
|
||||
"Add area" | "Add volume" => self.mode = AreaMode::Add,
|
||||
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
|
||||
"Undo" => {
|
||||
self.pts.pop();
|
||||
}
|
||||
|
||||
@@ -637,13 +637,13 @@ impl Interactive for MviewM {
|
||||
Stage::Arrange => Prompt::new("Enter viewport arrangement", curves::KW)
|
||||
.kw(&["Horizontal", "Vertical", "Above", "Below", "Left", "Right"])
|
||||
.default("Right"),
|
||||
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
||||
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a),
|
||||
Stage::Polygon(pi) => pi.prompt(),
|
||||
Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT),
|
||||
Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]),
|
||||
Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE),
|
||||
Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
|
||||
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
||||
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a),
|
||||
Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ pub mod plotters;
|
||||
mod props;
|
||||
pub use draw2::breakline::BreaklineOptions;
|
||||
pub use props::MatchProps;
|
||||
pub(crate) use props::set_current_color_value;
|
||||
pub(crate) use props::{current_color_value, set_current_color_value};
|
||||
mod qselect;
|
||||
mod selecttools;
|
||||
mod settings;
|
||||
|
||||
@@ -517,7 +517,7 @@ impl Interactive for StretchM {
|
||||
return m.prompt();
|
||||
}
|
||||
match (self.phase, self.corner) {
|
||||
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c),
|
||||
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c),
|
||||
(StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT),
|
||||
(StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),
|
||||
(StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),
|
||||
@@ -1397,21 +1397,17 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
// The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font.
|
||||
let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
|
||||
let g = cadcraft_render::dimension_in(d, dm);
|
||||
let mut v: Vec<Entity> = g
|
||||
.lines
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
if l.len() == 2 {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
|
||||
} else {
|
||||
Some(Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let line_entity = |l: &Vec<Vec2>| {
|
||||
if l.len() == 2 {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
|
||||
} else {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) })
|
||||
}
|
||||
};
|
||||
// The text's marks (arc length symbol, DIMINSPECT frame) go after the text background.
|
||||
let text_marks = &g.text_marks;
|
||||
let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l);
|
||||
let mut v: Vec<Entity> = marks(false).filter_map(&line_entity).collect();
|
||||
for t in &g.fills {
|
||||
if let [a, b, c] = t.as_slice() {
|
||||
v.push(Entity {
|
||||
@@ -1421,6 +1417,20 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
});
|
||||
}
|
||||
}
|
||||
// DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and
|
||||
// arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3).
|
||||
if style.text_fill == 2
|
||||
&& let [p0, p1, p2, p3] = g.text_fill.as_slice()
|
||||
{
|
||||
let fill = style.text_fill_color;
|
||||
let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill };
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: cadcraft_doc::Common { color, ..e.common.clone() },
|
||||
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }),
|
||||
});
|
||||
}
|
||||
v.extend(marks(true).filter_map(&line_entity));
|
||||
if !g.value.is_empty() {
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
|
||||
@@ -362,10 +362,12 @@ impl Interactive for PlotWindowM {
|
||||
fn name(&self) -> &'static str {
|
||||
"PLOTWINDOW"
|
||||
}
|
||||
/// Ask for the first corner, then for the opposite one marked as a rectangle corner
|
||||
/// (`Prompt::corner`), so ORTHO does not bend the pick and the window keeps its area.
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
|
||||
@@ -32,6 +32,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
|
||||
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
|
||||
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
|
||||
CommandSpec::new("thickness", "Thickness", run_thickness)
|
||||
.menu(&["Format", "Thickness"])
|
||||
.params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}")
|
||||
.transparent()
|
||||
.interactive(|_| Ok(Box::new(ThicknessM))),
|
||||
CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"),
|
||||
CommandSpec::new("style", "Text Style...", run_style)
|
||||
.menu(&["Format", "Text Style..."])
|
||||
@@ -426,7 +431,11 @@ fn run_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()> {
|
||||
// An out-of-range index is refused here, where `color` folds it to ByLayer.
|
||||
let c = match v {
|
||||
Value::String(t) => Color::parse(t),
|
||||
// "RGB:r,g,b" is how `getvar` reads a true colour back, so a saved value can be restored.
|
||||
Value::String(t) => match t.trim().get(..4).filter(|p| p.eq_ignore_ascii_case("rgb:")).and_then(|_| t.trim().get(4..)) {
|
||||
Some(triple) => Color::parse(triple).filter(|c| matches!(c, Color::True(_))),
|
||||
None => Color::parse(t),
|
||||
},
|
||||
Value::Number(n) => n.as_i64().and_then(|i| Color::parse(&i.to_string())),
|
||||
_ => None,
|
||||
}?;
|
||||
@@ -434,6 +443,20 @@ pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()>
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// `getvar CECOLOR` as AutoCAD gives it, always text: "BYLAYER", "BYBLOCK", the index ("1" for
|
||||
/// red) or "RGB:r,g,b".
|
||||
pub(crate) fn current_color_value(d: &cadcraft_doc::Drawing) -> String {
|
||||
if let Some(rgb) = d.header.get("CECOLOR_RGB").and_then(|v| v.as_i64()).and_then(|v| u32::try_from(v).ok()) {
|
||||
let cadcraft_color::Rgb(r, g, b) = cadcraft_color::Rgb::from_u32(rgb);
|
||||
return format!("RGB:{r},{g},{b}");
|
||||
}
|
||||
match Color::from_aci(d.header.i64("CECOLOR", 256) as i16) {
|
||||
Color::ByLayer => "BYLAYER".into(),
|
||||
Color::ByBlock => "BYBLOCK".into(),
|
||||
other => other.to_aci().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A true colour keeps its RGB in CECOLOR_RGB (which wins for new objects); any other colour must
|
||||
/// clear a stale one.
|
||||
fn set_current_color(d: &mut cadcraft_doc::Drawing, c: Color) {
|
||||
@@ -534,6 +557,56 @@ impl Interactive for LtscaleM {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest THICKNESS, either way.
|
||||
const MAX_THICKNESS: f64 = 1e12;
|
||||
|
||||
fn thickness_in_range(t: f64) -> bool {
|
||||
t.is_finite() && t.abs() <= MAX_THICKNESS
|
||||
}
|
||||
|
||||
fn run_thickness(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = f64_req("thickness", p, "thickness")?;
|
||||
if !thickness_in_range(t) {
|
||||
return Err(bad("thickness", "thickness is out of range (-1e12 to 1e12)"));
|
||||
}
|
||||
s.doc_mut()?.header.set_f64("THICKNESS", t);
|
||||
Ok(json!({ "thickness": t }))
|
||||
}
|
||||
|
||||
/// Format > Thickness and typed THICKNESS: "Enter new value for THICKNESS <0.0000>:".
|
||||
struct ThicknessM;
|
||||
|
||||
impl Interactive for ThicknessM {
|
||||
fn name(&self) -> &'static str {
|
||||
"THICKNESS"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let cur = s.doc().map(|d| d.header.f64("THICKNESS", 0.0)).unwrap_or(0.0);
|
||||
Prompt::new("Enter new value for THICKNESS", Accept::NUMBER).default(format!("{cur:.4}"))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let text = match i {
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
Input::Text(t) => t,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
match super::machines::number(&text) {
|
||||
Some(t) if thickness_in_range(t) => {
|
||||
run_thickness(s, &json!({ "thickness": t }))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Some(_) => {
|
||||
s.echo("Requires a value between -1e12 and 1e12.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
None => {
|
||||
s.echo("Requires a number.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] {
|
||||
|
||||
@@ -141,15 +141,28 @@ fn run_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
}
|
||||
|
||||
/// Modify the table `handle` with `f`.
|
||||
fn edit(s: &mut Session, p: &Value, id: &str, f: impl FnOnce(&mut Table) -> std::result::Result<(), String>) -> Result<Value> {
|
||||
let h = p
|
||||
.get("handle")
|
||||
.and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
|
||||
/// The table `handle`, or else the selected table. A supplied `handle` that can't be read is an
|
||||
/// error, never a silent fall back to the selection (like `targets`).
|
||||
fn table_handle(s: &Session, p: &Value, id: &str) -> Result<Handle> {
|
||||
let explicit = match p.get("handle") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_str()
|
||||
.and_then(Handle::parse_hex)
|
||||
.or_else(|| v.as_u64().map(Handle))
|
||||
.ok_or_else(|| bad(id, format!("`handle`: {v} is not an object handle (a hex string such as \"1A2\")")))?,
|
||||
),
|
||||
};
|
||||
explicit
|
||||
.or_else(|| {
|
||||
s.selection().into_iter().find(|h| s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(e.kind, EntityKind::Table(_))))
|
||||
})
|
||||
.ok_or_else(|| bad(id, "`handle` of a table is required"))?;
|
||||
.ok_or_else(|| bad(id, "`handle` of a table is required"))
|
||||
}
|
||||
|
||||
/// Modify the table `handle` with `f`.
|
||||
fn edit(s: &mut Session, p: &Value, id: &str, f: impl FnOnce(&mut Table) -> std::result::Result<(), String>) -> Result<Value> {
|
||||
let h = table_handle(s, p, id)?;
|
||||
let mut t = match s.doc()?.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Table(t)) => t.clone(),
|
||||
_ => return Err(bad(id, "object is not a table")),
|
||||
|
||||
@@ -6,7 +6,7 @@ use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::formula::{self, cell_name, parse_cell_name};
|
||||
use super::{MAX_COLS, MAX_ROWS, edit, usize_param};
|
||||
use super::{MAX_COLS, MAX_ROWS, edit, table_handle, usize_param};
|
||||
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
@@ -524,15 +524,6 @@ fn write_text(path: &str, text: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn table_handle(s: &Session, p: &Value, id: &str) -> Result<Handle> {
|
||||
p.get("handle")
|
||||
.and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
|
||||
.or_else(|| {
|
||||
s.selection().into_iter().find(|h| s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(e.kind, EntityKind::Table(_))))
|
||||
})
|
||||
.ok_or_else(|| bad(id, "`handle` of a table is required"))
|
||||
}
|
||||
|
||||
fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = "tableexport";
|
||||
let h = table_handle(s, p, id)?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use cadcraft_geom::{Segment, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -27,11 +27,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::<PickM>::default())),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
|
||||
.params("{mode: distance|radius|angle|area, ...}")
|
||||
.params("{mode: distance|radius|angle|area|volume, ...}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<MeasureM>::default())),
|
||||
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
|
||||
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
|
||||
CommandSpec::new("radius.measure", "Radius", run_radius)
|
||||
.menu(&["Tools", "Inquiry", "Radius"])
|
||||
.params("{handle, point?}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(RadiusM))),
|
||||
CommandSpec::new("angle.measure", "Angle", run_angle)
|
||||
.menu(&["Tools", "Inquiry", "Angle"])
|
||||
.params("{vertex, p1, p2} | {h1, h2}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<AngleM>::default())),
|
||||
CommandSpec::new("volume.measure", "Volume", super::inquiry::run_volume)
|
||||
.menu(&["Tools", "Inquiry", "Volume"])
|
||||
.params("{points: [[x,y],...] (3 or more) | handle (a closed 2D object), height} → {volume, area, height}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(super::inquiry::AreaM::volume()))),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -283,11 +296,70 @@ fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::Circle(c)) => c.radius,
|
||||
Some(EntityKind::Arc(a)) => a.radius,
|
||||
_ => return Err(bad("radius.measure", "select an arc or circle")),
|
||||
Some(EntityKind::LwPolyline(_)) => {
|
||||
let at = point_param(p, "point").ok_or_else(|| bad("radius.measure", "`point` on the polyline's arc segment is required"))?;
|
||||
match super::annotate::segment_at(s, h, at) {
|
||||
Some(Segment::Arc { arc, .. }) => arc.radius,
|
||||
_ => return Err(bad("radius.measure", "the polyline segment at `point` is not an arc")),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad("radius.measure", "select an arc, circle or polyline arc")),
|
||||
};
|
||||
Ok(json!({ "radius": r, "diameter": 2.0 * r, "message": format!("Radius = {r:.4}\nDiameter = {:.4}", 2.0 * r) }))
|
||||
}
|
||||
|
||||
/// MEASUREGEOM's Radius option and Tools > Inquiry > Radius: the radius and diameter of a picked
|
||||
/// arc, circle or polyline arc segment. Tools > Inquiry > Radius measures a selected arc or circle
|
||||
/// at once.
|
||||
struct RadiusM;
|
||||
|
||||
impl RadiusM {
|
||||
/// Measure the arc, circle or polyline arc segment at the picked point; a pick on anything else keeps the prompt.
|
||||
fn pick(s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Input::Point(p) = i else { return Ok(Step::Done) };
|
||||
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0, "point": [p.x, p.y] })) {
|
||||
Ok(r) => {
|
||||
echo_message(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(_) => {
|
||||
s.echo("Select an arc, circle or polyline arc.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for RadiusM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MEASUREGEOM"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Select arc or circle", Accept::POINT)
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let Some(h) = s.selection().first().copied() else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0 })) {
|
||||
Ok(r) => {
|
||||
echo_message(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(_) => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
Self::pick(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo each line of a result's `message`.
|
||||
fn echo_message(s: &mut Session, r: &Value) {
|
||||
for l in r["message"].as_str().unwrap_or_default().lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (v, a, b) = if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "p1"), point_param(p, "p2")) {
|
||||
(v, a, b)
|
||||
@@ -299,17 +371,29 @@ fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(EntityKind::Line(l)) => Some((l.a.xy(), l.b.xy())),
|
||||
_ => None,
|
||||
};
|
||||
let ((a1, a2), (b1, b2)) =
|
||||
let (l1, l2) =
|
||||
(line(h1).ok_or_else(|| bad("angle.measure", "h1 is not a line"))?, line(h2).ok_or_else(|| bad("angle.measure", "h2 is not a line"))?);
|
||||
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?;
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
|
||||
(x, far(a1, a2), far(b1, b2))
|
||||
lines_vertex(l1, l2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?
|
||||
};
|
||||
let mut ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
|
||||
if ang > 180.0 {
|
||||
ang = 360.0 - ang;
|
||||
}
|
||||
Ok(json!({ "angle": ang, "message": format!("Angle = {ang:.4}°") }))
|
||||
let ang = vertex_angle(v, a, b);
|
||||
Ok(json!({ "angle": ang, "message": angle_text(ang) }))
|
||||
}
|
||||
|
||||
/// The point where two lines (extended) meet and the end of each line farther from it; `None` when they are parallel.
|
||||
fn lines_vertex((a1, a2): (Vec2, Vec2), (b1, b2): (Vec2, Vec2)) -> Option<(Vec2, Vec2, Vec2)> {
|
||||
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
|
||||
Some((x, far(a1, a2), far(b1, b2)))
|
||||
}
|
||||
|
||||
/// The angle at `v` between the directions to `a` and `b`, in degrees from 0 to 180.
|
||||
fn vertex_angle(v: Vec2, a: Vec2, b: Vec2) -> f64 {
|
||||
let ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
|
||||
if ang > 180.0 { 360.0 - ang } else { ang }
|
||||
}
|
||||
|
||||
fn angle_text(ang: f64) -> String {
|
||||
format!("Angle = {ang:.4}°")
|
||||
}
|
||||
|
||||
fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -318,11 +402,12 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"radius" | "r" => run_radius(s, p),
|
||||
"angle" | "a" => run_angle(s, p),
|
||||
"area" | "ar" => s.execute("area", p),
|
||||
_ => Err(bad("measuregeom", "mode must be distance, radius, angle or area")),
|
||||
"volume" | "v" => super::inquiry::run_volume(s, p),
|
||||
_ => Err(bad("measuregeom", "mode must be distance, radius, angle, area or volume")),
|
||||
}
|
||||
}
|
||||
|
||||
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA or a radius pick.
|
||||
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA, a radius pick or an angle pick.
|
||||
#[derive(Default)]
|
||||
struct MeasureM {
|
||||
inner: Option<Box<dyn Interactive>>,
|
||||
@@ -339,7 +424,7 @@ impl Interactive for MeasureM {
|
||||
Some(m) => m.prompt(s),
|
||||
None if self.radius => Prompt::new("Select arc or circle", Accept::POINT),
|
||||
None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true })
|
||||
.kw(&["Distance", "Radius", "ARea"])
|
||||
.kw(&["Distance", "Radius", "Angle", "ARea", "Volume"])
|
||||
.default("Distance"),
|
||||
}
|
||||
}
|
||||
@@ -348,24 +433,15 @@ impl Interactive for MeasureM {
|
||||
return m.input(s, i);
|
||||
}
|
||||
if self.radius {
|
||||
let Input::Point(p) = i else { return Ok(Step::Done) };
|
||||
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0 })) {
|
||||
Ok(r) => {
|
||||
for l in r["message"].as_str().unwrap_or_default().lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Err(_) => s.echo("Select an arc or circle."),
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
return RadiusM::pick(s, i);
|
||||
}
|
||||
match i {
|
||||
Input::Enter => self.inner = Some(Box::new(super::inquiry::DistM::default())),
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Radius" => self.radius = true,
|
||||
"Angle" => self.inner = Some(Box::<AngleM>::default()),
|
||||
"ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())),
|
||||
"Volume" => self.inner = Some(Box::new(super::inquiry::AreaM::volume())),
|
||||
_ => self.inner = Some(Box::new(super::inquiry::DistM::default())),
|
||||
},
|
||||
_ => {}
|
||||
@@ -377,6 +453,103 @@ impl Interactive for MeasureM {
|
||||
}
|
||||
}
|
||||
|
||||
/// MEASUREGEOM's Angle option and Tools > Inquiry > Angle: the angle of an arc, of two points on a
|
||||
/// circle about its center, between two lines or polyline line segments, or (Enter) at a vertex
|
||||
/// between two endpoints.
|
||||
#[derive(Default)]
|
||||
struct AngleM {
|
||||
/// The first line picked.
|
||||
first: Option<(Vec2, Vec2)>,
|
||||
/// The vertex and endpoints given so far (Enter, or a circle's center and the point picked on it).
|
||||
vertex: Option<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
impl AngleM {
|
||||
fn finish(s: &mut Session, ang: f64) -> Result<Step> {
|
||||
s.echo(angle_text(ang));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AngleM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MEASUREGEOM"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(pts) = &self.vertex {
|
||||
let msg = ["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"].get(pts.len()).copied().unwrap_or("");
|
||||
return Prompt::new(msg, Accept::POINT).base_opt(pts.first().copied());
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"),
|
||||
Some(_) => Prompt::new("Select second line", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(pts) = &mut self.vertex {
|
||||
return match i {
|
||||
Input::Point(p) => {
|
||||
pts.push(p);
|
||||
match pts.as_slice() {
|
||||
[v, a, b] => Self::finish(s, vertex_angle(*v, *a, *b)),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
let p = match i {
|
||||
Input::Enter if self.first.is_none() => {
|
||||
self.vertex = Some(Vec::new());
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Input::Point(p) => p,
|
||||
Input::Enter => return Ok(Step::Cancel),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let Some(h) = super::curves::pick_at(s, p) else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let circle = s
|
||||
.doc()?
|
||||
.entity(h)
|
||||
.and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None });
|
||||
if let Some(c) = circle.filter(|_| self.first.is_none()) {
|
||||
self.vertex = Some(vec![c.center, c.closest(p)]);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match super::annotate::segment_at(s, h, p) {
|
||||
Some(Segment::Arc { arc, .. }) if self.first.is_none() => Self::finish(s, arc.sweep().to_degrees()),
|
||||
Some(Segment::Line(l)) => match self.first {
|
||||
None => {
|
||||
self.first = Some((l.a, l.b));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some(first) => match lines_vertex(first, (l.a, l.b)) {
|
||||
Some((v, a, b)) => Self::finish(s, vertex_angle(v, a, b)),
|
||||
None => {
|
||||
s.echo("The lines are parallel.");
|
||||
Ok(Step::Done)
|
||||
}
|
||||
},
|
||||
},
|
||||
_ => {
|
||||
s.echo(if self.first.is_none() { "Select an arc, circle or line." } else { "Select a line." });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match self.vertex.as_deref() {
|
||||
Some([v]) => vec![super::helpers::line(*v, c)],
|
||||
Some([v, a]) => vec![super::helpers::line(*v, *a), super::helpers::line(*v, c)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -280,7 +280,7 @@ impl Interactive for ViewM {
|
||||
Stage::Restore => Prompt::new("Enter view name to restore", Accept::TEXT),
|
||||
Stage::Save | Stage::WindowName => Prompt::new("Enter view name to save", Accept::TEXT),
|
||||
Stage::WindowFirst => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
Stage::Settings => Prompt::new("Enter an option", Accept::TEXT).kw(&["Layer snapshot"]),
|
||||
Stage::Snapshot => {
|
||||
let on = if s.settings.view_layer_snapshot { "Yes" } else { "No" };
|
||||
|
||||
@@ -647,6 +647,9 @@ impl Default for MLeaderOptions {
|
||||
pub struct Session {
|
||||
pub docs: Vec<DocState>,
|
||||
pub active: usize,
|
||||
/// Monotonic count of changes to the open drawings and the active one. A screenshot queued
|
||||
/// across a switch is rejected even when a later switch returns to the same drawing.
|
||||
pub doc_epoch: u64,
|
||||
pub settings: Settings,
|
||||
pub running: Option<Running>,
|
||||
/// A script is running ([`Session::script`]); a SCRIPT command inside it is refused.
|
||||
@@ -727,6 +730,7 @@ impl Session {
|
||||
Session {
|
||||
docs: Vec::new(),
|
||||
active: 0,
|
||||
doc_epoch: 0,
|
||||
settings: Settings::default(),
|
||||
running: None,
|
||||
in_script: false,
|
||||
@@ -759,6 +763,7 @@ impl Session {
|
||||
hatch_picks: None,
|
||||
}
|
||||
}
|
||||
/// Append a new untitled drawing (`DrawingN`, metric or imperial default view), end a running command, make it active, bump the document epoch and return its index.
|
||||
pub fn new_drawing(&mut self, metric: bool) -> usize {
|
||||
self.untitled_counter += 1;
|
||||
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
|
||||
@@ -767,8 +772,10 @@ impl Session {
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
self.bump_doc_epoch();
|
||||
self.active
|
||||
}
|
||||
/// Append a loaded drawing, fit the view to its model extents (aspect-corrected, minimum height 1e-6) when non-empty, end a running command, make it active, bump the epoch and return its index.
|
||||
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
|
||||
let mut st = DocState::new(d, title, path);
|
||||
let ext = st.doc.extents(&Space::Model);
|
||||
@@ -781,8 +788,14 @@ impl Session {
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
self.bump_doc_epoch();
|
||||
self.active
|
||||
}
|
||||
/// Record that which drawing is open or active changed (screenshot binding).
|
||||
pub fn bump_doc_epoch(&mut self) {
|
||||
self.doc_epoch = self.doc_epoch.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// A command still waiting for input belongs to the drawing it started in: end it there
|
||||
/// before another drawing becomes current, so its undo step lands in that drawing.
|
||||
fn end_running(&mut self) {
|
||||
@@ -800,6 +813,7 @@ impl Session {
|
||||
Err(_) => {
|
||||
self.docs.truncate(n);
|
||||
self.active = self.active.min(self.docs.len().saturating_sub(1));
|
||||
self.bump_doc_epoch();
|
||||
Err(EngineError::Internal("open".into(), "panic".into()))
|
||||
}
|
||||
}
|
||||
@@ -885,6 +899,19 @@ impl Session {
|
||||
if d.annotative_by_style(&kind) {
|
||||
common.anno_scales = vec![d.current_anno_scale()];
|
||||
}
|
||||
// Lines, arcs, circles, points, polylines and 2D solids take the current 3D thickness
|
||||
// (THICKNESS). RECTANG sets its own, and text never takes it.
|
||||
if matches!(
|
||||
kind,
|
||||
EntityKind::Line(_)
|
||||
| EntityKind::Arc(_)
|
||||
| EntityKind::Circle(_)
|
||||
| EntityKind::Point(_)
|
||||
| EntityKind::LwPolyline(_)
|
||||
| EntityKind::Solid(_)
|
||||
) {
|
||||
common.thickness = Some(d.header.f64("THICKNESS", 0.0)).filter(|t| t.is_finite()).unwrap_or(0.0);
|
||||
}
|
||||
let h = self.doc_mut()?.add(&space, common, kind)?;
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
@@ -797,8 +797,8 @@ fn glob_inner(
|
||||
return Err(());
|
||||
}
|
||||
*budget -= 1;
|
||||
if depth > 64 {
|
||||
return Ok(false);
|
||||
if depth > 256 {
|
||||
return Err(());
|
||||
}
|
||||
let (mut t, mut p) = (0usize, 0usize);
|
||||
while p < pat.len() {
|
||||
|
||||
@@ -136,6 +136,10 @@ fn lists_and_strings() {
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"x*,a?c\")"), "T");
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"~a*\")"), "nil");
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"b\" \"[a-c]\")"), "T");
|
||||
let many = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(80), "*a".repeat(80));
|
||||
assert_eq!(ok(&mut s, &many), "T");
|
||||
let too_deep = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(400), "*a".repeat(400));
|
||||
assert!(eval(&mut s, &too_deep).is_err());
|
||||
assert_eq!(ok(&mut s, "(vl-string-subst \"X\" \"b\" \"abcb\")"), "\"aXcb\"");
|
||||
assert_eq!(ok(&mut s, "(vl-string-search \"c\" \"abc\")"), "2");
|
||||
assert_eq!(ok(&mut s, "(read \"(1 2)\")"), "(1 2)");
|
||||
@@ -455,6 +459,15 @@ fn getvar_drawing_and_session_variables() {
|
||||
assert_eq!(ok(&mut s, "(getvar \"DWGTITLED\")"), "1");
|
||||
}
|
||||
|
||||
/// CECOLOR is a string in AutoCAD, whatever it was set from (#1999).
|
||||
#[test]
|
||||
fn getvar_cecolor_is_a_string() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "(list (getvar \"CECOLOR\") (type (getvar \"CECOLOR\")))"), "(\"BYLAYER\" STR)");
|
||||
assert_eq!(ok(&mut s, "(setvar \"CECOLOR\" \"red\") (getvar \"CECOLOR\")"), "\"1\"");
|
||||
assert_eq!(ok(&mut s, "(setvar \"CECOLOR\" \"10,20,30\") (getvar \"CECOLOR\")"), "\"RGB:10,20,30\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_functions_take_supplied_answers() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -41,8 +41,8 @@ pub struct Prompt {
|
||||
/// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred
|
||||
/// pick arrives as the plain point it was picked at.
|
||||
pub deferred: bool,
|
||||
/// The base is a rectangle's first corner (ZOOM Window, RECTANG, ARRAYRECT's unit cell): ortho
|
||||
/// does not bend the opposite corner, so the rectangle keeps its area.
|
||||
/// The base is a rectangle's first corner (ZOOM Window, PLOTWINDOW, RECTANG, ARRAYRECT's unit
|
||||
/// cell): ortho does not bend the opposite corner, so the rectangle keeps its area.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub corner: bool,
|
||||
/// The grip this prompt stretches (one hot grip at the STRETCH prompt), as (object, grip
|
||||
|
||||
@@ -434,7 +434,7 @@ impl SelectMode {
|
||||
};
|
||||
match last {
|
||||
None => Prompt::new(first, Accept::POINT),
|
||||
Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).base(p),
|
||||
Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p),
|
||||
Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
// 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)),
|
||||
// Text, as AutoCAD reads it back; the header keeps the index (and CECOLOR_RGB) for DXF.
|
||||
"CECOLOR" => json!(crate::cmd::current_color_value(s.doc().ok()?)),
|
||||
// Process-wide font substitution (profile settings, not saved in the drawing).
|
||||
"FONTALT" => json!(cadcraft_fonts::ttf::font_alt()),
|
||||
"FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()),
|
||||
@@ -379,6 +381,13 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
return r;
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
// A dimension variable takes only the values its style field takes, as in DIMSTYLE and DIMOVERRIDE.
|
||||
if let Some((_, field)) = cadcraft_doc::DIMVARS.iter().find(|(var, _)| *var == n) {
|
||||
let mut st = d.dim_style(&d.header.str("DIMSTYLE", "Standard")).cloned().unwrap_or_default();
|
||||
if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
let val = match v {
|
||||
Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)),
|
||||
Value::Number(x) => cadcraft_doc::HVal::Real(x.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?),
|
||||
@@ -440,7 +449,7 @@ pub fn list(s: &Session) -> Value {
|
||||
}
|
||||
if let Ok(d) = s.doc() {
|
||||
for (k, v) in &d.header.vars {
|
||||
if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT") {
|
||||
if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT" | "CECOLOR") {
|
||||
m.insert(k.clone(), get(s, k).unwrap_or(Value::Null));
|
||||
} else if !k.starts_with("CADCRAFT_") {
|
||||
m.insert(k.clone(), serde_json::to_value(v).unwrap_or(Value::Null));
|
||||
|
||||
@@ -1309,6 +1309,64 @@ fn typed_ltscale_asks_for_the_scale() {
|
||||
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_thickness_asks_for_the_thickness() {
|
||||
let mut s = Session::new();
|
||||
s.start("thickness").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter new value for THICKNESS <0.0000>"), "{}", s.prompt_text());
|
||||
// After text that is not a number, the prompt asks again.
|
||||
s.cmdline("thick").unwrap();
|
||||
assert!(s.current_prompt().is_some(), "asked again");
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Requires a number."));
|
||||
// A value out of range says so, and the prompt asks again.
|
||||
s.cmdline("2e12").unwrap();
|
||||
assert!(s.current_prompt().is_some(), "asked again");
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Requires a value between -1e12 and 1e12."));
|
||||
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), 0.0);
|
||||
// A negative thickness is accepted.
|
||||
s.cmdline("-1.5").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5);
|
||||
// Typed, Enter keeps the current thickness.
|
||||
s.cmdline("thickness").unwrap();
|
||||
assert!(s.prompt_text().contains("<-1.5000>"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5);
|
||||
assert!(s.execute("thickness", &json!({"thickness": "thick"})).is_err());
|
||||
assert!(s.execute("thickness", &json!({"thickness": 1e13})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_objects_take_the_current_thickness() {
|
||||
let mut s = Session::new();
|
||||
s.execute("thickness", &json!({"thickness": 2.5})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
|
||||
s.execute("point", &json!({"at": [1, 1]})).unwrap();
|
||||
s.execute("pline", &json!({"vertices": [[0, 0], [5, 0], [5, 5]]})).unwrap();
|
||||
s.execute("polygon", &json!({"sides": 6, "center": [20, 20], "radius": 2})).unwrap();
|
||||
s.execute("donut", &json!({"center": [30, 30], "inside": 1, "outside": 2})).unwrap();
|
||||
let thick: Vec<(&str, f64)> = s.doc().unwrap().model.iter().map(|e| (e.kind.type_name(), e.common.thickness)).collect();
|
||||
assert_eq!(thick.len(), 7, "{thick:?}");
|
||||
assert!(thick.iter().all(|(_, t)| *t == 2.5), "{thick:?}");
|
||||
// RECTANG keeps its own thickness option, and text does not take THICKNESS.
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4], "thickness": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 1.0);
|
||||
let before = s.doc().unwrap().model.len();
|
||||
s.cmdline("rectang 0,0 4,4").unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), before + 1);
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
s.execute("text", &json!({"at": [0, 0], "text": "A"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
// An ellipse has no thickness.
|
||||
s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
}
|
||||
|
||||
fn setvar_prompt_open(s: &Session) -> bool {
|
||||
s.running.as_ref().is_some_and(|r| r.id == "setvar")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
|
||||
let rounded = (v.abs() * scale).round() / scale;
|
||||
let a = if rounded.is_finite() { rounded } else { v.abs() };
|
||||
let ft = (a / 12.0).floor();
|
||||
if ft >= I64_SAFE_MAX {
|
||||
return format!("{:.*}", prec, v);
|
||||
}
|
||||
let inch = a - ft * 12.0;
|
||||
format!("{}{}'-{:.*}\"", if neg { "-" } else { "" }, ft as i64, prec, inch)
|
||||
}
|
||||
@@ -136,9 +139,13 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` (in `[0, full)`), or 0 when it rounds at `prec` decimals to the same value as a full turn.
|
||||
/// `v` (in `[0, full)`), or 0 when it rounds at `prec` decimals to the printed full-turn value (only when that value is exact).
|
||||
fn wrap_full_turn(v: f64, full: f64, prec: usize) -> f64 {
|
||||
if format!("{v:.prec$}") == format!("{full:.prec$}") { 0.0 } else { v }
|
||||
let full_text = format!("{full:.prec$}");
|
||||
// A full turn that cannot be printed exactly (2π) only wraps when `v` is numerically that value.
|
||||
let exact = full_text.parse::<f64>().is_ok_and(|f| f == full);
|
||||
let reached = exact || full - v < 1e-6;
|
||||
if reached && format!("{v:.prec$}") == full_text { 0.0 } else { v }
|
||||
}
|
||||
|
||||
/// Parse a distance typed by the user: decimal, `1'6"`, `1'-6 1/2"`, `6 1/2`, `3/4`, `1e3`.
|
||||
@@ -320,6 +327,12 @@ fn bearing(t: &str) -> Option<f64> {
|
||||
Some(deg.to_radians())
|
||||
}
|
||||
|
||||
/// Minutes or seconds of a D'" angle: a number in `0..=60`.
|
||||
fn dms_part(t: &str) -> Option<f64> {
|
||||
let v: f64 = t.trim().parse().ok()?;
|
||||
(0.0..=60.0).contains(&v).then_some(v)
|
||||
}
|
||||
|
||||
/// Degrees, `45d`, `45d30'` or `45d30'15"` (a leading `-` negates the whole angle).
|
||||
fn parse_dms(t: &str) -> Option<f64> {
|
||||
let (neg, t) = match t.trim().strip_prefix('-') {
|
||||
@@ -332,11 +345,11 @@ fn parse_dms(t: &str) -> Option<f64> {
|
||||
if !rest.is_empty() {
|
||||
let (m, rest2) = rest.split_once('\'').unwrap_or((rest, ""));
|
||||
if !m.trim().is_empty() {
|
||||
deg += m.trim().parse::<f64>().ok()? / 60.0;
|
||||
deg += dms_part(m)? / 60.0;
|
||||
}
|
||||
let s = rest2.trim().trim_end_matches('"');
|
||||
if !s.is_empty() {
|
||||
deg += s.parse::<f64>().ok()? / 3600.0;
|
||||
deg += dms_part(s)? / 3600.0;
|
||||
}
|
||||
}
|
||||
let deg = if neg { -deg } else { deg };
|
||||
@@ -356,6 +369,20 @@ pub const DEG: f64 = PI / 180.0;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dms_minutes_and_seconds_must_be_within_0_to_60() {
|
||||
for t in ["45d-30'", "45d30'-15\"", "45d75'", "45d30'75\"", "n45d-30'e"] {
|
||||
assert_eq!(parse_angle(t), None, "{t}");
|
||||
}
|
||||
assert!(parse_angle("45d30'15\"").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engineering_huge_distance_falls_back_to_decimal() {
|
||||
assert_eq!(format_distance(1e30, 3, 2), format!("{:.2}", 1e30));
|
||||
assert_eq!(format_distance(-1e300, 3, 8), format!("{:.8}", -1e300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_distance_needs_a_positive_fraction_after_the_whole_number() {
|
||||
assert_eq!(parse_distance("6 1/2"), Some(6.5));
|
||||
@@ -507,4 +534,11 @@ mod tests {
|
||||
assert_eq!(format_angle(angle, 4, 0), "S 74d E");
|
||||
assert_eq!(format_angle(angle, 4, 6), "S 73d41'51.00\" E");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angle_radians_low_precision_not_wrapped() {
|
||||
assert_eq!(format_angle(6.2, 3, 0), "6r");
|
||||
assert_eq!(format_angle(6.26, 3, 1), "6.3r");
|
||||
assert_eq!(format_angle(0.0, 3, 1), "0.0r");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,3 +322,41 @@ fn hooks() {
|
||||
plot: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_editor_that_cannot_be_composed_is_not_autosaved_as_the_old_host() {
|
||||
use cadcraft_engine::doc::{Entity, EntityKind, Insert};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[10, 10], [12, 10]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "Door", "base": [10, 10], "handles": [l] })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
// An unsaved change in the editor: the block would contain a reference to itself.
|
||||
let d = s.doc_mut().unwrap();
|
||||
let h = d.new_handle();
|
||||
let kind = EntityKind::Insert(Insert {
|
||||
block: "Door".into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: vec![],
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
});
|
||||
d.model.push(Entity::new(h, kind));
|
||||
assert!(s.state().unwrap().recovery_drawing().is_err());
|
||||
|
||||
let mut a = Autosaver::new("55-b");
|
||||
a.plan(&s, 0.0, 1, "/auto");
|
||||
assert!(a.plan(&s, 60.0, 1, "/auto").is_empty(), "no recovery file without the editor's changes");
|
||||
assert!(a.plan(&s, 120.0, 1, "/auto").is_empty());
|
||||
assert_eq!(a.next_due(120.0, 1), Some(0.0), "still waiting to be written");
|
||||
|
||||
// Once the reference is gone, the next autosave writes the editor's changes.
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.model.remove(h);
|
||||
assert_eq!(writes(&a.plan(&s, 180.0, 1, "/auto")).len(), 1);
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ fn a_file_save_writes_the_drawing_outside_the_editor() {
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
|
||||
// Autosave keeps the editor's unsaved change in the block.
|
||||
let rec = s.state().unwrap().recovery_drawing();
|
||||
let rec = s.state().unwrap().recovery_drawing().unwrap();
|
||||
assert_eq!(rec.block("Door").unwrap().entities.len(), 2);
|
||||
assert_eq!(rec.model.len(), 2);
|
||||
|
||||
|
||||
@@ -170,6 +170,118 @@ fn pasteblock_makes_a_block_of_the_clipboard() {
|
||||
assert!(d.block("DOOR").is_some() && d.layer("HINGE").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutbase_cuts_with_the_given_base_point() {
|
||||
let mut s = Session::empty();
|
||||
let mut da = drawing_a();
|
||||
if let Some(l) = da.layer_mut("WALLS") {
|
||||
l.locked = true;
|
||||
}
|
||||
let a = s.open_drawing(da, "A", None);
|
||||
let b = s.open_drawing(drawing_b(), "B", None);
|
||||
s.active = a;
|
||||
let r = s.execute("cutbase", &json!({ "handles": handles(&s), "base": [5, 5] })).unwrap();
|
||||
// The wall is on a locked layer, so only the door is cut.
|
||||
assert_eq!(r, json!({ "cut": 1, "message": "1 object was on a locked layer." }));
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(s.clipboard_base, Vec2::new(5.0, 5.0));
|
||||
let left = model(&s);
|
||||
assert_eq!(left.len(), 1);
|
||||
assert!(matches!(&left[0].kind, EntityKind::Line(_)), "the wall stays");
|
||||
|
||||
// PASTECLIP places the base point at the insertion point.
|
||||
s.active = b;
|
||||
s.execute("pasteclip", &json!({ "at": [105, 5] })).unwrap();
|
||||
let pasted = model(&s);
|
||||
assert!(matches!(&pasted[0].kind, EntityKind::Insert(i) if i.block == "DOOR" && i.insert.x == 115.0 && i.insert.y == 20.0));
|
||||
|
||||
// Undo in A brings the door back.
|
||||
s.active = a;
|
||||
s.execute("u", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutting_only_locked_objects_keeps_the_clipboard() {
|
||||
let mut s = Session::empty();
|
||||
let mut da = drawing_a();
|
||||
for l in &mut da.layers {
|
||||
l.locked = true;
|
||||
}
|
||||
s.open_drawing(da, "A", None);
|
||||
let hs = handles(&s);
|
||||
s.execute("copyclip", &json!({ "handles": [hs[0]] })).unwrap();
|
||||
let held = s.clipboard.clone();
|
||||
let r = s.execute("cutclip", &json!({ "handles": hs })).unwrap();
|
||||
assert_eq!(r, json!({ "cut": 0, "message": "2 objects were on a locked layer. Nothing was cut." }));
|
||||
assert_eq!(model(&s).len(), 2, "nothing was erased");
|
||||
assert_eq!(s.clipboard.len(), 1, "the clipboard keeps what it held");
|
||||
assert_eq!(s.clipboard[0].handle, held[0].handle);
|
||||
|
||||
// CUTBASE at the command line says so too.
|
||||
s.start("cutbase").unwrap();
|
||||
s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap();
|
||||
let wall = s.doc().unwrap().model.iter().next().unwrap().handle;
|
||||
s.input(cadcraft_engine::Input::Pick(vec![wall])).unwrap();
|
||||
s.input(cadcraft_engine::Input::Enter).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.log.iter().any(|l| l.contains("1 object was on a locked layer. Nothing was cut.")), "{:?}", s.log);
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(model(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutbase_and_copybase_need_a_base_point_and_take_each_object_once() {
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(drawing_a(), "A", None);
|
||||
let hs = handles(&s);
|
||||
for cmd in ["cutbase", "copybase"] {
|
||||
for p in [json!({ "handles": hs }), json!({ "handles": hs, "base": [5] })] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("`base` ([x, y]) is required"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
assert_eq!(model(&s).len(), 2, "nothing was cut");
|
||||
assert!(s.clipboard.is_empty(), "nothing was copied");
|
||||
|
||||
// A handle listed twice is copied and cut once.
|
||||
let twice = json!({ "handles": [hs[1], hs[1]], "base": [0, 0] });
|
||||
assert_eq!(s.execute("copybase", &twice).unwrap(), json!({ "copied": 1 }));
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(s.execute("cutbase", &twice).unwrap(), json!({ "cut": 1 }));
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(model(&s).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutbase_asks_for_the_base_point_then_the_objects() {
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(drawing_a(), "A", None);
|
||||
let hs: Vec<Handle> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
let undo_before = s.state().unwrap().undo.len();
|
||||
s.start("cutbase").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Pick(vec![hs[1]])).unwrap();
|
||||
s.input(cadcraft_engine::Input::Enter).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(s.clipboard_base, Vec2::new(3.0, 4.0));
|
||||
assert_eq!(model(&s).iter().map(|e| e.handle).collect::<Vec<_>>(), [hs[0]]);
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "one undo step");
|
||||
|
||||
// With a pickfirst selection, the base point is the only prompt.
|
||||
s.set_selection(vec![hs[0]]);
|
||||
s.start("cutbase").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Point(Vec2::new(1.0, 2.0))).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.doc().unwrap().model.is_empty());
|
||||
assert_eq!(s.clipboard_base, Vec2::new(1.0, 2.0));
|
||||
assert!(s.selection().is_empty());
|
||||
}
|
||||
|
||||
/// A stand-in for the DXF writer and reader: "writes" the drawing to memory.
|
||||
static WRITTEN: Mutex<Option<Drawing>> = Mutex::new(None);
|
||||
|
||||
|
||||
@@ -104,16 +104,40 @@ fn navigates_areas_and_closes() {
|
||||
assert!(s.compare.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_a_file_read_through_the_io_hooks() {
|
||||
// Test file format: the bytes are "x" — a drawing holding a line from 0,0 to x,0.
|
||||
/// The file-reading hook both tests here share (it is set once per process). Test file format:
|
||||
/// "xref" is a drawing with a reference to xref Y at `y.dwg`; a number x is a drawing holding a
|
||||
/// line from 0,0 to x,0.
|
||||
fn hooks() {
|
||||
fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
|
||||
let x: f64 = String::from_utf8_lossy(bytes).trim().parse().map_err(|_| "bad test file")?;
|
||||
let mut d = Drawing::new_imperial();
|
||||
if bytes == b"xref" {
|
||||
let mut y = Block::new("Y");
|
||||
y.xref_path = Some("y.dwg".into());
|
||||
d.blocks.insert("Y".into(), Arc::new(y));
|
||||
let insert = Insert {
|
||||
block: "Y".into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).map_err(|e| e.to_string())?;
|
||||
return Ok(d);
|
||||
}
|
||||
let x: f64 = String::from_utf8_lossy(bytes).trim().parse().map_err(|_| "bad test file")?;
|
||||
d.add(&Space::Model, Common::default(), line(0.0, 0.0, x, 0.0)).map_err(|e| e.to_string())?;
|
||||
Ok(d)
|
||||
}
|
||||
set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_a_file_read_through_the_io_hooks() {
|
||||
hooks();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-compare-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("other.dxf");
|
||||
@@ -194,3 +218,44 @@ fn derived_dimension_fields_are_not_differences() {
|
||||
let r = s.execute("compare", &json!({"document": 0})).unwrap();
|
||||
assert_eq!(counts(&r), (0, 0, 0, 1), "{r}");
|
||||
}
|
||||
|
||||
/// COMPAREEXPORT (#2049): an xref path relative to the compared file keeps that file's folder.
|
||||
#[test]
|
||||
fn relative_xref_paths_of_a_compared_file_take_that_files_folder() {
|
||||
hooks();
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("compareexport-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("other.dxf");
|
||||
std::fs::write(&path, "xref").unwrap();
|
||||
// Named by its absolute path and by a path relative to the working directory.
|
||||
let mut names = vec![path.to_string_lossy().to_string()];
|
||||
names.extend(relative_to_cwd(&path));
|
||||
for name in names {
|
||||
let mut s = Session::new();
|
||||
s.execute("compare", &json!({"path": name})).unwrap();
|
||||
// The compared file's xref is only there; the copy keeps the file's folder.
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let got = s.doc().unwrap().block("Y").unwrap().xref_path.clone().unwrap();
|
||||
let got = std::path::Path::new(&got);
|
||||
assert!(got.is_absolute(), "{got:?}, compared as {name}");
|
||||
assert_eq!(got.file_name(), Some(std::ffi::OsStr::new("y.dwg")));
|
||||
assert_eq!(got.parent().unwrap().canonicalize().unwrap(), dir.canonicalize().unwrap(), "compared as {name}");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// `p` as a path relative to the working directory (`..` steps up to the shared folder), when
|
||||
/// the two share a root.
|
||||
fn relative_to_cwd(p: &std::path::Path) -> Option<String> {
|
||||
use std::path::Component;
|
||||
let cwd = std::env::current_dir().ok()?;
|
||||
let (a, b): (Vec<Component>, Vec<Component>) = (cwd.components().collect(), p.components().collect());
|
||||
let shared = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
|
||||
if shared == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut rel = std::path::PathBuf::new();
|
||||
a[shared..].iter().for_each(|_| rel.push(".."));
|
||||
b[shared..].iter().for_each(|c| rel.push(c));
|
||||
Some(rel.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
@@ -59,6 +59,38 @@ fn attdisp_sets_attmode_and_the_display_follows_it() {
|
||||
assert!(s.execute("attdisp", &json!({"mode": "sometimes"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_attribute_display_menu_sets_attmode() {
|
||||
let mut s = Session::new();
|
||||
for (id, item, mode) in [("attdisp.on", "On", 2), ("attdisp.off", "Off", 0), ("attdisp.normal", "Normal", 1)] {
|
||||
let spec = cadcraft_engine::find_command(id).unwrap_or_else(|| panic!("no command {id}"));
|
||||
assert_eq!(spec.menu, ["View", "Attribute Display", item]);
|
||||
s.start(id).unwrap();
|
||||
assert!(s.running.is_none(), "{id}: {}", s.prompt_text());
|
||||
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), mode, "{id}");
|
||||
}
|
||||
assert_eq!(s.execute("attdisp.off", &json!({})).unwrap(), json!({"attmode": 0}));
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attdisp_runs_transparently_inside_another_command() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("LINE 0,0").unwrap();
|
||||
s.cmdline("'ATTDISP").unwrap();
|
||||
assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("attdisp"));
|
||||
assert!(s.prompt_text().starts_with(">>"), "{}", s.prompt_text());
|
||||
s.cmdline("off").unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 0);
|
||||
// LINE prompts again for its next point.
|
||||
assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("line"), "{}", s.prompt_text());
|
||||
s.cmdline("10,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.doc().unwrap().model.iter().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attsync_brings_references_in_line_with_the_definitions() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -40,6 +40,12 @@ fn a_block_cannot_be_redefined_to_reference_itself() {
|
||||
let r = s.execute("block", &json!({ "name": "a", "base": [0, 0], "handles": [b] }));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("references itself")));
|
||||
assert_eq!(contents(&s, "A"), ["line"]);
|
||||
|
||||
// Through a multileader whose content is the block being redefined.
|
||||
let m = s.execute("mleader", &json!({ "points": [[0, 0], [10, 5]], "block": "A" })).unwrap()["handle"].clone();
|
||||
let r = s.execute("block", &json!({ "name": "A", "base": [0, 0], "handles": [m] }));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("references itself")));
|
||||
assert_eq!(contents(&s, "A"), ["line"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! BLOCKREPLACE (Express Tools): every reference of one block, in model space, the layouts and
|
||||
//! other blocks, becomes a reference of another at the same insertion point, scale and rotation.
|
||||
|
||||
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
|
||||
use cadcraft_engine::doc::geom::Vec3;
|
||||
use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, RawTag, Space};
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Make block `name` from what is in model space now (the objects are removed).
|
||||
fn make_block(s: &mut Session, name: &str) {
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("block", &json!({"name": name, "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
|
||||
}
|
||||
|
||||
/// Block A: a circle and attribute TAG. Block B: a line, attributes TAG and NEW. Block N holds a
|
||||
/// reference of A. Model space: a reference of A on layer R at (10, 5), scaled 2 and rotated
|
||||
/// 30°, with TAG "kept", and a reference of N. Layout1: a reference of A.
|
||||
fn drawing() -> (Session, Handle) {
|
||||
let mut s = Session::new();
|
||||
s.execute("attdef", &json!({"tag": "TAG", "default": "a", "at": [0, 2], "height": 1.0})).unwrap();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
make_block(&mut s, "A");
|
||||
s.execute("attdef", &json!({"tag": "TAG", "default": "b", "at": [0, 3], "height": 1.0})).unwrap();
|
||||
s.execute("attdef", &json!({"tag": "NEW", "default": "new", "at": [0, 5], "height": 1.0})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
make_block(&mut s, "B");
|
||||
s.execute("insert", &json!({"name": "A", "at": [0, 0]})).unwrap();
|
||||
make_block(&mut s, "N");
|
||||
s.execute("insert", &json!({"name": "N", "at": [50, 0]})).unwrap();
|
||||
s.execute("layer.new", &json!({"name": "R", "current": true})).unwrap();
|
||||
let r = s.execute("insert", &json!({"name": "A", "at": [10, 5], "scale": 2, "rotation": 30, "attribs": {"TAG": "kept"}})).unwrap();
|
||||
let ins = Insert {
|
||||
block: "A".into(),
|
||||
insert: Vec3::new(0.0, 0.0, 0.0),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap();
|
||||
(s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap())
|
||||
}
|
||||
|
||||
/// The block names of the references in `ents`.
|
||||
fn refs<'a>(ents: impl Iterator<Item = &'a std::sync::Arc<Entity>>) -> Vec<String> {
|
||||
ents.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn insert(s: &Session, h: Handle) -> (Insert, String) {
|
||||
let e = s.doc().unwrap().entity(h).unwrap();
|
||||
let EntityKind::Insert(i) = &e.kind else { panic!("not a block reference") };
|
||||
(i.clone(), e.common.layer.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_replaces_every_reference_and_keeps_its_placement() {
|
||||
let (mut s, h) = drawing();
|
||||
let before = insert(&s, h).0;
|
||||
let r = s.execute("blockreplace", &json!({"block": "a", "with": "b"})).unwrap();
|
||||
assert_eq!(r, json!({"block": "A", "with": "B", "replaced": 3, "purged": false}));
|
||||
|
||||
let (after, layer) = insert(&s, h);
|
||||
assert_eq!(after.block, "B");
|
||||
assert_eq!((after.insert, after.scale, after.rotation, layer.as_str()), (before.insert, before.scale, before.rotation, "R"));
|
||||
// The replacement's attributes, with the value of the tag both blocks define.
|
||||
let values: Vec<(String, String)> = after.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect();
|
||||
assert_eq!(values, [("TAG".to_string(), "kept".to_string()), ("NEW".to_string(), "new".to_string())]);
|
||||
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(refs(d.model.iter()), ["N", "B"]);
|
||||
assert_eq!(refs(d.block("N").unwrap().entities.iter()), ["B"]);
|
||||
assert_eq!(refs(d.space(&Space::Paper("Layout1".into())).unwrap().iter()), ["B"]);
|
||||
assert!(d.block("A").is_some(), "the definition stays without `purge`");
|
||||
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(insert(&s, h).0, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_references_follow_the_replacement_blocks_annotative_setting() {
|
||||
let (mut s, h) = drawing();
|
||||
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap()["handle"].clone();
|
||||
s.execute("block", &json!({"name": "AN", "base": [0, 0], "handles": [c], "keep": "delete", "annotative": true})).unwrap();
|
||||
let scales = |s: &Session| s.doc().unwrap().entity(h).unwrap().common.anno_scales.len();
|
||||
assert_eq!(scales(&s), 0);
|
||||
s.execute("blockreplace", &json!({"block": "A", "with": "AN"})).unwrap();
|
||||
assert_eq!(scales(&s), 1, "a reference of an annotative block supports the current scale");
|
||||
s.execute("blockreplace", &json!({"block": "AN", "with": "B"})).unwrap();
|
||||
assert_eq!(scales(&s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_purges_the_replaced_definition_on_request() {
|
||||
let (mut s, _) = drawing();
|
||||
let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap();
|
||||
assert_eq!(r["purged"], json!(true));
|
||||
assert!(s.doc().unwrap().block("A").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_keeps_a_definition_an_unused_block_still_names() {
|
||||
let (mut s, _) = drawing();
|
||||
// Block U, never inserted, holds a multileader whose content is block A.
|
||||
let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "A"})).unwrap()["handle"].clone();
|
||||
s.execute("block", &json!({"name": "U", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap();
|
||||
let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap();
|
||||
assert_eq!((r["replaced"].clone(), r["purged"].clone()), (json!(3), json!(false)));
|
||||
assert!(s.doc().unwrap().block("A").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_refuses_what_it_cannot_replace() {
|
||||
let (mut s, _) = drawing();
|
||||
let err = |s: &mut Session, p: Value| s.execute("blockreplace", &p).unwrap_err().to_string();
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "a"})).contains("different block"));
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "MISSING"})).contains("not found"));
|
||||
assert!(err(&mut s, json!({"block": "MISSING", "with": "B"})).contains("not found"));
|
||||
// N holds a reference of A, so N replacing A would reference itself.
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "N"})).contains("references"));
|
||||
// M holds a multileader whose content is N, so M reaches A too.
|
||||
let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "N"})).unwrap()["handle"].clone();
|
||||
s.execute("block", &json!({"name": "M", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap();
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "M"})).contains("references"));
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "B", "purge": "yes"})).contains("`purge`"));
|
||||
assert!(err(&mut s, json!({"with": "B"})).contains("`block`"));
|
||||
// A block an xref brings in is not saved with the drawing.
|
||||
s.doc_mut().unwrap().blocks.insert("X|A".into(), std::sync::Arc::new(Block::new("X|A")));
|
||||
assert!(err(&mut s, json!({"block": "X|A", "with": "B"})).contains("xref"));
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "X|A"})).contains("xref"));
|
||||
assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]);
|
||||
// The Block Editor shows the block, not the drawing's references.
|
||||
s.execute("bedit", &json!({"name": "B"})).unwrap();
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "B"})).contains("Block Editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_asks_for_both_blocks_and_whether_to_purge() {
|
||||
let (mut s, h) = drawing();
|
||||
s.cmdline("-blockreplace").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter the name of the block to replace or [?/Select]"), "{}", s.prompt_text());
|
||||
s.cmdline("?").unwrap();
|
||||
assert!(s.log.iter().any(|l| l == "Blocks: A, B, N"), "{:?}", s.log);
|
||||
s.cmdline("a").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter the name of the replacement block for A or [?/Select]"), "{}", s.prompt_text());
|
||||
// A cannot replace itself: the prompt asks again.
|
||||
s.cmdline("A").unwrap();
|
||||
assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text());
|
||||
s.input(Input::Cancel).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]);
|
||||
|
||||
// The block to replace picked from one of its references; Enter purges it.
|
||||
s.cmdline("blockreplace").unwrap();
|
||||
s.cmdline("Select").unwrap();
|
||||
s.input(Input::Pick(vec![h])).unwrap();
|
||||
assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text());
|
||||
s.cmdline("B").unwrap();
|
||||
assert!(s.prompt_text().contains("Purge the unreferenced block definition A? [Yes/No] <Yes>"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.log.iter().any(|l| l == "3 references of block A replaced with B. Block A purged."), "{:?}", s.log);
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.block("A").is_none());
|
||||
assert_eq!(refs(d.model.iter()), ["N", "B"]);
|
||||
assert_eq!(insert(&s, h).0.block, "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_no_keeps_the_replaced_definition() {
|
||||
let (mut s, _) = drawing();
|
||||
s.cmdline("blockreplace").unwrap();
|
||||
s.cmdline("A").unwrap();
|
||||
s.cmdline("B").unwrap();
|
||||
s.cmdline("No").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.block("A").is_some());
|
||||
assert_eq!(refs(d.model.iter()), ["N", "B"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_refuses_a_block_as_soon_as_its_name_is_given() {
|
||||
let (mut s, _) = drawing();
|
||||
s.cmdline("blockreplace").unwrap();
|
||||
s.cmdline("A").unwrap();
|
||||
// N holds a reference of A: refused at once, and the prompt asks for another name.
|
||||
s.cmdline("N").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Block \"N\" references \"A\"")), "{:?}", s.log);
|
||||
assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text());
|
||||
s.cmdline("B").unwrap();
|
||||
assert!(s.prompt_text().contains("Purge the unreferenced block definition A?"), "{}", s.prompt_text());
|
||||
s.input(Input::Cancel).unwrap();
|
||||
|
||||
// A dynamic block is refused as the block to replace.
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
let mut f = Foreign::default();
|
||||
f.records.insert(("BLOCK_RECORD".into(), "A".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() });
|
||||
let tags = |t: &[(i32, &str)]| t.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect();
|
||||
f.objects.push(ForeignObject {
|
||||
handle: Handle(0xF00),
|
||||
kind: "DICTIONARY".into(),
|
||||
tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]),
|
||||
});
|
||||
f.objects.push(ForeignObject { handle: Handle(0xF01), kind: "ACAD_EVALUATION_GRAPH".into(), tags: tags(&[(100, "AcDbEvalGraph")]) });
|
||||
d.foreign = std::sync::Arc::new(f);
|
||||
s.open_drawing(d, "dynamic", None);
|
||||
let e = s.execute("blockreplace", &json!({"block": "A", "with": "B"})).unwrap_err().to_string();
|
||||
assert!(e.contains("dynamic block"), "{e}");
|
||||
s.cmdline("blockreplace").unwrap();
|
||||
s.cmdline("A").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Block \"A\" is a dynamic block")), "{:?}", s.log);
|
||||
assert!(s.prompt_text().contains("Enter the name of the block to replace"), "{}", s.prompt_text());
|
||||
s.input(Input::Cancel).unwrap();
|
||||
assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]);
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
//! COMPAREEXPORT: the comparison as a new drawing, with this drawing's objects in one group and
|
||||
//! the other drawing's differing objects in another, in the comparison colors. A compared file's
|
||||
//! relative xref paths, which need the file-reading hook, are tested in `tests/compare.rs`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::color::{Color, Rgb};
|
||||
use cadcraft_engine::doc::geom::Vec3;
|
||||
use cadcraft_engine::doc::{Block, CellStyle, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, MLeader, Space};
|
||||
use serde_json::json;
|
||||
|
||||
fn insert(block: &str) -> Insert {
|
||||
Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(x: f64) -> EntityKind {
|
||||
EntityKind::Line(cadcraft_engine::doc::Line { a: Vec3::ZERO, b: Vec3::new(x, 0.0, 0.0) })
|
||||
}
|
||||
|
||||
fn handle_of(s: &Session, ty: &str) -> String {
|
||||
s.doc().unwrap().model.iter().find(|e| e.kind.type_name() == ty).unwrap().handle.hex()
|
||||
}
|
||||
|
||||
/// "Drawing1" holds a line, a circle and a text; "B" is a copy where the circle moved, the text
|
||||
/// is gone and an arc was added. The comparison runs in "B" against "Drawing1".
|
||||
fn compared() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap();
|
||||
s.execute("text", &json!({"at": [0, 10], "height": 1, "text": "Note"})).unwrap();
|
||||
let d: Drawing = s.doc().unwrap().clone();
|
||||
s.open_drawing(d, "B.dxf", None);
|
||||
let circle = handle_of(&s, "Circle");
|
||||
let text = handle_of(&s, "Text");
|
||||
s.execute("move", &json!({"handles": [circle], "from": [0, 0], "to": [3, 0]})).unwrap();
|
||||
s.execute("erase", &json!({"handles": [text]})).unwrap();
|
||||
s.execute("arc", &json!({"center": [20, 0], "radius": 1, "start": 0, "end": 90})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Drawing1"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The objects of a group of the active drawing, in group order.
|
||||
fn members(s: &Session, group: &str) -> Vec<Arc<Entity>> {
|
||||
let d = s.doc().unwrap();
|
||||
let g = d.groups.iter().find(|g| g.name == group).unwrap_or_else(|| panic!("no group {group}"));
|
||||
g.members.iter().map(|h| d.entity(*h).unwrap().clone()).collect()
|
||||
}
|
||||
|
||||
/// The (type, color) of each object in a group of the active drawing, sorted.
|
||||
fn contents(s: &Session, group: &str) -> Vec<(String, Color)> {
|
||||
let mut v: Vec<(String, Color)> = members(s, group).iter().map(|e| (e.kind.type_name().to_string(), e.common.color)).collect();
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
v
|
||||
}
|
||||
|
||||
/// The x of each line's end in a block of the active drawing.
|
||||
fn ends(s: &Session, block: &str) -> Vec<f64> {
|
||||
let d = s.doc().unwrap();
|
||||
d.block(block)
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => Some(l.b.x),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const GREEN: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b));
|
||||
const RED: Color = Color::True(Rgb(0xff, 0x5c, 0x5c));
|
||||
const GRAY: Color = Color::Index(8);
|
||||
|
||||
#[test]
|
||||
fn exports_both_drawings_as_colored_groups_in_a_new_drawing() {
|
||||
let mut s = compared();
|
||||
let before = s.docs.len();
|
||||
let before_epoch = s.doc_epoch;
|
||||
let source = s.doc().unwrap().clone();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert!(s.doc_epoch > before_epoch, "opening the exported drawing invalidates queued captures");
|
||||
assert_eq!(s.docs.len(), before + 1, "{r}");
|
||||
assert_eq!(r["title"], "compare_B vs Drawing1.dwg", "{r}");
|
||||
assert_eq!(s.state().unwrap().title, "compare_B vs Drawing1.dwg");
|
||||
assert_eq!(s.state().unwrap().path, None, "the snapshot is untitled");
|
||||
assert!(s.state().unwrap().is_dirty(), "closing the snapshot asks to save it");
|
||||
assert_eq!((r["counts"]["current"].as_u64(), r["counts"]["compared"].as_u64()), (Some(3), Some(2)), "{r}");
|
||||
assert_eq!(r["truncated"], false);
|
||||
let (cur, other) = (r["current"].as_str().unwrap(), r["compared"].as_str().unwrap());
|
||||
assert_eq!((cur, other), ("COMPARE_CURRENT", "COMPARE_COMPARED"));
|
||||
// This drawing: the moved circle and the new arc in green, the unchanged line in gray.
|
||||
assert_eq!(contents(&s, cur), vec![("Arc".into(), GREEN), ("Circle".into(), GREEN), ("Line".into(), GRAY)]);
|
||||
// The other drawing: the circle where it was and the erased text, in red, in its draw order.
|
||||
assert_eq!(contents(&s, other), vec![("Circle".into(), RED), ("Text".into(), RED)]);
|
||||
let order: Vec<&str> = members(&s, other).iter().map(|e| e.kind.type_name()).collect();
|
||||
assert_eq!(order, ["Circle", "Text"]);
|
||||
let circle_there = members(&s, other).iter().find_map(|e| match &e.kind {
|
||||
EntityKind::Circle(c) => Some(c.center),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(circle_there.map(|c| (c.x, c.y)), Some((5.0, 5.0)));
|
||||
// Model space holds the other drawing's objects first, then this drawing's.
|
||||
let d = s.doc().unwrap();
|
||||
let order: Vec<Handle> = d.model.iter().map(|e| e.handle).collect();
|
||||
let groups: Vec<Handle> = members(&s, other).iter().chain(members(&s, cur).iter()).map(|e| e.handle).collect();
|
||||
assert_eq!(order, groups);
|
||||
// Every handle is new and unique.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for e in d.model.iter().chain(d.blocks.values().flat_map(|b| b.entities.iter())) {
|
||||
assert!(seen.insert(e.handle), "handle {} repeats", e.handle.hex());
|
||||
}
|
||||
// The drawing COMPARE ran in is unchanged, and the comparison stays on it.
|
||||
let back = s.docs.iter().position(|d| d.title == "B.dxf").unwrap();
|
||||
assert!(*s.docs[back].doc == source);
|
||||
assert!(s.compare.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_the_other_drawings_layers_and_blocks_it_needs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
// The other drawing holds a reference to a block this one lacks, on a layer this one lacks.
|
||||
let mut other = s.doc().unwrap().clone();
|
||||
other.handseed = 0x9000;
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(0x8000), EntityKind::Insert(insert("BOLT"))));
|
||||
let mut bolt = Block::new("BOLT");
|
||||
bolt.entities.push(Entity::new(Handle(0x8001), line(1.0)));
|
||||
other.blocks.insert("PART".into(), Arc::new(part));
|
||||
other.blocks.insert("BOLT".into(), Arc::new(bolt));
|
||||
other.layers.push(Layer::new("Parts"));
|
||||
other.add(&Space::Model, Common { layer: "Parts".into(), ..Common::default() }, EntityKind::Insert(insert("PART"))).unwrap();
|
||||
// A group of this drawing's name does not carry over, so the snapshot takes the first name.
|
||||
s.execute("group", &json!({"name": "COMPARE_CURRENT", "handles": [handle_of(&s, "Line")]})).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(r["current"], "COMPARE_CURRENT", "{r}");
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.groups.len(), 2);
|
||||
assert!(d.layer("Parts").is_some());
|
||||
assert!(d.block("PART").is_some() && d.block("BOLT").is_some(), "nested blocks are copied");
|
||||
assert_eq!(ends(&s, "COMPARE_COMPARED_BOLT"), vec![1.0], "and shown through red copies");
|
||||
assert!(d.blocks.values().flat_map(|b| b.entities.iter()).all(|e| e.handle.0 < 0x8000 || e.handle.0 >= 0x9000));
|
||||
let refs: Vec<String> = members(&s, "COMPARE_COMPARED")
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(format!("{} on {}", i.block, e.common.layer)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(refs, vec!["COMPARE_COMPARED_PART on Parts".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_blocks_of_the_other_drawing_keep_their_own_definition() {
|
||||
let mut s = Session::new();
|
||||
let mut mine = Block::new("*U1");
|
||||
mine.entities.push(Entity::new(Handle(0x700), line(1.0)));
|
||||
s.doc_mut().unwrap().blocks.insert("*U1".into(), Arc::new(mine));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("*U1"))).unwrap();
|
||||
// The other drawing uses the same anonymous name for other geometry.
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
let mut theirs = Block::new("*U1");
|
||||
theirs.entities.push(Entity::new(Handle(0x8000), line(5.0)));
|
||||
other.blocks.insert("*U1".into(), Arc::new(theirs));
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("*U1") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(ends(&s, "*U1"), vec![1.0], "this drawing's definition is kept");
|
||||
let referenced: Vec<String> = members(&s, r["compared"].as_str().unwrap())
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// The other drawing's `*U1` is copied as `*U2`, and the reference shows it through a red copy.
|
||||
assert_eq!(ends(&s, "*U2"), vec![5.0]);
|
||||
assert_eq!(referenced, vec!["COMPARE_COMPARED_U2".to_string()], "{r}");
|
||||
assert_eq!(ends(&s, "COMPARE_COMPARED_U2"), vec![5.0]);
|
||||
assert_eq!(ends(&s, "COMPARE_CURRENT_U1"), vec![1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_defined_differently_in_the_other_drawing_shows_the_other_definition() {
|
||||
// PART holds a line, a reference to SUB, which holds a line of length 2, and a multileader
|
||||
// whose content is SYMBOL, which holds one line.
|
||||
let blocks = |part_end: f64, part_base: f64, sub_base: f64, symbol_end: f64, first: u64| {
|
||||
let mut part = Block::new("PART");
|
||||
part.base = Vec3::new(part_base, 0.0, 0.0);
|
||||
part.entities.push(Entity::new(Handle(first), line(part_end)));
|
||||
part.entities.push(Entity::new(Handle(first + 1), EntityKind::Insert(insert("SUB"))));
|
||||
let leader = MLeader {
|
||||
leaders: Vec::new(),
|
||||
landing: Vec3::ZERO,
|
||||
dogleg: 0.0,
|
||||
text: None,
|
||||
style: "Standard".into(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: Some(insert("SYMBOL")),
|
||||
};
|
||||
part.entities.push(Entity::new(Handle(first + 2), EntityKind::MLeader(leader)));
|
||||
let mut sub = Block::new("SUB");
|
||||
sub.base = Vec3::new(sub_base, 0.0, 0.0);
|
||||
sub.entities.push(Entity::new(Handle(first + 3), line(2.0)));
|
||||
let mut symbol = Block::new("SYMBOL");
|
||||
symbol.entities.push(Entity::new(Handle(first + 4), line(symbol_end)));
|
||||
[part, sub, symbol]
|
||||
};
|
||||
let run = |theirs_end: f64, theirs_base: f64, theirs_sub_base: f64, theirs_symbol_end: f64| {
|
||||
let mut s = Session::new();
|
||||
for b in blocks(1.0, 0.0, 0.0, 3.0, 0x700) {
|
||||
s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
for b in blocks(theirs_end, theirs_base, theirs_sub_base, theirs_symbol_end, 0x8000) {
|
||||
other.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
// A second reference, so the other drawing has one that differs even when PART does not.
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let referenced: Vec<String> = members(&s, r["compared"].as_str().unwrap())
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(s, referenced)
|
||||
};
|
||||
// The other drawing's PART draws a longer line: it is copied as PART_2 and shown in red.
|
||||
let (s, referenced) = run(5.0, 0.0, 0.0, 3.0);
|
||||
assert_eq!(ends(&s, "PART"), vec![1.0], "this drawing's definition is kept");
|
||||
assert_eq!(ends(&s, "PART_2"), vec![5.0]);
|
||||
assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]);
|
||||
assert_eq!(ends(&s, "COMPARE_COMPARED_PART_2"), vec![5.0]);
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.block("SUB_2").is_none() && d.block("SYMBOL_2").is_none(), "SUB and SYMBOL are the same in both drawings");
|
||||
// The blocks a copy of PART refers to.
|
||||
let refers = |s: &Session| -> Vec<String> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.block("PART_2")
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.flat_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// Another base point on PART, another base point on the SUB inside it, or another SYMBOL
|
||||
// in its multileader: PART is copied too, with the blocks that differ.
|
||||
let (s, referenced) = run(1.0, 3.0, 0.0, 3.0);
|
||||
assert_eq!(s.doc().unwrap().block("PART_2").map(|b| b.base.x), Some(3.0));
|
||||
assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]);
|
||||
assert_eq!(refers(&s), ["SUB", "SYMBOL"]);
|
||||
let (s, _) = run(1.0, 0.0, 4.0, 3.0);
|
||||
assert_eq!(s.doc().unwrap().block("SUB_2").map(|b| b.base.x), Some(4.0));
|
||||
assert_eq!(refers(&s), ["SUB_2", "SYMBOL"]);
|
||||
let (s, _) = run(1.0, 0.0, 0.0, 6.0);
|
||||
assert_eq!(ends(&s, "SYMBOL_2"), vec![6.0]);
|
||||
assert_eq!(refers(&s), ["SUB", "SYMBOL_2"]);
|
||||
// The same definitions in both drawings: the red reference uses this drawing's PART.
|
||||
let (s, referenced) = run(1.0, 0.0, 0.0, 3.0);
|
||||
assert!(s.doc().unwrap().block("PART_2").is_none());
|
||||
assert_eq!(referenced, vec!["COMPARE_COMPARED_PART".to_string()]);
|
||||
assert_eq!(ends(&s, "COMPARE_COMPARED_PART"), vec![1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition() {
|
||||
use cadcraft_engine::doc::{DimKind, Dimension};
|
||||
// PART holds a dimension with the same parameters in both drawings; the block it draws, `*D1`,
|
||||
// holds a line of another length in the other drawing.
|
||||
let dim = Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(15.0, 5.0, 0.0),
|
||||
text_mid: Vec3::new(7.0, 5.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(30.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 30.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some("*D1".into()),
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
let blocks = |dim_end: f64, first: u64| {
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim.clone())));
|
||||
let mut drawn = Block::new("*D1");
|
||||
drawn.entities.push(Entity::new(Handle(first + 1), line(dim_end)));
|
||||
[part, drawn]
|
||||
};
|
||||
let mut s = Session::new();
|
||||
for b in blocks(1.0, 0x700) {
|
||||
s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
for b in blocks(5.0, 0x8000) {
|
||||
other.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(ends(&s, "*D1"), vec![1.0], "this drawing's dimension block is kept");
|
||||
let d = s.doc().unwrap();
|
||||
let drawn: Vec<Option<String>> = d
|
||||
.block("PART_2")
|
||||
.map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some(dm.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(drawn, [Some("*D2".to_string())], "the other drawing's PART is copied with its dimension block");
|
||||
assert_eq!(ends(&s, "*D2"), vec![5.0]);
|
||||
// The colored copies of PART refer to colored copies of the dimension block, which are
|
||||
// anonymous `*D` blocks of their own, not the originals.
|
||||
let d = s.doc().unwrap();
|
||||
let copies: Vec<String> = d
|
||||
.blocks
|
||||
.values()
|
||||
.filter(|b| b.name.starts_with("COMPARE_"))
|
||||
.flat_map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some(dm.block.clone().unwrap_or_default()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
assert!(!copies.is_empty(), "PART has colored copies");
|
||||
for n in &copies {
|
||||
assert!(n.starts_with("*D") && !["*D1", "*D2"].contains(&n.as_str()), "{n} in {copies:?}");
|
||||
assert!(d.block(n).is_some_and(|b| b.anonymous), "{n} is an anonymous block");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_drawings_definition() {
|
||||
use cadcraft_engine::doc::{DimKind, Dimension};
|
||||
// PART holds the same dimension in both drawings; the block it draws holds the same line but
|
||||
// is named `*D1` here and `*D3` in the other drawing.
|
||||
let blocks = |drawn_name: &str, first: u64| {
|
||||
let dim = Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(15.0, 5.0, 0.0),
|
||||
text_mid: Vec3::new(7.0, 5.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(30.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 30.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some(drawn_name.into()),
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim)));
|
||||
let mut drawn = Block::new(drawn_name);
|
||||
drawn.entities.push(Entity::new(Handle(first + 1), line(1.0)));
|
||||
[part, drawn]
|
||||
};
|
||||
let mut s = Session::new();
|
||||
for b in blocks("*D1", 0x700) {
|
||||
s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
for b in blocks("*D3", 0x8000) {
|
||||
other.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().block("PART_2").is_none(), "PART draws the same thing in both drawings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_whose_line_is_hidden_or_thicker_in_the_other_drawing_shows_the_other_definition() {
|
||||
let run = |theirs: Common| {
|
||||
let part = |common: Common, first: u64| {
|
||||
let mut part = Block::new("PART");
|
||||
let mut e = Entity::new(Handle(first), line(1.0));
|
||||
e.common = common;
|
||||
part.entities.push(e);
|
||||
part
|
||||
};
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(Common::default(), 0x700)));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
other.blocks.insert("PART".into(), Arc::new(part(theirs, 0x8000)));
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
s.doc().unwrap().block("PART_2").is_some()
|
||||
};
|
||||
assert!(run(Common { visible: false, ..Common::default() }), "a hidden line");
|
||||
assert!(run(Common { thickness: 2.0, ..Common::default() }), "a line with thickness");
|
||||
assert!(!run(Common::default()), "the same line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_with_the_same_relative_image_path_from_another_folder_shows_the_other_definition() {
|
||||
// PART holds an image at `a.png` in both drawings; this drawing is unsaved, the other one is
|
||||
// a file in another folder, so its `a.png` is another file.
|
||||
let part = |first: u64| {
|
||||
let at = serde_json::to_value(Vec3::ZERO).unwrap();
|
||||
let image = serde_json::from_value(json!({"insert": at, "u": at, "v": at, "size": [1.0, 1.0], "path": "a.png"})).unwrap();
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(first), EntityKind::Image(image)));
|
||||
part
|
||||
};
|
||||
let dir = std::env::temp_dir().join("compareexport-image-folder");
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(0x700)));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
other.blocks.insert("PART".into(), Arc::new(part(0x8000)));
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", Some(dir.join("other.dxf").to_string_lossy().to_string()));
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let paths: Vec<String> = d
|
||||
.block("PART_2")
|
||||
.map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Image(i) => Some(i.path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(paths.len(), 1, "the other drawing's PART is copied");
|
||||
assert_eq!(std::path::Path::new(&paths[0]).parent(), Some(std::path::absolute(&dir).unwrap().as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() {
|
||||
use cadcraft_engine::doc::{DimKind, Dimension};
|
||||
// The dimension's block holds a line its parameters would not draw: a block read from a file.
|
||||
let dim = Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(15.0, 5.0, 0.0),
|
||||
text_mid: Vec3::new(7.0, 5.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(30.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 30.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some("*D1".into()),
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let mut drawn = Block::new("*D1");
|
||||
drawn.entities.push(Entity::new(Handle(0x701), line(123.0)));
|
||||
s.doc_mut().unwrap().blocks.insert("*D1".into(), Arc::new(drawn));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim)).unwrap();
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
|
||||
let block = read
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => dm.block.clone(),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let ends: Vec<f64> = read
|
||||
.block(&block)
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => Some(l.b.x),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ends, [123.0], "the saved dimension block is the copy's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_contents_take_the_comparison_color() {
|
||||
let mut s = Session::new();
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity { handle: Handle(0x700), common: Common { color: Color::Index(1), ..Common::default() }, kind: line(1.0) });
|
||||
part.entities.push(Entity::new(Handle(0x701), EntityKind::Insert(insert("PART"))));
|
||||
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = s.doc().unwrap().clone();
|
||||
other.model = Default::default();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let objs = members(&s, r["current"].as_str().unwrap());
|
||||
let EntityKind::Insert(i) = &objs[0].kind else { panic!("{r}") };
|
||||
assert_eq!(i.block, "COMPARE_CURRENT_PART");
|
||||
let d = s.doc().unwrap();
|
||||
let copy = d.block("COMPARE_CURRENT_PART").unwrap();
|
||||
assert!(copy.entities.iter().all(|e| e.common.color == GREEN), "the line inside is green too");
|
||||
// The block that refers to itself refers to its own copy, which ends the copying.
|
||||
assert!(copy.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(n) if n.block == "COMPARE_CURRENT_PART")));
|
||||
// This drawing's definition is left as it was.
|
||||
assert_eq!(d.block("PART").unwrap().entities.iter().next().unwrap().common.color, Color::Index(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_carried_apart_from_the_object_color_are_replaced() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mtext", &json!({"at": [0, 0], "height": 1, "width": 20, "text": "a{\\C1;red} \\c16711680;b \\\\C"})).unwrap();
|
||||
s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [10, 0], "at": [5, 3]})).unwrap();
|
||||
s.execute("table", &json!({"at": [0, 20], "rows": 1, "cols": 1, "cells": [["\\C1;x"]]})).unwrap();
|
||||
s.execute("tolerance", &json!({"at": [0, 30], "symbol": "flatness", "tol1": "0.1"})).unwrap();
|
||||
s.execute("mline", &json!({"points": [[0, 40], [10, 40]]})).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.dim_styles[0].dim_line_color = Color::Index(1);
|
||||
d.dim_styles[0].text_color = Color::Index(5);
|
||||
let mut data = d.table_styles[0].cell_style(CellStyle::DATA);
|
||||
(data.text_color, data.fill, data.borders.top.color) = (Color::Index(1), Some(Color::Index(2)), Color::Index(3));
|
||||
d.table_styles[0].cell_styles.push(data);
|
||||
let mline = d.model.iter().find(|e| matches!(e.kind, EntityKind::MLine(_))).unwrap().handle;
|
||||
d.modify_entity(mline, |e| {
|
||||
if let EntityKind::MLine(m) = &mut e.kind {
|
||||
m.def.elements.iter_mut().for_each(|el| el.color = Color::Index(3));
|
||||
m.def.fill = Some(Color::Index(4));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let other = Drawing::new_imperial();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let mut kinds = Vec::new();
|
||||
let d = s.doc().unwrap();
|
||||
for e in members(&s, r["current"].as_str().unwrap()) {
|
||||
match &e.kind {
|
||||
EntityKind::MText(t) => assert_eq!(t.contents, "a{red} b \\\\C", "inline colors are taken out"),
|
||||
EntityKind::Dimension(dm) => {
|
||||
let st = d.dim_style(&dm.style).unwrap().with_overrides(&dm.overrides);
|
||||
assert_eq!((st.dim_line_color, st.ext_line_color, st.text_color), (Color::ByBlock, Color::ByBlock, Color::ByBlock));
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
assert_eq!(t.style, "COMPARE_CURRENT_Standard");
|
||||
assert_eq!(t.cells.iter().flatten().find(|c| !c.text.is_empty()).map(|c| c.text.as_str()), Some("x"));
|
||||
let st = d.table_styles.iter().find(|s| s.name == t.style).unwrap();
|
||||
let data = st.cell_style(CellStyle::DATA);
|
||||
assert_eq!((data.text_color, data.fill, data.borders.top.color), (Color::ByBlock, None, Color::ByBlock));
|
||||
}
|
||||
EntityKind::Tolerance(t) => {
|
||||
assert_eq!(t.style, "COMPARE_CURRENT_Standard");
|
||||
let st = d.dim_style(&t.style).unwrap();
|
||||
assert_eq!((st.dim_line_color, st.text_color), (Color::ByBlock, Color::ByBlock));
|
||||
}
|
||||
EntityKind::MLine(m) => {
|
||||
assert!(m.def.elements.iter().all(|el| el.color == Color::ByBlock));
|
||||
assert_eq!(m.def.fill, None);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
kinds.push(e.kind.type_name());
|
||||
}
|
||||
assert_eq!(kinds.len(), 5, "{kinds:?}");
|
||||
// This drawing's styles are left as they were.
|
||||
assert_eq!(d.table_styles[0].cell_style(CellStyle::DATA).text_color, Color::Index(1));
|
||||
assert_eq!(d.dim_style("Standard").unwrap().dim_line_color, Color::Index(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_colors_of_multiline_attributes_are_replaced() {
|
||||
use cadcraft_engine::doc::Attrib;
|
||||
let attrib = |contents: &str| -> Attrib {
|
||||
let at = serde_json::to_value(Vec3::ZERO).unwrap();
|
||||
serde_json::from_value(json!({
|
||||
"tag": "T",
|
||||
"text": {"insert": at, "height": 1.0, "value": "x"},
|
||||
"multiline": {"mtext": {"insert": at, "height": 1.0, "contents": contents}, "readAs": "x"},
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(0x700), EntityKind::AttDef(attrib("\\C1;x"))));
|
||||
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part));
|
||||
let reference = Insert { attribs: vec![attrib("\\C3;x")], ..insert("PART") };
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap();
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let multiline = |a: &Attrib| a.multiline.as_ref().map(|m| m.mtext.contents.clone());
|
||||
let mut blocks = Vec::new();
|
||||
for e in members(&s, r["current"].as_str().unwrap()) {
|
||||
if let EntityKind::Insert(i) = &e.kind {
|
||||
assert_eq!(i.attribs.iter().map(multiline).collect::<Vec<_>>(), [Some("x".to_string())], "the reference's attribute");
|
||||
blocks.push(i.block.clone());
|
||||
}
|
||||
}
|
||||
assert_eq!(blocks.len(), 1);
|
||||
let d = s.doc().unwrap();
|
||||
let defs: Vec<Option<String>> = d
|
||||
.block(&blocks[0])
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(a) => Some(multiline(a)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(defs, [Some("x".to_string())], "the attribute definition in the recolored block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_after_compare_count_and_objects_keep_their_layers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let copy: Drawing = s.doc().unwrap().clone();
|
||||
s.open_drawing(copy, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
// A line drawn after COMPARE ran is only in this drawing.
|
||||
s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let mut got = contents(&s, r["current"].as_str().unwrap());
|
||||
got.sort_by_key(|(_, c)| *c == GRAY);
|
||||
assert_eq!(got, vec![("Line".into(), GREEN), ("Line".into(), GRAY)], "{r}");
|
||||
// The copies sit in model space on their own layer, not inside a block.
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.model.iter().all(|e| e.common.layer == "0" && matches!(e.kind, EntityKind::Line(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_drop_associations_to_the_source_objects() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let l = handle_of(&s, "Line");
|
||||
s.execute("dimlinear", &json!({"object": l, "at": [5, 3]})).unwrap();
|
||||
let assoc = |d: &Drawing| d.model.iter().find_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.assoc.len()) } else { None });
|
||||
assert!(assoc(s.doc().unwrap()).is_some_and(|n| n > 0), "the dimension follows the line");
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(assoc(s.doc().unwrap()), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_xref_paths_become_paths_from_the_source_folder() {
|
||||
let folder = std::env::temp_dir().join("cadcraft-compareexport");
|
||||
let host = folder.join("host.dxf");
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut x = Block::new("X");
|
||||
x.xref_path = Some("parts/x.dwg".into());
|
||||
d.blocks.insert("X".into(), Arc::new(x));
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("X"))).unwrap();
|
||||
let image = cadcraft_engine::doc::Image { path: "img/a.png".into(), ..Default::default() };
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Image(image.clone())).unwrap();
|
||||
let mut b = Block::new("PIC");
|
||||
b.entities.push(Entity::new(Handle(0x900), EntityKind::Image(image)));
|
||||
d.blocks.insert("PIC".into(), Arc::new(b));
|
||||
let mut s = Session::new();
|
||||
s.open_drawing(d, "host.dxf", Some(host.to_string_lossy().to_string()));
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let got = d.block("X").unwrap().xref_path.clone().unwrap();
|
||||
assert_eq!(std::path::Path::new(&got), folder.join("parts").join("x.dwg"));
|
||||
// Images in model space and in blocks too.
|
||||
let want = folder.join("img").join("a.png");
|
||||
let images = d.model.iter().chain(d.block("PIC").unwrap().entities.iter()).filter_map(|e| match &e.kind {
|
||||
EntityKind::Image(i) => Some(i.path.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let images: Vec<String> = images.collect();
|
||||
assert_eq!(images.len(), 2);
|
||||
assert!(images.iter().all(|p| std::path::Path::new(p) == want), "{images:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn says_when_blocks_of_the_other_drawing_were_left_out() {
|
||||
let mut other = Drawing::new_imperial();
|
||||
for i in 0..10_001 {
|
||||
let name = format!("B{i}");
|
||||
let mut b = Block::new(&name);
|
||||
b.entities.push(Entity::new(Handle(0x10_0000 + i), line(1.0)));
|
||||
other.blocks.insert(name.clone(), Arc::new(b));
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&name))).unwrap();
|
||||
}
|
||||
other.handseed = other.handseed.max(0x20_0000);
|
||||
let mut s = Session::new();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(r["truncated"], true, "{}", r["message"]);
|
||||
assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]);
|
||||
// The same blocks in this drawing, compared with an empty one: past the limit of recolored
|
||||
// copies some references keep their blocks' own colors.
|
||||
let ours = s.docs[1].doc.as_ref().clone();
|
||||
let mut s = Session::new();
|
||||
s.open_drawing(ours, "Ours", None);
|
||||
s.execute("compare", &json!({"document": 0})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(r["truncated"], true, "{}", r["message"]);
|
||||
assert!(r["message"].as_str().unwrap().contains("some blocks keep their own colors"), "{}", r["message"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn says_when_blocks_both_drawings_define_differently_were_left_out() {
|
||||
// Both drawings define B0 to B10000, each with a line of another length, so past the block
|
||||
// limit the blocks left are ones this drawing defines differently.
|
||||
let blocks = |end: f64, first: u64, d: &mut Drawing| {
|
||||
for i in 0..10_001 {
|
||||
let name = format!("B{i}");
|
||||
let mut b = Block::new(&name);
|
||||
b.entities.push(Entity::new(Handle(first + i), line(end)));
|
||||
d.blocks.insert(name, Arc::new(b));
|
||||
}
|
||||
};
|
||||
let mut other = Drawing::new_imperial();
|
||||
blocks(1.0, 0x10_0000, &mut other);
|
||||
for i in 0..10_001 {
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&format!("B{i}")))).unwrap();
|
||||
}
|
||||
other.handseed = other.handseed.max(0x20_0000);
|
||||
let mut s = Session::new();
|
||||
let mut mine = s.doc().unwrap().clone();
|
||||
blocks(2.0, 0x30_0000, &mut mine);
|
||||
mine.handseed = mine.handseed.max(0x40_0000);
|
||||
s.open_drawing(mine, "Mine", None);
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() {
|
||||
use cadcraft_engine::doc::{DimKind, Dimension, MAX_BLOCK_DEPTH};
|
||||
// B1 holds B2, …, and the innermost block holds a dimension: its `*D1` copy would be past
|
||||
// the nesting limit.
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut drawn = Block::new("*D1");
|
||||
drawn.entities.push(Entity::new(Handle(0x600), line(30.0)));
|
||||
d.blocks.insert("*D1".into(), Arc::new(drawn));
|
||||
let dim = Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(15.0, 5.0, 0.0),
|
||||
text_mid: Vec3::new(7.0, 5.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(30.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 30.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some("*D1".into()),
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
for k in 1..=MAX_BLOCK_DEPTH {
|
||||
let mut b = Block::new(&format!("B{k}"));
|
||||
let kind = if k == MAX_BLOCK_DEPTH { EntityKind::Dimension(dim.clone()) } else { EntityKind::Insert(insert(&format!("B{}", k + 1))) };
|
||||
b.entities.push(Entity::new(Handle(0x700 + k as u64), kind));
|
||||
d.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("B1"))).unwrap();
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let dims: Vec<Option<String>> = d
|
||||
.blocks
|
||||
.values()
|
||||
.filter(|b| b.name.starts_with("COMPARE_"))
|
||||
.flat_map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some(dm.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
// The colored copy keeps no block, so it is drawn in the comparison color, not with `*D1`'s.
|
||||
assert_eq!(dims, [None], "{:?}", d.blocks.keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_a_comparison_of_the_current_drawing() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("compareexport", &json!({})).is_err(), "no comparison yet");
|
||||
let mut s = compared();
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
assert!(s.execute("compareexport", &json!({})).is_err(), "the comparison belongs to the other drawing");
|
||||
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
||||
s.execute("compareclose", &json!({})).unwrap();
|
||||
assert!(s.execute("compareexport", &json!({})).is_err(), "closed");
|
||||
// Typed at the command line it runs too.
|
||||
let mut s = compared();
|
||||
let before = s.docs.len();
|
||||
s.cmdline("compareexport").unwrap();
|
||||
assert_eq!(s.docs.len(), before + 1);
|
||||
}
|
||||
@@ -100,6 +100,34 @@ fn new_variables_are_style_overrides_and_read_back() {
|
||||
assert_eq!(st.dim_linetype, "DASHED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fill_variables_are_style_overrides() {
|
||||
let mut s = Session::new();
|
||||
set(&mut s, "DIMTFILL", json!(2));
|
||||
set(&mut s, "DIMTFILLCLR", json!(3));
|
||||
let o = s.doc().unwrap().dim_overrides();
|
||||
assert_eq!(o.get("textFill"), Some(&json!(2)));
|
||||
linear(&mut s, 10.0);
|
||||
let (dm, g) = last_dim(&s);
|
||||
let st = DimStyle::default().with_overrides(&dm.overrides);
|
||||
assert_eq!((st.text_fill, st.text_fill_color), (2, cadcraft_engine::doc::color::Color::Index(3)));
|
||||
assert_eq!(g.text_fill.len(), 4, "the new dimension has a text background");
|
||||
// DIMTFILL takes 0, 1 or 2.
|
||||
let mut st = DimStyle::default();
|
||||
assert_eq!(st.apply_fields(&serde_json::Map::from_iter([("DIMTFILL".to_string(), json!(3))])).len(), 1);
|
||||
// EXPLODE keeps the background as a SOLID in DIMTFILLCLR, before the text.
|
||||
let h = s.doc().unwrap().model.last().unwrap().handle.hex();
|
||||
s.execute("explode", &json!({ "handles": [h] })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let solid = d.model.iter().position(|e| matches!(e.kind, EntityKind::Solid(_)) && e.common.color == cadcraft_engine::doc::color::Color::Index(3));
|
||||
let mtext = d.model.iter().position(|e| matches!(e.kind, EntityKind::MText(_)));
|
||||
assert!(solid.is_some() && solid < mtext, "{solid:?}, {mtext:?}");
|
||||
// The arrowheads (the other SOLIDs) come before the background, as the dimension draws them.
|
||||
let arrows: Vec<usize> =
|
||||
d.model.iter().enumerate().filter(|(i, e)| matches!(e.kind, EntityKind::Solid(_)) && Some(*i) != solid).map(|(i, _)| i).collect();
|
||||
assert!(!arrows.is_empty() && arrows.iter().all(|i| Some(*i) < solid), "{arrows:?} before {solid:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_moves_text_or_arrows_outside() {
|
||||
// 0.5 between the extension lines: the text "0.5000" doesn't fit, the arrows (2 × 0.18) do.
|
||||
@@ -196,6 +224,37 @@ fn text_move_settings() {
|
||||
assert!((dm.defpt.y - (12.0 - 0.18)).abs() < 1e-9, "{:?}", dm.defpt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_refuses_the_values_the_style_refuses() {
|
||||
let mut s = Session::new();
|
||||
set(&mut s, "DIMTMOVE", json!(1));
|
||||
let get = |s: &mut Session, name: &str| s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone();
|
||||
for (name, v) in [
|
||||
("DIMTMOVE", json!(3)),
|
||||
("DIMATFIT", json!(7)),
|
||||
("DIMLWD", json!(500)),
|
||||
("DIMJOGANG", json!(120)),
|
||||
("DIMFXL", json!(-1)),
|
||||
("DIMTXT", json!(0)),
|
||||
("DIMDEC", json!(300)),
|
||||
("DIMCLRT", json!("no such color")),
|
||||
] {
|
||||
let before = get(&mut s, name);
|
||||
assert!(s.execute("setvar", &json!({ "name": name, "value": v })).is_err(), "{name} {v}");
|
||||
assert_eq!(get(&mut s, name), before, "{name} keeps its value");
|
||||
// DIMSTYLE and DIMOVERRIDE refuse the same value.
|
||||
let mut st = DimStyle::default();
|
||||
assert_eq!(st.apply_fields(&serde_json::Map::from_iter([(name.to_string(), v)])).len(), 1, "{name}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().dim_overrides().get("textMove"), Some(&json!(1)));
|
||||
// Values in range still go in, typed as text too.
|
||||
set(&mut s, "DIMTMOVE", json!("2"));
|
||||
set(&mut s, "DIMCLRT", json!(3));
|
||||
let o = s.doc().unwrap().dim_overrides();
|
||||
assert_eq!(o.get("textMove"), Some(&json!(2)));
|
||||
assert_eq!(o.get("textColor"), serde_json::to_value(cadcraft_engine::doc::color::Color::Index(3)).ok().as_ref(), "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_justification_angular_zeros_and_arc_symbol() {
|
||||
let mut s = Session::new();
|
||||
@@ -262,6 +321,69 @@ fn linetypes_and_lineweights_of_dimension_and_extension_lines() {
|
||||
assert!(lws.contains(&0.5) && lws.contains(&0.13), "{lws:?}");
|
||||
}
|
||||
|
||||
fn ignored(r: &Value) -> Vec<&str> {
|
||||
r["ignored"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimstyle_and_dimoverride_refuse_negative_whole_numbers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("dimstyle", &json!({ "name": "Standard", "DIMTMOVE": 2, "DIMLWD": 50 })).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"dimstyle",
|
||||
&json!({
|
||||
"name": "Standard",
|
||||
"DIMDEC": -1,
|
||||
"DIMTMOVE": -1,
|
||||
"DIMLUNIT": "-1",
|
||||
"DIMTDEC": -0.6,
|
||||
"DIMADEC": -2,
|
||||
"DIMALTD": "300",
|
||||
"DIMLWE": "300",
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let ign = ignored(&r);
|
||||
for k in ["DIMDEC", "DIMTMOVE", "DIMLUNIT", "DIMTDEC", "DIMADEC", "DIMALTD", "DIMLWE"] {
|
||||
assert!(ign.contains(&k), "{k}: {r}");
|
||||
}
|
||||
let st = s.doc().unwrap().dim_style("Standard").unwrap().clone();
|
||||
let def = DimStyle::default();
|
||||
assert_eq!((st.decimals, st.text_move, st.linear_unit, st.tol_decimals), (def.decimals, 2, def.linear_unit, def.tol_decimals));
|
||||
assert_eq!((st.angular_decimals, st.alt_decimals, st.ext_lineweight), (def.angular_decimals, def.alt_decimals, def.ext_lineweight));
|
||||
// DIMADEC -1 is a documented value (stored as 0); ByLayer lineweights stay -1.
|
||||
let r = s.execute("dimstyle", &json!({ "name": "Standard", "DIMADEC": -1, "DIMLWD": "ByLayer" })).unwrap();
|
||||
assert!(r.get("ignored").is_none(), "{r}");
|
||||
assert_eq!(s.doc().unwrap().dim_style("Standard").unwrap().dim_lineweight, -1);
|
||||
|
||||
// DIMOVERRIDE keeps the valid value and leaves the negative one out.
|
||||
linear(&mut s, 10.0);
|
||||
let h = s.doc().unwrap().model.last().unwrap().handle.hex();
|
||||
s.execute("dimoverride", &json!({ "handles": [h], "DIMDEC": -1, "DIMTMOVE": 1 })).unwrap();
|
||||
let (dm, _) = last_dim(&s);
|
||||
assert!(!dm.overrides.contains_key("decimals"), "{:?}", dm.overrides);
|
||||
assert_eq!(dm.overrides.get("textMove"), Some(&json!(1)));
|
||||
assert!(s.execute("dimoverride", &json!({ "handles": [h], "DIMZIN": -3 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_negative_whole_numbers_do_not_reach_new_dimensions() {
|
||||
let mut s = Session::new();
|
||||
for (name, v) in [("DIMDEC", json!(-1)), ("DIMLUNIT", json!("-1")), ("DIMTDEC", json!(-0.6))] {
|
||||
// The header may hold the value; new dimensions keep the style's own.
|
||||
let _ = s.execute("setvar", &json!({ "name": name, "value": v }));
|
||||
}
|
||||
let o = s.doc().unwrap().dim_overrides();
|
||||
for f in ["decimals", "linearUnit", "tolDecimals"] {
|
||||
assert!(!o.contains_key(f), "{f}: {o:?}");
|
||||
}
|
||||
linear(&mut s, 10.0);
|
||||
let (dm, g) = last_dim(&s);
|
||||
assert_eq!(g.value, "10.0000");
|
||||
assert!(dm.overrides.is_empty(), "{:?}", dm.overrides);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaders_use_the_leader_arrow() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -43,3 +43,51 @@ fn dragging_the_grip_of_a_fixed_end_leaves_it_in_place() {
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-6), "{start:?}");
|
||||
assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_midpoint_stays_in_place_when_an_end_grip_is_dragged() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("gcfix", &json!({ "h1": a.hex(), "p1": "mid" })).unwrap();
|
||||
|
||||
grip_drag(&mut s, a, Vec2::new(10.0, 0.0), "10,5");
|
||||
let (start, end) = ends(&s, a);
|
||||
assert!(start.mid(end).near(Vec2::new(5.0, 0.0), 1e-6), "the fixed midpoint moved to {:?} ({start:?}, {end:?})", start.mid(end));
|
||||
// The dragged end lands where it was put; the line turns about its fixed midpoint.
|
||||
assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}");
|
||||
assert!(start.near(Vec2::new(0.0, -5.0), 1e-6), "{start:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_an_arc_with_a_fixed_end_leaves_it_whole_and_in_place() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("arc", &json!({ "center": [0, 0], "radius": 5, "start": 0, "end": 90 })).unwrap();
|
||||
let a = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
|
||||
s.execute("gcfix", &json!({ "h1": a.hex(), "p1": "end" })).unwrap();
|
||||
|
||||
s.execute("move", &json!({ "handles": [a.hex()], "delta": [1, 1] })).unwrap();
|
||||
match &s.doc().unwrap().entity(a).unwrap().kind {
|
||||
EntityKind::Arc(c) => {
|
||||
assert!(c.center.xy().near(Vec2::new(0.0, 0.0), 1e-6), "{:?}", c.center);
|
||||
assert!((c.radius - 5.0).abs() < 1e-6, "radius {}", c.radius);
|
||||
assert!((c.end - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "end at {}", c.end);
|
||||
}
|
||||
k => panic!("not an arc: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_an_end_of_one_of_two_parallel_lines_leaves_its_other_end_in_place() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let b = line(&mut s, [0.0, 2.0], [10.0, 2.0]);
|
||||
s.execute("gcparallel", &json!({ "h1": a.hex(), "h2": b.hex() })).unwrap();
|
||||
|
||||
grip_drag(&mut s, a, Vec2::new(10.0, 0.0), "10,5");
|
||||
let (start, end) = ends(&s, a);
|
||||
assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}");
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-3), "the end that was not dragged moved to {start:?}");
|
||||
// The other line took up the change.
|
||||
let (b0, b1) = ends(&s, b);
|
||||
assert!((b1 - b0).cross(end - start).abs() < 1e-6, "{b0:?} {b1:?}");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! AREA's Object / Add area / Subtract area and DIST's Multiple points, typed as at the command line.
|
||||
//! AREA's Object / Add area / Subtract area, DIST's Multiple points and MEASUREGEOM's Angle and
|
||||
//! Volume, typed as at the command line.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
@@ -74,6 +75,56 @@ fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() {
|
||||
assert!(!has(&log, "Area = "), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_inquiry_radius_asks_for_the_object() {
|
||||
// Nothing selected: the menu item asks for the arc or circle.
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 0,0 2").unwrap();
|
||||
s.start("radius.measure").unwrap();
|
||||
assert!(s.prompt_text().contains("Select arc or circle"), "{}", s.prompt_text());
|
||||
s.cmdline("2,0").unwrap();
|
||||
assert!(has(&s.log, "Radius = 2.0000"), "{:?}", s.log);
|
||||
assert!(has(&s.log, "Diameter = 4.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// A selected arc is measured at once.
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap();
|
||||
s.set_selection(vec![cadcraft_doc::Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()]);
|
||||
s.start("radius.measure").unwrap();
|
||||
assert!(has(&s.log, "Radius = 3.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_radius_measures_a_polyline_arc_segment() {
|
||||
// A line from 0,0 to 10,0, then a half circle of radius 5 to 10,10 through 15,5.
|
||||
let pline = json!({"vertices": [[0, 0], {"p": [10, 0], "bulge": 1}, [10, 10]]});
|
||||
let mut s = Session::new();
|
||||
s.execute("pline", &pline).unwrap();
|
||||
s.cmdline("measuregeom").unwrap();
|
||||
s.cmdline("r").unwrap();
|
||||
// A pick on the line segment keeps the prompt.
|
||||
s.cmdline("5,0").unwrap();
|
||||
assert!(has(&s.log, "Select an arc, circle or polyline arc."), "{:?}", s.log);
|
||||
assert!(s.running.is_some());
|
||||
s.cmdline("15,5").unwrap();
|
||||
assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log);
|
||||
assert!(has(&s.log, "Diameter = 10.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// The same pick from Tools > Inquiry > Radius, and the JSON form with the point on the arc.
|
||||
let mut s = Session::new();
|
||||
let h = s.execute("pline", &pline).unwrap()["handle"].clone();
|
||||
s.start("radius.measure").unwrap();
|
||||
s.cmdline("15,5").unwrap();
|
||||
assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log);
|
||||
let r = s.execute("radius.measure", &json!({"handle": h, "point": [15, 5]})).unwrap();
|
||||
assert_eq!(r["radius"].as_f64(), Some(5.0), "{r}");
|
||||
assert!(s.execute("radius.measure", &json!({"handle": h, "point": [5, 0]})).is_err());
|
||||
assert!(s.execute("radius.measure", &json!({"handle": h})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_typed_asks_for_the_option_then_the_points() {
|
||||
// Distance is the default option (#1103: typed, it used to fail asking for `p1`).
|
||||
@@ -88,6 +139,63 @@ fn measuregeom_typed_asks_for_the_option_then_the_points() {
|
||||
assert!(has(&log, "Area = 6.0000"), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_angle_measures_an_arc_a_circle_two_lines_and_a_vertex() {
|
||||
let typed = |s: &mut Session, lines: &[&str]| {
|
||||
for l in lines {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
s.log.clone()
|
||||
};
|
||||
|
||||
// Arc: the angle between its endpoints about its center.
|
||||
let mut s = Session::new();
|
||||
s.execute("arc", &json!({"center": [0, 0], "radius": 2, "start": 0, "end": 270})).unwrap();
|
||||
assert!(has(&typed(&mut s, &["measuregeom", "a", "-2,0"]), "Angle = 270.0000°"));
|
||||
|
||||
// Circle: the pick on the circle and a second point, about the center.
|
||||
let log = typed(&mut Session::new(), &["circle 0,0 2", "measuregeom", "a", "2,0", "0,5"]);
|
||||
assert!(has(&log, "Angle = 90.0000°"), "{log:?}");
|
||||
|
||||
// Two lines that do not meet, and two segments of one polyline.
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[1, 0], [10, 0]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 1], [5, 6]]})).unwrap();
|
||||
assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "2,3"]), "Angle = 45.0000°"), "{:?}", s.log);
|
||||
let mut s = Session::new();
|
||||
s.execute("pline", &json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap();
|
||||
assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "10,5"]), "Angle = 90.0000°"), "{:?}", s.log);
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap();
|
||||
assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "5,5"]), "The lines are parallel."), "{:?}", s.log);
|
||||
|
||||
// Enter: a vertex and two endpoints.
|
||||
let log = typed(&mut Session::new(), &["measuregeom", "a", "", "0,0", "1,0", "-1,1"]);
|
||||
assert!(has(&log, "Angle = 135.0000°"), "{log:?}");
|
||||
|
||||
// Empty space is not an object, and the prompt stays.
|
||||
let (s, log) = run(&["measuregeom", "a", "50,50"]);
|
||||
assert!(has(&log, "*Invalid selection*"), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_inquiry_angle_asks_for_the_objects() {
|
||||
let spec = cadcraft_engine::find_command("angle.measure").expect("no command angle.measure");
|
||||
assert_eq!(spec.menu, ["Tools", "Inquiry", "Angle"]);
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 0,0 2").unwrap();
|
||||
s.start("angle.measure").unwrap();
|
||||
assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text());
|
||||
for l in ["0,2", "-3,0"] {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
assert!(has(&s.log, "Angle = 90.0000°"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_of_an_ellipse_reports_its_exact_perimeter() {
|
||||
// References from the arithmetic-geometric mean (Gauss–Kummer), to 12 decimals. A closed-form
|
||||
@@ -103,3 +211,104 @@ fn area_of_an_ellipse_reports_its_exact_perimeter() {
|
||||
let (_, log) = run(&["ellipse c 0,0 10,0 4", "area", "o", "10,0"]);
|
||||
assert!(has(&log, "Perimeter = 46.0262"), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_volume_multiplies_an_area_by_a_height() {
|
||||
// Points: a 4 x 3 triangle's area is 6, times a height of 10.
|
||||
let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "10"]);
|
||||
assert!(has(&log, "Volume = 60.0000"), "{log:?}");
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// Object (the default): a 10 x 10 square polyline, height 2.
|
||||
let (s, log) = run(&["rectang 0,0 10,10", "measuregeom", "v", "", "10,5", "2"]);
|
||||
assert!(has(&log, "Volume = 200.0000"), "{log:?}");
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// A height that is not a distance is asked for again.
|
||||
let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "tall"]);
|
||||
assert!(has(&log, "Requires a distance."), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
|
||||
|
||||
// Enter is not a height; two points give the height as their distance.
|
||||
let (mut s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "", "1,1"]);
|
||||
assert!(has(&log, "Requires a height."), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Specify second point"), "{}", s.prompt_text());
|
||||
s.cmdline("7,9").unwrap();
|
||||
assert!(has(&s.log, "Volume = 60.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// The JSON forms, by points and by handle.
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("measuregeom", &json!({"mode": "volume", "points": [[0, 0], [4, 0], [4, 3]], "height": 10})).unwrap();
|
||||
assert_eq!(r["volume"], json!(60.0));
|
||||
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
let r = s.execute("volume.measure", &json!({"handle": c["handle"], "height": -3})).unwrap();
|
||||
assert!((r["volume"].as_f64().unwrap() - 3.0 * std::f64::consts::PI).abs() < 1e-12, "{r}");
|
||||
assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0]], "height": 1})).is_err());
|
||||
assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0], [1, 1]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_with_points_that_are_not_points_is_an_error_not_the_selection() {
|
||||
let mut s = Session::new();
|
||||
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
s.set_selection(vec![cadcraft_engine::doc::Handle::parse_hex(c["handle"].as_str().unwrap()).unwrap()]);
|
||||
for bad in [json!([[0, 0], [4, 0], "north"]), json!([[0, 0], [4, 0], [4]]), json!(7)] {
|
||||
assert!(s.execute("volume.measure", &json!({"points": bad, "height": 10})).is_err(), "{bad}");
|
||||
assert!(s.execute("area", &json!({"points": bad})).is_err(), "{bad}");
|
||||
}
|
||||
// Without `points`, both measure the selected circle.
|
||||
let r = s.execute("volume.measure", &json!({"height": 1})).unwrap();
|
||||
assert!((r["volume"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}");
|
||||
let r = s.execute("area", &json!({"points": null})).unwrap();
|
||||
assert!((r["area"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_volume_too_large_to_hold_is_refused() {
|
||||
// A 2e150 square has an area of 4e300; times 1e10 it is past f64's range.
|
||||
let p = json!([[0, 0], [2e150, 0], [2e150, 2e150], [0, 2e150]]);
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("volume.measure", &json!({"points": p, "height": 1e10})).is_err());
|
||||
assert!(s.execute("volume.measure", &json!({"points": p, "height": 1})).is_ok());
|
||||
|
||||
// Typed, the height is asked for again.
|
||||
let (s, log) = run(&["measuregeom", "v", "0,0", "2e150,0", "2e150,2e150", "0,2e150", "", "1e10"]);
|
||||
assert!(has(&log, "The volume is too large."), "{log:?}");
|
||||
assert!(!has(&log, "Volume = "), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
|
||||
|
||||
// Two volumes that each fit can overflow the running total; the second is asked for again.
|
||||
let sq = ["0,0", "1,0", "1,1", "0,1", ""];
|
||||
let mut lines = vec!["measuregeom", "v", "a"];
|
||||
lines.extend(sq);
|
||||
lines.push("1e308");
|
||||
lines.extend(sq);
|
||||
lines.push("1e308");
|
||||
let (s, log) = run(&lines);
|
||||
assert!(has(&log, "The volume is too large."), "{log:?}");
|
||||
assert!(!has(&log, "Total volume = 0"), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_inquiry_volume_adds_and_subtracts_a_running_total() {
|
||||
let spec = cadcraft_engine::find_command("volume.measure").expect("no command volume.measure");
|
||||
assert_eq!(spec.menu, ["Tools", "Inquiry", "Volume"]);
|
||||
|
||||
// Add a 10 x 10 square by points at height 3, subtract a 2 x 2 square object at height 5.
|
||||
let mut s = Session::new();
|
||||
s.cmdline("rectang 20,0 22,2").unwrap();
|
||||
s.start("volume.measure").unwrap();
|
||||
for l in ["a", "0,0", "10,0", "10,10", "0,10", "", "3", "s", "o", "21,0", "5", "", ""] {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
assert!(has(&s.log, "Total volume = 300.0000"), "{:?}", s.log);
|
||||
assert!(has(&s.log, "Total volume = 280.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// Architectural units show the volume as a number, not as a feet-and-inches length.
|
||||
let (_, log) = run(&["setvar lunits 4", "measuregeom", "v", "0,0", "10,0", "10,10", "0,10", "", "12"]);
|
||||
assert!(has(&log, "Volume = 1200"), "{log:?}");
|
||||
assert!(!has(&log, "'-"), "{log:?}");
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ mod attrib_props;
|
||||
mod attribute_tools;
|
||||
mod block_keep;
|
||||
mod block_redefine;
|
||||
mod blockreplace;
|
||||
mod break_closed_polyline;
|
||||
mod break_join_locked_layers;
|
||||
mod break_keeps_z;
|
||||
mod burst;
|
||||
mod compare_export;
|
||||
mod constraint_geometry;
|
||||
mod constraint_infer;
|
||||
mod count;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `setvar CECOLOR` takes what the `color` command takes: "ByLayer", "ByBlock", a colour name or an
|
||||
//! index (#1999).
|
||||
//! index (#1999). `getvar CECOLOR` reads it back as AutoCAD does, as text: "BYLAYER", "BYBLOCK",
|
||||
//! the index ("1" for red) or "RGB:r,g,b".
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::color::{Color, Rgb};
|
||||
@@ -14,22 +15,66 @@ fn new_line_color(s: &mut Session) -> Color {
|
||||
s.doc().unwrap().model.last().unwrap().common.color
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn getvar_cecolor_of_a_new_drawing_is_bylayer() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(cecolor(&mut s), json!("BYLAYER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_cecolor_accepts_names() {
|
||||
for (value, aci) in [("BYLAYER", 256), ("ByLayer", 256), ("BYBLOCK", 0), ("red", 1), ("250", 250)] {
|
||||
for (value, shown) in [
|
||||
("BYLAYER", "BYLAYER"),
|
||||
("ByLayer", "BYLAYER"),
|
||||
("256", "BYLAYER"),
|
||||
("BYBLOCK", "BYBLOCK"),
|
||||
("0", "BYBLOCK"),
|
||||
("red", "1"),
|
||||
("7", "7"),
|
||||
("250", "250"),
|
||||
("RGB:10,20,30", "RGB:10,20,30"),
|
||||
("10,20,30", "RGB:10,20,30"),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "CECOLOR", "value": value })).unwrap_or_else(|e| panic!("{value}: {e}"));
|
||||
assert_eq!(cecolor(&mut s), json!(aci), "{value}");
|
||||
assert_eq!(cecolor(&mut s), json!(shown), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn getvar_cecolor_after_the_color_command() {
|
||||
let mut s = Session::new();
|
||||
s.execute("color", &json!({ "color": "ByBlock" })).unwrap();
|
||||
assert_eq!(cecolor(&mut s), json!("BYBLOCK"));
|
||||
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
|
||||
assert_eq!(cecolor(&mut s), json!("RGB:10,20,30"));
|
||||
}
|
||||
|
||||
/// The value `getvar` gives back is one `setvar` takes, so saving and restoring keeps a true colour.
|
||||
#[test]
|
||||
fn getvar_cecolor_restores_a_true_colour() {
|
||||
let mut s = Session::new();
|
||||
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
|
||||
let saved = cecolor(&mut s);
|
||||
s.execute("setvar", &json!({ "name": "CECOLOR", "value": "250" })).unwrap();
|
||||
s.execute("setvar", &json!({ "name": "CECOLOR", "value": saved })).unwrap();
|
||||
assert_eq!(new_line_color(&mut s), Color::True(Rgb(10, 20, 30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_cecolor_lists_cecolor_as_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "CECOLOR", "value": "red" })).unwrap();
|
||||
assert_eq!(cadcraft_engine::sysvars::list(&s)["CECOLOR"], json!("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_cecolor_name_replaces_a_true_colour() {
|
||||
let mut s = Session::new();
|
||||
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
|
||||
assert_eq!(new_line_color(&mut s), Color::True(Rgb(10, 20, 30)));
|
||||
s.execute("setvar", &json!({ "name": "CECOLOR", "value": "ByLayer" })).unwrap();
|
||||
assert_eq!(cecolor(&mut s), json!(256));
|
||||
assert_eq!(cecolor(&mut s), json!("BYLAYER"));
|
||||
assert_eq!(new_line_color(&mut s), Color::ByLayer);
|
||||
}
|
||||
|
||||
@@ -44,8 +89,8 @@ fn setvar_cecolor_number_replaces_a_true_colour() {
|
||||
#[test]
|
||||
fn setvar_cecolor_refuses_what_is_not_a_colour() {
|
||||
let mut s = Session::new();
|
||||
for bad in [json!("chartreuse"), json!(257), json!(-1), json!(true)] {
|
||||
for bad in [json!("chartreuse"), json!(257), json!(-1), json!(true), json!("RGB:"), json!("RGB:300,0,0")] {
|
||||
assert!(s.execute("setvar", &json!({ "name": "CECOLOR", "value": bad })).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(cecolor(&mut s), json!(256));
|
||||
assert_eq!(cecolor(&mut s), json!("BYLAYER"));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use cadcraft_doc::{Drawing, Space};
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, base64_decode, set_io};
|
||||
use cadcraft_engine::cmd::plotters::{PlotterStore, set_plotter_store};
|
||||
use cadcraft_engine::snap::tracking::CursorQuery;
|
||||
use cadcraft_engine::{Input, geom::Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The plot hook echoes the options it gets.
|
||||
@@ -13,6 +15,7 @@ fn echo_plot(_: &Drawing, _: &Space, opts: &Value) -> Result<Vec<u8>, String> {
|
||||
Ok(opts.to_string().into_bytes())
|
||||
}
|
||||
|
||||
/// A session with in-memory plotters and I/O hooks, holding one line so layouts have content.
|
||||
fn session() -> Session {
|
||||
set_plotter_store(PlotterStore::memory());
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Err("no writer in this test".into()), plot: Some(echo_plot) });
|
||||
@@ -22,6 +25,7 @@ fn session() -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// The device names the PLOTTERMANAGER report lists, in the order it lists them.
|
||||
fn names(s: &mut Session) -> Vec<String> {
|
||||
let r = s.execute("plottermanager", &json!({})).unwrap();
|
||||
r["devices"].as_array().unwrap().iter().map(|d| d["name"].as_str().unwrap().to_string()).collect()
|
||||
@@ -34,6 +38,8 @@ fn plotted(r: Value) -> Value {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// PLOTTERMANAGER creates, edits, saves, loads and deletes user devices; built-ins are fixed,
|
||||
/// duplicate names and papers with margins larger than the page are refused.
|
||||
fn manage_devices() {
|
||||
let mut s = session();
|
||||
let n = names(&mut s);
|
||||
@@ -73,6 +79,8 @@ fn manage_devices() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// PAGESETUP copies the device's papers and margins into the layout's page, keeps a paper the new
|
||||
/// device also has, and falls back to the device default when it does not.
|
||||
fn page_setup_takes_a_device_and_its_papers() {
|
||||
let mut s = session();
|
||||
s.execute("plottermanager", &json!({"action": "new", "name": "Plan Printer", "output": "pdf"})).unwrap();
|
||||
@@ -110,6 +118,8 @@ fn page_setup_takes_a_device_and_its_papers() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// PLOT and EXPORTPDF hand the hook the device's output, resolution, paper and margins, and a
|
||||
/// layout plots with its own page setup; unknown devices and papers are refused.
|
||||
fn plot_uses_the_device() {
|
||||
let mut s = session();
|
||||
s.execute("plottermanager", &json!({"action": "new", "name": "Sketch PNG", "output": "png", "resolution": 96})).unwrap();
|
||||
@@ -136,6 +146,7 @@ fn plot_uses_the_device() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The typed -PLOTTERMANAGER prompt machine creates a device, adds a paper to it and deletes it.
|
||||
fn typed_plotter_manager() {
|
||||
let mut s = session();
|
||||
for line in ["-plottermanager", "New", "Typed Plotter", "Svg"] {
|
||||
@@ -155,6 +166,8 @@ fn typed_plotter_manager() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Hostile names and values (empty or path-like names, unknown output, huge resolution, negative
|
||||
/// or enormous paper sizes, oversized loads) are refused instead of panicking.
|
||||
fn hostile_names_and_values_are_refused() {
|
||||
let mut s = session();
|
||||
let r = s.execute("plottermanager", &json!({"action": "new", "name": "../../etc/passwd"})).unwrap();
|
||||
@@ -174,6 +187,8 @@ fn hostile_names_and_values_are_refused() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// PLOTWINDOW keeps both picked corners for the plot dialog, previews the rubber-band rectangle
|
||||
/// after the first pick, and refuses a window with no area (JSON form included).
|
||||
fn plotwindow_keeps_the_picked_window_for_the_plot_dialog() {
|
||||
let mut s = Session::new();
|
||||
// Typed: two corners, with the window's rectangle following the cursor after the first.
|
||||
@@ -190,3 +205,37 @@ fn plotwindow_keeps_the_picked_window_for_the_plot_dialog() {
|
||||
assert_eq!(r["window"], json!([[0.0, 0.0], [3.0, 4.0]]));
|
||||
assert!(s.execute("plotwindow", &json!({"p1": [0, 0], "p2": [3, 0]})).is_err());
|
||||
}
|
||||
|
||||
/// Snap `raw` at the current prompt the way the canvas does, so a test sees the pick ORTHO and
|
||||
/// object snap would really produce.
|
||||
fn hover(s: &mut Session, raw: Vec2) -> Vec2 {
|
||||
let p = s.current_prompt().expect("a prompt");
|
||||
let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false };
|
||||
s.snap_cursor(raw, &q).point
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Regression (#1641): with ORTHO on, PLOTWINDOW's opposite corner stays where the cursor is so
|
||||
/// the window keeps both extents, while an ordinary point prompt still follows ortho.
|
||||
fn plotwindow_picks_both_corners_with_ortho_on() {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({"name": "OSMODE", "value": 0})).unwrap();
|
||||
s.execute("setvar", &json!({"name": "ORTHOMODE", "value": 1})).unwrap();
|
||||
s.cmdline("plotwindow").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify first corner");
|
||||
let a = hover(&mut s, Vec2::new(10.0, 20.0));
|
||||
s.input(Input::Point(a)).unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner");
|
||||
let c = hover(&mut s, Vec2::new(40.0, 5.0));
|
||||
assert!(c.near(Vec2::new(40.0, 5.0), 1e-9), "the opposite corner is where the cursor is: {c:?}");
|
||||
s.input(Input::Point(c)).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let w = s.plot_window.expect("the picked window");
|
||||
assert!(w.width() > 0.0 && w.height() > 0.0, "the window has both extents: {w:?}");
|
||||
assert_eq!((w.min.x, w.min.y, w.max.x, w.max.y), (10.0, 5.0, 40.0, 20.0));
|
||||
|
||||
// An ordinary point prompt still follows ortho: a line's next point stays on its axis.
|
||||
s.cmdline("line 0,0").unwrap();
|
||||
let c = hover(&mut s, Vec2::new(10.0, 4.0));
|
||||
assert!(c.near(Vec2::new(10.0, 0.0), 1e-9), "ortho keeps the line horizontal: {c:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! STRETCH's crossing window ignores ortho: the opposite corner is where the cursor is, so the
|
||||
//! window keeps its area and catches what it crosses. The same holds for every rectangle picked by
|
||||
//! two corners (PLOTWINDOW, MVIEW, -VIEW Window, REVCLOUD Rectangular).
|
||||
|
||||
use cadcraft_engine::snap::tracking::CursorQuery;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
/// Move the cursor to `raw` at the current prompt, as the canvas does.
|
||||
fn hover(s: &mut Session, raw: Vec2) -> Vec2 {
|
||||
let p = s.current_prompt().unwrap();
|
||||
let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false };
|
||||
let c = s.snap_cursor(raw, &q).point;
|
||||
s.cursor = c;
|
||||
c
|
||||
}
|
||||
|
||||
/// A session with ortho on, object snaps off and a line from 0,0 to 10,0.
|
||||
fn ortho_session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap();
|
||||
s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Pick the window's opposite corner at `to`, stretch by 5,0 and return the extents.
|
||||
fn stretch_from_corner(mut s: Session, to: Vec2) -> (Vec2, Vec2) {
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner");
|
||||
let c = hover(&mut s, to);
|
||||
assert!(c.near(to, 1e-9), "the corner is where the cursor is: {c:?}");
|
||||
s.input(Input::Point(c)).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
for t in ["0,0", "5,0"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(s.current_prompt().is_none(), "STRETCH is done, still at {:?}", s.current_prompt().map(|p| p.message));
|
||||
let b = s.doc().unwrap().extents(&s.space());
|
||||
(b.min, b.max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stretch_implied_window_ignores_ortho() {
|
||||
let mut s = ortho_session();
|
||||
s.cmdline("stretch").unwrap();
|
||||
// Right to left: a crossing window.
|
||||
s.input(Input::Point(Vec2::new(12.0, 1.0))).unwrap();
|
||||
assert_eq!(stretch_from_corner(s, Vec2::new(8.0, -1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stretch_crossing_mode_ignores_ortho() {
|
||||
let mut s = ortho_session();
|
||||
for t in ["stretch", "c", "8,-1"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(stretch_from_corner(s, Vec2::new(12.0, 1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_corner_prompts_ignore_ortho() {
|
||||
for script in [&["plotwindow", "0,0"][..], &["mview", "0,0"], &["-view", "w", "v1", "0,0"], &["revcloud", "r", "0,0"]] {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap();
|
||||
s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap();
|
||||
if script[0] == "mview" {
|
||||
s.cmdline("layout s Layout1").unwrap();
|
||||
}
|
||||
for t in script {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.message, "Specify opposite corner", "{script:?}");
|
||||
assert!(p.corner, "{script:?}: the opposite corner ignores ortho");
|
||||
let c = hover(&mut s, Vec2::new(10.0, 4.0));
|
||||
assert!(c.near(Vec2::new(10.0, 4.0), 1e-9), "{script:?}: the corner is where the cursor is: {c:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! A malformed `handle` on a table edit command or TABLEEXPORT is an error. It never falls back
|
||||
//! to the selected table (#934, follow-up to #330).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use serde_json::json;
|
||||
|
||||
fn cell_text(s: &Session, h: &str) -> String {
|
||||
let h = Handle::parse_hex(h).unwrap();
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Table(t) => t.cells[0][0].text.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_table_handle_does_not_edit_the_selected_table() {
|
||||
let mut s = Session::new();
|
||||
let h = s.execute("table", &json!({ "at": [0, 0], "cells": [["a", "b"]], "rowHeight": 1.0, "colWidth": 3.0 })).unwrap()["handle"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.set_selection(vec![Handle::parse_hex(&h).unwrap()]);
|
||||
|
||||
for bad in [json!("zzz"), json!(""), json!("#1"), json!(-1), json!(true), json!([1])] {
|
||||
let e = s.execute("table.set", &json!({ "handle": bad, "row": 0, "col": 0, "text": "X" })).expect_err(&format!("handle {bad}"));
|
||||
assert!(e.to_string().contains("is not an object handle"), "handle {bad}: {e}");
|
||||
assert_eq!(cell_text(&s, &h), "a", "handle {bad} must change nothing");
|
||||
// TABLEEXPORT resolves its table the same way: no CSV of the selected table either.
|
||||
let e = s.execute("tableexport", &json!({ "handle": bad })).expect_err(&format!("tableexport handle {bad}"));
|
||||
assert!(e.to_string().contains("is not an object handle"), "tableexport handle {bad}: {e}");
|
||||
}
|
||||
|
||||
// An explicit valid handle and an omitted handle (selection) still work.
|
||||
s.execute("table.set", &json!({ "handle": h, "row": 0, "col": 0, "text": "Y" })).unwrap();
|
||||
assert_eq!(cell_text(&s, &h), "Y");
|
||||
s.execute("table.set", &json!({ "row": 0, "col": 0, "text": "Z" })).unwrap();
|
||||
assert_eq!(cell_text(&s, &h), "Z");
|
||||
assert_eq!(s.execute("tableexport", &json!({})).unwrap()["csv"], "Z,b\r\n");
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
use std::ops::Range;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
|
||||
@@ -109,7 +108,7 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64
|
||||
for p in contours.iter_mut().flatten() {
|
||||
*p += Vec2::new(dy * shear, dy);
|
||||
}
|
||||
out.glyphs.push(contours);
|
||||
out.push_glyph(contours, None);
|
||||
}
|
||||
let adv = g.advance * k;
|
||||
let (u, o) = flags(g.cluster);
|
||||
@@ -183,9 +182,9 @@ struct Glyph {
|
||||
/// Shape characters `r` of `cs` (one direction, one face) into glyphs in display order.
|
||||
fn shape_run(face: &Face, cs: &[char], r: Range<usize>, rtl: bool) -> Vec<Glyph> {
|
||||
let run = cs.get(r.clone()).unwrap_or_default();
|
||||
// Shares the face's bytes instead of copying them.
|
||||
let bytes: Arc<dyn AsRef<[u8]> + Send + Sync> = face.bytes.clone();
|
||||
let Some(font) = harfrust::Font::new(bytes, face.index) else { return Vec::new() };
|
||||
// Keep the shared font bytes: shaping a run must not clone the entire font file.
|
||||
let source = harfrust::font::Blob::Shared(face.bytes.clone());
|
||||
let Some(font) = harfrust::Font::new(source, face.index) else { return Vec::new() };
|
||||
let shaper = harfrust::ShaperFont::new(&font);
|
||||
let mut buf = harfrust::Buffer::new();
|
||||
let arabic = run.iter().any(|c| matches!(*c, '\u{0600}'..='\u{08FF}'));
|
||||
@@ -200,11 +199,10 @@ fn shape_run(face: &Face, cs: &[char], r: Range<usize>, rtl: bool) -> Vec<Glyph>
|
||||
}
|
||||
buf.set_direction(if rtl { harfrust::Direction::RightToLeft } else { harfrust::Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
// Without a plan, shaping fails only on an already shaped buffer or an unset direction.
|
||||
if harfrust::shape(&shaper, &mut buf, harfrust::ShapeOptions::new()).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
let k = 1000.0 / f64::from(font.units_per_em().max(1));
|
||||
let k = 1000.0 / f64::from(shaper.units_per_em().max(1));
|
||||
buf.glyph_infos()
|
||||
.iter()
|
||||
.zip(buf.glyph_positions())
|
||||
@@ -222,6 +220,59 @@ fn shape_run(face: &Face, cs: &[char], r: Range<usize>, rtl: bool) -> Vec<Glyph>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A single empty glyph with a positive advance, without relying on installed fonts.
|
||||
fn empty_glyph_face() -> Face {
|
||||
let mut head = vec![0_u8; 54];
|
||||
head[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
head[18..20].copy_from_slice(&1000_u16.to_be_bytes());
|
||||
let mut hhea = vec![0_u8; 36];
|
||||
hhea[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
hhea[4..6].copy_from_slice(&800_i16.to_be_bytes());
|
||||
hhea[6..8].copy_from_slice(&(-200_i16).to_be_bytes());
|
||||
hhea[34..36].copy_from_slice(&1_u16.to_be_bytes());
|
||||
let tables = [
|
||||
(*b"head", head),
|
||||
(*b"hhea", hhea),
|
||||
(*b"hmtx", vec![1, 244, 0, 0]), // Advance 500, left side bearing 0.
|
||||
(*b"maxp", vec![0, 0, 80, 0, 0, 1]), // Version 0.5, one glyph.
|
||||
];
|
||||
let mut bytes = vec![0_u8; 12 + tables.len() * 16];
|
||||
bytes[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
bytes[4..6].copy_from_slice(&4_u16.to_be_bytes());
|
||||
bytes[6..8].copy_from_slice(&64_u16.to_be_bytes());
|
||||
bytes[8..10].copy_from_slice(&2_u16.to_be_bytes());
|
||||
for (i, (tag, data)) in tables.into_iter().enumerate() {
|
||||
while !bytes.len().is_multiple_of(4) {
|
||||
bytes.push(0);
|
||||
}
|
||||
let record = 12 + i * 16;
|
||||
let offset = u32::try_from(bytes.len()).unwrap();
|
||||
let len = u32::try_from(data.len()).unwrap();
|
||||
bytes[record..record + 4].copy_from_slice(&tag);
|
||||
bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes());
|
||||
bytes[record + 12..record + 16].copy_from_slice(&len.to_be_bytes());
|
||||
bytes.extend(data);
|
||||
}
|
||||
Face::new(std::sync::Arc::new(bytes), 0).expect("synthetic font parses")
|
||||
}
|
||||
|
||||
/// Empty complex glyphs retain spacing and underlines without adding geometry or text slots.
|
||||
#[test]
|
||||
fn empty_complex_glyph_keeps_advance_without_geometry() {
|
||||
let face = empty_glyph_face();
|
||||
let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0).expect("shapes empty glyph");
|
||||
assert!(shaped.width > 0.0, "the font's advance is retained");
|
||||
assert!(shaped.is_empty());
|
||||
assert!(shaped.glyphs.is_empty());
|
||||
assert!(shaped.text.is_empty());
|
||||
|
||||
let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0).expect("shapes decorated empty glyph");
|
||||
assert_eq!(underlined.width, shaped.width);
|
||||
assert!(underlined.glyphs.is_empty());
|
||||
assert!(underlined.text.is_empty());
|
||||
assert!(!underlined.strokes.is_empty(), "decoration spans survive an empty outline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_font_rtl_lines_keep_latin_and_digits_in_the_stroke_font() {
|
||||
// With or without installed fonts: the Persian words are fallback glyphs or
|
||||
|
||||
+8
-286
@@ -1,25 +1,13 @@
|
||||
//! TrueType subsets for embedding fonts in exported files (PDF): only the glyphs a document
|
||||
//! uses, plus the composite-glyph components they need. Glyph ids are kept (unused glyphs
|
||||
//! become empty), so text written with the original ids shows the same glyphs.
|
||||
//!
|
||||
//! Embedding follows the font's licence bits (OS/2 `fsType`): fonts marked "restricted
|
||||
//! licence", "no subsetting" or "bitmap only" are never embedded; callers draw their text as
|
||||
//! outlines instead. Fonts without TrueType outlines (CFF-flavoured OpenType) aren't subset
|
||||
//! here either.
|
||||
//! Whether a font may be embedded as a subset in exported files (PDF), from its licence bits
|
||||
//! (OS/2 `fsType`): fonts marked "restricted licence", "no subsetting" or "bitmap only" are
|
||||
//! never embedded; callers draw their text as outlines instead. Fonts without TrueType outlines
|
||||
//! (CFF-flavoured OpenType) aren't embedded either.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::FontRef;
|
||||
use skrifa::raw::types::Tag;
|
||||
use skrifa::{FontRef, MetadataProvider};
|
||||
|
||||
use crate::ttf::Face;
|
||||
|
||||
/// Largest subset written (bytes).
|
||||
pub const MAX_SUBSET_BYTES: usize = 32 << 20;
|
||||
/// Most components followed in one composite glyph, and most glyphs a subset may reach.
|
||||
const MAX_COMPONENTS: usize = 256;
|
||||
|
||||
/// Whether a face may be embedded as a subset.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Embedding {
|
||||
@@ -35,40 +23,17 @@ pub enum Embedding {
|
||||
NotTrueType,
|
||||
}
|
||||
|
||||
/// Why a subset couldn't be made.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SubsetError {
|
||||
/// The font may not be embedded as a subset.
|
||||
NotAllowed(Embedding),
|
||||
/// A table is missing or malformed.
|
||||
Malformed(&'static str),
|
||||
/// The subset would be larger than [`MAX_SUBSET_BYTES`].
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
fn font(face: &Face) -> Option<FontRef<'_>> {
|
||||
FontRef::from_index(&face.bytes, face.index).ok()
|
||||
}
|
||||
|
||||
fn tag(t: &[u8; 4]) -> Tag {
|
||||
Tag::new(t)
|
||||
}
|
||||
|
||||
fn table<'a>(f: &FontRef<'a>, t: &[u8; 4]) -> Option<&'a [u8]> {
|
||||
f.table_data(tag(t)).map(|d| d.as_bytes())
|
||||
f.table_data(Tag::new(t)).map(|d| d.as_bytes())
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], i: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], i: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([*b.get(i)?, *b.get(i + 1)?, *b.get(i + 2)?, *b.get(i + 3)?]))
|
||||
}
|
||||
|
||||
/// The embedding permission of a face.
|
||||
pub fn embedding(face: &Face) -> Embedding {
|
||||
let Some(f) = font(face) else { return Embedding::NotTrueType };
|
||||
let Ok(f) = FontRef::from_index(&face.bytes, face.index) else { return Embedding::NotTrueType };
|
||||
if table(&f, b"glyf").is_none() || table(&f, b"loca").is_none() || table(&f, b"head").is_none() {
|
||||
return Embedding::NotTrueType;
|
||||
}
|
||||
@@ -84,257 +49,14 @@ pub fn embedding(face: &Face) -> Embedding {
|
||||
}
|
||||
}
|
||||
|
||||
/// Font-wide metrics for a font descriptor, in units of 1/1000 em.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FontInfo {
|
||||
/// PostScript name: letters, digits and hyphens only (never empty).
|
||||
pub postscript_name: String,
|
||||
/// Glyph bounding box: x min, y min, x max, y max.
|
||||
pub bbox: [f64; 4],
|
||||
pub ascent: f64,
|
||||
pub descent: f64,
|
||||
pub cap_height: f64,
|
||||
/// Degrees, counter-clockwise from vertical.
|
||||
pub italic_angle: f64,
|
||||
}
|
||||
|
||||
/// A finite value or 0.
|
||||
fn fin(v: f32) -> f64 {
|
||||
let v = f64::from(v);
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The descriptor metrics of a face.
|
||||
pub fn info(face: &Face) -> Option<FontInfo> {
|
||||
let f = font(face)?;
|
||||
let m = f.metrics(Size::new(1000.0), LocationRef::default());
|
||||
let bbox = m.bounds.map(|b| [fin(b.x_min), fin(b.y_min), fin(b.x_max), fin(b.y_max)]).unwrap_or([0.0, -250.0, 1000.0, 1000.0]);
|
||||
let name: String = f
|
||||
.localized_strings(skrifa::string::StringId::POSTSCRIPT_NAME)
|
||||
.english_or_first()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
|
||||
.take(63)
|
||||
.collect();
|
||||
Some(FontInfo {
|
||||
postscript_name: if name.is_empty() { "Font".into() } else { name },
|
||||
bbox,
|
||||
ascent: fin(m.ascent),
|
||||
descent: fin(m.descent),
|
||||
cap_height: m.cap_height.map(fin).unwrap_or(fin(m.ascent) * 0.72),
|
||||
italic_angle: fin(m.italic_angle),
|
||||
})
|
||||
}
|
||||
|
||||
/// The advance width of glyph `gid` in units of 1/1000 em.
|
||||
pub fn advance(face: &Face, gid: u16) -> f64 {
|
||||
font(face)
|
||||
.and_then(|f| f.glyph_metrics(Size::new(1000.0), LocationRef::default()).advance_width(skrifa::GlyphId::new(u32::from(gid))))
|
||||
.map(fin)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Glyph data offsets from `loca`: `n + 1` entries, checked against the `glyf` length.
|
||||
fn loca_offsets(loca: &[u8], long: bool, n: usize, glyf_len: usize) -> Option<Vec<usize>> {
|
||||
let mut v = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
let o = if long { u32_at(loca, i * 4)? as usize } else { usize::from(u16_at(loca, i * 2)?) * 2 };
|
||||
v.push(o.min(glyf_len));
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// The data of glyph `g` (empty when its range is bad).
|
||||
fn glyph_data<'a>(glyf: &'a [u8], offsets: &[usize], g: usize) -> &'a [u8] {
|
||||
match (offsets.get(g), offsets.get(g + 1)) {
|
||||
(Some(a), Some(b)) if a < b => glyf.get(*a..*b).unwrap_or(&[]),
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph ids a composite glyph is built from.
|
||||
fn components(data: &[u8]) -> Vec<u16> {
|
||||
let mut out = Vec::new();
|
||||
// numberOfContours < 0: a composite.
|
||||
if u16_at(data, 0).is_none_or(|n| (n as i16) >= 0) {
|
||||
return out;
|
||||
}
|
||||
let mut pos = 10;
|
||||
while out.len() < MAX_COMPONENTS {
|
||||
let (Some(flags), Some(gid)) = (u16_at(data, pos), u16_at(data, pos + 2)) else { break };
|
||||
out.push(gid);
|
||||
pos += 4 + if flags & 0x0001 != 0 { 4 } else { 2 };
|
||||
pos += if flags & 0x0008 != 0 {
|
||||
2
|
||||
} else if flags & 0x0040 != 0 {
|
||||
4
|
||||
} else if flags & 0x0080 != 0 {
|
||||
8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if flags & 0x0020 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The sum of a table's big-endian 32-bit words (zero-padded).
|
||||
fn checksum(data: &[u8]) -> u32 {
|
||||
data.chunks(4).fold(0u32, |sum, c| {
|
||||
let mut w = [0u8; 4];
|
||||
for (d, s) in w.iter_mut().zip(c) {
|
||||
*d = *s;
|
||||
}
|
||||
sum.wrapping_add(u32::from_be_bytes(w))
|
||||
})
|
||||
}
|
||||
|
||||
/// A subset of `face` with the glyphs `gids` (and `.notdef` and the components they use). The
|
||||
/// other glyphs keep their ids but have no outline.
|
||||
pub fn subset(face: &Face, gids: &BTreeSet<u16>) -> Result<Vec<u8>, SubsetError> {
|
||||
let allowed = embedding(face);
|
||||
if allowed != Embedding::Allowed {
|
||||
return Err(SubsetError::NotAllowed(allowed));
|
||||
}
|
||||
let f = font(face).ok_or(SubsetError::Malformed("font"))?;
|
||||
let head = table(&f, b"head").filter(|h| h.len() >= 54).ok_or(SubsetError::Malformed("head"))?;
|
||||
let maxp = table(&f, b"maxp").ok_or(SubsetError::Malformed("maxp"))?;
|
||||
let glyf = table(&f, b"glyf").ok_or(SubsetError::Malformed("glyf"))?;
|
||||
let loca = table(&f, b"loca").ok_or(SubsetError::Malformed("loca"))?;
|
||||
let n = usize::from(u16_at(maxp, 4).ok_or(SubsetError::Malformed("maxp"))?);
|
||||
let long = u16_at(head, 50).ok_or(SubsetError::Malformed("head"))? != 0;
|
||||
let offsets = loca_offsets(loca, long, n, glyf.len()).ok_or(SubsetError::Malformed("loca"))?;
|
||||
// The glyphs to keep: asked for, .notdef, and composite components (transitively).
|
||||
let mut keep: BTreeSet<u16> = BTreeSet::new();
|
||||
let mut todo: Vec<u16> = std::iter::once(0).chain(gids.iter().copied()).filter(|g| usize::from(*g) < n).collect();
|
||||
while let Some(g) = todo.pop() {
|
||||
if !keep.insert(g) {
|
||||
continue;
|
||||
}
|
||||
for c in components(glyph_data(glyf, &offsets, usize::from(g))) {
|
||||
if usize::from(c) < n && !keep.contains(&c) {
|
||||
todo.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut new_glyf: Vec<u8> = Vec::new();
|
||||
let mut new_loca: Vec<u8> = Vec::with_capacity((n + 1) * 4);
|
||||
for g in 0..n {
|
||||
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
|
||||
if u16::try_from(g).is_ok_and(|g| keep.contains(&g)) {
|
||||
new_glyf.extend_from_slice(glyph_data(glyf, &offsets, g));
|
||||
while !new_glyf.len().is_multiple_of(4) {
|
||||
new_glyf.push(0);
|
||||
}
|
||||
}
|
||||
if new_glyf.len() > MAX_SUBSET_BYTES {
|
||||
return Err(SubsetError::TooLarge);
|
||||
}
|
||||
}
|
||||
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
|
||||
let mut new_head = head.to_vec();
|
||||
// checkSumAdjustment is set once the file is complete; offsets are long now.
|
||||
new_head.get_mut(8..12).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&[0; 4]);
|
||||
new_head.get_mut(50..52).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&1u16.to_be_bytes());
|
||||
let mut tables: Vec<([u8; 4], Vec<u8>)> = vec![(*b"glyf", new_glyf), (*b"head", new_head), (*b"loca", new_loca), (*b"maxp", maxp.to_vec())];
|
||||
for t in [b"cvt ", b"fpgm", b"hhea", b"hmtx", b"prep"] {
|
||||
if let Some(d) = table(&f, t) {
|
||||
tables.push((*t, d.to_vec()));
|
||||
}
|
||||
}
|
||||
if !tables.iter().any(|(t, _)| t == b"hhea") || !tables.iter().any(|(t, _)| t == b"hmtx") {
|
||||
return Err(SubsetError::Malformed("hhea/hmtx"));
|
||||
}
|
||||
tables.sort_by_key(|t| t.0);
|
||||
let out = sfnt(&tables);
|
||||
if out.len() > MAX_SUBSET_BYTES { Err(SubsetError::TooLarge) } else { Ok(out) }
|
||||
}
|
||||
|
||||
/// Write a TrueType file from `(tag, data)` tables sorted by tag, with checksums and the
|
||||
/// `head` checksum adjustment.
|
||||
fn sfnt(tables: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let count = tables.len() as u16;
|
||||
let mut pow = 1u16;
|
||||
let mut sel = 0u16;
|
||||
while pow.saturating_mul(2) <= count {
|
||||
pow = pow.saturating_mul(2);
|
||||
sel += 1;
|
||||
}
|
||||
let range = pow.saturating_mul(16);
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0x0001_0000u32.to_be_bytes());
|
||||
for v in [count, range, sel, count.saturating_mul(16).saturating_sub(range)] {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
let mut head_at = None;
|
||||
for (t, d) in tables {
|
||||
out.extend_from_slice(t);
|
||||
out.extend_from_slice(&checksum(d).to_be_bytes());
|
||||
out.extend_from_slice(&(offset as u32).to_be_bytes());
|
||||
out.extend_from_slice(&(d.len() as u32).to_be_bytes());
|
||||
offset += d.len().div_ceil(4) * 4;
|
||||
}
|
||||
for (t, d) in tables {
|
||||
if t == b"head" {
|
||||
head_at = Some(out.len());
|
||||
}
|
||||
out.extend_from_slice(d);
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
if let Some(h) = head_at {
|
||||
let adj = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out));
|
||||
if let Some(slot) = out.get_mut(h + 8..h + 12) {
|
||||
slot.copy_from_slice(&adj.to_be_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn composite_components_are_followed_and_bounded() {
|
||||
// numberOfContours = -1, then two components (word args, MORE_COMPONENTS on the first).
|
||||
let mut g = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
g.extend_from_slice(&[0x00, 0x21, 0x00, 0x05, 0, 0, 0, 0]);
|
||||
g.extend_from_slice(&[0x00, 0x00, 0x00, 0x07, 0, 0]);
|
||||
assert_eq!(components(&g), [5, 7]);
|
||||
// Truncated and endless (always MORE_COMPONENTS) data stay bounded.
|
||||
assert_eq!(components(&g[..14]), [5]);
|
||||
let mut endless = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
for _ in 0..2000 {
|
||||
endless.extend_from_slice(&[0x00, 0x20, 0x00, 0x01, 0, 0]);
|
||||
}
|
||||
assert_eq!(components(&endless).len(), MAX_COMPONENTS);
|
||||
assert!(components(&[0, 1, 0]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_fonts_are_not_embedded() {
|
||||
let junk = Face { bytes: std::sync::Arc::new(b"not a font".to_vec()), index: 0 };
|
||||
assert_eq!(embedding(&junk), Embedding::NotTrueType);
|
||||
assert!(subset(&junk, &BTreeSet::from([1])).is_err());
|
||||
assert!(info(&junk).is_none());
|
||||
assert_eq!(advance(&junk, 3), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sfnt_directory_and_checksums() {
|
||||
let mut head = vec![0u8; 54];
|
||||
head[12..16].copy_from_slice(&0x5F0F_3CF5u32.to_be_bytes());
|
||||
let out = sfnt(&[(*b"head", head), (*b"maxp", vec![0, 0, 0x50, 0, 0, 1])]);
|
||||
assert_eq!(u16_at(&out, 4), Some(2));
|
||||
assert_eq!(out.len() % 4, 0);
|
||||
// The whole file sums to the magic number once the adjustment is in.
|
||||
assert_eq!(checksum(&out), 0xB1B0_AFBA);
|
||||
assert_eq!(embedding(&Face { bytes: std::sync::Arc::new(Vec::new()), index: 7 }), Embedding::NotTrueType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,7 +569,9 @@ impl Polyline {
|
||||
let (Some(a), Some(b)) = (self.vertices.get(i), self.vertices.get((i + 1) % n)) else { continue };
|
||||
let (pa, pb) = (a.p - origin, b.p - origin);
|
||||
add(pa.cross(pb) / 2.0, (pa + pb) * (1.0 / 3.0));
|
||||
if let Some((arc, ccw)) = bulge_to_arc(a.p, b.p, a.bulge) {
|
||||
if (i + 1 < n || self.closed)
|
||||
&& let Some((arc, ccw)) = bulge_to_arc(a.p, b.p, a.bulge)
|
||||
{
|
||||
let t = arc.sweep();
|
||||
let seg = arc.radius * arc.radius * (t - t.sin()) / 2.0;
|
||||
if seg > 0.0 {
|
||||
|
||||
@@ -62,22 +62,29 @@ pub fn circle_circle(c1: &Circle, c2: &Circle) -> Vec<Vec2> {
|
||||
|
||||
// Work at the scale of the two circles: fixed absolute tolerances classify
|
||||
// small secants as tangencies, and raw radius squares overflow on large drawings.
|
||||
// Measure the chord from the smaller circle: from the larger one the half-chord is a
|
||||
// difference of nearly equal numbers, so a tiny circle crossing a huge one is lost.
|
||||
let swapped = r2 < r1;
|
||||
let (from, dir) = if swapped { (c2.center, -(delta / d)) } else { (c1.center, delta / d) };
|
||||
let scale = d.max(r1).max(r2);
|
||||
let (ds, r1s, r2s) = (d / scale, r1 / scale, r2 / scale);
|
||||
if ds > r1s + r2s || ds < (r1s - r2s).abs() {
|
||||
return Vec::new();
|
||||
}
|
||||
let a = ((r1s - r2s) * (r1s + r2s) + ds * ds) / (2.0 * ds);
|
||||
let h2 = (r1s - a) * (r1s + a);
|
||||
if h2 < -1e-12 {
|
||||
let (rs, ro) = if swapped { (r2s, r1s) } else { (r1s, r2s) };
|
||||
let a = ((rs - ro) * (rs + ro) + ds * ds) / (2.0 * ds);
|
||||
let h2 = (rs - a) * (rs + a);
|
||||
// Tangency is judged against the smaller circle, which sets the size of the chord, with
|
||||
// room for the rounding in `a` (a few ulps of the unit scale over ds, times 2a in h2).
|
||||
let tol = (1e-12 * rs * rs).max(8.0 * f64::EPSILON * rs / ds);
|
||||
if h2 < -tol {
|
||||
return Vec::new();
|
||||
}
|
||||
let dir = delta / d;
|
||||
let p = c1.center + dir * (a * scale);
|
||||
let p = from + dir * (a * scale);
|
||||
if !p.is_finite() {
|
||||
return Vec::new();
|
||||
}
|
||||
if h2 <= 1e-12 {
|
||||
if h2 <= tol {
|
||||
return vec![p];
|
||||
}
|
||||
let step = dir.perp() * (h2.sqrt() * scale);
|
||||
@@ -85,7 +92,8 @@ pub fn circle_circle(c1: &Circle, c2: &Circle) -> Vec<Vec2> {
|
||||
if !p1.is_finite() || !p2.is_finite() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![p1, p2]
|
||||
// Keep the result order independent of which circle was used as the base.
|
||||
if swapped { vec![p2, p1] } else { vec![p1, p2] }
|
||||
}
|
||||
|
||||
fn on_arc(a: &Arc, p: Vec2) -> bool {
|
||||
|
||||
+44
-17
@@ -60,6 +60,21 @@ struct Graph {
|
||||
edge_src: HashMap<(usize, usize), usize>,
|
||||
}
|
||||
|
||||
/// Records the crossing of segments `i` and `j`, if any, as split parameters on both.
|
||||
fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec<f64>], i: usize, j: usize) {
|
||||
let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { return };
|
||||
let li = Line::new(si.0, si.1);
|
||||
let lj = Line::new(sj.0, sj.1);
|
||||
if let Some(x) = line_line(&li, &lj) {
|
||||
if let Some(v) = splits.get_mut(i) {
|
||||
v.push(li.param_of(x).clamp(0.0, 1.0));
|
||||
}
|
||||
if let Some(v) = splits.get_mut(j) {
|
||||
v.push(lj.param_of(x).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
let mut owner: Vec<usize> = Vec::new();
|
||||
@@ -94,16 +109,13 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
)
|
||||
};
|
||||
let mut grid: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
let mut long: Vec<usize> = Vec::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let (x0, y0) = cell(a.min(*b));
|
||||
let (x1, y1) = cell(a.max(*b));
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
|
||||
// Very long segment: register in a coarse band.
|
||||
for gx in (x0..=x1).step_by(((x1 - x0) / 64).max(1)) {
|
||||
for gy in (y0..=y1).step_by(((y1 - y0) / 64).max(1)) {
|
||||
grid.entry((gx, gy)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
// Very long segment: too many cells to register in; it is tested against every segment below.
|
||||
long.push(i);
|
||||
continue;
|
||||
}
|
||||
for gx in x0..=x1 {
|
||||
@@ -130,23 +142,24 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { continue };
|
||||
let li = Line::new(si.0, si.1);
|
||||
let lj = Line::new(sj.0, sj.1);
|
||||
if let Some(x) = line_line(&li, &lj) {
|
||||
if let Some(v) = splits.get_mut(i) {
|
||||
v.push(li.param_of(x).clamp(0.0, 1.0));
|
||||
}
|
||||
if let Some(v) = splits.get_mut(j) {
|
||||
v.push(lj.param_of(x).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
split_pair(&segs, &mut splits, i, j);
|
||||
if tested.len() > 20_000_000 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for &i in &long {
|
||||
for j in 0..n {
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
split_pair(&segs, &mut splits, i, j);
|
||||
if tested.len() > 20_000_000 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n), edge_src: HashMap::new() };
|
||||
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
|
||||
let mut vid = |p: Vec2, g: &mut Graph| -> usize {
|
||||
@@ -609,6 +622,20 @@ mod tests {
|
||||
vec![Vec2::new(x0, y0), Vec2::new(x0 + s, y0), Vec2::new(x0 + s, y0 + s), Vec2::new(x0, y0 + s), Vec2::new(x0, y0)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_segment_crossing_in_unsampled_cell_is_split() {
|
||||
// A diagonal long enough to skip the grid, and a short segment crossing it in a cell the old coarse sampling missed.
|
||||
let mut polys = vec![vec![Vec2::new(0.0, 0.0), Vec2::new(1000.0, 1000.0)], vec![Vec2::new(11.0, 12.0), Vec2::new(12.0, 11.0)]];
|
||||
for i in 0..17_000usize {
|
||||
let x = 600.0 + (i % 130) as f64 * 2.0;
|
||||
let y = 100.0 + (i / 130) as f64 * 2.0;
|
||||
polys.push(vec![Vec2::new(x, y), Vec2::new(x + 1.0, y)]);
|
||||
}
|
||||
let g = build(&polys, 1e-6).unwrap();
|
||||
let hit = g.pts.iter().position(|p| p.near(Vec2::new(11.5, 11.5), 1e-6)).unwrap();
|
||||
assert_eq!(g.adj[hit].len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_square_around_point() {
|
||||
let l = enclosing_loop(&[square(0.0, 0.0, 10.0)], Vec2::new(5.0, 5.0), 1e-9).unwrap();
|
||||
|
||||
@@ -119,6 +119,29 @@ fn circle_circle_hits() {
|
||||
assert!(h.iter().all(|p| close(p.x, 4.0) && close(p.y.abs(), 3.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_circle_crossing_a_huge_circle_gives_two_hits() {
|
||||
// Radii 1e7 and 1 with centres 1e7 apart: the small circle crosses the large one.
|
||||
let big = Circle::new(Vec2::ZERO, 1e7);
|
||||
let small = Circle::new(Vec2::new(1e7, 0.0), 1.0);
|
||||
for hits in [circle_circle(&big, &small), circle_circle(&small, &big)] {
|
||||
assert_eq!(hits.len(), 2, "{hits:?}");
|
||||
for p in &hits {
|
||||
assert!((p.dist(small.center) - 1.0).abs() < 1e-6, "{p:?} is off the small circle");
|
||||
assert!((p.dist(big.center) - 1e7).abs() < 1e-6, "{p:?} is off the big circle");
|
||||
}
|
||||
assert!(hits[0].dist(hits[1]) > 1.0, "{hits:?}");
|
||||
}
|
||||
// Touching from outside and from inside is still one tangent point, in both orders.
|
||||
for (x, touch) in [(1e7 + 1.0, 1e7), (1e7 - 1.0, 1e7)] {
|
||||
let small = Circle::new(Vec2::new(x, 0.0), 1.0);
|
||||
for hits in [circle_circle(&big, &small), circle_circle(&small, &big)] {
|
||||
assert_eq!(hits.len(), 1, "{hits:?}");
|
||||
assert!(hits[0].dist(Vec2::new(touch, 0.0)) < 1e-6, "{hits:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_circle_secants_and_separated_circles() {
|
||||
let first = Circle::new(Vec2::ZERO, 1e-9);
|
||||
@@ -331,3 +354,13 @@ fn long_secant_keeps_both_intersections() {
|
||||
assert!(hits[0].0.near(Vec2::new(-2.0, 0.0), 1e-8), "{hits:?}");
|
||||
assert!(hits[1].0.near(Vec2::new(2.0, 0.0), 1e-8), "{hits:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_polyline_centroid_ignores_last_bulge() {
|
||||
let pts = [Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0)];
|
||||
let mut pl = Polyline::from_points(&pts, false);
|
||||
let before = pl.centroid().unwrap();
|
||||
pl.vertices[2].bulge = 1.0;
|
||||
let after = pl.centroid().unwrap();
|
||||
assert!(before.near(after, 1e-9), "{before:?} -> {after:?}");
|
||||
}
|
||||
|
||||
@@ -20,9 +20,12 @@ thiserror = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
miniz_oxide = "0.9"
|
||||
craft-pdf = { workspace = true, features = ["write"] }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
|
||||
acadrust = "0.6.5"
|
||||
tiny-skia = { workspace = true }
|
||||
# Reads plotted PDFs back.
|
||||
craft-pdf = { workspace = true, features = ["read"] }
|
||||
|
||||
@@ -118,6 +118,10 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[
|
||||
("arrowBlock2", 344, K::Block),
|
||||
("fixedExtLength", 49, K::Real),
|
||||
("jogAngle", 50, K::Angle),
|
||||
// DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1, DIMSTYLE). In a
|
||||
// DIMSTYLE record 70 also holds the record's flags, which come first.
|
||||
("textFill", 69, K::Int),
|
||||
("textFillColor", 70, K::Color),
|
||||
("angularZeroSuppression", 79, K::Int),
|
||||
("arcSymbol", 90, K::Int),
|
||||
("textVerticalPos", 145, K::Real),
|
||||
@@ -143,14 +147,23 @@ pub(crate) const DIMSAH: i32 = 173;
|
||||
/// DIMPOST's group in DIMSTYLE records.
|
||||
pub(crate) const DIMPOST: i32 = 3;
|
||||
|
||||
/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMARCSYM, DIMFXLON, DIMLTYPE, DIMLTEX1,
|
||||
/// DIMLTEX2. Files of [`NEWER_SINCE`] or later get them as DIMSTYLE groups; R2000 files get them
|
||||
/// in a style's `CADCRAFT` "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of
|
||||
/// groups an R2000 reader doesn't expect. The reader takes both.
|
||||
pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 90, 290, 345, 346, 347];
|
||||
/// The first version with [`NEWER_CODES`] as DIMSTYLE groups: all came with AutoCAD 2006, whose
|
||||
/// files are the 2004 format (AC1018).
|
||||
/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMTFILL, DIMTFILLCLR, DIMARCSYM, DIMFXLON,
|
||||
/// DIMLTYPE, DIMLTEX1, DIMLTEX2. Files of a variable's [`newer_since`] version or later get it as
|
||||
/// a DIMSTYLE group (R2004, and R2007 for DIMTFILL and DIMTFILLCLR); older files get it in a
|
||||
/// style's `CADCRAFT` "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of a
|
||||
/// group an older reader doesn't expect. The reader takes both.
|
||||
pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 69, 70, 90, 290, 345, 346, 347];
|
||||
/// The first version with [`NEWER_CODES`] as DIMSTYLE groups, except DIMTFILL and DIMTFILLCLR
|
||||
/// (see [`newer_since`]): the others came with AutoCAD 2006, whose files are the 2004 format
|
||||
/// (AC1018).
|
||||
pub(crate) const NEWER_SINCE: Version = Version::R2004;
|
||||
|
||||
/// The first version that has `code` (one of [`NEWER_CODES`]) as a DIMSTYLE group: R2007 for
|
||||
/// DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1), else
|
||||
/// [`NEWER_SINCE`].
|
||||
pub(crate) fn newer_since(code: i32) -> Version {
|
||||
if matches!(code, 69 | 70) { Version::R2007 } else { NEWER_SINCE }
|
||||
}
|
||||
/// Xdata list name of [`NEWER_CODES`] values.
|
||||
pub(crate) const DIMVARS_LIST: &str = "DIMVARS";
|
||||
|
||||
|
||||
@@ -734,7 +734,7 @@ fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
|
||||
while let Some(x) = get(i).filter(|x| matches!(x.code, 94 | 73 | 74 | 95 | 96)) {
|
||||
let n = x.i64().clamp(0, 100_000) as usize;
|
||||
match x.code {
|
||||
94 => degree = n,
|
||||
94 => degree = n.clamp(1, 10), // same cap as the SPLINE reader
|
||||
95 => nk = n,
|
||||
96 => nc = n,
|
||||
_ => {}
|
||||
@@ -1587,7 +1587,12 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
// Every variable with a DIMSTYLE group code; values the style can't take are
|
||||
// left at their defaults.
|
||||
let mut fields = serde_json::Map::new();
|
||||
for tag in tg.iter().take_while(|x| x.code < 1000) {
|
||||
// The first 70 is the record's flags; a later one is DIMTFILLCLR.
|
||||
let flags = tg.iter().position(|x| x.code == 70);
|
||||
for (i, tag) in tg.iter().enumerate().take_while(|(_, x)| x.code < 1000) {
|
||||
if Some(i) == flags {
|
||||
continue;
|
||||
}
|
||||
let Some((field, kind)) = crate::dxf_ext::dim_field(tag.code) else { continue };
|
||||
if let Some(v) = crate::dxf_ext::decode(kind, tag, &rx.styles, &rx.brs, &rx.ltypes) {
|
||||
fields.insert(field.to_string(), v);
|
||||
@@ -1979,6 +1984,8 @@ struct Objects {
|
||||
layout_views: Vec<(String, (Vec2, f64))>,
|
||||
/// SORTENTSTABLE objects: (owning block record handle, entity handle → sort handle).
|
||||
sortents: Vec<(String, HashMap<Handle, u64>)>,
|
||||
/// WIPEOUTVARIABLES: the drawing's WIPEOUTFRAME (0 = frames hidden, 1 = shown).
|
||||
wipeout_frame: Option<i64>,
|
||||
}
|
||||
|
||||
const MAX_OBJECTS: usize = 1_000_000;
|
||||
@@ -2017,6 +2024,7 @@ impl Objects {
|
||||
"DIMASSOC" if self.dimassocs.len() < MAX_OBJECTS => self.dimassocs.push(tags.to_vec()),
|
||||
"MLEADERSTYLE" if self.mleader_styles.len() < MAX_OBJECTS => self.mleader_styles.push((h, tags.to_vec())),
|
||||
"GROUP" if self.groups.len() < MAX_OBJECTS => self.groups.push((h, tags.to_vec())),
|
||||
"WIPEOUTVARIABLES" => self.wipeout_frame = T(tags).i(70).filter(|v| (0..=2).contains(v)),
|
||||
"SORTENTSTABLE" if self.sortents.len() < MAX_OBJECTS => {
|
||||
if let Some(t) = sortents_table(tags) {
|
||||
self.sortents.push(t);
|
||||
@@ -2047,6 +2055,9 @@ impl Objects {
|
||||
}
|
||||
|
||||
fn apply(self, d: &mut Drawing, rx: &Rx) {
|
||||
if let Some(v) = self.wipeout_frame {
|
||||
d.header.set_i64("WIPEOUTFRAME", v);
|
||||
}
|
||||
if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) {
|
||||
d.header.set_str("CMLEADERSTYLE", &style);
|
||||
}
|
||||
|
||||
@@ -1393,16 +1393,21 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
|
||||
// dimension's own colour (#1052).
|
||||
let in_color = |color: Color| Common { layer: layer.into(), color, ..Common::default() };
|
||||
let (dim_c, ext_c, text_c) = (in_color(style.dim_line_color), in_color(style.ext_line_color), in_color(style.text_color));
|
||||
for (i, l) in g.lines.iter().enumerate() {
|
||||
let line_entities = |i: usize, l: &[Vec2]| -> Vec<Entity> {
|
||||
let c = match g.line_roles.get(i) {
|
||||
Some(cadcraft_render::LineRole::Ext) => &ext_c,
|
||||
_ => &dim_c,
|
||||
};
|
||||
for seg in l.windows(2) {
|
||||
if let [a, b] = seg {
|
||||
out.push(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) });
|
||||
}
|
||||
}
|
||||
l.windows(2)
|
||||
.filter_map(|seg| match seg {
|
||||
[a, b] => Some(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) }),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// The text's marks (arc length symbol, DIMINSPECT frame) go after the text background.
|
||||
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| !g.text_marks.contains(i)) {
|
||||
out.extend(line_entities(i, l));
|
||||
}
|
||||
for t in &g.fills {
|
||||
if let [a, b, cc] = t.as_slice() {
|
||||
@@ -1413,6 +1418,20 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// DIMTFILL 2: the text background in DIMTFILLCLR, under the text and its marks (SOLID
|
||||
// corners go 1, 2, 4, 3).
|
||||
if style.text_fill == 2
|
||||
&& let [p0, p1, p2, p3] = g.text_fill.as_slice()
|
||||
{
|
||||
out.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: in_color(style.text_fill_color),
|
||||
kind: EntityKind::Solid(Solid { corners: [p0.to3(0.0), p1.to3(0.0), p3.to3(0.0), p2.to3(0.0)] }),
|
||||
});
|
||||
}
|
||||
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| g.text_marks.contains(i)) {
|
||||
out.extend(line_entities(i, l));
|
||||
}
|
||||
if !g.value.is_empty() {
|
||||
out.push(Entity {
|
||||
handle: Handle(0),
|
||||
@@ -2290,8 +2309,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
|
||||
w.dimval(*code, enc, false);
|
||||
continue;
|
||||
}
|
||||
// Table groups where the file version has them, else xdata.
|
||||
if version >= dxf_ext::NEWER_SINCE {
|
||||
// Table groups where the file version has them, else xdata. DIMTFILL and DIMTFILLCLR
|
||||
// are written only when set, as other writers do, so a style without a text
|
||||
// background has a single 70 (its flags).
|
||||
if version >= dxf_ext::newer_since(*code) {
|
||||
if matches!(*code, 69 | 70) && v.get(*field) == defaults.get(*field) {
|
||||
continue;
|
||||
}
|
||||
w.dimval(*code, enc, false);
|
||||
} else if v.get(*field) != defaults.get(*field) {
|
||||
newer.i(1070, i64::from(*code));
|
||||
|
||||
+360
-382
@@ -1,11 +1,12 @@
|
||||
//! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF writer of our own (pure Rust, no file
|
||||
//! access, so it runs on the web too).
|
||||
//! Vector PDF plotting (PLOT / EXPORTPDF), written with craft-pdf's PDF writer (krilla; pure
|
||||
//! Rust, no file access, so it runs on the web too).
|
||||
//!
|
||||
//! One page per sheet: model-space extents fitted to a sheet, or a layout at 1:1 (or the given
|
||||
//! `scale`) on its paper (paper units are millimetres in metric drawings, inches otherwise).
|
||||
//! Lines are stroked paths (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white
|
||||
//! paper, and line widths come from lineweights when enabled. The page setup's plot style
|
||||
//! table (or the `plotStyleTable` option) sets the printed colours, linetypes and lineweights.
|
||||
//! Lines are stroked paths, fills are filled paths, colours are RGB with colour 7 printing
|
||||
//! black on white paper, and line widths come from lineweights when enabled. The page setup's
|
||||
//! plot style table (or the `plotStyleTable` option) sets the printed colours, linetypes and
|
||||
//! lineweights.
|
||||
//!
|
||||
//! Several sheets go into one file ([`pdf_sheets`], the `layouts` option), each page labelled
|
||||
//! with its layout name. Drawing layers become PDF layers (optional content groups) that a PDF
|
||||
@@ -19,6 +20,19 @@ use cadcraft_geom::{Bounds2, Vec2};
|
||||
use cadcraft_render::{Kind, Sheet, clip, paper};
|
||||
use serde_json::Value;
|
||||
|
||||
use craft_pdf::write::backend as kr;
|
||||
use craft_pdf::write::{self, Metadata, WriteError, Writer};
|
||||
use kr::action::LinkAction;
|
||||
use kr::annotation::{Annotation, LinkAnnotation, Target};
|
||||
use kr::error::KrillaError;
|
||||
use kr::geom::{Path, PathBuilder, Point, Rect, Transform};
|
||||
use kr::num::NormalizedF32;
|
||||
use kr::optional_content::OcgId;
|
||||
use kr::page::{PageLabel, PageSettings};
|
||||
use kr::paint::{Fill, FillRule, LineCap, LineJoin, Stroke};
|
||||
use kr::surface::Surface;
|
||||
use kr::text::{GlyphId, KrillaGlyph};
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
mod fonts;
|
||||
@@ -31,9 +45,13 @@ const MAX_SHEET_MM: f64 = 5080.0;
|
||||
pub const MAX_PAGES: usize = 256;
|
||||
/// Most PDF layers in one file; objects on further layers print without one.
|
||||
const MAX_LAYERS: usize = 4096;
|
||||
/// Longest PDF layer name, in characters.
|
||||
const MAX_LAYER_NAME: usize = 1000;
|
||||
/// Most link annotations in one file, and the longest link target (bytes).
|
||||
const MAX_LINKS: usize = 10_000;
|
||||
const MAX_URL: usize = 2048;
|
||||
/// Longest title and page label written (characters).
|
||||
const MAX_TEXT: usize = 1000;
|
||||
|
||||
/// Plot settings. `None` fields fall back to the layout's page setup (or sensible defaults for
|
||||
/// model space).
|
||||
@@ -64,8 +82,8 @@ pub struct PdfOptions {
|
||||
pub window: Option<Bounds2>,
|
||||
/// Drawing layers become PDF layers (optional content groups). Default on.
|
||||
pub layers: bool,
|
||||
/// With `layers`: objects on layers that are off are included on PDF layers that start
|
||||
/// hidden (frozen layers are never included). Default off: they aren't plotted.
|
||||
/// With `layers`: objects on layers that are off are plotted on PDF layers that start
|
||||
/// hidden (frozen layers are never plotted). Default off: they aren't plotted.
|
||||
pub include_off_layers: bool,
|
||||
/// Hyperlinks: an entity's handle and its link target (a URL). The area the entity covers
|
||||
/// on the sheet becomes a link annotation.
|
||||
@@ -356,23 +374,121 @@ pub fn pdf_items(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions) ->
|
||||
if items.len() > MAX_PAGES {
|
||||
return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF")));
|
||||
}
|
||||
// Layers that are off plot on PDF layers that start hidden: draw them from a copy with them on.
|
||||
let with_off;
|
||||
let drawn = if o.layers && o.include_off_layers && d.layers.iter().any(|l| !l.on && !l.frozen) {
|
||||
let mut c = d.clone();
|
||||
for l in c.layers.iter_mut().filter(|l| !l.frozen) {
|
||||
l.on = true;
|
||||
match write_pdf(d, items, o, o.text_as_geometry) {
|
||||
// A font the PDF writer can't embed after all, or one that makes it fail internally:
|
||||
// plot the text as outlines instead. Outlines embed no font, so this runs at most once.
|
||||
Err(Failure::Write(e)) if !o.text_as_geometry && font_failure(&e) => write_pdf(d, items, o, true).map_err(Failure::into_io),
|
||||
r => r.map_err(Failure::into_io),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a writer failure may come from embedding a font: a font the writer refused, or a
|
||||
/// panic inside the writer (caught by `guard`), which a malformed font can cause.
|
||||
fn font_failure(e: &WriteError) -> bool {
|
||||
match e {
|
||||
WriteError::Serialization(e) => matches!(**e, kr::error::KrillaError::Font(..)),
|
||||
WriteError::BackendPanic(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Why writing failed: our own error, or the PDF writer's.
|
||||
enum Failure {
|
||||
Io(IoError),
|
||||
Write(WriteError),
|
||||
}
|
||||
|
||||
impl From<IoError> for Failure {
|
||||
fn from(e: IoError) -> Self {
|
||||
Failure::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WriteError> for Failure {
|
||||
fn from(e: WriteError) -> Self {
|
||||
Failure::Write(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
fn into_io(self) -> IoError {
|
||||
match self {
|
||||
Failure::Io(e) => e,
|
||||
Failure::Write(e) => IoError::Format(format!("PDF export failed: {e}")),
|
||||
}
|
||||
with_off = c;
|
||||
&with_off
|
||||
} else {
|
||||
d
|
||||
};
|
||||
let links = all_links(d, o);
|
||||
let pages = items.iter().map(|(s, po)| sheet(drawn, s, po, &links)).collect::<Result<Vec<_>>>()?;
|
||||
let title = if o.title.is_empty() { pages.first().map(|p| p.label.clone()).unwrap_or_default() } else { o.title.clone() };
|
||||
let fonts = if o.text_as_geometry { fonts::Fonts::default() } else { fonts::Fonts::collect(pages.iter().map(|p| &p.list)) };
|
||||
Ok(assemble(d, &pages, &fonts, o, &title))
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the file, one sheet at a time: each sheet's display list is built, written as its page
|
||||
/// and dropped before the next.
|
||||
fn write_pdf(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions, text_as_geometry: bool) -> std::result::Result<Vec<u8>, Failure> {
|
||||
write::guard(|| {
|
||||
// Layers that are off plot on PDF layers that start hidden: draw them from a copy with
|
||||
// them on.
|
||||
let with_off;
|
||||
let drawn = if o.layers && o.include_off_layers && d.layers.iter().any(|l| !l.on && !l.frozen) {
|
||||
let mut c = d.clone();
|
||||
for l in c.layers.iter_mut().filter(|l| !l.frozen) {
|
||||
l.on = true;
|
||||
}
|
||||
with_off = c;
|
||||
&with_off
|
||||
} else {
|
||||
d
|
||||
};
|
||||
let links = all_links(d, o);
|
||||
let title = if o.title.is_empty() { items.first().map(|(s, _)| sheet_name(s)).unwrap_or_default() } else { o.title.clone() };
|
||||
let metadata = Metadata {
|
||||
creator: "CADCraft".into(),
|
||||
producer: "CADCraft".into(),
|
||||
title: Some(title.chars().take(MAX_TEXT).collect()),
|
||||
..Metadata::default()
|
||||
};
|
||||
let settings = kr::SerializeSettings {
|
||||
compress_content_streams: o.compress,
|
||||
xmp_metadata: false,
|
||||
enable_tagging: false,
|
||||
..kr::SerializeSettings::default()
|
||||
};
|
||||
let mut writer = Writer::new(settings, metadata);
|
||||
let mut fonts = fonts::Fonts::new(!text_as_geometry);
|
||||
let mut groups = Groups::default();
|
||||
for (s, po) in items {
|
||||
let page = sheet(drawn, s, po, &links)?;
|
||||
let doc = writer.document_mut();
|
||||
// Groups are added to the document, so they must exist before the page starts.
|
||||
groups.add(doc, d, &page.layers);
|
||||
write_page(doc, &page, &groups, &mut fonts)?;
|
||||
}
|
||||
if items.len() == 1 && !groups.ids.is_empty() {
|
||||
writer.document_mut().set_optional_content_page_mode(true);
|
||||
}
|
||||
Ok(writer.finish()?)
|
||||
})
|
||||
}
|
||||
|
||||
/// The PDF layers (optional content groups) of a file: one per drawing layer used, shared by
|
||||
/// all pages, at most [`MAX_LAYERS`].
|
||||
#[derive(Default)]
|
||||
struct Groups {
|
||||
ids: std::collections::HashMap<u32, OcgId>,
|
||||
}
|
||||
|
||||
impl Groups {
|
||||
/// Add groups for the drawing layers in `layers` that don't have one yet, named after the
|
||||
/// layer and visible when the layer is on in `d`, the drawing as given (not the copy that
|
||||
/// turns layers that are off on to plot them).
|
||||
fn add(&mut self, doc: &mut kr::Document, d: &Drawing, layers: &[u32]) {
|
||||
for l in layers {
|
||||
if self.ids.len() >= MAX_LAYERS || self.ids.contains_key(l) {
|
||||
continue;
|
||||
}
|
||||
let Some(layer) = d.layers.get(*l as usize) else { continue };
|
||||
let name: String = layer.name.chars().take(MAX_LAYER_NAME).collect();
|
||||
let id = doc.add_optional_content_group(name, layer.on);
|
||||
self.ids.insert(*l, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One plotted sheet, ready to be written as a page.
|
||||
@@ -383,7 +499,8 @@ struct SheetPage {
|
||||
list: cadcraft_render::DisplayList,
|
||||
map: Map,
|
||||
clip_pt: Bounds2,
|
||||
/// The drawing layers (indices in the layer table) of the content's `/L<i>` marks.
|
||||
/// The drawing layers (indices in the layer table) drawn on, in order of first use: the
|
||||
/// candidates for PDF layers.
|
||||
layers: Vec<u32>,
|
||||
/// Link annotations: rectangle in points and target.
|
||||
links: Vec<(Bounds2, String)>,
|
||||
@@ -556,125 +673,170 @@ pub(crate) fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f6
|
||||
if s.is_finite() && s > 0.0 { s } else { 1.0 }
|
||||
}
|
||||
|
||||
/// A number for a content stream: short, finite, no exponent.
|
||||
fn num(out: &mut String, v: f64) {
|
||||
let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
|
||||
let mut s = format!("{v:.3}");
|
||||
if s.contains('.') {
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
if s.ends_with('.') {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
if s == "-0" {
|
||||
s = "0".into();
|
||||
}
|
||||
out.push_str(&s);
|
||||
/// Largest coordinate written (points); a primitive reaching further is clipped to the plot
|
||||
/// area first. The PDF writer's geometry is `f32`.
|
||||
const MAX_COORD: f64 = 1.0e7;
|
||||
|
||||
/// A point in points as the writer's `f32`, or `None` when it is not finite or out of range.
|
||||
fn coord(p: Vec2) -> Option<(f32, f32)> {
|
||||
let ok = |v: f64| v.is_finite() && v.abs() <= MAX_COORD;
|
||||
(ok(p.x) && ok(p.y)).then_some((p.x as f32, p.y as f32))
|
||||
}
|
||||
|
||||
fn pt(out: &mut String, p: Vec2) {
|
||||
num(out, p.x);
|
||||
out.push(' ');
|
||||
num(out, p.y);
|
||||
/// A rectangle path between two corners (points), if it has an area.
|
||||
fn rect_path(min: Vec2, max: Vec2) -> Option<Path> {
|
||||
let (x0, y0) = coord(min)?;
|
||||
let (x1, y1) = coord(max)?;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_rect(Rect::from_ltrb(x0, y0, x1, y1)?);
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
fn color(out: &mut String, c: Rgb, op: &str) {
|
||||
for v in [c.0, c.1, c.2] {
|
||||
num(out, f64::from(v) / 255.0);
|
||||
out.push(' ');
|
||||
/// Diameter (points) of the dot a zero-length hairline plots: a hairline has no width of its
|
||||
/// own, and this is about the thinnest mark that still prints.
|
||||
const HAIRLINE_DOT_PT: f32 = 0.25;
|
||||
|
||||
/// A circle path (points) around `(x, y)`: four cubic arcs.
|
||||
fn circle_path(x: f32, y: f32, r: f32) -> Option<Path> {
|
||||
// Control-point distance of a quarter-circle cubic, as a fraction of the radius.
|
||||
let k = r * 0.552_284_8;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.move_to(x + r, y);
|
||||
pb.cubic_to(x + r, y + k, x + k, y + r, x, y + r);
|
||||
pb.cubic_to(x - k, y + r, x - r, y + k, x - r, y);
|
||||
pb.cubic_to(x - r, y - k, x - k, y - r, x, y - r);
|
||||
pb.cubic_to(x + k, y - r, x + r, y - k, x + r, y);
|
||||
pb.close();
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Write one sheet as a page: its size, label, link annotations and marks.
|
||||
fn write_page(doc: &mut kr::Document, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> {
|
||||
let (w, h) = (p.media.x as f32, p.media.y as f32);
|
||||
let label = PageLabel::new(None, Some(p.label.chars().take(MAX_TEXT).collect()), None);
|
||||
let settings = PageSettings::from_wh(w, h).ok_or_else(|| IoError::Format("the sheet has no area".into()))?.with_page_label(label);
|
||||
let mut page = doc.start_page_with(settings);
|
||||
for (r, url) in &p.links {
|
||||
let Some(uri) = uri(url) else { continue };
|
||||
// Annotation rectangles are given top-down, like the writer's page surface.
|
||||
let Some(rect) = Rect::from_ltrb(r.min.x as f32, h - r.max.y as f32, r.max.x as f32, h - r.min.y as f32) else { continue };
|
||||
let link = LinkAnnotation::new(rect, Target::Action(LinkAction::new(uri).into()));
|
||||
page.add_annotation(Annotation::new_link(link, None));
|
||||
}
|
||||
out.push_str(op);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
/// The page's marks; those of primitives on `layers` are marked as optional content
|
||||
/// (`/OC /L<layer> BDC` … `EMC`).
|
||||
///
|
||||
/// Primitives in `texts` (filled glyph outlines) are written as text instead: the glyph in its
|
||||
/// embedded font (`/F<font>`), placed by a text matrix.
|
||||
///
|
||||
/// Transparent primitives are drawn with a graphics state of their alpha (`/T<percent> gs`);
|
||||
/// the transparencies used are returned for the page's `/ExtGState` (see [`ext_gstates`]). A
|
||||
/// list without transparency writes no graphics states.
|
||||
fn content_stream(
|
||||
list: &cadcraft_render::DisplayList,
|
||||
map: &Map,
|
||||
clip_pt: &Bounds2,
|
||||
layers: &[u32],
|
||||
texts: &std::collections::HashMap<u32, (usize, &cadcraft_render::PlotText)>,
|
||||
) -> (String, Vec<u8>) {
|
||||
let marked: std::collections::HashSet<u32> = layers.iter().copied().collect();
|
||||
let white = Rgb(255, 255, 255);
|
||||
let mut c = String::new();
|
||||
c.push_str("q\n1 J 1 j\n");
|
||||
let mut surface = page.surface();
|
||||
// The surface's y axis points down; flip it so we draw in PDF points (y up).
|
||||
surface.push_transform(&Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, h));
|
||||
// Clip to the plot area.
|
||||
pt(&mut c, clip_pt.min);
|
||||
c.push(' ');
|
||||
num(&mut c, clip_pt.width());
|
||||
c.push(' ');
|
||||
num(&mut c, clip_pt.height());
|
||||
c.push_str(" re W n\n");
|
||||
let mut stroke: Option<Rgb> = None;
|
||||
let mut fill: Option<Rgb> = None;
|
||||
let mut width: Option<f64> = None;
|
||||
let mut pending = false;
|
||||
let flush = |c: &mut String, pending: &mut bool| {
|
||||
if *pending {
|
||||
c.push_str("S\n");
|
||||
*pending = false;
|
||||
}
|
||||
};
|
||||
// The PDF layer (optional content) the marks are in: `/OC /L<i> BDC` … `EMC`.
|
||||
let mut layer: Option<u32> = None;
|
||||
// Inside a text object (`BT` … `ET`), with this font set.
|
||||
let mut text_font: Option<Option<usize>> = None;
|
||||
// The transparency (percent) marks are drawn with, and those used on the page.
|
||||
let mut alpha: u8 = 0;
|
||||
let mut alphas: Vec<u8> = Vec::new();
|
||||
for (i, p) in list.prims.iter().enumerate() {
|
||||
let want = list.prim_layers.get(i).copied().filter(|l| marked.contains(l));
|
||||
let text = u32::try_from(i).ok().and_then(|i| texts.get(&i));
|
||||
if text_font.is_some() && (text.is_none() || want != layer) {
|
||||
c.push_str("ET\n");
|
||||
text_font = None;
|
||||
}
|
||||
let mut drawn = Ok(());
|
||||
if let Some(clip) = rect_path(p.clip_pt.min, p.clip_pt.max) {
|
||||
surface.push_clip_path(&clip, &FillRule::NonZero);
|
||||
drawn = marks(&mut surface, p, groups, fonts);
|
||||
surface.pop();
|
||||
}
|
||||
surface.pop();
|
||||
surface.finish();
|
||||
page.finish();
|
||||
drawn
|
||||
}
|
||||
|
||||
/// What consecutive strokes share to be painted as one path: colour, width (points) and
|
||||
/// transparency (percent).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct StrokeStyle {
|
||||
rgb: Rgb,
|
||||
width: f32,
|
||||
transparency: u8,
|
||||
}
|
||||
|
||||
/// Opacity of a transparency in percent.
|
||||
fn opacity(t: u8) -> NormalizedF32 {
|
||||
NormalizedF32::new(f32::from(100 - t.min(cadcraft_render::MAX_TRANSPARENCY)) / 100.0).unwrap_or(NormalizedF32::ONE)
|
||||
}
|
||||
|
||||
fn paint(c: Rgb) -> kr::paint::Paint {
|
||||
kr::color::rgb::Color::new(c.0, c.1, c.2).into()
|
||||
}
|
||||
|
||||
fn fill_path(s: &mut Surface<'_>, path: &Path, rgb: Rgb, t: u8) {
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(t), rule: FillRule::NonZero }));
|
||||
s.draw_path(path);
|
||||
}
|
||||
|
||||
/// Paint the strokes collected so far as one path.
|
||||
fn flush(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>) {
|
||||
let Some((style, pb)) = batch.take() else { return };
|
||||
let Some(path) = pb.finish() else { return };
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke {
|
||||
paint: paint(style.rgb),
|
||||
width: style.width,
|
||||
line_cap: LineCap::Round,
|
||||
line_join: LineJoin::Round,
|
||||
opacity: opacity(style.transparency),
|
||||
..Stroke::default()
|
||||
}));
|
||||
s.draw_path(&path);
|
||||
}
|
||||
|
||||
/// The PDF layer of the following marks changed from `from` to `to` (`None`: no layer): end
|
||||
/// the old one's marked content and start the new one's.
|
||||
fn layer_changed(s: &mut Surface<'_>, from: Option<OcgId>, to: Option<OcgId>) -> std::result::Result<(), Failure> {
|
||||
if from.is_some() {
|
||||
s.end_optional_content().map_err(krilla_failure)?;
|
||||
}
|
||||
if let Some(id) = to {
|
||||
s.start_optional_content(id).map_err(krilla_failure)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn krilla_failure(e: KrillaError) -> Failure {
|
||||
Failure::Io(IoError::Format(format!("PDF export failed: {e}")))
|
||||
}
|
||||
|
||||
/// The page's marks, clipped to the plot area by the caller. Those of primitives on a layer
|
||||
/// with a PDF layer in `groups` are marked as its optional content.
|
||||
///
|
||||
/// Consecutive strokes with the same colour, width and transparency are one path; a
|
||||
/// transparent primitive is stroked on its own (one path is painted once), so where
|
||||
/// primitives overlap they build up, as in PNG plots and on screen. Block contents and
|
||||
/// viewport contents share their reference's handle, so this goes by primitive, not by object.
|
||||
/// Each filled primitive is one path of its triangles.
|
||||
///
|
||||
/// Primitives that stand for a glyph in an embeddable font (see [`fonts::Fonts`]) are written
|
||||
/// as text instead: the glyph at font size 1 under its glyph matrix (the matrix axes are one
|
||||
/// em).
|
||||
fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> {
|
||||
let (list, map) = (&p.list, &p.map);
|
||||
let texts = fonts::texts(list);
|
||||
let white = Rgb(255, 255, 255);
|
||||
let mut batch: Option<(StrokeStyle, PathBuilder)> = None;
|
||||
let mut layer: Option<OcgId> = None;
|
||||
for (i, prim) in list.prims.iter().enumerate() {
|
||||
let want = list.prim_layers.get(i).and_then(|l| groups.ids.get(l)).copied();
|
||||
if want != layer {
|
||||
flush(&mut c, &mut pending);
|
||||
if layer.is_some() {
|
||||
c.push_str("EMC\n");
|
||||
}
|
||||
if let Some(l) = want {
|
||||
let _ = writeln!(c, "/OC /L{l} BDC");
|
||||
}
|
||||
flush(s, &mut batch);
|
||||
layer_changed(s, layer, want)?;
|
||||
layer = want;
|
||||
}
|
||||
let rgb = p.display_rgb(white);
|
||||
let raw = list.points(p);
|
||||
let rgb = prim.display_rgb(white);
|
||||
let raw = list.points(prim);
|
||||
if raw.iter().any(|q| !q.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
// A wipeout's mask always hides what is under it.
|
||||
let t = if p.kind == Kind::Mask { 0 } else { p.transparency.min(cadcraft_render::MAX_TRANSPARENCY) };
|
||||
if t != alpha {
|
||||
flush(&mut c, &mut pending);
|
||||
let _ = writeln!(c, "/T{t} gs");
|
||||
if !alphas.contains(&t) {
|
||||
alphas.push(t);
|
||||
}
|
||||
alpha = t;
|
||||
}
|
||||
let w = f64::from(p.lw) * PT_PER_MM;
|
||||
let t = if prim.kind == Kind::Mask { 0 } else { prim.transparency.min(cadcraft_render::MAX_TRANSPARENCY) };
|
||||
let w = f64::from(prim.lw) * PT_PER_MM;
|
||||
let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 };
|
||||
match p.kind {
|
||||
match prim.kind {
|
||||
Kind::Polyline | Kind::Infinite { .. } => {
|
||||
let pts: Vec<Vec2> = match p.kind {
|
||||
let pts: Vec<Vec2> = match prim.kind {
|
||||
Kind::Infinite { ray } => match (raw.first(), raw.get(1)) {
|
||||
(Some(base), Some(dir)) => {
|
||||
let a = map.pt(*base);
|
||||
let dir_pt = map.pt(*base + *dir) - a;
|
||||
match clip::clip_infinite(a, dir_pt, ray, clip_pt) {
|
||||
match clip::clip_infinite(a, dir_pt, ray, &p.clip_pt) {
|
||||
Some((x, y)) => vec![x, y],
|
||||
None => continue,
|
||||
}
|
||||
@@ -686,301 +848,117 @@ fn content_stream(
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
// A transparent primitive is stroked on its own (one path is painted once), so
|
||||
// where primitives overlap they build up, as in PNG plots and on screen. Block
|
||||
// contents and viewport contents share their reference's handle, so this goes
|
||||
// by primitive, not by object.
|
||||
if t > 0 {
|
||||
flush(&mut c, &mut pending);
|
||||
// With a vertex out of range, keep the pieces inside the plot area, widened so
|
||||
// a wide stroke just outside it still shows its edge.
|
||||
let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(w + 1.0)) };
|
||||
let style = StrokeStyle { rgb, width: w as f32, transparency: t };
|
||||
if t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) {
|
||||
flush(s, &mut batch);
|
||||
}
|
||||
if stroke != Some(rgb) || width != Some(w) {
|
||||
flush(&mut c, &mut pending);
|
||||
if stroke != Some(rgb) {
|
||||
color(&mut c, rgb, "RG");
|
||||
stroke = Some(rgb);
|
||||
for run in runs {
|
||||
let Some(pts) = run.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { continue };
|
||||
// The writer leaves out a path without extent, so a zero-length line is
|
||||
// painted as the round-cap dot its stroke would make.
|
||||
if let Some(&(x, y)) = pts.first()
|
||||
&& pts.iter().all(|q| *q == (x, y))
|
||||
{
|
||||
flush(s, &mut batch);
|
||||
let d = if w > 0.0 { w as f32 } else { HAIRLINE_DOT_PT };
|
||||
if let Some(path) = circle_path(x, y, d / 2.0) {
|
||||
fill_path(s, &path, rgb, t);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if width != Some(w) {
|
||||
num(&mut c, w);
|
||||
c.push_str(" w\n");
|
||||
width = Some(w);
|
||||
let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new()));
|
||||
for (j, (x, y)) in pts.iter().enumerate() {
|
||||
if j == 0 {
|
||||
pb.move_to(*x, *y);
|
||||
} else {
|
||||
pb.line_to(*x, *y);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
pt(&mut c, *q);
|
||||
c.push_str(if i == 0 { " m\n" } else { " l\n" });
|
||||
}
|
||||
pending = true;
|
||||
}
|
||||
Kind::Tris | Kind::Mask => {
|
||||
flush(&mut c, &mut pending);
|
||||
flush(s, &mut batch);
|
||||
// A wipeout's mask is paper white.
|
||||
let rgb = if p.kind == Kind::Mask { white } else { rgb };
|
||||
if fill != Some(rgb) {
|
||||
color(&mut c, rgb, "rg");
|
||||
fill = Some(rgb);
|
||||
}
|
||||
if let Some((font, t)) = text {
|
||||
glyph(&mut c, &mut text_font, *font, t, map);
|
||||
let rgb = if prim.kind == Kind::Mask { white } else { rgb };
|
||||
if let Some(text) = u32::try_from(i).ok().and_then(|i| texts.get(&i))
|
||||
&& let Some(font) = fonts.font(&text.glyph.face)
|
||||
{
|
||||
glyph(s, font, text, map, rgb, t);
|
||||
continue;
|
||||
}
|
||||
let mut any = false;
|
||||
for t in raw.as_chunks::<3>().0 {
|
||||
for (i, q) in t.iter().enumerate() {
|
||||
pt(&mut c, map.pt(*q));
|
||||
c.push_str(if i == 0 { " m\n" } else { " l\n" });
|
||||
let mapped: Vec<Vec2> = raw.iter().map(|q| map.pt(*q)).collect();
|
||||
// With a vertex out of range, keep the parts inside the plot area.
|
||||
let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) };
|
||||
let mut pb = PathBuilder::new();
|
||||
for tri in tris.as_chunks::<3>().0 {
|
||||
let Some(v) = tri.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { continue };
|
||||
for (j, (x, y)) in v.iter().enumerate() {
|
||||
if j == 0 {
|
||||
pb.move_to(*x, *y);
|
||||
} else {
|
||||
pb.line_to(*x, *y);
|
||||
}
|
||||
}
|
||||
c.push_str("h\n");
|
||||
any = true;
|
||||
pb.close();
|
||||
}
|
||||
if any {
|
||||
c.push_str("f\n");
|
||||
if let Some(path) = pb.finish() {
|
||||
fill_path(s, &path, rgb, t);
|
||||
}
|
||||
}
|
||||
Kind::Point => {
|
||||
let Some(q) = raw.first() else { continue };
|
||||
flush(&mut c, &mut pending);
|
||||
if fill != Some(rgb) {
|
||||
color(&mut c, rgb, "rg");
|
||||
fill = Some(rgb);
|
||||
}
|
||||
flush(s, &mut batch);
|
||||
let half = (w.max(0.5)) / 2.0;
|
||||
pt(&mut c, map.pt(*q) - Vec2::new(half, half));
|
||||
c.push(' ');
|
||||
num(&mut c, half * 2.0);
|
||||
c.push(' ');
|
||||
num(&mut c, half * 2.0);
|
||||
c.push_str(" re f\n");
|
||||
let c = map.pt(*q);
|
||||
if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) {
|
||||
fill_path(s, &path, rgb, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut c, &mut pending);
|
||||
if text_font.is_some() {
|
||||
c.push_str("ET\n");
|
||||
}
|
||||
if layer.is_some() {
|
||||
c.push_str("EMC\n");
|
||||
}
|
||||
c.push_str("Q\n");
|
||||
(c, alphas)
|
||||
flush(s, &mut batch);
|
||||
layer_changed(s, layer, None)
|
||||
}
|
||||
|
||||
/// The page resources' graphics states for transparencies `alphas` (percent): `/T<percent>`
|
||||
/// sets the stroke and fill alpha (`/CA`, `/ca`). Empty when there are none.
|
||||
fn ext_gstates(alphas: &[u8]) -> String {
|
||||
if alphas.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::from(" /ExtGState <<");
|
||||
for t in alphas {
|
||||
let mut a = String::new();
|
||||
num(&mut a, f64::from(100 - u32::from((*t).min(cadcraft_render::MAX_TRANSPARENCY))) / 100.0);
|
||||
let _ = write!(out, " /T{t} << /Type /ExtGState /CA {a} /ca {a} >>");
|
||||
}
|
||||
out.push_str(" >>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Write one glyph as text: opens a text object and sets the font when needed, then places the
|
||||
/// glyph with a text matrix (font size 1: the matrix axes are one em).
|
||||
fn glyph(c: &mut String, text_font: &mut Option<Option<usize>>, font: usize, t: &cadcraft_render::PlotText, map: &Map) {
|
||||
if text_font.is_none() {
|
||||
c.push_str("BT\n");
|
||||
*text_font = Some(None);
|
||||
}
|
||||
if *text_font != Some(Some(font)) {
|
||||
let _ = writeln!(c, "/F{font} 1 Tf");
|
||||
*text_font = Some(Some(font));
|
||||
}
|
||||
let s = map.s * map.k;
|
||||
/// Write one glyph as text in its embedded font: font size 1, placed by the glyph's matrix (one
|
||||
/// em along each axis). A glyph whose matrix is out of range or collapses is left out.
|
||||
fn glyph(s: &mut Surface<'_>, font: kr::text::Font, t: &cadcraft_render::PlotText, map: &Map, rgb: Rgb, transparency: u8) {
|
||||
let k = map.s * map.k;
|
||||
let g = &t.glyph;
|
||||
for v in [g.x.x * s, g.x.y * s, g.y.x * s, g.y.y * s] {
|
||||
num(c, v);
|
||||
c.push(' ');
|
||||
let (Some((a, b)), Some((c, d)), Some((e, f))) = (coord(g.x * k), coord(g.y * k), coord(map.pt(g.origin))) else { return };
|
||||
let det = a * d - b * c;
|
||||
if !det.is_finite() || det == 0.0 {
|
||||
return;
|
||||
}
|
||||
pt(c, map.pt(g.origin));
|
||||
let _ = writeln!(c, " Tm <{:04X}> Tj", g.gid);
|
||||
let text = g.ch.to_string();
|
||||
let glyphs = [KrillaGlyph::new(GlyphId::new(u32::from(g.gid)), 0.0, 0.0, 0.0, 0.0, 0..text.len(), None)];
|
||||
// The writer sets glyphs upright on a y-down surface: flip the em's y axis back.
|
||||
s.push_transform(&Transform::from_row(a, b, -c, -d, e, f));
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(transparency), rule: FillRule::NonZero }));
|
||||
s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &text, 1.0, false);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// A PDF text string: a literal with `(`, `)` and `\` escaped when it is printable ASCII,
|
||||
/// else UTF-16BE with a byte order mark (hex). At most 1000 characters.
|
||||
fn pdf_string(s: &str) -> String {
|
||||
let s: String = s.chars().take(1000).collect();
|
||||
if s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
|
||||
let mut out = String::from("(");
|
||||
for ch in s.chars() {
|
||||
if matches!(ch, '(' | ')' | '\\') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.push(')');
|
||||
return out;
|
||||
}
|
||||
let mut out = String::from("<FEFF");
|
||||
for u in s.encode_utf16() {
|
||||
let _ = write!(out, "{u:04X}");
|
||||
}
|
||||
out.push('>');
|
||||
out
|
||||
}
|
||||
|
||||
/// A link target as a PDF URI string: 7-bit ASCII (other bytes percent-encoded), `(`, `)` and
|
||||
/// `\` escaped. `None` for script and data URLs, which a plot never carries.
|
||||
fn uri_string(url: &str) -> Option<String> {
|
||||
/// A link target as a URI: 7-bit printable ASCII, other bytes percent-encoded. `None` for
|
||||
/// script and data URLs, which a plot never carries.
|
||||
fn uri(url: &str) -> Option<String> {
|
||||
let url = url.trim();
|
||||
let scheme = url.split(':').next().unwrap_or("").to_ascii_lowercase();
|
||||
if url.is_empty() || url.len() > MAX_URL || matches!(scheme.as_str(), "javascript" | "vbscript" | "data") {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::from("(");
|
||||
let mut out = String::with_capacity(url.len());
|
||||
for b in url.bytes() {
|
||||
match b {
|
||||
b'(' | b')' | b'\\' => {
|
||||
out.push('\\');
|
||||
out.push(char::from(b));
|
||||
}
|
||||
0x21..=0x7E => out.push(char::from(b)),
|
||||
_ => {
|
||||
let _ = write!(out, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(')');
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A stream object body, Flate-compressed when asked.
|
||||
fn stream(data: &[u8], compress: bool, extra: &str) -> Vec<u8> {
|
||||
let (data, filter) = if compress { (miniz_oxide::deflate::compress_to_vec_zlib(data, 6), " /Filter /FlateDecode") } else { (data.to_vec(), "") };
|
||||
let mut out = format!("<< /Length {}{filter}{extra} >>\nstream\n", data.len()).into_bytes();
|
||||
out.extend_from_slice(&data);
|
||||
out.extend_from_slice(b"\nendstream");
|
||||
out
|
||||
}
|
||||
|
||||
/// PDF objects being written: bodies by object number - 1.
|
||||
#[derive(Default)]
|
||||
struct Objects {
|
||||
bodies: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Objects {
|
||||
/// Reserve an object number (its body is set later).
|
||||
fn reserve(&mut self) -> usize {
|
||||
self.bodies.push(Vec::new());
|
||||
self.bodies.len()
|
||||
}
|
||||
fn set(&mut self, n: usize, body: impl Into<Vec<u8>>) {
|
||||
if let Some(b) = n.checked_sub(1).and_then(|i| self.bodies.get_mut(i)) {
|
||||
*b = body.into();
|
||||
}
|
||||
}
|
||||
fn add(&mut self, body: impl Into<Vec<u8>>) -> usize {
|
||||
let n = self.reserve();
|
||||
self.set(n, body);
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// `n 0 R` references, space-separated.
|
||||
fn refs(ids: impl IntoIterator<Item = usize>) -> String {
|
||||
ids.into_iter().map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Assemble the file: catalog, page tree, pages with their content streams and link
|
||||
/// annotations, the PDF layers (one optional content group per drawing layer used, named
|
||||
/// like it), page labels, document information, cross-reference table and trailer.
|
||||
fn assemble(d: &Drawing, pages: &[SheetPage], fonts: &fonts::Fonts, o: &PdfOptions, title: &str) -> Vec<u8> {
|
||||
let mut objs = Objects::default();
|
||||
let catalog = objs.reserve();
|
||||
let tree = objs.reserve();
|
||||
// Optional content groups shared by all pages, by layer index: (layer, object).
|
||||
let mut ocg_ids: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
|
||||
let mut ocgs: Vec<(u32, usize)> = Vec::new();
|
||||
let mut kids = Vec::new();
|
||||
// Page dictionaries are completed once the fonts are written: (page, its fonts, the rest).
|
||||
let mut page_dicts: Vec<(usize, Vec<usize>, String, String, String)> = Vec::new();
|
||||
for p in pages {
|
||||
let page = objs.reserve();
|
||||
let texts = fonts.texts(&p.list);
|
||||
let (marks, alphas) = content_stream(&p.list, &p.map, &p.clip_pt, &p.layers, &texts);
|
||||
let content = objs.add(stream(marks.as_bytes(), o.compress, ""));
|
||||
let mut used: Vec<usize> = texts.values().map(|(f, _)| *f).collect();
|
||||
used.sort_unstable();
|
||||
used.dedup();
|
||||
let mut props = String::new();
|
||||
for l in &p.layers {
|
||||
let Some(layer) = d.layers.get(*l as usize) else { continue };
|
||||
let id = match ocg_ids.get(l) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
let id = objs.add(format!("<< /Type /OCG /Name {} >>", pdf_string(&layer.name)));
|
||||
ocg_ids.insert(*l, id);
|
||||
ocgs.push((*l, id));
|
||||
id
|
||||
}
|
||||
};
|
||||
let _ = write!(props, " /L{l} {id} 0 R");
|
||||
}
|
||||
let mut annots = Vec::new();
|
||||
for (r, url) in &p.links {
|
||||
let Some(uri) = uri_string(url) else { continue };
|
||||
let mut rect = String::new();
|
||||
pt(&mut rect, r.min);
|
||||
rect.push(' ');
|
||||
pt(&mut rect, r.max);
|
||||
annots.push(objs.add(format!("<< /Type /Annot /Subtype /Link /Rect [{rect}] /Border [0 0 0] /A << /S /URI /URI {uri} >> >>")));
|
||||
}
|
||||
let mut mb = String::new();
|
||||
pt(&mut mb, p.media);
|
||||
let annots = if annots.is_empty() { String::new() } else { format!(" /Annots [{}]", refs(annots)) };
|
||||
page_dicts.push((page, used, props, ext_gstates(&alphas), format!("/MediaBox [0 0 {mb}] /Contents {content} 0 R{annots}")));
|
||||
kids.push(page);
|
||||
}
|
||||
let font_ids = if fonts.is_empty() { Vec::new() } else { fonts.write(&mut objs, o.compress) };
|
||||
for (page, used, props, gstates, rest) in page_dicts {
|
||||
let mut res = String::new();
|
||||
if !props.is_empty() {
|
||||
let _ = write!(res, " /Properties <<{props} >>");
|
||||
}
|
||||
res.push_str(&gstates);
|
||||
let f: Vec<String> = used.iter().filter_map(|i| Some(format!("/F{i} {} 0 R", font_ids.get(*i)?))).collect();
|
||||
if !f.is_empty() {
|
||||
let _ = write!(res, " /Font << {} >>", f.join(" "));
|
||||
}
|
||||
objs.set(page, format!("<< /Type /Page /Parent {tree} 0 R /Resources <<{res} >> {rest} >>"));
|
||||
}
|
||||
objs.set(tree, format!("<< /Type /Pages /Kids [{}] /Count {} >>", refs(kids.iter().copied()), kids.len()));
|
||||
let mut cat = format!("<< /Type /Catalog /Pages {tree} 0 R");
|
||||
if !ocgs.is_empty() {
|
||||
// Optional content needs PDF 1.5; the catalog's version overrides the header's.
|
||||
let off: Vec<usize> = ocgs.iter().filter(|(l, _)| d.layers.get(*l as usize).is_some_and(|l| !l.on)).map(|(_, id)| *id).collect();
|
||||
let all = refs(ocgs.iter().map(|(_, id)| *id));
|
||||
let _ = write!(cat, " /Version /1.5 /OCProperties << /OCGs [{all}] /D << /Name (Layers) /Order [{all}] /OFF [{}] >> >>", refs(off));
|
||||
if pages.len() == 1 {
|
||||
cat.push_str(" /PageMode /UseOC");
|
||||
}
|
||||
}
|
||||
let labels: Vec<String> = pages.iter().enumerate().map(|(i, p)| format!("{i} << /P {} >>", pdf_string(&p.label))).collect();
|
||||
let _ = write!(cat, " /PageLabels << /Nums [{}] >> >>", labels.join(" "));
|
||||
objs.set(catalog, cat);
|
||||
let info = objs.add(format!("<< /Producer (CADCraft) /Creator (CADCraft) /Title {} >>", pdf_string(title)));
|
||||
let size: usize = objs.bodies.iter().map(Vec::len).sum();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(size + 64 * objs.bodies.len() + 256);
|
||||
out.extend_from_slice(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
|
||||
let mut offsets = Vec::with_capacity(objs.bodies.len());
|
||||
for (i, body) in objs.bodies.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1);
|
||||
for o in &offsets {
|
||||
let _ = writeln!(x, "{o:010} 00000 n ");
|
||||
}
|
||||
let _ = write!(x, "trailer\n<< /Size {} /Root {catalog} 0 R /Info {info} 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1);
|
||||
out.extend_from_slice(x.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user