mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 19:36:49 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
920f487637 |
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
timeout-minutes: 90
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
|
||||
@@ -29,4 +29,4 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -23,11 +23,6 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
|
||||
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
|
||||
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
|
||||
Generated
+222
-1042
File diff suppressed because it is too large
Load Diff
+8
-38
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/cadcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -25,10 +25,7 @@ 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" }
|
||||
# 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-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -53,14 +50,11 @@ 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"] }
|
||||
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-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
egui_kittest = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui-wgpu = "0.36"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -86,27 +80,3 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
|
||||
# (Cargo ignores dependency-local patches).
|
||||
[patch.crates-io]
|
||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -15,36 +15,10 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/
|
||||
is available from crates.io and the repository above.
|
||||
|
||||
|
||||
CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code.
|
||||
The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md.
|
||||
Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its
|
||||
ATTRIBUTION.md, each under the SIL Open Font License 1.1.
|
||||
None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and
|
||||
linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with
|
||||
the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md,
|
||||
each under the SIL Open Font License 1.1.
|
||||
|
||||
CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
|
||||
|
||||
|
||||
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
|
||||
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
|
||||
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
|
||||
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
|
||||
(pinned through https://github.com/storytold/craft-libs/tree/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc).
|
||||
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
|
||||
Pinned licence text: https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/LICENSE
|
||||
|
||||
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
|
||||
and registered through egui's default font definitions. They derive from emoji-icon-font by
|
||||
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Original: https://github.com/jslegers/emoji-icon-font
|
||||
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -123,7 +123,8 @@ numbers):
|
||||
|
||||
## Quick start
|
||||
|
||||
CADCraft requires Rust 1.95 or later.
|
||||
Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum
|
||||
remains Rust 1.90.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/cadcraft
|
||||
|
||||
@@ -226,12 +226,6 @@ fn info(path: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
|
||||
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched.
|
||||
fn write_atomic(out: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(out), bytes)
|
||||
}
|
||||
|
||||
fn export(s: &Session, out: &str) -> Result<(), String> {
|
||||
export_framed(s, out, None)
|
||||
}
|
||||
@@ -239,7 +233,7 @@ fn export(s: &Session, out: &str) -> Result<(), String> {
|
||||
fn export_framed(s: &Session, out: &str, window: Option<Bounds2>) -> Result<(), String> {
|
||||
let d = s.doc().map_err(|e| e.to_string())?;
|
||||
let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?;
|
||||
write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
if cadcraft_engine::cmd::file::drops_unknown(out) {
|
||||
let lost = cadcraft_engine::cmd::file::not_saved(d);
|
||||
@@ -258,7 +252,7 @@ fn sample(args: &[String]) -> Result<(), String> {
|
||||
other => return Err(format!("unknown sample `{other}` (bracket, floorplan)")),
|
||||
};
|
||||
let bytes = cadcraft_io::write(&d, out).map_err(|e| e.to_string())?;
|
||||
write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -868,37 +862,6 @@ fn cli() -> ExitCode {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_replaces_file_and_leaves_no_staging_files() {
|
||||
let dir = scratch_dir("replace");
|
||||
let out = dir.join("a.dxf");
|
||||
let out = out.to_str().expect("utf-8 path");
|
||||
write_atomic(out, b"old").expect("create");
|
||||
write_atomic(out, b"new").expect("overwrite");
|
||||
assert_eq!(std::fs::read(out).expect("read"), b"new");
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_failure_keeps_destination_and_cleans_up() {
|
||||
let dir = scratch_dir("fail");
|
||||
let out = dir.join("target");
|
||||
std::fs::create_dir(&out).expect("dir as destination");
|
||||
std::fs::write(out.join("keep"), b"x").expect("seed");
|
||||
assert!(write_atomic(out.to_str().expect("utf-8 path"), b"new").is_err());
|
||||
assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x");
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
|
||||
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
@@ -27,7 +26,7 @@ serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
|
||||
|
||||
@@ -95,8 +95,7 @@ impl Bridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
|
||||
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
|
||||
/// After each frame: tell the parent when unsaved changes appear or go away.
|
||||
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
|
||||
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
|
||||
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
|
||||
@@ -41,22 +41,18 @@ mod web {
|
||||
/// document title when it changed (so the tab and an embedding page name the open
|
||||
/// drawing), and reports unsaved changes to that page.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Background reads are consume-based and run on hidden ticks too. Dropped files are
|
||||
// input and are read from `ui` only.
|
||||
self.1.take_dropped(ctx);
|
||||
self.1.drain(&mut self.0);
|
||||
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
|
||||
self.0.logic(ctx);
|
||||
let drawing = self.0.session.state().ok();
|
||||
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
|
||||
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
|
||||
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
}
|
||||
}
|
||||
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
self.0.raw_input_hook(raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
|
||||
@@ -67,15 +63,11 @@ mod web {
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let first_pass = ui.ctx().current_pass_index() == 0;
|
||||
if first_pass {
|
||||
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
|
||||
// re-read the same file.
|
||||
self.1.take_dropped(ui.ctx());
|
||||
}
|
||||
self.0.ui(ui);
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,9 +68,8 @@ impl Inbox {
|
||||
}
|
||||
|
||||
/// Read the files dropped on the page this frame (eframe hands them over unread on the web).
|
||||
/// The list is taken, not cloned: a later pass or hidden tick must not read the same drop.
|
||||
pub fn take_dropped(&self, ctx: &egui::Context) {
|
||||
for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) {
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
if let Some(file) = f.web_file() {
|
||||
self.read(file.clone());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ description = "CADCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
publish = false
|
||||
default-run = "cadcraft"
|
||||
|
||||
@@ -17,7 +16,7 @@ cadcraft-ui-egui = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
# For the shared per-session control-token helper (control_server.rs authentication).
|
||||
cadcraft-mcp = { workspace = true }
|
||||
eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -34,7 +33,6 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
|
||||
+13
-37
@@ -65,47 +65,37 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
/// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
|
||||
self.cad.logic(ctx);
|
||||
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
|
||||
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
|
||||
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
|
||||
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
|
||||
if ctx.current_pass_index() == 0 {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad, ctx));
|
||||
}
|
||||
if let Some(m) = &mut self.native_menu {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
|
||||
}
|
||||
if let Some(m) = &mut self.native_menu {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
self.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.cad.system_animations = self.system_animations.current();
|
||||
self.cad.logic(ctx);
|
||||
self.spacemouse.poll(&mut self.cad, ctx);
|
||||
self.persist_workspace(false);
|
||||
if std::mem::take(&mut self.cad.quit_requested) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
/// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
follow_desktop(raw, self.desktop_theme.current());
|
||||
self.cad.raw_input_hook(ctx, raw);
|
||||
self.cad.raw_input_hook(raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.cad.ui(ui);
|
||||
self.persist_workspace(false);
|
||||
}
|
||||
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
|
||||
/// crash leaves them for recovery).
|
||||
fn on_exit(&mut self) {
|
||||
self.persist_workspace(true);
|
||||
self.cad.cancel_screenshots();
|
||||
self.cad.shutdown_autosave();
|
||||
}
|
||||
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
|
||||
@@ -242,19 +232,9 @@ fn control_flag(current: Option<u16>, value: Option<String>) -> Option<u16> {
|
||||
current
|
||||
}
|
||||
|
||||
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
// The update helper relaunches this binary after a self-update; it runs and exits here.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::CADCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("CADCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let mut control_port =
|
||||
control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()));
|
||||
@@ -300,9 +280,6 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
|
||||
// system-font discovery as a hidden fallback.
|
||||
system_font_fallback: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
@@ -361,7 +338,6 @@ fn main() -> eframe::Result {
|
||||
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
|
||||
if let Some(dir) = config_dir() {
|
||||
app.enable_autosave(&dir.join("autosave"));
|
||||
app.enable_update_check();
|
||||
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
|
||||
// folder is made so it is there to put them in.
|
||||
let support = dir.join("support");
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use cadcraft_ui_egui::menus::{self, Entry};
|
||||
use cadcraft_ui_egui::{CadApp, i18n};
|
||||
@@ -16,14 +15,10 @@ pub struct NativeMenu {
|
||||
submenus: Vec<(String, Submenu)>,
|
||||
predefined: Vec<(&'static str, PredefinedMenuItem)>,
|
||||
language: &'static str,
|
||||
/// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh
|
||||
/// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first
|
||||
/// ~300 ms of menu enablement.
|
||||
last_refresh: f64,
|
||||
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
|
||||
recent: Vec<String>,
|
||||
counter: usize,
|
||||
events: mpsc::Receiver<MenuEvent>,
|
||||
}
|
||||
|
||||
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
|
||||
@@ -35,16 +30,7 @@ fn accel(sc: &str) -> Option<Accelerator> {
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
/// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus.
|
||||
pub fn install(app: &mut CadApp, ctx: &egui::Context) -> Self {
|
||||
// A menu choice (or its shortcut, such as Cmd+W) is no window event, so winit would not
|
||||
// wake the app for it: the handler does, and the choice runs in the next frame.
|
||||
let (sender, events) = mpsc::channel();
|
||||
let wake = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.send(event);
|
||||
wake.request_repaint();
|
||||
}));
|
||||
pub fn install(app: &mut CadApp) -> Self {
|
||||
let menu = Menu::new();
|
||||
let mut items = HashMap::new();
|
||||
let language = app.language();
|
||||
@@ -80,17 +66,7 @@ impl NativeMenu {
|
||||
submenus.push((title, sub));
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
Self {
|
||||
_menu: menu,
|
||||
items,
|
||||
submenus,
|
||||
predefined,
|
||||
language,
|
||||
last_refresh: f64::NEG_INFINITY,
|
||||
recent: app.ui.recent_files.clone(),
|
||||
counter,
|
||||
events,
|
||||
}
|
||||
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
|
||||
}
|
||||
|
||||
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
|
||||
@@ -111,9 +87,8 @@ impl NativeMenu {
|
||||
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
|
||||
}
|
||||
|
||||
/// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed.
|
||||
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
while let Ok(ev) = MenuEvent::receiver().try_recv() {
|
||||
if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) {
|
||||
if cmd == "ui.discord" {
|
||||
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
|
||||
@@ -138,7 +113,7 @@ impl NativeMenu {
|
||||
}
|
||||
}
|
||||
let now = cadcraft_ui_egui::now_ms();
|
||||
if !refresh_due(now, self.last_refresh) {
|
||||
if now - self.last_refresh < 300.0 {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
@@ -149,13 +124,6 @@ impl NativeMenu {
|
||||
}
|
||||
}
|
||||
|
||||
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
|
||||
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
|
||||
/// suppress the first refresh for 300 ms.
|
||||
fn refresh_due(now: f64, last_refresh: f64) -> bool {
|
||||
now - last_refresh >= 300.0
|
||||
}
|
||||
|
||||
fn build(
|
||||
parent: &Submenu,
|
||||
entries: &[Entry],
|
||||
@@ -182,17 +150,3 @@ fn build(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::refresh_due;
|
||||
|
||||
/// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`.
|
||||
#[test]
|
||||
fn the_first_poll_refreshes_immediately() {
|
||||
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
|
||||
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
|
||||
assert!(!refresh_due(299.9, 0.0));
|
||||
assert!(refresh_due(300.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Op
|
||||
}
|
||||
|
||||
fn snapshot(ui: &UiState) -> Result<Value, String> {
|
||||
cadcraft_ui_egui::docking::validate(&ui.docking)?;
|
||||
ui.docking.validate()?;
|
||||
if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") {
|
||||
return Err("toolsetTab must be Drafting or Modeling".into());
|
||||
}
|
||||
@@ -85,7 +85,7 @@ fn read(path: &Path) -> Result<Option<UiState>, String> {
|
||||
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
|
||||
if let Some(docking) = docking
|
||||
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
|
||||
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
|
||||
&& docking.validate().is_ok()
|
||||
{
|
||||
ui.docking = docking;
|
||||
}
|
||||
@@ -236,7 +236,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
|
||||
use cadcraft_ui_egui::docking::{Panel, permissions};
|
||||
use cadcraft_ui_egui::docking::Panel;
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
@@ -244,58 +244,19 @@ mod tests {
|
||||
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
|
||||
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap();
|
||||
ui.docking = app.ui.docking;
|
||||
ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
|
||||
ui.docking.set_visible(Panel::ToolSets, false).unwrap();
|
||||
ui.show_toolsets = false;
|
||||
store.save_if_changed(&ui).unwrap();
|
||||
let (_, reloaded, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none());
|
||||
let mut reloaded = reloaded.unwrap();
|
||||
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
|
||||
reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap();
|
||||
reloaded.docking.set_visible(Panel::ToolSets, true).unwrap();
|
||||
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
|
||||
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_docking_file_still_loads() {
|
||||
use cadcraft_ui_egui::docking::{Panel, reveal_panel};
|
||||
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
|
||||
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
|
||||
let file = r#"{"version":1,"workspace":{
|
||||
"docking":{
|
||||
"layout":{
|
||||
"root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0},
|
||||
"first":{"Tabs":{"panels":["canvas"],"active":0}},
|
||||
"second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}},
|
||||
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
|
||||
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
|
||||
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
|
||||
"showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#;
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
fs::write(&path, file).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none(), "{error:?}");
|
||||
let mut ui = restored.unwrap();
|
||||
assert_eq!(ui.toolset_tab, "Modeling");
|
||||
let docking = &ui.docking;
|
||||
assert_eq!(docking.layout.floating.len(), 1);
|
||||
let window = &docking.layout.floating[0];
|
||||
assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}}));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"],
|
||||
json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}})
|
||||
);
|
||||
assert!(!docking.layout.contains(&Panel::ToolSets));
|
||||
reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap();
|
||||
let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
assert_eq!(tools.anchor, Some(Panel::Canvas));
|
||||
assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}]));
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_oversized_and_newer_state_are_reported_and_preserved() {
|
||||
let directory = temp_dir();
|
||||
|
||||
@@ -87,7 +87,3 @@ real_name = "Richard Sauer"
|
||||
display_name = "Richard Sauer"
|
||||
sync_github_name = false
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ mod budget_tests {
|
||||
aux_init: Vec::new(),
|
||||
aux: 0,
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+44
-195
@@ -81,12 +81,6 @@ pub struct SolveOptions {
|
||||
pub keep: Vec<Handle>,
|
||||
/// Entities that should take up the change.
|
||||
pub prefer_move: Vec<Handle>,
|
||||
/// Points held where they are for this solve, as a Fix would hold them (the grip an edit
|
||||
/// just put down): see [`edited_points`].
|
||||
pub hold: Vec<GeomRef>,
|
||||
/// Where a Fix on a derived point (by constraint id) holds it, instead of where the point is
|
||||
/// now: see [`moved_anchors`].
|
||||
pub anchors: Vec<(u32, Vec2)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
@@ -211,19 +205,12 @@ pub(crate) struct Comp {
|
||||
pub(crate) model: Model,
|
||||
pub(crate) built: Vec<Built>,
|
||||
weights: Vec<f64>,
|
||||
/// Parameters of the points held by [`SolveOptions::hold`].
|
||||
held: Vec<usize>,
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
impl Comp {
|
||||
fn locks(&self, skip: Option<usize>) -> Vec<bool> {
|
||||
let mut locked = vec![false; self.model.x0.len()];
|
||||
for &j in &self.held {
|
||||
if let Some(l) = locked.get_mut(j) {
|
||||
*l = true;
|
||||
}
|
||||
}
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) == skip || b.form != Form::Lock {
|
||||
continue;
|
||||
@@ -258,30 +245,7 @@ impl Comp {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
|
||||
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
|
||||
// A solution the ellipses would be stored differently from (axes swapped, minor axis
|
||||
// mirrored) holds only if the constraints still hold on the stored form: a constraint on
|
||||
// an axis end by index does not.
|
||||
if o.converged && !self.model.storable(&o.x) {
|
||||
let mut out = Vec::new();
|
||||
match self.model.stored_form(&o.x) {
|
||||
Some(mut xc) => {
|
||||
// Contact parameters are positions along the curves: they follow the stored form.
|
||||
for b in self.built.iter().filter(|b| b.form == Form::Contact) {
|
||||
for (k, r) in b.refs.iter().enumerate() {
|
||||
if let (Some(&t), Some(slot)) = (o.x.get(b.aux + k), xc.get_mut(b.aux + k)) {
|
||||
*slot = self.model.stored_t(&o.x, r.handle, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.eval(&xc, &mut out, skip);
|
||||
o.converged = lm::max_abs(&out) <= self.tol;
|
||||
o.x = xc;
|
||||
}
|
||||
None => o.converged = false,
|
||||
}
|
||||
}
|
||||
Ok(o)
|
||||
Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER))
|
||||
}
|
||||
/// Constraint ids in conflict (each one whose removal makes the rest solvable).
|
||||
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
|
||||
@@ -358,56 +322,6 @@ 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;
|
||||
@@ -432,24 +346,55 @@ pub(crate) fn build_budgeted(
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
match interpret(c, &mut model, target) {
|
||||
Ok(mut b) => {
|
||||
if let Form::Anchor(_) = b.form
|
||||
&& let Some(&(_, a)) = opts.anchors.iter().find(|(id, _)| *id == c.id)
|
||||
{
|
||||
b.form = Form::Anchor(a);
|
||||
}
|
||||
built.push(b);
|
||||
}
|
||||
Ok(b) => built.push(b),
|
||||
Err(msg) => skipped.push((c.id, msg)),
|
||||
}
|
||||
}
|
||||
if built.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let weights = solver_weights(&model, opts);
|
||||
let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
|
||||
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 tol = scale_tol(&model.x0);
|
||||
comps.push(Comp { model, built, weights, held, tol });
|
||||
comps.push(Comp { model, built, weights, tol });
|
||||
}
|
||||
Ok((comps, skipped))
|
||||
}
|
||||
@@ -500,9 +445,6 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
|
||||
/// A Fix keeps no position of its own: the solver locks the parameters at their current values.
|
||||
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
|
||||
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
|
||||
/// An object held by a Fix on a derived point (a line's midpoint, an arc's end) has nothing to
|
||||
/// lock: when the edit only moved it (MOVE), it is put back whole, so it is not reshaped to bring
|
||||
/// the point back.
|
||||
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
let refs: Vec<GeomRef> = d
|
||||
.constraints
|
||||
@@ -519,21 +461,10 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
let mut changed = false;
|
||||
for r in &refs {
|
||||
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
|
||||
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue };
|
||||
if !same_shape {
|
||||
continue;
|
||||
}
|
||||
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else {
|
||||
if let (Some(a), Some(b)) = (now.slot(r.handle), old.slot(r.handle))
|
||||
&& translated(&now, &old, r.handle)
|
||||
&& let (Some(dst), Some(src)) = (x.get_mut(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len))
|
||||
{
|
||||
for (v, &w) in dst.iter_mut().zip(src) {
|
||||
*v = w;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
for (i, j) in i.into_iter().zip(j) {
|
||||
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
|
||||
&& *slot != v
|
||||
@@ -549,88 +480,6 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
changed
|
||||
}
|
||||
|
||||
/// The parameters of `h`'s points (see [`Model::point_refs`]) in `m`, as (x, y) pairs.
|
||||
fn point_params(m: &Model, h: Handle) -> Vec<(usize, usize)> {
|
||||
m.point_refs(h)
|
||||
.iter()
|
||||
.filter_map(|r| m.lock_indices(r))
|
||||
.flat_map(|ix| ix.as_chunks::<2>().0.iter().map(|&[x, y]| (x, y)).collect::<Vec<_>>())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Is `h` in `now` its shape in `old` moved by one vector, and nothing else?
|
||||
fn translated(now: &Model, old: &Model, h: Handle) -> bool {
|
||||
let (Some(a), Some(b)) = (now.slot(h), old.slot(h)) else { return false };
|
||||
let (Some(xa), Some(xb)) = (now.x0.get(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) else { return false };
|
||||
let tol = scale_tol(xb) * 10.0;
|
||||
let pts: Vec<(usize, usize)> =
|
||||
point_params(old, h).into_iter().filter_map(|(i, j)| Some((i.checked_sub(b.off)?, j.checked_sub(b.off)?))).collect();
|
||||
let delta = |(i, j): (usize, usize)| Some(Vec2::new(xa.get(i)? - xb.get(i)?, xa.get(j)? - xb.get(j)?));
|
||||
let Some(v) = pts.first().and_then(|&p| delta(p)) else { return false };
|
||||
if v.len() <= tol || !pts.iter().all(|&p| delta(p).is_some_and(|d| d.near(v, tol))) {
|
||||
return false;
|
||||
}
|
||||
(0..xb.len())
|
||||
.filter(|k| !pts.iter().any(|&(i, j)| *k == i || *k == j))
|
||||
.all(|k| matches!((xa.get(k), xb.get(k)), (Some(p), Some(q)) if (p - q).abs() <= tol))
|
||||
}
|
||||
|
||||
/// The points of `h` (its ends, centre or vertices) the edit from `before` to `d` moved, when it
|
||||
/// moved some of them but not all: the grip that was dragged, to pass as [`SolveOptions::hold`] so
|
||||
/// it stays where it was put and the rest of the object takes up the constraints. Empty when the
|
||||
/// object kept its points or moved them all.
|
||||
pub fn edited_points(d: &Drawing, before: &Drawing, h: Handle) -> Vec<GeomRef> {
|
||||
let (now, old) = (Model::build(d, &[h]), Model::build(before, &[h]));
|
||||
if !matches!((now.slot(h), old.slot(h)), (Some(a), Some(b)) if a.shape == b.shape) {
|
||||
return Vec::new();
|
||||
}
|
||||
let tol = scale_tol(&old.x0) * 10.0;
|
||||
let refs = now.point_refs(h);
|
||||
let moved: Vec<GeomRef> = refs
|
||||
.iter()
|
||||
.filter(|r| matches!((now.lock_indices(r), old.lock_indices(r)), (Some(i), Some(j)) if i.iter().zip(&j).any(|(&i, &j)| matches!((now.x0.get(i), old.x0.get(j)), (Some(p), Some(q)) if (p - q).abs() > tol))))
|
||||
.copied()
|
||||
.collect();
|
||||
if moved.len() == refs.len() { Vec::new() } else { moved }
|
||||
}
|
||||
|
||||
/// Where `before` had each derived point held by a Fix (a line's midpoint, an arc's end), for the
|
||||
/// Fixes whose point has moved since, to pass as [`SolveOptions::anchors`].
|
||||
///
|
||||
/// A Fix on a derived point anchors it where it is when the system is built, and
|
||||
/// [`restore_fixed`] has no parameters to put back: without this, an edit that moves the point
|
||||
/// would leave the Fix satisfied at the new place.
|
||||
pub fn moved_anchors(d: &Drawing, before: &Drawing) -> Vec<(u32, Vec2)> {
|
||||
let fixes: Vec<(u32, GeomRef)> = d
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
|
||||
.filter_map(|c| match c.refs.as_slice() {
|
||||
[r] => Some((c.id, *r)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if fixes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let handles: Vec<Handle> = fixes.iter().map(|(_, r)| r.handle).collect();
|
||||
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
|
||||
let tol = scale_tol(&old.x0);
|
||||
let mut out = Vec::new();
|
||||
for (id, r) in fixes {
|
||||
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
|
||||
if !same_shape || now.lock_indices(&r).is_some() {
|
||||
continue;
|
||||
}
|
||||
if let (Some(p), Some(a)) = (now.point(&now.x0, &r), old.point(&old.x0, &r))
|
||||
&& !p.near(a, tol)
|
||||
{
|
||||
out.push((id, a));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when every interpretable constraint holds at the current geometry.
|
||||
pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
|
||||
@@ -32,8 +32,6 @@ pub const MAX_RIGID_POINTS: usize = 256;
|
||||
const MAX_RIGID_MEMBERS: usize = 100_000;
|
||||
/// Shared across every model built for one analysis request.
|
||||
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
|
||||
/// How far past 1 a solved ellipse ratio may land by rounding and still be stored as 1.
|
||||
const RATIO_TOL: f64 = 1e-9;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Shape {
|
||||
@@ -479,18 +477,6 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// The points of an object a Fix locks directly (ends, centre, vertices, insertion point).
|
||||
pub fn point_refs(&self, h: Handle) -> Vec<GeomRef> {
|
||||
let Some(s) = self.slot(h) else { return Vec::new() };
|
||||
let at = |sub: Sub| GeomRef { handle: h, sub };
|
||||
match s.shape {
|
||||
Shape::Line => vec![at(Sub::Start), at(Sub::End)],
|
||||
Shape::Point => vec![at(Sub::Whole)],
|
||||
Shape::Circle | Shape::Arc | Shape::Ellipse { .. } | Shape::Insert { .. } => vec![at(Sub::Center)],
|
||||
Shape::Poly { n, .. } | Shape::Spline { n, .. } => (0..n).filter_map(|i| u32::try_from(i).ok()).map(|i| at(Sub::Vertex(i))).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters a Fix on this reference locks directly. `None` when the reference is a derived
|
||||
/// point (fixed through a residual instead).
|
||||
pub fn lock_indices(&self, r: &GeomRef) -> Option<Vec<usize>> {
|
||||
@@ -518,57 +504,6 @@ impl Model {
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Can `write_back` store `x` as it is? An ellipse keeps 0 < ratio <= 1, its major axis the
|
||||
/// longer one: a solution past that is stored with its axes swapped or its minor axis
|
||||
/// mirrored (see [`Model::stored_form`]), which renumbers the axis ends.
|
||||
pub fn storable(&self, x: &[f64]) -> bool {
|
||||
self.slots.iter().all(|s| match s.shape {
|
||||
Shape::Ellipse { .. } => x.get(s.off + 4).is_some_and(|r| *r > 0.0 && *r <= 1.0 + RATIO_TOL),
|
||||
_ => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// `x` with every ellipse re-expressed as [`Model::write_back`] stores it (the same curve with
|
||||
/// the major axis the longer one, a full ellipse's negative ratio made positive), so the
|
||||
/// constraints can be checked against what would be stored. `None` when an elliptical arc has
|
||||
/// a negative ratio or an ellipse a zero one.
|
||||
pub fn stored_form(&self, x: &[f64]) -> Option<Vec<f64>> {
|
||||
let mut xc = x.to_vec();
|
||||
for s in &self.slots {
|
||||
let Shape::Ellipse { arc } = s.shape else { continue };
|
||||
let o = s.off;
|
||||
let mut ratio = *x.get(o + 4)?;
|
||||
if ratio < 0.0 && !arc {
|
||||
ratio = -ratio;
|
||||
}
|
||||
if ratio <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if ratio > 1.0 + RATIO_TOL {
|
||||
let major = p2(x, o + 2)?.perp() * ratio;
|
||||
*xc.get_mut(o + 2)? = major.x;
|
||||
*xc.get_mut(o + 3)? = major.y;
|
||||
ratio = 1.0 / ratio;
|
||||
if arc {
|
||||
*xc.get_mut(o + 5)? -= std::f64::consts::FRAC_PI_2;
|
||||
*xc.get_mut(o + 6)? -= std::f64::consts::FRAC_PI_2;
|
||||
}
|
||||
}
|
||||
*xc.get_mut(o + 4)? = ratio;
|
||||
}
|
||||
Some(xc)
|
||||
}
|
||||
|
||||
/// The parameter `stored_form` gives the point at parameter `t` on ellipse `h` (unchanged for
|
||||
/// any other object): `-t` where a negative ratio was made positive, a quarter turn back where
|
||||
/// the axes were swapped.
|
||||
pub fn stored_t(&self, x: &[f64], h: Handle, t: f64) -> f64 {
|
||||
let Some(s) = self.slot(h) else { return t };
|
||||
let (Shape::Ellipse { .. }, Some(&ratio)) = (s.shape, x.get(s.off + 4)) else { return t };
|
||||
let (t, ratio) = if ratio < 0.0 { (-t, -ratio) } else { (t, ratio) };
|
||||
if ratio > 1.0 + RATIO_TOL { t - std::f64::consts::FRAC_PI_2 } else { t }
|
||||
}
|
||||
|
||||
/// Write solved parameters back. Returns the handles that moved.
|
||||
pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec<Handle> {
|
||||
let mut moved = Vec::new();
|
||||
@@ -637,9 +572,8 @@ impl Model {
|
||||
[a, b] if arc => (*a, *b),
|
||||
_ => (el.start, el.end),
|
||||
};
|
||||
// Keep the stored form: the major axis is the longer one. `storable` lets a
|
||||
// solved ratio exceed 1 only by rounding: that is a circle, not a swap.
|
||||
if ratio > 1.0 + RATIO_TOL {
|
||||
// Keep the stored form: the major axis is the longer one.
|
||||
if ratio > 1.0 {
|
||||
major = major.perp() * ratio;
|
||||
ratio = 1.0 / ratio;
|
||||
if arc {
|
||||
|
||||
@@ -305,7 +305,7 @@ fn keep_option_moves_the_other_entity() {
|
||||
let mut d = Drawing::default();
|
||||
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
|
||||
let b = line(&mut d, (0.0, 2.0), (10.0, 4.0));
|
||||
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b], ..Default::default() }).unwrap();
|
||||
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap();
|
||||
let (a0, a1) = ln(&d, a);
|
||||
assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}");
|
||||
}
|
||||
@@ -412,7 +412,7 @@ fn glyphs_and_residuals() {
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!all_satisfied(&d));
|
||||
let r = solve(&mut d, &SolveOptions::default()).unwrap();
|
||||
let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap();
|
||||
assert_eq!(r.moved, vec![a]);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
@@ -382,83 +382,3 @@ fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
|
||||
assert_eq!(report.status, DofStatus::Under);
|
||||
assert_eq!(report.dof, Some(1000));
|
||||
}
|
||||
|
||||
/// Adds a constraint that the stored ellipse can only hold with its axes renumbered: refused,
|
||||
/// with the drawing unchanged.
|
||||
fn refused_on_minor_axis_end(p: (f64, f64)) {
|
||||
let mut d = Drawing::default();
|
||||
let e = ellipse(&mut d, (0.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::TAU);
|
||||
ok(&mut d, con(K::Fix, &[s(e, Sub::Center)]));
|
||||
let p = point(&mut d, p);
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
let before = d.clone();
|
||||
let r = add(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(1)), w(p)]), &SolveOptions::default());
|
||||
assert!(matches!(r, Err(SolveError::Conflict { .. })), "{r:?}, ellipse {:?}", ell(&d, e));
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
/// Pulling a minor axis end past the major axis length would need a ratio above 1, which the
|
||||
/// stored ellipse holds only by swapping its axes (and so renumbering their ends).
|
||||
#[test]
|
||||
fn minor_axis_end_pulled_past_the_major_axis_is_refused() {
|
||||
refused_on_minor_axis_end((0.0, 6.0));
|
||||
}
|
||||
|
||||
/// Pulling a minor axis end through the centre would need a negative ratio, which the stored
|
||||
/// ellipse cannot hold: storing its absolute value would mirror the minor axis end.
|
||||
#[test]
|
||||
fn minor_axis_end_pulled_through_the_centre_is_refused() {
|
||||
refused_on_minor_axis_end((0.0, -1.0));
|
||||
}
|
||||
|
||||
/// A circle-like ellipse whose solution makes the minor axis the longer one: stored with its axes
|
||||
/// swapped, which constraints that name no axis end by index do not notice.
|
||||
fn round_ellipse(d: &mut Drawing, start: f64, end: f64) -> Handle {
|
||||
let e = ellipse(d, (0.0, 0.0), (4.0, 0.0), 1.0, start, end);
|
||||
ok(d, con(K::Fix, &[s(e, Sub::Center)]));
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let l = line(&mut d, (-10.0, 4.5), (10.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(l)]));
|
||||
ok(&mut d, con(K::Tangent, &[w(l), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_on_curve_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let p = point(&mut d, (0.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
ok(&mut d, con(K::Coincident, &[w(p), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elliptical_arc_start_that_swaps_its_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
|
||||
let p = point(&mut d, (0.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
ok(&mut d, con(K::Coincident, &[s(e, Sub::Start), w(p)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_circle_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let c = circle(&mut d, (0.0, 6.0), 1.5);
|
||||
ok(&mut d, con(K::Fix, &[w(c)]));
|
||||
ok(&mut d, con(K::Tangent, &[w(c), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
@@ -29,21 +29,16 @@ fn tag(code: i32, value: impl Into<String>) -> RawTag {
|
||||
RawTag { code, value: value.into() }
|
||||
}
|
||||
|
||||
/// The element tag (`AcDbBlockRepETag` 1071) in `x`, if it has one.
|
||||
fn etag_in(x: &[RawTag]) -> Option<i64> {
|
||||
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one.
|
||||
fn etag(d: &Drawing, h: Handle) -> Option<i64> {
|
||||
let x = &d.foreign.entities.get(&h)?.xdata;
|
||||
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
|
||||
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
|
||||
}
|
||||
|
||||
/// The element tag of object `e`: its own xdata (where the importer keeps it), else any kept in the
|
||||
/// foreign data.
|
||||
fn etag(d: &Drawing, e: &Entity) -> Option<i64> {
|
||||
etag_in(&e.common.xdata).or_else(|| etag_in(&d.foreign.entities.get(&e.handle)?.xdata))
|
||||
}
|
||||
|
||||
/// Each object of `b` keyed by its element tag, or by its position when it has none.
|
||||
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
|
||||
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
|
||||
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
|
||||
}
|
||||
|
||||
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
|
||||
@@ -547,12 +542,6 @@ fn apply(
|
||||
let Some(nh) = copies.get(&e.handle).copied() else { continue };
|
||||
let mut c: Entity = (**e).clone();
|
||||
c.handle = nh;
|
||||
// The copy's own xdata names the copy, not its original (as the kept data below does).
|
||||
for t in &mut c.common.xdata {
|
||||
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(e.handle) {
|
||||
t.value = nh.hex();
|
||||
}
|
||||
}
|
||||
if let Some(v) = shown_copy.get(&e.handle) {
|
||||
c.common.visible = *v;
|
||||
}
|
||||
@@ -975,30 +964,3 @@ fn drop_if_unused(d: &mut Drawing, name: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tagged(h: u64, tag_value: Option<&str>) -> Entity {
|
||||
let mut e = Entity::new(Handle(h), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::ZERO }));
|
||||
if let Some(v) = tag_value {
|
||||
e.common.xdata = vec![tag(1001, ETAG), tag(1071, v)];
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn element_tags_are_read_from_the_entitys_own_xdata() {
|
||||
let d = Drawing::new_imperial();
|
||||
let mut def = Block::new("DEF");
|
||||
def.entities.push(tagged(1, Some("10")));
|
||||
def.entities.push(tagged(2, Some("20")));
|
||||
let mut anon = Block::new("*U1");
|
||||
// Reordered, and the first object left out.
|
||||
anon.entities.push(tagged(5, Some("20")));
|
||||
anon.entities.push(tagged(6, None));
|
||||
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
|
||||
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,7 @@ impl Handle {
|
||||
format!("{:X}", self.0)
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Handle> {
|
||||
let s = s.trim();
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u64::from_str_radix(s, 16).ok().map(Handle)
|
||||
u64::from_str_radix(s.trim(), 16).ok().map(Handle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,15 +1477,6 @@ mod grip_tests {
|
||||
use super::*;
|
||||
use cadcraft_geom::PolyVertex;
|
||||
|
||||
#[test]
|
||||
fn handle_parse_hex_accepts_only_hex_digits() {
|
||||
assert_eq!(Handle::parse_hex("1a"), Some(Handle(0x1A)));
|
||||
assert_eq!(Handle::parse_hex(" 1A "), Some(Handle(0x1A)));
|
||||
for bad in ["+1A", "-1A", "", "+", "1 A"] {
|
||||
assert_eq!(Handle::parse_hex(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_grips_are_vertices_and_midpoints() {
|
||||
// Grips are each vertex plus the midpoint of each segment; the segment vector is computed
|
||||
|
||||
@@ -177,11 +177,6 @@ pub struct DimStyle {
|
||||
pub dim_line_color: Color,
|
||||
pub ext_line_color: Color,
|
||||
pub text_color: Color,
|
||||
/// DIMTFILL, the background of the text: 0 none, 1 the drawing's background color,
|
||||
/// 2 `text_fill_color`.
|
||||
pub text_fill: u8,
|
||||
/// DIMTFILLCLR: the text background color when `text_fill` is 2.
|
||||
pub text_fill_color: Color,
|
||||
pub text_style: String,
|
||||
/// DIMPOST prefix/suffix "<>" template.
|
||||
pub post: String,
|
||||
@@ -301,8 +296,6 @@ pub const DIMVARS: &[(&str, &str)] = &[
|
||||
("DIMCLRD", "dimLineColor"),
|
||||
("DIMCLRE", "extLineColor"),
|
||||
("DIMCLRT", "textColor"),
|
||||
("DIMTFILL", "textFill"),
|
||||
("DIMTFILLCLR", "textFillColor"),
|
||||
("DIMTXSTY", "textStyle"),
|
||||
("DIMPOST", "post"),
|
||||
("DIMCEN", "centerMark"),
|
||||
@@ -374,8 +367,6 @@ impl Default for DimStyle {
|
||||
dim_line_color: Color::ByBlock,
|
||||
ext_line_color: Color::ByBlock,
|
||||
text_color: Color::ByBlock,
|
||||
text_fill: 0,
|
||||
text_fill_color: Color::ByBlock,
|
||||
text_style: "Standard".into(),
|
||||
post: String::new(),
|
||||
center_mark: 0.09,
|
||||
@@ -485,19 +476,10 @@ impl DimStyle {
|
||||
let coerced = match (&old, &coerced) {
|
||||
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
|
||||
(Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => {
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round() as i64)
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(-3.0, 211.0) as i64)
|
||||
}
|
||||
(Value::Number(_), Value::Number(_)) if matches!(field, "dimLineweight" | "extLineweight") => coerced,
|
||||
// A value below 0 or one that rounds above 255 is refused; DIMADEC -1 reads as 0.
|
||||
(Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => {
|
||||
let min = if field == "angularDecimals" { -1.0 } else { 0.0 };
|
||||
match n.as_f64().filter(|x| x.is_finite() && *x >= min).map(f64::round).filter(|x| *x <= 255.0) {
|
||||
Some(x) => serde_json::json!(x.max(0.0) as u64),
|
||||
None => {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64)
|
||||
}
|
||||
_ => coerced,
|
||||
};
|
||||
@@ -507,7 +489,7 @@ impl DimStyle {
|
||||
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
|
||||
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
|
||||
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" => 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,
|
||||
|
||||
+11
-23
@@ -413,7 +413,8 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
|
||||
for t in tags {
|
||||
let _ = write!(out, "{:>3}\r\n", t.code);
|
||||
match &t.value {
|
||||
Value::Str(s) => escape_unicode(s, utf8, &mut out),
|
||||
Value::Str(s) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })),
|
||||
Value::Str(s) => escape_unicode(s, &mut out),
|
||||
Value::Real(r) => out.push_str(&format_real(*r)),
|
||||
Value::Int(i) => out.push_str(&i.to_string()),
|
||||
Value::Bool(b) => out.push_str(&i64::from(*b).to_string()),
|
||||
@@ -445,7 +446,11 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
|
||||
v => v.as_str(),
|
||||
};
|
||||
let mut text = String::with_capacity(s.len());
|
||||
escape_unicode(&s, utf8, &mut text);
|
||||
if utf8 {
|
||||
text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c }));
|
||||
} else {
|
||||
escape_unicode(&s, &mut text);
|
||||
}
|
||||
out.extend(text.bytes().filter(|&b| b != 0));
|
||||
out.push(0);
|
||||
}
|
||||
@@ -502,16 +507,12 @@ pub fn is_binary(bytes: &[u8]) -> bool {
|
||||
|
||||
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
|
||||
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
|
||||
/// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are.
|
||||
/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
|
||||
/// gives back the literal text. Line breaks become spaces.
|
||||
fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces.
|
||||
fn escape_unicode(s: &str, out: &mut String) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\r' | '\n' => out.push(' '),
|
||||
'\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
|
||||
c if utf8 || c.is_ascii() => out.push(c),
|
||||
c if c.is_ascii() => out.push(c),
|
||||
c => {
|
||||
let mut units = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut units) {
|
||||
@@ -568,19 +569,6 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn literal_escape_text_roundtrips() {
|
||||
for utf8 in [false, true] {
|
||||
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
|
||||
let tags = vec![Tag::s(8, text)];
|
||||
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
|
||||
assert_eq!(back, tags, "utf8={utf8} {text}");
|
||||
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
|
||||
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_roundtrip() {
|
||||
let tags = vec![
|
||||
|
||||
@@ -208,12 +208,6 @@ impl Autosaver {
|
||||
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
|
||||
continue;
|
||||
}
|
||||
// In the Block Editor: the drawing with the editor's unsaved changes in the block. When
|
||||
// they can't be put there, nothing is written and nothing is marked as covered, so the
|
||||
// next pass tries again instead of keeping the host without them.
|
||||
let Ok(doc) = st.recovery_drawing() else {
|
||||
continue;
|
||||
};
|
||||
let path = t
|
||||
.file
|
||||
.clone()
|
||||
@@ -222,7 +216,8 @@ impl Autosaver {
|
||||
t.written = Some(Arc::downgrade(&st.doc));
|
||||
t.host = Some(Arc::downgrade(st.drawing()));
|
||||
t.since = None;
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc });
|
||||
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
|
||||
}
|
||||
jobs
|
||||
}
|
||||
|
||||
@@ -195,13 +195,12 @@ impl DocState {
|
||||
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
|
||||
}
|
||||
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
|
||||
/// block, so a crash doesn't lose them. An error when the editor's changes can't be put in the
|
||||
/// block (it would contain itself): the host drawing alone would lose them.
|
||||
pub fn recovery_drawing(&self) -> Result<Arc<Drawing>> {
|
||||
/// block, so a crash doesn't lose them.
|
||||
pub fn recovery_drawing(&self) -> Arc<Drawing> {
|
||||
match &self.block_edit {
|
||||
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new),
|
||||
Some(be) => Ok(be.host.doc.clone()),
|
||||
None => Ok(self.doc.clone()),
|
||||
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
|
||||
Some(be) => be.host.doc.clone(),
|
||||
None => self.doc.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annota
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::{CommandSpec, bad, f64_req, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -9,8 +9,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::{CommandSpec, bad, point_param, point_req, points_param, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -120,7 +119,7 @@ fn style(s: &Session) -> String {
|
||||
s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into())
|
||||
}
|
||||
|
||||
fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text: &str) -> EntityKind {
|
||||
fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, p16: Vec2, text: &str) -> EntityKind {
|
||||
EntityKind::Dimension(Dimension {
|
||||
kind,
|
||||
defpt: v3(defpt),
|
||||
@@ -128,7 +127,7 @@ fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2,
|
||||
p13: v3(p13),
|
||||
p14: v3(p14),
|
||||
p15: v3(p15),
|
||||
p16: v3(Vec2::ZERO),
|
||||
p16: v3(p16),
|
||||
text: text.into(),
|
||||
style: style(s),
|
||||
measurement: 0.0,
|
||||
@@ -205,10 +204,7 @@ fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<E
|
||||
// The picked leg end goes last in each pair, so the renderer measures between p14 and p15.
|
||||
let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) };
|
||||
let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) };
|
||||
let mut k = dim(s, DimKind::Angular, o2, o1, p1, p2, "");
|
||||
if let EntityKind::Dimension(d) = &mut k {
|
||||
d.p16 = v3(at);
|
||||
}
|
||||
let k = dim(s, DimKind::Angular, o2, o1, p1, p2, at, "");
|
||||
let mut assoc = Vec::new();
|
||||
if assoc_enabled(s) {
|
||||
assoc = vec![
|
||||
@@ -228,7 +224,7 @@ fn run_linear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let at = point_req("dimlinear", p, "at")?;
|
||||
let rot = p.get("rotation").and_then(Value::as_f64).map(f64::to_radians);
|
||||
let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
|
||||
let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
@@ -236,7 +232,7 @@ fn run_aligned(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("dimaligned", p, "p1")?;
|
||||
let b = point_req("dimaligned", p, "p2")?;
|
||||
let at = point_req("dimaligned", p, "at")?;
|
||||
let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
|
||||
let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
@@ -264,9 +260,9 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
|
||||
return Err(bad(id, "`point` must differ from `center`"));
|
||||
}
|
||||
let k = if diameter {
|
||||
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
|
||||
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
} else {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
};
|
||||
let k = match at {
|
||||
Some(at) => text_at(k, at),
|
||||
@@ -311,7 +307,7 @@ fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad("dimangular", "object is not an arc")),
|
||||
};
|
||||
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, "");
|
||||
let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "");
|
||||
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
|
||||
}
|
||||
let v = point_req("dimangular", p, "vertex")?;
|
||||
@@ -321,7 +317,7 @@ fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if v.near(a, 1e-12) || v.near(b, 1e-12) {
|
||||
return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`"));
|
||||
}
|
||||
let k = dim(s, DimKind::Angular3P, at, a, b, v, "");
|
||||
let k = dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
@@ -333,7 +329,7 @@ fn run_arclen(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad("dimarc", "object is not an arc")),
|
||||
};
|
||||
let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, "");
|
||||
let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, Vec2::ZERO, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
@@ -341,7 +337,7 @@ fn run_ordinate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = point_req("dimordinate", p, "feature")?;
|
||||
let l = point_req("dimordinate", p, "leader")?;
|
||||
let xtype = p.get("xtype").and_then(Value::as_bool).unwrap_or((l - f).y.abs() > (l - f).x.abs());
|
||||
let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, "");
|
||||
let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
@@ -901,7 +897,7 @@ fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
}
|
||||
|
||||
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
|
||||
pub(super) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
|
||||
fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
|
||||
match &s.doc().ok()?.entity(h)?.kind {
|
||||
EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))),
|
||||
EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),
|
||||
@@ -1063,7 +1059,7 @@ impl Interactive for LinearM {
|
||||
}
|
||||
(_, Input::Point(at)) => {
|
||||
let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
|
||||
let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, ""));
|
||||
let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, ""));
|
||||
add_dim(s, k)?;
|
||||
if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
|
||||
let st = s.doc()?.dim_style(&d.style).cloned().unwrap_or_default();
|
||||
@@ -1090,7 +1086,7 @@ impl Interactive for LinearM {
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match self.pts.as_slice() {
|
||||
[a] => vec![super::helpers::line(*a, c)],
|
||||
[a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, ""))],
|
||||
[a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, Vec2::ZERO, ""))],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1109,9 +1105,9 @@ impl RadialM {
|
||||
fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind {
|
||||
let pt = c + (at - c).normalized() * r;
|
||||
let k = self.text.apply(if self.diameter {
|
||||
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
|
||||
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
} else {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
});
|
||||
text_at(k, at)
|
||||
}
|
||||
@@ -1244,7 +1240,7 @@ impl Interactive for AngularM {
|
||||
([h1, h2], None) => angular_from_lines(s, *h1, *h2, at),
|
||||
_ => None,
|
||||
};
|
||||
let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, ""));
|
||||
let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, ""));
|
||||
add_dim(s, self.text.apply(k))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
@@ -1310,7 +1306,9 @@ impl Interactive for AngularM {
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.geometry().map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, ""))]).unwrap_or_default()
|
||||
self.geometry()
|
||||
.map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, Vec2::ZERO, ""))])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1386,7 +1384,7 @@ impl Interactive for ArcLenM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(g), Input::Point(at)) => {
|
||||
add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, "")))?;
|
||||
add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Some(_), Input::Keyword(k)) => {
|
||||
@@ -1405,7 +1403,9 @@ impl Interactive for ArcLenM {
|
||||
if self.partial.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.arc.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, ""))]).unwrap_or_default()
|
||||
self.arc
|
||||
.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""))])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1420,7 +1420,7 @@ struct OrdinateM {
|
||||
impl OrdinateM {
|
||||
fn kind(&self, s: &Session, f: Vec2, l: Vec2) -> EntityKind {
|
||||
let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs());
|
||||
self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, ""))
|
||||
self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, ""))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,8 @@ use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection,
|
||||
use cadcraft_geom::{Segment, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::helpers::v3;
|
||||
use super::super::machines::{SelOutcome, SelectPhase};
|
||||
use super::super::{CommandSpec, bad, bool_or, point_param, point_req, size_param, str_param, targets};
|
||||
use super::{DimText, add_dim, circle_of, dim, dims_of, pick_at};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension.
|
||||
@@ -283,7 +280,7 @@ fn jogged_kind(s: &Session, c: Vec2, r: f64, center: Vec2, at: Vec2, jog: Option
|
||||
}
|
||||
let tip = c + dir * r;
|
||||
let jog = jog.unwrap_or_else(|| center.mid(tip));
|
||||
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, text);
|
||||
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, Vec2::ZERO, text);
|
||||
if let EntityKind::Dimension(dm) = &mut k {
|
||||
dm.marks.jogged = Some(JoggedRadius { center: v3(center), jog: v3(jog) });
|
||||
if at.dist(c) > r + 1e-9 {
|
||||
|
||||
@@ -6,10 +6,8 @@ use cadcraft_doc::{DimKind, EntityKind, Handle, Prim};
|
||||
use cadcraft_geom::{Segment, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase};
|
||||
use super::super::{bad, point_param, point_req, points_param, str_param, targets};
|
||||
use super::{MAX_CHAIN, assoc_enabled, dim};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) const PARAMS: &str = "{handles?, at: [x,y] (dimension line position), mode?: continuous|staggered|baseline|ordinate|radius|diameter (default continuous), vertical?: bool (default: from `at`), datum?: [x,y] (baseline and ordinate; default the first point, or the origin for ordinate), add?: [[x,y]], remove?: [[x,y]] (dimension points; removes those within `tolerance`)}";
|
||||
@@ -96,9 +94,9 @@ fn plan(
|
||||
let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::from_angle(std::f64::consts::FRAC_PI_4) } else { dir };
|
||||
let p = *c + dir * *r;
|
||||
out.push(if mode == Mode::Diameter {
|
||||
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, "")
|
||||
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
|
||||
} else {
|
||||
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, "")
|
||||
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
|
||||
});
|
||||
}
|
||||
if out.is_empty() {
|
||||
@@ -131,7 +129,7 @@ fn plan(
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let end = if vertical { Vec2::new(at.x, p.y) } else { Vec2::new(p.x, at.y) };
|
||||
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, "")
|
||||
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, Vec2::ZERO, "")
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
@@ -147,7 +145,8 @@ fn plan(
|
||||
let mid = bounds.center();
|
||||
let out_dir =
|
||||
if vertical { Vec2::new(if at.x >= mid.x { 1.0 } else { -1.0 }, 0.0) } else { Vec2::new(0.0, if at.y >= mid.y { 1.0 } else { -1.0 }) };
|
||||
let lin = |a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, "");
|
||||
let lin =
|
||||
|a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, Vec2::ZERO, "");
|
||||
Ok(match mode {
|
||||
Mode::Baseline => {
|
||||
// From the datum (the point nearest it, by default the first) to every other point.
|
||||
|
||||
@@ -17,8 +17,7 @@ use serde_json::{Value, json};
|
||||
use super::helpers::{line, v3};
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::modify::transform_entities;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_req, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod edit;
|
||||
|
||||
@@ -16,8 +16,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::super::helpers::v3;
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::{CommandSpec, bad, bool_or, point_param, targets};
|
||||
|
||||
use super::super::*;
|
||||
use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, file, str_param};
|
||||
|
||||
use super::always;
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -12,9 +12,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, point_req, point_value, str_param, targets};
|
||||
|
||||
use super::has_doc;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attedit;
|
||||
@@ -76,12 +74,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
attribs::attdisp_spec(),
|
||||
attribs::attsync_spec(),
|
||||
burst::burst_spec(),
|
||||
blockreplace::blockreplace_spec(),
|
||||
extract::dataextraction_spec(),
|
||||
extract::eattext_spec(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(attribs::attdisp_menu_specs())
|
||||
.chain(xref::specs())
|
||||
.chain(refedit::specs())
|
||||
.chain(bedit::specs())
|
||||
@@ -92,19 +88,12 @@ pub(crate) fn valid_block_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
/// The block an entity draws, if any: a block reference's block, a dimension's block or a
|
||||
/// multileader's block content.
|
||||
/// The block an entity references, when it is a block reference.
|
||||
fn referenced_block(e: &Entity) -> Option<String> {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => dm.block.clone(),
|
||||
EntityKind::MLeader(m) => m.block.as_ref().map(|b| b.block.clone()),
|
||||
_ => None,
|
||||
}
|
||||
if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }
|
||||
}
|
||||
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references,
|
||||
/// dimension blocks and multileader block content.
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references.
|
||||
pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
let mut todo: Vec<String> = ents.iter().filter_map(referenced_block).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -889,4 +878,3 @@ impl Interactive for AttdefM {
|
||||
|
||||
mod attribs;
|
||||
mod battman;
|
||||
mod blockreplace;
|
||||
|
||||
@@ -16,22 +16,6 @@ pub(super) fn attdisp_spec() -> CommandSpec {
|
||||
CommandSpec::new("attdisp", "Attribute Display", run_attdisp)
|
||||
.params("{mode: \"normal\" | \"on\" | \"off\"} (sets ATTMODE to 1 / 2 / 0) → {attmode}")
|
||||
.interactive(|_| Ok(Box::new(AttdispM)))
|
||||
.transparent()
|
||||
}
|
||||
|
||||
/// Returns the View > Attribute Display > Normal, On and Off commands, which set ATTMODE as the matching ATTDISP option does.
|
||||
pub(super) fn attdisp_menu_specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("attdisp.normal", "Normal", |s, _| set_attmode(s, 1))
|
||||
.menu(&["View", "Attribute Display", "Normal"])
|
||||
.params("{} (ATTDISP Normal: ATTMODE 1) → {attmode}"),
|
||||
CommandSpec::new("attdisp.on", "On", |s, _| set_attmode(s, 2))
|
||||
.menu(&["View", "Attribute Display", "On"])
|
||||
.params("{} (ATTDISP ON: ATTMODE 2) → {attmode}"),
|
||||
CommandSpec::new("attdisp.off", "Off", |s, _| set_attmode(s, 0))
|
||||
.menu(&["View", "Attribute Display", "Off"])
|
||||
.params("{} (ATTDISP OFF: ATTMODE 0) → {attmode}"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn attsync_spec() -> CommandSpec {
|
||||
@@ -100,7 +84,7 @@ impl Interactive for AttdispM {
|
||||
/// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and
|
||||
/// shown as defined, keeping the values of attributes whose tag (any case) is still defined.
|
||||
/// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD.
|
||||
pub(super) fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
let m = ins.transform(blk.base.xy());
|
||||
defs.iter()
|
||||
.map(|ad| {
|
||||
@@ -135,16 +119,12 @@ fn references<'a>(ents: impl Iterator<Item = &'a Arc<Entity>>, name: &str) -> Ve
|
||||
/// Apply `f` to every reference of block `name` (in model space, layouts and other blocks);
|
||||
/// returns how many references there were.
|
||||
fn update_references(d: &mut cadcraft_doc::Drawing, name: &str, mut f: impl FnMut(&mut Insert)) -> usize {
|
||||
update_reference_entities(d, name, |e| {
|
||||
let mut n = 0;
|
||||
let mut update = |e: &mut Entity| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind {
|
||||
f(ins);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// [`update_references`], with `f` given the whole object.
|
||||
pub(super) fn update_reference_entities(d: &mut cadcraft_doc::Drawing, name: &str, mut update: impl FnMut(&mut Entity)) -> usize {
|
||||
let mut n = 0;
|
||||
};
|
||||
let spaces: Vec<cadcraft_doc::Space> =
|
||||
std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect();
|
||||
for sp in spaces {
|
||||
@@ -189,22 +169,18 @@ pub(super) fn rename_tag(s: &mut Session, name: &str, old: &str, new: &str) -> R
|
||||
}))
|
||||
}
|
||||
|
||||
/// The attribute definitions of `blk` that references carry (constant ones are not).
|
||||
pub(super) fn attdefs(blk: &Block) -> Vec<&Attrib> {
|
||||
blk.entities
|
||||
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
|
||||
/// Returns the block's name as defined and how many references were updated.
|
||||
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let defs: Vec<&Attrib> = blk
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(a) if !a.constant => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
|
||||
/// Returns the block's name as defined and how many references were updated.
|
||||
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let defs = attdefs(&blk);
|
||||
.collect();
|
||||
let d = s.doc_mut()?;
|
||||
let n = update_references(d, &blk.name, |ins| ins.attribs = synced(&blk, &defs, ins));
|
||||
Ok((blk.name.clone(), n))
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
//! BLOCKREPLACE and -BLOCKREPLACE (Express Tools): every reference of one block, in model space,
|
||||
//! the layouts and other blocks, becomes a reference of another block at the same insertion
|
||||
//! point, scale and rotation. The unreferenced block definition can then be purged.
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::attribs::{attdefs, synced, update_reference_entities};
|
||||
use super::purge::{override_arrows, used_blocks};
|
||||
use super::references_block;
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::machines::{SelOutcome, SelectPhase};
|
||||
use crate::cmd::{CommandSpec, bad, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn blockreplace_spec() -> CommandSpec {
|
||||
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
|
||||
.menu(&["Tools", "Express Tools", "Replace Block..."])
|
||||
.alias(&["-blockreplace"])
|
||||
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
|
||||
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
|
||||
}
|
||||
|
||||
/// The defined name of block `name` (names are case-insensitive), when it is a named block of
|
||||
/// this drawing: not anonymous and not one an xref brings in (`X|A`), which is never saved.
|
||||
fn named_block(d: &Drawing, name: &str) -> Option<String> {
|
||||
d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone())
|
||||
}
|
||||
|
||||
/// Why the references of block `old` cannot be replaced: a dynamic block's references keep their
|
||||
/// property values in data that names the block.
|
||||
fn not_replaceable(d: &Drawing, old: &str) -> Option<String> {
|
||||
cadcraft_doc::dynblock::dynamic_block(d, old).map(|_| format!("Block \"{old}\" is a dynamic block; its references cannot be replaced."))
|
||||
}
|
||||
|
||||
/// Why block `new` cannot replace block `old`: references of `old` inside `new` would make `new`
|
||||
/// reference itself.
|
||||
fn cannot_replace_with(d: &Drawing, old: &str, new: &str) -> Option<String> {
|
||||
let blk = d.block(new)?;
|
||||
references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::<Vec<_>>(), old)
|
||||
.then(|| format!("Block \"{new}\" references \"{old}\", so it cannot replace it."))
|
||||
}
|
||||
|
||||
/// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s
|
||||
/// definition when nothing references it afterwards. Returns (references replaced, purged).
|
||||
fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> {
|
||||
let cmd = "blockreplace";
|
||||
if s.state()?.block_edit.is_some() {
|
||||
return Err(bad(cmd, "the Block Editor is open; close it with BCLOSE first"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.refedit.is_some() {
|
||||
return Err(bad(cmd, "a block is being edited in place; finish with REFCLOSE first"));
|
||||
}
|
||||
if let Some(n) = [old, new].into_iter().find(|n| n.contains('|')) {
|
||||
return Err(bad(cmd, format!("Block \"{}\" comes from an xref; bind the xref first.", n.trim())));
|
||||
}
|
||||
let old = named_block(d, old).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", old.trim())))?;
|
||||
let new = named_block(d, new).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", new.trim())))?;
|
||||
if old.eq_ignore_ascii_case(&new) {
|
||||
return Err(bad(cmd, "the replacement block must be a different block"));
|
||||
}
|
||||
if let Some(why) = not_replaceable(d, &old).or_else(|| cannot_replace_with(d, &old, &new)) {
|
||||
return Err(bad(cmd, why));
|
||||
}
|
||||
let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?;
|
||||
let defs = attdefs(&blk);
|
||||
let scale = d.current_anno_scale();
|
||||
let d = s.doc_mut()?;
|
||||
let n = update_reference_entities(d, &old, |e| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind {
|
||||
ins.block.clone_from(&blk.name);
|
||||
ins.attribs = synced(&blk, &defs, ins);
|
||||
}
|
||||
// A reference of an annotative block is annotative, as INSERT makes it.
|
||||
if !blk.annotative {
|
||||
e.common.anno_scales.clear();
|
||||
} else if e.common.anno_scales.is_empty() {
|
||||
e.common.anno_scales = vec![scale.clone()];
|
||||
}
|
||||
});
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&old)).cloned();
|
||||
let purged = purge && !referenced(d, &old) && key.is_some_and(|k| d.blocks.remove(&k).is_some());
|
||||
Ok((n, purged))
|
||||
}
|
||||
|
||||
/// Whether anything still names block `name`: what PURGE counts as in use, and the dimensions,
|
||||
/// multileaders and block references of every other block definition, used or not.
|
||||
fn referenced(d: &Drawing, name: &str) -> bool {
|
||||
if used_blocks(d).contains(&name.to_ascii_uppercase()) {
|
||||
return true;
|
||||
}
|
||||
let names = |e: &Entity| -> Vec<String> {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::Dimension(dm) => dm.block.iter().cloned().chain(override_arrows(dm)).collect(),
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
d.blocks
|
||||
.values()
|
||||
.filter(|b| !b.name.eq_ignore_ascii_case(name))
|
||||
.flat_map(|b| b.entities.iter())
|
||||
.any(|e| names(e).iter().any(|n| n.trim().eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
fn run_blockreplace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "blockreplace";
|
||||
let old = str_param(p, "block").ok_or_else(|| bad(cmd, "`block` (the block to replace) is required"))?;
|
||||
let new = str_param(p, "with").ok_or_else(|| bad(cmd, "`with` (the replacement block) is required"))?;
|
||||
let purge = match p.get("purge") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(v) => return Err(bad(cmd, format!("`purge` must be true or false, not {v}"))),
|
||||
};
|
||||
let (old, new) = (old.to_string(), new.to_string());
|
||||
let (n, purged) = replace(s, &old, &new, purge)?;
|
||||
let d = s.doc()?;
|
||||
let defined = |name: &str| named_block(d, name).unwrap_or_else(|| name.trim().to_string());
|
||||
Ok(json!({ "block": defined(&old), "with": defined(&new), "replaced": n, "purged": purged }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Old,
|
||||
New,
|
||||
Select,
|
||||
Purge,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BlockreplaceM {
|
||||
stage: Stage,
|
||||
/// The stage a picked reference answers.
|
||||
back: Stage,
|
||||
sel: SelectPhase,
|
||||
old: String,
|
||||
new: String,
|
||||
}
|
||||
|
||||
impl BlockreplaceM {
|
||||
/// Take `name` as the answer to the current name prompt. A block that cannot take part is
|
||||
/// refused here, so the prompt asks again.
|
||||
fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> {
|
||||
let d = s.doc()?;
|
||||
let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?;
|
||||
if stage == Stage::Old {
|
||||
if let Some(why) = not_replaceable(d, &name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.old = name;
|
||||
self.stage = Stage::New;
|
||||
} else {
|
||||
if name.eq_ignore_ascii_case(&self.old) {
|
||||
return Err(EngineError::Other("The replacement block must be a different block.".into()));
|
||||
}
|
||||
if let Some(why) = cannot_replace_with(d, &self.old, &name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.new = name;
|
||||
self.stage = Stage::Purge;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self, s: &mut Session, purge: bool) -> Result<Step> {
|
||||
let (n, purged) = replace(s, &self.old, &self.new, purge)?;
|
||||
s.echo(format!(
|
||||
"{n} reference{} of block {} replaced with {}.{}",
|
||||
if n == 1 { "" } else { "s" },
|
||||
self.old,
|
||||
self.new,
|
||||
if purged { format!(" Block {} purged.", self.old) } else { String::new() }
|
||||
));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BlockreplaceM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BLOCKREPLACE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::Select => Prompt::new("Select a block reference", Accept::SELECT),
|
||||
Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
stage @ (Stage::Old | Stage::New) => match &i {
|
||||
Input::Keyword(k) if k == "?" => {
|
||||
let d = s.doc()?;
|
||||
let mut names: Vec<String> = d.blocks.keys().filter_map(|k| named_block(d, k)).collect();
|
||||
names.sort_by_key(|n| n.to_ascii_lowercase());
|
||||
s.echo(if names.is_empty() { "No blocks defined.".to_string() } else { format!("Blocks: {}", names.join(", ")) });
|
||||
}
|
||||
Input::Keyword(k) if k == "Select" => {
|
||||
self.back = stage;
|
||||
self.sel = SelectPhase::single();
|
||||
self.stage = Stage::Select;
|
||||
}
|
||||
Input::Text(t) if !t.trim().is_empty() => self.take(s, stage, t)?,
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
_ => {}
|
||||
},
|
||||
Stage::Select => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Empty => self.stage = self.back,
|
||||
SelOutcome::Done(hs) => {
|
||||
s.set_selection(Vec::new());
|
||||
self.sel = SelectPhase::single();
|
||||
let block = hs.iter().find_map(|h| match s.doc().ok()?.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(ins)) => Some(ins.block.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match block {
|
||||
Some(b) => self.take(s, self.back, &b)?,
|
||||
None => return Err(EngineError::Other("That object is not a block reference.".into())),
|
||||
}
|
||||
}
|
||||
},
|
||||
Stage::Purge => match &i {
|
||||
Input::Keyword(k) if k == "No" => return self.finish(s, false),
|
||||
Input::Keyword(_) | Input::Enter => return self.finish(s, true),
|
||||
Input::Cancel => return Ok(Step::Done),
|
||||
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
},
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
|
||||
}
|
||||
|
||||
/// The arrowhead block names a dimension's overrides set.
|
||||
pub(in crate::cmd) fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
|
||||
fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
|
||||
dm.overrides
|
||||
.iter()
|
||||
.filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock"))
|
||||
|
||||
@@ -420,17 +420,6 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
nb.anonymous = b.anonymous;
|
||||
nb.explodable = b.explodable;
|
||||
nb.units = b.units;
|
||||
// Array parameters keep their kind and spacing; block names follow the block renaming, and
|
||||
// a source edit in progress in the referenced file is not carried over.
|
||||
nb.array = b.array.as_ref().map(|a| {
|
||||
let mut a = a.clone();
|
||||
a.source = dep(Sym::Block, &a.source).unwrap_or(a.source);
|
||||
for r in &mut a.replaced {
|
||||
r.block = dep(Sym::Block, &r.block).unwrap_or_else(|| r.block.clone());
|
||||
}
|
||||
a.editing = None;
|
||||
a
|
||||
});
|
||||
for e in b.entities.iter() {
|
||||
let e = adopt(d, e);
|
||||
nb.entities.push(e);
|
||||
@@ -543,35 +532,4 @@ mod tests {
|
||||
assert!(!has_prefix("SITE", "site|"));
|
||||
assert!(!has_prefix("é", "ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_array_blocks_keep_their_parameters() {
|
||||
use cadcraft_doc::{ArrayKind, ArrayParams, Block, RectArray, ReplacedItem};
|
||||
let rect = RectArray { cols: 3, rows: 2, col_spacing: 5.0, row_spacing: 4.0, axis_angle: 0.0, row_elevation: 0.0 };
|
||||
let params = ArrayParams {
|
||||
source: "*U1".into(),
|
||||
kind: ArrayKind::Rect(rect),
|
||||
levels: 1,
|
||||
level_spacing: 0.0,
|
||||
replaced: vec![ReplacedItem { i: 1, j: 0, level: 0, block: "ALT".into() }],
|
||||
editing: Some(Default::default()),
|
||||
};
|
||||
let mut arr = Block::new("*U2");
|
||||
arr.array = Some(params);
|
||||
let mut x = Drawing::default();
|
||||
x.blocks.insert("*U2".into(), Arc::new(arr));
|
||||
|
||||
let mut d = Drawing::default();
|
||||
d.blocks.insert("SITE".into(), Arc::new(Block::new("SITE")));
|
||||
let mut cx = Ctx { chain: Vec::new(), objects: 0, files: 0, bytes: 0, notes: Vec::new() };
|
||||
assert!(import(&mut d, "SITE", &x, &mut cx).is_ok());
|
||||
|
||||
let got = d.blocks.get("SITE|*U2").and_then(|b| b.array.clone());
|
||||
let Some(got) = got else { panic!("array parameters were dropped") };
|
||||
assert_eq!(got.source, "SITE|*U1");
|
||||
assert_eq!(got.replaced[0].block, "SITE|ALT");
|
||||
assert!(got.editing.is_none());
|
||||
let ArrayKind::Rect(r) = got.kind else { panic!("not a rectangular array") };
|
||||
assert_eq!((r.cols, r.rows), (3, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ use cadcraft_geom::{Line, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, point_value, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
macro_rules! gc {
|
||||
@@ -328,7 +327,7 @@ fn add_constraint(s: &mut Session, c: Constraint) -> Result<u32> {
|
||||
let keep: Vec<Handle> = c.refs.first().map(|r| r.handle).into_iter().collect();
|
||||
let prefer_move: Vec<Handle> = c.refs.iter().skip(1).map(|r| r.handle).filter(|h| !keep.contains(h)).collect();
|
||||
let mut d = s.doc()?.clone();
|
||||
let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move, ..Default::default() }).map_err(err)?;
|
||||
let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move }).map_err(err)?;
|
||||
*s.doc_mut()? = d;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -1120,7 +1119,7 @@ fn infer_after(s: &mut Session, before: &Drawing) {
|
||||
return;
|
||||
}
|
||||
let mut nd = d.clone();
|
||||
let opts = cs::SolveOptions { keep: others, prefer_move: placed, ..Default::default() };
|
||||
let opts = cs::SolveOptions { keep: others, prefer_move: placed };
|
||||
let added = cands.into_iter().take(200).filter(|c| cs::add(&mut nd, c.clone(), &opts).is_ok()).count();
|
||||
if added > 0
|
||||
&& let Ok(st) = s.state_mut()
|
||||
@@ -1149,8 +1148,7 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
let mut d = (*st.doc).clone();
|
||||
let purged = cs::purge_invalid(&mut d);
|
||||
let restored = cs::restore_fixed(&mut d, before);
|
||||
let anchors = cs::moved_anchors(&d, before);
|
||||
let satisfied = anchors.is_empty() && cs::all_satisfied(&d);
|
||||
let satisfied = cs::all_satisfied(&d);
|
||||
if purged.is_empty() && !restored && satisfied {
|
||||
return;
|
||||
}
|
||||
@@ -1169,25 +1167,9 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
keep.push(h);
|
||||
}
|
||||
}
|
||||
// A grip drag moved some of an object's points: they stay where they were put, and the
|
||||
// edited objects still move least. When that cannot be solved, the edited objects are
|
||||
// only kept as a whole, as for any other edit.
|
||||
let hold: Vec<GeomRef> = keep.iter().flat_map(|&h| cs::edited_points(&d, before, h)).collect();
|
||||
let mut solved = None;
|
||||
if !hold.is_empty() {
|
||||
let mut trial = d.clone();
|
||||
let opts = cs::SolveOptions { keep: keep.clone(), prefer_move: Vec::new(), hold, anchors: anchors.clone() };
|
||||
solved = cs::solve(&mut trial, &opts).ok().map(|_| trial);
|
||||
}
|
||||
let solved = match solved {
|
||||
Some(t) => Ok(t),
|
||||
None => {
|
||||
let mut trial = d.clone();
|
||||
cs::solve(&mut trial, &cs::SolveOptions { keep, anchors, ..Default::default() }).map(|_| trial)
|
||||
}
|
||||
};
|
||||
match solved {
|
||||
Ok(t) => d = t,
|
||||
let mut trial = d.clone();
|
||||
match cs::solve(&mut trial, &cs::SolveOptions { keep, prefer_move: Vec::new() }) {
|
||||
Ok(_) => d = trial,
|
||||
Err(e) => msg.push(format!("Constraints could not be satisfied: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,10 @@ use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
|
||||
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::{arc, circle, line, lwpoly, polygon_from_edge, polygon_typed_radius_point, polygon_vertices, v3};
|
||||
|
||||
use super::helpers::*;
|
||||
use super::machines::number;
|
||||
use super::pline_opts::{Outcome, PlineAsk};
|
||||
use super::{
|
||||
CommandSpec, bad, bool_or, distinct_points, f64_or, f64_req, find_command, point_param, point_req, point_value, points_param, size_param,
|
||||
str_param,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap};
|
||||
|
||||
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
|
||||
|
||||
@@ -7,11 +7,9 @@ use cadcraft_geom::{Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Spline, TA
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::curves::{self, Chain, MAX_GEN, TanObj};
|
||||
use super::helpers::{arc, circle, line, lwpoly, rect_vertices, rect_with_corners, v3};
|
||||
|
||||
use super::helpers::*;
|
||||
use super::machines::number;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, point_value, points_param, size_param, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(crate) mod breakline;
|
||||
@@ -1085,9 +1083,7 @@ impl Interactive for RevcloudM {
|
||||
let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"];
|
||||
match (self.kind, self.pts.len()) {
|
||||
(CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"),
|
||||
(CloudKind::Rect, _) => {
|
||||
Prompt { corner: true, ..Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()) }
|
||||
}
|
||||
(CloudKind::Rect, _) => Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()),
|
||||
(CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"),
|
||||
(CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()),
|
||||
(CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
|
||||
@@ -1574,7 +1570,7 @@ impl Interactive for XlineM2 {
|
||||
// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness
|
||||
// =====================================================================================
|
||||
|
||||
fn rect_kind(first: Vec2, (w, h): (f64, f64), rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind {
|
||||
fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind {
|
||||
let local = rect_with_corners(Vec2::ZERO, Vec2::new(w, h), fillet, chamfer);
|
||||
let vs = local.into_iter().map(|v| PolyVertex { p: first + v.p.rotate(rot), ..v }).collect();
|
||||
let mut k = lwpoly(vs, true);
|
||||
@@ -1633,11 +1629,12 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("rectang", "rectangle has no area"));
|
||||
}
|
||||
let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a };
|
||||
let k = rect_kind(first, (w, h), rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let hd = s.add_entity(k)?;
|
||||
// A rectangle has its own thickness option and does not take THICKNESS.
|
||||
let th = f64_or(p, "thickness", 0.0);
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
|
||||
if th != 0.0 {
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
|
||||
}
|
||||
added(hd)
|
||||
}
|
||||
|
||||
@@ -1685,10 +1682,11 @@ impl RectM2 {
|
||||
return Err(other("The rectangle has no area."));
|
||||
}
|
||||
let first = self.origin(w, h).unwrap_or(a);
|
||||
let hd = s.add_entity(rect_kind(first, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
|
||||
// A rectangle has its own thickness option and does not take THICKNESS.
|
||||
let t = self.thickness;
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
|
||||
let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
|
||||
if self.thickness != 0.0 {
|
||||
let t = self.thickness;
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
/// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first
|
||||
@@ -1864,7 +1862,7 @@ impl Interactive for RectM2 {
|
||||
}
|
||||
match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) {
|
||||
Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
|
||||
vec![rect_kind(a, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation)]
|
||||
vec![rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
|
||||
@@ -9,11 +9,10 @@ use cadcraft_doc::{Drawing, EntityKind};
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::super::machines::number;
|
||||
use super::added;
|
||||
use super::curves::MAX_GEN;
|
||||
use super::{CommandSpec, line, lwpoly, point_param, point_req, size_param, str_param};
|
||||
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use crate::cmd::modify2::as_lwpoly;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -9,11 +9,9 @@ use cadcraft_doc::{
|
||||
use cadcraft_geom::{Line, Vec2, line_line_infinite};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::number;
|
||||
use super::super::{distinct_points, ok};
|
||||
use super::curves::{self, MAX_GEN};
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, point_req, points_param, str_param};
|
||||
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -8,8 +8,7 @@ use cadcraft_doc::{EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::{CommandSpec, EngineError, bad, ok, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -5,8 +5,7 @@ use cadcraft_geom::{Bounds2, Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::{CommandSpec, EngineError, bad, bool_or, clipboard, f64_or, has_selection, point_param, point_req, points_param, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::undoctl::Control;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
@@ -27,10 +26,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.key("Cmd+X")
|
||||
.params("{handles?}")
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("cutclip", "CUTCLIP")))),
|
||||
CommandSpec::new("cutbase", "Cut with Base Point", run_cutbase)
|
||||
.menu(&["Edit", "Cut with Base Point"])
|
||||
.params("{handles?, base: [x,y]}")
|
||||
.interactive(|_| Ok(Box::new(CopyBaseM { cut: true, ..CopyBaseM::default() }))),
|
||||
CommandSpec::new("copyclip", "Copy", run_copyclip)
|
||||
.menu(&["Edit", "Copy"])
|
||||
.key("Cmd+C")
|
||||
@@ -439,11 +434,9 @@ impl Interactive for MredoM {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the objects of `hs` to the clipboard, each once however often `hs` lists it.
|
||||
fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<usize> {
|
||||
let d = s.doc()?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let ents: Vec<Entity> = hs.iter().filter(|h| seen.insert(**h)).filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0)));
|
||||
let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO });
|
||||
let n = ents.len();
|
||||
@@ -458,59 +451,22 @@ fn run_copyclip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_copybase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = point_req("copybase", p, "base")?;
|
||||
let hs = targets(s, p)?;
|
||||
let n = copy_to_clip(s, &hs, Some(base))?;
|
||||
let base = point_param(p, "base");
|
||||
let n = copy_to_clip(s, &hs, base)?;
|
||||
Ok(json!({ "copied": n }))
|
||||
}
|
||||
|
||||
/// Copy the objects that are not on locked layers to the clipboard and erase them. Returns how
|
||||
/// many were cut and the message to show: how many were on a locked layer, and that nothing was
|
||||
/// cut when that is all of them (the clipboard then keeps what it held).
|
||||
fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<(usize, Option<String>)> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let live: Vec<Handle> = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().copied().filter(|h| seen.insert(*h) && d.entity(*h).is_some()).collect()
|
||||
};
|
||||
let (hs, locked): (Vec<Handle>, Vec<Handle>) = live.into_iter().partition(|h| !super::curves::is_locked(s, *h));
|
||||
let note = match locked.len() {
|
||||
0 => None,
|
||||
1 => Some("1 object was on a locked layer.".to_string()),
|
||||
n => Some(format!("{n} objects were on a locked layer.")),
|
||||
};
|
||||
if hs.is_empty() {
|
||||
let msg = note.map_or_else(|| "Nothing was cut.".to_string(), |m| format!("{m} Nothing was cut."));
|
||||
return Ok((0, Some(msg)));
|
||||
}
|
||||
let n = copy_to_clip(s, &hs, base)?;
|
||||
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut hs = targets(s, p)?;
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let n = copy_to_clip(s, &hs, None)?;
|
||||
let d = s.doc_mut()?;
|
||||
for h in &hs {
|
||||
d.remove_entity(*h);
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok((n, note))
|
||||
}
|
||||
|
||||
fn cut_result(n: usize, msg: Option<String>) -> Value {
|
||||
let mut r = json!({ "cut": n });
|
||||
if let Some(m) = msg {
|
||||
r["message"] = json!(m);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let (n, msg) = cut_to_clip(s, &hs, None)?;
|
||||
Ok(cut_result(n, msg))
|
||||
}
|
||||
|
||||
fn run_cutbase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = point_req("cutbase", p, "base")?;
|
||||
let hs = targets(s, p)?;
|
||||
let (n, msg) = cut_to_clip(s, &hs, Some(base))?;
|
||||
Ok(cut_result(n, msg))
|
||||
Ok(json!({ "cut": n }))
|
||||
}
|
||||
|
||||
/// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its
|
||||
@@ -693,31 +649,23 @@ impl Interactive for SelectM {
|
||||
}
|
||||
}
|
||||
|
||||
/// COPYBASE and CUTBASE: base point first, then "Select objects:" (skipped with a pickfirst
|
||||
/// selection). CUTBASE also erases the objects it copies.
|
||||
/// COPYBASE: base point first, then "Select objects:" (skipped with a pickfirst selection).
|
||||
#[derive(Default)]
|
||||
struct CopyBaseM {
|
||||
base: Option<Vec2>,
|
||||
sel: SelectPhase,
|
||||
cut: bool,
|
||||
}
|
||||
|
||||
impl CopyBaseM {
|
||||
fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
if self.cut {
|
||||
if let (_, Some(m)) = cut_to_clip(s, hs, self.base)? {
|
||||
s.echo(m);
|
||||
}
|
||||
} else {
|
||||
copy_to_clip(s, hs, self.base)?;
|
||||
}
|
||||
copy_to_clip(s, hs, self.base)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for CopyBaseM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.cut { "CUTBASE" } else { "COPYBASE" }
|
||||
"COPYBASE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
|
||||
@@ -18,8 +18,7 @@ use cadcraft_render::raster::{RasterOptions, View};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::file::{base64_decode, base64_encode, io};
|
||||
use super::{CommandSpec, bad, point_value, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Formats EXPORT writes (the io layer picks the writer from the extension).
|
||||
@@ -558,10 +557,7 @@ mod tests {
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
use super::super::file::{IoHooks, set_io};
|
||||
|
||||
use super::super::resolve_alias;
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
static DELIVERED: std::sync::Mutex<Vec<(String, String, usize)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ use cadcraft_doc::Drawing;
|
||||
pub use cadcraft_dxf::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
|
||||
@@ -363,7 +362,6 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(opened(s, i, false))
|
||||
}
|
||||
|
||||
/// `close [index]`: cancel the running command, remove the drawing (default: active), keep the active index valid, and bump the document epoch; an unknown index is an error.
|
||||
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active);
|
||||
if i >= s.docs.len() {
|
||||
@@ -378,16 +376,13 @@ fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
s.bump_doc_epoch();
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `closeall`: cancel the running command, remove every drawing, reset the active index to 0 and bump the document epoch.
|
||||
fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.cancel();
|
||||
s.docs.clear();
|
||||
s.active = 0;
|
||||
s.bump_doc_epoch();
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -492,7 +487,6 @@ fn drawing_version(s: &Session, path: &str) -> Version {
|
||||
if is_dxf(path) { v } else { v.max(Version::R2004) }
|
||||
}
|
||||
|
||||
/// `document.switch index`: require the index, cancel the running command, activate that drawing and bump the epoch even if it was already active (screenshot identity).
|
||||
fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.switch", "`index` is required"))? as usize;
|
||||
if i >= s.docs.len() {
|
||||
@@ -500,11 +494,6 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.cancel();
|
||||
s.active = i;
|
||||
// Bump even when `i` is already active. This is intentionally conservative: the command
|
||||
// cancels the active command, and a screenshot client can only see observed state, not
|
||||
// whether the switch was a no-op. Rejecting an outstanding capture is safer than returning
|
||||
// an image whose queueing identity the client cannot distinguish from a real switch.
|
||||
s.bump_doc_epoch();
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -732,7 +721,6 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
mod pr193_tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! drawing). Objects are matched by geometry and properties first (preferring the same handle),
|
||||
//! then by handle: the same handle with other geometry or properties is a change. What is left
|
||||
//! is only in this drawing or only in the other. The comparison stays on the session for the
|
||||
//! canvas overlay, next/previous navigation and COMPAREEXPORT until COMPARECLOSE.
|
||||
//! canvas overlay and next/previous navigation until COMPARECLOSE.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -12,13 +12,10 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space,
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::{CommandSpec, bad, str_param};
|
||||
|
||||
use super::super::*;
|
||||
use super::{base64_decode, io};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod export;
|
||||
|
||||
/// Objects compared per drawing; more are left out (and the result says so).
|
||||
pub const MAX_OBJECTS: usize = 200_000;
|
||||
/// Objects listed per kind of difference in the JSON result.
|
||||
@@ -39,9 +36,6 @@ pub struct Comparison {
|
||||
pub other_name: String,
|
||||
/// The other drawing (its objects are drawn where they differ).
|
||||
pub other: Arc<Drawing>,
|
||||
/// The other drawing's file, when it has one: COMPAREEXPORT resolves its relative xref
|
||||
/// paths from that file's folder.
|
||||
pub other_path: Option<String>,
|
||||
/// Only in this drawing (handles here).
|
||||
pub added: Vec<Handle>,
|
||||
/// Only in the other drawing (handles there).
|
||||
@@ -77,7 +71,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("compare.next", "Next Change", |s, _| step(s, 1)).params("→ {region, of}: zoom to the next area with changes").noundo(),
|
||||
CommandSpec::new("compare.previous", "Previous Change", |s, _| step(s, -1)).params("→ {region, of}").noundo(),
|
||||
CommandSpec::new("compareclose", "Close Comparison", run_close).params("{}: end the comparison and its overlay").noundo(),
|
||||
export::spec(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -140,23 +133,11 @@ struct Signer<'a> {
|
||||
d: &'a Drawing,
|
||||
blocks: HashMap<String, u64>,
|
||||
work: usize,
|
||||
/// When set, a block's signature also covers its base point, units, annotative and overlay
|
||||
/// flags and xref file, with relative xref, image and data link paths taken from the folder
|
||||
/// given, and the
|
||||
/// signatures of a multileader and a dimension cover their blocks'. Each object in a block
|
||||
/// then also counts with its visibility, transparency, thickness and extrusion, and an
|
||||
/// anonymous block's signature leaves out its name (a dimension also leaves its block's name
|
||||
/// out of its own geometry; an INSERT or MLEADER keeps it).
|
||||
frames: Option<Option<&'a std::path::Path>>,
|
||||
}
|
||||
|
||||
impl<'a> Signer<'a> {
|
||||
fn new(d: &'a Drawing) -> Self {
|
||||
Signer { d, blocks: HashMap::new(), work: 0, frames: None }
|
||||
}
|
||||
/// A signer whose block signatures also cover what changes how a reference draws the block.
|
||||
fn with_frames(d: &'a Drawing, folder: Option<&'a std::path::Path>) -> Self {
|
||||
Signer { frames: Some(folder), ..Signer::new(d) }
|
||||
Signer { d, blocks: HashMap::new(), work: 0 }
|
||||
}
|
||||
fn geometry(&mut self, k: &EntityKind, depth: usize) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -175,18 +156,6 @@ impl<'a> Signer<'a> {
|
||||
if let EntityKind::Insert(i) = k {
|
||||
self.block(&i.block, depth).hash(&mut h);
|
||||
}
|
||||
if self.frames.is_some()
|
||||
&& let EntityKind::MLeader(m) = k
|
||||
&& let Some(b) = &m.block
|
||||
{
|
||||
self.block(&b.block, depth).hash(&mut h);
|
||||
}
|
||||
if self.frames.is_some()
|
||||
&& let EntityKind::Dimension(dm) = k
|
||||
&& let Some(b) = &dm.block
|
||||
{
|
||||
self.block(b, depth).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
fn block(&mut self, name: &str, depth: usize) -> u64 {
|
||||
@@ -195,42 +164,17 @@ impl<'a> Signer<'a> {
|
||||
return *v;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let block = self.d.block(name);
|
||||
// With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the
|
||||
// same block can have another generated name in another drawing. Only a dimension's
|
||||
// geometry leaves the name out, so this matches renamed `*D` blocks, not `*U` references.
|
||||
if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) {
|
||||
key.hash(&mut h);
|
||||
}
|
||||
key.hash(&mut h);
|
||||
if depth < MAX_BLOCK_DEPTH
|
||||
&& let Some(b) = block
|
||||
&& let Some(b) = self.d.block(name)
|
||||
{
|
||||
if let Some(folder) = self.frames {
|
||||
let mut xref = b.xref_path.clone();
|
||||
if let Some(x) = xref.as_mut() {
|
||||
export::rebase_path(x, folder);
|
||||
}
|
||||
(format!("{:?}", b.base), b.units, b.annotative, b.xref_overlay, xref).hash(&mut h);
|
||||
}
|
||||
for e in b.entities.iter() {
|
||||
if self.work >= MAX_BLOCK_WORK {
|
||||
break;
|
||||
}
|
||||
self.work += 1;
|
||||
match self.frames {
|
||||
// An image's file and a table's data link, from the folder given.
|
||||
Some(folder) if matches!(e.kind, EntityKind::Image(_) | EntityKind::Table(_)) => {
|
||||
let mut k = e.kind.clone();
|
||||
export::rebase(&mut k, folder);
|
||||
self.geometry(&k, depth + 1).hash(&mut h);
|
||||
}
|
||||
_ => self.geometry(&e.kind, depth + 1).hash(&mut h),
|
||||
}
|
||||
self.geometry(&e.kind, depth + 1).hash(&mut h);
|
||||
properties(e).hash(&mut h);
|
||||
if self.frames.is_some() {
|
||||
let c = &e.common;
|
||||
format!("{:?}|{}|{}|{:?}", c.transparency, c.visible, (c.thickness * 1e6).round(), c.extrusion).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = h.finish();
|
||||
@@ -367,7 +311,6 @@ fn listed(d: &Drawing, hs: &[Handle]) -> Vec<Value> {
|
||||
|
||||
fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (other, name) = other_drawing(s, p)?;
|
||||
let other_path = export::source_path(s, p, &other);
|
||||
let st = s.state()?;
|
||||
let (uid, space, cur) = (st.uid, st.space.clone(), st.doc.clone());
|
||||
if Arc::ptr_eq(&cur, &other) {
|
||||
@@ -418,7 +361,7 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"message": message,
|
||||
});
|
||||
let changed = changed.into_iter().map(|(a, b, _, _)| (a, b)).collect();
|
||||
s.compare = Some(Comparison { uid, space, other_name: name, other, other_path, added, removed, changed, common, truncated, regions, at: None });
|
||||
s.compare = Some(Comparison { uid, space, other_name: name, other, added, removed, changed, common, truncated, regions, at: None });
|
||||
s.touch();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -1,684 +0,0 @@
|
||||
//! COMPAREEXPORT: the comparison as a new drawing (a snapshot). The drawings are compared again
|
||||
//! when the command runs, so edits made since COMPARE count. The new drawing's model space holds
|
||||
//! copies of the other drawing's objects that differ, then copies of the current drawing's
|
||||
//! objects in the compared tab, as two groups, `COMPARE_COMPARED` and `COMPARE_CURRENT`. Objects
|
||||
//! only in this drawing or changed in it are drawn in the overlay's green, objects only in the
|
||||
//! other drawing or changed there in its red, and objects in both in gray (color 8). A block
|
||||
//! reference, dimension or multileader refers to a copy of its block whose objects all have that
|
||||
//! color (`COMPARE_CURRENT_<name>`, `COMPARE_COMPARED_<name>`, `COMPARE_COMMON_<name>`; for a
|
||||
//! dimension's `*D` block, another `*D` block), and a table or tolerance uses a recolored copy
|
||||
//! of its style; xrefs keep their own colors. The new
|
||||
//! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other
|
||||
//! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of
|
||||
//! the other drawing whose name this one uses is copied under a free name. Relative xref, image
|
||||
//! and data link paths become absolute paths from the folder of the drawing they came from. It
|
||||
//! opens untitled as `compare_<this> vs <other>.dwg`, with unsaved changes; saving it asks for a
|
||||
//! file name.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad};
|
||||
use super::{Comparison, MAX_OBJECTS};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Only in this drawing, or changed in it (the overlay's color).
|
||||
const THIS: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b));
|
||||
/// Only in the other drawing, or changed there.
|
||||
const OTHER: Color = Color::True(Rgb(0xff, 0x5c, 0x5c));
|
||||
/// In both drawings.
|
||||
const COMMON: Color = Color::Index(8);
|
||||
/// Block definitions copied from the other drawing (the ones they reference included), and
|
||||
/// recolored copies of blocks and styles, per export.
|
||||
const MAX_BLOCKS: usize = 10_000;
|
||||
/// Objects copied into block definitions (the other drawing's blocks and the recolored
|
||||
/// copies), per export.
|
||||
const MAX_COPIED: usize = 1_000_000;
|
||||
|
||||
pub(super) fn spec() -> CommandSpec {
|
||||
CommandSpec::new("compareexport", "Export Comparison", run)
|
||||
.params(
|
||||
"{} → {index, title, current, compared: group names, counts: {current, compared}, truncated, message}: the comparison \
|
||||
as a new drawing, opened untitled",
|
||||
)
|
||||
.noundo()
|
||||
}
|
||||
|
||||
/// What [`snapshot`] made: the drawing, the two group names, how many objects each holds,
|
||||
/// whether a drawing had more objects than were compared, whether the other drawing had more
|
||||
/// blocks to copy than [`MAX_BLOCKS`], and whether a block was left in its own colors.
|
||||
struct Snapshot {
|
||||
drawing: Drawing,
|
||||
current: String,
|
||||
compared: String,
|
||||
counts: (usize, usize),
|
||||
truncated: bool,
|
||||
blocks_left_out: bool,
|
||||
uncolored: bool,
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.state()?;
|
||||
let (uid, cur, title, cur_folder) = (st.uid, st.doc.clone(), st.title.clone(), folder_of(st.path.as_deref()));
|
||||
let c = s.compare.as_ref().filter(|c| c.uid == uid).ok_or_else(|| bad("compareexport", "no comparison: run COMPARE first"))?;
|
||||
let name = format!("compare_{} vs {}.dwg", stem(&title), stem(&c.other_name));
|
||||
let folders = Folders { here: cur_folder, there: folder_of(c.other_path.as_deref()) };
|
||||
let Snapshot { drawing, current, compared, counts, truncated, blocks_left_out, uncolored } = snapshot(&cur, c, &folders);
|
||||
let i = s.open_drawing(drawing, &name, None);
|
||||
// Not saved yet: closing it asks to save it under a name.
|
||||
let st = s.state_mut()?;
|
||||
st.saved = Arc::new((*st.doc).clone());
|
||||
let mut notes = Vec::new();
|
||||
if truncated {
|
||||
notes.push(format!("only the first {MAX_OBJECTS} objects of each drawing were compared"));
|
||||
}
|
||||
if blocks_left_out {
|
||||
notes.push("some blocks of the other drawing were too many or too large to copy".to_string());
|
||||
}
|
||||
if uncolored {
|
||||
notes.push("some blocks keep their own colors".to_string());
|
||||
}
|
||||
let notes = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) };
|
||||
Ok(json!({
|
||||
"index": i,
|
||||
"title": name,
|
||||
"current": current,
|
||||
"compared": compared,
|
||||
"counts": { "current": counts.0, "compared": counts.1 },
|
||||
"truncated": truncated || blocks_left_out || uncolored,
|
||||
"message": format!("The comparison was exported to the new drawing {name}{notes}."),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The file of the drawing COMPARE compares with: the `path` it was read from, or the open
|
||||
/// drawing's own file. `None` for `{data}` and for an open drawing never saved.
|
||||
pub(super) fn source_path(s: &Session, p: &Value, other: &Arc<Drawing>) -> Option<String> {
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) if p.get("document").is_none_or(Value::is_null) => Some(path.to_string()),
|
||||
_ => s.docs.iter().find(|d| Arc::ptr_eq(&d.doc, other)).and_then(|d| d.path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders of the two drawings' files. The snapshot has no folder of its own, so the
|
||||
/// relative file paths it copies (xrefs, images, table data links) become absolute paths from
|
||||
/// the folder of the drawing they came from.
|
||||
struct Folders {
|
||||
here: Option<PathBuf>,
|
||||
there: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The absolute folder of the file at `path`.
|
||||
fn folder_of(path: Option<&str>) -> Option<PathBuf> {
|
||||
let parent = Path::new(path?).parent()?;
|
||||
let parent = if parent.as_os_str().is_empty() { Path::new(".") } else { parent };
|
||||
Some(std::path::absolute(parent).unwrap_or_else(|_| parent.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `p` as an absolute path from `folder` when it is relative.
|
||||
pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) {
|
||||
if let Some(folder) = folder
|
||||
&& !p.trim().is_empty()
|
||||
&& Path::new(p.as_str()).is_relative()
|
||||
{
|
||||
let joined = folder.join(p.as_str());
|
||||
*p = std::path::absolute(&joined).unwrap_or(joined).to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebase the file an object refers to: an image's file, a table's data link.
|
||||
pub(super) fn rebase(k: &mut EntityKind, folder: Option<&Path>) {
|
||||
match k {
|
||||
EntityKind::Image(i) => rebase_path(&mut i.path, folder),
|
||||
EntityKind::Table(t) => {
|
||||
if let Some(l) = t.data_link.as_mut() {
|
||||
rebase_path(&mut l.path, folder);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebase a block's xref path and the files its objects refer to.
|
||||
fn rebase_block(b: &mut Block, folder: Option<&Path>) {
|
||||
if let Some(x) = b.xref_path.as_mut() {
|
||||
rebase_path(x, folder);
|
||||
}
|
||||
let mut store = EntityStore::new();
|
||||
for e in b.entities.iter() {
|
||||
let mut e = (**e).clone();
|
||||
rebase(&mut e.kind, folder);
|
||||
store.push(e);
|
||||
}
|
||||
b.entities = store;
|
||||
}
|
||||
|
||||
/// Whether a block holds a relative file path that [`rebase_block`] would change.
|
||||
fn has_relative_paths(b: &Block) -> bool {
|
||||
let rel = |p: &str| !p.trim().is_empty() && Path::new(p).is_relative();
|
||||
b.xref_path.as_deref().is_some_and(rel)
|
||||
|| b.entities.iter().any(|e| match &e.kind {
|
||||
EntityKind::Image(i) => rel(&i.path),
|
||||
EntityKind::Table(t) => t.data_link.as_ref().is_some_and(|l| rel(&l.path)),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A drawing title without its file extension.
|
||||
fn stem(title: &str) -> String {
|
||||
std::path::Path::new(title).file_stem().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| title.to_string())
|
||||
}
|
||||
|
||||
fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
let other: &Drawing = &c.other;
|
||||
let mut d = cur.clone();
|
||||
if folders.here.is_some() {
|
||||
for b in d.blocks.values_mut().filter(|b| has_relative_paths(b)) {
|
||||
rebase_block(Arc::make_mut(b), folders.here.as_deref());
|
||||
}
|
||||
}
|
||||
d.model = EntityStore::new();
|
||||
for l in &mut d.layouts {
|
||||
l.entities = EntityStore::new();
|
||||
}
|
||||
d.foreign = Arc::default();
|
||||
d.groups.clear();
|
||||
d.constraints.clear();
|
||||
// The per-object constraint bar and dimension exceptions name objects the copy doesn't have.
|
||||
d.parametric.settings.bar_exceptions.clear();
|
||||
d.parametric.settings.dim_exceptions.clear();
|
||||
d.hidden_objects.clear();
|
||||
d.read_notes.clear();
|
||||
d.refedit = None;
|
||||
d.skipped_records.clear();
|
||||
d.handseed = d.handseed.max(other.handseed);
|
||||
|
||||
merge(&mut d.layers, &other.layers, |t| &t.name);
|
||||
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
|
||||
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
|
||||
merge(&mut d.dim_styles, &other.dim_styles, |t| &t.name);
|
||||
merge(&mut d.mleader_styles, &other.mleader_styles, |t| &t.name);
|
||||
merge(&mut d.table_styles, &other.table_styles, |t| &t.name);
|
||||
merge(&mut d.mline_styles, &other.mline_styles, |t| &t.name);
|
||||
|
||||
// Compared again, so edits made since COMPARE ran are classified as they are now.
|
||||
let diff = super::diff(cur, other, &c.space);
|
||||
let ours: HashSet<Handle> = diff.added.iter().copied().chain(diff.changed.iter().map(|(a, ..)| *a)).collect();
|
||||
let here: Vec<(&Entity, Color)> = cur
|
||||
.space(&c.space)
|
||||
.into_iter()
|
||||
.flat_map(EntityStore::iter)
|
||||
.filter(|e| !matches!(e.kind, EntityKind::Viewport(_)))
|
||||
.take(MAX_OBJECTS)
|
||||
.map(|e| (e.as_ref(), if ours.contains(&e.handle) { THIS } else { COMMON }))
|
||||
.collect();
|
||||
// In the other drawing's draw order.
|
||||
let theirs: HashSet<Handle> = diff.removed.iter().copied().chain(diff.changed.iter().map(|(_, b, ..)| *b)).collect();
|
||||
let there: Vec<(&Entity, Color)> = other
|
||||
.space(&c.space)
|
||||
.into_iter()
|
||||
.flat_map(EntityStore::iter)
|
||||
.filter(|e| theirs.contains(&e.handle) && !matches!(e.kind, EntityKind::Viewport(_)))
|
||||
.map(|e| (e.as_ref(), OTHER))
|
||||
.collect();
|
||||
let mut tinted = Tinted::default();
|
||||
let (renames, blocks_left_out) = copy_blocks(&mut d, other, there.iter().map(|(e, _)| *e), folders.there.as_deref(), &mut tinted);
|
||||
|
||||
// The other drawing's objects first, so this drawing's are drawn over them.
|
||||
let compared = add_group(&mut d, "COMPARE_COMPARED", &there, &renames, folders.there.as_deref(), &mut tinted);
|
||||
let current = add_group(&mut d, "COMPARE_CURRENT", &here, &Renames::new(), folders.here.as_deref(), &mut tinted);
|
||||
let uncolored = tinted.uncolored;
|
||||
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
|
||||
}
|
||||
|
||||
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
|
||||
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
|
||||
for t in from {
|
||||
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
|
||||
to.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add copies of `objs` to model space with new handles, their block references renamed by
|
||||
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
|
||||
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
|
||||
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
|
||||
let mut members = Vec::new();
|
||||
for (e, color) in objs {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
let h = d.new_handle();
|
||||
let mut copy = Entity { handle: h, ..(*e).clone() };
|
||||
detach(&mut copy.kind);
|
||||
rebase(&mut copy.kind, folder);
|
||||
rename_refs(&mut copy.kind, renames);
|
||||
paint(d, &mut copy, *color, tinted, 0);
|
||||
d.model.push(copy);
|
||||
members.push(h);
|
||||
}
|
||||
let name = free_name(name, d.groups.len(), |n| d.groups.iter().any(|g| g.name.eq_ignore_ascii_case(n)));
|
||||
if !members.is_empty() {
|
||||
d.groups.push(Group { name: name.clone(), description: String::new(), selectable: true, members });
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Drop the links of an associative dimension or hatch to the objects it follows: the copies
|
||||
/// have new handles, so the links would name objects the snapshot does not have.
|
||||
fn detach(k: &mut EntityKind) {
|
||||
match k {
|
||||
EntityKind::Dimension(dm) => dm.assoc.clear(),
|
||||
EntityKind::Hatch(h) => {
|
||||
h.associative = false;
|
||||
h.loops.iter_mut().for_each(|l| l.sources.clear());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The block names an object refers to.
|
||||
fn block_refs(k: &EntityKind) -> Vec<String> {
|
||||
match k {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::Dimension(dm) => dm.block.iter().cloned().collect(),
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` on each block name an object refers to.
|
||||
fn each_ref(k: &mut EntityKind, mut f: impl FnMut(&mut String)) {
|
||||
match k {
|
||||
EntityKind::Insert(i) => f(&mut i.block),
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Some(n) = dm.block.as_mut() {
|
||||
f(n);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(b) = m.block.as_mut() {
|
||||
f(&mut b.block);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the block references of an object at the names its blocks were copied under.
|
||||
fn rename_refs(k: &mut EntityKind, renames: &Renames) {
|
||||
each_ref(k, |n| {
|
||||
if let Some(new) = renames.get(&n.to_ascii_uppercase()) {
|
||||
n.clone_from(new);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Recolored copies of blocks and styles made so far, and whether a block was left in its own
|
||||
/// colors because the nesting or copy limit was reached.
|
||||
#[derive(Default)]
|
||||
struct Tinted {
|
||||
/// (comparison tag, "block", "table style" or "dimension style", upper-case name) → the
|
||||
/// copy's name.
|
||||
names: HashMap<(&'static str, &'static str, String), String>,
|
||||
uncolored: bool,
|
||||
/// Objects copied into block definitions so far, counted against [`MAX_COPIED`].
|
||||
copied: usize,
|
||||
next: NextFree,
|
||||
/// The drawing's block names in upper case, filled on the first block copy. Only
|
||||
/// [`tinted_block`] adds blocks after that, and it adds their names here too.
|
||||
blocks: Option<HashSet<String>>,
|
||||
}
|
||||
|
||||
/// The next index to try for each name base. Names are only added while a snapshot is built,
|
||||
/// so an index found taken stays taken, and each search starts where the last one for the same
|
||||
/// base stopped: a name costs O(1) amortised instead of a scan from 1.
|
||||
#[derive(Default)]
|
||||
struct NextFree(HashMap<(&'static str, String), usize>);
|
||||
|
||||
impl NextFree {
|
||||
/// The first of `name(i)` for `i` from 1 that `taken` doesn't hold, skipping the indices
|
||||
/// earlier searches for `base` in namespace `kind` passed. `n` is how many names there are,
|
||||
/// so one of `name(1)`…`name(n + 1)` is free. The caller adds the returned name.
|
||||
fn find(&mut self, kind: &'static str, base: &str, n: usize, name: impl Fn(usize) -> String, taken: impl Fn(&str) -> bool) -> String {
|
||||
let key = (kind, base.to_ascii_uppercase());
|
||||
let start = self.0.get(&key).copied().unwrap_or(1);
|
||||
let i = (start..=n.saturating_add(1)).find(|&i| !taken(&name(i))).unwrap_or_else(|| n.saturating_add(2).max(start));
|
||||
self.0.insert(key, i.saturating_add(1));
|
||||
name(i)
|
||||
}
|
||||
}
|
||||
|
||||
/// `base` for 1, `base_2`, `base_3`… after it.
|
||||
fn suffixed(base: &str, i: usize) -> String {
|
||||
if i == 1 { base.to_string() } else { format!("{base}_{i}") }
|
||||
}
|
||||
|
||||
/// The word a comparison color adds to the names of its recolored copies.
|
||||
fn tag(color: Color) -> &'static str {
|
||||
if color == THIS {
|
||||
"CURRENT"
|
||||
} else if color == OTHER {
|
||||
"COMPARED"
|
||||
} else {
|
||||
"COMMON"
|
||||
}
|
||||
}
|
||||
|
||||
/// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are.
|
||||
fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String {
|
||||
(1..=n + 1).map(|i| suffixed(base, i)).find(|c| !taken(c)).unwrap_or_else(|| suffixed(base, n + 2))
|
||||
}
|
||||
|
||||
/// Give an object its comparison color: its own color, its attributes' colors, and, through
|
||||
/// recolored copies of the blocks and styles it refers to, the color of everything they draw.
|
||||
fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, depth: usize) {
|
||||
e.common.color = color;
|
||||
let attribs = match &mut e.kind {
|
||||
EntityKind::Insert(i) => Some(&mut i.attribs),
|
||||
EntityKind::MLeader(m) => m.block.as_mut().map(|b| &mut b.attribs),
|
||||
_ => None,
|
||||
};
|
||||
for a in attribs.into_iter().flatten() {
|
||||
a.props.color = Some(color);
|
||||
if let Some(ml) = a.multiline.as_mut() {
|
||||
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
|
||||
}
|
||||
}
|
||||
// Colors an object carries apart from its own: inline MTEXT colors (in multiline attributes
|
||||
// too), a dimension style's DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element
|
||||
// colors, the style colors of a table or a tolerance (through a recolored copy of the style)
|
||||
// and inline cell colors. Hatch backgrounds, multiline fills and table cell fills are left out.
|
||||
match &mut e.kind {
|
||||
EntityKind::MText(t) => t.contents = without_color_codes(&t.contents),
|
||||
EntityKind::AttDef(a) => {
|
||||
if let Some(ml) = a.multiline.as_mut() {
|
||||
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = m.text.as_mut() {
|
||||
t.contents = without_color_codes(&t.contents);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Ok(by_block) = serde_json::to_value(Color::ByBlock) {
|
||||
for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] {
|
||||
dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var));
|
||||
dm.overrides.insert(var.into(), by_block.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Tolerance(t) => {
|
||||
if let Some(style) = tinted_dim_style(d, &t.style, color, tinted) {
|
||||
t.style = style;
|
||||
}
|
||||
}
|
||||
EntityKind::Hatch(h) => {
|
||||
if let Some(g) = h.gradient.as_mut() {
|
||||
(g.color1, g.color2) = (color, color);
|
||||
}
|
||||
h.background = None;
|
||||
}
|
||||
EntityKind::MLine(m) => {
|
||||
for el in &mut m.def.elements {
|
||||
el.color = Color::ByBlock;
|
||||
}
|
||||
m.def.fill = None;
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
for cell in t.cells.iter_mut().flatten() {
|
||||
cell.text = without_color_codes(&cell.text);
|
||||
}
|
||||
if let Some(style) = tinted_table_style(d, &t.style, color, tinted) {
|
||||
t.style = style;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
// A dimension whose block can't be copied (past the limits) is drawn again from its
|
||||
// geometry: its DIMCLR* overrides are ByBlock, so the original block would keep its colors.
|
||||
dm.block = dm.block.take().and_then(|n| tinted_block(d, &n, color, tinted, depth));
|
||||
return;
|
||||
}
|
||||
each_ref(&mut e.kind, |n| {
|
||||
if let Some(copy) = tinted_block(d, n, color, tinted, depth) {
|
||||
*n = copy;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The name of a copy of dimension style `name` that draws lines and text in the object's
|
||||
/// color, made on first use. `None` when the drawing has no such style.
|
||||
fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
|
||||
let key = (tag(color), "dimension style", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
|
||||
let base = format!("COMPARE_{}_{}", tag(color), st.name);
|
||||
let new = tinted.next.find(
|
||||
"dimension style",
|
||||
&base,
|
||||
d.dim_styles.len(),
|
||||
|i| suffixed(&base, i),
|
||||
|n| d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
|
||||
);
|
||||
d.dim_styles.push(DimStyle {
|
||||
name: new.clone(),
|
||||
dim_line_color: Color::ByBlock,
|
||||
ext_line_color: Color::ByBlock,
|
||||
text_color: Color::ByBlock,
|
||||
..st
|
||||
});
|
||||
tinted.names.insert(key, new.clone());
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// The name of a copy of table style `name` whose cell styles draw text and borders in the
|
||||
/// table's color and fill no cells, made on first use. `None` when the drawing has no such style.
|
||||
fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
|
||||
let key = (tag(color), "table style", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
|
||||
let base = format!("COMPARE_{}_{}", tag(color), st.name);
|
||||
let new = tinted.next.find(
|
||||
"table style",
|
||||
&base,
|
||||
d.table_styles.len(),
|
||||
|i| suffixed(&base, i),
|
||||
|n| d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
|
||||
);
|
||||
let names =
|
||||
[CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone()));
|
||||
let mut cell_styles: Vec<CellStyle> = Vec::new();
|
||||
for n in names {
|
||||
if cell_styles.iter().any(|c| c.name.eq_ignore_ascii_case(&n)) {
|
||||
continue;
|
||||
}
|
||||
let mut cs = st.cell_style(&n);
|
||||
cs.name = n;
|
||||
cs.text_color = Color::ByBlock;
|
||||
cs.fill = None;
|
||||
let b = &mut cs.borders;
|
||||
for border in [&mut b.top, &mut b.inside_h, &mut b.bottom, &mut b.left, &mut b.inside_v, &mut b.right] {
|
||||
border.color = Color::ByBlock;
|
||||
}
|
||||
cell_styles.push(cs);
|
||||
}
|
||||
d.table_styles.push(TableStyle { name: new.clone(), cell_styles, ..st });
|
||||
tinted.names.insert(key, new.clone());
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// MTEXT contents without inline color codes (`\C1;`, `\c255;`), so the text takes the
|
||||
/// object's color. An escaped backslash (`\\`) stays.
|
||||
fn without_color_codes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut it = s.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match it.peek().copied() {
|
||||
Some('C' | 'c') => {
|
||||
for d in it.by_ref() {
|
||||
if d == ';' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(n) => {
|
||||
out.push('\\');
|
||||
out.push(n);
|
||||
it.next();
|
||||
}
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The name of a copy of block `name` whose objects all have `color`, made on first use. `None`
|
||||
/// (the reference keeps the original block) for an xref, a missing block, or past the nesting
|
||||
/// and copy limits.
|
||||
fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, depth: usize) -> Option<String> {
|
||||
let key = (tag(color), "block", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let b = d.block(name)?.clone();
|
||||
if b.xref_path.is_some() {
|
||||
return None;
|
||||
}
|
||||
if depth >= MAX_BLOCK_DEPTH || tinted.names.len() >= MAX_BLOCKS || tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
|
||||
tinted.uncolored = true;
|
||||
return None;
|
||||
}
|
||||
tinted.copied += b.entities.len();
|
||||
// A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of
|
||||
// drawing the dimension again.
|
||||
let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D"));
|
||||
let Tinted { next, blocks, .. } = tinted;
|
||||
let taken = blocks.get_or_insert_with(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect());
|
||||
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
|
||||
let new = if dim_block {
|
||||
next.find("block", "*D", d.blocks.len(), |i| format!("*D{i}"), is_taken)
|
||||
} else {
|
||||
let base = format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*'));
|
||||
next.find("block", &base, d.blocks.len(), |i| suffixed(&base, i), is_taken)
|
||||
};
|
||||
taken.insert(new.to_ascii_uppercase());
|
||||
tinted.names.insert(key, new.clone());
|
||||
// Hold the name while the nested copies pick theirs.
|
||||
d.blocks.insert(new.clone(), Arc::new(Block::new(&new)));
|
||||
let mut copy = Block { name: new.clone(), anonymous: dim_block, entities: EntityStore::new(), ..(*b).clone() };
|
||||
for e in b.entities.iter() {
|
||||
let h = d.new_handle();
|
||||
let mut e = Entity { handle: h, ..(**e).clone() };
|
||||
detach(&mut e.kind);
|
||||
paint(d, &mut e, color, tinted, depth + 1);
|
||||
copy.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(new.clone(), Arc::new(copy));
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// The name each block copied from the other drawing has in the snapshot, by its upper-case
|
||||
/// name there.
|
||||
type Renames = HashMap<String, String>;
|
||||
|
||||
/// Copy into `d` the blocks of `other` that `objs` refer to, directly or through other blocks.
|
||||
/// A named block `d` already has keeps `d`'s definition when it draws the same thing there (the
|
||||
/// same objects, base point, units, flags and xref file, in it and in the blocks it refers to);
|
||||
/// otherwise the other drawing's block is copied as `PART_2` (or `PART_3`…). An anonymous block
|
||||
/// (`*D3`, `*U7`…) whose name `d` already uses is copied under the next free name with the same
|
||||
/// prefix. Copied
|
||||
/// objects get new handles, and relative file paths are rebased on `folder`. The flag is true
|
||||
/// when more than [`MAX_BLOCKS`] blocks or [`MAX_COPIED`] objects were needed and the rest were
|
||||
/// left out.
|
||||
fn copy_blocks<'a>(
|
||||
d: &mut Drawing,
|
||||
other: &Drawing,
|
||||
objs: impl Iterator<Item = &'a Entity>,
|
||||
folder: Option<&Path>,
|
||||
tinted: &mut Tinted,
|
||||
) -> (Renames, bool) {
|
||||
let mut frontier: Vec<String> = objs.flat_map(|e| block_refs(&e.kind)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut needed: Vec<Arc<Block>> = Vec::new();
|
||||
let mut left_out = false;
|
||||
// `d`'s relative paths were made absolute from its own folder before the copy.
|
||||
let (mut mine, mut theirs) = (super::Signer::with_frames(d, None), super::Signer::with_frames(other, folder));
|
||||
// Whether `d`'s block `n` draws what `other`'s block `b` draws. Past the signers' work limit
|
||||
// the signatures no longer cover every object, so the blocks count as different.
|
||||
let mut same = |n: &str, b: &Block| {
|
||||
!b.anonymous
|
||||
&& d.block(n).is_some()
|
||||
&& mine.block(n, 0) == theirs.block(n, 0)
|
||||
&& mine.work < super::MAX_BLOCK_WORK
|
||||
&& theirs.work < super::MAX_BLOCK_WORK
|
||||
};
|
||||
while let Some(n) = frontier.pop() {
|
||||
let key = n.to_ascii_uppercase();
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() >= MAX_BLOCKS {
|
||||
left_out |= other.block(&n).is_some_and(|b| !same(&n, b));
|
||||
continue;
|
||||
}
|
||||
seen.insert(key);
|
||||
let Some(b) = other.block(&n) else { continue };
|
||||
if same(&n, b) {
|
||||
continue;
|
||||
}
|
||||
if tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
|
||||
left_out = true;
|
||||
continue;
|
||||
}
|
||||
tinted.copied += b.entities.len();
|
||||
frontier.extend(b.entities.iter().flat_map(|e| block_refs(&e.kind)));
|
||||
needed.push(b.clone());
|
||||
}
|
||||
// Names `d` does not use stay; the others get free names, avoiding the kept ones.
|
||||
let mut renames = Renames::new();
|
||||
let mut taken: HashSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
let mut next = NextFree::default();
|
||||
for b in needed.iter().filter(|b| d.block(&b.name).is_none()) {
|
||||
taken.insert(b.name.to_ascii_uppercase());
|
||||
renames.insert(b.name.to_ascii_uppercase(), b.name.clone());
|
||||
}
|
||||
for b in needed.iter().filter(|b| d.block(&b.name).is_some()) {
|
||||
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
|
||||
let free = if b.anonymous {
|
||||
let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect();
|
||||
next.find("anonymous block", &prefix, taken.len(), |i| format!("{prefix}{i}"), is_taken)
|
||||
} else {
|
||||
next.find("block", &b.name, taken.len(), |i| suffixed(&b.name, i), is_taken)
|
||||
};
|
||||
taken.insert(free.to_ascii_uppercase());
|
||||
renames.insert(b.name.to_ascii_uppercase(), free);
|
||||
}
|
||||
for b in needed {
|
||||
let Some(name) = renames.get(&b.name.to_ascii_uppercase()).cloned() else { continue };
|
||||
let mut copy = Block { name: name.clone(), entities: EntityStore::new(), ..(*b).clone() };
|
||||
for e in b.entities.iter() {
|
||||
let h = d.new_handle();
|
||||
let mut e = Entity { handle: h, ..(**e).clone() };
|
||||
detach(&mut e.kind);
|
||||
rename_refs(&mut e.kind, &renames);
|
||||
copy.entities.push(e);
|
||||
}
|
||||
rebase_block(&mut copy, folder);
|
||||
d.blocks.insert(name, Arc::new(copy));
|
||||
}
|
||||
(renames, left_out)
|
||||
}
|
||||
@@ -12,9 +12,8 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::always;
|
||||
use super::super::helpers::support_text;
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, find_command};
|
||||
use super::*;
|
||||
|
||||
/// Most user aliases held.
|
||||
pub const MAX_ALIASES: usize = 10_000;
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
use cadcraft_doc::Drawing;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Result, Session, bad, io};
|
||||
use super::*;
|
||||
|
||||
/// The default template (profile setting, process-wide like FONTALT): empty = none.
|
||||
fn default_template() -> MutexGuard<'static, String> {
|
||||
|
||||
@@ -5,8 +5,7 @@ use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::curves;
|
||||
use super::{CommandSpec, bad, bool_or, point_param, point_req, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::grips::{GripAction, GripMode, grip_action, mode_matrix, stretch_grip};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ use cadcraft_doc::{Drawing, Group, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::{CommandSpec, bad, bool_or, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod dash;
|
||||
|
||||
@@ -7,8 +7,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::qselect::wildcard;
|
||||
use super::super::{CommandSpec, bad, bool_or, str_param, targets};
|
||||
|
||||
use super::super::*;
|
||||
use super::{add_members, create, echo_list, find, remove_members, rename, ungroup, valid_name};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use cadcraft_doc::Handle;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::{CommandSpec, bad};
|
||||
|
||||
use super::super::always;
|
||||
use super::super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -11,13 +11,11 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::lwpoly;
|
||||
use super::*;
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||||
|
||||
mod assoc;
|
||||
mod edit;
|
||||
mod pick;
|
||||
use super::has_doc;
|
||||
pub(crate) use assoc::update as refill_associative;
|
||||
pub use pick::{HatchPicks, PickMode};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use cadcraft_doc::library::{PatternLine, pattern, standard_patterns};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::helpers::support_text;
|
||||
use super::{Result, Session, bad, bool_or};
|
||||
use super::*;
|
||||
|
||||
/// Most user patterns held at once.
|
||||
pub const MAX_USER_PATTERNS: usize = 4096;
|
||||
|
||||
@@ -6,8 +6,7 @@ use cadcraft_doc::{Entity, EntityKind, Handle};
|
||||
use cadcraft_geom::{Polyline, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, f64_req, point_req, point_value, points_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::units::{format_angle, format_distance};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
@@ -402,17 +401,8 @@ pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
|
||||
(a, per)
|
||||
}
|
||||
|
||||
/// `points` as given, `None` when it is absent; a `points` that is not a list of points is an
|
||||
/// error, never a fall back to the selection.
|
||||
fn given_points(p: &Value, cmd: &str) -> Result<Option<Vec<Vec2>>> {
|
||||
match p.get("points") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => points_param(p, "points").map(Some).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points")),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, per) = if let Some(pts) = given_points(p, "area")? {
|
||||
let (a, per) = if let Some(pts) = points_param(p, "points") {
|
||||
area_of(&pts)
|
||||
} else {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
|
||||
@@ -425,37 +415,6 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
|
||||
format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per))
|
||||
}
|
||||
|
||||
/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a 2D object
|
||||
/// as AREA measures it (an open polyline as if closed), times the absolute value of a height.
|
||||
pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = if let Some(pts) = given_points(p, "volume.measure")? {
|
||||
if pts.len() < 3 {
|
||||
return Err(bad("volume.measure", "`points` needs 3 or more [x,y] points"));
|
||||
}
|
||||
area_of(&pts).0
|
||||
} else {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("volume.measure", "`points` or `handle` is required"))?;
|
||||
object_area(s, h)?.0
|
||||
};
|
||||
let height = f64_req("volume.measure", p, "height")?;
|
||||
let v = a * height.abs();
|
||||
if !v.is_finite() {
|
||||
return Err(bad("volume.measure", "the volume is too large"));
|
||||
}
|
||||
Ok(json!({ "volume": v, "area": a, "height": height, "message": volume_text(s, v) }))
|
||||
}
|
||||
|
||||
fn volume_text(s: &Session, v: f64) -> String {
|
||||
format!("Volume = {}", fmt_volume(s, v))
|
||||
}
|
||||
|
||||
/// Format a volume as a number in the drawing's `LUPREC`. Engineering and architectural units
|
||||
/// show it as a decimal number, not in feet and inches.
|
||||
fn fmt_volume(s: &Session, v: f64) -> String {
|
||||
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
|
||||
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
|
||||
}
|
||||
|
||||
/// Area and perimeter of a closed object (AREA's Object option).
|
||||
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
let d = s.doc()?;
|
||||
@@ -930,19 +889,9 @@ pub(super) struct AreaM {
|
||||
object: bool,
|
||||
/// Asking for the length of a segment (the Length option).
|
||||
length: bool,
|
||||
/// Measuring volumes (MEASUREGEOM's Volume option): each area is multiplied by the absolute value of a height.
|
||||
volume: bool,
|
||||
/// Areas measured in volume mode that still wait for their height, oldest first.
|
||||
pending: Vec<f64>,
|
||||
/// The first point of a height given by two points.
|
||||
height_from: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl AreaM {
|
||||
/// MEASUREGEOM's Volume option: AREA's prompts, then a height for each area.
|
||||
pub(super) fn volume() -> Self {
|
||||
AreaM { volume: true, ..AreaM::default() }
|
||||
}
|
||||
fn mode_prefix(&self) -> &'static str {
|
||||
match self.mode {
|
||||
AreaMode::Single => "",
|
||||
@@ -950,49 +899,8 @@ impl AreaM {
|
||||
AreaMode::Subtract => "(SUBTRACT mode) ",
|
||||
}
|
||||
}
|
||||
/// The Add and Subtract keywords: "Add area" and "Subtract area", or "Add volume" and
|
||||
/// "Subtract volume".
|
||||
fn mode_keywords(&self) -> (&'static str, &'static str) {
|
||||
if self.volume { ("Add volume", "Subtract volume") } else { ("Add area", "Subtract area") }
|
||||
}
|
||||
/// The running total with `v` added or subtracted; in single mode, the total as it is.
|
||||
fn total_with(&self, v: f64) -> f64 {
|
||||
match self.mode {
|
||||
AreaMode::Single => self.total,
|
||||
AreaMode::Add => self.total + v,
|
||||
AreaMode::Subtract => self.total - v,
|
||||
}
|
||||
}
|
||||
/// Measure the oldest pending area's volume with height `h`.
|
||||
fn height(&mut self, s: &mut Session, h: f64) -> Step {
|
||||
match self.pending.first().map(|a| a * h.abs()) {
|
||||
Some(v) if v.is_finite() && self.total_with(v).is_finite() => {
|
||||
self.pending.remove(0);
|
||||
self.volume_measured(s, v)
|
||||
}
|
||||
_ => {
|
||||
s.echo("The volume is too large.");
|
||||
Step::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Report a volume (and the running total in add/subtract mode); ends the command in single mode.
|
||||
fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step {
|
||||
s.echo(volume_text(s, v));
|
||||
if self.mode == AreaMode::Single {
|
||||
return Step::Done;
|
||||
}
|
||||
self.total = self.total_with(v);
|
||||
s.echo(format!("Total volume = {}", fmt_volume(s, self.total)));
|
||||
Step::Continue
|
||||
}
|
||||
/// Report an area (and the running total in add/subtract mode); ends the command in single mode.
|
||||
/// In volume mode the area waits for its height instead.
|
||||
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
|
||||
if self.volume {
|
||||
self.pending.push(a);
|
||||
return Step::Continue;
|
||||
}
|
||||
s.echo(area_text(s, a, per));
|
||||
match self.mode {
|
||||
AreaMode::Single => return Step::Done,
|
||||
@@ -1015,15 +923,9 @@ impl AreaM {
|
||||
|
||||
impl Interactive for AreaM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.volume { "MEASUREGEOM" } else { "AREA" }
|
||||
"AREA"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if !self.pending.is_empty() {
|
||||
return match self.height_from {
|
||||
Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p),
|
||||
None => Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::POINT_OR_NUMBER),
|
||||
};
|
||||
}
|
||||
if self.object {
|
||||
return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT);
|
||||
}
|
||||
@@ -1031,35 +933,17 @@ impl Interactive for AreaM {
|
||||
return length_prompt();
|
||||
}
|
||||
let next = format!("{}Specify next point", self.mode_prefix());
|
||||
let (add, subtract) = self.mode_keywords();
|
||||
match (self.pts.len(), self.mode) {
|
||||
(0, AreaMode::Single) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add, subtract]).default("Object"),
|
||||
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", subtract]),
|
||||
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add]),
|
||||
(0, AreaMode::Single) => {
|
||||
Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object")
|
||||
}
|
||||
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Subtract area"]),
|
||||
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area"]),
|
||||
(1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo", "Total"]).default("Total").base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.pending.is_empty() {
|
||||
// A height is typed, or given by two points (their distance).
|
||||
match (i, self.height_from) {
|
||||
(Input::Point(p), None) => self.height_from = Some(p),
|
||||
(Input::Point(p), Some(from)) => {
|
||||
self.height_from = None;
|
||||
return Ok(self.height(s, p.dist(from)));
|
||||
}
|
||||
(Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) {
|
||||
Some(h) => return Ok(self.height(s, h)),
|
||||
None => s.echo("Requires a distance."),
|
||||
},
|
||||
(Input::Enter, None) => s.echo("Requires a height."),
|
||||
(Input::Enter, Some(_)) => s.echo("Requires a second point."),
|
||||
(Input::Text(_), Some(_)) => s.echo("Requires a point."),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.object {
|
||||
match i {
|
||||
Input::Pick(hs) => {
|
||||
@@ -1069,10 +953,6 @@ impl Interactive for AreaM {
|
||||
if self.measured(s, a, per) == Step::Done {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
// A single volume takes the first object with an area.
|
||||
if self.volume && self.mode == AreaMode::Single {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => s.echo("The selected object has no area."),
|
||||
}
|
||||
@@ -1100,8 +980,8 @@ impl Interactive for AreaM {
|
||||
Input::Point(p) => self.pts.push(p),
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Object" => self.object = true,
|
||||
"Add area" | "Add volume" => self.mode = AreaMode::Add,
|
||||
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
|
||||
"Add area" => self.mode = AreaMode::Add,
|
||||
"Subtract area" => self.mode = AreaMode::Subtract,
|
||||
"Undo" => {
|
||||
self.pts.pop();
|
||||
}
|
||||
@@ -1131,8 +1011,6 @@ impl Interactive for AreaM {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cadcraft_doc::Handle;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// #1440 review: a value one ulp past its bound (a border rectangle's shoelace area against
|
||||
|
||||
@@ -9,8 +9,7 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Insert, MAX_BLOCK_DEPTH,
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::{bad, bool_or, point_req, str_param, targets};
|
||||
|
||||
use super::super::*;
|
||||
use super::SelfCheck;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Arc as GeoArc, EPS, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::bad;
|
||||
|
||||
use super::super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "loops";
|
||||
@@ -117,7 +116,21 @@ 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 = merge_ends(&ends, tol);
|
||||
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 groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
for i in 0..n {
|
||||
let root = find(&mut parent, i);
|
||||
@@ -126,8 +139,54 @@ 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 (joints, junctions) = sort_groups(&groups, &ends, &pieces, &mut excluded);
|
||||
let link = joint_links(joints, &ends, &pieces, &excluded);
|
||||
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 mut visited = vec![false; pieces.len()];
|
||||
let mut loops: Vec<Value> = Vec::new();
|
||||
@@ -165,84 +224,6 @@ 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,
|
||||
|
||||
@@ -11,8 +11,7 @@ use cadcraft_geom::{Line, Vec2, line_line, point_in_polygon};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::super::{CommandSpec, bad, targets};
|
||||
|
||||
use super::super::*;
|
||||
use super::SelfCheck;
|
||||
use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ use cadcraft_doc::{Entity, EntityKind};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::bad;
|
||||
|
||||
use super::super::point_value;
|
||||
use super::super::*;
|
||||
use super::{SelfCheck, area_of, object_area};
|
||||
use crate::{Result, Session};
|
||||
|
||||
|
||||
@@ -8,9 +8,8 @@ use std::collections::{HashMap, HashSet};
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::bad;
|
||||
use super::super::blocks::purge;
|
||||
|
||||
use super::super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "usage";
|
||||
@@ -281,9 +280,11 @@ impl Unused {
|
||||
fn item_json(
|
||||
c: Category,
|
||||
name: &str,
|
||||
(entities, block_entities): (usize, usize),
|
||||
entities: usize,
|
||||
block_entities: usize,
|
||||
references: usize,
|
||||
(used, empty): (bool, bool),
|
||||
used: bool,
|
||||
empty: bool,
|
||||
current: Option<bool>,
|
||||
unused: &Unused,
|
||||
) -> Value {
|
||||
@@ -313,13 +314,13 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
|
||||
let (e, b) = scan.layers.get(&l.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
let uses = e.saturating_add(b);
|
||||
let cur = l.name.eq_ignore_ascii_case(¤t_layer);
|
||||
out.push(item_json(Category::Layer, &l.name, (e, b), 0, (uses > 0, uses == 0), Some(cur), unused));
|
||||
out.push(item_json(Category::Layer, &l.name, e, b, 0, uses > 0, uses == 0, Some(cur), unused));
|
||||
}
|
||||
for b in d.blocks.values() {
|
||||
let (e, n) = scan.blocks.get(&b.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
// A definition is used when a reference to it is reachable, as PURGE counts it.
|
||||
let used = scan.used_blocks.contains(&b.name.to_ascii_uppercase());
|
||||
out.push(item_json(Category::Block, &b.name, (e, n), 0, (used, e.saturating_add(n) == 0), None, unused));
|
||||
out.push(item_json(Category::Block, &b.name, e, n, 0, used, e.saturating_add(n) == 0, None, unused));
|
||||
}
|
||||
let current_style = d.header.str("TEXTSTYLE", "Standard");
|
||||
for t in &d.text_styles {
|
||||
@@ -327,7 +328,7 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
|
||||
let r = scan.style_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
|
||||
let uses = e.saturating_add(b).saturating_add(r);
|
||||
let cur = t.name.eq_ignore_ascii_case(¤t_style);
|
||||
out.push(item_json(Category::TextStyle, &t.name, (e, b), r, (uses > 0, uses == 0), Some(cur), unused));
|
||||
out.push(item_json(Category::TextStyle, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
|
||||
}
|
||||
let current_linetype = d.header.str("CELTYPE", "ByLayer");
|
||||
for t in &d.linetypes {
|
||||
@@ -335,7 +336,7 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
|
||||
let r = scan.linetype_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
|
||||
let uses = e.saturating_add(b).saturating_add(r);
|
||||
let cur = t.name.eq_ignore_ascii_case(¤t_linetype);
|
||||
out.push(item_json(Category::Linetype, &t.name, (e, b), r, (uses > 0, uses == 0), Some(cur), unused));
|
||||
out.push(item_json(Category::Linetype, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ use cadcraft_doc::{Layer, Lineweight};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::SelectRun;
|
||||
use super::{CommandSpec, bad, bool_or, ok, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -10,7 +10,6 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::file::{base64_encode, io};
|
||||
use super::*;
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
use viewports::MviewM;
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
use cadcraft_doc::{Drawing, PlotStyle, PlotStyleKind, PlotStyleTable};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::targets;
|
||||
use super::{CommandSpec, Result, Session, bad, str_param};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -637,13 +637,13 @@ impl Interactive for MviewM {
|
||||
Stage::Arrange => Prompt::new("Enter viewport arrangement", curves::KW)
|
||||
.kw(&["Horizontal", "Vertical", "Above", "Below", "Left", "Right"])
|
||||
.default("Right"),
|
||||
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a),
|
||||
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
||||
Stage::Polygon(pi) => pi.prompt(),
|
||||
Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT),
|
||||
Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]),
|
||||
Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE),
|
||||
Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
|
||||
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a),
|
||||
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
||||
Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::{CommandSpec, bad, point_req, points_param, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most arrowheads added by one call, and most multileaders aligned / collected by one call.
|
||||
|
||||
@@ -33,7 +33,7 @@ pub mod plotters;
|
||||
mod props;
|
||||
pub use draw2::breakline::BreaklineOptions;
|
||||
pub use props::MatchProps;
|
||||
pub(crate) use props::{current_color_value, set_current_color_value};
|
||||
pub(crate) use props::set_current_color_value;
|
||||
mod qselect;
|
||||
mod selecttools;
|
||||
mod settings;
|
||||
|
||||
@@ -7,12 +7,10 @@ use cadcraft_geom::{
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::{arc, circle, line, lwpoly, v3};
|
||||
|
||||
use super::helpers::*;
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::trimextend::{self, TrimM};
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, points_param, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::select::{SelectKind, SelectMode};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
@@ -519,7 +517,7 @@ impl Interactive for StretchM {
|
||||
return m.prompt();
|
||||
}
|
||||
match (self.phase, self.corner) {
|
||||
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c),
|
||||
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c),
|
||||
(StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT),
|
||||
(StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),
|
||||
(StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),
|
||||
@@ -1399,17 +1397,21 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
// The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font.
|
||||
let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
|
||||
let g = cadcraft_render::dimension_in(d, dm);
|
||||
let 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();
|
||||
let mut v: Vec<Entity> = g
|
||||
.lines
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
if l.len() == 2 {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
|
||||
} else {
|
||||
Some(Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for t in &g.fills {
|
||||
if let [a, b, c] = t.as_slice() {
|
||||
v.push(Entity {
|
||||
@@ -1419,20 +1421,6 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
});
|
||||
}
|
||||
}
|
||||
// DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and
|
||||
// arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3).
|
||||
if style.text_fill == 2
|
||||
&& let [p0, p1, p2, p3] = g.text_fill.as_slice()
|
||||
{
|
||||
let fill = style.text_fill_color;
|
||||
let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill };
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: cadcraft_doc::Common { color, ..e.common.clone() },
|
||||
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }),
|
||||
});
|
||||
}
|
||||
v.extend(marks(true).filter_map(&line_entity));
|
||||
if !g.value.is_empty() {
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
|
||||
@@ -9,11 +9,9 @@ use cadcraft_geom::{
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::curves::{self, Chain, MAX_GEN, TanObj};
|
||||
use super::helpers::{arc, line, lwpoly, v3};
|
||||
|
||||
use super::helpers::*;
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod curve_edits;
|
||||
|
||||
@@ -80,7 +80,8 @@ fn same_polyline(
|
||||
s: &mut Session,
|
||||
h: Handle,
|
||||
pl: &LwPolyline,
|
||||
(p1, p2): (Vec2, Vec2),
|
||||
p1: Vec2,
|
||||
p2: Vec2,
|
||||
r: f64,
|
||||
chamfer: Option<ChamferDistances>,
|
||||
trim: bool,
|
||||
@@ -222,7 +223,7 @@ pub(crate) fn fillet_pline(
|
||||
_ => None,
|
||||
};
|
||||
let made = match (&e1.kind, &e2.kind) {
|
||||
(EntityKind::LwPolyline(pl), _) if h1 == h2 => same_polyline(s, h1, pl, (p1, p2), r, chamfer, trim)?,
|
||||
(EntityKind::LwPolyline(pl), _) if h1 == h2 => same_polyline(s, h1, pl, p1, p2, r, chamfer, trim)?,
|
||||
(EntityKind::LwPolyline(pl), k) if as_line(k).is_some() => {
|
||||
let ln = as_line(k).ok_or_else(|| other("Select a line."))?;
|
||||
polyline_and_line(s, (h1, pl, p1), (h2, ln, p2), true, r, chamfer, trim)?
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! PEDIT Edit vertex: Break and Straighten between two marked vertices, Tangent directions
|
||||
//! that Fit honours, and Regen.
|
||||
|
||||
use super::super::point_value;
|
||||
use super::{
|
||||
EntityKind, Handle, Input, LwPolyline, PPhase, PeditM, PolyVertex, Result, Session, Value, Vec2, bad, curves, json, other, push_entity, set_kind,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
fn ordered(pl: &LwPolyline, i: usize, j: usize) -> Result<(usize, usize)> {
|
||||
let n = pl.vertices.len();
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
//! parametrisation, end tangents and fit tolerance. Control-point edits change the curve
|
||||
//! directly and drop the fit data, as the curve no longer follows it.
|
||||
|
||||
use super::super::{point_value, points_param};
|
||||
use super::{
|
||||
Accept, Entity, EntityKind, Handle, Input, Interactive, LwPolyline, MAX_GEN, PolyVertex, Prompt, Result, SelOutcome, SelectPhase, Session, Space,
|
||||
Spline, Step, Value, Vec2, bad, bool_or, curves, f64_req, json, line, number, other, point_req, set_kind, str_param, targets,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// The spline after a JSON SPLINEDIT option (`option` plus that option's parameters).
|
||||
pub(super) fn run_splinedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -13,8 +13,7 @@ use cadcraft_geom::{TAU, Vec2, Vec3, ccw_sweep, norm_angle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::{CommandSpec, bad, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -14,8 +14,7 @@ use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfi
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, Result, always, bad, has_doc, point_req, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Session, Step};
|
||||
|
||||
/// Where plotter configuration files live. Names are file names ending in
|
||||
@@ -363,12 +362,10 @@ impl Interactive for PlotWindowM {
|
||||
fn name(&self) -> &'static str {
|
||||
"PLOTWINDOW"
|
||||
}
|
||||
/// Ask for the first corner, then for the opposite one marked as a rectangle corner
|
||||
/// (`Prompt::corner`), so ORTHO does not bend the pick and the window keeps its area.
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
|
||||
@@ -10,8 +10,7 @@ use cadcraft_doc::{EntityKind, Lineweight, Transparency};
|
||||
use cadcraft_geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, f64_req, find_command, ok, point_req, point_value, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -33,11 +32,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
|
||||
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
|
||||
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
|
||||
CommandSpec::new("thickness", "Thickness", run_thickness)
|
||||
.menu(&["Format", "Thickness"])
|
||||
.params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}")
|
||||
.transparent()
|
||||
.interactive(|_| Ok(Box::new(ThicknessM))),
|
||||
CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"),
|
||||
CommandSpec::new("style", "Text Style...", run_style)
|
||||
.menu(&["Format", "Text Style..."])
|
||||
@@ -432,11 +426,7 @@ fn run_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()> {
|
||||
// An out-of-range index is refused here, where `color` folds it to ByLayer.
|
||||
let c = match v {
|
||||
// "RGB:r,g,b" is how `getvar` reads a true colour back, so a saved value can be restored.
|
||||
Value::String(t) => match t.trim().get(..4).filter(|p| p.eq_ignore_ascii_case("rgb:")).and_then(|_| t.trim().get(4..)) {
|
||||
Some(triple) => Color::parse(triple).filter(|c| matches!(c, Color::True(_))),
|
||||
None => Color::parse(t),
|
||||
},
|
||||
Value::String(t) => Color::parse(t),
|
||||
Value::Number(n) => n.as_i64().and_then(|i| Color::parse(&i.to_string())),
|
||||
_ => None,
|
||||
}?;
|
||||
@@ -444,20 +434,6 @@ pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()>
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// `getvar CECOLOR` as AutoCAD gives it, always text: "BYLAYER", "BYBLOCK", the index ("1" for
|
||||
/// red) or "RGB:r,g,b".
|
||||
pub(crate) fn current_color_value(d: &cadcraft_doc::Drawing) -> String {
|
||||
if let Some(rgb) = d.header.get("CECOLOR_RGB").and_then(|v| v.as_i64()).and_then(|v| u32::try_from(v).ok()) {
|
||||
let cadcraft_color::Rgb(r, g, b) = cadcraft_color::Rgb::from_u32(rgb);
|
||||
return format!("RGB:{r},{g},{b}");
|
||||
}
|
||||
match Color::from_aci(d.header.i64("CECOLOR", 256) as i16) {
|
||||
Color::ByLayer => "BYLAYER".into(),
|
||||
Color::ByBlock => "BYBLOCK".into(),
|
||||
other => other.to_aci().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A true colour keeps its RGB in CECOLOR_RGB (which wins for new objects); any other colour must
|
||||
/// clear a stale one.
|
||||
fn set_current_color(d: &mut cadcraft_doc::Drawing, c: Color) {
|
||||
@@ -558,56 +534,6 @@ impl Interactive for LtscaleM {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest THICKNESS, either way.
|
||||
const MAX_THICKNESS: f64 = 1e12;
|
||||
|
||||
fn thickness_in_range(t: f64) -> bool {
|
||||
t.is_finite() && t.abs() <= MAX_THICKNESS
|
||||
}
|
||||
|
||||
fn run_thickness(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = f64_req("thickness", p, "thickness")?;
|
||||
if !thickness_in_range(t) {
|
||||
return Err(bad("thickness", "thickness is out of range (-1e12 to 1e12)"));
|
||||
}
|
||||
s.doc_mut()?.header.set_f64("THICKNESS", t);
|
||||
Ok(json!({ "thickness": t }))
|
||||
}
|
||||
|
||||
/// Format > Thickness and typed THICKNESS: "Enter new value for THICKNESS <0.0000>:".
|
||||
struct ThicknessM;
|
||||
|
||||
impl Interactive for ThicknessM {
|
||||
fn name(&self) -> &'static str {
|
||||
"THICKNESS"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let cur = s.doc().map(|d| d.header.f64("THICKNESS", 0.0)).unwrap_or(0.0);
|
||||
Prompt::new("Enter new value for THICKNESS", Accept::NUMBER).default(format!("{cur:.4}"))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let text = match i {
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
Input::Text(t) => t,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
match super::machines::number(&text) {
|
||||
Some(t) if thickness_in_range(t) => {
|
||||
run_thickness(s, &json!({ "thickness": t }))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Some(_) => {
|
||||
s.echo("Requires a value between -1e12 and 1e12.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
None => {
|
||||
s.echo("Requires a number.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] {
|
||||
@@ -1178,9 +1104,10 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn properties_set_rejects_unloaded_linetype() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -7,8 +7,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::super::helpers::support_text;
|
||||
use super::super::qselect::wildcard;
|
||||
use super::{Result, Session, bad, bool_or, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Step};
|
||||
|
||||
/// Names no file may define: they are not linetype definitions.
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::{bad, targets};
|
||||
|
||||
use super::super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Which properties MATCHPROP copies (its Settings).
|
||||
|
||||
@@ -5,8 +5,7 @@ use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Drawing, Entity, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -321,9 +320,10 @@ fn run_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("layer.new", &json!({ "name": "Walls" })).unwrap();
|
||||
|
||||
@@ -9,8 +9,7 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::{CommandSpec, bad, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most objects one call looks at (a hostile drawing can be huge).
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, point_param, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) mod iso;
|
||||
|
||||
@@ -12,8 +12,7 @@ use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::{CommandSpec, bad, point_req, size_param, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Caps for hostile parameters.
|
||||
@@ -142,28 +141,15 @@ fn run_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
}
|
||||
|
||||
/// The table `handle`, or else the selected table. A supplied `handle` that can't be read is an
|
||||
/// error, never a silent fall back to the selection (like `targets`).
|
||||
fn table_handle(s: &Session, p: &Value, id: &str) -> Result<Handle> {
|
||||
let explicit = match p.get("handle") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_str()
|
||||
.and_then(Handle::parse_hex)
|
||||
.or_else(|| v.as_u64().map(Handle))
|
||||
.ok_or_else(|| bad(id, format!("`handle`: {v} is not an object handle (a hex string such as \"1A2\")")))?,
|
||||
),
|
||||
};
|
||||
explicit
|
||||
/// Modify the table `handle` with `f`.
|
||||
fn edit(s: &mut Session, p: &Value, id: &str, f: impl FnOnce(&mut Table) -> std::result::Result<(), String>) -> Result<Value> {
|
||||
let h = p
|
||||
.get("handle")
|
||||
.and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
|
||||
.or_else(|| {
|
||||
s.selection().into_iter().find(|h| s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(e.kind, EntityKind::Table(_))))
|
||||
})
|
||||
.ok_or_else(|| bad(id, "`handle` of a table is required"))
|
||||
}
|
||||
|
||||
/// Modify the table `handle` with `f`.
|
||||
fn edit(s: &mut Session, p: &Value, id: &str, f: impl FnOnce(&mut Table) -> std::result::Result<(), String>) -> Result<Value> {
|
||||
let h = table_handle(s, p, id)?;
|
||||
.ok_or_else(|| bad(id, "`handle` of a table is required"))?;
|
||||
let mut t = match s.doc()?.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Table(t)) => t.clone(),
|
||||
_ => return Err(bad(id, "object is not a table")),
|
||||
|
||||
@@ -6,7 +6,7 @@ use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::formula::{self, cell_name, parse_cell_name};
|
||||
use super::{MAX_COLS, MAX_ROWS, edit, table_handle, usize_param};
|
||||
use super::{MAX_COLS, MAX_ROWS, edit, usize_param};
|
||||
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
@@ -524,6 +524,15 @@ fn write_text(path: &str, text: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn table_handle(s: &Session, p: &Value, id: &str) -> Result<Handle> {
|
||||
p.get("handle")
|
||||
.and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
|
||||
.or_else(|| {
|
||||
s.selection().into_iter().find(|h| s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(e.kind, EntityKind::Table(_))))
|
||||
})
|
||||
.ok_or_else(|| bad(id, "`handle` of a table is required"))
|
||||
}
|
||||
|
||||
fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = "tableexport";
|
||||
let h = table_handle(s, p, id)?;
|
||||
|
||||
@@ -5,8 +5,7 @@ use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::{line, lwpoly, v3};
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, point_req, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Segment, Vec2};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, point_param, points_param, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -28,24 +27,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::<PickM>::default())),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
|
||||
.params("{mode: distance|radius|angle|area|volume, ...}")
|
||||
.params("{mode: distance|radius|angle|area, ...}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<MeasureM>::default())),
|
||||
CommandSpec::new("radius.measure", "Radius", run_radius)
|
||||
.menu(&["Tools", "Inquiry", "Radius"])
|
||||
.params("{handle, point?}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(RadiusM))),
|
||||
CommandSpec::new("angle.measure", "Angle", run_angle)
|
||||
.menu(&["Tools", "Inquiry", "Angle"])
|
||||
.params("{vertex, p1, p2} | {h1, h2}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<AngleM>::default())),
|
||||
CommandSpec::new("volume.measure", "Volume", super::inquiry::run_volume)
|
||||
.menu(&["Tools", "Inquiry", "Volume"])
|
||||
.params("{points: [[x,y],...] (3 or more) | handle (a closed 2D object), height} → {volume, area, height}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(super::inquiry::AreaM::volume()))),
|
||||
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
|
||||
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -297,70 +283,11 @@ fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::Circle(c)) => c.radius,
|
||||
Some(EntityKind::Arc(a)) => a.radius,
|
||||
Some(EntityKind::LwPolyline(_)) => {
|
||||
let at = point_param(p, "point").ok_or_else(|| bad("radius.measure", "`point` on the polyline's arc segment is required"))?;
|
||||
match super::annotate::segment_at(s, h, at) {
|
||||
Some(Segment::Arc { arc, .. }) => arc.radius,
|
||||
_ => return Err(bad("radius.measure", "the polyline segment at `point` is not an arc")),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad("radius.measure", "select an arc, circle or polyline arc")),
|
||||
_ => return Err(bad("radius.measure", "select an arc or circle")),
|
||||
};
|
||||
Ok(json!({ "radius": r, "diameter": 2.0 * r, "message": format!("Radius = {r:.4}\nDiameter = {:.4}", 2.0 * r) }))
|
||||
}
|
||||
|
||||
/// MEASUREGEOM's Radius option and Tools > Inquiry > Radius: the radius and diameter of a picked
|
||||
/// arc, circle or polyline arc segment. Tools > Inquiry > Radius measures a selected arc or circle
|
||||
/// at once.
|
||||
struct RadiusM;
|
||||
|
||||
impl RadiusM {
|
||||
/// Measure the arc, circle or polyline arc segment at the picked point; a pick on anything else keeps the prompt.
|
||||
fn pick(s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Input::Point(p) = i else { return Ok(Step::Done) };
|
||||
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0, "point": [p.x, p.y] })) {
|
||||
Ok(r) => {
|
||||
echo_message(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(_) => {
|
||||
s.echo("Select an arc, circle or polyline arc.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for RadiusM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MEASUREGEOM"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Select arc or circle", Accept::POINT)
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let Some(h) = s.selection().first().copied() else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0 })) {
|
||||
Ok(r) => {
|
||||
echo_message(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(_) => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
Self::pick(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo each line of a result's `message`.
|
||||
fn echo_message(s: &mut Session, r: &Value) {
|
||||
for l in r["message"].as_str().unwrap_or_default().lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (v, a, b) = if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "p1"), point_param(p, "p2")) {
|
||||
(v, a, b)
|
||||
@@ -372,29 +299,17 @@ fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(EntityKind::Line(l)) => Some((l.a.xy(), l.b.xy())),
|
||||
_ => None,
|
||||
};
|
||||
let (l1, l2) =
|
||||
let ((a1, a2), (b1, b2)) =
|
||||
(line(h1).ok_or_else(|| bad("angle.measure", "h1 is not a line"))?, line(h2).ok_or_else(|| bad("angle.measure", "h2 is not a line"))?);
|
||||
lines_vertex(l1, l2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?
|
||||
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?;
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
|
||||
(x, far(a1, a2), far(b1, b2))
|
||||
};
|
||||
let ang = vertex_angle(v, a, b);
|
||||
Ok(json!({ "angle": ang, "message": angle_text(ang) }))
|
||||
}
|
||||
|
||||
/// The point where two lines (extended) meet and the end of each line farther from it; `None` when they are parallel.
|
||||
fn lines_vertex((a1, a2): (Vec2, Vec2), (b1, b2): (Vec2, Vec2)) -> Option<(Vec2, Vec2, Vec2)> {
|
||||
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
|
||||
Some((x, far(a1, a2), far(b1, b2)))
|
||||
}
|
||||
|
||||
/// The angle at `v` between the directions to `a` and `b`, in degrees from 0 to 180.
|
||||
fn vertex_angle(v: Vec2, a: Vec2, b: Vec2) -> f64 {
|
||||
let ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
|
||||
if ang > 180.0 { 360.0 - ang } else { ang }
|
||||
}
|
||||
|
||||
fn angle_text(ang: f64) -> String {
|
||||
format!("Angle = {ang:.4}°")
|
||||
let mut ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
|
||||
if ang > 180.0 {
|
||||
ang = 360.0 - ang;
|
||||
}
|
||||
Ok(json!({ "angle": ang, "message": format!("Angle = {ang:.4}°") }))
|
||||
}
|
||||
|
||||
fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -403,12 +318,11 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"radius" | "r" => run_radius(s, p),
|
||||
"angle" | "a" => run_angle(s, p),
|
||||
"area" | "ar" => s.execute("area", p),
|
||||
"volume" | "v" => super::inquiry::run_volume(s, p),
|
||||
_ => Err(bad("measuregeom", "mode must be distance, radius, angle, area or volume")),
|
||||
_ => Err(bad("measuregeom", "mode must be distance, radius, angle or area")),
|
||||
}
|
||||
}
|
||||
|
||||
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA, a radius pick or an angle pick.
|
||||
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA or a radius pick.
|
||||
#[derive(Default)]
|
||||
struct MeasureM {
|
||||
inner: Option<Box<dyn Interactive>>,
|
||||
@@ -425,7 +339,7 @@ impl Interactive for MeasureM {
|
||||
Some(m) => m.prompt(s),
|
||||
None if self.radius => Prompt::new("Select arc or circle", Accept::POINT),
|
||||
None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true })
|
||||
.kw(&["Distance", "Radius", "Angle", "ARea", "Volume"])
|
||||
.kw(&["Distance", "Radius", "ARea"])
|
||||
.default("Distance"),
|
||||
}
|
||||
}
|
||||
@@ -434,15 +348,24 @@ impl Interactive for MeasureM {
|
||||
return m.input(s, i);
|
||||
}
|
||||
if self.radius {
|
||||
return RadiusM::pick(s, i);
|
||||
let Input::Point(p) = i else { return Ok(Step::Done) };
|
||||
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0 })) {
|
||||
Ok(r) => {
|
||||
for l in r["message"].as_str().unwrap_or_default().lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Err(_) => s.echo("Select an arc or circle."),
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Enter => self.inner = Some(Box::new(super::inquiry::DistM::default())),
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Radius" => self.radius = true,
|
||||
"Angle" => self.inner = Some(Box::<AngleM>::default()),
|
||||
"ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())),
|
||||
"Volume" => self.inner = Some(Box::new(super::inquiry::AreaM::volume())),
|
||||
_ => self.inner = Some(Box::new(super::inquiry::DistM::default())),
|
||||
},
|
||||
_ => {}
|
||||
@@ -454,103 +377,6 @@ impl Interactive for MeasureM {
|
||||
}
|
||||
}
|
||||
|
||||
/// MEASUREGEOM's Angle option and Tools > Inquiry > Angle: the angle of an arc, of two points on a
|
||||
/// circle about its center, between two lines or polyline line segments, or (Enter) at a vertex
|
||||
/// between two endpoints.
|
||||
#[derive(Default)]
|
||||
struct AngleM {
|
||||
/// The first line picked.
|
||||
first: Option<(Vec2, Vec2)>,
|
||||
/// The vertex and endpoints given so far (Enter, or a circle's center and the point picked on it).
|
||||
vertex: Option<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
impl AngleM {
|
||||
fn finish(s: &mut Session, ang: f64) -> Result<Step> {
|
||||
s.echo(angle_text(ang));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AngleM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MEASUREGEOM"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(pts) = &self.vertex {
|
||||
let msg = ["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"].get(pts.len()).copied().unwrap_or("");
|
||||
return Prompt::new(msg, Accept::POINT).base_opt(pts.first().copied());
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"),
|
||||
Some(_) => Prompt::new("Select second line", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(pts) = &mut self.vertex {
|
||||
return match i {
|
||||
Input::Point(p) => {
|
||||
pts.push(p);
|
||||
match pts.as_slice() {
|
||||
[v, a, b] => Self::finish(s, vertex_angle(*v, *a, *b)),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
let p = match i {
|
||||
Input::Enter if self.first.is_none() => {
|
||||
self.vertex = Some(Vec::new());
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Input::Point(p) => p,
|
||||
Input::Enter => return Ok(Step::Cancel),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let Some(h) = super::curves::pick_at(s, p) else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let circle = s
|
||||
.doc()?
|
||||
.entity(h)
|
||||
.and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None });
|
||||
if let Some(c) = circle.filter(|_| self.first.is_none()) {
|
||||
self.vertex = Some(vec![c.center, c.closest(p)]);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match super::annotate::segment_at(s, h, p) {
|
||||
Some(Segment::Arc { arc, .. }) if self.first.is_none() => Self::finish(s, arc.sweep().to_degrees()),
|
||||
Some(Segment::Line(l)) => match self.first {
|
||||
None => {
|
||||
self.first = Some((l.a, l.b));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some(first) => match lines_vertex(first, (l.a, l.b)) {
|
||||
Some((v, a, b)) => Self::finish(s, vertex_angle(v, a, b)),
|
||||
None => {
|
||||
s.echo("The lines are parallel.");
|
||||
Ok(Step::Done)
|
||||
}
|
||||
},
|
||||
},
|
||||
_ => {
|
||||
s.echo(if self.first.is_none() { "Select an arc, circle or line." } else { "Select a line." });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match self.vertex.as_deref() {
|
||||
Some([v]) => vec![super::helpers::line(*v, c)],
|
||||
Some([v, a]) => vec![super::helpers::line(*v, *a), super::helpers::line(*v, c)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -6,8 +6,7 @@ use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelectRun, number};
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, ok, point_param, point_req, str_param, targets};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
|
||||
|
||||
mod named_views;
|
||||
|
||||
@@ -280,7 +280,7 @@ impl Interactive for ViewM {
|
||||
Stage::Restore => Prompt::new("Enter view name to restore", Accept::TEXT),
|
||||
Stage::Save | Stage::WindowName => Prompt::new("Enter view name to save", Accept::TEXT),
|
||||
Stage::WindowFirst => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
Stage::Settings => Prompt::new("Enter an option", Accept::TEXT).kw(&["Layer snapshot"]),
|
||||
Stage::Snapshot => {
|
||||
let on = if s.settings.view_layer_snapshot { "Yes" } else { "No" };
|
||||
|
||||
@@ -647,9 +647,6 @@ impl Default for MLeaderOptions {
|
||||
pub struct Session {
|
||||
pub docs: Vec<DocState>,
|
||||
pub active: usize,
|
||||
/// Monotonic count of changes to the open drawings and the active one. A screenshot queued
|
||||
/// across a switch is rejected even when a later switch returns to the same drawing.
|
||||
pub doc_epoch: u64,
|
||||
pub settings: Settings,
|
||||
pub running: Option<Running>,
|
||||
/// A script is running ([`Session::script`]); a SCRIPT command inside it is refused.
|
||||
@@ -730,7 +727,6 @@ impl Session {
|
||||
Session {
|
||||
docs: Vec::new(),
|
||||
active: 0,
|
||||
doc_epoch: 0,
|
||||
settings: Settings::default(),
|
||||
running: None,
|
||||
in_script: false,
|
||||
@@ -763,7 +759,6 @@ impl Session {
|
||||
hatch_picks: None,
|
||||
}
|
||||
}
|
||||
/// Append a new untitled drawing (`DrawingN`, metric or imperial default view), end a running command, make it active, bump the document epoch and return its index.
|
||||
pub fn new_drawing(&mut self, metric: bool) -> usize {
|
||||
self.untitled_counter += 1;
|
||||
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
|
||||
@@ -772,10 +767,8 @@ impl Session {
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
self.bump_doc_epoch();
|
||||
self.active
|
||||
}
|
||||
/// Append a loaded drawing, fit the view to its model extents (aspect-corrected, minimum height 1e-6) when non-empty, end a running command, make it active, bump the epoch and return its index.
|
||||
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
|
||||
let mut st = DocState::new(d, title, path);
|
||||
let ext = st.doc.extents(&Space::Model);
|
||||
@@ -788,14 +781,8 @@ impl Session {
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
self.bump_doc_epoch();
|
||||
self.active
|
||||
}
|
||||
/// Record that which drawing is open or active changed (screenshot binding).
|
||||
pub fn bump_doc_epoch(&mut self) {
|
||||
self.doc_epoch = self.doc_epoch.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// A command still waiting for input belongs to the drawing it started in: end it there
|
||||
/// before another drawing becomes current, so its undo step lands in that drawing.
|
||||
fn end_running(&mut self) {
|
||||
@@ -813,7 +800,6 @@ impl Session {
|
||||
Err(_) => {
|
||||
self.docs.truncate(n);
|
||||
self.active = self.active.min(self.docs.len().saturating_sub(1));
|
||||
self.bump_doc_epoch();
|
||||
Err(EngineError::Internal("open".into(), "panic".into()))
|
||||
}
|
||||
}
|
||||
@@ -899,19 +885,6 @@ impl Session {
|
||||
if d.annotative_by_style(&kind) {
|
||||
common.anno_scales = vec![d.current_anno_scale()];
|
||||
}
|
||||
// Lines, arcs, circles, points, polylines and 2D solids take the current 3D thickness
|
||||
// (THICKNESS). RECTANG sets its own, and text never takes it.
|
||||
if matches!(
|
||||
kind,
|
||||
EntityKind::Line(_)
|
||||
| EntityKind::Arc(_)
|
||||
| EntityKind::Circle(_)
|
||||
| EntityKind::Point(_)
|
||||
| EntityKind::LwPolyline(_)
|
||||
| EntityKind::Solid(_)
|
||||
) {
|
||||
common.thickness = Some(d.header.f64("THICKNESS", 0.0)).filter(|t| t.is_finite()).unwrap_or(0.0);
|
||||
}
|
||||
let h = self.doc_mut()?.add(&space, common, kind)?;
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
@@ -785,48 +785,6 @@ 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],
|
||||
@@ -839,8 +797,8 @@ fn glob_inner(
|
||||
return Err(());
|
||||
}
|
||||
*budget -= 1;
|
||||
if depth > 256 {
|
||||
return Err(());
|
||||
if depth > 64 {
|
||||
return Ok(false);
|
||||
}
|
||||
let (mut t, mut p) = (0usize, 0usize);
|
||||
while p < pat.len() {
|
||||
@@ -864,7 +822,42 @@ fn glob_inner(
|
||||
p += 2;
|
||||
}
|
||||
'[' => {
|
||||
let Some(close) = class_end(text, pat, p, t, budget)? else { return Ok(false) };
|
||||
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);
|
||||
}
|
||||
t += 1;
|
||||
p = close + 1;
|
||||
}
|
||||
|
||||
@@ -136,10 +136,6 @@ fn lists_and_strings() {
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"x*,a?c\")"), "T");
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"~a*\")"), "nil");
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"b\" \"[a-c]\")"), "T");
|
||||
let many = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(80), "*a".repeat(80));
|
||||
assert_eq!(ok(&mut s, &many), "T");
|
||||
let too_deep = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(400), "*a".repeat(400));
|
||||
assert!(eval(&mut s, &too_deep).is_err());
|
||||
assert_eq!(ok(&mut s, "(vl-string-subst \"X\" \"b\" \"abcb\")"), "\"aXcb\"");
|
||||
assert_eq!(ok(&mut s, "(vl-string-search \"c\" \"abc\")"), "2");
|
||||
assert_eq!(ok(&mut s, "(read \"(1 2)\")"), "(1 2)");
|
||||
@@ -459,15 +455,6 @@ fn getvar_drawing_and_session_variables() {
|
||||
assert_eq!(ok(&mut s, "(getvar \"DWGTITLED\")"), "1");
|
||||
}
|
||||
|
||||
/// CECOLOR is a string in AutoCAD, whatever it was set from (#1999).
|
||||
#[test]
|
||||
fn getvar_cecolor_is_a_string() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "(list (getvar \"CECOLOR\") (type (getvar \"CECOLOR\")))"), "(\"BYLAYER\" STR)");
|
||||
assert_eq!(ok(&mut s, "(setvar \"CECOLOR\" \"red\") (getvar \"CECOLOR\")"), "\"1\"");
|
||||
assert_eq!(ok(&mut s, "(setvar \"CECOLOR\" \"10,20,30\") (getvar \"CECOLOR\")"), "\"RGB:10,20,30\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_functions_take_supplied_answers() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -41,8 +41,8 @@ pub struct Prompt {
|
||||
/// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred
|
||||
/// pick arrives as the plain point it was picked at.
|
||||
pub deferred: bool,
|
||||
/// The base is a rectangle's first corner (ZOOM Window, PLOTWINDOW, RECTANG, ARRAYRECT's unit
|
||||
/// cell): ortho does not bend the opposite corner, so the rectangle keeps its area.
|
||||
/// The base is a rectangle's first corner (ZOOM Window, RECTANG, ARRAYRECT's unit cell): ortho
|
||||
/// does not bend the opposite corner, so the rectangle keeps its area.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub corner: bool,
|
||||
/// The grip this prompt stretches (one hot grip at the STRETCH prompt), as (object, grip
|
||||
|
||||
@@ -434,7 +434,7 @@ impl SelectMode {
|
||||
};
|
||||
match last {
|
||||
None => Prompt::new(first, Accept::POINT),
|
||||
Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p),
|
||||
Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).base(p),
|
||||
Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +146,6 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
// Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG.
|
||||
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
|
||||
"CONSTRAINTINFER" => json!(i32::from(s.doc().ok()?.parametric.settings.infer)),
|
||||
// Text, as AutoCAD reads it back; the header keeps the index (and CECOLOR_RGB) for DXF.
|
||||
"CECOLOR" => json!(crate::cmd::current_color_value(s.doc().ok()?)),
|
||||
// Process-wide font substitution (profile settings, not saved in the drawing).
|
||||
"FONTALT" => json!(cadcraft_fonts::ttf::font_alt()),
|
||||
"FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()),
|
||||
@@ -381,13 +379,6 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
return r;
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
// A dimension variable takes only the values its style field takes, as in DIMSTYLE and DIMOVERRIDE.
|
||||
if let Some((_, field)) = cadcraft_doc::DIMVARS.iter().find(|(var, _)| *var == n) {
|
||||
let mut st = d.dim_style(&d.header.str("DIMSTYLE", "Standard")).cloned().unwrap_or_default();
|
||||
if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
let val = match v {
|
||||
Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)),
|
||||
Value::Number(x) => cadcraft_doc::HVal::Real(x.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?),
|
||||
@@ -449,7 +440,7 @@ pub fn list(s: &Session) -> Value {
|
||||
}
|
||||
if let Ok(d) = s.doc() {
|
||||
for (k, v) in &d.header.vars {
|
||||
if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT" | "CECOLOR") {
|
||||
if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT") {
|
||||
m.insert(k.clone(), get(s, k).unwrap_or(Value::Null));
|
||||
} else if !k.starts_with("CADCRAFT_") {
|
||||
m.insert(k.clone(), serde_json::to_value(v).unwrap_or(Value::Null));
|
||||
|
||||
@@ -1309,64 +1309,6 @@ fn typed_ltscale_asks_for_the_scale() {
|
||||
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_thickness_asks_for_the_thickness() {
|
||||
let mut s = Session::new();
|
||||
s.start("thickness").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter new value for THICKNESS <0.0000>"), "{}", s.prompt_text());
|
||||
// After text that is not a number, the prompt asks again.
|
||||
s.cmdline("thick").unwrap();
|
||||
assert!(s.current_prompt().is_some(), "asked again");
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Requires a number."));
|
||||
// A value out of range says so, and the prompt asks again.
|
||||
s.cmdline("2e12").unwrap();
|
||||
assert!(s.current_prompt().is_some(), "asked again");
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Requires a value between -1e12 and 1e12."));
|
||||
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), 0.0);
|
||||
// A negative thickness is accepted.
|
||||
s.cmdline("-1.5").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5);
|
||||
// Typed, Enter keeps the current thickness.
|
||||
s.cmdline("thickness").unwrap();
|
||||
assert!(s.prompt_text().contains("<-1.5000>"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5);
|
||||
assert!(s.execute("thickness", &json!({"thickness": "thick"})).is_err());
|
||||
assert!(s.execute("thickness", &json!({"thickness": 1e13})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_objects_take_the_current_thickness() {
|
||||
let mut s = Session::new();
|
||||
s.execute("thickness", &json!({"thickness": 2.5})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
|
||||
s.execute("point", &json!({"at": [1, 1]})).unwrap();
|
||||
s.execute("pline", &json!({"vertices": [[0, 0], [5, 0], [5, 5]]})).unwrap();
|
||||
s.execute("polygon", &json!({"sides": 6, "center": [20, 20], "radius": 2})).unwrap();
|
||||
s.execute("donut", &json!({"center": [30, 30], "inside": 1, "outside": 2})).unwrap();
|
||||
let thick: Vec<(&str, f64)> = s.doc().unwrap().model.iter().map(|e| (e.kind.type_name(), e.common.thickness)).collect();
|
||||
assert_eq!(thick.len(), 7, "{thick:?}");
|
||||
assert!(thick.iter().all(|(_, t)| *t == 2.5), "{thick:?}");
|
||||
// RECTANG keeps its own thickness option, and text does not take THICKNESS.
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4], "thickness": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 1.0);
|
||||
let before = s.doc().unwrap().model.len();
|
||||
s.cmdline("rectang 0,0 4,4").unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), before + 1);
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
s.execute("text", &json!({"at": [0, 0], "text": "A"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
// An ellipse has no thickness.
|
||||
s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
}
|
||||
|
||||
fn setvar_prompt_open(s: &Session) -> bool {
|
||||
s.running.as_ref().is_some_and(|r| r.id == "setvar")
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
|
||||
let rounded = (v.abs() * scale).round() / scale;
|
||||
let a = if rounded.is_finite() { rounded } else { v.abs() };
|
||||
let ft = (a / 12.0).floor();
|
||||
if ft >= I64_SAFE_MAX {
|
||||
return format!("{:.*}", prec, v);
|
||||
}
|
||||
let inch = a - ft * 12.0;
|
||||
format!("{}{}'-{:.*}\"", if neg { "-" } else { "" }, ft as i64, prec, inch)
|
||||
}
|
||||
@@ -139,13 +136,9 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` (in `[0, full)`), or 0 when it rounds at `prec` decimals to the printed full-turn value (only when that value is exact).
|
||||
/// `v` (in `[0, full)`), or 0 when it rounds at `prec` decimals to the same value as a full turn.
|
||||
fn wrap_full_turn(v: f64, full: f64, prec: usize) -> f64 {
|
||||
let full_text = format!("{full:.prec$}");
|
||||
// A full turn that cannot be printed exactly (2π) only wraps when `v` is numerically that value.
|
||||
let exact = full_text.parse::<f64>().is_ok_and(|f| f == full);
|
||||
let reached = exact || full - v < 1e-6;
|
||||
if reached && format!("{v:.prec$}") == full_text { 0.0 } else { v }
|
||||
if format!("{v:.prec$}") == format!("{full:.prec$}") { 0.0 } else { v }
|
||||
}
|
||||
|
||||
/// Parse a distance typed by the user: decimal, `1'6"`, `1'-6 1/2"`, `6 1/2`, `3/4`, `1e3`.
|
||||
@@ -327,12 +320,6 @@ fn bearing(t: &str) -> Option<f64> {
|
||||
Some(deg.to_radians())
|
||||
}
|
||||
|
||||
/// Minutes or seconds of a D'" angle: a number in `0..=60`.
|
||||
fn dms_part(t: &str) -> Option<f64> {
|
||||
let v: f64 = t.trim().parse().ok()?;
|
||||
(0.0..=60.0).contains(&v).then_some(v)
|
||||
}
|
||||
|
||||
/// Degrees, `45d`, `45d30'` or `45d30'15"` (a leading `-` negates the whole angle).
|
||||
fn parse_dms(t: &str) -> Option<f64> {
|
||||
let (neg, t) = match t.trim().strip_prefix('-') {
|
||||
@@ -345,11 +332,11 @@ fn parse_dms(t: &str) -> Option<f64> {
|
||||
if !rest.is_empty() {
|
||||
let (m, rest2) = rest.split_once('\'').unwrap_or((rest, ""));
|
||||
if !m.trim().is_empty() {
|
||||
deg += dms_part(m)? / 60.0;
|
||||
deg += m.trim().parse::<f64>().ok()? / 60.0;
|
||||
}
|
||||
let s = rest2.trim().trim_end_matches('"');
|
||||
if !s.is_empty() {
|
||||
deg += dms_part(s)? / 3600.0;
|
||||
deg += s.parse::<f64>().ok()? / 3600.0;
|
||||
}
|
||||
}
|
||||
let deg = if neg { -deg } else { deg };
|
||||
@@ -369,20 +356,6 @@ pub const DEG: f64 = PI / 180.0;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dms_minutes_and_seconds_must_be_within_0_to_60() {
|
||||
for t in ["45d-30'", "45d30'-15\"", "45d75'", "45d30'75\"", "n45d-30'e"] {
|
||||
assert_eq!(parse_angle(t), None, "{t}");
|
||||
}
|
||||
assert!(parse_angle("45d30'15\"").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engineering_huge_distance_falls_back_to_decimal() {
|
||||
assert_eq!(format_distance(1e30, 3, 2), format!("{:.2}", 1e30));
|
||||
assert_eq!(format_distance(-1e300, 3, 8), format!("{:.8}", -1e300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_distance_needs_a_positive_fraction_after_the_whole_number() {
|
||||
assert_eq!(parse_distance("6 1/2"), Some(6.5));
|
||||
@@ -534,11 +507,4 @@ mod tests {
|
||||
assert_eq!(format_angle(angle, 4, 0), "S 74d E");
|
||||
assert_eq!(format_angle(angle, 4, 6), "S 73d41'51.00\" E");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angle_radians_low_precision_not_wrapped() {
|
||||
assert_eq!(format_angle(6.2, 3, 0), "6r");
|
||||
assert_eq!(format_angle(6.26, 3, 1), "6.3r");
|
||||
assert_eq!(format_angle(0.0, 3, 1), "0.0r");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,41 +322,3 @@ fn hooks() {
|
||||
plot: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_editor_that_cannot_be_composed_is_not_autosaved_as_the_old_host() {
|
||||
use cadcraft_engine::doc::{Entity, EntityKind, Insert};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[10, 10], [12, 10]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "Door", "base": [10, 10], "handles": [l] })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
// An unsaved change in the editor: the block would contain a reference to itself.
|
||||
let d = s.doc_mut().unwrap();
|
||||
let h = d.new_handle();
|
||||
let kind = EntityKind::Insert(Insert {
|
||||
block: "Door".into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: vec![],
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
});
|
||||
d.model.push(Entity::new(h, kind));
|
||||
assert!(s.state().unwrap().recovery_drawing().is_err());
|
||||
|
||||
let mut a = Autosaver::new("55-b");
|
||||
a.plan(&s, 0.0, 1, "/auto");
|
||||
assert!(a.plan(&s, 60.0, 1, "/auto").is_empty(), "no recovery file without the editor's changes");
|
||||
assert!(a.plan(&s, 120.0, 1, "/auto").is_empty());
|
||||
assert_eq!(a.next_due(120.0, 1), Some(0.0), "still waiting to be written");
|
||||
|
||||
// Once the reference is gone, the next autosave writes the editor's changes.
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.model.remove(h);
|
||||
assert_eq!(writes(&a.plan(&s, 180.0, 1, "/auto")).len(), 1);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user