mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 19:36:49 -06:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f3946476 | ||
|
|
793de21358 | ||
|
|
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 | ||
|
|
d0e83aefa6 |
@@ -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
+701
-106
File diff suppressed because it is too large
Load Diff
+35
-13
@@ -7,7 +7,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
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,10 +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 = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
|
||||
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 = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false }
|
||||
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -53,19 +53,17 @@ encoding_rs = "0.8.35"
|
||||
quick-xml = "0.42"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", 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 }
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -88,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +327,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 +1119,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 +1148,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 +1168,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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -362,6 +362,8 @@ 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),
|
||||
|
||||
@@ -431,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,
|
||||
}?;
|
||||
@@ -439,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) {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -10,7 +10,8 @@ use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::qselect::wildcard;
|
||||
use super::super::*;
|
||||
use super::super::{CommandSpec, bad, point_param, str_param};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step, View};
|
||||
|
||||
/// Most names one Delete or list takes.
|
||||
|
||||
@@ -15,7 +15,8 @@ use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::qselect::wildcard;
|
||||
use super::super::*;
|
||||
use super::super::{CommandSpec, bad, find_command, str_param};
|
||||
|
||||
use crate::{Accept, DocState, EngineError, Input, Interactive, ModelTile, ModelTiles, Prompt, Result, Session, Step, View};
|
||||
|
||||
/// Most tiles on screen at once.
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
@@ -447,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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -86,8 +86,10 @@ const GRAY: Color = Color::Index(8);
|
||||
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");
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! runs a few thousand cheap cases; the known-answer part of the suite works at 1e6–1e7
|
||||
//! coordinates, where double precision starts to show (geometry-parity.md §Tests).
|
||||
|
||||
use crate::*;
|
||||
use crate::{
|
||||
Arc, Bounds2, Circle, Line, Segment, Vec2, circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite,
|
||||
};
|
||||
|
||||
// ---------- generator ----------
|
||||
|
||||
|
||||
+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:?}");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
_ => {}
|
||||
@@ -1984,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;
|
||||
@@ -2022,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);
|
||||
@@ -2052,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);
|
||||
}
|
||||
|
||||
@@ -2139,6 +2139,30 @@ fn hatch_edges_keep_full_circles_splines_and_clockwise_ellipses() {
|
||||
near(hatch_edge_extents(&ellipse(1)), [-5.0, 0.0, 5.0, 2.5]);
|
||||
}
|
||||
|
||||
/// Issue #923: a HATCH spline edge caps its degree at 10 like the SPLINE reader does, both when the
|
||||
/// knots are valid for the stated degree and when the reader falls back to the control polygon.
|
||||
#[test]
|
||||
fn hatch_spline_edge_caps_degree_like_spline() {
|
||||
let control: Vec<Vec2> = (0..13).map(|i| Vec2::new(f64::from(i), f64::from(i % 2) * 10.0)).collect();
|
||||
let points: String = control.iter().map(|c| format!("10\n{}\n20\n{}\n", c.x, c.y)).collect();
|
||||
let close = "72\n1\n10\n12\n20\n0\n11\n0\n21\n0\n";
|
||||
// The degree-10 curve over the same controls, which is what the capped edge must produce.
|
||||
let want = cadcraft_geom::Spline::from_control(control, 10).tessellate(1e-3);
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
|
||||
for p in &want {
|
||||
(lo, hi) = (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)));
|
||||
}
|
||||
// Degree 12 with 13 controls: a valid knot vector has 26 knots; the second edge gives none.
|
||||
let knots = format!("{}{}", "40\n0\n".repeat(13), "40\n1\n".repeat(13));
|
||||
for (nk, knots) in [(26, knots.as_str()), (0, "")] {
|
||||
let edge = format!("72\n4\n94\n12\n73\n0\n74\n0\n95\n{nk}\n96\n13\n{knots}{points}97\n0\n{close}");
|
||||
let b = hatch_edge_extents(&edge);
|
||||
for (got, want) in [(b.min.x, lo.x), (b.min.y, lo.y), (b.max.x, hi.x), (b.max.y, hi.y)] {
|
||||
assert!((got - want).abs() < 1e-6, "extents {b:?}, want {lo:?} {hi:?} (knots {nk})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirrored_text_extrusion_keeps_its_footprint() {
|
||||
// TEXT at OCS (-10,5) under extrusion (0,0,-1) starts at WCS (10,5) and runs towards -x (with
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The drawing's WIPEOUTVARIABLES object sets WIPEOUTFRAME: AutoCAD files with hidden wipeout
|
||||
//! frames (group 70 = 0) must not show them.
|
||||
|
||||
fn dxf(frame: Option<i32>) -> String {
|
||||
let mut s = String::from(" 0\nSECTION\n 2\nENTITIES\n 0\nENDSEC\n 0\nSECTION\n 2\nOBJECTS\n");
|
||||
if let Some(f) = frame {
|
||||
s.push_str(&format!(" 0\nWIPEOUTVARIABLES\n 5\nA1\n100\nAcDbWipeoutVariables\n 70\n{f}\n"));
|
||||
}
|
||||
s.push_str(" 0\nENDSEC\n 0\nEOF\n");
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wipeoutvariables_sets_the_frame_flag() {
|
||||
let frame = |f| cadcraft_io::read_dxf(dxf(f).as_bytes()).unwrap().header.i64("WIPEOUTFRAME", 1);
|
||||
assert_eq!(frame(Some(0)), 0, "frames hidden");
|
||||
assert_eq!(frame(Some(1)), 1);
|
||||
assert_eq!(frame(Some(2)), 2);
|
||||
assert_eq!(frame(None), 1, "no object: the default shows frames");
|
||||
assert_eq!(frame(Some(7)), 1, "an out-of-range flag is ignored");
|
||||
}
|
||||
+120
-17
@@ -184,17 +184,20 @@ fn is_read_only(method: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
/// Send a control request with the token when available, refreshing it before a safe retry.
|
||||
/// A mutation that may have reached the server is never resent automatically.
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
impl Remote {
|
||||
/// Put the current token (if any) on `req`, replacing the one it carries.
|
||||
fn set_token(&mut self, req: &mut Value) {
|
||||
let token = self.token().map(str::to_string);
|
||||
let mut req = json!({"id": id, "method": method, "params": params});
|
||||
if let (Some(obj), Some(token)) = (req.as_object_mut(), token) {
|
||||
obj.insert("token".into(), Value::String(token));
|
||||
if let Some(obj) = req.as_object_mut() {
|
||||
obj.remove("token");
|
||||
if let Some(token) = token {
|
||||
obj.insert("token".into(), Value::String(token));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Send `req` and parse the reply, refreshing the token before a safe retry after a transport
|
||||
/// error. A mutation that may have reached the server is never resent automatically.
|
||||
fn exchange(&mut self, method: &str, req: &mut Value) -> Result<Value, String> {
|
||||
let reply = match self.roundtrip(&req.to_string()) {
|
||||
Ok(r) => r,
|
||||
Err((e, sent)) => {
|
||||
@@ -208,12 +211,7 @@ impl Backend for Remote {
|
||||
));
|
||||
}
|
||||
// A restarted server may have rotated its token before dropping the old socket.
|
||||
if let Some(obj) = req.as_object_mut() {
|
||||
obj.remove("token");
|
||||
if let Some(token) = self.token() {
|
||||
obj.insert("token".into(), Value::String(token.to_owned()));
|
||||
}
|
||||
}
|
||||
self.set_token(req);
|
||||
self.roundtrip(&req.to_string()).map_err(|(e, _)| {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
@@ -221,12 +219,37 @@ impl Backend for Remote {
|
||||
})?
|
||||
}
|
||||
};
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))?;
|
||||
serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// An `unauthorized` reply (the app also closes the connection).
|
||||
fn unauthorized(v: &Value) -> bool {
|
||||
v.get("error").and_then(Value::as_str).is_some_and(|e| e.starts_with("unauthorized"))
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
/// Send a control request with the token when available. A refused token is read again and the
|
||||
/// request resent once, only when the app says it did not run it.
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let mut req = json!({"id": id, "method": method, "params": params});
|
||||
self.set_token(&mut req);
|
||||
let mut v = self.exchange(method, &mut req)?;
|
||||
// The token is read before `--launch` opens the app, which then writes a new one (#2087); the
|
||||
// file can also be left over from an earlier session. Once, so a wrong token can't loop.
|
||||
if unauthorized(&v) && v.get("state").and_then(Value::as_str) == Some("not-run") {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
self.set_token(&mut req);
|
||||
v = self.exchange(method, &mut req)?;
|
||||
}
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
let e = v.get("error").and_then(Value::as_str).unwrap_or("error");
|
||||
if e.starts_with("unauthorized") {
|
||||
if unauthorized(&v) {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
}
|
||||
@@ -405,6 +428,7 @@ impl Backend for Headless {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A server that reads one request per connection and closes without replying must not see the request twice.
|
||||
#[test]
|
||||
@@ -479,6 +503,85 @@ mod tests {
|
||||
assert_eq!(remote.call("ping", json!({})).unwrap(), json!("pong"));
|
||||
server.join().unwrap();
|
||||
}
|
||||
/// Answer one request per connection like the app's control server: `reply` sees the request
|
||||
/// and the connection number and says what to send back; the server closes after each reply.
|
||||
/// Returns how many requests arrived, waiting a short while for each next connection.
|
||||
fn fake_app(listener: TcpListener, reply: impl Fn(&Value, usize) -> Value + Send + 'static) -> std::thread::JoinHandle<usize> {
|
||||
std::thread::spawn(move || {
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let mut seen = 0;
|
||||
loop {
|
||||
let start = std::time::Instant::now();
|
||||
let conn = loop {
|
||||
match listener.accept() {
|
||||
Ok((conn, _)) => break Some(conn),
|
||||
Err(_) if start.elapsed() < Duration::from_millis(500) => std::thread::sleep(Duration::from_millis(10)),
|
||||
Err(_) => break None,
|
||||
}
|
||||
};
|
||||
let Some(conn) = conn else { return seen };
|
||||
conn.set_nonblocking(false).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
let mut out = conn;
|
||||
writeln!(out, "{}", reply(&serde_json::from_str(&line).unwrap(), seen)).unwrap();
|
||||
seen += 1;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `--launch` reads the token before it opens the app, so the first request carries the token
|
||||
/// of an earlier session (or none); the app refuses it without running it, and the call is
|
||||
/// resent once with the token the app has just written (#2087).
|
||||
#[test]
|
||||
fn unauthorized_not_run_is_resent_once_with_the_new_token() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let old = crate::control_token::issue(addr.port()).unwrap();
|
||||
let fresh = Arc::new(std::sync::Mutex::new(String::new()));
|
||||
let f = fresh.clone();
|
||||
let server = fake_app(listener, move |req, n| {
|
||||
if n == 0 {
|
||||
assert_eq!(req["token"].as_str(), Some(old.as_str()));
|
||||
// The app starts and writes its own session's token.
|
||||
*f.lock().unwrap() = crate::control_token::issue(addr.port()).unwrap();
|
||||
json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"})
|
||||
} else if req["token"].as_str() == Some(f.lock().unwrap().as_str()) {
|
||||
json!({"ok": true, "result": "drawing"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"})
|
||||
}
|
||||
});
|
||||
let mut remote = Remote::lazy(&addr.to_string(), false);
|
||||
let first = remote.call("engine.execute", json!({"command": "circle", "params": {}}));
|
||||
let requests = server.join().unwrap();
|
||||
crate::control_token::revoke(addr.port());
|
||||
assert_eq!(first, Ok(json!("drawing")));
|
||||
assert_eq!(requests, 2);
|
||||
assert_eq!(remote.token.as_deref(), Some(fresh.lock().unwrap().as_str()));
|
||||
}
|
||||
|
||||
/// A token the app keeps refusing is resent once, not in a loop, and a refusal that doesn't
|
||||
/// say the request never ran is not resent at all.
|
||||
#[test]
|
||||
fn unauthorized_is_resent_at_most_once_and_only_when_not_run() {
|
||||
for (state, connections) in [(Some("not-run"), 2), (Some("may-have-run"), 1), (None, 1)] {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let server = fake_app(listener, move |_, _| {
|
||||
let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token"});
|
||||
if let (Some(o), Some(state)) = (r.as_object_mut(), state) {
|
||||
o.insert("state".into(), json!(state));
|
||||
}
|
||||
r
|
||||
});
|
||||
let mut remote = Remote::lazy(&addr, false);
|
||||
let err = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err();
|
||||
assert!(err.starts_with("unauthorized"), "{err}");
|
||||
assert_eq!(server.join().unwrap(), connections, "{state:?}");
|
||||
assert!(remote.token.is_none() && remote.conn.is_none());
|
||||
}
|
||||
}
|
||||
/// A failed mutation write reports uncertain delivery and does not open a retry connection.
|
||||
#[test]
|
||||
fn failed_mutation_write_is_not_resent() {
|
||||
|
||||
@@ -25,8 +25,14 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# The workspace `egui` dependency enables `default_fonts` and `monochrome_emoji_fonts` in
|
||||
# production too; the tests exercise the same installed chain as the app.
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
# Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses).
|
||||
naga = { version = "30", features = ["wgsl-in", "glsl-out"] }
|
||||
|
||||
@@ -143,6 +143,7 @@ pub fn apply(app: &mut CadApp) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Command Aliases window that edits the in-memory alias set in `app.alias_dlg`; OK/Apply send the differences to ALIASEDIT, read the resulting aliases and source file back, and show any error.
|
||||
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
if app.alias_dlg.is_none() {
|
||||
crate::aliasdlg::open(app);
|
||||
@@ -151,7 +152,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let warn = Tokens::get().warn;
|
||||
let (mut ok, mut cancel, mut apply_clicked) = (false, false, false);
|
||||
let Some(st) = app.alias_dlg.as_mut() else { return };
|
||||
let id = egui::Id::new("aliasedit_dialog");
|
||||
let id = egui::Id::unique("aliasedit_dialog");
|
||||
if std::mem::take(&mut st.raise) {
|
||||
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
|
||||
}
|
||||
@@ -161,8 +162,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
None => ui.label(crate::tl!("No alias file loaded. REINIT replaces these aliases with a file's.")),
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Filter"));
|
||||
ui.text_edit_singleline(&mut st.filter);
|
||||
let label = ui.label(crate::tl!("Filter"));
|
||||
ui.text_edit_singleline(&mut st.filter).labelled_by(label.id);
|
||||
});
|
||||
let f = st.filter.trim().to_ascii_lowercase();
|
||||
let mut names: Vec<&String> = st.user.keys().chain(built.keys().filter(|a| !st.user.contains_key(*a))).collect();
|
||||
@@ -226,11 +227,11 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
if let Some(form) = st.form.as_mut() {
|
||||
ui.group(|ui| {
|
||||
egui::Grid::new("alias_form").num_columns(2).show(ui, |ui| {
|
||||
ui.label(crate::tl!("Alias"));
|
||||
ui.text_edit_singleline(&mut form.alias);
|
||||
let alias_label = ui.label(crate::tl!("Alias"));
|
||||
ui.text_edit_singleline(&mut form.alias).labelled_by(alias_label.id);
|
||||
ui.end_row();
|
||||
ui.label(crate::tl!("Command"));
|
||||
ui.text_edit_singleline(&mut form.command);
|
||||
let command_label = ui.label(crate::tl!("Command"));
|
||||
ui.text_edit_singleline(&mut form.command).labelled_by(command_label.id);
|
||||
ui.end_row();
|
||||
});
|
||||
// A few commands starting with what was typed, to pick from.
|
||||
|
||||
@@ -75,7 +75,7 @@ struct Wheel {
|
||||
fn wheel(events: &[egui::Event], opts: &egui::InputOptions, page: f32) -> Wheel {
|
||||
let (mut scroll, mut zoom_points) = (egui::Vec2::ZERO, 0.0);
|
||||
for e in events {
|
||||
let egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers } = e else { continue };
|
||||
let egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers, .. } = e else { continue };
|
||||
let per = match unit {
|
||||
egui::MouseWheelUnit::Point => 1.0,
|
||||
egui::MouseWheelUnit::Line => opts.line_scroll_speed,
|
||||
@@ -142,7 +142,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn notch(unit: egui::MouseWheelUnit, delta: egui::Vec2, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers }
|
||||
egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -194,7 +194,13 @@ mod tests {
|
||||
assert_eq!(zoomed.scroll, egui::Vec2::ZERO);
|
||||
assert!((zoomed.zoom - (o.scroll_zoom_speed * o.line_scroll_speed).exp()).abs() < 1e-6 && zoomed.zoom > 1.0);
|
||||
// A touchpad's begin and end markers carry no scroll.
|
||||
let ended = egui::Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 9.0), phase: egui::TouchPhase::End, modifiers: none };
|
||||
let ended = egui::Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 9.0),
|
||||
phase: egui::TouchPhase::End,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
modifiers: none,
|
||||
};
|
||||
assert_eq!(wheel(&[ended], &o, 500.0), Wheel { scroll: egui::Vec2::ZERO, zoom: 1.0 });
|
||||
// Hostile deltas scroll and zoom nothing.
|
||||
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
|
||||
@@ -150,7 +150,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let mut open = None;
|
||||
let mut discard = None;
|
||||
let mut later = false;
|
||||
egui::Modal::new(egui::Id::new("cc_drawing_recovery")).show(ctx, |ui| {
|
||||
crate::modal::instant(ctx, egui::Id::unique("cc_drawing_recovery")).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
ui.label(RichText::new("Drawing Recovery").strong());
|
||||
ui.add_space(4.0);
|
||||
@@ -204,7 +204,7 @@ pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get();
|
||||
let mut dismiss = false;
|
||||
egui::Window::new("Graphics error")
|
||||
.id(egui::Id::new("cc_gpu_notice"))
|
||||
.id(egui::Id::unique("cc_gpu_notice"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0))
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn authoring_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool)
|
||||
.unwrap_or_else(|| crate::i18n::t("This drawing").to_string());
|
||||
let mut choice: Option<&str> = None;
|
||||
egui::Window::new(crate::tl!("Open in Block Editor"))
|
||||
.id(egui::Id::new("Open in Block Editor"))
|
||||
.id(egui::Id::unique("Open in Block Editor"))
|
||||
.open(open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
@@ -165,15 +165,15 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let description = existing.as_deref().and_then(|n| d.block(n)).map(|b| b.description.clone()).unwrap_or_default();
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
egui::Window::new(crate::tl!("Edit Block Definition"))
|
||||
.id(egui::Id::new("Edit Block Definition"))
|
||||
.id(egui::Id::unique("Edit Block Definition"))
|
||||
.open(open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.show(ctx, |ui| {
|
||||
ui.label(crate::tl!("Block to create or edit"));
|
||||
let edit = ui.add(egui::TextEdit::singleline(&mut app.bedit.name).desired_width(460.0));
|
||||
let name_label = ui.label(crate::tl!("Block to create or edit"));
|
||||
let edit = ui.add(egui::TextEdit::singleline(&mut app.bedit.name).desired_width(460.0)).labelled_by(name_label.id);
|
||||
if edit.changed() {
|
||||
app.bedit.error = None;
|
||||
}
|
||||
|
||||
@@ -692,6 +692,18 @@ fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 {
|
||||
u
|
||||
}
|
||||
|
||||
/// Index ranges of the grid lines inside `wb` for line spacing `minor`, or `None` when there would be
|
||||
/// 2000 lines or more along an axis. The span is computed with checked arithmetic: a huge view
|
||||
/// saturates the float-to-integer casts at the i64 limits, and subtracting those would overflow.
|
||||
fn grid_index_ranges(wb: &Bounds2, minor: f64) -> Option<((i64, i64), (i64, i64))> {
|
||||
let range = |lo: f64, hi: f64| {
|
||||
let a = (lo / minor).floor() as i64;
|
||||
let b = (hi / minor).ceil() as i64;
|
||||
b.checked_sub(a).filter(|n| *n < 2000).map(|_| (a, b))
|
||||
};
|
||||
Some((range(wb.min.x, wb.max.x)?, range(wb.min.y, wb.max.y)?))
|
||||
}
|
||||
|
||||
fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) {
|
||||
let t = Tokens::get();
|
||||
let s = app.session.drafting_settings();
|
||||
@@ -704,11 +716,7 @@ fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) {
|
||||
let minor = adaptive(unit, xf.scale, 8.0);
|
||||
let major = minor * major_n;
|
||||
let mut lines = Vec::new();
|
||||
let x0 = (wb.min.x / minor).floor() as i64;
|
||||
let x1 = (wb.max.x / minor).ceil() as i64;
|
||||
let y0 = (wb.min.y / minor).floor() as i64;
|
||||
let y1 = (wb.max.y / minor).ceil() as i64;
|
||||
if (x1 - x0) < 2000 && (y1 - y0) < 2000 {
|
||||
if let Some(((x0, x1), (y0, y1))) = grid_index_ranges(&wb, minor) {
|
||||
for i in x0..=x1 {
|
||||
let x = i as f64 * minor;
|
||||
let is_major = (x / major).round() * major - x == 0.0 || ((x / major) - (x / major).round()).abs() < 1e-6;
|
||||
@@ -1362,7 +1370,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
// draws the others.
|
||||
let tiles = tile_rects(app, full);
|
||||
let rect = tiles.iter().find(|(_, tile)| tile.active).map_or(full, |(r, _)| *r);
|
||||
let resp = ui.interact(full, ui.id().with("canvas"), Sense::click_and_drag());
|
||||
let resp = ui.interact(full, ui.scope_id().with("canvas"), Sense::click_and_drag());
|
||||
let painter = ui.painter_at(rect);
|
||||
app.canvas.rect = Some(rect);
|
||||
app.session.viewport_px = (f64::from(rect.width()).max(1.0), f64::from(rect.height()).max(1.0));
|
||||
@@ -2846,7 +2854,7 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
fn wheel(unit: egui::MouseWheelUnit, dy: f32, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }
|
||||
egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers }
|
||||
}
|
||||
|
||||
/// Wheel events convert to points as egui does; ⌘/Ctrl (egui's pinch zoom) and ⇧ (a sideways
|
||||
@@ -2995,3 +3003,16 @@ mod hatch_pick_tests {
|
||||
assert!(app.session.running.is_none(), "{:?}", app.session.running.as_ref().map(|r| r.machine.name()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod grid_range_tests {
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
|
||||
#[test]
|
||||
fn grid_index_ranges_survive_extreme_views() {
|
||||
let huge = Bounds2::new(Vec2::new(-1e300, -1e300), Vec2::new(1e300, 1e300));
|
||||
assert_eq!(super::grid_index_ranges(&huge, 1.0), None);
|
||||
let normal = Bounds2::new(Vec2::new(-5.0, 0.0), Vec2::new(5.0, 10.0));
|
||||
assert_eq!(super::grid_index_ranges(&normal, 1.0), Some(((-5, 5), (0, 10))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::CadApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
fn id() -> egui::Id {
|
||||
egui::Id::new("cadcraft-block-editor-bar")
|
||||
egui::Id::unique("cadcraft-block-editor-bar")
|
||||
}
|
||||
|
||||
/// The block the Block Editor is editing in the drawing shown, and whether it has unsaved changes.
|
||||
|
||||
@@ -26,7 +26,7 @@ struct Cache {
|
||||
}
|
||||
|
||||
fn id() -> egui::Id {
|
||||
egui::Id::new("cadcraft-compare-overlay")
|
||||
egui::Id::unique("cadcraft-compare-overlay")
|
||||
}
|
||||
|
||||
/// Draw the comparison over the canvas when it was made for the drawing and tab shown.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim);
|
||||
// Allow dragging the window by the title row.
|
||||
let tr = Rect::from_min_size(r.min, vec2(r.width(), title_h));
|
||||
let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::CLICK | Sense::DRAG);
|
||||
let resp = ui.interact(tr, ui.scope_id().with("titledrag"), Sense::CLICK | Sense::DRAG);
|
||||
if resp.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let Some(group) = TOOLBAR.get(*index) else { continue };
|
||||
for (icon, cmd, label, shortcut) in group.iter() {
|
||||
let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(size, size));
|
||||
let resp = ui.interact(br, ui.id().with(("tb", *cmd)), Sense::click());
|
||||
let resp = ui.interact(br, ui.scope_id().with(("tb", *cmd)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6));
|
||||
}
|
||||
@@ -141,7 +141,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
if !fit.overflow.is_empty() {
|
||||
let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(fit.overflow_width, size));
|
||||
let response = ui.interact(br, ui.id().with("toolbar_more"), Sense::click());
|
||||
let response = ui.interact(br, ui.scope_id().with("toolbar_more"), Sense::click());
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6));
|
||||
}
|
||||
@@ -184,7 +184,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
icons::paint(ui.painter(), ib.shrink(1.0), Icon::Switcher, false);
|
||||
x += 24.0;
|
||||
let plus = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0));
|
||||
let presp = ui.interact(plus, ui.id().with("tab+"), Sense::click());
|
||||
let presp = ui.interact(plus, ui.scope_id().with("tab+"), Sense::click());
|
||||
icons::paint(ui.painter(), plus.shrink(2.0), Icon::Plus, false);
|
||||
icons::describe_control(ui, &presp, "New Drawing", None, false);
|
||||
if presp.on_hover_text(crate::tl!("New Drawing")).clicked() {
|
||||
@@ -238,7 +238,8 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
x += w;
|
||||
(resp.clicked(), close, resp)
|
||||
};
|
||||
let (c, _, _) = tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
|
||||
let (c, _, _) =
|
||||
tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.scope_id().with("tab_start"));
|
||||
if c {
|
||||
app.ui.start_tab = true;
|
||||
}
|
||||
@@ -247,7 +248,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let titles: Vec<(usize, String, bool, bool)> =
|
||||
app.session.docs.iter().enumerate().map(|(i, d)| (i, d.title.clone(), d.is_dirty(), i == app.session.active)).collect();
|
||||
for (i, title, dirty, active) in titles {
|
||||
let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
|
||||
let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.scope_id().with(("tab", i)));
|
||||
crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::drawing_tab_entries(app, i));
|
||||
if c {
|
||||
switch_to = Some(i);
|
||||
@@ -297,7 +298,8 @@ fn visible_tabs(widths: &[f32], active: Option<usize>, room: f32) -> std::ops::R
|
||||
start..active + 1
|
||||
}
|
||||
|
||||
/// Status bar: layout tabs on the left, coordinates and drafting toggles on the right.
|
||||
/// Status bar: layout tabs follow the left-hand layout actions, independent of palette docking.
|
||||
/// Coordinates and drafting toggles remain on the right.
|
||||
pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
egui::Panel::bottom("cc_status").exact_size(26.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
@@ -314,7 +316,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let mut list_button = None;
|
||||
for (icon, tip) in [(Icon::Plus, "New layout"), (Icon::Menu, "Layout list")] {
|
||||
let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0));
|
||||
let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click());
|
||||
let resp = ui.interact(br, ui.scope_id().with(("sb", tip)), Sense::click());
|
||||
icons::paint(&p, br.shrink(2.0), icon, false);
|
||||
icons::describe_control(ui, &resp, tip, None, false);
|
||||
let resp = resp.on_hover_text(crate::i18n::t(tip));
|
||||
@@ -327,7 +329,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
x += 22.0;
|
||||
}
|
||||
x = x.max(r.left() + if app.ui.show_toolsets { 232.0 } else { 60.0 });
|
||||
x += 8.0;
|
||||
// Right side: toggles, right-aligned.
|
||||
let s = app.session.settings.clone();
|
||||
let anno_all = app.session.doc().is_ok_and(|d| d.anno_all_visible());
|
||||
@@ -368,7 +370,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let model = app.session.state().is_ok_and(|st| st.mspace.is_some());
|
||||
let label = if model { "MODEL" } else { "PAPER" };
|
||||
let br = Rect::from_min_max(pos2(rx - 60.0, r.center().y - 9.0), pos2(rx - 6.0, r.center().y + 9.0));
|
||||
let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click());
|
||||
let resp = ui.interact(br, ui.scope_id().with("mspace_toggle"), Sense::click());
|
||||
p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) });
|
||||
p.text(br.center(), egui::Align2::CENTER_CENTER, crate::i18n::t(label), crate::theme::small(), t.text);
|
||||
icons::describe_control(ui, &resp, label, None, false);
|
||||
@@ -417,7 +419,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let w = g.size().x + 26.0;
|
||||
let tr = Rect::from_min_size(pos2(x, r.top() + 1.0), vec2(w, r.height() - 2.0)).intersect(clip);
|
||||
let active = active_tab == Some(i);
|
||||
let resp = ui.interact(tr, ui.id().with(("lt", i)), Sense::click());
|
||||
let resp = ui.interact(tr, ui.scope_id().with(("lt", i)), Sense::click());
|
||||
if active {
|
||||
tp.rect_filled(tr, 2.0, t.tab_active);
|
||||
} else if resp.hovered() {
|
||||
@@ -433,7 +435,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
x += w + 2.0;
|
||||
if i == 0 {
|
||||
let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0));
|
||||
let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click());
|
||||
let presp = ui.interact(pr, ui.scope_id().with("layout_plus"), Sense::click());
|
||||
icons::paint(&p, pr.shrink(2.0), Icon::Plus, false);
|
||||
icons::describe_control(ui, &presp, "New layout", None, false);
|
||||
if presp.on_hover_text(crate::tl!("New layout")).clicked()
|
||||
@@ -448,7 +450,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
// The layouts that didn't fit, behind a button after the last tab shown.
|
||||
let more = hidden.then(|| {
|
||||
let mr = Rect::from_min_size(pos2(x + 2.0, r.top() + 5.0), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(mr, ui.id().with("layout_more"), Sense::click());
|
||||
let resp = ui.interact(mr, ui.scope_id().with("layout_more"), Sense::click());
|
||||
if resp.hovered() {
|
||||
p.rect_filled(mr, 3.0, t.control_hover.gamma_multiply(0.5));
|
||||
}
|
||||
@@ -489,13 +491,13 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let rect = Rect::from_min_max(pos2(left, r.top()), pos2(coord_right, r.bottom()));
|
||||
let text_x = if message.is_some() { left } else { coord_right - galley.size().x };
|
||||
p.with_clip_rect(rect).galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, color);
|
||||
ui.interact(rect, ui.id().with("status_readout"), Sense::hover()).on_hover_text(text);
|
||||
ui.interact(rect, ui.scope_id().with("status_readout"), Sense::hover()).on_hover_text(text);
|
||||
}
|
||||
}
|
||||
let mut toggle_cmd = None;
|
||||
for (icon, on, cmd, tip) in toggles {
|
||||
let br = Rect::from_min_size(pos2(rx, r.center().y - size / 2.0), vec2(size, size));
|
||||
let resp = ui.interact(br, ui.id().with(("tg", tip)), Sense::click());
|
||||
let resp = ui.interact(br, ui.scope_id().with(("tg", tip)), Sense::click());
|
||||
if on {
|
||||
p.rect_filled(br, 3.0, t.toggle_on.gamma_multiply(0.35));
|
||||
} else if resp.hovered() {
|
||||
@@ -541,7 +543,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let mut action = None;
|
||||
for (label, cmd) in [("Open...", "open"), ("New", "new"), ("New (metric)", "new.metric"), ("Open sample drawing", "sample")] {
|
||||
let br = Rect::from_min_size(pos2(left.left() + 34.0, y), vec2(220.0, 34.0));
|
||||
let resp = ui.interact(br, ui.id().with(("start", cmd)), Sense::click());
|
||||
let resp = ui.interact(br, ui.scope_id().with(("start", cmd)), Sense::click());
|
||||
p.rect_stroke(br, 2.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside);
|
||||
p.text(pos2(br.left() + 16.0, br.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(label), crate::theme::body(), t.text);
|
||||
if resp.clicked() {
|
||||
@@ -555,7 +557,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let mut y = main.top() + 44.0;
|
||||
for (i, path) in app.ui.recent_files.iter().enumerate() {
|
||||
let row = Rect::from_min_size(pos2(main.left(), y), vec2((r.right() - main.left() - 36.0).clamp(0.0, 640.0), 42.0));
|
||||
let resp = ui.interact(row, ui.id().with(("recent", i)), Sense::click());
|
||||
let resp = ui.interact(row, ui.scope_id().with(("recent", i)), Sense::click());
|
||||
if resp.hovered() {
|
||||
p.rect_filled(row, 2.0, t.control_hover.gamma_multiply(0.5));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 {
|
||||
let g = p.layout_no_wrap(current.clone(), crate::theme::small(), t.text);
|
||||
let w = g.size().x.min(160.0) + 22.0;
|
||||
let r = Rect::from_min_size(pos2(right - w, cy - 9.0), vec2(w, 18.0));
|
||||
let resp = ui.interact(r, ui.id().with("anno_scale"), Sense::click());
|
||||
let resp = ui.interact(r, ui.scope_id().with("anno_scale"), Sense::click());
|
||||
p.rect_filled(r, 3.0, if resp.hovered() { t.control_hover } else { t.control.gamma_multiply(0.5) });
|
||||
p.with_clip_rect(r.shrink2(vec2(4.0, 0.0))).galley(pos2(r.left() + 6.0, r.center().y - g.size().y / 2.0), g, t.text);
|
||||
let (ax, ay) = (r.right() - 9.0, r.center().y);
|
||||
|
||||
@@ -13,10 +13,10 @@ use crate::CadApp;
|
||||
|
||||
/// egui memory: the clipboard copy last mirrored, and the hash of the text it became.
|
||||
fn serial_id() -> egui::Id {
|
||||
egui::Id::new("cc_clipboard_serial")
|
||||
egui::Id::unique("cc_clipboard_serial")
|
||||
}
|
||||
fn text_id() -> egui::Id {
|
||||
egui::Id::new("cc_clipboard_text")
|
||||
egui::Id::unique("cc_clipboard_text")
|
||||
}
|
||||
|
||||
/// Line endings normalised (pasted text arrives with `\n`), so our own text is recognised.
|
||||
|
||||
@@ -204,7 +204,7 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) {
|
||||
};
|
||||
let t = Tokens::get();
|
||||
let mut choice = None;
|
||||
egui::Modal::new(egui::Id::new("cc_close_prompt")).show(ctx, |ui| {
|
||||
crate::modal::instant(ctx, egui::Id::unique("cc_close_prompt")).show(ctx, |ui| {
|
||||
ui.set_width(360.0);
|
||||
ui.label(RichText::new(crate::tl!("CADCraft")).strong());
|
||||
ui.add_space(4.0);
|
||||
|
||||
@@ -33,6 +33,16 @@ pub struct CmdLine {
|
||||
pub option_rows: Vec<(Rect, String)>,
|
||||
/// Where the Dynamic Input tooltip stays while the options menu is open.
|
||||
pub options_anchor: Option<Pos2>,
|
||||
/// The IME's composition (pre-edit) text: shown after the buffer while composing, never saved.
|
||||
pub preedit: String,
|
||||
/// The command line's editing rectangle and caret (screen) from the last frame, for the IME's
|
||||
/// composition window (see `enable_ime`).
|
||||
pub ime_rect: Option<(Rect, Rect)>,
|
||||
/// The command line's IME context generation: a new one each time it starts collecting text,
|
||||
/// as egui asks of an IME owner that (re)gains focus.
|
||||
ime_generation: u64,
|
||||
/// Whether the command line published an IME context last frame.
|
||||
ime_on: bool,
|
||||
}
|
||||
|
||||
/// The most history lines the command line shows above its bar.
|
||||
@@ -247,9 +257,36 @@ pub struct KeyPress {
|
||||
pub plain: bool,
|
||||
}
|
||||
|
||||
/// Let the operating system's IME compose into the command line while a prompt asks for text.
|
||||
/// Nothing owns keyboard focus there (typing goes to the command line from anywhere), so egui
|
||||
/// never enables the IME by itself: publishing an IME output does it, with the command line's
|
||||
/// rectangle and caret for the composition window. Without this a Windows IME (Pinyin, Japanese,
|
||||
/// Korean...) cannot even switch on (#1761).
|
||||
fn enable_ime(app: &mut CadApp, ctx: &egui::Context) {
|
||||
if !app.cmd.ime_on {
|
||||
app.cmd.ime_on = true;
|
||||
app.cmd.ime_generation = app.cmd.ime_generation.wrapping_add(1);
|
||||
}
|
||||
let (rect, cursor_rect) = app.cmd.ime_rect.unwrap_or_else(|| {
|
||||
let r = ctx.content_rect();
|
||||
(r, Rect::from_min_size(pos2(r.left() + 30.0, r.bottom() - 34.0), vec2(1.0, 18.0)))
|
||||
});
|
||||
ctx.output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput::new(
|
||||
egui::Id::unique("cc_cmdline_ime"),
|
||||
app.cmd.ime_generation,
|
||||
egui::IMEPurpose::Normal,
|
||||
rect,
|
||||
cursor_rect,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle keyboard input destined for the command line (when no text field has focus).
|
||||
pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
app.cmd.preedit.clear();
|
||||
app.cmd.ime_on = false;
|
||||
return;
|
||||
}
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
@@ -257,6 +294,13 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
|
||||
app.cmd.watch_selection(&selection);
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
let text_prompt = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number);
|
||||
if text_prompt {
|
||||
enable_ime(app, ctx);
|
||||
} else {
|
||||
// No prompt is collecting text: a composition left over from one cannot belong here.
|
||||
app.cmd.preedit.clear();
|
||||
app.cmd.ime_on = false;
|
||||
}
|
||||
options_still_fit(app);
|
||||
for ev in events {
|
||||
// The options menu takes the arrows, Enter and Escape; typing closes it.
|
||||
@@ -297,13 +341,26 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
|
||||
} else if t == " " && !text_prompt {
|
||||
submit(app);
|
||||
} else {
|
||||
app.cmd.preedit.clear();
|
||||
app.cmd.buffer.push_str(&t);
|
||||
}
|
||||
}
|
||||
// IME composition: the pre-edit is shown but stays out of the buffer; the commit is
|
||||
// the only text that is inserted. Enter/Return commits arrive separately as keys, so
|
||||
// a newline commit is ignored (as egui's own text fields do) instead of submitting.
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => app.cmd.preedit = text,
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
|
||||
app.cmd.preedit.clear();
|
||||
if text != "\n" && text != "\r" {
|
||||
app.cmd.buffer.push_str(&text);
|
||||
}
|
||||
}
|
||||
egui::Event::Ime(_) => {}
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => match key {
|
||||
Key::Enter => submit(app),
|
||||
Key::Escape => {
|
||||
app.cmd.buffer.clear();
|
||||
app.cmd.preedit.clear();
|
||||
app.session.cancel();
|
||||
}
|
||||
Key::Backspace => {
|
||||
@@ -357,6 +414,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
pub fn submit(app: &mut CadApp) {
|
||||
app.cmd.preedit.clear();
|
||||
// Grip dimensional input: the typed lengths and angles place the grip.
|
||||
if crate::grip_dyn::submit(app) {
|
||||
return;
|
||||
@@ -395,6 +453,7 @@ fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize {
|
||||
app.ui.history_lines.min(MAX_LINES).min(available)
|
||||
}
|
||||
|
||||
/// Draw the floating command bar and its history over the bottom-centre of `canvas`; the bar is 48 % of the canvas width, clamped to 360–760 points and never wider than the canvas minus 8 points.
|
||||
pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
let t = Tokens::get();
|
||||
let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0));
|
||||
@@ -443,7 +502,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
for (i, k) in kws.iter().enumerate() {
|
||||
let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword);
|
||||
let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0));
|
||||
let resp = ui.interact(r, ui.id().with(("kw", i)), Sense::click());
|
||||
let resp = ui.interact(r, ui.scope_id().with(("kw", i)), Sense::click());
|
||||
if resp.hovered() {
|
||||
p.rect_filled(r, 2.0, t.cmd_keyword_hover);
|
||||
}
|
||||
@@ -492,14 +551,26 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let g = p.layout_no_wrap(app.cmd.buffer.clone(), font, t.text);
|
||||
let g = p.layout_no_wrap(app.cmd.buffer.clone(), font.clone(), t.text);
|
||||
let gw = g.size().x;
|
||||
p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);
|
||||
let mut caret_x = x + gw;
|
||||
// The IME's composition text sits after the buffer (underlined) until it is committed.
|
||||
if !app.cmd.preedit.is_empty() {
|
||||
let pg = p.layout_no_wrap(app.cmd.preedit.clone(), font, t.text);
|
||||
let pw = pg.size().x;
|
||||
p.galley(pos2(caret_x, bar.center().y - pg.size().y / 2.0), pg, t.text);
|
||||
p.line_segment([pos2(caret_x, bar.bottom() - 7.0), pos2(caret_x + pw, bar.bottom() - 7.0)], Stroke::new(1.0, t.accent));
|
||||
caret_x += pw;
|
||||
}
|
||||
// Caret.
|
||||
let blink = (ui.input(|i| i.time) * 2.0).floor() as i64 % 2 == 0;
|
||||
if blink {
|
||||
p.line_segment([pos2(x + gw + 1.0, bar.top() + 5.0), pos2(x + gw + 1.0, bar.bottom() - 5.0)], Stroke::new(1.0, t.text));
|
||||
p.line_segment([pos2(caret_x + 1.0, bar.top() + 5.0), pos2(caret_x + 1.0, bar.bottom() - 5.0)], Stroke::new(1.0, t.text));
|
||||
}
|
||||
// Where the IME's composition window follows: the whole command line and the caret in it.
|
||||
let caret = Rect::from_min_size(pos2(caret_x + 1.0, bar.top() + 5.0), vec2(1.0, h - 10.0));
|
||||
app.cmd.ime_rect = Some((bar, caret));
|
||||
// History chevron.
|
||||
crate::icons::paint(
|
||||
&p,
|
||||
@@ -523,7 +594,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
p.rect_stroke(lr, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside);
|
||||
for (i, s) in sug.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(lr.left(), lr.top() + rh * i as f32), vec2(lr.width(), rh));
|
||||
let resp = ui.interact(r, ui.id().with(("sug", i)), Sense::click());
|
||||
let resp = ui.interact(r, ui.scope_id().with(("sug", i)), Sense::click());
|
||||
if resp.hovered() || i == 0 {
|
||||
p.rect_filled(r.shrink(1.0), 2.0, if resp.hovered() { t.cmd_keyword_hover } else { t.list_row });
|
||||
}
|
||||
@@ -586,7 +657,7 @@ fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones {
|
||||
/// the top edge sets how many lines show (`ui.cmdline.lines`).
|
||||
fn history(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect, bar: Rect) {
|
||||
let z = zones(app, canvas, bar);
|
||||
let resp = ui.interact(z.all, ui.id().with("cmdline"), Sense::click_and_drag());
|
||||
let resp = ui.interact(z.all, ui.scope_id().with("cmdline"), Sense::click_and_drag());
|
||||
let at = resp.hover_pos().or(resp.interact_pointer_pos());
|
||||
let on_grip = |p: Pos2| z.grip.contains(p) && z.window.is_none();
|
||||
let on_toggle = |p: Pos2| z.chevron.contains(p) || z.lines.is_some_and(|r| r.contains(p));
|
||||
@@ -667,19 +738,52 @@ mod tests {
|
||||
assert_eq!(app.ui.history_lines, 12);
|
||||
}
|
||||
|
||||
fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<Event>) {
|
||||
fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<Event>) -> egui::FullOutput {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
fn press(app: &mut CadApp, ctx: &egui::Context, at: Pos2, pressed: bool) {
|
||||
frame(app, ctx, vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::default() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_preedit_is_shown_and_its_commit_is_inserted_once() {
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
let mut app = CadApp::new(Session::new(), crate::Services::default());
|
||||
app.run("text", serde_json::json!({"at": [0, 0], "text": "HELLO", "height": 2.5})).unwrap();
|
||||
app.start("textedit");
|
||||
app.session.input(Input::Point(Vec2::new(1.0, 1.0))).unwrap();
|
||||
assert_eq!(app.session.current_prompt().map(|p| p.message.clone()).as_deref(), Some("Enter new text"));
|
||||
let ctx = egui::Context::default();
|
||||
let out = frame(&mut app, &ctx, Vec::new());
|
||||
// The IME is enabled for the command line while it is collecting text.
|
||||
assert!(out.platform_output.ime.is_some());
|
||||
// Composition is shown at the command line but is not part of the text being edited.
|
||||
frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Preedit { text: "zhong".into(), active_range_chars: None })]);
|
||||
assert_eq!(app.cmd.preedit, "zhong");
|
||||
assert!(app.cmd.buffer.is_empty());
|
||||
// The committed text replaces the pre-edit, exactly once.
|
||||
frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("中文".into()))]);
|
||||
assert!(app.cmd.preedit.is_empty());
|
||||
assert_eq!(app.cmd.buffer, "中文");
|
||||
let enter = |pressed| Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
frame(&mut app, &ctx, vec![enter(true), enter(false)]);
|
||||
let text = app.session.doc().unwrap().model.iter().next().map(|e| match &e.kind {
|
||||
cadcraft_engine::doc::EntityKind::Text(t) => t.value.clone(),
|
||||
_ => String::new(),
|
||||
});
|
||||
assert_eq!(text.as_deref(), Some("中文"));
|
||||
// Back to selecting objects: the next frame lets the IME go, so it can't swallow ASCII typing.
|
||||
let out = frame(&mut app, &ctx, Vec::new());
|
||||
assert!(out.platform_output.ime.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_expands_by_click_chevron_f2_and_resizes_by_drag_without_touching_the_drawing() {
|
||||
let mut app = CadApp::new(Session::empty(), crate::Services::default());
|
||||
|
||||
@@ -313,8 +313,8 @@ struct Press(f64, egui::Pos2);
|
||||
/// the menu while it is open.
|
||||
pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool {
|
||||
let ctx = ui.ctx().clone();
|
||||
let press_id = egui::Id::new("cc_command_menu_press");
|
||||
let popup_id = egui::Id::new("cc_command_menu");
|
||||
let press_id = egui::Id::unique("cc_command_menu_press");
|
||||
let popup_id = egui::Id::unique("cc_command_menu");
|
||||
let secondary = egui::PointerButton::Secondary;
|
||||
let (pressed, released, now, pos) =
|
||||
ctx.input(|i| (i.pointer.button_pressed(secondary), i.pointer.button_released(secondary), i.time, i.pointer.interact_pos()));
|
||||
|
||||
+181
-10
@@ -102,8 +102,9 @@ struct Widget {
|
||||
selected: Option<bool>,
|
||||
}
|
||||
|
||||
/// egui temp-data key holding the widgets observed during the current frame.
|
||||
fn widgets_id() -> egui::Id {
|
||||
egui::Id::new("cadcraft-observed-widgets")
|
||||
egui::Id::unique("cadcraft-observed-widgets")
|
||||
}
|
||||
|
||||
pub(crate) fn begin_frame(ctx: &egui::Context) {
|
||||
@@ -252,6 +253,7 @@ pub fn cmdline_state(app: &CadApp) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the `ui.inspect` snapshot: UI state and theme, animations, focus, canvas/window rects, view, cursor, snap, session summary, close prompt and performance counters.
|
||||
pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
|
||||
let r = ctx.content_rect();
|
||||
let view = app.session.state().map(|s| s.view()).ok();
|
||||
@@ -267,7 +269,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
|
||||
"snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})),
|
||||
"session": app.session.summary(),
|
||||
"closePrompt": app.close_prompt(),
|
||||
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())},
|
||||
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -543,18 +545,138 @@ fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// Save a screenshot PNG.
|
||||
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
/// A private, per-process screenshot directory for pathless `ui.screenshot` requests.
|
||||
///
|
||||
/// The directory is created with owner-only permissions (`0700` on Unix) under a name that
|
||||
/// includes the process id, a monotonic nonce and a sequence number. `create_dir` fails rather
|
||||
/// than following a pre-created symlink, so another local user cannot redirect the write.
|
||||
fn private_shot_dir() -> std::io::Result<std::path::PathBuf> {
|
||||
use std::sync::OnceLock;
|
||||
static DIR: OnceLock<Result<std::path::PathBuf, String>> = OnceLock::new();
|
||||
DIR.get_or_init(|| create_private_shot_dir().map_err(|e| e.to_string())).clone().map_err(std::io::Error::other)
|
||||
}
|
||||
|
||||
/// Process-wide monotonic counter used to keep pathless screenshot names unique.
|
||||
fn next_pathless_nonce() -> u64 {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Create at most 32 exclusive `cadcraft-shots-<pid>-<stamp>-<nonce>` directories in the temp dir; only `AlreadyExists` is retried.
|
||||
fn create_private_shot_dir() -> std::io::Result<std::path::PathBuf> {
|
||||
let base = std::env::temp_dir();
|
||||
let stamp = crate::now_ms().to_bits();
|
||||
for _ in 0..32 {
|
||||
let nonce = next_pathless_nonce();
|
||||
let dir = base.join(format!("cadcraft-shots-{}-{stamp:x}-{nonce}", std::process::id()));
|
||||
match create_private_dir(&dir) {
|
||||
Ok(()) => return Ok(dir),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not create a private screenshot directory"))
|
||||
}
|
||||
|
||||
/// Create `path` with mode 0700 on Unix (plain create elsewhere), failing rather than following or reusing a pre-created entry.
|
||||
fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt as _;
|
||||
std::fs::DirBuilder::new().mode(0o700).create(path)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::create_dir(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new file with owner-only permissions. `create_new` uses `O_EXCL` on Unix and refuses
|
||||
/// an existing path, including a symlink, so a pre-created link cannot redirect the screenshot.
|
||||
fn create_private_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
options.mode(0o600);
|
||||
}
|
||||
options.open(path)
|
||||
}
|
||||
|
||||
/// True when the metadata has no group/other permission bits (always true on non-Unix).
|
||||
fn is_private_shot_dir(meta: &std::fs::Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
meta.permissions().mode() & 0o077 == 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = meta;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a pathless screenshot into the private per-process directory: re-validate or recreate it as an owner-only real directory (0700 on Unix; plain create elsewhere), then create up to 64 exclusive files (0600 on Unix) and write a PNG.
|
||||
fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Result<std::path::PathBuf> {
|
||||
use std::io::Write as _;
|
||||
let dir = private_shot_dir()?;
|
||||
// Tests and users may remove the private directory between requests; recreate it exclusively
|
||||
// rather than following anything that replaced it.
|
||||
if !std::fs::symlink_metadata(&dir).is_ok_and(|m| m.file_type().is_dir() && !m.file_type().is_symlink() && is_private_shot_dir(&m)) {
|
||||
match create_private_dir(&dir) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
let meta = std::fs::symlink_metadata(&dir)?;
|
||||
if !meta.file_type().is_dir() || meta.file_type().is_symlink() || !is_private_shot_dir(&meta) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the screenshot directory was replaced"));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
for _ in 0..64 {
|
||||
let path = dir.join(format!("cadcraft-shot-{token}-{}.png", next_pathless_nonce()));
|
||||
match create_private_file(&path) {
|
||||
Ok(mut file) => {
|
||||
img.write_to(&mut file, image::ImageFormat::Png).map_err(std::io::Error::other)?;
|
||||
file.flush()?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not allocate a unique screenshot file"))
|
||||
}
|
||||
|
||||
/// Save a screenshot PNG. Only called from the logic drain, never from the screenshot callback.
|
||||
///
|
||||
/// An explicit `path` is written as before. Without one, the file gets a unique name inside a
|
||||
/// private per-process directory, created exclusively with owner-only permissions; concurrent
|
||||
/// requests (even from two app instances with the same per-process token) cannot collide or
|
||||
/// follow a pre-created symlink.
|
||||
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) -> Value {
|
||||
let [w, h] = image.size;
|
||||
let mut buf = Vec::with_capacity(w * h * 4);
|
||||
let Some(len) = w.checked_mul(h).and_then(|pixels| pixels.checked_mul(4)) else {
|
||||
return json!({"ok": false, "error": "screenshot dimensions overflow", "request": token});
|
||||
};
|
||||
let mut buf = Vec::with_capacity(len);
|
||||
for c in &image.pixels {
|
||||
buf.extend_from_slice(&c.to_array());
|
||||
}
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { return json!({"ok": false, "error": "bad image"}) };
|
||||
let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("cadcraft-shot.png").to_string_lossy().to_string());
|
||||
match img.save(&path) {
|
||||
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
|
||||
Err(e) => json!({"ok": false, "error": e.to_string()}),
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else {
|
||||
return json!({"ok": false, "error": "bad image", "request": token});
|
||||
};
|
||||
let saved = match path {
|
||||
Some(path) => img.save(path).map(|()| path.to_string()).map_err(std::io::Error::other),
|
||||
None => save_pathless_screenshot(&img, token).map(|path| path.to_string_lossy().into_owned()),
|
||||
};
|
||||
match saved {
|
||||
Ok(path) => json!({"ok": true, "request": token, "result": {"path": path, "width": w, "height": h}}),
|
||||
Err(e) => json!({"ok": false, "error": e.to_string(), "request": token}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,6 +732,55 @@ mod tests {
|
||||
assert_eq!(pending.wait(Duration::ZERO), json!({"ok": true}));
|
||||
}
|
||||
|
||||
/// Two pathless saves with the same token produce distinct, decodable PNGs; on Unix the files are 0600 and their directory 0700.
|
||||
#[test]
|
||||
fn pathless_screenshots_are_unique_private_png_files() {
|
||||
let img = egui::ColorImage::filled([4, 3], egui::Color32::RED);
|
||||
let first = save_screenshot(&img, None, 1);
|
||||
let second = save_screenshot(&img, None, 1);
|
||||
assert_eq!(first["ok"], true, "{first}");
|
||||
assert_eq!(second["ok"], true, "{second}");
|
||||
let a = std::path::PathBuf::from(first["result"]["path"].as_str().unwrap_or_default());
|
||||
let b = std::path::PathBuf::from(second["result"]["path"].as_str().unwrap_or_default());
|
||||
assert_ne!(a, b, "the same per-process token must not reuse a pathless file");
|
||||
for path in [&a, &b] {
|
||||
let bytes = std::fs::read(path).expect("the screenshot file exists");
|
||||
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "{path:?} is a PNG");
|
||||
image::load_from_memory(&bytes).expect("decodable PNG");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
for path in [&a, &b] {
|
||||
let mode = std::fs::metadata(path).expect("metadata").permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "screenshot {path:?} is owner-only");
|
||||
}
|
||||
let dir = a.parent().expect("the file has a private directory");
|
||||
let dir_mode = std::fs::metadata(dir).expect("directory metadata").permissions().mode() & 0o777;
|
||||
assert_eq!(dir_mode, 0o700, "the screenshot directory is owner-only");
|
||||
}
|
||||
for path in [&a, &b] {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// `create_new` refuses a pre-existing symlink with `AlreadyExists` and leaves the link target unchanged.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_screenshot_file_refuses_a_preexisting_symlink() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-shot-symlink-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
create_private_dir(&dir).expect("private test directory");
|
||||
let target = dir.join("target.txt");
|
||||
std::fs::write(&target, b"do not overwrite").expect("target file");
|
||||
let link = dir.join("shot.png");
|
||||
std::os::unix::fs::symlink(&target, &link).expect("symlink");
|
||||
let err = create_private_file(&link).expect_err("an existing symlink is refused");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(std::fs::read(&target).expect("target unchanged"), b"do not overwrite");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reply_has_base64_width_height_and_keeps_old_keys() {
|
||||
let r = render_reply(b"abc", 7, 5, Some("x.png"));
|
||||
|
||||
@@ -212,7 +212,7 @@ impl Default for View {
|
||||
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let id = egui::Id::new("credits_view");
|
||||
let id = egui::Id::unique("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(crate::tl!("Show"));
|
||||
|
||||
@@ -39,10 +39,11 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drafting Settings window: snap/grid modes and spacing, isometric snap, polar tracking and object snaps, editing the session settings directly.
|
||||
fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let settings = app.session.drafting_settings();
|
||||
let (mut snapunit, mut gridunit) = (settings.snapunit, settings.gridunit);
|
||||
egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::new("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| {
|
||||
egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::unique("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| {
|
||||
let s = &mut app.session.settings;
|
||||
ui.heading(crate::tl!("Snap and Grid"));
|
||||
ui.checkbox(&mut s.snapmode, crate::tl!("Snap On (F9)"));
|
||||
@@ -52,10 +53,10 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
ui.radio_value(&mut s.isodraft, true, crate::tl!("Isometric snap"));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Snap X spacing"));
|
||||
ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false));
|
||||
ui.label(crate::tl!("Y"));
|
||||
ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false));
|
||||
let x_label = ui.label(crate::tl!("Snap X spacing"));
|
||||
ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(x_label.id);
|
||||
let y_label = ui.label(crate::tl!("Y"));
|
||||
ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(y_label.id);
|
||||
});
|
||||
// SNAPTYPE and POLARDIST.
|
||||
ui.horizontal(|ui| {
|
||||
@@ -65,16 +66,16 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
});
|
||||
ui.add_enabled_ui(s.polarsnap, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Polar distance"));
|
||||
ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6));
|
||||
let label = ui.label(crate::tl!("Polar distance"));
|
||||
ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6)).labelled_by(label.id);
|
||||
});
|
||||
});
|
||||
ui.checkbox(&mut s.gridmode, crate::tl!("Grid On (F7)"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Grid spacing"));
|
||||
ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false));
|
||||
ui.label(crate::tl!("Major line every"));
|
||||
ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100));
|
||||
let grid_label = ui.label(crate::tl!("Grid spacing"));
|
||||
ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false)).labelled_by(grid_label.id);
|
||||
let major_label = ui.label(crate::tl!("Major line every"));
|
||||
ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)).labelled_by(major_label.id);
|
||||
});
|
||||
ui.separator();
|
||||
ui.heading(crate::tl!("Polar Tracking"));
|
||||
@@ -84,12 +85,13 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let current = (s.polarang.to_degrees() * 1e6).round() / 1e6;
|
||||
let mut deg = current;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Increment angle"));
|
||||
egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
|
||||
let label = ui.label(crate::tl!("Increment angle"));
|
||||
let combo = egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
|
||||
for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] {
|
||||
ui.selectable_value(&mut deg, a, format!("{a}"));
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(label.id);
|
||||
});
|
||||
if deg != current {
|
||||
s.polarang = deg.to_radians();
|
||||
@@ -161,14 +163,15 @@ fn tracking_options(ui: &mut egui::Ui, s: &mut cadcraft_engine::Settings) {
|
||||
}
|
||||
let mut additional = s.polar_flags & polarmode::ADDITIONAL != 0;
|
||||
ui.horizontal(|ui| {
|
||||
if ui.checkbox(&mut additional, crate::tl!("Additional angles")).changed() {
|
||||
let additional_label = ui.checkbox(&mut additional, crate::tl!("Additional angles"));
|
||||
if additional_label.changed() {
|
||||
s.polar_flags ^= polarmode::ADDITIONAL;
|
||||
}
|
||||
// The list as typed (degrees separated by `;`), kept while it doesn't parse yet.
|
||||
let id = ui.id().with("polaraddang");
|
||||
let id = ui.scope_id().with("polaraddang");
|
||||
let shown = cadcraft_engine::sysvars::format_angle_list(&s.polaraddang);
|
||||
let mut text = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone());
|
||||
let resp = ui.add_enabled(additional, egui::TextEdit::singleline(&mut text).desired_width(140.0));
|
||||
let resp = ui.add_enabled(additional, egui::TextEdit::singleline(&mut text).desired_width(140.0)).labelled_by(additional_label.id);
|
||||
if resp.changed()
|
||||
&& let Some(angles) = cadcraft_engine::sysvars::parse_angle_list(&text)
|
||||
{
|
||||
@@ -250,7 +253,7 @@ fn length_precisions(lunits: i64) -> Vec<(i64, String)> {
|
||||
/// through the `units` command on OK.
|
||||
fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
use cadcraft_engine::units::{format_angle, format_distance};
|
||||
let id = egui::Id::new("units_dialog_values");
|
||||
let id = egui::Id::unique("units_dialog_values");
|
||||
let mut v = ctx.data_mut(|d| d.get_temp::<[i64; 5]>(id)).unwrap_or_else(|| {
|
||||
app.session
|
||||
.doc()
|
||||
@@ -314,11 +317,12 @@ fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// About window with About, Contributors and Models tabs, version, licence and project links.
|
||||
fn about(ctx: &egui::Context, open: &mut bool) {
|
||||
let t = Tokens::get();
|
||||
let tab_id = egui::Id::new("about_tab");
|
||||
let tab_id = egui::Id::unique("about_tab");
|
||||
egui::Window::new(crate::tl!("About CADCraft"))
|
||||
.id(egui::Id::new("About CADCraft"))
|
||||
.id(egui::Id::unique("About CADCraft"))
|
||||
.open(open)
|
||||
.default_size(vec2(640.0, 420.0))
|
||||
.collapsible(false)
|
||||
@@ -351,16 +355,17 @@ fn about(ctx: &egui::Context, open: &mut bool) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Command Reference window; filters commands by id or label (English or translated) and starts the clicked command after closing the dialog.
|
||||
fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let mut start = None;
|
||||
egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::new("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show(
|
||||
egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::unique("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
let id = ui.id().with("cmdfilter");
|
||||
let id = ui.scope_id().with("cmdfilter");
|
||||
let mut filter = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Filter"));
|
||||
ui.text_edit_singleline(&mut filter);
|
||||
let label = ui.label(crate::tl!("Filter"));
|
||||
ui.text_edit_singleline(&mut filter).labelled_by(label.id);
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, filter.clone()));
|
||||
let f = filter.to_lowercase();
|
||||
@@ -391,9 +396,10 @@ fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks window listing named (non-anonymous) blocks; clicking one starts INSERT with that block name already supplied.
|
||||
fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let mut pick = None;
|
||||
egui::Window::new(crate::tl!("Blocks")).id(egui::Id::new("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| {
|
||||
egui::Window::new(crate::tl!("Blocks")).id(egui::Id::unique("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| {
|
||||
let Ok(d) = app.session.doc() else { return };
|
||||
let names: Vec<&String> = d.blocks.keys().filter(|k| !k.starts_with('*')).collect();
|
||||
if names.is_empty() {
|
||||
@@ -422,8 +428,9 @@ fn insert_block(app: &mut CadApp, name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Interface Language combo; selecting a language runs `ui.language` with its code.
|
||||
fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::new("interface_language")).open(open).resizable(false).show(ctx, |ui| {
|
||||
egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::unique("interface_language")).open(open).resizable(false).show(ctx, |ui| {
|
||||
egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| {
|
||||
for preference in crate::i18n::Preference::for_picker() {
|
||||
if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() {
|
||||
|
||||
@@ -213,6 +213,7 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<V
|
||||
Ok(json!({"docking": app.ui.docking}))
|
||||
}
|
||||
|
||||
/// Validate and repair the stored workspace, sync it with the app, and render the dock panels; a failed restore falls back to the default layout with a status message.
|
||||
pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
@@ -237,7 +238,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
// Panels arrange in a split tree around the drawing; floating title bars only move windows.
|
||||
style.new_dock_zones = false;
|
||||
style.title_bar_docks = false;
|
||||
let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits(
|
||||
let output = DockArea::new(egui::Id::unique("cadcraft-docking")).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
&style,
|
||||
@@ -273,7 +274,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
let _ = sync(&mut workspace, app);
|
||||
let stale = workspace != rendered;
|
||||
if stale {
|
||||
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("cadcraft-docking"));
|
||||
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::unique("cadcraft-docking"));
|
||||
}
|
||||
publish_visibility(app, &workspace);
|
||||
app.ui.docking = workspace;
|
||||
|
||||
@@ -131,7 +131,7 @@ pub(crate) fn menu(app: &mut CadApp, ctx: &egui::Context, xf: &Xf) {
|
||||
let gs = app.session.settings.gripsize as f32;
|
||||
let at = xf.to_screen(g.at) + vec2(-gs, gs * 1.5);
|
||||
let mut chosen: Option<String> = None;
|
||||
let area = egui::Area::new(egui::Id::new(("dyn-grip-list", h, g.node))).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
|
||||
let area = egui::Area::new(egui::Id::unique(("dyn-grip-list", h, g.node))).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
for c in &g.choices {
|
||||
if ui.selectable_label(c.eq_ignore_ascii_case(¤t), c.as_str()).clicked() {
|
||||
|
||||
@@ -72,14 +72,14 @@ const FROM_PLOT: &str = "cc_plot_preview_from_plot";
|
||||
/// return to the dialog.
|
||||
pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value, info: String) {
|
||||
PREVIEW_GEN.fetch_add(1, Ordering::Relaxed);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new(FROM_PLOT), FromPlot { params, info }));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(FROM_PLOT), FromPlot { params, info }));
|
||||
app.ui.dialog = Some("preview".into());
|
||||
}
|
||||
|
||||
/// Render the plot preview of the current layout (or model), or of the Plot dialog's settings,
|
||||
/// as a texture.
|
||||
fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> {
|
||||
if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))) {
|
||||
if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::unique(FROM_PLOT))) {
|
||||
let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?;
|
||||
let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;
|
||||
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
|
||||
@@ -101,7 +101,7 @@ fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, Strin
|
||||
|
||||
/// The Plot Preview window: the sheet as it plots, with Print and Close.
|
||||
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let id = egui::Id::new("cc_plot_preview");
|
||||
let id = egui::Id::unique("cc_plot_preview");
|
||||
let generation = PREVIEW_GEN.load(Ordering::Relaxed);
|
||||
let cached = ctx.data(|d| d.get_temp::<Preview>(id)).filter(|p| p.generation == generation);
|
||||
let preview = match cached {
|
||||
@@ -137,11 +137,11 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
if close || print {
|
||||
*open = false;
|
||||
}
|
||||
let from_plot = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))).is_some();
|
||||
let from_plot = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::unique(FROM_PLOT))).is_some();
|
||||
if !*open {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Preview>(id);
|
||||
d.remove::<FromPlot>(egui::Id::new(FROM_PLOT));
|
||||
d.remove::<FromPlot>(egui::Id::unique(FROM_PLOT));
|
||||
});
|
||||
}
|
||||
if from_plot && !*open {
|
||||
|
||||
@@ -430,7 +430,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
};
|
||||
let title = if st.edit.is_empty() { crate::tl!("Hatch and Gradient") } else { crate::tl!("Hatch Edit") };
|
||||
let mut action = Action::None;
|
||||
egui::Window::new(title).id(egui::Id::new("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| {
|
||||
egui::Window::new(title).id(egui::Id::unique("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(340.0);
|
||||
@@ -478,6 +478,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pattern/gradient fill selector and its controls; an origin pick is reported through `action`.
|
||||
fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut f.gradient, false, crate::tl!("Pattern"));
|
||||
@@ -487,12 +488,12 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) {
|
||||
if f.gradient {
|
||||
egui::Grid::new("hatch_gradient").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(crate::tl!("Color 1"));
|
||||
if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color1"), f.color1, false, 160.0) {
|
||||
if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color1"), f.color1, false, 160.0) {
|
||||
f.color1 = c;
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label(crate::tl!("Color 2"));
|
||||
if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color2"), f.color2, false, 160.0) {
|
||||
if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color2"), f.color2, false, 160.0) {
|
||||
f.color2 = c;
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -853,7 +854,7 @@ mod tests {
|
||||
let (mut app, _) = app_with_rect();
|
||||
open_create(&mut app, gradient);
|
||||
let ctx = frames(&mut app);
|
||||
let r = ctx.memory(|m| m.area_rect(egui::Id::new("hatch_dialog"))).unwrap();
|
||||
let r = ctx.memory(|m| m.area_rect(egui::Id::unique("hatch_dialog"))).unwrap();
|
||||
// The screen is 1000 points high: the window keeps to its content.
|
||||
assert!(r.height() < 700.0, "gradient {gradient}: {r:?}");
|
||||
}
|
||||
|
||||
@@ -1237,3 +1237,6 @@ menu Modify تعديل
|
||||
menu View عرض
|
||||
@plural {n} commit {n} إيداع|{n} إيداع|{n} إيداعان|{n} إيداعات|{n} إيداعاً|{n} إيداع
|
||||
@plural {n} contributor {n} مساهم|{n} مساهم|{n} مساهمان|{n} مساهمين|{n} مساهماً|{n} مساهم
|
||||
Check for Updates… التحقق من وجود تحديثات…
|
||||
CADCraft is up to date. CADCraft محدّث.
|
||||
Could not check for updates. تعذّر التحقق من وجود تحديثات.
|
||||
|
||||
|
@@ -1222,3 +1222,6 @@ menu Modify Modifikace
|
||||
Not a number or distance: {text} Není číslo ani vzdálenost: {text}
|
||||
Query Texts Dotaz na texty
|
||||
Analyze Shapes Analyzovat tvary
|
||||
Check for Updates… Zkontrolovat aktualizace…
|
||||
CADCraft is up to date. CADCraft je aktuální.
|
||||
Could not check for updates. Aktualizace se nepodařilo zkontrolovat.
|
||||
|
||||
|
@@ -1039,3 +1039,6 @@
|
||||
Justify Blocksatz
|
||||
Text color Textfarbe
|
||||
Click the ruler to add a tab stop, right-click to remove one Klick auf das Lineal setzt einen Tabstopp, Rechtsklick entfernt ihn
|
||||
Check for Updates… Nach Updates suchen…
|
||||
CADCraft is up to date. CADCraft ist auf dem neuesten Stand.
|
||||
Could not check for updates. Die Suche nach Updates ist fehlgeschlagen.
|
||||
|
||||
|
@@ -1333,3 +1333,6 @@ menu Dimension Διαστασιολόγηση
|
||||
menu Draw Σχεδίαση
|
||||
menu Modify Τροποποίηση
|
||||
menu View Προβολή
|
||||
Check for Updates… Έλεγχος για ενημερώσεις…
|
||||
CADCraft is up to date. Το CADCraft είναι ενημερωμένο.
|
||||
Could not check for updates. Δεν ήταν δυνατός ο έλεγχος για ενημερώσεις.
|
||||
|
||||
|
@@ -1251,3 +1251,6 @@
|
||||
Not a number or distance: {text} No es un número ni una distancia: {text}
|
||||
Query Texts Consultar textos
|
||||
Analyze Shapes Analizar formas
|
||||
Check for Updates… Buscar actualizaciones…
|
||||
CADCraft is up to date. CADCraft está actualizado.
|
||||
Could not check for updates. No se pudo buscar actualizaciones.
|
||||
|
||||
|
@@ -1012,3 +1012,6 @@
|
||||
Not a number or distance: {text} Ni un nombre ni une distance : {text}
|
||||
Query Texts Interroger les textes
|
||||
Analyze Shapes Analyser les formes
|
||||
Check for Updates… Rechercher des mises à jour…
|
||||
CADCraft is up to date. CADCraft est à jour.
|
||||
Could not check for updates. Impossible de rechercher les mises à jour.
|
||||
|
||||
|
@@ -1344,3 +1344,6 @@ menu Dimension Méretezés
|
||||
menu Draw Rajzolás
|
||||
menu Modify Módosítás
|
||||
menu View Nézet
|
||||
Check for Updates… Frissítések keresése…
|
||||
CADCraft is up to date. A CADCraft naprakész.
|
||||
Could not check for updates. A frissítések keresése nem sikerült.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 16 and column 72.
|
@@ -1362,3 +1362,6 @@ menu Modify Edita
|
||||
Click the ruler to add a tab stop, right-click to remove one Fare clic sul righello per aggiungere una tabulazione, clic destro per rimuoverla
|
||||
Select using two clicks only Seleziona solo con due clic
|
||||
Define each corner of the selection window with a separate click. Definire ogni angolo della finestra di selezione con un clic separato.
|
||||
Check for Updates… Verifica aggiornamenti…
|
||||
CADCraft is up to date. CADCraft è aggiornato.
|
||||
Could not check for updates. Impossibile verificare gli aggiornamenti.
|
||||
|
||||
|
@@ -1242,3 +1242,6 @@ menu View 보기
|
||||
menu Draw 그리기
|
||||
menu Dimension 치수
|
||||
menu Modify 수정
|
||||
Check for Updates… 업데이트 확인…
|
||||
CADCraft is up to date. CADCraft가 최신 버전입니다.
|
||||
Could not check for updates. 업데이트를 확인하지 못했습니다.
|
||||
|
||||
|
@@ -1251,3 +1251,6 @@
|
||||
Not a number or distance: {text} Não é um número nem uma distância: {text}
|
||||
Select using two clicks only Selecionar apenas com dois cliques
|
||||
Define each corner of the selection window with a separate click. Defina cada canto da janela de seleção com um clique separado.
|
||||
Check for Updates… Verificar atualizações…
|
||||
CADCraft is up to date. O CADCraft está atualizado.
|
||||
Could not check for updates. Não foi possível verificar atualizações.
|
||||
|
||||
|
@@ -1013,3 +1013,6 @@
|
||||
Not a number or distance: {text} Не число и не расстояние: {text}
|
||||
Query Texts Запрос текстов
|
||||
Analyze Shapes Анализ фигур
|
||||
Check for Updates… Проверить обновления…
|
||||
CADCraft is up to date. У вас последняя версия CADCraft.
|
||||
Could not check for updates. Не удалось проверить обновления.
|
||||
|
||||
|
@@ -1397,3 +1397,55 @@ menu Modify Ändra
|
||||
Window < Fönster <
|
||||
X offset (mm) X-förskjutning (mm)
|
||||
Y offset (mm) Y-förskjutning (mm)
|
||||
AutoLISP (LSP) AutoLISP (LSP)
|
||||
Units of new drawings Enheter för nya ritningar
|
||||
Imperial (inches) Anglosaxiskt (tum)
|
||||
Metric (millimetres) Metriskt (millimeter)
|
||||
New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Nya ritningar och den första ritningen vid start använder dessa enheter om ingen standardmall är angiven (QNEWTEMPLATE).
|
||||
Analyze Usage Analysera användning
|
||||
Implied Loops Implicita slingor
|
||||
Angstroms Ångström
|
||||
Architectural Arkitektonisk
|
||||
Astronomical Units Astronomiska enheter
|
||||
Centimeters Centimeter
|
||||
Decameters Dekameter
|
||||
Decimal Decimal
|
||||
Decimal Degrees Decimalgrader
|
||||
Decimeters Decimeter
|
||||
Deg/Min/Sec Grad/min/sek
|
||||
Don't Save Spara inte
|
||||
Drawing Units Ritningsenheter
|
||||
Engineering Teknisk
|
||||
Feet Fot
|
||||
Fractional Bråk
|
||||
Gigameters Gigameter
|
||||
Grads Gon
|
||||
Hectometers Hektometer
|
||||
Inches Tum
|
||||
Insertion scale Insättningsskala
|
||||
Kilometers Kilometer
|
||||
Light Years Ljusår
|
||||
Meters Meter
|
||||
Microinches Mikrotum
|
||||
Microns Mikrometer
|
||||
Miles Engelska mil
|
||||
Millimeters Millimeter
|
||||
Mils Tusendels tum
|
||||
Nanometers Nanometer
|
||||
Parsecs Parsek
|
||||
Radians Radianer
|
||||
Sample output Exempel på utdata
|
||||
Save changes to {name}? Spara ändringar i {name}?
|
||||
Scientific Vetenskaplig
|
||||
Surveyor's Units Lantmäterienheter
|
||||
Unitless Enhetslös
|
||||
Units to scale inserted content Enheter för skalning av infogat innehåll
|
||||
US Survey Feet Lantmäterifot (USA)
|
||||
US Survey Inch Lantmäteritum (USA)
|
||||
US Survey Mile Engelsk lantmäterimil (USA)
|
||||
US Survey Yard Lantmäteriyard (USA)
|
||||
Yards Yard
|
||||
Your changes will be lost if you don't save them. Dina ändringar går förlorade om du inte sparar dem.
|
||||
Check for Updates… Sök efter uppdateringar…
|
||||
CADCraft is up to date. CADCraft är uppdaterat.
|
||||
Could not check for updates. Det gick inte att söka efter uppdateringar.
|
||||
|
||||
|
@@ -323,8 +323,9 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() {
|
||||
// Without a font for its script, a language stays chosen and the interface shows English.
|
||||
let shown = app.language();
|
||||
assert_eq!(shown, if crate::theme::can_show(language.code) { language.code } else { "en" });
|
||||
assert_eq!(app.fonts_language, Some(shown));
|
||||
let text = render(&mut app, &ctx);
|
||||
// Fonts are a frame-only concern: the render above installs them.
|
||||
assert_eq!(app.fonts_language, Some(shown));
|
||||
for source in ["Properties", "File"] {
|
||||
assert!(text.contains(tr(shown, source)), "{}: {source}: {text}", language.code);
|
||||
}
|
||||
@@ -345,8 +346,8 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() {
|
||||
let _ = crate::control::handle(&mut app, &ctx, &request);
|
||||
assert_eq!(app.ui.interface_language, Preference::parse(code).unwrap());
|
||||
app.logic(&ctx);
|
||||
assert_eq!(app.fonts_language, Some(app.language()));
|
||||
render(&mut app, &ctx);
|
||||
assert_eq!(app.fonts_language, Some(app.language()));
|
||||
}
|
||||
assert!(render(&mut app, &ctx).contains("Properties"));
|
||||
// The menu tree used by the control channel keeps its original labels and IDs.
|
||||
@@ -409,9 +410,9 @@ fn interface_fonts_cover_every_letter_in_each_catalog() {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
||||
// In egui 0.36, has_glyph can report a false negative for glyphs in the same
|
||||
// face as the replacement character. Check the family's actual character maps.
|
||||
let mut font = view.fonts.font(&family);
|
||||
let chars = view.characters(&family);
|
||||
for c in &letters {
|
||||
if !font.characters().contains_key(c) {
|
||||
if !chars.contains_key(c) {
|
||||
missing.push(format!("{family:?}: {c} (U+{:04X})", u32::from(*c)));
|
||||
}
|
||||
}
|
||||
@@ -470,9 +471,9 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() {
|
||||
let missing = ctx.fonts_mut(|view| {
|
||||
let mut missing = Vec::new();
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
||||
let mut font = view.fonts.font(&family);
|
||||
let chars = view.characters(&family);
|
||||
for c in "한국어".chars() {
|
||||
if !font.characters().contains_key(&c) {
|
||||
if !chars.contains_key(&c) {
|
||||
missing.push(format!("{family:?}: {c}"));
|
||||
}
|
||||
}
|
||||
@@ -481,3 +482,28 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() {
|
||||
});
|
||||
assert!(missing.is_empty(), "missing Korean picker glyphs: {}", missing.join(", "));
|
||||
}
|
||||
|
||||
/// The production font chain covers alphanumeric ASCII in proportional and monospace, plus ⌘ ⇧ ↩ … − in proportional and ⇧ in monospace.
|
||||
#[test]
|
||||
fn production_font_chain_covers_latin_and_shortcut_symbols() {
|
||||
// Exercise the same chain the app installs: the workspace egui dependency enables
|
||||
// `default_fonts` and `monochrome_emoji_fonts` in production, so web and native builds
|
||||
// always start with bounded Latin and monochrome icon faces instead of an empty family.
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, "en");
|
||||
let mut output = ctx.run_ui(Default::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
ctx.fonts_mut(|view| {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
||||
for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".chars() {
|
||||
assert!(view.has_glyph(&egui::FontId::new(12.0, family.clone()), c), "Latin {c:?} missing in {family:?}");
|
||||
}
|
||||
}
|
||||
// Proportional shortcut labels (`Button::shortcut_text` and the translated menus) use
|
||||
// these symbols: ⌘ from the monochrome icon face, ⇧ from Hack as a bounded fallback.
|
||||
for c in ['\u{2318}', '\u{21e7}', '\u{21a9}', '\u{2026}', '\u{2212}'] {
|
||||
assert!(view.has_glyph(&egui::FontId::proportional(12.0), c), "U+{:04X} missing in Proportional", c as u32);
|
||||
}
|
||||
assert!(view.has_glyph(&egui::FontId::monospace(12.0), '\u{21e7}'), "U+21E7 shift symbol missing in Monospace");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1347,3 +1347,6 @@ menu Dimension Ölçülendirme
|
||||
menu Draw Çiz
|
||||
menu Modify Değiştir
|
||||
menu View Görünüm
|
||||
Check for Updates… Güncellemeleri denetle…
|
||||
CADCraft is up to date. CADCraft güncel.
|
||||
Could not check for updates. Güncellemeler denetlenemedi.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 13.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user