mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-26 03:46:53 -06:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3ae8a6af0 | ||
|
|
2a2d4e2582 | ||
|
|
d1cf06cbea | ||
|
|
7d33d9d4dd | ||
|
|
53080b1748 | ||
|
|
21c48cc05b |
+4
-4
@@ -24,10 +24,10 @@ 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`. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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 9f89fe8 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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 9f89fe8 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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
+21
-7
@@ -571,7 +571,9 @@ dependencies = [
|
||||
"cadcraft-mcp",
|
||||
"cadcraft-ui-egui",
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"eframe",
|
||||
"egui",
|
||||
"hidapi",
|
||||
@@ -740,6 +742,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui_kittest",
|
||||
@@ -992,12 +995,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
@@ -1007,7 +1010,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
@@ -1020,7 +1023,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1042,6 +1045,17 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
@@ -1981,7 +1995,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
]
|
||||
@@ -1995,7 +2009,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
dependencies = [
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
@@ -2357,7 +2371,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
|
||||
+8
-7
@@ -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 = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
|
||||
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
|
||||
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false }
|
||||
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", default-features = false }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -53,14 +53,15 @@ encoding_rs = "0.8.35"
|
||||
quick-xml = "0.42"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["serde"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] }
|
||||
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"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -90,9 +91,9 @@ 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" }
|
||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -28,9 +28,9 @@ The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds fo
|
||||
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).
|
||||
(pinned through https://github.com/storytold/craft-libs/tree/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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
|
||||
Pinned licence text: https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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
|
||||
|
||||
@@ -34,11 +34,16 @@ 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]
|
||||
# Floating panels open in their own OS windows on the desktop; the web build keeps them in the page.
|
||||
craft-ui = { workspace = true, features = ["native-windows"] }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# `title_bar::sync_movable`: AppKit's title-band drag leaves the window still under the row's
|
||||
# controls and under floating panels.
|
||||
craft-window = { workspace = true }
|
||||
muda = { version = "0.21", default-features = false }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
|
||||
# (system_animations.rs). Already in Cargo.lock through muda.
|
||||
|
||||
@@ -39,11 +39,22 @@ struct App {
|
||||
workspace_error: Option<String>,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: Option<native_menu::NativeMenu>,
|
||||
/// The `movable` flag last set on the main window.
|
||||
#[cfg(target_os = "macos")]
|
||||
movable: Option<bool>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn persist_workspace(&mut self, final_attempt: bool) {
|
||||
let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) };
|
||||
/// `ctx` is `None` only for the final attempt at exit.
|
||||
fn persist_workspace(&mut self, ctx: Option<&egui::Context>) {
|
||||
let result = match ctx {
|
||||
None => self.workspace.save_on_exit(&self.cad.ui),
|
||||
Some(ctx) => self.workspace.save_if_changed(&self.cad.ui, std::time::Instant::now()).map(|wait| {
|
||||
if let Some(wait) = wait {
|
||||
ctx.request_repaint_after(wait);
|
||||
}
|
||||
}),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
if self.workspace_error.as_ref() != Some(&error) {
|
||||
eprintln!("CADCraft workspace: {error}");
|
||||
@@ -86,7 +97,7 @@ impl eframe::App for App {
|
||||
}
|
||||
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);
|
||||
self.persist_workspace(Some(ctx));
|
||||
}
|
||||
if std::mem::take(&mut self.cad.quit_requested) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -100,11 +111,13 @@ impl eframe::App for App {
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.cad.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
|
||||
}
|
||||
/// 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.persist_workspace(None);
|
||||
self.cad.cancel_screenshots();
|
||||
self.cad.shutdown_autosave();
|
||||
}
|
||||
@@ -405,6 +418,8 @@ fn main() -> eframe::Result {
|
||||
workspace_error,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
movable: None,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -118,7 +118,7 @@ impl NativeMenu {
|
||||
if cmd == "ui.discord" {
|
||||
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
|
||||
} else {
|
||||
menus::activate(app, &cmd.clone());
|
||||
menus::activate_native(app, ctx, &cmd.clone());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -142,8 +142,9 @@ impl NativeMenu {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
let typing = menus::text_field_window(ctx).is_some();
|
||||
for (cmd, _, item) in self.items.values() {
|
||||
let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
|
||||
let en = (typing && menus::edits_text(cmd)) || cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
|
||||
item.set_enabled(en);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use cadcraft_ui_egui::UiState;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const MAX_BYTES: u64 = 64 * 1024;
|
||||
/// Dragging a floating window changes its rect every frame; writes (each with an fsync) are
|
||||
/// spaced at least this far apart, and the newest state is written when the interval ends.
|
||||
const MIN_WRITE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub struct Workspace {
|
||||
@@ -17,6 +21,7 @@ pub struct Workspace {
|
||||
last_attempt: Option<Value>,
|
||||
read_failed: bool,
|
||||
pending_write_error: Option<String>,
|
||||
last_write: Option<Instant>,
|
||||
}
|
||||
|
||||
fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
@@ -44,6 +49,7 @@ fn snapshot(ui: &UiState) -> Result<Value, String> {
|
||||
}
|
||||
Ok(json!({
|
||||
"docking": ui.docking,
|
||||
"dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION,
|
||||
"showToolsets": ui.show_toolsets,
|
||||
"showPalettes": ui.show_palettes,
|
||||
"showToolbar": ui.show_toolbar,
|
||||
@@ -80,10 +86,16 @@ fn read(path: &Path) -> Result<Option<UiState>, String> {
|
||||
value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?;
|
||||
// Dock schemas can evolve independently of the other saved workspace preferences.
|
||||
// Recover only this optional field; runtime UiState deserialization remains strict.
|
||||
// A layout from before the current shape (split frames, saved without `dockingVersion`)
|
||||
// gives way to the default, its hidden panels with it.
|
||||
let mut fields = workspace.clone();
|
||||
let docking = fields.as_object_mut().and_then(|fields| fields.remove("docking"));
|
||||
let (docking, version) = match fields.as_object_mut() {
|
||||
Some(fields) => (fields.remove("docking"), fields.remove("dockingVersion")),
|
||||
None => (None, None),
|
||||
};
|
||||
let current = version.and_then(|version| version.as_u64()).is_some_and(|version| version >= u64::from(cadcraft_ui_egui::docking::LAYOUT_VERSION));
|
||||
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
|
||||
if let Some(docking) = docking
|
||||
if let Some(docking) = docking.filter(|_| current)
|
||||
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
|
||||
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
|
||||
{
|
||||
@@ -136,38 +148,47 @@ impl Workspace {
|
||||
match result {
|
||||
Ok(ui) => {
|
||||
let initial = ui.as_ref().cloned().unwrap_or_default();
|
||||
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None }, ui, None)
|
||||
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None, last_write: None }, ui, None)
|
||||
}
|
||||
Err(error) => (
|
||||
Self { path, last_attempt: None, read_failed: true, pending_write_error: None },
|
||||
Self { path, last_attempt: None, read_failed: true, pending_write_error: None, last_write: None },
|
||||
None,
|
||||
Some(format!("Workspace could not be restored; its file was left unchanged. {error}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> {
|
||||
let Some(path) = &self.path else { return Ok(()) };
|
||||
/// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change
|
||||
/// is held back by [`MIN_WRITE_INTERVAL`]; call again after `wait` to write it.
|
||||
pub fn save_if_changed(&mut self, ui: &UiState, now: Instant) -> Result<Option<Duration>, String> {
|
||||
let Some(path) = &self.path else { return Ok(None) };
|
||||
if self.read_failed {
|
||||
// Preserve a corrupt, oversized or newer-version file until the user repairs it.
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
let value = snapshot(ui)?;
|
||||
if self.last_attempt.as_ref() == Some(&value) {
|
||||
return self.pending_write_error.clone().map_or(Ok(()), Err);
|
||||
return self.pending_write_error.clone().map_or(Ok(None), Err);
|
||||
}
|
||||
if let Some(wait) = self.last_write.and_then(|last| MIN_WRITE_INTERVAL.checked_sub(now.saturating_duration_since(last)))
|
||||
&& !wait.is_zero()
|
||||
{
|
||||
return Ok(Some(wait));
|
||||
}
|
||||
// Keep a failed state's warning without retrying disk I/O every rendered frame.
|
||||
self.last_attempt = Some(value.clone());
|
||||
self.last_write = Some(now);
|
||||
let result = write(path, &value);
|
||||
self.pending_write_error = result.as_ref().err().cloned();
|
||||
result
|
||||
result.map(|()| None)
|
||||
}
|
||||
|
||||
pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> {
|
||||
if self.pending_write_error.is_some() {
|
||||
self.last_attempt = None;
|
||||
}
|
||||
self.save_if_changed(ui)
|
||||
self.last_write = None;
|
||||
self.save_if_changed(ui, Instant::now()).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +237,7 @@ mod tests {
|
||||
start_tab: true,
|
||||
..Default::default()
|
||||
};
|
||||
first.save_if_changed(&ui).unwrap();
|
||||
first.save_if_changed(&ui, Instant::now()).unwrap();
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert!(value["workspace"].get("dialog").is_none());
|
||||
@@ -229,37 +250,42 @@ mod tests {
|
||||
let mut transient_only = ui.clone();
|
||||
transient_only.dialog = None;
|
||||
transient_only.start_tab = false;
|
||||
first.save_if_changed(&transient_only).unwrap();
|
||||
first.save_if_changed(&transient_only, Instant::now()).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
|
||||
use cadcraft_ui_egui::docking::{Panel, permissions};
|
||||
fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() {
|
||||
use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel};
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let mut ui = UiState::default();
|
||||
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
|
||||
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap();
|
||||
app.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"left"})).unwrap();
|
||||
app.run("ui.dock", json!({"operation":"float", "panel":"properties", "rect":[120,90,340,410]})).unwrap();
|
||||
ui.docking = app.ui.docking;
|
||||
ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
|
||||
ui.show_toolsets = false;
|
||||
store.save_if_changed(&ui).unwrap();
|
||||
store.save_if_changed(&ui, Instant::now()).unwrap();
|
||||
let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(saved["workspace"]["dockingVersion"], json!(LAYOUT_VERSION));
|
||||
let (_, reloaded, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none());
|
||||
let mut reloaded = reloaded.unwrap();
|
||||
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
|
||||
reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap();
|
||||
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
|
||||
assert!(!reloaded.show_toolsets);
|
||||
reveal_panel(&mut reloaded.docking, Panel::ToolSets).unwrap();
|
||||
let tools = reloaded.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
assert_eq!(serde_json::to_value(&tools.group).unwrap()["column"]["side"], json!("Start"), "{tools:?}");
|
||||
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_docking_file_still_loads() {
|
||||
use cadcraft_ui_egui::docking::{Panel, reveal_panel};
|
||||
fn split_frame_layout_file_loads_as_the_dock_default_with_its_other_settings() {
|
||||
use cadcraft_ui_egui::docking::default_workspace;
|
||||
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
|
||||
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
|
||||
let file = r#"{"version":1,"workspace":{
|
||||
@@ -271,28 +297,18 @@ mod tests {
|
||||
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
|
||||
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
|
||||
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
|
||||
"showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#;
|
||||
"showToolsets":false,"showPalettes":true,"showToolbar":false,"toolsetTab":"Modeling","historyLines":4}}"#;
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
fs::write(&path, file).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none(), "{error:?}");
|
||||
let mut ui = restored.unwrap();
|
||||
let ui = restored.unwrap();
|
||||
assert_eq!(ui.toolset_tab, "Modeling");
|
||||
let docking = &ui.docking;
|
||||
assert_eq!(docking.layout.floating.len(), 1);
|
||||
let window = &docking.layout.floating[0];
|
||||
assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}}));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"],
|
||||
json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}})
|
||||
);
|
||||
assert!(!docking.layout.contains(&Panel::ToolSets));
|
||||
reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap();
|
||||
let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
assert_eq!(tools.anchor, Some(Panel::Canvas));
|
||||
assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}]));
|
||||
assert_eq!(ui.history_lines, 4);
|
||||
assert!(!ui.show_toolsets && ui.show_palettes && !ui.show_toolbar);
|
||||
assert_eq!(ui.docking.layout, default_workspace().layout);
|
||||
assert!(ui.docking.hidden.is_empty(), "{:?}", ui.docking.hidden);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
@@ -311,7 +327,7 @@ mod tests {
|
||||
let (mut store, ui, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(ui.is_none());
|
||||
assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged")));
|
||||
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }).unwrap();
|
||||
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }, Instant::now()).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
@@ -326,7 +342,8 @@ mod tests {
|
||||
json!({"layout":false,"hidden":[]}),
|
||||
json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}),
|
||||
] {
|
||||
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking});
|
||||
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking,
|
||||
"dockingVersion":cadcraft_ui_egui::docking::LAYOUT_VERSION});
|
||||
assert!(serde_json::from_value::<UiState>(workspace.clone()).is_err(), "runtime state must stay strict");
|
||||
fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path.clone()));
|
||||
@@ -342,16 +359,41 @@ mod tests {
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapid_changes_are_written_once_per_interval_with_the_newest_state() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let start = Instant::now();
|
||||
let mut ui = UiState { history_lines: 1, ..Default::default() };
|
||||
assert_eq!(store.save_if_changed(&ui, start).unwrap(), None);
|
||||
// A drag: a new state every frame, none of them written until the interval ends.
|
||||
for (frame, lines) in (2..=5).enumerate() {
|
||||
ui.history_lines = lines;
|
||||
let now = start + Duration::from_millis(16 * (frame as u64 + 1));
|
||||
let wait = store.save_if_changed(&ui, now).unwrap();
|
||||
assert_eq!(wait, Some(MIN_WRITE_INTERVAL - (now - start)));
|
||||
}
|
||||
assert_eq!(read(&path).unwrap().unwrap().history_lines, 1);
|
||||
assert_eq!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap(), None);
|
||||
assert_eq!(read(&path).unwrap().unwrap().history_lines, 5);
|
||||
ui.history_lines = 6;
|
||||
assert!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap().is_some());
|
||||
store.save_on_exit(&ui).unwrap();
|
||||
assert_eq!(read(&path).unwrap().unwrap().history_lines, 6);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_update_cannot_replace_last_good_workspace() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let mut ui = UiState { show_palettes: false, ..Default::default() };
|
||||
store.save_if_changed(&ui).unwrap();
|
||||
store.save_if_changed(&ui, Instant::now()).unwrap();
|
||||
let good = fs::read(&path).unwrap();
|
||||
ui.collapsed_groups = vec!["x".repeat(257)];
|
||||
assert!(store.save_if_changed(&ui).is_err());
|
||||
assert!(store.save_if_changed(&ui, Instant::now()).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), good);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
@@ -364,9 +406,9 @@ mod tests {
|
||||
// Simulate a destination becoming unavailable after startup.
|
||||
fs::create_dir(&path).unwrap();
|
||||
let ui = UiState { show_palettes: false, ..Default::default() };
|
||||
let first_error = store.save_if_changed(&ui).unwrap_err();
|
||||
let first_error = store.save_if_changed(&ui, Instant::now()).unwrap_err();
|
||||
fs::remove_dir(&path).unwrap();
|
||||
assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error);
|
||||
assert_eq!(store.save_if_changed(&ui, Instant::now()).unwrap_err(), first_error);
|
||||
assert!(!path.exists(), "ordinary frames must not retry disk writes");
|
||||
store.save_on_exit(&ui).unwrap();
|
||||
assert!(!read(&path).unwrap().unwrap().show_palettes);
|
||||
|
||||
@@ -358,6 +358,56 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
|
||||
groups.into_values().collect()
|
||||
}
|
||||
|
||||
/// The solver weight of each unknown of `model`: kept handles weigh more, preferred-to-move ones
|
||||
/// less, angles and ratios by size², and contact parameters move freely.
|
||||
fn solver_weights(model: &Model, opts: &SolveOptions) -> Vec<f64> {
|
||||
let mut weights = vec![1.0; model.x0.len()];
|
||||
for s in &model.slots {
|
||||
let w = if opts.keep.contains(&s.handle) {
|
||||
1e4
|
||||
} else if opts.prefer_move.contains(&s.handle) {
|
||||
1e-2
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for j in s.off..s.off + s.len {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot = w;
|
||||
}
|
||||
}
|
||||
for (j, f) in slot_scales(model, s) {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot *= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Contact parameters move freely.
|
||||
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
|
||||
for w in weights.iter_mut().skip(geo) {
|
||||
*w = 1e-3;
|
||||
}
|
||||
weights
|
||||
}
|
||||
|
||||
/// Per-unknown factors for the angles and ratios of one slot: size², so that the movement of
|
||||
/// points is what is minimised.
|
||||
fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
|
||||
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
|
||||
match s.shape {
|
||||
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
|
||||
model::Shape::Ellipse { arc } => {
|
||||
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
|
||||
let mut v = vec![(s.off + 4, a2)];
|
||||
if arc {
|
||||
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
|
||||
}
|
||||
v
|
||||
}
|
||||
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
|
||||
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut members_left = model::MAX_RIGID_WORK;
|
||||
@@ -396,46 +446,7 @@ pub(crate) fn build_budgeted(
|
||||
if built.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut weights = vec![1.0; model.x0.len()];
|
||||
for s in &model.slots {
|
||||
let w = if opts.keep.contains(&s.handle) {
|
||||
1e4
|
||||
} else if opts.prefer_move.contains(&s.handle) {
|
||||
1e-2
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for j in s.off..s.off + s.len {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot = w;
|
||||
}
|
||||
}
|
||||
// Angles and ratios: weight by size² so the movement of points is what is minimised.
|
||||
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
|
||||
let scaled: Vec<(usize, f64)> = match s.shape {
|
||||
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
|
||||
model::Shape::Ellipse { arc } => {
|
||||
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
|
||||
let mut v = vec![(s.off + 4, a2)];
|
||||
if arc {
|
||||
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
|
||||
}
|
||||
v
|
||||
}
|
||||
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for (j, f) in scaled {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot *= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Contact parameters move freely.
|
||||
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
|
||||
for w in weights.iter_mut().skip(geo) {
|
||||
*w = 1e-3;
|
||||
}
|
||||
let weights = solver_weights(&model, opts);
|
||||
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, held, tol });
|
||||
|
||||
@@ -191,6 +191,77 @@ impl Model {
|
||||
Self::build_budgeted(d, handles, &mut members_left)
|
||||
}
|
||||
|
||||
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
|
||||
/// left out (too large, degenerate or unsupported).
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
|
||||
let shape = match kind {
|
||||
EntityKind::Line(l) => {
|
||||
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
|
||||
Shape::Line
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
m.x0.extend([c.center.x, c.center.y, c.radius]);
|
||||
Shape::Circle
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
|
||||
Shape::Arc
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
|
||||
return None;
|
||||
}
|
||||
for v in &p.vertices {
|
||||
m.x0.extend([v.p.x, v.p.y]);
|
||||
}
|
||||
Shape::Poly { n: p.vertices.len(), closed: p.closed }
|
||||
}
|
||||
EntityKind::Point(p) => {
|
||||
m.x0.extend([p.p.x, p.p.y]);
|
||||
Shape::Point
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
let arc = !ell.is_full();
|
||||
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
|
||||
if arc {
|
||||
m.x0.extend([el.start, el.end]);
|
||||
}
|
||||
Shape::Ellipse { arc }
|
||||
}
|
||||
EntityKind::Spline(sp) => {
|
||||
let fit = fit_mode(sp);
|
||||
let pts = if fit { &sp.fit } else { &sp.control };
|
||||
let n = pts.len();
|
||||
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
|
||||
return None;
|
||||
}
|
||||
for p in pts {
|
||||
m.x0.extend([p.x, p.y]);
|
||||
}
|
||||
// Fit points interpolate exactly, so the curve's ends are the first and last
|
||||
// fit point; a control polygon's ends are its first and last points when the
|
||||
// knots are clamped.
|
||||
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
|
||||
// Missing weights default to 1 in eval; only excess weights are unused.
|
||||
let mut kept = sp.clone();
|
||||
kept.weights.truncate(kept.control.len());
|
||||
m.splines.insert(h, kept);
|
||||
Shape::Spline { n, fit, ends }
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
||||
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||
let n = pts.len();
|
||||
m.rigid.insert(h, pts);
|
||||
Shape::Insert { n }
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(shape)
|
||||
}
|
||||
|
||||
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
|
||||
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
|
||||
let mut m = Model::default();
|
||||
@@ -200,71 +271,7 @@ impl Model {
|
||||
}
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let off = m.x0.len();
|
||||
let shape = match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
|
||||
Shape::Line
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
m.x0.extend([c.center.x, c.center.y, c.radius]);
|
||||
Shape::Circle
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
|
||||
Shape::Arc
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
|
||||
continue;
|
||||
}
|
||||
for v in &p.vertices {
|
||||
m.x0.extend([v.p.x, v.p.y]);
|
||||
}
|
||||
Shape::Poly { n: p.vertices.len(), closed: p.closed }
|
||||
}
|
||||
EntityKind::Point(p) => {
|
||||
m.x0.extend([p.p.x, p.p.y]);
|
||||
Shape::Point
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
let arc = !ell.is_full();
|
||||
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
|
||||
if arc {
|
||||
m.x0.extend([el.start, el.end]);
|
||||
}
|
||||
Shape::Ellipse { arc }
|
||||
}
|
||||
EntityKind::Spline(sp) => {
|
||||
let fit = fit_mode(sp);
|
||||
let pts = if fit { &sp.fit } else { &sp.control };
|
||||
let n = pts.len();
|
||||
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
|
||||
continue;
|
||||
}
|
||||
for p in pts {
|
||||
m.x0.extend([p.x, p.y]);
|
||||
}
|
||||
// Fit points interpolate exactly, so the curve's ends are the first and last
|
||||
// fit point; a control polygon's ends are its first and last points when the
|
||||
// knots are clamped.
|
||||
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
|
||||
// Missing weights default to 1 in eval; only excess weights are unused.
|
||||
let mut kept = sp.clone();
|
||||
kept.weights.truncate(kept.control.len());
|
||||
m.splines.insert(h, kept);
|
||||
Shape::Spline { n, fit, ends }
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
||||
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||
let n = pts.len();
|
||||
m.rigid.insert(h, pts);
|
||||
Shape::Insert { n }
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
|
||||
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
|
||||
m.x0.truncate(off);
|
||||
continue;
|
||||
|
||||
+45
-33
@@ -441,6 +441,49 @@ impl DimStyle {
|
||||
.or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
/// The value `v` takes for a field whose current value is `old`: coerced to the field's type where
|
||||
/// the text or number can be read as it.
|
||||
fn coerce_value(old: &serde_json::Value, v: &serde_json::Value, field: &str) -> serde_json::Value {
|
||||
use serde_json::Value;
|
||||
match (old, v) {
|
||||
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
|
||||
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
|
||||
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
|
||||
Ok(x) => serde_json::json!(x),
|
||||
// Lineweight names.
|
||||
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
|
||||
"bylayer" => serde_json::json!(-1),
|
||||
"byblock" => serde_json::json!(-2),
|
||||
"default" => serde_json::json!(-3),
|
||||
_ => v.clone(),
|
||||
},
|
||||
Err(_) => v.clone(),
|
||||
},
|
||||
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
|
||||
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
|
||||
(Value::Object(_), Value::Number(n)) => {
|
||||
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
|
||||
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
|
||||
}
|
||||
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
|
||||
_ => v.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `coerced` is outside the range its `field` can take.
|
||||
fn out_of_range(field: &str, coerced: &serde_json::Value) -> bool {
|
||||
let x = coerced.as_f64();
|
||||
match field {
|
||||
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
|
||||
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
|
||||
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
|
||||
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
|
||||
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
|
||||
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
|
||||
/// had unusable values.
|
||||
@@ -458,29 +501,7 @@ impl DimStyle {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
};
|
||||
let coerced = match (&old, v) {
|
||||
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
|
||||
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
|
||||
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
|
||||
Ok(x) => serde_json::json!(x),
|
||||
// Lineweight names.
|
||||
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
|
||||
"bylayer" => serde_json::json!(-1),
|
||||
"byblock" => serde_json::json!(-2),
|
||||
"default" => serde_json::json!(-3),
|
||||
_ => v.clone(),
|
||||
},
|
||||
Err(_) => v.clone(),
|
||||
},
|
||||
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
|
||||
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
|
||||
(Value::Object(_), Value::Number(n)) => {
|
||||
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
|
||||
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
|
||||
}
|
||||
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
|
||||
_ => v.clone(),
|
||||
};
|
||||
let coerced = Self::coerce_value(&old, v, field);
|
||||
// Integers stored in u8 fields: round floats.
|
||||
let coerced = match (&old, &coerced) {
|
||||
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
|
||||
@@ -502,16 +523,7 @@ impl DimStyle {
|
||||
_ => coerced,
|
||||
};
|
||||
// Sizes that can't be negative (text height and tolerance scale: not zero either).
|
||||
let x = coerced.as_f64();
|
||||
let out_of_range = match field {
|
||||
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
|
||||
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
|
||||
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
|
||||
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
|
||||
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
|
||||
_ => false,
|
||||
};
|
||||
let out_of_range = Self::out_of_range(field, &coerced);
|
||||
if out_of_range {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
|
||||
@@ -1585,25 +1585,10 @@ fn rect_kind(first: Vec2, (w, h): (f64, f64), rot: f64, fillet: f64, chamfer: (f
|
||||
k
|
||||
}
|
||||
|
||||
/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is
|
||||
/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`.
|
||||
pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("rectang", p, "p1")?;
|
||||
// Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly.
|
||||
let centered = bool_or(p, "center", false);
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let ch = p
|
||||
.get("chamfer")
|
||||
.and_then(Value::as_array)
|
||||
.map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
|
||||
.map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
if ch.0 < 0.0 || ch.1 < 0.0 {
|
||||
return Err(bad("rectang", "`chamfer` distances must be zero or positive"));
|
||||
}
|
||||
let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0);
|
||||
let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0);
|
||||
let (w, h) = if let Some(d) = p.get("dimensions").and_then(Value::as_array) {
|
||||
/// The width and height of a rectangle from its `dimensions`, its `p2` corner, its `area` with
|
||||
/// `length` or `breadth`, or its second corner `p2`, rotated by `rot` about `a`.
|
||||
fn rect_size(p: &Value, a: Vec2, rot: f64, centered: bool) -> Result<(f64, f64)> {
|
||||
Ok(if let Some(d) = p.get("dimensions").and_then(Value::as_array) {
|
||||
let l = d.first().and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
|
||||
let wd = d.get(1).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
|
||||
let (sx, sy) = match point_param(p, "p2") {
|
||||
@@ -1628,7 +1613,28 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let loc = (b - a).rotate(-rot);
|
||||
// In centre mode the picked point is one corner, mirrored across the centre.
|
||||
if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) }
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is
|
||||
/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`.
|
||||
pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("rectang", p, "p1")?;
|
||||
// Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly.
|
||||
let centered = bool_or(p, "center", false);
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let ch = p
|
||||
.get("chamfer")
|
||||
.and_then(Value::as_array)
|
||||
.map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
|
||||
.map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
if ch.0 < 0.0 || ch.1 < 0.0 {
|
||||
return Err(bad("rectang", "`chamfer` distances must be zero or positive"));
|
||||
}
|
||||
let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0);
|
||||
let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0);
|
||||
let (w, h) = rect_size(p, a, rot, centered)?;
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() {
|
||||
return Err(bad("rectang", "rectangle has no area"));
|
||||
}
|
||||
|
||||
@@ -963,6 +963,57 @@ impl AreaM {
|
||||
AreaMode::Subtract => self.total - v,
|
||||
}
|
||||
}
|
||||
/// Input while a height is pending: typed, or given by two points (their distance).
|
||||
fn height_input(&mut self, s: &mut Session, i: Input) -> Step {
|
||||
match (i, self.height_from) {
|
||||
(Input::Point(p), None) => self.height_from = Some(p),
|
||||
(Input::Point(p), Some(from)) => {
|
||||
self.height_from = None;
|
||||
return self.height(s, p.dist(from));
|
||||
}
|
||||
(Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) {
|
||||
Some(h) => return self.height(s, h),
|
||||
None => s.echo("Requires a distance."),
|
||||
},
|
||||
(Input::Enter, None) => s.echo("Requires a height."),
|
||||
(Input::Enter, Some(_)) => s.echo("Requires a second point."),
|
||||
(Input::Text(_), Some(_)) => s.echo("Requires a point."),
|
||||
_ => {}
|
||||
}
|
||||
Step::Continue
|
||||
}
|
||||
|
||||
/// Input while the objects to measure are picked.
|
||||
fn object_input(&mut self, s: &mut Session, i: Input) -> Step {
|
||||
match i {
|
||||
Input::Pick(hs) => {
|
||||
for h in hs {
|
||||
match object_area(s, h) {
|
||||
Ok((a, per)) => {
|
||||
if self.measured(s, a, per) == Step::Done {
|
||||
return Step::Done;
|
||||
}
|
||||
// A single volume takes the first object with an area.
|
||||
if self.volume && self.mode == AreaMode::Single {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => s.echo("The selected object has no area."),
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Enter => {
|
||||
self.object = false;
|
||||
if self.mode == AreaMode::Single {
|
||||
return Step::Done;
|
||||
}
|
||||
}
|
||||
Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"),
|
||||
_ => {}
|
||||
}
|
||||
Step::Continue
|
||||
}
|
||||
|
||||
/// Measure the oldest pending area's volume with height `h`.
|
||||
fn height(&mut self, s: &mut Session, h: f64) -> Step {
|
||||
match self.pending.first().map(|a| a * h.abs()) {
|
||||
@@ -1042,52 +1093,10 @@ impl Interactive for AreaM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.pending.is_empty() {
|
||||
// A height is typed, or given by two points (their distance).
|
||||
match (i, self.height_from) {
|
||||
(Input::Point(p), None) => self.height_from = Some(p),
|
||||
(Input::Point(p), Some(from)) => {
|
||||
self.height_from = None;
|
||||
return Ok(self.height(s, p.dist(from)));
|
||||
}
|
||||
(Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) {
|
||||
Some(h) => return Ok(self.height(s, h)),
|
||||
None => s.echo("Requires a distance."),
|
||||
},
|
||||
(Input::Enter, None) => s.echo("Requires a height."),
|
||||
(Input::Enter, Some(_)) => s.echo("Requires a second point."),
|
||||
(Input::Text(_), Some(_)) => s.echo("Requires a point."),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
return Ok(self.height_input(s, i));
|
||||
}
|
||||
if self.object {
|
||||
match i {
|
||||
Input::Pick(hs) => {
|
||||
for h in hs {
|
||||
match object_area(s, h) {
|
||||
Ok((a, per)) => {
|
||||
if self.measured(s, a, per) == Step::Done {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
// A single volume takes the first object with an area.
|
||||
if self.volume && self.mode == AreaMode::Single {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => s.echo("The selected object has no area."),
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Enter => {
|
||||
self.object = false;
|
||||
if self.mode == AreaMode::Single {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
return Ok(self.object_input(s, i));
|
||||
}
|
||||
if self.length {
|
||||
if let Some(p) = length_input(&self.pts, i)? {
|
||||
|
||||
@@ -117,21 +117,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// Endpoints closer than the tolerance meet. Transitive components of three or more ends are
|
||||
// branch junctions; a component of exactly two is a simple joint.
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
for i in 0..n {
|
||||
let Some(a) = ends.get(i) else { continue };
|
||||
for j in (i + 1)..n {
|
||||
let Some(b) = ends.get(j) else { continue };
|
||||
if a.at.dist(b.at) <= tol {
|
||||
let (ra, rb) = (find(&mut parent, i), find(&mut parent, j));
|
||||
if ra != rb
|
||||
&& let Some(slot) = parent.get_mut(ra)
|
||||
{
|
||||
*slot = rb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut parent = merge_ends(&ends, tol);
|
||||
let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
for i in 0..n {
|
||||
let root = find(&mut parent, i);
|
||||
@@ -140,54 +126,8 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// Junctions are reported and their pieces left out of the chains: not resolved.
|
||||
let mut excluded = vec![false; pieces.len()];
|
||||
let mut junctions: Vec<Value> = Vec::new();
|
||||
let mut joints: Vec<(usize, usize)> = Vec::new();
|
||||
for group in groups.values() {
|
||||
match group.as_slice() {
|
||||
&[a, b] => joints.push((a, b)),
|
||||
[_, _, ..] => {
|
||||
let scale = group.len() as f64;
|
||||
let mut at = Vec2::ZERO;
|
||||
let mut members: Vec<Handle> = Vec::new();
|
||||
for &e in group {
|
||||
let Some(end) = ends.get(e) else { continue };
|
||||
at += end.at / scale;
|
||||
let Some(piece) = pieces.get(end.piece) else { continue };
|
||||
if !members.contains(&piece.handle) {
|
||||
members.push(piece.handle);
|
||||
}
|
||||
if let Some(slot) = excluded.get_mut(end.piece) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
junctions.push(json!({
|
||||
"at": [at.x, at.y],
|
||||
"ends": group.len(),
|
||||
"members": members.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// A joint links the two ends for the walk. A joint into an excluded piece is not a link, and
|
||||
// a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop.
|
||||
let mut link: Vec<Option<usize>> = vec![None; n];
|
||||
for (a, b) in joints {
|
||||
let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue };
|
||||
if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) {
|
||||
continue;
|
||||
}
|
||||
if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = link.get_mut(a) {
|
||||
*slot = Some(b);
|
||||
}
|
||||
if let Some(slot) = link.get_mut(b) {
|
||||
*slot = Some(a);
|
||||
}
|
||||
}
|
||||
let (joints, junctions) = sort_groups(&groups, &ends, &pieces, &mut excluded);
|
||||
let link = joint_links(joints, &ends, &pieces, &excluded);
|
||||
|
||||
let mut visited = vec![false; pieces.len()];
|
||||
let mut loops: Vec<Value> = Vec::new();
|
||||
@@ -225,6 +165,84 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A report with nothing analyzed: an empty drawing, or one past the pair cap.
|
||||
/// Union-find over the ends: ends closer than `tol` share a root. Returns each end's parent.
|
||||
fn merge_ends(ends: &[End], tol: f64) -> Vec<usize> {
|
||||
let n = ends.len();
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
for i in 0..n {
|
||||
let Some(a) = ends.get(i) else { continue };
|
||||
for j in (i + 1)..n {
|
||||
let Some(b) = ends.get(j) else { continue };
|
||||
if a.at.dist(b.at) <= tol {
|
||||
let (ra, rb) = (find(&mut parent, i), find(&mut parent, j));
|
||||
if ra != rb
|
||||
&& let Some(slot) = parent.get_mut(ra)
|
||||
{
|
||||
*slot = rb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
parent
|
||||
}
|
||||
|
||||
/// Each group of two ends is a joint (end indices); each of three or more is a junction, reported
|
||||
/// with its pieces marked `excluded`.
|
||||
fn sort_groups(groups: &BTreeMap<usize, Vec<usize>>, ends: &[End], pieces: &[Piece], excluded: &mut [bool]) -> (Vec<(usize, usize)>, Vec<Value>) {
|
||||
let mut joints: Vec<(usize, usize)> = Vec::new();
|
||||
let mut junctions: Vec<Value> = Vec::new();
|
||||
for group in groups.values() {
|
||||
match group.as_slice() {
|
||||
&[a, b] => joints.push((a, b)),
|
||||
[_, _, ..] => {
|
||||
let scale = group.len() as f64;
|
||||
let mut at = Vec2::ZERO;
|
||||
let mut members: Vec<Handle> = Vec::new();
|
||||
for &e in group {
|
||||
let Some(end) = ends.get(e) else { continue };
|
||||
at += end.at / scale;
|
||||
let Some(piece) = pieces.get(end.piece) else { continue };
|
||||
if !members.contains(&piece.handle) {
|
||||
members.push(piece.handle);
|
||||
}
|
||||
if let Some(slot) = excluded.get_mut(end.piece) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
junctions.push(json!({
|
||||
"at": [at.x, at.y],
|
||||
"ends": group.len(),
|
||||
"members": members.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(joints, junctions)
|
||||
}
|
||||
|
||||
/// The link each end takes from the joints. A joint into an excluded piece is not a link, and
|
||||
/// a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop.
|
||||
fn joint_links(joints: Vec<(usize, usize)>, ends: &[End], pieces: &[Piece], excluded: &[bool]) -> Vec<Option<usize>> {
|
||||
let mut link: Vec<Option<usize>> = vec![None; ends.len()];
|
||||
for (a, b) in joints {
|
||||
let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue };
|
||||
if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) {
|
||||
continue;
|
||||
}
|
||||
if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = link.get_mut(a) {
|
||||
*slot = Some(b);
|
||||
}
|
||||
if let Some(slot) = link.get_mut(b) {
|
||||
*slot = Some(a);
|
||||
}
|
||||
}
|
||||
link
|
||||
}
|
||||
|
||||
fn empty_report(offset: usize, tol: f64, pair_tests: usize, pair_tests_needed: usize, pair_limit: usize, capped: bool, skipped: Skipped) -> Value {
|
||||
json!({
|
||||
"schema": 1,
|
||||
|
||||
+127
-130
@@ -1296,6 +1296,128 @@ fn run_chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------- explode / array / order / break / join ----------------
|
||||
|
||||
fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option<Vec<Entity>> {
|
||||
if let Some(items) = super::array::explode_array(d, e) {
|
||||
return Some(items);
|
||||
}
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
Some(
|
||||
blk.entities
|
||||
.iter()
|
||||
.map(|be| {
|
||||
let mut ne = (**be).clone();
|
||||
ne.kind.transform(&m);
|
||||
if ne.common.layer == "0" {
|
||||
ne.common.layer = e.common.layer.clone();
|
||||
}
|
||||
if ne.common.color == cadcraft_color::Color::ByBlock {
|
||||
ne.common.color = e.common.color;
|
||||
}
|
||||
ne
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn explode_mtext(e: &Entity, m: &cadcraft_doc::MText) -> Vec<Entity> {
|
||||
cadcraft_fonts::plain_mtext(&m.contents)
|
||||
.split('\n')
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64));
|
||||
Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: EntityKind::Text(cadcraft_doc::Text {
|
||||
insert: v3(at),
|
||||
align_pt: None,
|
||||
height: m.height,
|
||||
value: l.into(),
|
||||
rotation: m.rotation,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: m.style.clone(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The entities of a boundary's lines and arcs, on the object's layer and style.
|
||||
fn segment_entities(e: &Entity, segs: impl IntoIterator<Item = Segment>) -> Vec<Entity> {
|
||||
segs.into_iter()
|
||||
.map(|s| {
|
||||
let k = match s {
|
||||
Segment::Line(l) => line(l.a, l.b),
|
||||
Segment::Arc { arc: a, .. } => arc(&a),
|
||||
};
|
||||
Entity { handle: Handle(0), common: e.common.clone(), kind: k }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn explode_dimension(d: &cadcraft_doc::Drawing, e: &Entity, dm: &cadcraft_doc::Dimension) -> Vec<Entity> {
|
||||
// The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font.
|
||||
let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
|
||||
let g = cadcraft_render::dimension_in(d, dm);
|
||||
let line_entity = |l: &Vec<Vec2>| {
|
||||
if l.len() == 2 {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
|
||||
} else {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) })
|
||||
}
|
||||
};
|
||||
// The text's marks (arc length symbol, DIMINSPECT frame) go after the text background.
|
||||
let text_marks = &g.text_marks;
|
||||
let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l);
|
||||
let mut v: Vec<Entity> = marks(false).filter_map(&line_entity).collect();
|
||||
for t in &g.fills {
|
||||
if let [a, b, c] = t.as_slice() {
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }),
|
||||
});
|
||||
}
|
||||
}
|
||||
// DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and
|
||||
// arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3).
|
||||
if style.text_fill == 2
|
||||
&& let [p0, p1, p2, p3] = g.text_fill.as_slice()
|
||||
{
|
||||
let fill = style.text_fill_color;
|
||||
let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill };
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: cadcraft_doc::Common { color, ..e.common.clone() },
|
||||
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }),
|
||||
});
|
||||
}
|
||||
v.extend(marks(true).filter_map(&line_entity));
|
||||
if !g.value.is_empty() {
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: EntityKind::MText(cadcraft_doc::MText {
|
||||
insert: v3(g.text_pos),
|
||||
height: g.text_height,
|
||||
width: 0.0,
|
||||
attach: 5,
|
||||
rotation: g.text_angle,
|
||||
style: style.text_style.clone(),
|
||||
contents: g.mtext_contents(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}),
|
||||
});
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
// An annotative object explodes into plain objects as it draws at the current annotation scale.
|
||||
if cadcraft_doc::is_annotative(e) {
|
||||
@@ -1309,42 +1431,9 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
match &e.kind {
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
Some(
|
||||
pl.segments()
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let k = match s {
|
||||
Segment::Line(l) => line(l.a, l.b),
|
||||
Segment::Arc { arc: a, .. } => arc(&a),
|
||||
};
|
||||
Entity { handle: Handle(0), common: e.common.clone(), kind: k }
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
if let Some(items) = super::array::explode_array(d, e) {
|
||||
return Some(items);
|
||||
}
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
Some(
|
||||
blk.entities
|
||||
.iter()
|
||||
.map(|be| {
|
||||
let mut ne = (**be).clone();
|
||||
ne.kind.transform(&m);
|
||||
if ne.common.layer == "0" {
|
||||
ne.common.layer = e.common.layer.clone();
|
||||
}
|
||||
if ne.common.color == cadcraft_color::Color::ByBlock {
|
||||
ne.common.color = e.common.color;
|
||||
}
|
||||
ne
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
Some(segment_entities(e, pl.segments()))
|
||||
}
|
||||
EntityKind::Insert(ins) => explode_block_ref(d, e, ins),
|
||||
// A helix explodes into its spline (seen from above).
|
||||
EntityKind::Helix(h) => Some(vec![Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Spline(h.spline()) }]),
|
||||
EntityKind::Polyline3d(p) => Some(
|
||||
@@ -1357,102 +1446,10 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
.collect(),
|
||||
),
|
||||
EntityKind::MLine(m) => Some(super::draw2::mline::explode(e, m)),
|
||||
EntityKind::MText(m) => Some(
|
||||
cadcraft_fonts::plain_mtext(&m.contents)
|
||||
.split('\n')
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64));
|
||||
Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: EntityKind::Text(cadcraft_doc::Text {
|
||||
insert: v3(at),
|
||||
align_pt: None,
|
||||
height: m.height,
|
||||
value: l.into(),
|
||||
rotation: m.rotation,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: m.style.clone(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
EntityKind::MText(m) => Some(explode_mtext(e, m)),
|
||||
// A region explodes into its boundary's lines and arcs.
|
||||
EntityKind::Region(r) => Some(
|
||||
r.segments()
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let k = match s {
|
||||
Segment::Line(l) => line(l.a, l.b),
|
||||
Segment::Arc { arc: a, .. } => arc(&a),
|
||||
};
|
||||
Entity { handle: Handle(0), common: e.common.clone(), kind: k }
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
EntityKind::Dimension(dm) => {
|
||||
// The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font.
|
||||
let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
|
||||
let g = cadcraft_render::dimension_in(d, dm);
|
||||
let line_entity = |l: &Vec<Vec2>| {
|
||||
if l.len() == 2 {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
|
||||
} else {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) })
|
||||
}
|
||||
};
|
||||
// The text's marks (arc length symbol, DIMINSPECT frame) go after the text background.
|
||||
let text_marks = &g.text_marks;
|
||||
let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l);
|
||||
let mut v: Vec<Entity> = marks(false).filter_map(&line_entity).collect();
|
||||
for t in &g.fills {
|
||||
if let [a, b, c] = t.as_slice() {
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }),
|
||||
});
|
||||
}
|
||||
}
|
||||
// DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and
|
||||
// arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3).
|
||||
if style.text_fill == 2
|
||||
&& let [p0, p1, p2, p3] = g.text_fill.as_slice()
|
||||
{
|
||||
let fill = style.text_fill_color;
|
||||
let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill };
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: cadcraft_doc::Common { color, ..e.common.clone() },
|
||||
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }),
|
||||
});
|
||||
}
|
||||
v.extend(marks(true).filter_map(&line_entity));
|
||||
if !g.value.is_empty() {
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: EntityKind::MText(cadcraft_doc::MText {
|
||||
insert: v3(g.text_pos),
|
||||
height: g.text_height,
|
||||
width: 0.0,
|
||||
attach: 5,
|
||||
rotation: g.text_angle,
|
||||
style: style.text_style.clone(),
|
||||
contents: g.mtext_contents(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
EntityKind::Region(r) => Some(segment_entities(e, r.segments())),
|
||||
EntityKind::Dimension(dm) => Some(explode_dimension(d, e, dm)),
|
||||
EntityKind::Tolerance(t) => Some(super::tolerance::explode(d, e, t)),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -785,6 +785,48 @@ fn glob_cached(
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
/// For the bracket class opening at `pat[p]`, whether `text[t]` is in it: the index of its closing
|
||||
/// `]` when it is, `None` when it is not or the class is never closed.
|
||||
fn class_end(text: &[char], pat: &[char], p: usize, t: usize, budget: &mut u64) -> Result<Option<usize>, ()> {
|
||||
let mut close = p;
|
||||
while let Some(c) = pat.get(close) {
|
||||
charge_match(budget)?;
|
||||
if *c == ']' {
|
||||
break;
|
||||
}
|
||||
close += 1;
|
||||
}
|
||||
if close == pat.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(&c) = text.get(t) else { return Ok(None) };
|
||||
let set = &pat[p + 1..close];
|
||||
let (neg, set) = match set.split_first() {
|
||||
Some(('~', r)) if !r.is_empty() => (true, r),
|
||||
_ => (false, set),
|
||||
};
|
||||
let mut hit = false;
|
||||
let mut i = 0;
|
||||
while i < set.len() {
|
||||
charge_match(budget)?;
|
||||
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
|
||||
if set[i] <= c && c <= set[i + 2] {
|
||||
hit = true;
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
if set[i] == c {
|
||||
hit = true;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if hit == neg {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(close))
|
||||
}
|
||||
|
||||
/// Evaluate wildcard tokens and backtracking branches under shared work and recursion limits.
|
||||
fn glob_inner(
|
||||
text: &[char],
|
||||
@@ -822,42 +864,7 @@ fn glob_inner(
|
||||
p += 2;
|
||||
}
|
||||
'[' => {
|
||||
let mut close = p;
|
||||
while let Some(c) = pat.get(close) {
|
||||
charge_match(budget)?;
|
||||
if *c == ']' {
|
||||
break;
|
||||
}
|
||||
close += 1;
|
||||
}
|
||||
if close == pat.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(&c) = text.get(t) else { return Ok(false) };
|
||||
let set = &pat[p + 1..close];
|
||||
let (neg, set) = match set.split_first() {
|
||||
Some(('~', r)) if !r.is_empty() => (true, r),
|
||||
_ => (false, set),
|
||||
};
|
||||
let mut hit = false;
|
||||
let mut i = 0;
|
||||
while i < set.len() {
|
||||
charge_match(budget)?;
|
||||
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
|
||||
if set[i] <= c && c <= set[i + 2] {
|
||||
hit = true;
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
if set[i] == c {
|
||||
hit = true;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if hit == neg {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(close) = class_end(text, pat, p, t, budget)? else { return Ok(false) };
|
||||
t += 1;
|
||||
p = close + 1;
|
||||
}
|
||||
|
||||
+65
-38
@@ -236,6 +236,46 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V
|
||||
if !(r.is_finite() && r >= 0.0) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(win) = search_window(c1, c2, near) else { return Vec::new() };
|
||||
let size = win.width().max(win.height()).max(r).max(1e-9);
|
||||
let win = win.expand(2.0 * r + 0.25 * size);
|
||||
let tol = 1e-9 * size.max(1.0);
|
||||
let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() };
|
||||
let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] };
|
||||
let mut out: Vec<CornerFit> = Vec::new();
|
||||
let mut work = 16_000_000usize;
|
||||
for (s1, s2) in signs {
|
||||
let (d1, d2) = (s1 * r, s2 * r);
|
||||
let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2));
|
||||
let search = TanSearch { c1, r1: &r1, d1, c2, r2: &r2, d2, tol };
|
||||
// Refine each pair before visiting the next: raw duplicate seeds in an early run must
|
||||
// not consume all candidate slots. A separate request-wide budget bounds actual work.
|
||||
for a in &o1 {
|
||||
for b in &o2 {
|
||||
if !refine_pair(&search, a, b, &mut work, &mut out) {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The two curves of one tangent search for one sign choice: their ranges and offsets, and the
|
||||
/// tolerance.
|
||||
struct TanSearch<'a> {
|
||||
c1: &'a TanCurve,
|
||||
r1: &'a Range,
|
||||
d1: f64,
|
||||
c2: &'a TanCurve,
|
||||
r2: &'a Range,
|
||||
d2: f64,
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
/// The window the tangent search looks in: the pick points and the extents of the curves that
|
||||
/// are not lines. `None` when it is empty or not finite.
|
||||
fn search_window(c1: &TanCurve, c2: &TanCurve, near: &[Vec2]) -> Option<Bounds2> {
|
||||
let mut win = Bounds2::from_points(near.iter().copied().filter(|p| p.is_finite()));
|
||||
for c in [c1, c2] {
|
||||
match c {
|
||||
@@ -251,47 +291,34 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V
|
||||
}
|
||||
}
|
||||
if win.is_empty() || !(win.width().is_finite() && win.height().is_finite()) {
|
||||
return Vec::new();
|
||||
return None;
|
||||
}
|
||||
let size = win.width().max(win.height()).max(r).max(1e-9);
|
||||
let win = win.expand(2.0 * r + 0.25 * size);
|
||||
let tol = 1e-9 * size.max(1.0);
|
||||
let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() };
|
||||
let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] };
|
||||
let mut out: Vec<CornerFit> = Vec::new();
|
||||
let mut work = 16_000_000usize;
|
||||
for (s1, s2) in signs {
|
||||
let (d1, d2) = (s1 * r, s2 * r);
|
||||
let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2));
|
||||
// Refine each pair before visiting the next: raw duplicate seeds in an early run must
|
||||
// not consume all candidate slots. A separate request-wide budget bounds actual work.
|
||||
for a in &o1 {
|
||||
for b in &o2 {
|
||||
let mut seeds = Vec::new();
|
||||
crossings(a, b, &mut seeds, &mut work);
|
||||
seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)));
|
||||
seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9);
|
||||
for seed in seeds {
|
||||
// Newton refinement has a fixed iteration cap and several curve evaluations.
|
||||
let Some(left) = work.checked_sub(128) else { return out };
|
||||
work = left;
|
||||
let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue };
|
||||
let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue };
|
||||
let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3);
|
||||
if !out.iter().any(near_dup) {
|
||||
out.push(CornerFit { t1, t2, p1, p2, center });
|
||||
}
|
||||
if out.len() >= MAX_SOLUTIONS {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
if work == 0 {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
Some(win)
|
||||
}
|
||||
|
||||
/// Refine the crossings of the samples `a` and `b` into circles, adding the distinct ones to
|
||||
/// `out`. `false` when the work budget or the solution cap ends the search.
|
||||
fn refine_pair(search: &TanSearch, a: &[(f64, Vec2)], b: &[(f64, Vec2)], work: &mut usize, out: &mut Vec<CornerFit>) -> bool {
|
||||
let mut seeds = Vec::new();
|
||||
crossings(a, b, &mut seeds, work);
|
||||
seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)));
|
||||
seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9);
|
||||
for seed in seeds {
|
||||
// Newton refinement has a fixed iteration cap and several curve evaluations.
|
||||
let Some(left) = work.checked_sub(128) else { return false };
|
||||
*work = left;
|
||||
let TanSearch { c1, r1, d1, c2, r2, d2, tol } = *search;
|
||||
let Some((t1, t2)) = refine(c1, r1, d1, c2, r2, d2, seed, tol) else { continue };
|
||||
let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue };
|
||||
let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3);
|
||||
if !out.iter().any(near_dup) {
|
||||
out.push(CornerFit { t1, t2, p1, p2, center });
|
||||
}
|
||||
if out.len() >= MAX_SOLUTIONS {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
out
|
||||
*work != 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+40
-32
@@ -447,6 +447,44 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve,
|
||||
/// segment, parameter, point). `None` when the end touches a curve, or none is near. `Err` when
|
||||
/// the budget of segment checks runs out.
|
||||
fn nearest_curve(polys: &[Vec<Vec2>], i: usize, e: Vec2, snap: f64, tol: f64, budget: &mut usize) -> Result<Option<(usize, usize, f64, Vec2)>, ()> {
|
||||
let mut best: Option<(f64, usize, usize, f64, Vec2)> = None;
|
||||
let mut touching = false;
|
||||
for (j, other) in polys.iter().enumerate() {
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
for (k, w) in other.windows(2).enumerate() {
|
||||
if *budget == 0 {
|
||||
return Err(());
|
||||
}
|
||||
*budget -= 1;
|
||||
let [a, b] = w else { continue };
|
||||
if !(a.is_finite() && b.is_finite())
|
||||
|| e.x < a.x.min(b.x) - snap
|
||||
|| e.x > a.x.max(b.x) + snap
|
||||
|| e.y < a.y.min(b.y) - snap
|
||||
|| e.y > a.y.max(b.y) + snap
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let line = Line::new(*a, *b);
|
||||
let t = line.param_of(e).clamp(0.0, 1.0);
|
||||
let q = line.at(t);
|
||||
let d = q.dist(e);
|
||||
if d <= tol {
|
||||
touching = true;
|
||||
} else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) {
|
||||
best = Some((d, j, k, t, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(best.filter(|_| !touching).map(|(_, j, k, t, q)| (j, k, t, q)))
|
||||
}
|
||||
|
||||
/// The curves with each open end that stops within `snap` of another curve moved onto that
|
||||
/// curve (which gains a vertex there), so the two meet exactly. A curve that is flattened
|
||||
/// into chords (an ellipse, a spline) lies up to the flattening tolerance off the true curve,
|
||||
@@ -474,38 +512,8 @@ pub fn snap_ends(polys: &[Vec<Vec2>], snap: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
if !e.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let mut best: Option<(f64, usize, usize, f64, Vec2)> = None;
|
||||
let mut touching = false;
|
||||
for (j, other) in polys.iter().enumerate() {
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
for (k, w) in other.windows(2).enumerate() {
|
||||
if budget == 0 {
|
||||
break 'ends;
|
||||
}
|
||||
budget -= 1;
|
||||
let [a, b] = w else { continue };
|
||||
if !(a.is_finite() && b.is_finite())
|
||||
|| e.x < a.x.min(b.x) - snap
|
||||
|| e.x > a.x.max(b.x) + snap
|
||||
|| e.y < a.y.min(b.y) - snap
|
||||
|| e.y > a.y.max(b.y) + snap
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let line = Line::new(*a, *b);
|
||||
let t = line.param_of(e).clamp(0.0, 1.0);
|
||||
let q = line.at(t);
|
||||
let d = q.dist(e);
|
||||
if d <= tol {
|
||||
touching = true;
|
||||
} else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) {
|
||||
best = Some((d, j, k, t, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, j, k, t, q)) = best.filter(|_| !touching) else { continue };
|
||||
let Ok(near) = nearest_curve(polys, i, e, snap, tol, &mut budget) else { break 'ends };
|
||||
let Some((j, k, t, q)) = near else { continue };
|
||||
let Some(slot) = out.get_mut(i).and_then(|v| if at_end { v.last_mut() } else { v.first_mut() }) else { continue };
|
||||
*slot = q;
|
||||
let ends = polys.get(j).and_then(|o| o.get(k).zip(o.get(k + 1)));
|
||||
|
||||
+396
-326
@@ -249,6 +249,130 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
||||
}
|
||||
|
||||
/// Parse one entity while charging materialized table cells to the caller's shared import budget.
|
||||
/// An ARC, with its angles run the other way in a mirrored OCS.
|
||||
fn arc(c: &Common, t: &T) -> Arc {
|
||||
let mut a = Arc { center: ocs(c, t.p(10)), radius: t.fd(40, 1.0).abs(), start: t.fd(50, 0.0).to_radians(), end: t.fd(51, 360.0).to_radians() };
|
||||
if c.extrusion.z < 0.0 {
|
||||
// Mirrored OCS: angles run the other way.
|
||||
let (s, e) = (std::f64::consts::PI - a.end, std::f64::consts::PI - a.start);
|
||||
a.start = cadcraft_geom::norm_angle(s);
|
||||
a.end = cadcraft_geom::norm_angle(e);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn ellipse(c: &Common, t: &T) -> Ellipse {
|
||||
let mut e = Ellipse {
|
||||
center: t.p(10),
|
||||
major: t.p(11),
|
||||
ratio: t.fd(40, 1.0).clamp(1e-9, 1.0),
|
||||
start: t.fd(41, 0.0),
|
||||
end: t.fd(42, std::f64::consts::TAU),
|
||||
};
|
||||
if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 {
|
||||
// Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1)
|
||||
// it points the other way, so parameter t lands where -t does in the XY plane. A full
|
||||
// ellipse is symmetric and stays as it is.
|
||||
let (s, e2) = (-e.end, -e.start);
|
||||
e.start = cadcraft_geom::norm_angle(s);
|
||||
e.end = cadcraft_geom::norm_angle(e2);
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// A LWPOLYLINE, or the region CADCraft wrote as one.
|
||||
fn lwpolyline(tags: &[Tag], c: &Common, t: &T) -> Option<EntityKind> {
|
||||
let mut verts = Vec::new();
|
||||
let mut cur: Option<PolyVertex> = None;
|
||||
for tg in tags {
|
||||
match tg.code {
|
||||
10 => {
|
||||
if let Some(v) = cur.take() {
|
||||
verts.push(v);
|
||||
}
|
||||
cur = Some(PolyVertex { p: Vec2::new(tg.f64(), 0.0), ..Default::default() });
|
||||
}
|
||||
20 => {
|
||||
if let Some(v) = cur.as_mut() {
|
||||
v.p.y = tg.f64();
|
||||
}
|
||||
}
|
||||
40 => {
|
||||
if let Some(v) = cur.as_mut() {
|
||||
v.start_width = tg.f64();
|
||||
}
|
||||
}
|
||||
41 => {
|
||||
if let Some(v) = cur.as_mut() {
|
||||
v.end_width = tg.f64();
|
||||
}
|
||||
}
|
||||
42 => {
|
||||
if let Some(v) = cur.as_mut() {
|
||||
v.bulge = tg.f64();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
verts.extend(cur);
|
||||
let flags = t.i(70).unwrap_or(0);
|
||||
if c.extrusion.z < 0.0 {
|
||||
for v in &mut verts {
|
||||
v.p.x = -v.p.x;
|
||||
v.bulge = -v.bulge;
|
||||
}
|
||||
}
|
||||
let pl = LwPolyline { vertices: verts, closed: flags & 1 != 0, const_width: t.fd(43, 0.0), elevation: t.fd(38, 0.0), plinegen: flags & 128 != 0 };
|
||||
// A region CADCraft wrote as polylines (`dxf_region`).
|
||||
crate::dxf_region::from_polyline(tags, pl)
|
||||
}
|
||||
|
||||
fn spline(t: &T) -> Spline {
|
||||
let degree = t.i(71).unwrap_or(3).clamp(1, 10) as usize;
|
||||
let knots = t.all_f(40);
|
||||
let control = t.pts(10);
|
||||
let weights = t.all_f(41);
|
||||
let fit = t.pts(11);
|
||||
let tangent = |c: i32| t.f(c).map(|_| t.p(c).xy());
|
||||
let fit_opts =
|
||||
cadcraft_geom::FitOptions { knots: Default::default(), start_tangent: tangent(12), end_tangent: tangent(13), tolerance: t.fd(44, 0.0) }
|
||||
.sanitized();
|
||||
let closed = t.i(70).unwrap_or(0) & 1 != 0;
|
||||
let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed, fit_opts };
|
||||
if !sp.is_valid() && fit.len() >= 2 {
|
||||
sp = Spline::fit_with(&fit, sp.closed, fit_opts);
|
||||
} else if !sp.is_valid() && sp.control.len() >= 2 {
|
||||
sp = Spline::from_control(sp.control.clone(), degree);
|
||||
} else if fit.len() >= 3 {
|
||||
// DXF keeps the knots, not how they were spaced: recover it for later refits.
|
||||
sp.fit_opts.knots = sp.infer_knot_param();
|
||||
}
|
||||
sp
|
||||
}
|
||||
|
||||
/// An ATTDEF, whose multiline text is embedded MTEXT after its own groups.
|
||||
fn attdef(tags: &[Tag], c: &Common) -> Attrib {
|
||||
// A multiline definition has its text as embedded MTEXT after its own groups.
|
||||
let (own, embedded) = split_embedded(tags);
|
||||
let multiline = multiline_attrib(embedded, c);
|
||||
let own = T(own);
|
||||
let mut text = text_from(&own);
|
||||
if let Some(m) = &multiline {
|
||||
text.value.clone_from(&m.read_as);
|
||||
}
|
||||
Attrib {
|
||||
tag: own.s(2).unwrap_or_default(),
|
||||
text,
|
||||
invisible: own.i(70).unwrap_or(0) & 1 != 0,
|
||||
constant: own.i(70).unwrap_or(0) & 2 != 0,
|
||||
prompt: own.s(3).unwrap_or_default(),
|
||||
props: Default::default(),
|
||||
multiline,
|
||||
modes: attrib_modes(own.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usize) -> Option<(Common, EntityKind)> {
|
||||
// The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit.
|
||||
if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) {
|
||||
@@ -260,138 +384,16 @@ fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usi
|
||||
"LINE" => EntityKind::Line(Line { a: t.p(10), b: t.p(11) }),
|
||||
"POINT" => EntityKind::Point(Point { p: t.p(10), angle: t.fd(50, 0.0).to_radians() }),
|
||||
"CIRCLE" => EntityKind::Circle(Circle { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs() }),
|
||||
"ARC" => {
|
||||
let mut a =
|
||||
Arc { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs(), start: t.fd(50, 0.0).to_radians(), end: t.fd(51, 360.0).to_radians() };
|
||||
if c.extrusion.z < 0.0 {
|
||||
// Mirrored OCS: angles run the other way.
|
||||
let (s, e) = (std::f64::consts::PI - a.end, std::f64::consts::PI - a.start);
|
||||
a.start = cadcraft_geom::norm_angle(s);
|
||||
a.end = cadcraft_geom::norm_angle(e);
|
||||
}
|
||||
EntityKind::Arc(a)
|
||||
}
|
||||
"ELLIPSE" => {
|
||||
let mut e = Ellipse {
|
||||
center: t.p(10),
|
||||
major: t.p(11),
|
||||
ratio: t.fd(40, 1.0).clamp(1e-9, 1.0),
|
||||
start: t.fd(41, 0.0),
|
||||
end: t.fd(42, std::f64::consts::TAU),
|
||||
};
|
||||
if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 {
|
||||
// Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1)
|
||||
// it points the other way, so parameter t lands where -t does in the XY plane. A full
|
||||
// ellipse is symmetric and stays as it is.
|
||||
let (s, e2) = (-e.end, -e.start);
|
||||
e.start = cadcraft_geom::norm_angle(s);
|
||||
e.end = cadcraft_geom::norm_angle(e2);
|
||||
}
|
||||
EntityKind::Ellipse(e)
|
||||
}
|
||||
"LWPOLYLINE" => {
|
||||
let mut verts = Vec::new();
|
||||
let mut cur: Option<PolyVertex> = None;
|
||||
for tg in tags {
|
||||
match tg.code {
|
||||
10 => {
|
||||
if let Some(v) = cur.take() {
|
||||
verts.push(v);
|
||||
}
|
||||
cur = Some(PolyVertex { p: Vec2::new(tg.f64(), 0.0), ..Default::default() });
|
||||
}
|
||||
20 => {
|
||||
if let Some(v) = cur.as_mut() {
|
||||
v.p.y = tg.f64();
|
||||
}
|
||||
}
|
||||
40 => {
|
||||
if let Some(v) = cur.as_mut() {
|
||||
v.start_width = tg.f64();
|
||||
}
|
||||
}
|
||||
41 => {
|
||||
if let Some(v) = cur.as_mut() {
|
||||
v.end_width = tg.f64();
|
||||
}
|
||||
}
|
||||
42 => {
|
||||
if let Some(v) = cur.as_mut() {
|
||||
v.bulge = tg.f64();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
verts.extend(cur);
|
||||
let flags = t.i(70).unwrap_or(0);
|
||||
if c.extrusion.z < 0.0 {
|
||||
for v in &mut verts {
|
||||
v.p.x = -v.p.x;
|
||||
v.bulge = -v.bulge;
|
||||
}
|
||||
}
|
||||
let pl = LwPolyline {
|
||||
vertices: verts,
|
||||
closed: flags & 1 != 0,
|
||||
const_width: t.fd(43, 0.0),
|
||||
elevation: t.fd(38, 0.0),
|
||||
plinegen: flags & 128 != 0,
|
||||
};
|
||||
// A region CADCraft wrote as polylines (`dxf_region`).
|
||||
crate::dxf_region::from_polyline(tags, pl)?
|
||||
}
|
||||
"SPLINE" => {
|
||||
let degree = t.i(71).unwrap_or(3).clamp(1, 10) as usize;
|
||||
let knots = t.all_f(40);
|
||||
let control = t.pts(10);
|
||||
let weights = t.all_f(41);
|
||||
let fit = t.pts(11);
|
||||
let tangent = |c: i32| t.f(c).map(|_| t.p(c).xy());
|
||||
let fit_opts = cadcraft_geom::FitOptions {
|
||||
knots: Default::default(),
|
||||
start_tangent: tangent(12),
|
||||
end_tangent: tangent(13),
|
||||
tolerance: t.fd(44, 0.0),
|
||||
}
|
||||
.sanitized();
|
||||
let closed = t.i(70).unwrap_or(0) & 1 != 0;
|
||||
let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed, fit_opts };
|
||||
if !sp.is_valid() && fit.len() >= 2 {
|
||||
sp = Spline::fit_with(&fit, sp.closed, fit_opts);
|
||||
} else if !sp.is_valid() && sp.control.len() >= 2 {
|
||||
sp = Spline::from_control(sp.control.clone(), degree);
|
||||
} else if fit.len() >= 3 {
|
||||
// DXF keeps the knots, not how they were spaced: recover it for later refits.
|
||||
sp.fit_opts.knots = sp.infer_knot_param();
|
||||
}
|
||||
EntityKind::Spline(sp)
|
||||
}
|
||||
"ARC" => EntityKind::Arc(arc(&c, &t)),
|
||||
"ELLIPSE" => EntityKind::Ellipse(ellipse(&c, &t)),
|
||||
"LWPOLYLINE" => lwpolyline(tags, &c, &t)?,
|
||||
"SPLINE" => EntityKind::Spline(spline(&t)),
|
||||
"HELIX" => EntityKind::Helix(helix(tags)),
|
||||
"RAY" => EntityKind::Ray(RayLine { base: t.p(10), dir: t.p(11) }),
|
||||
"XLINE" => EntityKind::XLine(RayLine { base: t.p(10), dir: t.p(11) }),
|
||||
"TEXT" => EntityKind::Text(text_from(&t)),
|
||||
"MTEXT" => EntityKind::MText(mtext_from(tags, &c)),
|
||||
"ATTDEF" => {
|
||||
// A multiline definition has its text as embedded MTEXT after its own groups.
|
||||
let (own, embedded) = split_embedded(tags);
|
||||
let multiline = multiline_attrib(embedded, &c);
|
||||
let own = T(own);
|
||||
let mut text = text_from(&own);
|
||||
if let Some(m) = &multiline {
|
||||
text.value.clone_from(&m.read_as);
|
||||
}
|
||||
EntityKind::AttDef(Attrib {
|
||||
tag: own.s(2).unwrap_or_default(),
|
||||
text,
|
||||
invisible: own.i(70).unwrap_or(0) & 1 != 0,
|
||||
constant: own.i(70).unwrap_or(0) & 2 != 0,
|
||||
prompt: own.s(3).unwrap_or_default(),
|
||||
props: Default::default(),
|
||||
multiline,
|
||||
modes: attrib_modes(own.0),
|
||||
})
|
||||
}
|
||||
"ATTDEF" => EntityKind::AttDef(attdef(tags, &c)),
|
||||
"INSERT" => {
|
||||
// A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale,
|
||||
// rotation and column spacing are in that OCS, where x runs the other way. In the WCS that
|
||||
@@ -1109,6 +1111,91 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m
|
||||
|
||||
/// Parse a run of entity records (ENTITIES section or a block's body) into entities. Records that
|
||||
/// can't be placed are counted in `skipped`.
|
||||
/// The entity of a POLYLINE, with its mesh faces (advancing `i` past its VERTEX records). `None`
|
||||
/// for a mesh without faces.
|
||||
fn polyline_entity(recs: &[(String, Vec<Tag>)], i: &mut usize, t: &T) -> Option<(Common, EntityKind, Vec<EntityKind>)> {
|
||||
let flags = t.i(70).unwrap_or(0);
|
||||
let mut pts: Vec<(Vec3, f64, f64, f64)> = Vec::new();
|
||||
// Polyface face records (VERTEX 70 bit 128 without 64): vertex indices 71–74.
|
||||
let mut faces: Vec<[i64; 4]> = Vec::new();
|
||||
while let Some((k2, t2)) = recs.get(*i) {
|
||||
if k2 == "VERTEX" {
|
||||
let tt = T(t2);
|
||||
let vflags = tt.i(70).unwrap_or(0);
|
||||
if vflags & 128 != 0 && vflags & 64 == 0 {
|
||||
faces.push([71, 72, 73, 74].map(|c| tt.i(c).unwrap_or(0)));
|
||||
} else if vflags & 16 == 0 {
|
||||
pts.push((tt.p(10), tt.fd(42, 0.0), tt.fd(40, 0.0), tt.fd(41, 0.0)));
|
||||
}
|
||||
*i += 1;
|
||||
} else {
|
||||
if k2 == "SEQEND" {
|
||||
*i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let c = common(t);
|
||||
let mut extra: Vec<EntityKind> = Vec::new();
|
||||
let k = if flags & (16 | 64) != 0 {
|
||||
// Polyface (64) and polygon (16) meshes become 3D faces; the first takes the
|
||||
// polyline's place.
|
||||
let verts: Vec<Vec3> = pts.iter().map(|p| p.0).collect();
|
||||
let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, t, flags) }.into_iter().map(EntityKind::Face3d);
|
||||
let first = fs.next()?;
|
||||
extra.extend(fs);
|
||||
first
|
||||
} else if flags & 8 != 0 {
|
||||
EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 })
|
||||
} else {
|
||||
// A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE.
|
||||
let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 };
|
||||
EntityKind::LwPolyline(LwPolyline {
|
||||
vertices: pts.iter().map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 }).collect(),
|
||||
closed: flags & 1 != 0,
|
||||
const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0),
|
||||
elevation: t.p(10).z,
|
||||
plinegen: flags & 128 != 0,
|
||||
})
|
||||
};
|
||||
Some((c, k, extra))
|
||||
}
|
||||
|
||||
/// The ATTRIB records after an INSERT, advancing `i` past them and their SEQEND.
|
||||
fn insert_attribs(recs: &[(String, Vec<Tag>)], i: &mut usize, ins_layer: &str) -> Vec<Attrib> {
|
||||
let mut attribs = Vec::new();
|
||||
while let Some((k2, t2)) = recs.get(*i) {
|
||||
if k2 == "ATTRIB" {
|
||||
let tt = T(t2);
|
||||
// A multiline attribute has its text as embedded MTEXT after its own groups.
|
||||
let (own, embedded) = split_embedded(t2);
|
||||
let multiline = multiline_attrib(embedded, &common(&tt));
|
||||
let own = T(own);
|
||||
let mut text = text_from(&own);
|
||||
if let Some(m) = &multiline {
|
||||
text.value.clone_from(&m.read_as);
|
||||
}
|
||||
attribs.push(Attrib {
|
||||
tag: own.s(2).unwrap_or_default(),
|
||||
text,
|
||||
invisible: own.i(70).unwrap_or(0) & 1 != 0,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: attrib_props(&tt, ins_layer),
|
||||
multiline,
|
||||
modes: attrib_modes(own.0),
|
||||
});
|
||||
*i += 1;
|
||||
} else {
|
||||
if k2 == "SEQEND" {
|
||||
*i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
attribs
|
||||
}
|
||||
|
||||
fn parse_entities(
|
||||
recs: &[(String, Vec<Tag>)],
|
||||
d: &mut Drawing,
|
||||
@@ -1134,92 +1221,17 @@ fn parse_entities(
|
||||
i += 1;
|
||||
match kind.as_str() {
|
||||
"POLYLINE" => {
|
||||
let flags = t.i(70).unwrap_or(0);
|
||||
let mut pts: Vec<(Vec3, f64, f64, f64)> = Vec::new();
|
||||
// Polyface face records (VERTEX 70 bit 128 without 64): vertex indices 71–74.
|
||||
let mut faces: Vec<[i64; 4]> = Vec::new();
|
||||
while let Some((k2, t2)) = recs.get(i) {
|
||||
if k2 == "VERTEX" {
|
||||
let tt = T(t2);
|
||||
let vflags = tt.i(70).unwrap_or(0);
|
||||
if vflags & 128 != 0 && vflags & 64 == 0 {
|
||||
faces.push([71, 72, 73, 74].map(|c| tt.i(c).unwrap_or(0)));
|
||||
} else if vflags & 16 == 0 {
|
||||
pts.push((tt.p(10), tt.fd(42, 0.0), tt.fd(40, 0.0), tt.fd(41, 0.0)));
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
if k2 == "SEQEND" {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let c = common(&t);
|
||||
let k = if flags & (16 | 64) != 0 {
|
||||
// Polyface (64) and polygon (16) meshes become 3D faces; the first takes the
|
||||
// polyline's place.
|
||||
let verts: Vec<Vec3> = pts.iter().map(|p| p.0).collect();
|
||||
let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, &t, flags) }
|
||||
.into_iter()
|
||||
.map(EntityKind::Face3d);
|
||||
let Some(first) = fs.next() else {
|
||||
*skipped = skipped.saturating_add(1);
|
||||
continue;
|
||||
};
|
||||
extra.extend(fs);
|
||||
first
|
||||
} else if flags & 8 != 0 {
|
||||
EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 })
|
||||
} else {
|
||||
// A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE.
|
||||
let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 };
|
||||
EntityKind::LwPolyline(LwPolyline {
|
||||
vertices: pts
|
||||
.iter()
|
||||
.map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 })
|
||||
.collect(),
|
||||
closed: flags & 1 != 0,
|
||||
const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0),
|
||||
elevation: t.p(10).z,
|
||||
plinegen: flags & 128 != 0,
|
||||
})
|
||||
let Some((c, k, more)) = polyline_entity(recs, &mut i, &t) else {
|
||||
*skipped = skipped.saturating_add(1);
|
||||
continue;
|
||||
};
|
||||
extra = more;
|
||||
kindent = Some((c, k));
|
||||
}
|
||||
"INSERT" => {
|
||||
if t.i(66) == Some(1) {
|
||||
let mut attribs = Vec::new();
|
||||
let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default();
|
||||
while let Some((k2, t2)) = recs.get(i) {
|
||||
if k2 == "ATTRIB" {
|
||||
let tt = T(t2);
|
||||
// A multiline attribute has its text as embedded MTEXT after its own groups.
|
||||
let (own, embedded) = split_embedded(t2);
|
||||
let multiline = multiline_attrib(embedded, &common(&tt));
|
||||
let own = T(own);
|
||||
let mut text = text_from(&own);
|
||||
if let Some(m) = &multiline {
|
||||
text.value.clone_from(&m.read_as);
|
||||
}
|
||||
attribs.push(Attrib {
|
||||
tag: own.s(2).unwrap_or_default(),
|
||||
text,
|
||||
invisible: own.i(70).unwrap_or(0) & 1 != 0,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: attrib_props(&tt, &ins_layer),
|
||||
multiline,
|
||||
modes: attrib_modes(own.0),
|
||||
});
|
||||
i += 1;
|
||||
} else {
|
||||
if k2 == "SEQEND" {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let attribs = insert_attribs(recs, &mut i, &ins_layer);
|
||||
if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() {
|
||||
ins.attribs = attribs;
|
||||
}
|
||||
@@ -1688,6 +1700,13 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
read_budgeted(bytes, 4_000_000)
|
||||
}
|
||||
|
||||
/// A block definition read from BLOCKS: name, base point, description, entities (owner, paper
|
||||
/// flag, entity) and block flags.
|
||||
type BlockDef = (String, Vec3, String, Vec<(Option<String>, bool, Entity)>, i64);
|
||||
|
||||
/// A layout read from OBJECTS: name, tab order, paper block handle and page setup.
|
||||
type LayoutDef = (String, u32, String, PageSetup);
|
||||
|
||||
/// Import DXF sections while sharing one materialized-table-cell budget across blocks and entity sections.
|
||||
fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?;
|
||||
@@ -1700,8 +1719,8 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
// A file without $INSUNITS declares no units (#1209); only new drawings take the template's.
|
||||
d.header.set_i64("INSUNITS", 0);
|
||||
let mut rx = Rx { pstyles: plot_style_names(&secs), ..Rx::default() };
|
||||
let mut blocks: Vec<(String, Vec3, String, Vec<(Option<String>, bool, Entity)>, i64)> = Vec::new();
|
||||
let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new();
|
||||
let mut blocks: Vec<BlockDef> = Vec::new();
|
||||
let mut layout_objs: Vec<LayoutDef> = Vec::new();
|
||||
let mut entities = Vec::new();
|
||||
let mut objs = Objects::default();
|
||||
// Every handle another program's file already uses is taken before anything gets a new one:
|
||||
@@ -1721,73 +1740,109 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
match s.name.as_str() {
|
||||
"HEADER" => header(&s.tags, &mut d),
|
||||
"TABLES" => tables(&s.tags, &mut d, &mut rx),
|
||||
"BLOCKS" => {
|
||||
let recs = records(&s.tags);
|
||||
for (k, tg) in &recs {
|
||||
let t = T(tg);
|
||||
if k == "BLOCK" && t.i(70).unwrap_or(0) & 4 != 0 {
|
||||
rx.xref_paths.insert(t.s(2).unwrap_or_default().to_ascii_uppercase(), t.s(1).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
let mut n = 0;
|
||||
let mut i = 0;
|
||||
while let Some((k, tg)) = recs.get(i) {
|
||||
if k == "BLOCK" {
|
||||
let t = T(tg);
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
let base = t.p(10);
|
||||
let description = t.s(4).unwrap_or_default();
|
||||
// Block flags: 4 xref, 8 overlay.
|
||||
let xref = t.i(70).unwrap_or(0) & 12;
|
||||
let mut j = i + 1;
|
||||
while recs.get(j).is_some_and(|(k, _)| k != "ENDBLK") {
|
||||
j += 1;
|
||||
}
|
||||
let body = recs.get(i + 1..j).unwrap_or(&[]);
|
||||
let ents = parse_entities(body, &mut d, &mut rx, &mut n, &mut table_cells_left);
|
||||
blocks.push((name, base, description, ents, xref));
|
||||
i = j + 1;
|
||||
} else {
|
||||
// A record outside any BLOCK ... ENDBLK pair.
|
||||
if k != "ENDBLK" {
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
count_skipped(&mut skipped, "BLOCKS", n);
|
||||
}
|
||||
"BLOCKS" => blocks.extend(read_blocks(&s.tags, &mut d, &mut rx, &mut table_cells_left, &mut skipped)),
|
||||
// A damaged file may split its entities over several ENTITIES sections (#865).
|
||||
"ENTITIES" => {
|
||||
let mut n = 0;
|
||||
entities.extend(parse_entities(&records(&s.tags), &mut d, &mut rx, &mut n, &mut table_cells_left));
|
||||
count_skipped(&mut skipped, "ENTITIES", n);
|
||||
}
|
||||
"OBJECTS" => {
|
||||
for (k, tg) in records(&s.tags) {
|
||||
objs.add(&k, &tg);
|
||||
if k == "LAYOUT" {
|
||||
// Fields after the AcDbLayout marker (AcDbPlotSettings also has a code 1).
|
||||
let start = tg.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0);
|
||||
let tail = tg.get(start..).unwrap_or(&[]);
|
||||
let t = T(tail);
|
||||
// LAYOUT has two 330s: owner dict and the block record; take the last.
|
||||
let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default();
|
||||
let plot = T(tg.get(..start).unwrap_or(&[]));
|
||||
layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot)));
|
||||
}
|
||||
}
|
||||
}
|
||||
"OBJECTS" => read_objects(&s.tags, &mut objs, &mut layout_objs),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A file CADCraft wrote names its own next handle (#495): its table records and objects
|
||||
// are numbered above it and rebuilt on save, so following $HANDSEED would drift every save.
|
||||
let own_seed = objs.xrecord_text(crate::dxf_ext::HANDSEED_KEY).and_then(|t| Handle::parse_hex(t.trim()));
|
||||
// Layouts.
|
||||
let (paper_block_for, first_layout) = setup_layouts(&mut d, &mut layout_objs, &rx);
|
||||
place_blocks(&mut d, blocks, &mut rx, &paper_block_for, &first_layout);
|
||||
place_entities(&mut d, entities, &rx, &paper_block_for, &first_layout);
|
||||
apply_sortents(&mut d, &objs.sortents, &rx.brs, &paper_block_for, &first_layout);
|
||||
objs.apply(&mut d, &rx);
|
||||
crate::dxf_image::resolve(&secs, &mut d);
|
||||
crate::dxf_mline::resolve(&secs, &mut d);
|
||||
// Keep handles unique against the header's seed.
|
||||
let seed = d.header.str("HANDSEED", "");
|
||||
if let Some(h) = own_seed.or_else(|| Handle::parse_hex(&seed)) {
|
||||
d.bump_handseed(Handle(h.0.saturating_sub(1)));
|
||||
}
|
||||
if d.layer("0").is_none() {
|
||||
d.layers.insert(0, Layer::default());
|
||||
}
|
||||
crate::dxf_raw::read(&secs, &mut d);
|
||||
d.skipped_records = skipped;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Read the BLOCKS section: the xref paths of external blocks, then each block's entities.
|
||||
fn read_blocks(
|
||||
tags: &[Tag],
|
||||
d: &mut Drawing,
|
||||
rx: &mut Rx,
|
||||
table_cells_left: &mut usize,
|
||||
skipped: &mut std::collections::BTreeMap<String, usize>,
|
||||
) -> Vec<BlockDef> {
|
||||
let recs = records(tags);
|
||||
for (k, tg) in &recs {
|
||||
let t = T(tg);
|
||||
if k == "BLOCK" && t.i(70).unwrap_or(0) & 4 != 0 {
|
||||
rx.xref_paths.insert(t.s(2).unwrap_or_default().to_ascii_uppercase(), t.s(1).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
let mut blocks = Vec::new();
|
||||
let mut n = 0;
|
||||
let mut i = 0;
|
||||
while let Some((k, tg)) = recs.get(i) {
|
||||
if k == "BLOCK" {
|
||||
let t = T(tg);
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
let base = t.p(10);
|
||||
let description = t.s(4).unwrap_or_default();
|
||||
// Block flags: 4 xref, 8 overlay.
|
||||
let xref = t.i(70).unwrap_or(0) & 12;
|
||||
let mut j = i + 1;
|
||||
while recs.get(j).is_some_and(|(k, _)| k != "ENDBLK") {
|
||||
j += 1;
|
||||
}
|
||||
let body = recs.get(i + 1..j).unwrap_or(&[]);
|
||||
let ents = parse_entities(body, d, rx, &mut n, table_cells_left);
|
||||
blocks.push((name, base, description, ents, xref));
|
||||
i = j + 1;
|
||||
} else {
|
||||
// A record outside any BLOCK ... ENDBLK pair.
|
||||
if k != "ENDBLK" {
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
count_skipped(skipped, "BLOCKS", n);
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Read the OBJECTS section into `objs`, and each LAYOUT into `layout_objs`.
|
||||
fn read_objects(tags: &[Tag], objs: &mut Objects, layout_objs: &mut Vec<LayoutDef>) {
|
||||
for (k, tg) in records(tags) {
|
||||
objs.add(&k, &tg);
|
||||
if k == "LAYOUT" {
|
||||
// Fields after the AcDbLayout marker (AcDbPlotSettings also has a code 1).
|
||||
let start = tg.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0);
|
||||
let tail = tg.get(start..).unwrap_or(&[]);
|
||||
let t = T(tail);
|
||||
// LAYOUT has two 330s: owner dict and the block record; take the last.
|
||||
let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default();
|
||||
let plot = T(tg.get(..start).unwrap_or(&[]));
|
||||
layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the drawing's layouts, and return the layout each paper-space block belongs to, and the
|
||||
/// layout that takes paper-space entities without an owner.
|
||||
fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx: &Rx) -> (HashMap<String, String>, String) {
|
||||
let mut paper_block_for: HashMap<String, String> = HashMap::new();
|
||||
layout_objs.sort_by_key(|l| l.1);
|
||||
for (name, order, br, page) in &layout_objs {
|
||||
for (name, order, br, page) in layout_objs.iter() {
|
||||
if name.eq_ignore_ascii_case("Model") {
|
||||
continue;
|
||||
}
|
||||
@@ -1806,7 +1861,12 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
let first_tab = d.layouts.iter().min_by_key(|l| l.tab_order).map(|l| l.name.clone());
|
||||
d.active_layout = active.clone().filter(|a| Some(a) != first_tab.as_ref());
|
||||
let first_layout = active.or_else(|| d.layouts.first().map(|l| l.name.clone())).unwrap_or_else(|| "Layout1".into());
|
||||
// Blocks: model/paper space blocks hold entities in newer files.
|
||||
(paper_block_for, first_layout)
|
||||
}
|
||||
|
||||
/// Place the BLOCKS entities: model and paper space blocks go to their space, the rest become
|
||||
/// block definitions.
|
||||
fn place_blocks(d: &mut Drawing, blocks: Vec<BlockDef>, rx: &mut Rx, paper_block_for: &HashMap<String, String>, first_layout: &str) {
|
||||
for (name, base, description, ents, xref) in blocks {
|
||||
let up = name.to_ascii_uppercase();
|
||||
if up == "*MODEL_SPACE" {
|
||||
@@ -1817,7 +1877,7 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
continue;
|
||||
}
|
||||
if up.starts_with("*PAPER_SPACE") {
|
||||
let lname = paper_block_for.get(&up).cloned().unwrap_or_else(|| first_layout.clone());
|
||||
let lname = paper_block_for.get(&up).cloned().unwrap_or_else(|| first_layout.to_string());
|
||||
if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) {
|
||||
for (_, _, e) in ents {
|
||||
l.entities.push(e);
|
||||
@@ -1857,13 +1917,22 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
}
|
||||
d.blocks.insert(name, std::sync::Arc::new(b));
|
||||
}
|
||||
// ENTITIES section: model space, or paper space when flagged (67=1).
|
||||
}
|
||||
|
||||
/// Place the ENTITIES section: model space, or paper space when flagged (67=1).
|
||||
fn place_entities(
|
||||
d: &mut Drawing,
|
||||
entities: Vec<(Option<String>, bool, Entity)>,
|
||||
rx: &Rx,
|
||||
paper_block_for: &HashMap<String, String>,
|
||||
first_layout: &str,
|
||||
) {
|
||||
for (owner, paper, e) in entities {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase());
|
||||
let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE"));
|
||||
if to_paper {
|
||||
let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.clone());
|
||||
let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.to_string());
|
||||
if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) {
|
||||
l.entities.push(e);
|
||||
}
|
||||
@@ -1871,21 +1940,6 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
d.model.push(e);
|
||||
}
|
||||
}
|
||||
apply_sortents(&mut d, &objs.sortents, &rx.brs, &paper_block_for, &first_layout);
|
||||
objs.apply(&mut d, &rx);
|
||||
crate::dxf_image::resolve(&secs, &mut d);
|
||||
crate::dxf_mline::resolve(&secs, &mut d);
|
||||
// Keep handles unique against the header's seed.
|
||||
let seed = d.header.str("HANDSEED", "");
|
||||
if let Some(h) = own_seed.or_else(|| Handle::parse_hex(&seed)) {
|
||||
d.bump_handseed(Handle(h.0.saturating_sub(1)));
|
||||
}
|
||||
if d.layer("0").is_none() {
|
||||
d.layers.insert(0, Layer::default());
|
||||
}
|
||||
crate::dxf_raw::read(&secs, &mut d);
|
||||
d.skipped_records = skipped;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn count_skipped(skipped: &mut std::collections::BTreeMap<String, usize>, section: &str, n: usize) {
|
||||
@@ -2054,13 +2108,7 @@ impl Objects {
|
||||
Some(text)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
fn apply_table_styles(&self, d: &mut Drawing) {
|
||||
// Table styles (named by their ACAD_TABLESTYLE dictionary entries).
|
||||
for (h, tags) in &self.table_styles {
|
||||
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };
|
||||
@@ -2070,6 +2118,9 @@ impl Objects {
|
||||
None => d.table_styles.push(st),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_table_style_fixes(&self, d: &mut Drawing, rx: &Rx) {
|
||||
for (h, sh) in &rx.table_style_fix {
|
||||
let name = self.names.get(sh).cloned();
|
||||
// A table from another writer has no scale of its own: its text height is its
|
||||
@@ -2090,6 +2141,61 @@ impl Objects {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_mleader_styles(&self, d: &mut Drawing, rx: &Rx) {
|
||||
// Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries).
|
||||
for (h, tags) in &self.mleader_styles {
|
||||
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };
|
||||
let t = T(tags);
|
||||
let def = MLeaderStyle::default();
|
||||
let num = |code: i32, d: f64, min: f64| t.f(code).filter(|v| v.is_finite() && *v >= min).unwrap_or(d);
|
||||
let text_style = t.s(342).and_then(|sh| rx.styles.get(&sh.trim().to_ascii_uppercase()).cloned()).filter(|n| !n.is_empty());
|
||||
let st = MLeaderStyle {
|
||||
name: name.clone(),
|
||||
arrow_size: num(44, def.arrow_size, 0.0),
|
||||
text_height: t.f(45).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(def.text_height),
|
||||
landing_gap: num(42, def.landing_gap, 0.0),
|
||||
dogleg: num(43, def.dogleg, 0.0),
|
||||
text_style: text_style.unwrap_or(def.text_style),
|
||||
annotative: t.i(296).unwrap_or(0) != 0,
|
||||
};
|
||||
match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = st,
|
||||
None => d.mleader_styles.push(st),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_groups(&self, d: &mut Drawing) {
|
||||
// Groups (named by their ACAD_GROUP dictionary entries); members that exist.
|
||||
for (h, tags) in &self.groups {
|
||||
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };
|
||||
let t = T(tags);
|
||||
let members: Vec<Handle> = tags
|
||||
.iter()
|
||||
.filter(|x| x.code == 340)
|
||||
.filter_map(|x| Handle::parse_hex(&x.str()))
|
||||
.filter(|m| d.entity(*m).is_some())
|
||||
.take(MAX_OBJECTS)
|
||||
.collect();
|
||||
let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members };
|
||||
match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = g,
|
||||
None => d.groups.push(g),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
self.apply_table_styles(d);
|
||||
self.apply_table_style_fixes(d, rx);
|
||||
// Parametric constraints and parameters.
|
||||
if let Some((c, p)) = self.xrecord_text(crate::dxf_ext::CONSTRAINTS_KEY).and_then(|t| crate::dxf_ext::parse_constraints(&t)) {
|
||||
d.constraints = c;
|
||||
@@ -2113,50 +2219,14 @@ impl Objects {
|
||||
if let Some(tables) = self.xrecord_text(crate::dxf_ext::PLOT_STYLES_KEY).and_then(|t| crate::dxf_ext::parse_plot_styles(&t)) {
|
||||
d.plot_style_tables = tables;
|
||||
}
|
||||
// Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries).
|
||||
for (h, tags) in &self.mleader_styles {
|
||||
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };
|
||||
let t = T(tags);
|
||||
let def = MLeaderStyle::default();
|
||||
let num = |code: i32, d: f64, min: f64| t.f(code).filter(|v| v.is_finite() && *v >= min).unwrap_or(d);
|
||||
let text_style = t.s(342).and_then(|sh| rx.styles.get(&sh.trim().to_ascii_uppercase()).cloned()).filter(|n| !n.is_empty());
|
||||
let st = MLeaderStyle {
|
||||
name: name.clone(),
|
||||
arrow_size: num(44, def.arrow_size, 0.0),
|
||||
text_height: t.f(45).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(def.text_height),
|
||||
landing_gap: num(42, def.landing_gap, 0.0),
|
||||
dogleg: num(43, def.dogleg, 0.0),
|
||||
text_style: text_style.unwrap_or(def.text_style),
|
||||
annotative: t.i(296).unwrap_or(0) != 0,
|
||||
};
|
||||
match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = st,
|
||||
None => d.mleader_styles.push(st),
|
||||
}
|
||||
}
|
||||
self.apply_mleader_styles(d, rx);
|
||||
crate::dxf_mleader::apply(d, &rx.mleader_fix, &self.names);
|
||||
for (name, view) in &self.layout_views {
|
||||
if let Some(l) = d.layouts.iter_mut().find(|l| l.name == *name) {
|
||||
l.view = Some(*view);
|
||||
}
|
||||
}
|
||||
// Groups (named by their ACAD_GROUP dictionary entries); members that exist.
|
||||
for (h, tags) in &self.groups {
|
||||
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };
|
||||
let t = T(tags);
|
||||
let members: Vec<Handle> = tags
|
||||
.iter()
|
||||
.filter(|x| x.code == 340)
|
||||
.filter_map(|x| Handle::parse_hex(&x.str()))
|
||||
.filter(|m| d.entity(*m).is_some())
|
||||
.take(MAX_OBJECTS)
|
||||
.collect();
|
||||
let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members };
|
||||
match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = g,
|
||||
None => d.groups.push(g),
|
||||
}
|
||||
}
|
||||
self.apply_groups(d);
|
||||
// Standard associativity, for dimensions without CADCraft's exact links.
|
||||
for tags in &self.dimassocs {
|
||||
dimassoc(d, tags);
|
||||
|
||||
+110
-84
@@ -17,7 +17,7 @@ use std::fmt::Write as _;
|
||||
use cadcraft_color::Rgb;
|
||||
use cadcraft_doc::{Drawing, Handle, PageSetup, Space};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use cadcraft_render::{Kind, Sheet, clip, paper};
|
||||
use cadcraft_render::{DPrim, Kind, Sheet, clip, paper};
|
||||
use serde_json::Value;
|
||||
|
||||
use craft_pdf::write::backend as kr;
|
||||
@@ -808,7 +808,7 @@ fn krilla_failure(e: KrillaError) -> Failure {
|
||||
/// as text instead: the glyph at font size 1 under its glyph matrix (the matrix axes are one
|
||||
/// em).
|
||||
fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> {
|
||||
let (list, map) = (&p.list, &p.map);
|
||||
let list = &p.list;
|
||||
let texts = fonts::texts(list);
|
||||
let white = Rgb(255, 255, 255);
|
||||
let mut batch: Option<(StrokeStyle, PathBuilder)> = None;
|
||||
@@ -829,100 +829,126 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts:
|
||||
let t = if prim.kind == Kind::Mask { 0 } else { prim.transparency.min(cadcraft_render::MAX_TRANSPARENCY) };
|
||||
let w = f64::from(prim.lw) * PT_PER_MM;
|
||||
let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 };
|
||||
let m = Mark { rgb, t, w };
|
||||
match prim.kind {
|
||||
Kind::Polyline | Kind::Infinite { .. } => {
|
||||
let pts: Vec<Vec2> = match prim.kind {
|
||||
Kind::Infinite { ray } => match (raw.first(), raw.get(1)) {
|
||||
(Some(base), Some(dir)) => {
|
||||
let a = map.pt(*base);
|
||||
let dir_pt = map.pt(*base + *dir) - a;
|
||||
match clip::clip_infinite(a, dir_pt, ray, &p.clip_pt) {
|
||||
Some((x, y)) => vec![x, y],
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
},
|
||||
_ => raw.iter().map(|q| map.pt(*q)).collect(),
|
||||
};
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
// With a vertex out of range, keep the pieces inside the plot area, widened so
|
||||
// a wide stroke just outside it still shows its edge.
|
||||
let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(w + 1.0)) };
|
||||
let style = StrokeStyle { rgb, width: w as f32, transparency: t };
|
||||
if t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) {
|
||||
flush(s, &mut batch);
|
||||
}
|
||||
for run in runs {
|
||||
let Some(pts) = run.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { continue };
|
||||
// The writer leaves out a path without extent, so a zero-length line is
|
||||
// painted as the round-cap dot its stroke would make.
|
||||
if let Some(&(x, y)) = pts.first()
|
||||
&& pts.iter().all(|q| *q == (x, y))
|
||||
{
|
||||
flush(s, &mut batch);
|
||||
let d = if w > 0.0 { w as f32 } else { HAIRLINE_DOT_PT };
|
||||
if let Some(path) = circle_path(x, y, d / 2.0) {
|
||||
fill_path(s, &path, rgb, t);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new()));
|
||||
for (j, (x, y)) in pts.iter().enumerate() {
|
||||
if j == 0 {
|
||||
pb.move_to(*x, *y);
|
||||
} else {
|
||||
pb.line_to(*x, *y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Polyline | Kind::Infinite { .. } => polyline(s, &mut batch, p, prim, raw, m),
|
||||
Kind::Tris | Kind::Mask => {
|
||||
flush(s, &mut batch);
|
||||
// A wipeout's mask is paper white.
|
||||
let rgb = if prim.kind == Kind::Mask { white } else { rgb };
|
||||
if let Some(text) = u32::try_from(i).ok().and_then(|i| texts.get(&i))
|
||||
&& let Some(font) = fonts.font(&text.glyph.face)
|
||||
{
|
||||
glyph(s, font, text, map, rgb, t);
|
||||
continue;
|
||||
}
|
||||
let mapped: Vec<Vec2> = raw.iter().map(|q| map.pt(*q)).collect();
|
||||
// With a vertex out of range, keep the parts inside the plot area.
|
||||
let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) };
|
||||
let mut pb = PathBuilder::new();
|
||||
for tri in tris.as_chunks::<3>().0 {
|
||||
let Some(v) = tri.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { continue };
|
||||
for (j, (x, y)) in v.iter().enumerate() {
|
||||
if j == 0 {
|
||||
pb.move_to(*x, *y);
|
||||
} else {
|
||||
pb.line_to(*x, *y);
|
||||
}
|
||||
}
|
||||
pb.close();
|
||||
}
|
||||
if let Some(path) = pb.finish() {
|
||||
fill_path(s, &path, rgb, t);
|
||||
}
|
||||
}
|
||||
Kind::Point => {
|
||||
let Some(q) = raw.first() else { continue };
|
||||
flush(s, &mut batch);
|
||||
let half = (w.max(0.5)) / 2.0;
|
||||
let c = map.pt(*q);
|
||||
if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) {
|
||||
fill_path(s, &path, rgb, t);
|
||||
let m = if prim.kind == Kind::Mask { Mark { rgb: white, ..m } } else { m };
|
||||
let text_font =
|
||||
u32::try_from(i).ok().and_then(|i| texts.get(&i).copied()).and_then(|text| fonts.font(&text.glyph.face).map(|font| (text, font)));
|
||||
match text_font {
|
||||
Some((text, font)) => glyph(s, font, text, &p.map, m.rgb, m.t),
|
||||
None => fill_tris(s, p, raw, m.rgb, m.t),
|
||||
}
|
||||
}
|
||||
Kind::Point => point(s, &mut batch, &p.map, raw, m),
|
||||
}
|
||||
}
|
||||
flush(s, &mut batch);
|
||||
layer_changed(s, layer, None)
|
||||
}
|
||||
|
||||
/// How a primitive is painted: colour, transparency (percent) and lineweight (points).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Mark {
|
||||
rgb: Rgb,
|
||||
t: u8,
|
||||
w: f64,
|
||||
}
|
||||
|
||||
/// Stroke a polyline or infinite line, clipped to the plot area, into the open batch.
|
||||
fn polyline(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, p: &SheetPage, prim: &DPrim, raw: &[Vec2], m: Mark) {
|
||||
let Some(pts) = polyline_points(prim, raw, &p.map, &p.clip_pt) else { return };
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
}
|
||||
// With a vertex out of range, keep the pieces inside the plot area, widened so
|
||||
// a wide stroke just outside it still shows its edge.
|
||||
let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(m.w + 1.0)) };
|
||||
let style = StrokeStyle { rgb: m.rgb, width: m.w as f32, transparency: m.t };
|
||||
if m.t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) {
|
||||
flush(s, batch);
|
||||
}
|
||||
for run in runs {
|
||||
stroke_run(s, batch, &run, style, m);
|
||||
}
|
||||
}
|
||||
|
||||
/// The page points of a polyline, or of an infinite line clipped to the plot area; `None` when
|
||||
/// there is nothing to draw.
|
||||
fn polyline_points(prim: &DPrim, raw: &[Vec2], map: &Map, clip_pt: &Bounds2) -> Option<Vec<Vec2>> {
|
||||
match prim.kind {
|
||||
Kind::Infinite { ray } => {
|
||||
let (Some(base), Some(dir)) = (raw.first(), raw.get(1)) else { return None };
|
||||
let a = map.pt(*base);
|
||||
let dir_pt = map.pt(*base + *dir) - a;
|
||||
let (x, y) = clip::clip_infinite(a, dir_pt, ray, clip_pt)?;
|
||||
Some(vec![x, y])
|
||||
}
|
||||
_ => Some(raw.iter().map(|q| map.pt(*q)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add one run of a polyline to the batch, or paint it as a dot when it has no extent.
|
||||
fn stroke_run(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, run: &[Vec2], style: StrokeStyle, m: Mark) {
|
||||
let Some(pts) = run.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { return };
|
||||
// The writer leaves out a path without extent, so a zero-length line is
|
||||
// painted as the round-cap dot its stroke would make.
|
||||
if let Some(&(x, y)) = pts.first()
|
||||
&& pts.iter().all(|q| *q == (x, y))
|
||||
{
|
||||
flush(s, batch);
|
||||
let d = if m.w > 0.0 { m.w as f32 } else { HAIRLINE_DOT_PT };
|
||||
if let Some(path) = circle_path(x, y, d / 2.0) {
|
||||
fill_path(s, &path, m.rgb, m.t);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new()));
|
||||
for (j, (x, y)) in pts.iter().enumerate() {
|
||||
if j == 0 {
|
||||
pb.move_to(*x, *y);
|
||||
} else {
|
||||
pb.line_to(*x, *y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill a set of triangles, clipped to the plot area.
|
||||
fn fill_tris(s: &mut Surface<'_>, p: &SheetPage, raw: &[Vec2], rgb: Rgb, t: u8) {
|
||||
let mapped: Vec<Vec2> = raw.iter().map(|q| p.map.pt(*q)).collect();
|
||||
// With a vertex out of range, keep the parts inside the plot area.
|
||||
let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) };
|
||||
let mut pb = PathBuilder::new();
|
||||
for tri in tris.as_chunks::<3>().0 {
|
||||
let Some(v) = tri.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { continue };
|
||||
for (j, (x, y)) in v.iter().enumerate() {
|
||||
if j == 0 {
|
||||
pb.move_to(*x, *y);
|
||||
} else {
|
||||
pb.line_to(*x, *y);
|
||||
}
|
||||
}
|
||||
pb.close();
|
||||
}
|
||||
if let Some(path) = pb.finish() {
|
||||
fill_path(s, &path, rgb, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a point marker: a small square at the point.
|
||||
fn point(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, map: &Map, raw: &[Vec2], m: Mark) {
|
||||
let Some(q) = raw.first() else { return };
|
||||
flush(s, batch);
|
||||
let half = (m.w.max(0.5)) / 2.0;
|
||||
let c = map.pt(*q);
|
||||
if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) {
|
||||
fill_path(s, &path, m.rgb, m.t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one glyph as text in its embedded font: font size 1, placed by the glyph's matrix (one
|
||||
/// em along each axis). A glyph whose matrix is out of range or collapses is left out.
|
||||
fn glyph(s: &mut Surface<'_>, font: kr::text::Font, t: &cadcraft_render::PlotText, map: &Map, rgb: Rgb, transparency: u8) {
|
||||
|
||||
@@ -235,6 +235,46 @@ pub struct Op {
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
/// The index just past the string literal opening at `b[i]`, honouring escapes and nesting.
|
||||
fn skip_string(b: &[char], mut i: usize) -> usize {
|
||||
let mut depth = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
'\\' => i += 1,
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i + 1
|
||||
}
|
||||
|
||||
/// The index just past the inline dictionary opening at `b[i]` (`<<`).
|
||||
fn skip_dict(b: &[char], mut i: usize) -> usize {
|
||||
let mut depth = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == '<' && b.get(i + 1) == Some(&'<') {
|
||||
depth += 1;
|
||||
i += 1;
|
||||
} else if b[i] == '>' && b.get(i + 1) == Some(&'>') {
|
||||
depth -= 1;
|
||||
i += 1;
|
||||
if depth == 0 {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// The operators of content text in order, each with its numeric operands (other operands
|
||||
/// are skipped).
|
||||
pub fn ops(content: &str) -> Vec<Op> {
|
||||
@@ -253,39 +293,10 @@ pub fn ops(content: &str) -> Vec<Op> {
|
||||
i += 1;
|
||||
}
|
||||
} else if c == '(' {
|
||||
let mut depth = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
'\\' => i += 1,
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
i = skip_string(&b, i);
|
||||
} else if c == '<' && b.get(i + 1) == Some(&'<') {
|
||||
// An inline dictionary (marked-content properties): skip to its end.
|
||||
let mut depth = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == '<' && b.get(i + 1) == Some(&'<') {
|
||||
depth += 1;
|
||||
i += 1;
|
||||
} else if b[i] == '>' && b.get(i + 1) == Some(&'>') {
|
||||
depth -= 1;
|
||||
i += 1;
|
||||
if depth == 0 {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i = skip_dict(&b, i);
|
||||
} else if c == '<' {
|
||||
while i < b.len() && b[i] != '>' {
|
||||
i += 1;
|
||||
|
||||
+38
-26
@@ -465,6 +465,26 @@ impl Family<'_> {
|
||||
self.origin + p.rotate(self.ang)
|
||||
}
|
||||
|
||||
/// The range of the lines swept, in this frame: the boundary's extent, cut to the view's
|
||||
/// when there is one, and the view's x extent that spans are cut to. Spans are cut to the
|
||||
/// view's extent only: they already lie within the boundary, and cutting them to its extent
|
||||
/// too would move an end that sits on a boundary corner by a rounding error, so a view
|
||||
/// covering the hatch would change its lines. `None` when the view misses the boundary.
|
||||
fn lines_range(&self, bb: &Bounds2, view: Option<&Bounds2>) -> Option<(Vec2, Vec2, Option<(f64, f64)>)> {
|
||||
let (mut lo, mut hi) = (bb.min, bb.max);
|
||||
let mut span_cut = None;
|
||||
if let Some(v) = view {
|
||||
let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p)));
|
||||
if vb.is_empty() || !vb.intersects(bb) {
|
||||
return None;
|
||||
}
|
||||
lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y));
|
||||
hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y));
|
||||
span_cut = Some((vb.min.x, vb.max.x));
|
||||
}
|
||||
Some((lo, hi, span_cut))
|
||||
}
|
||||
|
||||
/// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits.
|
||||
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
|
||||
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
|
||||
@@ -472,21 +492,7 @@ impl Family<'_> {
|
||||
if bb.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The lines swept: the boundary's extent, cut to the view's (its corners in this frame)
|
||||
// when there is one. Spans are cut to the view's extent only: they already lie within the
|
||||
// boundary, and cutting them to its extent too would move an end that sits on a boundary
|
||||
// corner by a rounding error, so a view covering the hatch would change its lines.
|
||||
let (mut lo, mut hi) = (bb.min, bb.max);
|
||||
let mut span_cut = None;
|
||||
if let Some(v) = view {
|
||||
let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p)));
|
||||
if vb.is_empty() || !vb.intersects(&bb) {
|
||||
return;
|
||||
}
|
||||
lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y));
|
||||
hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y));
|
||||
span_cut = Some((vb.min.x, vb.max.x));
|
||||
}
|
||||
let Some((lo, hi, span_cut)) = self.lines_range(&bb, view) else { return };
|
||||
let shift_per_line = self.delta.rotate(-self.ang).x;
|
||||
let k0 = (lo.y / self.spacing).floor() as i64;
|
||||
let k1 = (hi.y / self.spacing).ceil() as i64;
|
||||
@@ -504,17 +510,7 @@ impl Family<'_> {
|
||||
return;
|
||||
}
|
||||
let y = k as f64 * self.spacing;
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for l in &local {
|
||||
let n = l.len();
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
|
||||
if (a.y > y) != (b.y > y) {
|
||||
xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
let xs = crossings(&local, y);
|
||||
let offset = shift_per_line * k as f64;
|
||||
for span in xs.chunks(2) {
|
||||
let [x0, x1] = span else { continue };
|
||||
@@ -565,6 +561,22 @@ impl Family<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The x positions where the boundary loops cross the horizontal line at `y`, in order.
|
||||
fn crossings(local: &[Vec<Vec2>], y: f64) -> Vec<f64> {
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for l in local {
|
||||
let n = l.len();
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
|
||||
if (a.y > y) != (b.y > y) {
|
||||
xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
xs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -17,6 +17,7 @@ cadcraft-doc = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
cadcraft-engine = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
craft-window = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
# GPU canvas paint callback (same egui-wgpu/wgpu eframe uses with its "wgpu" feature).
|
||||
egui-wgpu = { workspace = true }
|
||||
@@ -31,6 +32,8 @@ web-time = { workspace = true }
|
||||
craft-update = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests build the dock area as the desktop app does (headless contexts keep floats in-window).
|
||||
craft-ui = { workspace = true, features = ["native-windows"] }
|
||||
# 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"] }
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::doc::Handle;
|
||||
use cadcraft_engine::cmd::file::compare::Comparison;
|
||||
use cadcraft_engine::doc::{Drawing, Handle};
|
||||
use cadcraft_render::DisplayList;
|
||||
use egui::{Rect, RichText, Stroke, vec2};
|
||||
use serde_json::json;
|
||||
@@ -29,6 +30,23 @@ fn id() -> egui::Id {
|
||||
egui::Id::unique("cadcraft-compare-overlay")
|
||||
}
|
||||
|
||||
/// The display lists of the differences for this frame: the stored ones when they still match
|
||||
/// `key`, otherwise rebuilt at zoom `scale` and stored.
|
||||
fn cache_for(ctx: &egui::Context, key: (u64, u64, usize, usize, i64), doc: &Drawing, c: &Comparison, scale: f64) -> Cache {
|
||||
let mut cache: Cache = ctx.data_mut(|d| d.get_temp(id())).unwrap_or_default();
|
||||
if cache.key != key || cache.ours.is_none() {
|
||||
let opts = cadcraft_render::Options { tolerance: 0.5 / scale.max(1e-12), ..Default::default() };
|
||||
let ours: Vec<Handle> = c.added.iter().copied().chain(c.changed.iter().map(|(a, _)| *a)).take(MAX_DRAWN).collect();
|
||||
let theirs: Vec<Handle> = c.removed.iter().copied().chain(c.changed.iter().map(|(_, b)| *b)).take(MAX_DRAWN).collect();
|
||||
let list = |d: &Drawing, hs: &[Handle]| {
|
||||
Arc::new(cadcraft_render::build_entities(d, hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.as_ref()), &opts))
|
||||
};
|
||||
cache = Cache { key, ours: Some(list(doc, &ours)), theirs: Some(list(&c.other, &theirs)) };
|
||||
ctx.data_mut(|d| d.insert_temp(id(), cache.clone()));
|
||||
}
|
||||
cache
|
||||
}
|
||||
|
||||
/// Draw the comparison over the canvas when it was made for the drawing and tab shown.
|
||||
pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf: &Xf, rect: Rect) {
|
||||
let t = Tokens::get();
|
||||
@@ -38,17 +56,7 @@ pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf
|
||||
p.rect_filled(rect, 0.0, t.canvas.gamma_multiply(0.75));
|
||||
let band = xf.scale.max(1e-300).log2().round() as i64;
|
||||
let key = (st.uid, st.revision, Arc::as_ptr(&c.other) as usize, c.differences(), band);
|
||||
let mut cache: Cache = ctx.data_mut(|d| d.get_temp(id())).unwrap_or_default();
|
||||
if cache.key != key || cache.ours.is_none() {
|
||||
let opts = cadcraft_render::Options { tolerance: 0.5 / xf.scale.max(1e-12), ..Default::default() };
|
||||
let ours: Vec<Handle> = c.added.iter().copied().chain(c.changed.iter().map(|(a, _)| *a)).take(MAX_DRAWN).collect();
|
||||
let theirs: Vec<Handle> = c.removed.iter().copied().chain(c.changed.iter().map(|(_, b)| *b)).take(MAX_DRAWN).collect();
|
||||
let list = |d: &cadcraft_engine::doc::Drawing, hs: &[Handle]| {
|
||||
Arc::new(cadcraft_render::build_entities(d, hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.as_ref()), &opts))
|
||||
};
|
||||
cache = Cache { key, ours: Some(list(&st.doc, &ours)), theirs: Some(list(&c.other, &theirs)) };
|
||||
ctx.data_mut(|d| d.insert_temp(id(), cache.clone()));
|
||||
}
|
||||
let cache = cache_for(ctx, key, &st.doc, c, xf.scale);
|
||||
for (list, color) in [(&cache.theirs, t.compare_other), (&cache.ours, t.compare_this)] {
|
||||
if let Some(l) = list {
|
||||
let all: Vec<usize> = (0..l.prims.len()).collect();
|
||||
|
||||
@@ -69,6 +69,8 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
egui::Panel::top("cc_title_toolbar").exact_size(title_h + tb_h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
if title_h > 0.0 {
|
||||
// The empty row moves the window; it goes first so later widgets take their own clicks.
|
||||
craft_window::title_bar::empty_area(ui, Rect::from_min_size(r.min, vec2(r.width(), title_h)));
|
||||
let title = match app.session.state() {
|
||||
Ok(st) if !app.ui.start_tab => format!("CADCraft {}{}", st.title, if st.title.contains('.') { "" } else { ".dwg" }),
|
||||
_ => format!("CADCraft {}", crate::tl!("Start")),
|
||||
@@ -78,16 +80,6 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim);
|
||||
let x = title_left(r.left(), r.width(), galley.size().x);
|
||||
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.scope_id().with("titledrag"), Sense::CLICK | Sense::DRAG);
|
||||
if resp.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
}
|
||||
}
|
||||
if tb_h > 0.0 {
|
||||
let row = Rect::from_min_size(pos2(r.left(), r.top() + title_h), vec2(r.width(), tb_h));
|
||||
|
||||
+194
-48
@@ -2,8 +2,8 @@
|
||||
//! Document contents remain owned by their existing view implementations.
|
||||
|
||||
use craft_ui::docking::{
|
||||
DockArea, DockCommand, DockOperation, DockStyle, FloatSpace, Layout, Location, Node, PanelLimits, Permissions, Placement, StackEntry, UiDock,
|
||||
Zone,
|
||||
Action, Column, DockArea, DockCommand, DockContent, DockHeights, DockOperation, DockStyle, FloatSpace, Group, Layout, Location, Node,
|
||||
PanelLimits, Permissions, Placement, Side, Sides, Target, UiDock, Zone,
|
||||
};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -43,6 +43,18 @@ impl Panel {
|
||||
Self::ExternalRefs => "External References",
|
||||
}
|
||||
}
|
||||
/// The icon a palette shows in a column collapsed to icons.
|
||||
pub fn icon(self) -> Option<crate::icons::Icon> {
|
||||
use crate::icons::Icon;
|
||||
match self {
|
||||
Self::Canvas => None,
|
||||
Self::ToolSets => Some(Icon::Switcher),
|
||||
Self::QuickCalc => Some(Icon::Table),
|
||||
Self::Layers => Some(Icon::Layers),
|
||||
Self::Properties => Some(Icon::Properties),
|
||||
Self::ExternalRefs => Some(Icon::Attach),
|
||||
}
|
||||
}
|
||||
/// Palettes outside the default arrangement, opened on request (QUICKCALC).
|
||||
pub const ON_DEMAND: &[Self] = &[Self::QuickCalc];
|
||||
}
|
||||
@@ -63,31 +75,106 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
|
||||
/// The saved panel arrangement: craft-ui's workspace over CADCraft's panels.
|
||||
pub type Workspace = craft_ui::docking::Workspace<Panel>;
|
||||
|
||||
/// Tool Sets on the left, the drawing, and the Layers and Properties accordion on the right.
|
||||
/// The panel layout's shape version, saved beside it. Layouts saved without it, or with an
|
||||
/// older one, are the split frames from before the dock columns and give way to the default.
|
||||
pub const LAYOUT_VERSION: u32 = 2;
|
||||
|
||||
/// Width of the left dock's Tool Sets column.
|
||||
pub const TOOL_SETS_WIDTH: f32 = 220.0;
|
||||
/// Width of the right dock's palettes column.
|
||||
pub const PALETTES_WIDTH: f32 = 300.0;
|
||||
/// Layers' body height above Properties in a dock column.
|
||||
const LAYERS_HEIGHT: f32 = 280.0;
|
||||
|
||||
fn dock(side: Side, width: f32, groups: &[Panel]) -> Node<Panel> {
|
||||
let groups = groups.iter().map(|panel| Group::new(vec![*panel], 0)).collect();
|
||||
Node::Dock { side, columns: vec![Column::new(groups, width)] }
|
||||
}
|
||||
|
||||
/// Tool Sets in the left dock, the drawing, and Layers above Properties in the right dock.
|
||||
pub fn default_workspace() -> Workspace {
|
||||
Workspace::new(Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(220.0),
|
||||
tabs(Panel::ToolSets),
|
||||
SplitSize::FixedFirst(TOOL_SETS_WIDTH),
|
||||
dock(Side::Start, TOOL_SETS_WIDTH, &[Panel::ToolSets]),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(300.0),
|
||||
SplitSize::FixedSecond(PALETTES_WIDTH),
|
||||
tabs(Panel::Canvas),
|
||||
Node::Stack {
|
||||
entries: vec![
|
||||
StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) },
|
||||
StackEntry { panel: Panel::Properties, open: true, height: None },
|
||||
],
|
||||
},
|
||||
dock(Side::End, PALETTES_WIDTH, &[Panel::Layers, Panel::Properties]),
|
||||
),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The dock a panel belongs in: Tool Sets on the left, the palettes on the right.
|
||||
fn home_side(panel: Panel) -> (Side, f32) {
|
||||
if panel == Panel::ToolSets { (Side::Start, TOOL_SETS_WIDTH) } else { (Side::End, PALETTES_WIDTH) }
|
||||
}
|
||||
|
||||
/// The panels of every dock group on `side`: columns from the window edge inwards, groups from
|
||||
/// top to bottom.
|
||||
fn dock_groups(layout: &Layout<Panel>, side: Side) -> Vec<Vec<Vec<Panel>>> {
|
||||
let mut nodes: Vec<&Node<Panel>> = layout.root.iter().collect();
|
||||
let mut seen = 0usize;
|
||||
while let Some(node) = nodes.pop() {
|
||||
seen = seen.saturating_add(1);
|
||||
if seen > 4 * craft_ui::docking::MAX_PANELS {
|
||||
break;
|
||||
}
|
||||
match node {
|
||||
Node::Split { first, second, .. } => nodes.extend([&**first, &**second]),
|
||||
Node::Dock { side: s, columns } if *s == side => {
|
||||
return columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn docked(layout: &Layout<Panel>, panel: Panel) -> bool {
|
||||
[Side::Start, Side::End].into_iter().any(|side| dock_groups(layout, side).iter().flatten().flatten().any(|p| *p == panel))
|
||||
}
|
||||
|
||||
/// The docked neighbour a panel with no recorded place opens beside, as a new group above
|
||||
/// (`false`) or below (`true`) it: Layers above Properties, External References and QuickCalc
|
||||
/// below them, Tool Sets at the top of the left dock.
|
||||
fn home_anchor(layout: &Layout<Panel>, panel: Panel) -> Option<(Panel, bool)> {
|
||||
let neighbours: &[(Panel, bool)] = match panel {
|
||||
Panel::Canvas | Panel::ToolSets => &[],
|
||||
Panel::Layers => &[(Panel::Properties, false), (Panel::ExternalRefs, false)],
|
||||
Panel::Properties => &[(Panel::Layers, true), (Panel::ExternalRefs, false)],
|
||||
Panel::ExternalRefs => &[(Panel::Properties, true), (Panel::Layers, true)],
|
||||
Panel::QuickCalc => &[(Panel::ExternalRefs, true), (Panel::Properties, true), (Panel::Layers, true)],
|
||||
};
|
||||
if let Some(found) = neighbours.iter().copied().find(|(neighbour, _)| docked(layout, *neighbour)) {
|
||||
return Some(found);
|
||||
}
|
||||
let (side, _) = home_side(panel);
|
||||
let columns = dock_groups(layout, side);
|
||||
let column = columns.first()?;
|
||||
if panel == Panel::ToolSets {
|
||||
column.first().and_then(|group| group.first()).map(|anchor| (*anchor, false))
|
||||
} else {
|
||||
column.last().and_then(|group| group.first()).map(|anchor| (*anchor, true))
|
||||
}
|
||||
}
|
||||
|
||||
/// The actions that open a panel with no recorded place in its dock: a new group beside its
|
||||
/// docked neighbour, else a new dock on its side at its default width.
|
||||
fn home(layout: &Layout<Panel>, panel: Panel) -> Vec<Action<Panel>> {
|
||||
if let Some((anchor, below)) = home_anchor(layout, panel) {
|
||||
return vec![Action::OpenAt { panel, anchor, placement: Placement::Group { below, height: None } }];
|
||||
}
|
||||
let (side, width) = home_side(panel);
|
||||
vec![Action::Open { panel, anchor: None }, Action::MovePanel { panel, to: Target::NewDock { side } }, Action::ResizeColumn { panel, width }]
|
||||
}
|
||||
|
||||
/// craft-ui's checks plus the drawing view's: a saved or hand-edited file cannot hide, float,
|
||||
/// group or collapse it, or restore a palette over it.
|
||||
/// group, collapse or dock it, or restore a palette over it.
|
||||
pub fn validate(workspace: &Workspace) -> Result<(), String> {
|
||||
workspace.validate().map_err(|error| error.to_string())?;
|
||||
if !workspace.layout.contains(&Panel::Canvas) {
|
||||
@@ -124,11 +211,17 @@ pub fn validate(workspace: &Workspace) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show a panel and select it at its recorded place (craft-ui places a panel it has no place
|
||||
/// for), keeping the drawing view's rules.
|
||||
/// Show a panel and select it at its recorded place, keeping the drawing view's rules. A panel
|
||||
/// with no recorded place opens in its dock ([`home`]); one whose recorded place is gone joins
|
||||
/// its docked neighbour's group.
|
||||
pub fn reveal_panel(workspace: &mut Workspace, panel: Panel) -> Result<(), String> {
|
||||
let mut next = workspace.clone();
|
||||
next.set_visible(panel, true, None, permissions).map_err(|error| error.to_string())?;
|
||||
if next.layout.contains(&panel) || next.hidden.iter().any(|(id, _)| *id == panel) {
|
||||
let fallback = home_anchor(&next.layout, panel).map(|(anchor, _)| anchor);
|
||||
next.set_visible(panel, true, fallback, permissions).map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
next.apply_all(home(&next.layout, panel).into_iter().chain([Action::Activate { panel }]), permissions).map_err(|error| error.to_string())?;
|
||||
}
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
Ok(())
|
||||
@@ -188,7 +281,7 @@ fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> {
|
||||
set_visible(workspace, Panel::Layers, false)?;
|
||||
set_visible(workspace, Panel::Properties, false)?;
|
||||
} else if !workspace.layout.contains(&Panel::Layers) && !workspace.layout.contains(&Panel::Properties) {
|
||||
// Reopen the last surviving accordion entry before restoring its earlier sibling.
|
||||
// Reopen the palette that closed last before the one recorded beside it.
|
||||
set_visible(workspace, Panel::Properties, true)?;
|
||||
set_visible(workspace, Panel::Layers, true)?;
|
||||
}
|
||||
@@ -207,6 +300,10 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<V
|
||||
} else {
|
||||
sync(&mut workspace, app)?;
|
||||
apply_command(&mut workspace, params)?;
|
||||
// An opened panel's floating window comes to the front, in its own OS window too.
|
||||
if matches!(params.get("operation").and_then(Value::as_str), Some("open" | "activate")) {
|
||||
app.raise_panel = params.get("panel").and_then(|panel| serde_json::from_value(panel.clone()).ok());
|
||||
}
|
||||
}
|
||||
publish_visibility(app, &workspace);
|
||||
app.ui.docking = workspace;
|
||||
@@ -235,39 +332,30 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
style.border = egui::Stroke::new(1.0, t.border);
|
||||
style.accent = t.accent;
|
||||
style.font = crate::theme::body();
|
||||
// Panels arrange in a split tree around the drawing; floating title bars only move windows.
|
||||
style.new_dock_zones = false;
|
||||
// AutoCAD's palettes dock only in the left and right dock columns: a window edge without a
|
||||
// dock makes one, and neither the drawing nor the top and bottom edges take a drop.
|
||||
// Floating title bars only move windows.
|
||||
style.side_edge_zones = false;
|
||||
style.new_dock_zones = true;
|
||||
style.drop_edges = Sides::NONE;
|
||||
style.title_bar_docks = false;
|
||||
let output = DockArea::new(egui::Id::unique("cadcraft-docking")).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
&style,
|
||||
|panel| crate::i18n::t(panel.label()).into(),
|
||||
permissions,
|
||||
panel_limits,
|
||||
|ui, panel| match panel {
|
||||
Panel::Canvas => {
|
||||
ui.painter().rect_filled(ui.max_rect(), 0.0, t.canvas);
|
||||
crate::canvas::show(app, ui);
|
||||
let mut area = DockArea::new(egui::Id::unique("cadcraft-docking")).input_blocked(native_input_blocked(app, ui.ctx()));
|
||||
if let Some(panel) = app.raise_panel.take() {
|
||||
area = area.raise_panel(panel);
|
||||
}
|
||||
let mut output = area.show_content(ui, &workspace.layout, &style, &mut Panels { app });
|
||||
// Keys typed in a floating panel's own OS window run the main window's shortcuts and
|
||||
// command line; files dropped on one open like files dropped on the main window.
|
||||
app.forward_native_input(ui.ctx(), std::mem::take(&mut output.forwarded_events), output.forwarded_modifiers);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for files in std::mem::take(&mut output.window_files) {
|
||||
for file in files.dropped {
|
||||
let path = file.path().to_string_lossy().to_string();
|
||||
if !path.is_empty() {
|
||||
app.open_path(&path);
|
||||
}
|
||||
Panel::ToolSets => crate::palettes::toolsets(app, ui),
|
||||
Panel::QuickCalc => crate::palettes::quickcalc::show(app, ui),
|
||||
Panel::Layers => {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("dock-layers")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| crate::palettes::layers_section(app, ui));
|
||||
}
|
||||
Panel::Properties => {
|
||||
egui::ScrollArea::vertical().id_salt("dock-properties").auto_shrink([false, false]).show(ui, |ui| {
|
||||
crate::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries)
|
||||
});
|
||||
}
|
||||
Panel::ExternalRefs => {
|
||||
egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Existing panel close buttons and commands still own their visibility switches.
|
||||
let rendered = workspace;
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
@@ -290,6 +378,64 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the floating panels' own OS windows ignore input: while a modal dialog is up, since
|
||||
/// egui's `Modal` blocks only the main window. egui's modal layer is last frame's; the app's
|
||||
/// state covers the frame a modal opens in.
|
||||
pub fn native_input_blocked(app: &crate::CadApp, ctx: &egui::Context) -> bool {
|
||||
app.shows_modal() || ctx.memory(|memory| memory.top_modal_layer()).is_some()
|
||||
}
|
||||
|
||||
/// The panels' contents, names, size limits and icons for the dock area.
|
||||
pub struct Panels<'a> {
|
||||
pub app: &'a mut crate::CadApp,
|
||||
}
|
||||
|
||||
impl DockContent<Panel> for Panels<'_> {
|
||||
fn body(&mut self, ui: &mut egui::Ui, panel: &Panel) {
|
||||
let app = &mut *self.app;
|
||||
match panel {
|
||||
Panel::Canvas => {
|
||||
ui.painter().rect_filled(ui.max_rect(), 0.0, crate::theme::Tokens::get().canvas);
|
||||
crate::canvas::show(app, ui);
|
||||
}
|
||||
Panel::ToolSets => crate::palettes::toolsets(app, ui),
|
||||
Panel::QuickCalc => crate::palettes::quickcalc::show(app, ui),
|
||||
Panel::Layers => {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("dock-layers")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| crate::palettes::layers_section(app, ui));
|
||||
}
|
||||
Panel::Properties => {
|
||||
egui::ScrollArea::vertical().id_salt("dock-properties").auto_shrink([false, false]).show(ui, |ui| {
|
||||
crate::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries)
|
||||
});
|
||||
}
|
||||
Panel::ExternalRefs => {
|
||||
egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn logical_label(&self, panel: &Panel) -> String {
|
||||
crate::i18n::t(panel.label()).into()
|
||||
}
|
||||
fn permissions(&self, panel: &Panel) -> Permissions {
|
||||
permissions(panel)
|
||||
}
|
||||
fn limits(&self, panel: &Panel) -> PanelLimits {
|
||||
panel_limits(panel)
|
||||
}
|
||||
fn dock_heights(&self, panel: &Panel) -> Option<DockHeights> {
|
||||
// Layers keeps its list's height above Properties, which takes the rest of the column.
|
||||
(*panel == Panel::Layers).then(|| DockHeights::new(LAYERS_HEIGHT))
|
||||
}
|
||||
fn paint_icon(&mut self, painter: &egui::Painter, rect: egui::Rect, panel: &Panel, _color: egui::Color32) -> bool {
|
||||
let Some(icon) = panel.icon() else { return false };
|
||||
crate::icons::paint(painter, rect, icon, false);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_limits(panel: &Panel) -> PanelLimits {
|
||||
let (min, max_width) = match panel {
|
||||
Panel::Canvas => (egui::vec2(300.0, 180.0), 1_000_000.0),
|
||||
|
||||
@@ -235,6 +235,15 @@ pub struct CadApp {
|
||||
/// Admitted `ui.screenshot` requests, including completed-but-undrained ones.
|
||||
pub(crate) shots: capture::Shots,
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
/// Keys, text and clipboard events a floating panel's own OS window left over, with the
|
||||
/// modifiers held there, for the main window's next input ([`Self::forward_native_input`]).
|
||||
forwarded: Vec<egui::Event>,
|
||||
forwarded_modifiers: Option<egui::Modifiers>,
|
||||
/// The main window's own modifiers, put back the frame after forwarded keys went in.
|
||||
restore_modifiers: Option<egui::Modifiers>,
|
||||
/// A panel opened from a menu or command, whose floating window comes to the front on the
|
||||
/// next frame (craft-ui's `DockArea::raise_panel`).
|
||||
pub raise_panel: Option<docking::Panel>,
|
||||
/// One synthetic batch has been put into `RawInput` and is waiting for a real `App::ui` call.
|
||||
synthetic_injected: bool,
|
||||
/// Counts settled UI passes (one per painted frame; never hidden logic-only ticks or
|
||||
@@ -303,6 +312,10 @@ impl CadApp {
|
||||
capture_identity_epoch: 0,
|
||||
shots: capture::Shots::default(),
|
||||
synthetic: Vec::new(),
|
||||
forwarded: Vec::new(),
|
||||
forwarded_modifiers: None,
|
||||
restore_modifiers: None,
|
||||
raise_panel: None,
|
||||
synthetic_injected: false,
|
||||
ui_frames: 0,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -604,6 +617,9 @@ impl CadApp {
|
||||
if ctx.current_pass_index() != 0 {
|
||||
return;
|
||||
}
|
||||
if self.restore_modifiers.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// Closing the window with unsaved changes asks first. `run_logic` refreshes viewport
|
||||
// close/focus state, so this must also work while hidden.
|
||||
if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() {
|
||||
@@ -625,13 +641,56 @@ impl CadApp {
|
||||
capture::request_hidden_paint(self, ctx);
|
||||
}
|
||||
|
||||
/// Input hook: refresh the raw surface snapshot and release at most one synthetic batch.
|
||||
/// Queue keys typed in a floating panel's own OS window (craft-ui's
|
||||
/// `DockOutput::forwarded_events` and `forwarded_modifiers`) for the main window's next
|
||||
/// input. egui gives each OS window only its own input, and the shortcuts, the command line
|
||||
/// and the canvas all read the main window's, so the keys go in there as if typed in it.
|
||||
pub fn forward_native_input(&mut self, ctx: &egui::Context, events: Vec<egui::Event>, modifiers: Option<egui::Modifiers>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.forwarded.extend(events);
|
||||
if modifiers.is_some() {
|
||||
self.forwarded_modifiers = modifiers;
|
||||
}
|
||||
if self.restore_modifiers.is_none() {
|
||||
self.restore_modifiers = Some(ctx.input(|i| i.modifiers));
|
||||
}
|
||||
ctx.request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
|
||||
/// Whether a modal dialog is up: the "Save changes?" prompt or Drawing Recovery. The
|
||||
/// floating panels' own OS windows take no input meanwhile (egui's `Modal` blocks only the
|
||||
/// main window). The app's other dialogs are ordinary windows that leave the drawing usable.
|
||||
pub fn shows_modal(&self) -> bool {
|
||||
self.close_prompt().is_some() || !self.recovery.items.is_empty()
|
||||
}
|
||||
|
||||
/// Input hook: refresh the raw surface snapshot, put in keys forwarded from floating panels'
|
||||
/// OS windows, and release at most one synthetic batch.
|
||||
///
|
||||
/// Eframe keeps the unused `RawInput` across hidden logic-only ticks, so a batch already in
|
||||
/// the buffer is never injected again; the next batch waits for a real `ui` pass to consume
|
||||
/// the previous one.
|
||||
pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
capture::note_raw_geometry(self, ctx, raw);
|
||||
// The forwarded window's modifiers hold for the frame its keys go in (Shift+Copy is
|
||||
// COPYBASE); the next frame puts the main window's back, before its own changes.
|
||||
if !self.forwarded.is_empty() {
|
||||
// A modifier the main window changed meanwhile is the one to put back.
|
||||
if let Some(own) = raw.events.iter().rev().find_map(|event| match event {
|
||||
egui::Event::ModifiersChanged(modifiers) => Some(*modifiers),
|
||||
_ => None,
|
||||
}) {
|
||||
self.restore_modifiers = Some(own);
|
||||
}
|
||||
if let Some(modifiers) = self.forwarded_modifiers.take() {
|
||||
raw.events.push(egui::Event::ModifiersChanged(modifiers));
|
||||
}
|
||||
raw.events.append(&mut self.forwarded);
|
||||
} else if let Some(modifiers) = self.restore_modifiers.take() {
|
||||
raw.events.insert(0, egui::Event::ModifiersChanged(modifiers));
|
||||
}
|
||||
if self.synthetic_injected || self.synthetic.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
let pick = app.services.pick_lisp.as_ref()?;
|
||||
let dir = app.ui.last_lisp_dir.clone().unwrap_or_default();
|
||||
let Some(path) = pick(&dir) else { return Some(Ok(Value::Null)) };
|
||||
if let Some(dir) = std::path::Path::new(&path).parent().and_then(|p| p.to_str()).filter(|d| !d.is_empty()) {
|
||||
if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::to_str).filter(|d| !d.is_empty()) {
|
||||
app.ui.last_lisp_dir = Some(dir.to_string());
|
||||
}
|
||||
if let Err(e) = app.session.start("appload") {
|
||||
@@ -649,6 +649,61 @@ pub fn activate(app: &mut CadApp, id: &str) {
|
||||
app.start(id);
|
||||
}
|
||||
|
||||
/// Activate the menu command `id` from a native menu bar, which takes the Edit menu's key
|
||||
/// equivalents (⌘C, ⌘X, ⌘V, ⌘A, ⌘Z, ⇧⌘Z) before any window sees them: while a text field has
|
||||
/// the keyboard, in the main window or a floating panel's own window, the field gets the key
|
||||
/// and the drawing command does not run.
|
||||
pub fn activate_native(app: &mut CadApp, ctx: &egui::Context, id: &str) {
|
||||
let (Some(window), Some(text)) = (text_field_window(ctx), text_command(id, ctx.os().is_mac())) else {
|
||||
activate(app, id);
|
||||
return;
|
||||
};
|
||||
match text {
|
||||
TextCommand::Paste => ctx.send_viewport_cmd_to(window, egui::ViewportCommand::RequestPaste),
|
||||
TextCommand::Events(events) if window == egui::ViewportId::ROOT => {
|
||||
app.synthetic.extend(events);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
TextCommand::Events(events) => craft_ui::docking::send_keys(ctx, window, events),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window whose text field has the keyboard: a floating panel's own OS window, or the main
|
||||
/// window.
|
||||
pub fn text_field_window(ctx: &egui::Context) -> Option<egui::ViewportId> {
|
||||
craft_ui::docking::typing_window(ctx).or_else(|| ctx.text_edit_focused().then_some(egui::ViewportId::ROOT))
|
||||
}
|
||||
|
||||
/// Whether a focused text field takes the menu command `id` ([`activate_native`]): a native
|
||||
/// menu keeps such items enabled while one has the keyboard, whatever the drawing allows.
|
||||
pub fn edits_text(id: &str) -> bool {
|
||||
text_command(id, false).is_some()
|
||||
}
|
||||
|
||||
/// What a focused text field does for an Edit menu command.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum TextCommand {
|
||||
/// The clipboard text, which the window asks for and receives as `Paste`.
|
||||
Paste,
|
||||
Events(Vec<egui::Event>),
|
||||
}
|
||||
|
||||
fn text_command(id: &str, mac: bool) -> Option<TextCommand> {
|
||||
let key = |key, shift| {
|
||||
let modifiers = egui::Modifiers { mac_cmd: mac, ctrl: !mac, command: true, shift, alt: false };
|
||||
TextCommand::Events([true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).into())
|
||||
};
|
||||
Some(match id {
|
||||
"copyclip" => TextCommand::Events(vec![egui::Event::Copy]),
|
||||
"cutclip" => TextCommand::Events(vec![egui::Event::Cut]),
|
||||
"pasteclip" => TextCommand::Paste,
|
||||
"u" => key(egui::Key::Z, false),
|
||||
"redo" => key(egui::Key::Z, true),
|
||||
"selectall" => key(egui::Key::A, false),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `id` with default parameters without cancelling the active command, as the drafting
|
||||
/// toggles (F3, F7-F12, the status bar buttons) are in AutoCAD.
|
||||
pub fn run_transparent(app: &mut CadApp, id: &str) {
|
||||
|
||||
@@ -217,73 +217,15 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
/// Files tab: save format and default version, new-drawing units, autosave interval and autosave file location; edits accumulate in `changes`.
|
||||
fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
grid(ui, "options_files", |ui| {
|
||||
ui.label(crate::tl!("Save new drawings as"));
|
||||
ui.horizontal(|ui| {
|
||||
for f in crate::SaveFormat::ALL {
|
||||
let label = match f {
|
||||
crate::SaveFormat::Dxf => crate::tl!("DXF"),
|
||||
crate::SaveFormat::Dwg => crate::tl!("DWG"),
|
||||
};
|
||||
if ui.radio(app.ui.save_format == f, label).clicked() && app.ui.save_format != f {
|
||||
changes.insert("saveFormat".into(), json!(f.as_str()));
|
||||
}
|
||||
}
|
||||
});
|
||||
save_format_row(ui, app, changes);
|
||||
ui.end_row();
|
||||
|
||||
let version_caption = ui.label(crate::tl!("Default save version"));
|
||||
let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default();
|
||||
let mut version = current;
|
||||
let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| {
|
||||
for v in crate::SaveVersion::ALL {
|
||||
ui.selectable_value(&mut version, v, v.year());
|
||||
}
|
||||
});
|
||||
version_combo.response.labelled_by(version_caption.id);
|
||||
if version != current {
|
||||
changes.insert("saveVersion".into(), json!(version.year()));
|
||||
}
|
||||
save_version_row(ui, app, changes);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(crate::tl!("Units of new drawings"));
|
||||
ui.horizontal(|ui| {
|
||||
let metric = app.session.settings.measureinit;
|
||||
if ui.radio(!metric, crate::tl!("Imperial (inches)")).clicked() && metric {
|
||||
changes.insert("MEASUREINIT".into(), json!(0));
|
||||
}
|
||||
if ui.radio(metric, crate::tl!("Metric (millimetres)")).clicked() && !metric {
|
||||
changes.insert("MEASUREINIT".into(), json!(1));
|
||||
}
|
||||
});
|
||||
units_row(ui, app, changes);
|
||||
ui.end_row();
|
||||
|
||||
let minutes_caption = ui.label(crate::tl!("Automatic save every"));
|
||||
let mut minutes = cadcraft_engine::autosave::savetime();
|
||||
let max = cadcraft_engine::autosave::MAX_SAVETIME;
|
||||
if ui
|
||||
.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min"))))
|
||||
.labelled_by(minutes_caption.id)
|
||||
.changed()
|
||||
{
|
||||
changes.insert("SAVETIME".into(), json!(minutes));
|
||||
}
|
||||
autosave_row(ui, changes);
|
||||
ui.end_row();
|
||||
|
||||
let savefile_label = ui.label(crate::tl!("Automatic save file location"));
|
||||
// Edited as text; the folder changes when the field loses focus.
|
||||
let id = egui::Id::unique("cc_options_savefilepath");
|
||||
let current = cadcraft_engine::autosave::savefilepath();
|
||||
let mut path = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| current.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)).labelled_by(savefile_label.id);
|
||||
if r.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(id, path.clone()));
|
||||
}
|
||||
if r.lost_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
if path.trim() != current {
|
||||
changes.insert("SAVEFILEPATH".into(), json!(path.trim()));
|
||||
}
|
||||
}
|
||||
savefile_row(ui, changes);
|
||||
ui.end_row();
|
||||
});
|
||||
hint(ui, crate::tl!("New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE)."));
|
||||
@@ -291,6 +233,76 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
hint(ui, crate::tl!("Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off."));
|
||||
}
|
||||
|
||||
fn save_format_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map<String, Value>) {
|
||||
ui.label(crate::tl!("Save new drawings as"));
|
||||
ui.horizontal(|ui| {
|
||||
for f in crate::SaveFormat::ALL {
|
||||
let label = match f {
|
||||
crate::SaveFormat::Dxf => crate::tl!("DXF"),
|
||||
crate::SaveFormat::Dwg => crate::tl!("DWG"),
|
||||
};
|
||||
if ui.radio(app.ui.save_format == f, label).clicked() && app.ui.save_format != f {
|
||||
changes.insert("saveFormat".into(), json!(f.as_str()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn save_version_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map<String, Value>) {
|
||||
let version_caption = ui.label(crate::tl!("Default save version"));
|
||||
let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default();
|
||||
let mut version = current;
|
||||
let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| {
|
||||
for v in crate::SaveVersion::ALL {
|
||||
ui.selectable_value(&mut version, v, v.year());
|
||||
}
|
||||
});
|
||||
version_combo.response.labelled_by(version_caption.id);
|
||||
if version != current {
|
||||
changes.insert("saveVersion".into(), json!(version.year()));
|
||||
}
|
||||
}
|
||||
|
||||
fn units_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map<String, Value>) {
|
||||
ui.label(crate::tl!("Units of new drawings"));
|
||||
ui.horizontal(|ui| {
|
||||
let metric = app.session.settings.measureinit;
|
||||
if ui.radio(!metric, crate::tl!("Imperial (inches)")).clicked() && metric {
|
||||
changes.insert("MEASUREINIT".into(), json!(0));
|
||||
}
|
||||
if ui.radio(metric, crate::tl!("Metric (millimetres)")).clicked() && !metric {
|
||||
changes.insert("MEASUREINIT".into(), json!(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn autosave_row(ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
let minutes_caption = ui.label(crate::tl!("Automatic save every"));
|
||||
let mut minutes = cadcraft_engine::autosave::savetime();
|
||||
let max = cadcraft_engine::autosave::MAX_SAVETIME;
|
||||
if ui.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))).labelled_by(minutes_caption.id).changed() {
|
||||
changes.insert("SAVETIME".into(), json!(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
fn savefile_row(ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
let savefile_label = ui.label(crate::tl!("Automatic save file location"));
|
||||
// Edited as text; the folder changes when the field loses focus.
|
||||
let id = egui::Id::unique("cc_options_savefilepath");
|
||||
let current = cadcraft_engine::autosave::savefilepath();
|
||||
let mut path = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| current.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)).labelled_by(savefile_label.id);
|
||||
if r.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(id, path.clone()));
|
||||
}
|
||||
if r.lost_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
if path.trim() != current {
|
||||
changes.insert("SAVEFILEPATH".into(), json!(path.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drafting tab: object-snap marker size and aperture previews plus the Drafting Settings entry; edits accumulate in `changes` and the entry may request a command through `start`.
|
||||
fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>, start: &mut Option<&'static str>) {
|
||||
let t = Tokens::get();
|
||||
|
||||
@@ -37,19 +37,10 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the palette. The first time it docks below the Layers palette (where the palettes column
|
||||
/// is); after that it comes back where it was.
|
||||
/// Show the palette. The first time it docks below Properties in the right dock; after that it
|
||||
/// comes back where it was.
|
||||
fn open_palette(app: &mut CadApp) -> Result<Value, String> {
|
||||
let panel = Panel::ExternalRefs;
|
||||
let first = !app.ui.docking.layout.contains(&panel) && !app.ui.docking.hidden.iter().any(|(p, _)| *p == panel);
|
||||
let r = crate::docking::command(app, false, &json!({ "operation": "open", "panel": panel.id() }))?;
|
||||
if first && app.ui.docking.layout.contains(&Panel::Layers) {
|
||||
let moved = json!({ "operation": "move", "panel": panel.id(), "target": Panel::Layers.id(), "zone": "bottom" });
|
||||
if let Ok(r) = crate::docking::command(app, false, &moved) {
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
crate::docking::command(app, false, &json!({ "operation": "open", "panel": Panel::ExternalRefs.id() }))
|
||||
}
|
||||
|
||||
/// A picked file inside the host drawing's folder is attached by its relative path, so the
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Panel moves use the real app shell, retain commands, and survive a complete UI-state reload.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel};
|
||||
use cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel, menus};
|
||||
use craft_ui::docking::{Column, Divider, DockArea, Hit, Layout, Node, Side};
|
||||
use egui::{Event, Modifiers, PointerButton, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
@@ -42,6 +43,110 @@ fn drag(harness: &mut Harness<'_, CadApp>, from: egui::Pos2, to: egui::Pos2) {
|
||||
harness.run_steps(4);
|
||||
}
|
||||
|
||||
fn area() -> egui::Id {
|
||||
egui::Id::unique("cadcraft-docking")
|
||||
}
|
||||
|
||||
/// The rect the dock area drew last frame for `hit`.
|
||||
fn hit(h: &Harness<'_, CadApp>, hit: Hit<Panel>) -> egui::Rect {
|
||||
let hits = DockArea::<Panel>::hit_rects(&h.ctx, area());
|
||||
hits.iter().find(|(drawn, _)| *drawn == hit).map(|(_, rect)| *rect).unwrap_or_else(|| panic!("{hit:?} is not drawn: {hits:?}"))
|
||||
}
|
||||
|
||||
/// The columns of the dock on `side`, from the window edge inwards.
|
||||
fn columns(layout: &Layout<Panel>, side: Side) -> Vec<Column<Panel>> {
|
||||
let mut nodes: Vec<&Node<Panel>> = layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => nodes.extend([&**first, &**second]),
|
||||
Node::Dock { side: s, columns } if *s == side => return columns.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The panels of each group of each column of the dock on `side`.
|
||||
fn dock_panels(layout: &Layout<Panel>, side: Side) -> Vec<Vec<Vec<Panel>>> {
|
||||
columns(layout, side).iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect()
|
||||
}
|
||||
|
||||
/// The tab groups outside the docks: the frames of the split tree.
|
||||
fn frames(layout: &Layout<Panel>) -> Vec<Vec<Panel>> {
|
||||
let mut out = Vec::new();
|
||||
let mut nodes: Vec<&Node<Panel>> = layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => nodes.extend([&**first, &**second]),
|
||||
Node::Tabs { panels, .. } => out.push(panels.clone()),
|
||||
Node::Stack { entries } => out.push(entries.iter().map(|entry| entry.panel).collect()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_layout_is_tool_sets_left_and_the_palettes_right_in_dock_columns() {
|
||||
use cadcraft_ui_egui::docking::{PALETTES_WIDTH, TOOL_SETS_WIDTH, default_workspace, validate};
|
||||
let workspace = default_workspace();
|
||||
validate(&workspace).unwrap();
|
||||
assert!(workspace.hidden.is_empty() && workspace.layout.floating.is_empty());
|
||||
assert_eq!(frames(&workspace.layout), [[Panel::Canvas]]);
|
||||
let start = columns(&workspace.layout, Side::Start);
|
||||
let end = columns(&workspace.layout, Side::End);
|
||||
assert_eq!(dock_panels(&workspace.layout, Side::Start), [[[Panel::ToolSets]]]);
|
||||
assert_eq!(dock_panels(&workspace.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]);
|
||||
assert_eq!((start[0].width, end[0].width), (TOOL_SETS_WIDTH, PALETTES_WIDTH));
|
||||
assert!(start.iter().chain(&end).all(|column| !column.iconic));
|
||||
assert!(start.iter().chain(&end).flat_map(|column| &column.groups).all(|group| group.height.is_none() && !group.minimized));
|
||||
assert!(!workspace.layout.contains(&Panel::QuickCalc) && !workspace.layout.contains(&Panel::ExternalRefs));
|
||||
|
||||
// Drawn: Tool Sets left of the drawing, Layers above Properties right of it, Layers at its
|
||||
// own height and Properties taking the rest of the column.
|
||||
let h = harness(None, 1280.0, 1.0);
|
||||
let drawing = h.state().canvas.rect.expect("the drawing view is drawn");
|
||||
let tools = hit(&h, Hit::Tab(Panel::ToolSets));
|
||||
let layers = hit(&h, Hit::Tab(Panel::Layers));
|
||||
let properties = hit(&h, Hit::Tab(Panel::Properties));
|
||||
assert!(tools.right() <= drawing.left() + 1.0, "{tools:?} {drawing:?}");
|
||||
assert!(layers.left() >= drawing.right() - 1.0 && properties.left() >= drawing.right() - 1.0, "{layers:?} {properties:?} {drawing:?}");
|
||||
let seam = hit(&h, Hit::Divider(Divider::Group(Panel::Layers)));
|
||||
assert!((seam.center().y - layers.bottom() - 280.0).abs() < 8.0, "{layers:?} {seam:?}");
|
||||
assert!(properties.top() > seam.top());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_icons_are_painted_in_code_and_shown_in_an_iconic_column() {
|
||||
use cadcraft_ui_egui::docking::Panels;
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let mut paint = |panel: Panel| {
|
||||
let mut painted = false;
|
||||
let mut content = Panels { app: &mut app };
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::background());
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(20.0, 20.0));
|
||||
painted = craft_ui::docking::DockContent::paint_icon(&mut content, &painter, rect, &panel, egui::Color32::RED);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
(painted, output.shapes.len())
|
||||
};
|
||||
for panel in [Panel::ToolSets, Panel::QuickCalc, Panel::Layers, Panel::Properties, Panel::ExternalRefs] {
|
||||
let (painted, shapes) = paint(panel);
|
||||
assert!(painted && shapes > 0, "{panel:?}: {painted} {shapes}");
|
||||
}
|
||||
assert_eq!(paint(Panel::Canvas), (false, 0));
|
||||
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
h.state_mut().run("ui.dock", json!({"action":{"SetIconic":{"panel":"layers","iconic":true}}})).unwrap();
|
||||
h.run_steps(4);
|
||||
let drawing = h.state().canvas.rect.unwrap();
|
||||
let layers = hit(&h, Hit::Icon(Panel::Layers));
|
||||
let properties = hit(&h, Hit::Icon(Panel::Properties));
|
||||
assert!(layers.left() >= drawing.right() - 1.0 && layers.top() < properties.top(), "{layers:?} {properties:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_panel_drag_float_close_reopen_and_full_workspace_reload() {
|
||||
let mut harness = harness(None, 1280.0, 1.0);
|
||||
@@ -94,9 +199,11 @@ fn invalid_requests_leave_document_and_panel_layout_unchanged() {
|
||||
json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"right", "before":null}),
|
||||
json!({"operation":"spin", "panel":"layers"}),
|
||||
json!({"action":{"Float":{"panel":"canvas","rect":[0,0,100,100]}}}),
|
||||
json!({"action":{"MovePanel":{"panel":"canvas","to":{"NewDock":{"side":"Start"}}}}}),
|
||||
json!({"action":{"Move":{"panel":"layers","anchor":"canvas","placement":{"Tab":{"before":null}}}}}),
|
||||
json!(null),
|
||||
] {
|
||||
assert!(harness.state_mut().run("ui.dock", params).is_err());
|
||||
assert!(harness.state_mut().run("ui.dock", params.clone()).is_err(), "{params}");
|
||||
assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before);
|
||||
assert_eq!(harness.state().session.doc().unwrap(), &drawing);
|
||||
}
|
||||
@@ -131,48 +238,39 @@ fn docking_renders_default_float_and_redocked_at_normal_and_narrow_sizes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_reaches_split_stack_resize_and_tab_reorder_through_the_same_handler() {
|
||||
use craft_ui::{docking::Action, layout::SplitSize};
|
||||
fn automation_reaches_column_and_group_resizes_tab_reorder_and_minimize_through_the_same_handler() {
|
||||
use craft_ui::docking::Action;
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
h.state_mut()
|
||||
.run(
|
||||
"ui.dock",
|
||||
json!({"action":serde_json::to_value(Action::<Panel>::ResizeSplit { path: vec![], size: SplitSize::FixedFirst(280.0) }).unwrap()}),
|
||||
)
|
||||
.unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"setStackOpen","panel":"layers","open":false})).unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"resizeStack","panel":"layers","height":160.0})).unwrap();
|
||||
assert_eq!(h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack, Some((false, Some(160.0))));
|
||||
let act = |h: &mut Harness<'_, CadApp>, action: Action<Panel>| {
|
||||
h.state_mut().run("ui.dock", json!({"action": serde_json::to_value(action).unwrap()})).unwrap();
|
||||
};
|
||||
act(&mut h, Action::ResizeColumn { panel: Panel::ToolSets, width: 280.0 });
|
||||
act(&mut h, Action::ResizeGroup { panel: Panel::Layers, height: Some(160.0) });
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert_eq!(columns(layout, Side::Start)[0].width, 280.0);
|
||||
assert_eq!(columns(layout, Side::End)[0].groups[0].height, Some(160.0));
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"layers","target":"properties","zone":"center","before":null})).unwrap();
|
||||
assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [[[Panel::Properties, Panel::Layers]]]);
|
||||
act(&mut h, Action::SetMinimized { panel: Panel::Layers, minimized: true });
|
||||
h.run_steps(4);
|
||||
let panels = h.state().ui.docking.layout.panels();
|
||||
assert!(panels.iter().position(|p| **p == Panel::Properties).unwrap() < panels.iter().position(|p| **p == Panel::Layers).unwrap());
|
||||
assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap().size, Some(SplitSize::FixedFirst(280.0)));
|
||||
assert!(columns(&h.state().ui.docking.layout, Side::End)[0].groups[0].minimized);
|
||||
assert_eq!(columns(&h.state().ui.docking.layout, Side::Start)[0].width, 280.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing() {
|
||||
fn group_divider_resizes_by_pointer_and_keyboard_without_changing_the_drawing() {
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
let drawing = h.state().session.doc().unwrap().clone();
|
||||
let center = h
|
||||
.get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels")
|
||||
.into_iter()
|
||||
.find(|node| node.rect().width() > node.rect().height())
|
||||
.unwrap()
|
||||
.rect()
|
||||
.center();
|
||||
let layers_height = |h: &Harness<'_, CadApp>| columns(&h.state().ui.docking.layout, Side::End)[0].groups[0].height;
|
||||
let center = hit(&h, Hit::Divider(Divider::Group(Panel::Layers))).center();
|
||||
drag(&mut h, center, center + vec2(0.0, -45.0));
|
||||
let height = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap();
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels")
|
||||
.into_iter()
|
||||
.find(|node| node.rect().width() > node.rect().height())
|
||||
.unwrap()
|
||||
.focus();
|
||||
let height = layers_height(&h).expect("the drag sets the Layers group's height");
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(DockArea::<Panel>::hit_id(area(), &Hit::Divider(Divider::Group(Panel::Layers)))));
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(4);
|
||||
let after = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap();
|
||||
assert!(after > height, "keyboard resize must reach the same panel command handler");
|
||||
let after = layers_height(&h).unwrap();
|
||||
assert!(after > height, "keyboard resize must reach the same panel command handler: {height} {after}");
|
||||
assert_eq!(h.state().session.doc().unwrap(), &drawing);
|
||||
}
|
||||
|
||||
@@ -219,6 +317,67 @@ fn palettes_off_and_on_reopen_a_floating_group_as_one_window() {
|
||||
assert!(floating[0].contains(&Panel::Layers) && floating[0].contains(&Panel::Properties), "{floating:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palettes_off_and_on_restore_the_right_dock_column() {
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
let before = h.state().ui.docking.layout.clone();
|
||||
h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().ui.docking.layout.contains(&Panel::Layers) && !h.state().ui.docking.layout.contains(&Panel::Properties));
|
||||
h.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert_eq!(dock_panels(layout, Side::End), dock_panels(&before, Side::End));
|
||||
assert_eq!(columns(layout, Side::End)[0].width, columns(&before, Side::End)[0].width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_menu_reopens_closed_and_new_palettes_in_their_dock_groups() {
|
||||
use cadcraft_ui_egui::docking::{PALETTES_WIDTH, TOOL_SETS_WIDTH};
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
// A closed palette comes back where it was.
|
||||
h.state_mut().run("ui.dock", json!({"operation":"close","panel":"layers"})).unwrap();
|
||||
h.run_steps(3);
|
||||
menus::activate(h.state_mut(), "ui.panel.layers");
|
||||
h.run_steps(3);
|
||||
assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]);
|
||||
// Tool Sets turned off and on in the Window menu comes back in the left dock at its width.
|
||||
menus::activate(h.state_mut(), "ui.toggle.toolsets");
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().ui.docking.layout.contains(&Panel::ToolSets));
|
||||
menus::activate(h.state_mut(), "ui.toggle.toolsets");
|
||||
h.run_steps(3);
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert_eq!(dock_panels(layout, Side::Start), [[[Panel::ToolSets]]]);
|
||||
assert_eq!(columns(layout, Side::Start)[0].width, TOOL_SETS_WIDTH);
|
||||
// After Reset nothing has a recorded place: External References opens below Properties.
|
||||
h.state_mut().run("ui.dock.reset", json!({})).unwrap();
|
||||
h.state_mut().run("xref", serde_json::Value::Null).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties], vec![Panel::ExternalRefs]]]);
|
||||
// Tool Sets with no recorded place and no left dock makes one at its width.
|
||||
let mut workspace = cadcraft_ui_egui::docking::default_workspace();
|
||||
workspace.apply(craft_ui::docking::Action::Close { panel: Panel::ToolSets }, cadcraft_ui_egui::docking::permissions).unwrap();
|
||||
workspace.hidden.clear();
|
||||
h.state_mut().ui.docking = workspace;
|
||||
h.state_mut().ui.show_toolsets = false;
|
||||
menus::activate(h.state_mut(), "ui.toggle.toolsets");
|
||||
h.run_steps(3);
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert_eq!(dock_panels(layout, Side::Start), [[[Panel::ToolSets]]]);
|
||||
assert_eq!(columns(layout, Side::Start)[0].width, TOOL_SETS_WIDTH);
|
||||
// With the palettes off, External References makes a new right dock at the palettes' width.
|
||||
h.state_mut().run("ui.dock.reset", json!({})).unwrap();
|
||||
h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("xref", serde_json::Value::Null).unwrap();
|
||||
h.run_steps(3);
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert_eq!(dock_panels(layout, Side::End), [[[Panel::ExternalRefs]]]);
|
||||
assert_eq!(columns(layout, Side::End)[0].width, PALETTES_WIDTH);
|
||||
assert_eq!(frames(layout), [[Panel::Canvas]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() {
|
||||
let mut harness = harness(None, 1000.0, 1.0);
|
||||
@@ -276,15 +435,8 @@ fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspac
|
||||
let drawing = h.state().session.doc().unwrap().clone();
|
||||
let tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
type_command(&mut h, "PROPERTIES");
|
||||
assert!(h.state().ui.docking.layout.contains(&Panel::Properties));
|
||||
assert!(h.state().ui.docking.layout.contains(&Panel::Layers));
|
||||
let restored_tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
assert_eq!(restored_tools.anchor, tools.anchor);
|
||||
assert_eq!(restored_tools.placement, tools.placement);
|
||||
assert_eq!(restored_tools.size, tools.size);
|
||||
assert_eq!(restored_tools.floating, tools.floating);
|
||||
assert_eq!(restored_tools.stack, tools.stack);
|
||||
assert!(restored_tools.sibling.contains(&Panel::Properties));
|
||||
assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]);
|
||||
assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(), tools);
|
||||
|
||||
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap();
|
||||
@@ -310,34 +462,27 @@ fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() {
|
||||
fn canvas_reset_during_column_drag_discards_old_resize_and_ends_gesture() {
|
||||
use craft_ui::docking::Action;
|
||||
use craft_ui::layout::SplitSize;
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
// Give the old root divider a size different from Reset's 220px ToolSets pane.
|
||||
// Give the Tool Sets column a width different from Reset's.
|
||||
h.state_mut()
|
||||
.run(
|
||||
"ui.dock",
|
||||
json!({"action":Action::<Panel>::ResizeSplit {
|
||||
path: Vec::new(), size: SplitSize::FixedFirst(280.0),
|
||||
}}),
|
||||
)
|
||||
.run("ui.dock", json!({"action": serde_json::to_value(Action::ResizeColumn { panel: Panel::ToolSets, width: 280.0 }).unwrap()}))
|
||||
.unwrap();
|
||||
h.run_steps(3);
|
||||
let drawing = h.state().session.doc().unwrap().clone();
|
||||
let splitter = egui::Id::unique("cadcraft-docking").with(("split", Vec::<bool>::new()));
|
||||
let from = h.ctx.read_response(splitter).expect("rendered root divider").rect.center();
|
||||
let divider = Hit::Divider(Divider::Column(Panel::ToolSets));
|
||||
let handle = DockArea::<Panel>::hit_id(area(), ÷r);
|
||||
let from = hit(&h, divider).center();
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
// Canvas is visited before the ancestor ResizeHandle. The command runs before
|
||||
// that ancestor queues this frame's ResizeSplit against the old immutable tree.
|
||||
assert_eq!(h.ctx.dragged_id(), Some(splitter), "actual divider owns the pointer");
|
||||
assert_eq!(h.ctx.dragged_id(), Some(handle), "the column's divider owns the pointer");
|
||||
assert!(h.ctx.input(|input| input.pointer.primary_down()));
|
||||
// A ResizeHandle takes keyboard focus on press. Release only that keyboard focus
|
||||
// so the real Canvas command line can submit while the pointer remains captured.
|
||||
h.ctx.memory_mut(|memory| memory.surrender_focus(splitter));
|
||||
// A divider takes keyboard focus on press. Release only that keyboard focus so the real
|
||||
// Canvas command line can submit while the pointer remains captured.
|
||||
h.ctx.memory_mut(|memory| memory.surrender_focus(handle));
|
||||
assert!(!h.ctx.egui_wants_keyboard_input());
|
||||
let to = from + vec2(36.0, 0.0);
|
||||
h.event(Event::PointerMoved(to));
|
||||
@@ -346,10 +491,10 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() {
|
||||
h.run_steps(1);
|
||||
let expected = cadcraft_ui_egui::docking::default_workspace();
|
||||
assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command");
|
||||
assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree");
|
||||
assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's layout");
|
||||
assert_eq!(h.state().ui.docking.hidden, expected.hidden);
|
||||
// Keep the physical button down: replacement must terminate the old divider gesture,
|
||||
// rather than letting its stable root ID continue resizing the new workspace.
|
||||
// Keep the physical button down: replacement must end the old divider gesture rather than
|
||||
// letting it go on resizing the new workspace.
|
||||
let later = to + vec2(24.0, 0.0);
|
||||
h.event(Event::PointerMoved(later));
|
||||
h.run_steps(2);
|
||||
@@ -386,35 +531,65 @@ fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_drag_bar_moves_the_whole_group_beside_the_drawing() {
|
||||
use craft_ui::docking::{DockArea, Hit, Node};
|
||||
fn palettes_dock_only_in_the_left_and_right_columns() {
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
let document = h.state().session.doc().unwrap().clone();
|
||||
let drawing = h.state().canvas.rect.expect("the drawing view is drawn");
|
||||
// Neither the drawing nor the bottom edge takes a drop: the palette floats there.
|
||||
for to in [drawing.center(), egui::pos2(drawing.center().x, drawing.bottom() - 4.0)] {
|
||||
h.state_mut().run("ui.dock.reset", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let from = hit(&h, Hit::Tab(Panel::Layers)).center();
|
||||
drag(&mut h, from, to);
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert_eq!(frames(layout), [[Panel::Canvas]], "{to:?}: {:?}", layout.root);
|
||||
assert_eq!(dock_panels(layout, Side::End), [[[Panel::Properties]]], "{to:?}");
|
||||
assert!(layout.floating.iter().any(|window| window.contains(&Panel::Layers)), "{to:?}: {layout:?}");
|
||||
}
|
||||
// The left window edge, where Tool Sets docks, takes the palette as a new outer column.
|
||||
h.state_mut().run("ui.dock.reset", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let column = hit(&h, Hit::ColumnBar(Panel::ToolSets));
|
||||
let from = hit(&h, Hit::Tab(Panel::Layers)).center();
|
||||
drag(&mut h, from, egui::pos2(column.left() + 3.0, drawing.center().y));
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert_eq!(dock_panels(layout, Side::Start), [[[Panel::Layers]], [[Panel::ToolSets]]], "{:?}", layout.root);
|
||||
assert_eq!(frames(layout), [[Panel::Canvas]]);
|
||||
// Without a left dock, the left window edge makes one.
|
||||
h.state_mut().run("ui.dock.reset", json!({})).unwrap();
|
||||
h.state_mut().run("ui.toggle.toolsets", json!({"on": false})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(columns(&h.state().ui.docking.layout, Side::Start).iter().all(|column| column.groups.is_empty()));
|
||||
let drawing = h.state().canvas.rect.unwrap();
|
||||
let from = hit(&h, Hit::Tab(Panel::Layers)).center();
|
||||
drag(&mut h, from, egui::pos2(drawing.left() + 3.0, drawing.center().y));
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert_eq!(dock_panels(layout, Side::Start), [[[Panel::Layers]]], "{:?}", layout.root);
|
||||
assert_eq!(frames(layout), [[Panel::Canvas]]);
|
||||
assert_eq!(h.state().session.doc().unwrap(), &document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_drag_bar_moves_the_whole_group_into_the_other_dock() {
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap();
|
||||
h.run_steps(4);
|
||||
let bar = |h: &Harness<'_, CadApp>| {
|
||||
DockArea::<Panel>::hit_rects(&h.ctx, egui::Id::unique("cadcraft-docking"))
|
||||
.into_iter()
|
||||
.find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers))
|
||||
.map(|(_, rect)| rect)
|
||||
};
|
||||
let from = bar(&h).expect("the Tool Sets group draws a drag bar").center();
|
||||
let drawing = h.state().canvas.rect.expect("the drawing view is drawn");
|
||||
let to = egui::pos2(drawing.right() - 10.0, drawing.center().y);
|
||||
let bar = DockArea::<Panel>::hit_rects(&h.ctx, area())
|
||||
.into_iter()
|
||||
.find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers))
|
||||
.map(|(_, rect)| rect)
|
||||
.expect("the Tool Sets group draws a drag bar");
|
||||
// Into the middle of the Properties group, as tabs.
|
||||
let properties = hit(&h, Hit::Tab(Panel::Properties));
|
||||
let column = hit(&h, Hit::ColumnBar(Panel::Properties));
|
||||
let to = egui::pos2(column.center().x, properties.bottom() + 120.0);
|
||||
let document = h.state().session.doc().unwrap().clone();
|
||||
drag(&mut h, from, to);
|
||||
drag(&mut h, bar.center(), to);
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert!(layout.floating.is_empty(), "a drop beside the drawing docks the group");
|
||||
// The drawing's frame splits: the drawing on the left, the whole group on the right.
|
||||
let mut nodes: Vec<&Node<Panel>> = layout.root.iter().collect();
|
||||
let mut beside = false;
|
||||
while let Some(node) = nodes.pop() {
|
||||
if let Node::Split { first, second, .. } = node {
|
||||
beside |= matches!((&**first, &**second), (Node::Tabs { panels: left, .. }, Node::Tabs { panels: right, .. })
|
||||
if left == &[Panel::Canvas] && right == &[Panel::ToolSets, Panel::Layers]);
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
}
|
||||
assert!(beside, "the group moves as one to the drawing's right: {:?}", layout.root);
|
||||
assert!(layout.floating.is_empty(), "{layout:?}");
|
||||
let end = dock_panels(layout, Side::End);
|
||||
assert_eq!(end.len(), 1, "{end:?}");
|
||||
assert!(end[0].iter().any(|group| group.contains(&Panel::ToolSets) && group.contains(&Panel::Layers)), "{end:?}");
|
||||
assert_eq!(frames(layout), [[Panel::Canvas]]);
|
||||
assert_eq!(h.state().session.doc().unwrap(), &document);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ mod lisp_files;
|
||||
mod measure_distance_snapping;
|
||||
mod menubar_hover;
|
||||
mod mspace_viewport_view;
|
||||
mod native_menu_text_keys;
|
||||
mod native_windows;
|
||||
mod options_dialog;
|
||||
mod parameters_rename;
|
||||
mod pickdrag;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! The macOS menu bar takes the Edit menu's key equivalents before the window does: with a text
|
||||
//! field focused they edit the field, and only without one do they run the drawing command.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services, menus};
|
||||
use egui::{accesskit::Role, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> Harness<'static, CadApp> {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
app.ui.start_tab = false;
|
||||
app.ui.in_window_menu = false;
|
||||
let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut CadApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.ctx.set_os(egui::os::OperatingSystem::Mac);
|
||||
h.run_steps(5);
|
||||
h
|
||||
}
|
||||
|
||||
/// A native menu choice, then the frames that deliver what it queued for the main window.
|
||||
fn menu(h: &mut Harness<'_, CadApp>, id: &str) {
|
||||
let ctx = h.ctx.clone();
|
||||
menus::activate_native(h.state_mut(), &ctx, id);
|
||||
let mut raw = egui::RawInput::default();
|
||||
h.state_mut().raw_input_hook(&ctx, &mut raw);
|
||||
h.input_mut().events.extend(raw.events);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn expression<'a>(h: &'a Harness<'_, CadApp>) -> egui_kittest::Node<'a> {
|
||||
h.get_by_role_and_label(Role::Tab, "QuickCalc").parent().unwrap().get_all_by_role(Role::TextInput).next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_menu_keys_edit_the_focused_text_field_and_otherwise_the_drawing() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("line", json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let drawing = h.state().session.doc().unwrap().clone();
|
||||
menus::activate(h.state_mut(), "ui.toggle.quickcalc");
|
||||
h.run_steps(5);
|
||||
expression(&h).click();
|
||||
h.run_steps(3);
|
||||
expression(&h).type_text("2+3");
|
||||
h.run_steps(3);
|
||||
assert_eq!(menus::text_field_window(&h.ctx), Some(egui::ViewportId::ROOT));
|
||||
assert!(menus::edits_text("pasteclip") && !menus::edits_text("line"));
|
||||
menu(&mut h, "selectall");
|
||||
menu(&mut h, "cutclip");
|
||||
assert_eq!(expression(&h).value().as_deref(), Some(""), "Select All and Cut act on the field");
|
||||
assert!(h.state().session.running.is_none());
|
||||
assert_eq!(h.state().session.doc().unwrap(), &drawing);
|
||||
|
||||
h.ctx.memory_mut(|m| m.stop_text_input());
|
||||
h.run_steps(3);
|
||||
assert_eq!(menus::text_field_window(&h.ctx), None);
|
||||
menu(&mut h, "u");
|
||||
assert_ne!(h.state().session.doc().unwrap(), &drawing, "without a focused field, Undo undoes the line");
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Floating panels in their own OS windows: a modal dialog blocks their input, and keys typed in
|
||||
//! one run the main window's shortcuts and command line.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::autosave::Recoverable;
|
||||
use cadcraft_ui_egui::closing::Choice;
|
||||
use cadcraft_ui_egui::docking::native_input_blocked;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use serde_json::json;
|
||||
|
||||
fn frame(app: &mut CadApp, ctx: &egui::Context) {
|
||||
frame_with(app, ctx, Vec::new());
|
||||
}
|
||||
|
||||
/// A frame whose main-window input carries `events`.
|
||||
fn frame_with(app: &mut CadApp, ctx: &egui::Context, events: Vec<Event>) {
|
||||
let mut raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), events, ..Default::default() };
|
||||
app.raw_input_hook(ctx, &mut raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, &mut raw);
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn pump(app: &mut CadApp, ctx: &egui::Context) {
|
||||
for _ in 0..3 {
|
||||
frame(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
fn running(app: &CadApp) -> Option<&str> {
|
||||
app.session.running.as_ref().map(|command| command.id.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_dialogs_block_native_panel_windows() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
pump(&mut app, &ctx);
|
||||
assert!(!native_input_blocked(&app, &ctx));
|
||||
|
||||
// "Save changes?" while a dirty drawing closes.
|
||||
app.run("circle", json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
app.request_close(None);
|
||||
assert!(native_input_blocked(&app, &ctx), "close prompt");
|
||||
pump(&mut app, &ctx);
|
||||
assert!(native_input_blocked(&app, &ctx), "close prompt shown");
|
||||
app.answer_close(Choice::Cancel);
|
||||
pump(&mut app, &ctx);
|
||||
assert!(!native_input_blocked(&app, &ctx), "after Cancel");
|
||||
|
||||
// Drawing Recovery.
|
||||
app.recovery.items.push(Recoverable { path: "missing.sv$".into(), drawing: "Plan.dxf".into(), modified: 0, bytes: 0 });
|
||||
assert!(native_input_blocked(&app, &ctx), "drawing recovery");
|
||||
app.recovery.items.clear();
|
||||
pump(&mut app, &ctx);
|
||||
assert!(!native_input_blocked(&app, &ctx));
|
||||
|
||||
// Any other egui modal, from the frame after it is shown.
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
egui::Modal::new(egui::Id::unique("test_modal")).show(ui.ctx(), |ui| ui.label("modal"));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(native_input_blocked(&app, &ctx), "egui modal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_forwarded_from_a_native_window_reach_the_command_line_and_shortcuts() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
pump(&mut app, &ctx);
|
||||
|
||||
let typed = vec![Event::Text("LINE".into()), key(Key::Enter, true, Modifiers::NONE), key(Key::Enter, false, Modifiers::NONE)];
|
||||
app.forward_native_input(&ctx, typed, Some(Modifiers::NONE));
|
||||
pump(&mut app, &ctx);
|
||||
assert_eq!(running(&app), Some("line"));
|
||||
assert_eq!(app.cmd.history, ["LINE"]);
|
||||
|
||||
let escape = vec![key(Key::Escape, true, Modifiers::NONE), key(Key::Escape, false, Modifiers::NONE)];
|
||||
app.forward_native_input(&ctx, escape, Some(Modifiers::NONE));
|
||||
pump(&mut app, &ctx);
|
||||
assert_eq!(running(&app), None);
|
||||
|
||||
let ortho = app.session.settings.orthomode;
|
||||
app.forward_native_input(&ctx, vec![key(Key::F8, true, Modifiers::NONE), key(Key::F8, false, Modifiers::NONE)], Some(Modifiers::NONE));
|
||||
pump(&mut app, &ctx);
|
||||
assert_eq!(app.session.settings.orthomode, !ortho, "F8");
|
||||
|
||||
// Shift held in the native window: Cmd/Ctrl+Shift+C arrives as Copy and runs COPYBASE. The
|
||||
// main window's own modifiers come back afterwards.
|
||||
app.forward_native_input(&ctx, vec![Event::Copy], Some(Modifiers::SHIFT | Modifiers::COMMAND));
|
||||
pump(&mut app, &ctx);
|
||||
assert_eq!(running(&app), Some("copybase"));
|
||||
assert_eq!(ctx.input(|i| i.modifiers), Modifiers::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_main_window_modifier_change_outlasts_keys_forwarded_meanwhile() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
pump(&mut app, &ctx);
|
||||
frame_with(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]);
|
||||
app.forward_native_input(&ctx, vec![key(Key::F8, true, Modifiers::COMMAND), key(Key::F8, false, Modifiers::COMMAND)], Some(Modifiers::COMMAND));
|
||||
// Shift is released in the main window in the frame the forwarded keys go in.
|
||||
frame_with(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE)]);
|
||||
pump(&mut app, &ctx);
|
||||
assert_eq!(ctx.input(|i| i.modifiers), Modifiers::NONE, "the released Shift is not put back");
|
||||
}
|
||||
Reference in New Issue
Block a user